//********************* // create by 流云 // time: Sat Aug 15 2026 // desc: 开局匹配对手 //********************* import { _decorator, Label, Node, sp, Tween, tween, UIOpacity, Vec3, view } from 'cc'; import { Auto_match } from './Auto_match'; import { RankDataManager } from '../../manager/RankDataManager'; import { MatchWinStats } from '../../manager/MatchWinStats'; import { SpriteLoad } from '../../../mx/components/SpriteLoad'; import { GAudioPath, UIID } from '../../game/ConfigRes'; import { TableNames } from '../../game/ConfigTableData'; import { HeadIconManager } from '../../manager/HeadIconManager'; import { BattleSpinePlayback } from '../../battle/presentation/BattleSpinePlayback'; const { ccclass } = _decorator; /** 匹配最短展示下限(秒) */ const MATCH_WAIT_MIN_SEC = 2; /** 匹配最短展示上限(秒) */ const MATCH_WAIT_MAX_SEC = 3; const RED_HEAD_WAIT_SEC = 0.8; const RED_HEAD_ROLL_INTERVAL = 0.35; const PANEL_MOVE_SEC = 0.5; const MATCH_SPINE_MATCHING = '匹配中'; const MATCH_SPINE_STAND = '站'; /** 「站」里「战」淡到全透明的时刻(秒),与 匹配中.json 槽位「战」color 关键帧一致;此后是空尾巴,拉开不再等它 */ const MATCH_STAND_VANISH_SEC = 1; /** 段位/头像绑定超时后仍进入完成态 */ const MATCH_BIND_FALLBACK_SEC = 8; /** 匹配全流程超时:关页并回大厅 */ const MATCH_STUCK_SEC = 90; const MATCH_SPINE_RED = '红方'; const MATCH_SPINE_BLUE = '蓝方'; const MATCH_SPINE_RED2 = '红方2'; const MATCH_SPINE_BLUE2 = '蓝方2'; const MATCH_SPINE_SEARCH = 'animation'; const EMPTY_RANK_NAME = '无段位'; /** 匹配门闩:进战斗结果回传匹配页 */ export type MatchGateHandlers = { /** 局内页已打开,可拉开匹配页并关闭 */ notifyBattleEntered: () => void; /** 进战斗失败 */ notifyEnterFailed: () => void; }; /** 打开匹配页参数 */ export type MatchParam = { /** 预载与合拢完成:开始进战斗(匹配页仍保持显示,最短展示到点且局内页打开后再切完成态) */ onGateReady?: (handlers: MatchGateHandlers) => void; }; /** 匹配页展示的对手信息 */ type AiMatchDisplay = { /** 段位展示文案(含星) */ rankName: string; /** 胜率 0~100 */ winRate: number; /** 对手段位 id */ opponentRankId: number; /** 对手总星 */ opponentStars: number; }; /** 开局匹配对手页:打开即匹配中;对手就绪且战斗页打开后进入匹配完成,播完动画关页 */ @ccclass('UImatch') export class UImatch extends Auto_match { /** 进战斗门闩回调 */ private _finishCb: ((handlers: MatchGateHandlers) => void) | null = null; /** 最短展示时间已到 */ private _minWaitDone = false; /** 战斗资源预载完成 */ private _loadDone = false; /** 段位/头像绑定完成 */ private _bindDone = false; /** 已进入匹配完成(播「站」及后续退出动画) */ private _completeEntered = false; /** 「站」播完 */ private _standDone = false; /** 入场合拢完成 */ private _enterMoveDone = false; /** 局内页已打开 */ private _battleReady = false; /** 防止重复触发进战斗 */ private _gateTriggered = false; /** 正在退出关闭 */ private _finishing = false; /** 本局对手展示 */ private _aiInfo: AiMatchDisplay | null = null; /** 头像滚动世代,取消过期动画 */ private _rollGen = 0; /** 位移动画剩余节点数 */ private _panelMoveAnimLeft = 0; /** 当前位移阶段:合拢 / 拉开 */ private _panelMoveKind: 'in' | 'out' | null = null; /** ui_match Spine */ private _matchSk: sp.Skeleton | null = null; /** ui_spine 放大镜 */ private _searchSk: sp.Skeleton | null = null; /** ui_red / ui_bule 侧 Spine */ private _redSk: sp.Skeleton | null = null; private _blueSk: sp.Skeleton | null = null; /** 上下栏/design 位 */ private _topRest = new Vec3(); private _bottomRest = new Vec3(); private _redRest = new Vec3(); private _buleRest = new Vec3(); private _redBoxRest = new Vec3(); private _blueBoxRest = new Vec3(); /** 是否已缓存 design 位 */ private _restPosCaptured = false; onLoad(): void { super.onLoad(); this._matchSk = this.ui_match?.getComponent(sp.Skeleton) ?? null; this._searchSk = this.ui_spine?.getComponent(sp.Skeleton) ?? null; this._redSk = this.ui_red?.getComponent(sp.Skeleton) ?? null; this._blueSk = this.ui_bule?.getComponent(sp.Skeleton) ?? null; this._captureRestPositions(); this._hideMatchingCenter(); this._placePanelsOffscreen(); } onDisable(): void { this.unschedule(this._onMinWait); this.unschedule(this._onStandComplete); this.unschedule(this._onBindFallback); this.unschedule(this._onMatchStuck); this.unschedule(this._onPanelMoveWatchdog); this._rollGen++; this._stopPanelTweens(); this._clearMatchSpines(); super.onDisable(); } /** 停止匹配页 Spine 并清除回调 */ private _clearMatchSpines(): void { const list = [this._matchSk, this._searchSk, this._redSk, this._blueSk]; for (let i = 0; i < list.length; i++) { const sk = list[i]; if (!sk?.isValid) continue; BattleSpinePlayback.clearPendingComplete(sk); BattleSpinePlayback.resetTracks(sk); } } onInit(): void { const p = (this.prema ?? {}) as MatchParam; this._finishCb = typeof p.onGateReady === 'function' ? p.onGateReady : null; this._minWaitDone = false; this._loadDone = false; this._bindDone = false; this._completeEntered = false; this._standDone = false; this._enterMoveDone = false; this._battleReady = false; this._gateTriggered = false; this._finishing = false; this._aiInfo = null; this._rollGen++; this.unschedule(this._onStandComplete); this._resetMatchUi(); this._setWinRate(this.ui_blueBox, MatchWinStats.getPlayerWinRate()); this._playPanelEnterMove(); void this._bindMatchInfo(); void this._runRedHeadRoll(); void this._preloadBattleRes(); this.scheduleOnce(this._onMinWait, this._rollMatchWaitSec()); this.scheduleOnce(this._onBindFallback, MATCH_BIND_FALLBACK_SEC); this.scheduleOnce(this._onMatchStuck, MATCH_STUCK_SEC); } /** 缓存 ui_top/ui_bottom 等 design 坐标 */ private _captureRestPositions(): void { if (this._restPosCaptured) return; if (this.ui_top?.isValid) this._topRest.set(this.ui_top.position); if (this.ui_bottom?.isValid) this._bottomRest.set(this.ui_bottom.position); if (this.ui_red?.isValid) this._redRest.set(this.ui_red.position); if (this.ui_bule?.isValid) this._buleRest.set(this.ui_bule.position); if (this.ui_redBox?.isValid) this._redBoxRest.set(this.ui_redBox.position); if (this.ui_blueBox?.isValid) this._blueBoxRest.set(this.ui_blueBox.position); this._restPosCaptured = true; } /** 复位匹配态:上下栏置于屏外待合拢;中间「匹配中」与放大镜待合拢后再显示 */ private _resetMatchUi(): void { this._captureRestPositions(); this._hideMatchingCenter(); this._placePanelsOffscreen(); this._resetOpacity(this.ui_center); this._resetOpacity(this.ui_bottom); this._resetOpacity(this.ui_top); this._resetOpacity(this.ui_bule); this._resetOpacity(this.ui_red); this._resetOpacity(this.ui_redBox); this._resetOpacity(this.ui_blueBox); this._resetOpacity(this.ui_spine); this._resetRedHeadUi(); this._playSideSpineLoops(); } /** 隐藏中间「匹配中」、放大镜与合拢光效 */ private _hideMatchingCenter(): void { if (this.ui_match?.isValid) this.ui_match.active = false; if (this.ui_spine?.isValid) this.ui_spine.active = false; if (this.ui_matchLight?.isValid) this.ui_matchLight.active = false; } /** 上下栏移到屏幕外(上往上、下往下) */ private _placePanelsOffscreen(): void { const dy = this._offscreenDy(); this._setPanelOffset(this.ui_top, this._topRest, dy); this._setPanelOffset(this.ui_red, this._redRest, dy); this._setPanelOffset(this.ui_redBox, this._redBoxRest, dy); this._setPanelOffset(this.ui_bottom, this._bottomRest, -dy); this._setPanelOffset(this.ui_bule, this._buleRest, -dy); this._setPanelOffset(this.ui_blueBox, this._blueBoxRest, -dy); } /** * 按 rest + dy 立刻设位置 * @param node 目标 * @param rest design 坐标 * @param dy 纵向偏移 */ private _setPanelOffset(node: Node | null, rest: Vec3, dy: number): void { if (!node?.isValid) return; node.setPosition(rest.x, rest.y + dy, rest.z); } /** 屏外位移量(可见高度) */ private _offscreenDy(): number { return view.getVisibleSize().height; } /** 停止上下栏 tween */ private _stopPanelTweens(): void { this._panelMoveKind = null; this._panelMoveAnimLeft = 0; this.unschedule(this._onPanelMoveWatchdog); const nodes = [this.ui_top, this.ui_bottom, this.ui_red, this.ui_bule, this.ui_redBox, this.ui_blueBox]; for (let i = 0; i < nodes.length; i++) { const n = nodes[i]; if (n?.isValid) Tween.stopAllByTarget(n); } } /** 循环播放 ui_spine 放大镜 */ private _playSearchSpineLoop(): void { const sk = this._searchSk; if (!sk?.isValid) return; BattleSpinePlayback.play(sk, MATCH_SPINE_SEARCH, { loop: true, optional: true }); } /** 循环播放 ui_red/ui_bule 侧 Spine(匹配中) */ private _playSideSpineLoops(): void { if (this._redSk?.isValid) { BattleSpinePlayback.play(this._redSk, MATCH_SPINE_RED, { loop: true, optional: true }); } if (this._blueSk?.isValid) { BattleSpinePlayback.play(this._blueSk, MATCH_SPINE_BLUE, { loop: true, optional: true }); } } /** 匹配完成:红方2 / 蓝方2 */ private _playSideCompleteAnims(): void { if (this._redSk?.isValid) { BattleSpinePlayback.play(this._redSk, MATCH_SPINE_RED2, { loop: true }); } if (this._blueSk?.isValid) { BattleSpinePlayback.play(this._blueSk, MATCH_SPINE_BLUE2, { loop: true }); } } /** 循环播放 ui_match「匹配中」 */ private _playMatchingLoop(): void { const sk = this._matchSk; if (!sk?.isValid) { console.warn('[UImatch] ui_match 缺少 sp.Skeleton'); return; } BattleSpinePlayback.play(sk, MATCH_SPINE_MATCHING, { loop: true }); } /** * 将节点不透明度置为 255 * @param node 目标节点 */ private _resetOpacity(node: Node | null | undefined): void { if (!node?.isValid) return; const uo = node.getComponent(UIOpacity) ?? node.addComponent(UIOpacity); uo.opacity = 255; } /** 本局匹配最短展示秒数,落在 [2, 3] */ private _rollMatchWaitSec(): number { return MATCH_WAIT_MIN_SEC + Math.random() * (MATCH_WAIT_MAX_SEC - MATCH_WAIT_MIN_SEC); } private _onMinWait = (): void => { this._minWaitDone = true; this._tryEnterComplete(); }; /** 绑定超时:不再空等表,避免一直停在「匹配中」 */ private _onBindFallback = (): void => { if (this._bindDone || this._finishing || !this.isValid) return; console.error('[UImatch] 绑定超时,继续匹配完成'); this._bindDone = true; this._tryEnterComplete(); }; /** 全流程超时:未进战则回大厅;已进战则强制关匹配页 */ private _onMatchStuck = (): void => { if (this._finishing || this._completeEntered || !this.isValid) return; if (this._battleReady) { console.error('[UImatch] 匹配完成态超时,强制关页'); this._bindDone = true; this._minWaitDone = true; this._tryEnterComplete(); return; } if (this._gateTriggered) return; console.error('[UImatch] 匹配流程超时', 'load=', this._loadDone, 'enterMove=', this._enterMoveDone); this._onEnterFailed(); gg.game.recoverHome('match_stuck'); }; /** 预载本局战斗资源 */ private async _preloadBattleRes(): Promise { try { await gg.game.preloadBattleForMatch(); } catch (err) { console.error('[UImatch] preload battle res failed', String(err)); } this._loadDone = true; this._checkGateReady(); } /** * 匹配到对手且战斗页已打开 → 进入匹配完成:藏放大镜、揭示对手、播「站」;「战」一消失立刻拉开关页。 */ private _tryEnterComplete(): void { if (this._completeEntered || this._finishing) return; if (!this._bindDone || !this._battleReady || !this._minWaitDone) return; if (!this.isValid) return; this._completeEntered = true; this._stopRedHeadRoll(); gg.audio.playEffect({ name: '匹配完成', path: GAudioPath.SfxUi }); if (this.ui_matchLight?.isValid) this.ui_matchLight.active = false; this._hideSearchSpine(); this._applyMatchReveal(); this._playSideCompleteAnims(); const sk = this._matchSk; if (!sk?.isValid) { console.warn('[UImatch] ui_match 缺少 sp.Skeleton,跳过「站」'); this._onStandComplete(); return; } const ok = BattleSpinePlayback.play(sk, MATCH_SPINE_STAND, { loop: false }); if (!ok) { console.warn('[UImatch] ui_match 动画「站」不存在'); this._onStandComplete(); return; } sk.timeScale = 1; this.unschedule(this._onStandComplete); this.scheduleOnce(this._onStandComplete, MATCH_STAND_VANISH_SEC); } /** 「战」已消失:立刻拉开上下栏后关页 */ private _onStandComplete = (): void => { if (!this.isValid) return; if (this.ui_match?.isValid) this.ui_match.active = false; this._standDone = true; this._tryPlayExitMove(); }; /** 预载与合拢完成 → 立刻进战斗(不空等最短展示,加载与匹配中并行) */ private _checkGateReady(): void { if (!this._loadDone || !this._enterMoveDone || this._gateTriggered) return; if (!this.isValid) return; this._gateTriggered = true; const cb = this._finishCb; if (cb) { cb({ notifyBattleEntered: this._onBattleEntered, notifyEnterFailed: this._onEnterFailed, }); } } private _onBattleEntered = (): void => { this._battleReady = true; if (this.node?.isValid) { this.node.setSiblingIndex(-1); } this._tryEnterComplete(); }; private _onEnterFailed = (): void => { if (!this.isValid || this._finishing) return; this._finishing = true; void this.close(1).catch((err) => { console.error('[UImatch] close failed', String(err)); }); }; /** 匹配完成动画走完 → 上下栏拉开后关页 */ private _tryPlayExitMove(): void { if (!this._standDone || this._finishing || this._panelMoveKind === 'out') return; if (!this.isValid) return; if (this.node?.isValid) { this.node.setSiblingIndex(-1); } this._playPanelExitMove(); } /** 从屏外合拢到 design 位 */ private _playPanelEnterMove(): void { this._playPanelMove('in', 0); } /** 从 design 位拉开到屏外 */ private _playPanelExitMove(): void { this._playPanelMove('out', this._offscreenDy()); } /** * 上下栏同时位移 * @param kind 合拢 / 拉开 * @param offDy 拉开时的屏外偏移;合拢为 0 */ private _playPanelMove(kind: 'in' | 'out', offDy: number): void { this._stopPanelTweens(); this._panelMoveKind = kind; this._panelMoveAnimLeft = 0; this._tweenPanelToOffset(this.ui_top, this._topRest, offDy); this._tweenPanelToOffset(this.ui_red, this._redRest, offDy); this._tweenPanelToOffset(this.ui_redBox, this._redBoxRest, offDy); this._tweenPanelToOffset(this.ui_bottom, this._bottomRest, -offDy); this._tweenPanelToOffset(this.ui_bule, this._buleRest, -offDy); this._tweenPanelToOffset(this.ui_blueBox, this._blueBoxRest, -offDy); if (this._panelMoveAnimLeft <= 0) { this._onPanelMoveTick(); } else { this.unschedule(this._onPanelMoveWatchdog); this.scheduleOnce(this._onPanelMoveWatchdog, PANEL_MOVE_SEC + 0.4); } } /** 合拢/拉开 tween 丢失回调时强制收尾 */ private _onPanelMoveWatchdog = (): void => { if (!this.isValid || this._panelMoveAnimLeft <= 0) return; console.warn('[UImatch] 栏位移画超时'); this._panelMoveAnimLeft = 1; this._onPanelMoveTick(); }; /** * 单节点 tween 到 rest + dy * @param node 目标 * @param rest design 坐标 * @param dy 纵向偏移 */ private _tweenPanelToOffset(node: Node | null, rest: Vec3, dy: number): void { if (!node?.isValid) return; this._panelMoveAnimLeft++; Tween.stopAllByTarget(node); tween(node) .to(PANEL_MOVE_SEC, { position: new Vec3(rest.x, rest.y + dy, rest.z) }) .call(this._onPanelMoveTick) .start(); } private _onPanelMoveTick = (): void => { this._panelMoveAnimLeft--; if (this._panelMoveAnimLeft > 0) return; const kind = this._panelMoveKind; this._panelMoveKind = null; if (kind === 'in') { this._onEnterMoveDone(); return; } if (kind === 'out') { this._onExitMoveDone(); } }; /** 合拢完成:显示匹配中、放大镜与光效,关首页,再允许进战斗 */ private _onEnterMoveDone(): void { if (!this.isValid) return; if (this.ui_match?.isValid) this.ui_match.active = true; if (this.ui_spine?.isValid) this.ui_spine.active = true; if (this.ui_matchLight?.isValid) this.ui_matchLight.active = true; this._playMatchingLoop(); this._playSearchSpineLoop(); void gg.ui.closePanel(UIID.Home); this._enterMoveDone = true; this._checkGateReady(); } /** 拉开完成:关匹配页 */ private _onExitMoveDone(): void { if (!this.isValid || this._finishing) return; this._finishing = true; void this._closeAfterExitAnim(); } /** 退出动画播完后关匹配页,并清理其它弹窗(保留局内页) */ private _closeAfterExitAnim = async (): Promise => { if (!this.isValid) return; try { await this.close(1); } catch (err) { console.error('[UImatch] close failed', String(err)); } await gg.ui.closeAllPanel([UIID.BattleGame]); }; /** 绑定己方段位/头像,并抽出对手 */ private async _bindMatchInfo(): Promise { await RankDataManager.ensureTable(); await gg.data.table.ensureTableLoaded(TableNames.Headicon); if (!this.isValid) return; const playerStars = RankDataManager.getPlayerRankStars(); const playerRankId = RankDataManager.getPlayerRankId(); this._aiInfo = this._rollAiDisplay(); this._setSideLevel( this.ui_blueBox, RankDataManager.formatRankLevelDesc(playerRankId, playerStars), ); this._setSideLevel(this.ui_redBox, '???'); this._bindPlayerHeadFromSettings(); this._bindDone = true; this._tryEnterComplete(); } /** 蓝方 head:读取设置页存档头像 */ private _bindPlayerHeadFromSettings(): void { const headNode = this._findHead(this.ui_blueBox); if (!headNode?.isValid) { console.warn('[UImatch] ui_blueBox 缺少 head'); return; } headNode.getOrAddComponent(SpriteLoad); HeadIconManager.applyToNode(headNode); } /** 取消红方头像滚动 */ private _stopRedHeadRoll(): void { this._rollGen++; } /** 红方头像滚动:匹配中循环,匹配完成时停在当前头像 */ private async _runRedHeadRoll(): Promise { const gen = ++this._rollGen; await this._delay(RED_HEAD_WAIT_SEC); if (!this.isValid || gen !== this._rollGen || this._completeEntered) return; await gg.data.table.ensureTableLoaded(TableNames.Headicon); if (!this.isValid || gen !== this._rollGen || this._completeEntered) return; const headRows = HeadIconManager.getHeadIconList(); if (headRows.length <= 0) { console.warn('[UImatch] Headicon 表无数据,跳过红方头像滚动'); return; } this._showRedHeadFilled(); while (this.isValid && gen === this._rollGen && !this._completeEntered) { const row = headRows[Math.floor(Math.random() * headRows.length)]; this._setRedHeadRow(row); await this._delay(RED_HEAD_ROLL_INTERVAL); } } /** 匹配完成:隐藏 ui_spine 放大镜 */ private _hideSearchSpine(): void { if (this.ui_spine?.isValid) this.ui_spine.active = false; const sk = this._searchSk; if (sk?.isValid) { BattleSpinePlayback.clearPendingComplete(sk); BattleSpinePlayback.resetTracks(sk); } } /** 红方初始态:仅显示空底框 */ private _resetRedHeadUi(): void { const side = this.ui_redBox; if (!side?.isValid) return; const emptyHead = side.getChildByName('emptyHead'); const headBg = side.getChildByName('headBg'); const head = side.getChildByName('head'); if (emptyHead) emptyHead.active = true; if (headBg) headBg.active = false; if (head) head.active = false; } /** 红方开始展示头像:显示底框与头像,隐藏空底框 */ private _showRedHeadFilled(): void { const side = this.ui_redBox; if (!side?.isValid) return; const emptyHead = side.getChildByName('emptyHead'); const headBg = side.getChildByName('headBg'); const head = side.getChildByName('head'); if (emptyHead) emptyHead.active = false; if (headBg) headBg.active = true; if (head) head.active = true; } /** * 绑定红方 Headicon 头像 * @param row Headicon 表行 */ private _setRedHeadRow(row: ITableHeadicon | null | undefined): void { HeadIconManager.applyRowToNode(this._findHead(this.ui_redBox), row); } /** 揭示对手段位昵称与双方胜率(播「站」时展示) */ private _applyMatchReveal(): void { const ai = this._aiInfo; if (!ai) return; this._setSideLevel(this.ui_redBox, ai.rankName); this._setWinRate(this.ui_redBox, ai.winRate); } /** * 延迟 * @param sec 秒 */ private _delay(sec: number): Promise { return new Promise((resolve) => { this.scheduleOnce(resolve, sec); }); } /** 按 Const 11 抽出对手段位 */ private _rollAiDisplay(): AiMatchDisplay { const oppId = MatchWinStats.ensurePendingOpponentRank(); const oppStars = MatchWinStats.peekPendingOpponentStars(); const stars = oppStars > 0 ? oppStars : RankDataManager.getPlayerRankStars(); const rankId = oppId > 0 ? oppId : (RankDataManager.resolveTierByStars(stars)?.id ?? 0); return { rankName: RankDataManager.formatRankLevelDesc(rankId, stars) || EMPTY_RANK_NAME, winRate: MatchWinStats.getAiWinRate(rankId), opponentRankId: rankId, opponentStars: stars, }; } /** * 查找头像节点 * @param side 蓝/红侧根 */ private _findHead(side: Node | null | undefined): Node | null { if (!side?.isValid) return null; return side.getChildByName('headMask').getChildByName('head') ?? side.find('headMask/head'); } /** * 设置段位描述 * @param side 蓝/红侧 * @param rankName 段位文案 */ private _setSideLevel(side: Node | null | undefined, rankName: string): void { if (!side?.isValid) return; const lb = side.getChildByName('level')?.getComponent(Label); if (!lb) return; lb.string = String(rankName ?? '').trim() || EMPTY_RANK_NAME; } /** * 设置胜率文案 * @param side 蓝/红侧 * @param rate 0~100 */ private _setWinRate(side: Node | null | undefined, rate: number): void { if (!side?.isValid) return; const lb = side.getChildByName('num')?.getComponent(Label); if (!lb) return; const n = Math.max(0, Math.min(100, Number(rate) || 0)); lb.string = n <= 0 ? '胜率:0%' : `胜率:${n.toFixed(1)}%`; } }