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/battle/view/AreaUnlockGuidePresenter.ts

464 lines
16 KiB

19 hours ago
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';
19 hours ago
const { ccclass } = _decorator;
/** 引导虚线框预制体路径 */
const FRAME_PATH = 'prefab/障碍物/';
/** 引导虚线框预制体名 */
const FRAME_NAME = '引导虚线框';
/** 虚线框 Spine 动画名 */
const FRAME_ANIM = 'animation';
/** 连线层节点名 */
const LINE_LAYER = 'ui_areaUnlockGuideLine';
/** 黑白交替每段长度(对齐效果图) */
const DASH_LEN = 12;
1 week ago
/** 斑马段半宽 */
const DASH_HALF_W = 2.5;
1 week ago
/** 描边相对半宽加宽 */
const DASH_OUTLINE_EXTRA = 1.5;
19 hours ago
/** 流动速度:像素/秒(背包→地图) */
const FLOW_SPEED = 48;
/** 交替色:黑 */
const DASH_BLACK = new Color(15, 15, 15, 255);
/** 交替色:白 */
const DASH_WHITE = new Color(255, 255, 255, 255);
19 hours ago
/**
* 连线层每帧推进斑马相位。
*/
@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;
1 week ago
/** 取背包槽位节点(虚线框挂这里,禁止挂 TextGrid,避免合成叠字) */
slotNodeAt: (slotIndex: number) => Node | null;
/** 槽位是否可拖(开启且非待开启) */
canDragSlot: (slotIndex: number) => boolean;
1 week ago
/** 为 true 时只清不显(拖动中 / 入场中 / 落子飞行中) */
skip?: boolean;
}
/**
* 背包可解锁当前地图区域时:随机标一个背包字 + 连到格子锁气泡。
1 week ago
* 虚线框挂在槽位节点上,不进入 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;
19 hours ago
/** 连线本地起点 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();
19 hours ago
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 {
1 week ago
const prevHost = this._frame?.parent;
this._slotIndex = null;
19 hours ago
this._stopFlow();
this._releaseFrame();
1 week ago
if (prevHost?.isValid) this._purgeOrphanFrames(prevHost);
if (this._graphics?.isValid) this._graphics.clear();
if (this._lineLayer?.isValid) this._lineLayer.active = false;
}
19 hours ago
/**
* 连线层帧 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);
1 week ago
const slotNode = opts.slotNodeAt(slot);
if (!grid?.node?.isValid || !slotNode?.isValid) {
this.clear();
return;
}
1 week ago
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;
}
/**
1 week ago
* 虚线框挂到背包槽位(不是 TextGrid),与字块同中心,不参与合成节点树。
* @param slotNode 背包槽
* @param glyphNode 字块(仅用于对齐位置)
*/
1 week ago
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;
1 week ago
frame.layer = slotNode.layer;
if (frame.parent !== slotNode) {
frame.parent = slotNode;
}
1 week ago
// 与槽内字块同局部坐标,避免挂 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) {
1 week ago
sk.node.layer = slotNode.layer;
BattleSpinePlayback.play(sk, FRAME_ANIM, { loop: true, optional: true });
}
return true;
}
/**
1 week ago
* 关闭节点树命中,防止 Spine/UITransform 截获拖拽。
* @param root 虚线框或连线层
*/
1 week ago
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);
1 week ago
this._disableNodeHit(layer);
}
this._lineLayer = layer;
this._graphics = layer.getComponent(Graphics);
19 hours ago
let tick = layer.getComponent(AreaUnlockGuideFlowTick);
if (!tick) tick = layer.addComponent(AreaUnlockGuideFlowTick);
tick.host = this;
1 week ago
this._disableNodeHit(layer);
layer.active = false;
}
/**
19 hours ago
* 背包字中心 → 地图待解锁格中心(气泡已挂在格心)。
* @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;
19 hours ago
_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);
19 hours ago
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);
19 hours ago
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;
}
/**
19 hours ago
* 黑白段交替实心绳:相位沿背包→地图方向滑动。
* @param g Graphics
* @param x1 起点 x
* @param y1 起点 y
* @param x2 终点 x
* @param y2 终点 y
19 hours ago
* @param phase 流动相位(像素)
*/
19 hours ago
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;
19 hours ago
const period = DASH_LEN * 2;
const offset = ((phase % period) + period) % period;
let d = -offset;
let useBlack = true;
while (d < len) {
19 hours ago
const d0 = Math.max(d, 0);
const d1 = Math.min(d + DASH_LEN, len);
19 hours ago
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;
}
}
/**
1 week ago
* 沿路径画一段实心矩形。
* @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();
}
}