feat: 优化通关逻辑

This commit is contained in:
richarjiang
2026-05-25 11:00:44 +08:00
parent 2a599b0356
commit 3bfce30607
18 changed files with 7593 additions and 177 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
import { _decorator, Node, EditBox, instantiate, Vec3, Button, Label, Sprite, SpriteFrame, AudioClip, AudioSource, Prefab, EffectAsset, UITransform, UIOpacity, tween, Tween, Color, Layout } from 'cc';
import { _decorator, Node, EditBox, instantiate, Vec3, Button, Label, Sprite, SpriteFrame, AudioClip, AudioSource, Prefab, EffectAsset, UITransform, UIOpacity, ProgressBar, tween, Tween, Color, Layout, sp, view } from 'cc';
import { BaseView } from 'db://assets/scripts/core/BaseView';
import { ViewManager } from 'db://assets/scripts/core/ViewManager';
import { StaminaManager } from 'db://assets/scripts/utils/StaminaManager';
@@ -11,12 +11,12 @@ import { ShareManager } from 'db://assets/scripts/utils/ShareManager';
import { StorageManager } from 'db://assets/scripts/utils/StorageManager';
import { HttpUtil } from 'db://assets/scripts/utils/HttpUtil';
import { API_ENDPOINTS, API_TIMEOUT } from 'db://assets/scripts/config/ApiConfig';
import { PassModal } from 'db://assets/prefabs/PassModal';
import { WrongModal } from 'db://assets/prefabs/WrongModal';
import { TimeoutModal } from 'db://assets/prefabs/TimeoutModal';
import { CommonModal } from 'db://assets/prefabs/CommonModal';
import { ApiEnvelope, StaminaInfo, NextLevelData, SubmitShareLevel } from 'db://assets/scripts/types/ApiTypes';
import { AchievementTitleManager } from 'db://assets/scripts/utils/AchievementTitleManager';
import { AchievementTitleManager, AchievementTitleInfo } from 'db://assets/scripts/utils/AchievementTitleManager';
import { AchievementTitleAnimator } from 'db://assets/scripts/utils/AchievementTitleAnimator';
import { applyRoundedCorner } from 'db://assets/scripts/utils/roundedMaterial.utils';
import { AudioManager } from 'db://assets/scripts/utils/AudioManager';
const { ccclass, property } = _decorator;
@@ -82,6 +82,15 @@ export class PageLevel extends BaseView {
/** 分享模式最后一题提交文案 */
private static readonly SHARE_SUBMIT_BUTTON_TEXT = '提交答案';
/** PassNode 滑入 / 滑出动画时长(秒) */
private static readonly PASS_NODE_SLIDE_DURATION = 0.4;
/** BottomLayout / TipsLayout 透明度淡入淡出动画时长(秒) */
private static readonly BOTTOM_LAYER_FADE_DURATION = 0.25;
/** 彩带 spine 动画名(一次播放) */
private static readonly CAIDAI_ANIMATION_NAME = 'open';
// ========== 节点引用 ==========
@property(Node)
inputLayout: Node | null = null;
@@ -186,6 +195,47 @@ export class PageLevel extends BaseView {
@property(Node)
pkNextLevelButton: Node | null = null;
// ========== PassNode通关后展示==========
/** PassNode 根节点:用户通关后从屏幕左侧滑入 */
@property(Node)
passNode: Node | null = null;
/** PassNode 内的成就体系容器TitlteLevel。分享模式下整体隐藏 */
@property(Node)
passTitleLevelNode: Node | null = null;
/** 称号文案 Label如「冷场小白1级」 */
@property(Label)
passTitleLevelLabel: Label | null = null;
/** 称号进度条 */
@property(ProgressBar)
passTitleProgressBar: ProgressBar | null = null;
/** 进度提示 Label如「还差3题解锁新成就等级」 */
@property(Label)
passProgressLabel: Label | null = null;
/** 进度条上跟随移动的 anchor 节点(其下 Label 显示百分比) */
@property(Node)
passProgressAnchor: Node | null = null;
/** 通关页「下一关 / 提交答案」按钮 */
@property(Node)
passNextLevelButton: Node | null = null;
/** 通关页「分享」按钮 */
@property(Node)
passShareButton: Node | null = null;
/** 彩带 spine 根节点(与 PassNode 同级,挂在 PageLevel 下) */
@property(Node)
caidaiNode: Node | null = null;
/** 彩带 sp.Skeleton 组件,用于播放 "open" 动画 */
@property(sp.Skeleton)
caidaiSkeleton: sp.Skeleton | null = null;
// ========== 配置属性 ==========
@property(AudioClip)
clickAudio: AudioClip | null = null;
@@ -196,9 +246,6 @@ export class PageLevel extends BaseView {
@property(AudioClip)
failAudio: AudioClip | null = null;
@property(Prefab)
passModalPrefab: Prefab | null = null;
@property(Prefab)
wrongModalPrefab: Prefab | null = null;
@@ -271,14 +318,23 @@ export class PageLevel extends BaseView {
/** 下一个待解锁的线索序号2 或 3超过 3 表示全部已解锁 */
private _nextClueIndex: number = 2;
/** 通关弹窗实例 */
private _passModalNode: Node | null = null;
/** 通关PassNode当前是否已展示 */
private _isPassNodeShown: boolean = false;
/** 本次通关弹窗使用的已通关数量 */
private _passModalCompletedLevelCount: number | null = null;
/** PassNode 进出动画是否在进行中(防止重入) */
private _isPassNodeAnimating: boolean = false;
/** 本次通关弹窗动画起点(通关前)的已通关数量;为 null 表示不播动画 */
private _passModalPreviousCompletedLevelCount: number | null = null;
/** PassNode 原始 local positionprefab 摆放位),动画结束后用来回归 */
private _passNodeOriginalPos: Vec3 | null = null;
/** 通关页所用「已通关数量」(业务数据,给成就体系展示用) */
private _passCompletedLevelCount: number | null = null;
/** 通关页动画起点(通关前)的已通关数量;为 null 表示不播跨称号过渡 */
private _passPreviousCompletedLevelCount: number | null = null;
/** 通关页称号 / 进度条动画工具,惰性初始化 */
private _titleAnimator: AchievementTitleAnimator | null = null;
/** 错误弹窗实例 */
private _wrongModalNode: Node | null = null;
@@ -364,6 +420,7 @@ export class PageLevel extends BaseView {
this.initUnlockButtons();
this.initSubmitButton();
this.initPkNextLevelButton();
this._initPassNodeState();
// 异步加载关卡资源并调用进入关卡接口,完成后启动倒计时
this._enterAndInitLevel().catch(err => {
@@ -405,12 +462,12 @@ export class PageLevel extends BaseView {
// - 提交按钮被 _isTransitioning 锁住,无法重新提交
// - 倒计时已停、谐音梗已揭示
// 此时玩家从首页再次进入会看到一个无法操作的"死局"。必须把会话推进到下一关。
// 注意:这只可能在主线模式发生 —— 分享模式下点 iconSetting / PassModal 的 home
// 注意:这只可能在主线模式发生 —— 分享模式下点 iconSetting / PassNode 的入口
// 都会调用 ShareManager.clearShareMode + ViewManager.replace再次进入会被
// 上面的 modeChanged / shareCodeChanged 分支拦截走 _reinitLevelSession。
if (this._isTransitioning) {
console.log('[PageLevel] 上次离场时停留在通关后状态,自动推进到下一关');
this._closePassModal();
this._resetPassNode();
this._closeWrongModal();
this._closeTimeoutModal();
this._closeCommonModal();
@@ -433,7 +490,7 @@ export class PageLevel extends BaseView {
this._isShareMode = shareMode;
// 上一场可能遗留的弹窗 / 倒计时一并清掉,避免主线模式还看到分享态弹窗
this._closePassModal();
this._resetPassNode();
this._closeWrongModal();
this._closeTimeoutModal();
this._closeCommonModal();
@@ -493,7 +550,7 @@ export class PageLevel extends BaseView {
this.clearInputNodes();
this.clearPunchBlocks();
this.stopCountdown();
this._closePassModal();
this._resetPassNode();
this._closeWrongModal();
this._closeTimeoutModal();
this._closeCommonModal();
@@ -908,11 +965,11 @@ export class PageLevel extends BaseView {
AudioManager.instance.playButtonClick();
// 离开 PageLevel 时把所有挂在 Canvas 上的关卡级弹窗一起清掉。
// PassModal / WrongModal / TimeoutModal / CommonModal 都是 addChild 到 this.node.parent
// WrongModal / TimeoutModal / CommonModal 都是 addChild 到 this.node.parent
// 也就是 PageLevel 的兄弟节点PageLevel 被 ViewManager 隐藏后它们并不会自动消失,
// 否则会孤儿地盖在 PageHome 上。同时清掉 PassModal 也避免再次进入时缓存实例
// 残留 _passModalNode 引用让 _swapToNextLevelImagesIfReady 误判弹窗仍在打开
this._closePassModal();
// 否则会孤儿地盖在 PageHome 上。
// PassNode 是 PageLevel 自身子节点(不是弹窗),用 _resetPassNode 同步归位即可
this._resetPassNode();
this._closeWrongModal();
this._closeTimeoutModal();
this._closeCommonModal();
@@ -1662,6 +1719,20 @@ export class PageLevel extends BaseView {
this.playSound(this.failAudio);
}
/**
* 播放通关成功音效
*/
private playSuccessSound(): void {
this.playSound(this.successAudio);
}
/**
* 播放通关成功音效
*/
private playSuccessSound(): void {
this.playSound(this.successAudio);
}
// ========== 倒计时相关方法 ==========
/**
@@ -1902,8 +1973,8 @@ export class PageLevel extends BaseView {
// 停止倒计时
this.stopCountdown();
// 通关音效不在这里播,改由通关弹窗 onViewShow 触发(见 PassModal._playSuccessSound
// 产品节奏:玩家看到弹窗的瞬间音效才响起,避免谐音梗揭示期间就被音效抢戏
// 通关音效不在这里播,改由 _showPassNode 在 PassNode 弹起时触发(_playSuccessSound
// 产品节奏:玩家看到通关页的瞬间音效才响起,避免谐音梗揭示期间就被音效抢戏
const punchline = this.getValidPunchline(this._currentConfig?.punchline ?? null);
if (punchline) {
@@ -1922,7 +1993,7 @@ export class PageLevel extends BaseView {
await this.delay(PageLevel.PASS_MODAL_DELAY_MS);
// 显示通关弹窗
this._showPassModal();
this._showPassNode();
}
private getValidPunchline(punchline: string | null): string | null {
@@ -1941,9 +2012,9 @@ export class PageLevel extends BaseView {
// 乐观更新通关计数(用于称号展示)
const previousCount = AuthManager.instance.completedLevelCount;
AuthManager.instance.addCompletedLevelCount();
this._passModalCompletedLevelCount = AuthManager.instance.completedLevelCount;
this._passCompletedLevelCount = AuthManager.instance.completedLevelCount;
// 本次预期为首次通关,起点 = 通关前计数;如果回调回退,则清掉避免误播动画
this._passModalPreviousCompletedLevelCount = previousCount;
this._passPreviousCompletedLevelCount = previousCount;
void StaminaManager.instance.completeLevel(levelId, timeSpent).then((result) => {
if (result) {
@@ -1953,20 +2024,17 @@ export class PageLevel extends BaseView {
if (!result.firstClear) {
// 非首次通关,回退乐观更新
AuthManager.instance.addCompletedLevelCount(-1);
this._passModalCompletedLevelCount = AuthManager.instance.completedLevelCount;
this._passModalPreviousCompletedLevelCount = null;
this._passCompletedLevelCount = AuthManager.instance.completedLevelCount;
this._passPreviousCompletedLevelCount = null;
}
console.log(`[PageLevel] 通关上报成功,首次通关: ${result.firstClear}, 有下一关: ${!!result.nextLevel}`);
// 若此时通关弹窗已打开但当时 nextLevel 还没到,补一次底图预替换
this._swapToNextLevelImagesIfReady();
}
});
return;
}
this._passModalCompletedLevelCount = null;
this._passModalPreviousCompletedLevelCount = null;
this._passCompletedLevelCount = null;
this._passPreviousCompletedLevelCount = null;
this._recordCurrentShareSubmission(undefined, timeSpent);
}
@@ -2011,158 +2079,357 @@ export class PageLevel extends BaseView {
}
/**
* 显示通关弹窗
* 将弹窗添加到 Canvas 根节点下(而非 PageLevel 子节点)
* 这样 Widget 可以正确对齐到全屏
* 通关展示BottomLayout / TipsLayout 透明度淡出PassNode 从屏幕左侧滑入,
* Caidai 彩带 spine 播放 "open" 动画;按 NextLevel 时再走 _hidePassNode。
*
* 与原 PassModal 弹窗的差异:
* - PassNode 是 PageLevel 自己的子节点,不是 instantiate 出的弹窗,无需挂到 Canvas
* - PassNode 不全屏遮盖,所以不能在它显示期间偷偷预换底图(用户能看见底图)
* - 分享模式不展示成就体系passTitleLevelNode 整体隐藏)
*/
private _showPassModal(): void {
if (!this.passModalPrefab) {
console.warn('[PageLevel] passModalPrefab 未设置');
private _showPassNode(): void {
if (!this.passNode) {
console.warn('[PageLevel] passNode 未挂引用,跳过通关展示');
return;
}
// 如果弹窗已显示,不再重复创建
if (this._passModalNode && this._passModalNode.isValid) {
if (this._isPassNodeShown || this._isPassNodeAnimating) {
return;
}
// 实例化弹窗
const modalNode = instantiate(this.passModalPrefab);
modalNode.setPosition(PageLevel.ZERO_POS);
modalNode.setSiblingIndex(PassModal.MODAL_Z_INDEX);
this._isPassNodeShown = true;
// 找到 Canvas 根节点并添加弹窗
const canvasNode = this.node.parent;
if (canvasNode) {
canvasNode.addChild(modalNode);
} else {
this.node.addChild(modalNode);
}
this._passModalNode = modalNode;
// 配置成就体系数据 / 按钮文案 / 事件
this._setupPassNodeContent();
this._bindPassNodeEvents();
// 获取 PassModal 组件并设置回调
const passModal = modalNode.getComponent(PassModal);
if (passModal) {
const completedCount = this._getPassModalCompletedLevelCount();
const titleInfo = AchievementTitleManager.getTitleInfo(completedCount);
const previousCompletedCount = this._passModalPreviousCompletedLevelCount;
const previousTitleInfo = (previousCompletedCount !== null && previousCompletedCount !== completedCount)
? AchievementTitleManager.getTitleInfo(previousCompletedCount)
: undefined;
// 启动彩带 + 滑入动画 + 淡出底部UI + 通关音效
this._playCaidai();
this._playPassNodeShowAnimation();
this.playSuccessSound();
passModal.setParams({
levelIndex: this.getDisplayLevelNumber(),
nextButtonText: this._isShareMode && this._isFinalShareLevel()
? PageLevel.SHARE_SUBMIT_BUTTON_TEXT
: undefined,
titleInfo,
previousTitleInfo
});
passModal.setCallbacks({
onNextLevel: () => {
if (this._isShareMode) {
this._closePassModal();
void this.goToNextLevel();
return;
}
this._showShareNextConfirmModal(() => {
this._closePassModal();
void this.goToNextLevel();
});
},
onShare: () => {
// 分享后不关闭弹窗,用户可继续点击下一关
console.log('[PageLevel] 分享完成');
},
onHome: () => {
this._closePassModal();
if (this._isShareMode) {
ShareManager.instance.clearShareMode();
ViewManager.instance.replace('PageHome');
return;
}
ViewManager.instance.back();
}
});
// 动画消费完一次后清除起点,避免弹窗多次打开时复用
this._passModalPreviousCompletedLevelCount = null;
// 手动调用 onViewLoad 和 onViewShow
passModal.onViewLoad();
passModal.onViewShow();
}
// 弹窗已全屏遮盖当前关卡,利用这个时机把底层图片静默替换为下一关
// 这样用户点"下一关"关闭弹窗时,底图已经是新的,不会出现闪烁
this._swapToNextLevelImagesIfReady();
console.log('[PageLevel] 显示通关弹窗');
console.log('[PageLevel] 显示通关页 PassNode');
}
private _getPassModalCompletedLevelCount(): number {
return this._passModalCompletedLevelCount ?? AuthManager.instance.completedLevelCount;
private _getPassCompletedLevelCount(): number {
return this._passCompletedLevelCount ?? AuthManager.instance.completedLevelCount;
}
/**
* 在通关弹窗遮盖期间,把底层的主图预先替换为下一关的图
* 目的:点击"下一关"关闭弹窗的瞬间,底图已经是新的,避免闪屏
*
* 三种情况:
* - 分享模式:直接通过 ShareManager.ensureShareLevelReady(index+1) 取下一关 config
* - 正常模式 & nextLevel 已到达:从 LevelDataManager 缓存里取enter 时已 preloadLevel 过)
* - 正常模式 & nextLevel 尚未到达complete 接口还没回包):由 reportLevelCompleted 的回调补偿调用
* 配置通关页内容:
* - 成就体系(分享模式整体隐藏;普通模式根据通关数量绑定文案 / 进度条 / 跨称号动画)
* - 「下一关 / 提交答案」按钮文案:分享模式最后一题显示"提交答案"
*/
private _swapToNextLevelImagesIfReady(): void {
if (this._isShareMode) {
const nextIndex = this._shareLevelIndex + 1;
if (nextIndex >= ShareManager.instance.getShareLevelCount()) {
private _setupPassNodeContent(): void {
const showTitle = !this._isShareMode;
if (this.passTitleLevelNode) {
this.passTitleLevelNode.active = showTitle;
}
if (showTitle) {
const completedCount = this._getPassCompletedLevelCount();
const titleInfo = AchievementTitleManager.getTitleInfo(completedCount);
const previousCompletedCount = this._passPreviousCompletedLevelCount;
const previousTitleInfo: AchievementTitleInfo | null = (
previousCompletedCount !== null && previousCompletedCount !== completedCount
)
? AchievementTitleManager.getTitleInfo(previousCompletedCount)
: null;
const animator = this._ensureTitleAnimator();
animator.bind({
titleLabel: this.passTitleLevelLabel,
progressLabel: this.passProgressLabel,
progressBar: this.passTitleProgressBar,
progressAnchor: this.passProgressAnchor,
});
animator.playTransition(previousTitleInfo, titleInfo);
// 跨称号动画起点是一次性的,消费完清掉,避免下次重复
this._passPreviousCompletedLevelCount = null;
} else {
// 分享模式不展示成就,停掉可能的旧动画
this._titleAnimator?.stop();
}
// NextLevelButton 文案
const isFinalShareSubmit = this._isShareMode && this._isFinalShareLevel();
const nextLabel = this.passNextLevelButton?.getChildByName('Label')?.getComponent(Label);
if (nextLabel) {
nextLabel.string = isFinalShareSubmit
? PageLevel.SHARE_SUBMIT_BUTTON_TEXT
: '下一关';
}
}
private _ensureTitleAnimator(): AchievementTitleAnimator {
if (!this._titleAnimator) {
this._titleAnimator = new AchievementTitleAnimator();
}
return this._titleAnimator;
}
/**
* 在 onViewLoad 阶段把 PassNode / Caidai 的可见性归到「未通关」初始态。
* Prefab 里 PassNode 默认 active=true方便编辑预览运行时必须先关掉
* 否则页面一打开就直接展示通关页。
*
* 同时记录 PassNode 当前 local position 作为「滑入终点位置」。
* Widget 的 alignMode=ON_WINDOW_RESIZE运行时不会每帧覆盖所以 clone 一次即可。
*/
private _initPassNodeState(): void {
if (this.passNode) {
this._passNodeOriginalPos = this.passNode.position.clone();
this.passNode.active = false;
}
if (this.caidaiNode) {
this.caidaiNode.active = false;
}
this._isPassNodeShown = false;
this._isPassNodeAnimating = false;
}
/** PassNode 进入动画:底部两层淡出 + PassNode 从屏幕左侧滑入 */
private _playPassNodeShowAnimation(): void {
if (!this.passNode) return;
this._isPassNodeAnimating = true;
// 底部两层淡出后置 active = falsenext 进入时会重新 active = true
this._fadeOutBottomLayers();
// 记录原位置(首次记录后保留,反向动画 / reset 时复用)
if (!this._passNodeOriginalPos) {
this._passNodeOriginalPos = this.passNode.position.clone();
}
// 从屏幕左侧外滑入:起点 X = 原 X - 屏幕宽度(确保完全在屏幕外)
const screenWidth = view.getVisibleSize().width;
const originalPos = this._passNodeOriginalPos;
const startX = originalPos.x - screenWidth;
Tween.stopAllByTarget(this.passNode);
this.passNode.active = true;
this.passNode.setPosition(startX, originalPos.y, originalPos.z);
tween(this.passNode)
.to(
PageLevel.PASS_NODE_SLIDE_DURATION,
{ position: originalPos.clone() },
{ easing: 'cubicOut' },
)
.call(() => {
this._isPassNodeAnimating = false;
})
.start();
}
/**
* PassNode 退出(用户点下一关时调用):滑出 PassNode + 底部两层淡入
* 返回 Promise外部链路通常 await 后再调 goToNextLevel
*/
private _hidePassNode(): Promise<void> {
return new Promise<void>((resolve) => {
if (!this.passNode || !this._isPassNodeShown) {
resolve();
return;
}
// ensureShareLevelReady 若已缓存会立即 resolve首次则会加载此时图已在弹窗后面偷偷替换用户无感
ShareManager.instance.ensureShareLevelReady(nextIndex)
.then(config => this._applyNextLevelImagesFromConfig(config))
.catch(() => {});
return;
}
const nextLevel = this._nextLevelData ?? AuthManager.instance.nextLevel;
if (!nextLevel) {
// complete 接口还没回包,走回调补偿路径
return;
}
this._isPassNodeAnimating = true;
const config = LevelDataManager.instance.getLevelConfig(nextLevel.id);
this._applyNextLevelImagesFromConfig(config);
// 底部两层淡入恢复
this._fadeInBottomLayers();
// 滑出 PassNode向左滑出屏幕
const screenWidth = view.getVisibleSize().width;
const originalPos = this._passNodeOriginalPos ?? this.passNode.position.clone();
const exitX = originalPos.x - screenWidth;
Tween.stopAllByTarget(this.passNode);
tween(this.passNode)
.to(
PageLevel.PASS_NODE_SLIDE_DURATION,
{ position: new Vec3(exitX, originalPos.y, originalPos.z) },
{ easing: 'cubicIn' },
)
.call(() => {
this._finalizeHidePassNode();
resolve();
})
.start();
});
}
private _applyNextLevelImagesFromConfig(config: RuntimeLevelConfig | null): void {
if (!config) return;
// 只在弹窗仍然打开时换图;否则说明用户已经点过下一关进入后续流程了,不再重复设置
if (!this._passModalNode || !this._passModalNode.isValid) {
return;
private _finalizeHidePassNode(): void {
// 隐藏 PassNode 并归位_passNodeOriginalPos 已记录)
if (this.passNode) {
this.passNode.active = false;
if (this._passNodeOriginalPos) {
this.passNode.setPosition(this._passNodeOriginalPos);
}
}
if (config.spriteFrame1) {
this.setMainImage(config.spriteFrame1);
// 关掉彩带
if (this.caidaiNode) {
this.caidaiNode.active = false;
}
if (config.spriteFrame2) {
this.setMainImage2(config.spriteFrame2);
}
console.log('[PageLevel] 弹窗遮盖期间已预替换下一关底图');
this._unbindPassNodeEvents();
this._titleAnimator?.stop();
this._isPassNodeShown = false;
this._isPassNodeAnimating = false;
}
/**
* 关闭通关弹窗
* 同步重置 PassNode 与底部两层到「未通关」状态。
* 用于销毁、模式切换、IconSetting 离场、自动推进下一关 等需要立即清场的场景。
* 不带动画,直接归位。
*/
private _closePassModal(): void {
if (this._passModalNode && this._passModalNode.isValid) {
this._passModalNode.destroy();
this._passModalNode = null;
console.log('[PageLevel] 关闭通关弹窗');
private _resetPassNode(): void {
if (this.passNode) {
Tween.stopAllByTarget(this.passNode);
this.passNode.active = false;
if (this._passNodeOriginalPos) {
this.passNode.setPosition(this._passNodeOriginalPos);
}
}
if (this.caidaiNode) {
this.caidaiNode.active = false;
}
this._restoreBottomLayersImmediate();
this._unbindPassNodeEvents();
this._titleAnimator?.stop();
this._isPassNodeShown = false;
this._isPassNodeAnimating = false;
}
/** PassNode 事件绑定NextLevel + Share */
private _bindPassNodeEvents(): void {
// 防御:先解绑,避免重复绑定
this._unbindPassNodeEvents();
if (this.passNextLevelButton) {
this.passNextLevelButton.on(Node.EventType.TOUCH_END, this._onPassNextLevelClick, this);
}
if (this.passShareButton) {
this.passShareButton.on(Node.EventType.TOUCH_END, this._onPassShareClick, this);
}
}
private _unbindPassNodeEvents(): void {
if (this.passNextLevelButton?.isValid) {
this.passNextLevelButton.off(Node.EventType.TOUCH_END, this._onPassNextLevelClick, this);
}
if (this.passShareButton?.isValid) {
this.passShareButton.off(Node.EventType.TOUCH_END, this._onPassShareClick, this);
}
}
/**
* 「下一关 / 提交答案」点击:与原 PassModal.onNextLevel 等价,
* - 分享模式弹「确认进入下一题 / 确认提交挑战答案」二次确认
* - 普通模式直接走 _hidePassNode + goToNextLevel
*/
private _onPassNextLevelClick(): void {
if (this._isPassNodeAnimating) return;
AudioManager.instance.playButtonClick();
// 普通模式 _showShareNextConfirmModal 内部首行就 onConfirm(),所以两条路统一
this._showShareNextConfirmModal(async () => {
await this._hidePassNode();
void this.goToNextLevel();
});
}
/**
* 「分享」点击:与原 PassModal.onShare 行为一致——发普通 query=level=N 的微信卡片
* (这条仍是 CLAUDE.md Current Gaps #2 列出的已知缺陷:不是好友挑战的 shareCode 链路)
*/
private _onPassShareClick(): void {
AudioManager.instance.playButtonClick();
WxSDK.shareAppMessage({
title: '快来一起玩这款游戏吧',
query: `level=${this.getDisplayLevelNumber()}`,
});
}
/** 启动彩带 spine 动画 "open",单次播放 */
private _playCaidai(): void {
if (!this.caidaiNode || !this.caidaiSkeleton) return;
this.caidaiNode.active = true;
// setAnimation 三参trackIndex, name, loop
this.caidaiSkeleton.setAnimation(0, PageLevel.CAIDAI_ANIMATION_NAME, false);
}
/** 底部两层淡出(透明度 → 0完成后 active = false */
private _fadeOutBottomLayers(): void {
for (const layer of [this.bottomLayoutNode, this.tipsLayout]) {
if (!layer) continue;
const opacity = this._ensureUIOpacity(layer);
Tween.stopAllByTarget(opacity);
opacity.opacity = 255;
tween(opacity)
.to(
PageLevel.BOTTOM_LAYER_FADE_DURATION,
{ opacity: 0 },
{ easing: 'sineOut' },
)
.call(() => {
if (layer.isValid) {
layer.active = false;
}
})
.start();
}
}
/** 底部两层淡入active = true透明度 0 → 255 */
private _fadeInBottomLayers(): void {
for (const layer of [this.bottomLayoutNode, this.tipsLayout]) {
if (!layer) continue;
const opacity = this._ensureUIOpacity(layer);
Tween.stopAllByTarget(opacity);
layer.active = true;
opacity.opacity = 0;
tween(opacity)
.to(
PageLevel.BOTTOM_LAYER_FADE_DURATION,
{ opacity: 255 },
{ easing: 'sineOut' },
)
.start();
}
}
/** 立即把底部两层恢复成「关卡进行中」状态(无动画),用于销毁 / 切关时清场 */
private _restoreBottomLayersImmediate(): void {
for (const layer of [this.bottomLayoutNode, this.tipsLayout]) {
if (!layer) continue;
const opacity = layer.getComponent(UIOpacity);
if (opacity) {
Tween.stopAllByTarget(opacity);
opacity.opacity = 255;
}
// 注意:是否 active=true 由 _refreshModeUI 决定PK 模式下 BottomLayout 应隐藏)
// 这里只负责把透明度归位,可见性由模式刷新控制
}
}
private _ensureUIOpacity(node: Node): UIOpacity {
let opacity = node.getComponent(UIOpacity);
if (!opacity) {
opacity = node.addComponent(UIOpacity);
}
return opacity;
}
/**

View File

@@ -652,7 +652,7 @@
"__id__": 86
}
],
"_active": true,
"_active": false,
"_components": [
{
"__id__": 98
@@ -1632,7 +1632,7 @@
},
"_contentSize": {
"__type__": "cc.Size",
"width": 442.03125,
"width": 461.40625,
"height": 75.6
},
"_anchorPoint": {