716 lines
28 KiB
Markdown
716 lines
28 KiB
Markdown
# 梗中作乐 V1.0 - 源代码文档(第 5 部分)
|
||
|
||
软件名称:梗中作乐
|
||
版本号:V1.0
|
||
|
||
---
|
||
|
||
## 第 5 部分:关卡核心逻辑
|
||
|
||
本部分展示关卡页 PageLevel 的核心实现:包含视图绑定、关卡数据加载、答题判定与通关流程驱动的关键代码。
|
||
|
||
### 5.1 关卡页 (assets/prefabs/PageLevel.ts,节选 1-700 行)
|
||
|
||
```typescript
|
||
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;
|
||
|
||
/**
|
||
* 通关赞美 spine(pose 节点)档位:[最小通关数, 动画名],倒序匹配。
|
||
* 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 position(prefab 摆放位),动画结束后用来回归 */
|
||
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}`);
|
||
```
|