Files
mp-xieyingeng/软著源代码/05-关卡核心逻辑.md
2026-06-29 19:55:40 +08:00

28 KiB
Raw Blame History

梗中作乐 V1.0 - 源代码文档(第 5 部分)

软件名称:梗中作乐 版本号V1.0


第 5 部分:关卡核心逻辑

本部分展示关卡页 PageLevel 的核心实现:包含视图绑定、关卡数据加载、答题判定与通关流程驱动的关键代码。

5.1 关卡页 (assets/prefabs/PageLevel.ts节选 1-700 行)

import { _decorator, Node, EditBox, instantiate, Vec3, Button, Label, Sprite, SpriteFrame, AudioClip, AudioSource, Prefab, EffectAsset, UITransform, UIOpacity, ProgressBar, tween, Tween, Color, Layout, Widget, sp, view, resources } 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';
import { WxSDK, getUserProfile, type WxUserInfo } from 'db://assets/scripts/utils/WxSDK';
import { LevelDataManager } from 'db://assets/scripts/utils/LevelDataManager';
import { AuthManager } from 'db://assets/scripts/utils/AuthManager';
import { RuntimeLevelConfig } from 'db://assets/scripts/types/LevelTypes';
import { ToastManager } from 'db://assets/scripts/utils/ToastManager';
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 { 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, 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;

/**
 * 关卡页面组件
 * 继承 BaseView实现页面生命周期
 * 关卡流程由服务端 NextLevelData 驱动,客户端不再维护关卡列表
 */
@ccclass('PageLevel')
export class PageLevel extends BaseView {
    /** 静态常量:零位置 */
    private static readonly ZERO_POS = new Vec3(0, 0, 0);

    /** 解锁线索按钮默认文案 */
    private static readonly UNLOCK_BUTTON_CLUE_TEXT = '查看线索';

    /** 线索全部查看后的按钮文案 */
    private static readonly UNLOCK_BUTTON_ANSWER_TEXT = '查看答案';

    /** 默认体力上限,服务端未返回 max 时使用 */
    private static readonly DEFAULT_STAMINA_MAX = 50;

    /** 答案正确后到弹出通关弹窗之间的停留时间(不论是否有谐音梗都保持一致) */
    private static readonly PASS_MODAL_DELAY_MS = 800;

    /** 图片2描述默认文案 */
    private static readonly DEFAULT_IMAGE2_DESCRIPTION = '这是什么?';

    /** 线索解锁出现动画时长ms */
    private static readonly CLUE_APPEAR_DURATION = 0.3;

    /** 线索解锁出现动画起始缩放 */
    private static readonly CLUE_APPEAR_START_SCALE = 0.8;

    /** 倒计时进入紧迫状态的阈值(秒,≤ 该值开始警示) */
    private static readonly CLOCK_URGENT_THRESHOLD = 10;

    /** 紧迫状态下倒计时字体颜色(红) */
    private static readonly CLOCK_URGENT_COLOR = new Color(230, 60, 60, 255);

    /** 倒计时 tick 脉冲峰值缩放 */
    private static readonly CLOCK_PULSE_PEAK_SCALE = 1.3;

    /** 倒计时 tick 脉冲单向时长(放大、回落各一半) */
    private static readonly CLOCK_PULSE_HALF_DURATION = 0.15;

    /** 谐音梗揭示动画InputLayout 位移、divider 淡入时长 */
    private static readonly PUNCH_REVEAL_DURATION = 0.3;

    /** 谐音梗揭示动画punchLayout 出现的起始缩放 */
    private static readonly PUNCH_REVEAL_START_SCALE = 0.85;

    /** 谐音梗揭示动画punchLayout 在 InputLayout 动起来后再出现的延迟(让动画有节奏) */
    private static readonly PUNCH_REVEAL_DELAY = 0.1;

    /** 分享模式只展示提示 1 时,固定放回 TipsLayout 顶部,避免 Layout 把它排到底部被下一题按钮遮挡 */
    private static readonly SHARE_MODE_TIP1_Y = 120;

    /** 分享模式底部按钮默认文案 */
    private static readonly SHARE_NEXT_BUTTON_TEXT = '下一题';

    /** 分享模式最后一题提交文案 */
    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';

    /** pose 赞美音效 resources 路径assets/resources/audios/good.mp3 */
    private static readonly GOOD_AUDIO_RESOURCE_PATH = 'audios/good';

    /** pose 赞美段提前量:比通关动效 / 成功音效结束点提前 1 秒启动 */
    private static readonly POSE_PRAISE_ADVANCE_SECONDS = 1.5;

    /**
     * 通关赞美 spinepose 节点)档位:[最小通关数, 动画名],倒序匹配。
     * 1-5 关 → "1"6-10 关 → "2"11+ 关 → "3"。
     * count <= 0 不会匹配任何档位,自然不播放。
     */
    private static readonly POSE_TIERS: ReadonlyArray<readonly [number, string]> = [
        [11, '3'],
        [6, '2'],
        [1, '1'],
    ];

    // ========== 节点引用 ==========
    @property(Node)
    inputLayout: Node | null = null;

    @property(Node)
    punchLayout: Node | null = null;

    /** Action 区域内 InputLayout 与 punchLayout 之间的分割线节点prefab 中的 border_dashline_wht */
    @property(Node)
    punchDivider: Node | null = null;

    @property(Node)
    submitButton: Node | null = null;

    @property(Node)
    inputTemplate: Node | null = null;

    @property(Node)
    actionNode: Node | null = null;

    @property(Node)
    iconSetting: Node | null = null;

    @property(Node)
    tipsLayout: Node | null = null;

    @property(Node)
    mainImage: Node | null = null;

    @property(Node)
    mainImage2: Node | null = null;

    @property(Label)
    image1DescLabel: Label | null = null;

    @property(Label)
    image2DescLabel: Label | null = null;

    @property(Node)
    tipsItem1: Node | null = null;

    @property(Node)
    tipsItem2: Node | null = null;

    @property(Node)
    tipsItem3: Node | null = null;

    @property(Node)
    unLockTipsBtn: Node | null = null;

    @property(Node)
    addTimeBtn: Node | null = null;

    @property(Label)
    clockLabel: Label | null = null;

    /** 体力值显示标签prefab 中序列化名为 liveLabel保持兼容 */
    @property(Label)
    liveLabel: Label | null = null;

    /** 关卡标题标签,显示为"第 N 关" */
    @property(Label)
    titleLevelLabel: Label | null = null;

    /** 普通模式背景 */
    @property(Node)
    bgNode: Node | null = null;

    /** 分享 / PK 模式背景 */
    @property(Node)
    pkBgNode: Node | null = null;

    /** 普通模式标题容器 */
    @property(Node)
    titleLevelNode: Node | null = null;

    /** 分享 / PK 模式标题容器 */
    @property(Node)
    pkTitleLevelNode: Node | null = null;

    /** 分享 / PK 模式标题标签 */
    @property(Label)
    pkTitleLevelLabel: Label | null = null;

    /** 普通模式体力区域容器 */
    @property(Node)
    liveNode: Node | null = null;

    /** 分享 / PK 模式进度容器 */
    @property(Node)
    pkLevelProgressNode: Node | null = null;

    /** 分享 / PK 模式进度标签 */
    @property(Label)
    pkLevelProgressLabel: Label | null = null;

    /** 普通模式底部按钮区域 */
    @property(Node)
    bottomLayoutNode: Node | null = null;

    /** 分享 / PK 模式底部下一题按钮 */
    @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;

    /** 通关赞美 spine 节点PassNode 子节点,根据本场通关数选择 1/2/3 动画) */
    @property(Node)
    poseNode: Node | null = null;

    /** 通关赞美 sp.Skeleton 组件,动画名 "1" / "2" / "3"loop=false */
    @property(sp.Skeleton)
    poseSkeleton: sp.Skeleton | null = null;

    // ========== 配置属性 ==========
    @property(AudioClip)
    clickAudio: AudioClip | null = null;

    @property(AudioClip)
    successAudio: AudioClip | null = null;

    @property(AudioClip)
    failAudio: AudioClip | null = null;

    @property(Prefab)
    wrongModalPrefab: Prefab | null = null;

    @property(Prefab)
    timeoutModalPrefab: Prefab | null = null;

    @property(Prefab)
    commonModalPrefab: Prefab | null = null;

    /** 主图圆角材质 EffectAsset */
    @property(EffectAsset)
    roundedSpriteEffect: EffectAsset | null = null;

    /** 主图圆角半径比例相对于短边0-0.5 */
    @property
    mainImageCornerRadius: number = 0.1;

    // ========== 内部状态 ==========
    /** 当前创建的输入框节点数组 */
    private _inputNodes: Node[] = [];

    /** InputLayout 中默认放置的输入框模板节点 */
    private _inputTemplateNode: Node | null = null;

    /** 当前创建的包袱展示块节点数组 */
    private _punchBlockNodes: Node[] = [];

    /** punchLayout 中默认放置的展示块模板节点 */
    private _punchBlockTemplateNode: Node | null = null;

    /** 是否正在同步输入格内容,避免设置文本时重复触发事件 */
    private _isSyncingInputText: boolean = false;

    /** 最近一次自动提交的答案,避免填满后重复提交同一内容 */
    private _lastAutoSubmittedAnswer: string = '';

    /** 当前正在编辑的输入格索引 */
    private _editingInputIndex: number = -1;

    /** 倒计时剩余秒数 */
    private _countdown: number = 60;

    /** clockLabel 非紧迫状态下的原始颜色(首次渲染时懒记录,避免 hardcode prefab 颜色) */
    private _clockLabelNormalColor: Color | null = null;

    /** InputLayout 原始位置prefab 中的初始 _lpos作为"有梗揭示"的目标位置) */
    private _inputLayoutOriginalPos: Vec3 | null = null;

    /** punchLayout 原始位置prefab 中的初始 _lpos作为"有梗揭示"的目标位置) */
    private _punchLayoutOriginalPos: Vec3 | null = null;

    /** "无梗居中态"下 InputLayout 的 Y 坐标InputLayout 原始 Y 与 punchLayout 原始 Y 的中点 */
    private _inputLayoutCenteredY: number | null = null;

    /** 关卡开始时间戳ms用于准确计算耗时 */
    private _levelStartTime: number = 0;

    /** 倒计时是否结束 */
    private _isTimeUp: boolean = false;

    /** 当前关卡配置 */
    private _currentConfig: RuntimeLevelConfig | null = null;

    /** 是否正在切换关卡(防止重复提交) */
    private _isTransitioning: boolean = false;

    /** 是否正在解锁提示(防止双击重复触发) */
    private _isUnlocking: boolean = false;

    /** 下一个待解锁的线索序号2 或 3超过 3 表示全部已解锁 */
    private _nextClueIndex: number = 2;

    /** 通关页PassNode当前是否已展示 */
    private _isPassNodeShown: boolean = false;

    /** PassNode 进出动画是否在进行中(防止重入) */
    private _isPassNodeAnimating: boolean = false;

    /** PassNode 原始 local positionprefab 摆放位),动画结束后用来回归 */
    private _passNodeOriginalPos: Vec3 | null = null;

    /** PassNode Widget 初始启用状态;进出场动画期间临时关闭,避免激活首帧回写位置 */
    private _passNodeWidgetOriginalEnabled: boolean | null = null;

    /** 通关页所用「已通关数量」(业务数据,给成就体系展示用) */
    private _passCompletedLevelCount: number | null = null;

    /**
     * 本次进入 PageLevel 后累计通关数onViewLoad / _reinitLevelSession 时归零)。
     * 普通模式 / 分享模式都计入;用于驱动 pose 赞美动画档位。
     */
    private _sessionPassCount: number = 0;

    /** pose Spine 隐藏延时定时器setTimeout 句柄);切关 / 关页时需清理避免穿屏触发 */
    private _poseHideTimer: ReturnType<typeof setTimeout> | null = null;

    /** pose 赞美延迟播放定时器;等待通关动画与成功音效结束后再触发 */
    private _posePraiseDelayTimer: ReturnType<typeof setTimeout> | null = null;

    /** pose 赞美延迟播放序号,用于取消 resources.load 异步回调里的过期播放 */
    private _posePraiseSequenceId: number = 0;

    /** good.mp3 加载缓存 */
    private _goodAudioClip: AudioClip | null = null;

    /** good.mp3 加载中的 Promise避免重复请求 resources */
    private _goodAudioLoadPromise: Promise<AudioClip | null> | null = null;

    /** 通关页动画起点(通关前)的已通关数量;为 null 表示不播跨称号过渡 */
    private _passPreviousCompletedLevelCount: number | null = null;

    /** 通关页称号 / 进度条动画工具,惰性初始化 */
    private _titleAnimator: AchievementTitleAnimator | null = null;

    /** 错误弹窗实例 */
    private _wrongModalNode: Node | null = null;

    /** 超时弹窗实例 */
    private _timeoutModalNode: Node | null = null;

    /** 通用确认弹窗实例 */
    private _commonModalNode: Node | null = null;

    /** 是否处于分享挑战模式 */
    private _isShareMode: boolean = false;

    /**
     * 当前 PageLevel 实例所处分享挑战的 shareCode 缓存。
     * PageLevel 注册时 cache: true复用同一个实例。
     * 当用户在后台切换好友分享卡片时ShareManager.shareCode 会发生变化,
     * onViewShow 通过对比这个值与最新的 ShareManager.shareCode 来判断是否需要 _reinitLevelSession。
     */
    private _activeShareCode: string | null = null;

    /** 体力恢复倒计时定时器 */
    private _staminaTimerId: ReturnType<typeof setInterval> | null = null;

    // ========== 关卡驱动状态NextLevel 驱动) ==========

    /** 当前关卡 ID */
    private _currentLevelId: string = '';

    /** 当前关卡编号(仅显示用,来自 NextLevelData.level */
    private _currentLevelNumber: number = 0;

    /** 下一关数据(来自 complete 接口返回),点击"下一关"时使用 */
    private _nextLevelData: NextLevelData | null = null;

    /** 分享模式下的关卡索引(仅分享模式使用) */
    private _shareLevelIndex: number = 0;

    /** 分享模式下每关最终提交内容,等整场结束后一次性提交 */
    private _shareSubmissions: Map<string, SubmitShareLevel> = new Map();

    /** 是否正在提交分享挑战结果 */
    private _isSubmittingShareResult: boolean = false;

    /** 本场分享挑战是否已经尝试拉取过用户头像昵称,避免结算流程重复弹窗 */
    private _hasRequestedShareUserInfo: boolean = false;

    /**
     * 页面首次加载时调用
     */
    onViewLoad(): void {
        console.log('[PageLevel] onViewLoad');

        // 本次进入答题页的会话通关数归零;普通 / 分享模式都重新开始计数
        this._sessionPassCount = 0;

        // 必须在任何可能改动 InputLayout/punchLayout 位置的逻辑之前记录原始位置
        this._captureActionOriginalPositions();

        const params = this.getParams();
        this._isShareMode = params?.shareMode === true;

        if (this._isShareMode) {
            this._shareLevelIndex = 0;
            this._shareSubmissions.clear();
            this._isSubmittingShareResult = false;
            this._hasRequestedShareUserInfo = false;
            this._activeShareCode = ShareManager.instance.shareCode;
            console.log('[PageLevel] 进入分享挑战模式');
        } else {
            this._activeShareCode = null;
            // 从 AuthManager 获取首关数据(由 PageLoading → game-data 提供)
            const nextLevel = AuthManager.instance.nextLevel;
            if (nextLevel) {
                this._currentLevelId = nextLevel.id;
                this._currentLevelNumber = nextLevel.level;
                console.log(`[PageLevel] 进入关卡: 第 ${nextLevel.level} 关 (${nextLevel.id})`);
            } else {
                console.warn('[PageLevel] 没有可用关卡');
            }
        }

        this._refreshModeUI();
        this.updateStaminaLabel();
        this.initIconSetting();
        this.initUnlockButtons();
        this.initSubmitButton();
        this.initPkNextLevelButton();
        this._initPassNodeState();

        // 异步加载关卡资源并调用进入关卡接口,完成后启动倒计时
        this._enterAndInitLevel().catch(err => {
            console.error('[PageLevel] 进入关卡失败:', err);
        });
    }

    /**
     * 页面每次显示时调用
     */
    onViewShow(): void {
        console.log('[PageLevel] onViewShow');

        // PageLevel 注册时 cache: true缓存实例会被复用。
        // 必须根据本次进入时携带的 params 重新派生 _isShareMode
        // 否则上一场分享挑战的状态会残留到下一次主线挑战进入。
        const params = this.getParams();
        const desiredShareMode = params?.shareMode === true;

        // 当前 ShareManager 中的 shareCode可能因为后台切到新的分享卡片而变化
        const latestShareCode = ShareManager.instance.shareCode;

        const modeChanged = desiredShareMode !== this._isShareMode;
        // 同样是分享模式,但 ShareManager 中的 shareCode 已经换了一份题单 —— 也必须重建会话
        const shareCodeChanged = desiredShareMode && latestShareCode !== this._activeShareCode;

        if (modeChanged || shareCodeChanged) {
            console.log(
                `[PageLevel] 检测到模式/分享码切换 mode:${this._isShareMode}->${desiredShareMode} ` +
                `code:${this._activeShareCode}->${latestShareCode},重新初始化关卡会话`,
            );
            this._reinitLevelSession(desiredShareMode);
            return;
        }

        // 上一次离场时如果停留在「答对后通关流程」_isTransitioning=true 由 showSuccess 置位、
        // 而 _applyLevelConfig 才会重置),缓存的 PageLevel 实例会保留完成态:
        //   - 输入格已填入正确答案
        //   - 提交按钮被 _isTransitioning 锁住,无法重新提交
        //   - 倒计时已停、谐音梗已揭示
        // 此时玩家从首页再次进入会看到一个无法操作的"死局"。必须把会话推进到下一关。
        // 注意:这只可能在主线模式发生 —— 分享模式下点 iconSetting / PassNode 的入口
        // 都会调用 ShareManager.clearShareMode + ViewManager.replace再次进入会被
        // 上面的 modeChanged / shareCodeChanged 分支拦截走 _reinitLevelSession。
        if (this._isTransitioning) {
            console.log('[PageLevel] 上次离场时停留在通关后状态,自动推进到下一关');
            this._resetPassNode();
            this._closeWrongModal();
            this._closeTimeoutModal();
            this._closeCommonModal();
            void this.goToNextLevel();
            return;
        }

        this._refreshModeUI();
        this.updateStaminaLabel();
        if (!this._isShareMode) {
            this._startStaminaRecoverTimer();
        }
    }

    /**
     * 跨模式切换时(例如分享挑战 → 主线挑战)重置会话状态并重新加载关卡。
     * 仅在 onViewShow 检测到模式发生变化时调用,避免对正常的同模式连续作答产生副作用。
     */
    private _reinitLevelSession(shareMode: boolean): void {
        this._isShareMode = shareMode;

        // 跨模式切换视为新的"本次进入"会话,赞美动画从最低档位重新开始
        this._sessionPassCount = 0;

        // 上一场可能遗留的弹窗 / 倒计时一并清掉,避免主线模式还看到分享态弹窗
        this._resetPassNode();
        this._closeWrongModal();
        this._closeTimeoutModal();
        this._closeCommonModal();
        this.stopCountdown();
        this._stopStaminaRecoverTimer();

        // 复位分享态相关字段(无论切换到哪个模式,分享态都不应残留)
        this._shareLevelIndex = 0;
        this._shareSubmissions.clear();
        this._isSubmittingShareResult = false;
        this._hasRequestedShareUserInfo = false;

        if (this._isShareMode) {
            this._activeShareCode = ShareManager.instance.shareCode;
            console.log(`[PageLevel] 切换到分享挑战模式 (shareCode=${this._activeShareCode})`);
        } else {
            this._activeShareCode = null;
            // 主线模式:从 AuthManager 拉取最新的 nextLevel
            this._nextLevelData = null;
            const nextLevel = AuthManager.instance.nextLevel;
            if (nextLevel) {
                this._currentLevelId = nextLevel.id;
                this._currentLevelNumber = nextLevel.level;
                console.log(`[PageLevel] 切换到主线挑战,进入关卡: 第 ${nextLevel.level} 关 (${nextLevel.id})`);
            } else {
                this._currentLevelId = '';
                this._currentLevelNumber = 0;
                console.warn('[PageLevel] 切换到主线挑战,但没有可用关卡');
            }
        }

        this._refreshModeUI();
        this.updateStaminaLabel();
        if (!this._isShareMode) {
            this._startStaminaRecoverTimer();
        }

        // 异步加载关卡资源并调用进入关卡接口,完成后启动倒计时
        this._enterAndInitLevel().catch(err => {
            console.error('[PageLevel] 模式切换后重新进入关卡失败:', err);
        });
    }

    /**
     * 页面隐藏时调用
     */
    onViewHide(): void {
        console.log('[PageLevel] onViewHide');
        this._stopStaminaRecoverTimer();
    }

    /**
     * 页面销毁时调用
     */
    onViewDestroy(): void {
        console.log('[PageLevel] onViewDestroy');
        this.clearInputNodes();
        this.clearPunchBlocks();
        this.stopCountdown();
        this._resetPassNode();
        this._closeWrongModal();
        this._closeTimeoutModal();
        this._closeCommonModal();
        this._stopStaminaRecoverTimer();

        // 清理事件监听
        this.iconSetting?.off(Node.EventType.TOUCH_END, this.onIconSettingClick, this);
        this.unLockTipsBtn?.off(Node.EventType.TOUCH_END);
        this.addTimeBtn?.off(Node.EventType.TOUCH_END);
        this.submitButton?.off(Node.EventType.TOUCH_END, this.onSubmitAnswer, this);
        this.pkNextLevelButton?.off(Node.EventType.TOUCH_END, this.onPkNextLevelClick, this);
    }

    /**
     * 进入关卡并初始化
     * 1. 加载关卡图片资源(从缓存或 NextLevelData
     * 2. 调用进入关卡接口(消耗体力,获取答案和线索)
     * 3. 启动倒计时
     */
    private async _enterAndInitLevel(): Promise<void> {
        let config: RuntimeLevelConfig | null = null;

        if (this._isShareMode) {
            // 分享模式:使用 ShareManager 的关卡数据
            config = await ShareManager.instance.ensureShareLevelReady(this._shareLevelIndex);
        } else {
            // 正常模式先尝试从缓存获取PageLoading 初始化时已加载首关)
            config = LevelDataManager.instance.getLevelConfig(this._currentLevelId);

            if (!config) {
                // 缓存未命中,从 nextLevel 数据加载complete 返回的下一关)
                const nextLevelData = this._nextLevelData ?? AuthManager.instance.nextLevel;
                if (nextLevelData && nextLevelData.id === this._currentLevelId) {
                    console.log(`[PageLevel] 关卡 ${this._currentLevelId} 资源未缓存,开始加载...`);
                    config = await LevelDataManager.instance.ensureLevelReady(nextLevelData);
                }
            }
        }

        if (!config) {
            console.warn(`[PageLevel] 没有找到关卡配置ID: ${this._currentLevelId}`);
            return;
        }

        // 非分享模式下,调用进入关卡接口获取答案和线索
        if (!this._isShareMode) {
            const enterData = await StaminaManager.instance.enterLevel(this._currentLevelId);
            if (!enterData) {
                // 进入关卡失败(可能是体力不足)
                const stamina = StaminaManager.instance.getStamina();
                if (stamina.current <= 0) {
                    ToastManager.show('体力不足,请等待恢复');
                    this._startStaminaRecoverTimer();
                } else {
                    ToastManager.show('进入关卡失败,请重试');
                }
                this.updateStaminaLabel();
                return;
            }


            // 用 enter 接口返回的数据更新关卡配置(填充答案和线索)
            LevelDataManager.instance.updateLevelDetails(
                this._currentLevelId,
                {
                    answer: enterData.answer,
                    image1Description: enterData.image1Description,
                    image2Description: enterData.image2Description,
                    punchline: enterData.punchline,
                    hint1: enterData.hint1,
                    hint2: enterData.hint2,
                    hint3: enterData.hint3,
                }
            );

            // 重新获取更新后的配置
            config = LevelDataManager.instance.getLevelConfig(this._currentLevelId);
            if (!config) {
                console.error('[PageLevel] 更新关卡详情后获取配置失败');
                return;
            }

            // 更新体力显示
            this.updateStaminaLabel();

            // 预加载下一关图片enter 返回的 preloadNextLevel
            if (enterData.preloadNextLevel) {
                LevelDataManager.instance.preloadLevel(enterData.preloadNextLevel);
            }
        }

        console.log(`[PageLevel] 初始化关卡 第${this._currentLevelNumber}关: ${config.name}`);