2776 lines
95 KiB
TypeScript
2776 lines
95 KiB
TypeScript
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';
|
||
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 = 2000;
|
||
|
||
/** 图片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';
|
||
|
||
// ========== 节点引用 ==========
|
||
@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;
|
||
|
||
// ========== 配置属性 ==========
|
||
@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;
|
||
|
||
/** 通关页所用「已通关数量」(业务数据,给成就体系展示用) */
|
||
private _passCompletedLevelCount: number | 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');
|
||
|
||
// 必须在任何可能改动 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._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}`);
|
||
this._applyLevelConfig(config);
|
||
this.startCountdown();
|
||
}
|
||
|
||
/**
|
||
* 应用关卡配置(通用初始化逻辑)
|
||
*/
|
||
private _applyLevelConfig(config: RuntimeLevelConfig): void {
|
||
this._currentConfig = config;
|
||
|
||
// 重置关卡切换状态,允许再次提交
|
||
this._isTransitioning = false;
|
||
|
||
// 重置倒计时状态
|
||
this._isTimeUp = false;
|
||
this._countdown = config.timeLimit ?? 60;
|
||
|
||
// 设置主图(图片1)
|
||
this.setMainImage(config.spriteFrame1);
|
||
|
||
// 设置图片2
|
||
this.setMainImage2(config.spriteFrame2);
|
||
|
||
// 设置图片描述
|
||
this.setImageDescriptions(config.image1Description, config.image2Description);
|
||
|
||
// 设置关卡标题
|
||
this.updateTitleLevelLabel();
|
||
this.updatePkLevelProgressLabel();
|
||
this.updatePkNextLevelButtonText();
|
||
|
||
// 隐藏包袱答案,通关后再按 punchline 展示
|
||
this.hidePunchline();
|
||
|
||
// 设置线索1(默认解锁,如果有的话)
|
||
if (config.clue1) {
|
||
this.setClue(1, config.clue1);
|
||
}
|
||
|
||
// 重置线索解锁进度
|
||
this._nextClueIndex = 2;
|
||
|
||
// 线索2、3 保持显示,写入"待解锁"占位文案
|
||
this.setClue(2, '待解锁');
|
||
this.setClue(3, '待解锁');
|
||
|
||
// 显示解锁按钮(单个统一按钮)
|
||
this.showUnlockButton();
|
||
this._refreshTipsModeUI();
|
||
|
||
// 根据答案字数创建输入格
|
||
if (config.answer) {
|
||
this.createInputBlocks(config.answer);
|
||
}
|
||
|
||
// 更新倒计时显示
|
||
this.updateClockLabel();
|
||
|
||
// 分享模式下预加载下一关
|
||
if (this._isShareMode) {
|
||
const nextIndex = this._shareLevelIndex + 1;
|
||
if (nextIndex < ShareManager.instance.getShareLevelCount()) {
|
||
ShareManager.instance.ensureShareLevelReady(nextIndex).catch(() => {});
|
||
}
|
||
}
|
||
// 正常模式的预加载在 enter 返回 preloadNextLevel 时已处理
|
||
|
||
console.log(`[PageLevel] 初始化关卡 第${this._currentLevelNumber}关, 答案长度: ${Array.from(config.answer ?? '').length}`);
|
||
}
|
||
|
||
/**
|
||
* 根据答案字数创建输入格
|
||
*/
|
||
private createInputBlocks(answer: string): void {
|
||
if (!this.inputLayout) {
|
||
console.error('[PageLevel] inputLayout 未设置');
|
||
return;
|
||
}
|
||
|
||
const chars = Array.from(answer);
|
||
const template = this.getInputTemplateNode();
|
||
if (!template) {
|
||
console.error('[PageLevel] InputLayout 下未找到默认 Input 节点');
|
||
return;
|
||
}
|
||
if (this.inputTemplate && this.inputTemplate !== template) {
|
||
this.inputTemplate.active = false;
|
||
}
|
||
|
||
this.clearInputNodes();
|
||
this.removeUnexpectedInputLayoutChildren(template);
|
||
this._lastAutoSubmittedAnswer = '';
|
||
|
||
for (let i = 0; i < chars.length; i++) {
|
||
const inputNode = i === 0 ? template : instantiate(template);
|
||
inputNode.active = true;
|
||
inputNode.name = `Input_${i + 1}`;
|
||
inputNode.setPosition(PageLevel.ZERO_POS);
|
||
|
||
const editBox = inputNode.getComponent(EditBox);
|
||
if (editBox) {
|
||
editBox.placeholder = '';
|
||
// 不限制单格 maxLength:iOS 拼音 / 日韩 IME 在选词前需要键入比目标字数更长的拼写串,
|
||
// 例如答案"你好"(2 字)需键入"nihao"(5 字符)才能上屏;若 maxLength=2 会在第三个
|
||
// 拼音字符就被原生键盘截断,用户连选词都做不到。最终长度限制由 distributeInputText
|
||
// 在 EDITING_DID_ENDED 时裁剪到每格 1 字(见 applyInputTextToBlocks)。
|
||
editBox.maxLength = -1;
|
||
editBox.string = '';
|
||
editBox.node.on(EditBox.EventType.EDITING_DID_BEGAN, this.onInputEditingBegan, this);
|
||
editBox.node.on(EditBox.EventType.TEXT_CHANGED, this.onInputTextChanged, this);
|
||
editBox.node.on(EditBox.EventType.EDITING_DID_ENDED, this.onInputEditingEnded, this);
|
||
}
|
||
|
||
if (inputNode.parent !== this.inputLayout) {
|
||
this.inputLayout.addChild(inputNode);
|
||
}
|
||
this._inputNodes.push(inputNode);
|
||
}
|
||
|
||
console.log(`[PageLevel] 创建输入格,答案长度: ${chars.length}`);
|
||
}
|
||
|
||
/**
|
||
* 清理所有输入框节点
|
||
*/
|
||
private clearInputNodes(): void {
|
||
const template = this.getInputTemplateNode();
|
||
|
||
for (const node of this._inputNodes) {
|
||
if (node.isValid) {
|
||
const editBox = node.getComponent(EditBox);
|
||
if (editBox) {
|
||
editBox.node.off(EditBox.EventType.EDITING_DID_BEGAN, this.onInputEditingBegan, this);
|
||
editBox.node.off(EditBox.EventType.TEXT_CHANGED, this.onInputTextChanged, this);
|
||
editBox.node.off(EditBox.EventType.EDITING_DID_ENDED, this.onInputEditingEnded, this);
|
||
editBox.string = '';
|
||
}
|
||
|
||
if (node === template) {
|
||
node.active = false;
|
||
} else {
|
||
node.removeFromParent();
|
||
node.destroy();
|
||
}
|
||
}
|
||
}
|
||
this._inputNodes = [];
|
||
}
|
||
|
||
private getInputTemplateNode(): Node | null {
|
||
if (this._inputTemplateNode?.isValid) return this._inputTemplateNode;
|
||
|
||
this._inputTemplateNode = this.inputLayout?.children.find(node => !!node.getComponent(EditBox)) ?? this.inputTemplate ?? null;
|
||
return this._inputTemplateNode;
|
||
}
|
||
|
||
private removeUnexpectedInputLayoutChildren(template: Node): void {
|
||
if (!this.inputLayout) return;
|
||
|
||
for (const child of [...this.inputLayout.children]) {
|
||
if (child !== template) {
|
||
child.removeFromParent();
|
||
child.destroy();
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取所有输入框的值
|
||
*/
|
||
getInputValues(): string[] {
|
||
if (this._inputNodes.length === 0) return [];
|
||
return this._inputNodes.map(node => (node.getComponent(EditBox)?.string ?? '').trim());
|
||
}
|
||
|
||
/**
|
||
* 获取拼接后的答案字符串
|
||
*/
|
||
getAnswer(): string {
|
||
if (this._inputNodes.length === 0) return '';
|
||
return this.getInputValues().join('').trim();
|
||
}
|
||
|
||
// ========== EditBox 事件回调 ==========
|
||
|
||
private onInputEditingBegan(editBox: EditBox): void {
|
||
if (this._isSyncingInputText) return;
|
||
|
||
const inputIndex = this._inputNodes.findIndex(node => node === editBox.node);
|
||
if (inputIndex < 0) return;
|
||
|
||
this._editingInputIndex = inputIndex;
|
||
}
|
||
|
||
/**
|
||
* 输入框文本变化回调
|
||
*/
|
||
private onInputTextChanged(_editBox: EditBox): void {
|
||
this._lastAutoSubmittedAnswer = '';
|
||
}
|
||
|
||
/**
|
||
* 输入框编辑结束回调
|
||
*/
|
||
private onInputEditingEnded(editBox: EditBox): void {
|
||
if (this._isSyncingInputText) return;
|
||
|
||
const inputIndex = this._editingInputIndex >= 0
|
||
? this._editingInputIndex
|
||
: this._inputNodes.findIndex(node => node === editBox.node);
|
||
if (inputIndex < 0) return;
|
||
|
||
this.applyInputTextToBlocks(editBox.string, inputIndex);
|
||
this._editingInputIndex = -1;
|
||
this.tryAutoSubmitAnswer();
|
||
}
|
||
|
||
private applyInputTextToBlocks(text: string, inputIndex: number): void {
|
||
const chars = Array.from(text.trim());
|
||
if (chars.length <= 1) {
|
||
this.setInputBlockText(inputIndex, chars[0] ?? '');
|
||
return;
|
||
}
|
||
|
||
this.distributeInputText(text, inputIndex);
|
||
}
|
||
|
||
private setInputBlockText(index: number, text: string): void {
|
||
const editBox = this._inputNodes[index]?.getComponent(EditBox);
|
||
if (!editBox) return;
|
||
|
||
this._isSyncingInputText = true;
|
||
try {
|
||
editBox.string = Array.from(text.trim())[0] ?? '';
|
||
} finally {
|
||
this._isSyncingInputText = false;
|
||
}
|
||
}
|
||
|
||
private distributeInputText(text: string, startIndex: number = 0): void {
|
||
const chars = Array.from(text.trim());
|
||
const safeStartIndex = Math.max(0, Math.min(startIndex, this._inputNodes.length - 1));
|
||
this._isSyncingInputText = true;
|
||
|
||
try {
|
||
for (let i = safeStartIndex; i < this._inputNodes.length; i++) {
|
||
const editBox = this._inputNodes[i].getComponent(EditBox);
|
||
if (editBox) {
|
||
editBox.string = chars[i - safeStartIndex] ?? '';
|
||
}
|
||
}
|
||
} finally {
|
||
this._isSyncingInputText = false;
|
||
}
|
||
}
|
||
|
||
private clearInputText(): void {
|
||
this._isSyncingInputText = true;
|
||
|
||
try {
|
||
for (const node of this._inputNodes) {
|
||
const editBox = node.getComponent(EditBox);
|
||
if (editBox) {
|
||
editBox.string = '';
|
||
}
|
||
}
|
||
this._lastAutoSubmittedAnswer = '';
|
||
} finally {
|
||
this._isSyncingInputText = false;
|
||
}
|
||
}
|
||
|
||
private tryAutoSubmitAnswer(): void {
|
||
if (!this._currentConfig || this._isTransitioning) return;
|
||
|
||
const values = this.getInputValues();
|
||
const isFilled = values.length === Array.from(this._currentConfig.answer ?? '').length && values.every(value => value.length === 1);
|
||
if (!isFilled) {
|
||
this._lastAutoSubmittedAnswer = '';
|
||
return;
|
||
}
|
||
|
||
const answer = values.join('');
|
||
if (answer === this._lastAutoSubmittedAnswer) return;
|
||
|
||
this._lastAutoSubmittedAnswer = answer;
|
||
this.onSubmitAnswer();
|
||
}
|
||
|
||
private normalizeAnswerForCompare(answer: string): string {
|
||
return answer.trim().toLocaleLowerCase();
|
||
}
|
||
|
||
// ========== IconSetting 按钮相关 ==========
|
||
|
||
/**
|
||
* 初始化 IconSetting 按钮事件
|
||
*/
|
||
private initIconSetting(): void {
|
||
if (!this.iconSetting) {
|
||
console.warn('[PageLevel] iconSetting 节点未设置');
|
||
return;
|
||
}
|
||
|
||
const button = this.iconSetting.getComponent(Button);
|
||
if (!button) {
|
||
console.warn('[PageLevel] iconSetting 节点缺少 Button 组件');
|
||
return;
|
||
}
|
||
|
||
this.iconSetting.on(Node.EventType.TOUCH_END, this.onIconSettingClick, this);
|
||
console.log('[PageLevel] IconSetting 按钮事件已绑定');
|
||
}
|
||
|
||
/**
|
||
* IconSetting 按钮点击回调
|
||
*/
|
||
private onIconSettingClick(): void {
|
||
console.log('[PageLevel] IconSetting 点击,返回主页');
|
||
AudioManager.instance.playButtonClick();
|
||
|
||
// 离开 PageLevel 时把所有挂在 Canvas 上的关卡级弹窗一起清掉。
|
||
// WrongModal / TimeoutModal / CommonModal 都是 addChild 到 this.node.parent
|
||
// 也就是 PageLevel 的兄弟节点,PageLevel 被 ViewManager 隐藏后它们并不会自动消失,
|
||
// 否则会孤儿地盖在 PageHome 上。
|
||
// PassNode 是 PageLevel 自身子节点(不是弹窗),用 _resetPassNode 同步归位即可。
|
||
this._resetPassNode();
|
||
this._closeWrongModal();
|
||
this._closeTimeoutModal();
|
||
this._closeCommonModal();
|
||
|
||
// 分享模式下栈中没有 PageHome,需要清除分享状态并直接打开首页
|
||
if (this._isShareMode) {
|
||
ShareManager.instance.clearShareMode();
|
||
ViewManager.instance.replace('PageHome');
|
||
} else {
|
||
ViewManager.instance.back();
|
||
}
|
||
}
|
||
|
||
// ========== 线索相关方法 ==========
|
||
|
||
/**
|
||
* 获取线索节点
|
||
*/
|
||
private getTipsItem(index: number): Node | null {
|
||
switch (index) {
|
||
case 1: return this.tipsItem1;
|
||
case 2: return this.tipsItem2;
|
||
case 3: return this.tipsItem3;
|
||
default: return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 设置线索内容
|
||
*/
|
||
private setClue(index: number, content: string): void {
|
||
const tipsItem = this.getTipsItem(index);
|
||
if (!tipsItem) return;
|
||
|
||
const label = this.getTipsLabel(tipsItem);
|
||
if (label) {
|
||
label.string = `提示${index}:${content}`;
|
||
console.log(`[PageLevel] 设置线索${index}: ${content}`);
|
||
}
|
||
}
|
||
|
||
private getTipsLabel(tipsItem: Node): Label | null {
|
||
const directLabel = tipsItem.getChildByName('TipsLabel')?.getComponent(Label);
|
||
if (directLabel) return directLabel;
|
||
|
||
return tipsItem.getChildByName('Content')?.getChildByName('TipsLabel')?.getComponent(Label) ?? null;
|
||
}
|
||
|
||
/**
|
||
* 显示线索
|
||
*/
|
||
private showClue(index: number): void {
|
||
const tipsItem = this.getTipsItem(index);
|
||
if (tipsItem) {
|
||
tipsItem.active = true;
|
||
console.log(`[PageLevel] 显示线索${index}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 隐藏线索
|
||
*/
|
||
private hideClue(index: number): void {
|
||
const tipsItem = this.getTipsItem(index);
|
||
if (tipsItem) {
|
||
tipsItem.active = false;
|
||
console.log(`[PageLevel] 隐藏线索${index}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 显示解锁按钮
|
||
*/
|
||
private showUnlockButton(_index?: number): void {
|
||
if (this.unLockTipsBtn) {
|
||
this.unLockTipsBtn.active = true;
|
||
this.setUnlockButtonText(PageLevel.UNLOCK_BUTTON_CLUE_TEXT);
|
||
console.log('[PageLevel] 显示解锁按钮');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 线索解锁出现动画:透明度淡入 + 轻微缩放回弹(backOut)
|
||
* 设计:淡入与缩放并行、同时长,用 backOut 轻微过冲 ~5% 带来"弹出"感但不夸张
|
||
*/
|
||
private playClueAppearAnimation(tipsItem: Node): void {
|
||
// 透明度控制用 UIOpacity,避免污染子节点颜色
|
||
let uiOpacity = tipsItem.getComponent(UIOpacity);
|
||
if (!uiOpacity) {
|
||
uiOpacity = tipsItem.addComponent(UIOpacity);
|
||
}
|
||
|
||
// 停掉任何进行中的动画,保证重复点击/快速切换时状态一致
|
||
Tween.stopAllByTarget(uiOpacity);
|
||
Tween.stopAllByTarget(tipsItem);
|
||
|
||
// 起始态
|
||
uiOpacity.opacity = 0;
|
||
tipsItem.setScale(
|
||
PageLevel.CLUE_APPEAR_START_SCALE,
|
||
PageLevel.CLUE_APPEAR_START_SCALE,
|
||
1
|
||
);
|
||
|
||
// 透明度:线性淡入即可(淡入在感知上本来就不需要额外缓动)
|
||
tween(uiOpacity)
|
||
.to(PageLevel.CLUE_APPEAR_DURATION, { opacity: 255 })
|
||
.start();
|
||
|
||
// 缩放:backOut —— 轻微过冲再回落到 1.0,是"弹出"感的关键
|
||
tween(tipsItem)
|
||
.to(
|
||
PageLevel.CLUE_APPEAR_DURATION,
|
||
{ scale: new Vec3(1, 1, 1) },
|
||
{ easing: 'backOut' }
|
||
)
|
||
.start();
|
||
}
|
||
|
||
|
||
/**
|
||
* 更新底部线索/答案按钮文案
|
||
*/
|
||
private setUnlockButtonText(text: string): void {
|
||
const label = this.unLockTipsBtn?.getChildByName('Label')?.getComponent(Label);
|
||
if (label) {
|
||
label.string = text;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 初始化解锁按钮事件
|
||
*/
|
||
private initUnlockButtons(): void {
|
||
if (this.unLockTipsBtn) {
|
||
this.unLockTipsBtn.on(Node.EventType.TOUCH_END, this.onUnlockClue, this);
|
||
}
|
||
|
||
if (this.addTimeBtn) {
|
||
this.addTimeBtn.on(Node.EventType.TOUCH_END, this.onAddTime, this);
|
||
}
|
||
|
||
console.log('[PageLevel] 解锁按钮事件已绑定');
|
||
}
|
||
|
||
/**
|
||
* 初始化提交按钮事件
|
||
*/
|
||
private initSubmitButton(): void {
|
||
if (!this.submitButton) {
|
||
console.warn('[PageLevel] submitButton 节点未设置');
|
||
return;
|
||
}
|
||
|
||
this.submitButton.on(Node.EventType.TOUCH_END, this.onSubmitAnswer, this);
|
||
console.log('[PageLevel] 提交按钮事件已绑定');
|
||
}
|
||
|
||
private initPkNextLevelButton(): void {
|
||
if (!this.pkNextLevelButton) {
|
||
return;
|
||
}
|
||
|
||
this.pkNextLevelButton.on(Node.EventType.TOUCH_END, this.onPkNextLevelClick, this);
|
||
console.log('[PageLevel] PK 下一题按钮事件已绑定');
|
||
}
|
||
|
||
private onPkNextLevelClick(): void {
|
||
if (!this._isShareMode) {
|
||
return;
|
||
}
|
||
if (this._isSubmittingShareResult) {
|
||
return;
|
||
}
|
||
if (this._isTransitioning) {
|
||
return;
|
||
}
|
||
|
||
this.playClickSound();
|
||
this._showShareNextConfirmModal(() => {
|
||
this._recordCurrentShareSubmission();
|
||
void this.goToNextLevel();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 点击解锁线索(顺序解锁:先线索2,再线索3;全部解锁后切换为查看答案入口)
|
||
*/
|
||
private onUnlockClue(): void {
|
||
// 全部已解锁后,点击"查看答案":自动填入正确答案并走通关流程
|
||
if (this._nextClueIndex > 3) {
|
||
if (this._isTransitioning) return;
|
||
|
||
const answer = this._currentConfig?.answer;
|
||
if (!answer) {
|
||
ToastManager.show('答案暂未配置');
|
||
return;
|
||
}
|
||
|
||
this.playClickSound();
|
||
console.log('[PageLevel] 点击查看答案,自动填充答案并触发通关流程');
|
||
|
||
// 填充答案到输入格(distributeInputText 内部会用 _isSyncingInputText 阻止 EditBox 事件回调)
|
||
this.distributeInputText(answer);
|
||
|
||
// 走提交流程(答案命中 → showSuccess → 通关弹窗)
|
||
this.onSubmitAnswer();
|
||
return;
|
||
}
|
||
|
||
if (!this._currentConfig) return;
|
||
|
||
const index = this._nextClueIndex;
|
||
const clueContent = index === 2 ? this._currentConfig.clue2 : this._currentConfig.clue3;
|
||
if (!clueContent) {
|
||
ToastManager.show('该提示暂未配置');
|
||
return;
|
||
}
|
||
|
||
this.playClickSound();
|
||
this.setClue(index, clueContent);
|
||
|
||
// 解锁线索后播放"出现"动画,让内容刷新不突兀
|
||
const tipsItem = this.getTipsItem(index);
|
||
if (tipsItem) {
|
||
this.playClueAppearAnimation(tipsItem);
|
||
}
|
||
|
||
// 推进到下一条待解锁线索
|
||
this._nextClueIndex++;
|
||
|
||
// 全部解锁完毕后不隐藏按钮,改为查看答案入口
|
||
if (this._nextClueIndex > 3) {
|
||
this.setUnlockButtonText(PageLevel.UNLOCK_BUTTON_ANSWER_TEXT);
|
||
}
|
||
|
||
console.log(`[PageLevel] 解锁线索${index}`);
|
||
}
|
||
|
||
/**
|
||
* 点击增加时间按钮(倒计时增加 60 秒)
|
||
*/
|
||
private onAddTime(): void {
|
||
if (this._isTimeUp) {
|
||
ToastManager.show('时间已结束,无法增加');
|
||
return;
|
||
}
|
||
|
||
const wasUrgent = this._countdown <= PageLevel.CLOCK_URGENT_THRESHOLD;
|
||
this._countdown += 60;
|
||
|
||
// 从紧迫态跳回安全区:停掉残留脉冲并复位 scale(updateClockLabel 会负责把颜色改回)
|
||
if (wasUrgent && this._countdown > PageLevel.CLOCK_URGENT_THRESHOLD && this.clockLabel) {
|
||
Tween.stopAllByTarget(this.clockLabel.node);
|
||
this.clockLabel.node.setScale(1, 1, 1);
|
||
}
|
||
|
||
this.updateClockLabel();
|
||
this.playClickSound();
|
||
ToastManager.show('已成功增加60秒!');
|
||
console.log(`[PageLevel] 增加60秒倒计时,当前剩余: ${this._countdown}s`);
|
||
}
|
||
|
||
// ========== 主图相关方法 ==========
|
||
|
||
/**
|
||
* 设置主图(图片1)
|
||
*/
|
||
private setMainImage(spriteFrame: SpriteFrame | null): void {
|
||
if (!this.mainImage) return;
|
||
|
||
const sprite = this.mainImage.getComponent(Sprite);
|
||
if (sprite && spriteFrame) {
|
||
sprite.spriteFrame = spriteFrame;
|
||
this.applyMainImageRoundedCorner(sprite);
|
||
console.log('[PageLevel] 设置主图1');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 设置图片2
|
||
*/
|
||
private setMainImage2(spriteFrame: SpriteFrame | null): void {
|
||
if (!this.mainImage2) return;
|
||
|
||
const sprite = this.mainImage2.getComponent(Sprite);
|
||
if (sprite && spriteFrame) {
|
||
sprite.spriteFrame = spriteFrame;
|
||
this.applyMainImageRoundedCorner(sprite);
|
||
console.log('[PageLevel] 设置主图2');
|
||
}
|
||
}
|
||
|
||
private applyMainImageRoundedCorner(sprite: Sprite): void {
|
||
if (!this.roundedSpriteEffect) {
|
||
return;
|
||
}
|
||
|
||
const uiTransform = sprite.node.getComponent(UITransform);
|
||
if (!uiTransform) {
|
||
return;
|
||
}
|
||
|
||
applyRoundedCorner(
|
||
sprite,
|
||
this.roundedSpriteEffect,
|
||
uiTransform.width,
|
||
uiTransform.height,
|
||
this.mainImageCornerRadius
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 设置图片描述文本
|
||
*/
|
||
private setImageDescriptions(desc1: string | null, desc2: string | null): void {
|
||
if (this.image1DescLabel) {
|
||
this.image1DescLabel.string = desc1 ?? '';
|
||
}
|
||
if (this.image2DescLabel) {
|
||
this.image2DescLabel.string = desc2?.trim() ? desc2 : PageLevel.DEFAULT_IMAGE2_DESCRIPTION;
|
||
}
|
||
}
|
||
|
||
private updateTitleLevelLabel(): void {
|
||
const titleText = `第 ${this.getDisplayLevelNumber()} 关`;
|
||
|
||
if (this.titleLevelLabel) {
|
||
this.titleLevelLabel.string = titleText;
|
||
}
|
||
|
||
if (this.pkTitleLevelLabel) {
|
||
this.pkTitleLevelLabel.string = titleText;
|
||
}
|
||
}
|
||
|
||
private updatePkLevelProgressLabel(): void {
|
||
if (!this.pkLevelProgressLabel) {
|
||
return;
|
||
}
|
||
|
||
if (!this._isShareMode) {
|
||
this.pkLevelProgressLabel.string = '';
|
||
return;
|
||
}
|
||
|
||
const totalLevels = ShareManager.instance.getShareLevelCount();
|
||
const currentIndex = this._shareLevelIndex + 1;
|
||
this.pkLevelProgressLabel.string = totalLevels > 0
|
||
? `${currentIndex}/${totalLevels}`
|
||
: `${currentIndex}`;
|
||
}
|
||
|
||
private updatePkNextLevelButtonText(): void {
|
||
if (!this.pkNextLevelButton) {
|
||
return;
|
||
}
|
||
|
||
const label = this.pkNextLevelButton.getChildByName('Label')?.getComponent(Label);
|
||
if (!label) {
|
||
return;
|
||
}
|
||
|
||
label.string = this._isFinalShareLevel()
|
||
? PageLevel.SHARE_SUBMIT_BUTTON_TEXT
|
||
: PageLevel.SHARE_NEXT_BUTTON_TEXT;
|
||
}
|
||
|
||
private _refreshModeUI(): void {
|
||
const isPkMode = this._isShareMode;
|
||
|
||
if (this.bgNode) {
|
||
this.bgNode.active = !isPkMode;
|
||
}
|
||
if (this.pkBgNode) {
|
||
this.pkBgNode.active = isPkMode;
|
||
}
|
||
if (this.titleLevelNode) {
|
||
this.titleLevelNode.active = !isPkMode;
|
||
}
|
||
if (this.pkTitleLevelNode) {
|
||
this.pkTitleLevelNode.active = isPkMode;
|
||
}
|
||
if (this.liveNode) {
|
||
this.liveNode.active = !isPkMode;
|
||
}
|
||
this._refreshTipsModeUI();
|
||
if (this.pkLevelProgressNode) {
|
||
this.pkLevelProgressNode.active = isPkMode;
|
||
}
|
||
if (this.bottomLayoutNode) {
|
||
this.bottomLayoutNode.active = !isPkMode;
|
||
}
|
||
if (this.pkNextLevelButton) {
|
||
this.pkNextLevelButton.active = isPkMode;
|
||
}
|
||
|
||
this.updateTitleLevelLabel();
|
||
this.updatePkLevelProgressLabel();
|
||
this.updatePkNextLevelButtonText();
|
||
}
|
||
|
||
private _refreshTipsModeUI(): void {
|
||
if (this.tipsLayout) {
|
||
this.tipsLayout.active = true;
|
||
}
|
||
|
||
if (this._isShareMode) {
|
||
this._setTipsLayoutEnabled(false);
|
||
this.showClue(1);
|
||
this._placeShareModeTip1();
|
||
this.hideClue(2);
|
||
this.hideClue(3);
|
||
if (this.unLockTipsBtn) {
|
||
this.unLockTipsBtn.active = false;
|
||
}
|
||
return;
|
||
}
|
||
|
||
this._setTipsLayoutEnabled(true);
|
||
this.showClue(1);
|
||
this.showClue(2);
|
||
this.showClue(3);
|
||
if (this.unLockTipsBtn) {
|
||
this.unLockTipsBtn.active = true;
|
||
}
|
||
}
|
||
|
||
private _setTipsLayoutEnabled(enabled: boolean): void {
|
||
const layout = this.tipsLayout?.getComponent(Layout);
|
||
if (layout) {
|
||
layout.enabled = enabled;
|
||
}
|
||
}
|
||
|
||
private _placeShareModeTip1(): void {
|
||
if (!this.tipsItem1) {
|
||
return;
|
||
}
|
||
|
||
this.tipsItem1.setPosition(
|
||
this.tipsItem1.position.x,
|
||
PageLevel.SHARE_MODE_TIP1_Y,
|
||
this.tipsItem1.position.z,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 服务端 level 使用 sortOrder,首关可能为 0;页面展示统一转成从 1 开始的关卡序号
|
||
*/
|
||
private getDisplayLevelNumber(): number {
|
||
if (this._isShareMode) {
|
||
return this._shareLevelIndex + 1;
|
||
}
|
||
|
||
return Math.max(1, this._currentLevelNumber + 1);
|
||
}
|
||
|
||
/**
|
||
* 设置谐音梗说明(通关后逐字展示,未通关时传 null 隐藏)
|
||
*/
|
||
private setPunchline(punchline: string | null): void {
|
||
if (!this.punchLayout) return;
|
||
|
||
const chars = Array.from(punchline ?? '');
|
||
if (chars.length === 0) {
|
||
this.hidePunchline();
|
||
return;
|
||
}
|
||
|
||
const template = this.getPunchBlockTemplateNode();
|
||
if (!template) {
|
||
console.error('[PageLevel] punchLayout 下未找到默认 block 节点');
|
||
return;
|
||
}
|
||
|
||
this.clearPunchBlocks();
|
||
this.removeUnexpectedPunchLayoutChildren(template);
|
||
this.punchLayout.active = true;
|
||
|
||
for (let i = 0; i < chars.length; i++) {
|
||
const blockNode = i === 0 ? template : instantiate(template);
|
||
blockNode.active = true;
|
||
blockNode.name = `block_${i + 1}`;
|
||
blockNode.setPosition(PageLevel.ZERO_POS);
|
||
|
||
const label = this.getPunchBlockLabel(blockNode);
|
||
if (label) {
|
||
label.node.active = true;
|
||
label.enabled = true;
|
||
label.string = chars[i];
|
||
console.log(`[PageLevel] 设置包袱块${i + 1}: ${chars[i]}`);
|
||
} else {
|
||
console.warn(`[PageLevel] 包袱块${i + 1} 未找到 Label 组件`);
|
||
}
|
||
|
||
if (blockNode.parent !== this.punchLayout) {
|
||
this.punchLayout.addChild(blockNode);
|
||
}
|
||
this._punchBlockNodes.push(blockNode);
|
||
}
|
||
|
||
// 揭示谐音梗:InputLayout 回到原位、divider 与 punchLayout 带动画出现
|
||
this._playPunchRevealAnimation();
|
||
}
|
||
|
||
private clearPunchBlocks(): void {
|
||
const template = this.getPunchBlockTemplateNode();
|
||
|
||
for (const node of this._punchBlockNodes) {
|
||
if (node.isValid) {
|
||
const label = this.getPunchBlockLabel(node);
|
||
if (label) {
|
||
label.string = '';
|
||
}
|
||
|
||
if (node === template) {
|
||
node.active = false;
|
||
} else {
|
||
node.removeFromParent();
|
||
node.destroy();
|
||
}
|
||
}
|
||
}
|
||
this._punchBlockNodes = [];
|
||
}
|
||
|
||
private hidePunchline(): void {
|
||
if (!this.punchLayout) return;
|
||
|
||
const template = this.getPunchBlockTemplateNode();
|
||
if (!template) {
|
||
this.punchLayout.active = false;
|
||
this._applyNoPunchLayout(false);
|
||
return;
|
||
}
|
||
|
||
this.clearPunchBlocks();
|
||
this.removeUnexpectedPunchLayoutChildren(template);
|
||
this.punchLayout.active = false;
|
||
template.active = false;
|
||
template.name = 'block';
|
||
|
||
const label = this.getPunchBlockLabel(template);
|
||
if (label) {
|
||
label.node.active = true;
|
||
label.enabled = true;
|
||
label.string = '';
|
||
}
|
||
|
||
this._punchBlockNodes = [];
|
||
|
||
// 无梗态布局:InputLayout 居中、分割线与 punchLayout 隐藏
|
||
this._applyNoPunchLayout(false);
|
||
}
|
||
|
||
private removeUnexpectedPunchLayoutChildren(template: Node): void {
|
||
if (!this.punchLayout) return;
|
||
|
||
for (const child of [...this.punchLayout.children]) {
|
||
if (child !== template) {
|
||
child.removeFromParent();
|
||
child.destroy();
|
||
}
|
||
}
|
||
}
|
||
|
||
private getPunchBlockTemplateNode(): Node | null {
|
||
if (this._punchBlockTemplateNode?.isValid) return this._punchBlockTemplateNode;
|
||
|
||
this._punchBlockTemplateNode = this.punchLayout?.children[0] ?? null;
|
||
return this._punchBlockTemplateNode;
|
||
}
|
||
|
||
private getPunchBlockLabel(blockNode: Node): Label | null {
|
||
return this.findLabelInNode(blockNode);
|
||
}
|
||
|
||
private findLabelInNode(node: Node): Label | null {
|
||
const label = node.getComponent(Label);
|
||
if (label) return label;
|
||
|
||
for (const child of node.children) {
|
||
const childLabel = this.findLabelInNode(child);
|
||
if (childLabel) return childLabel;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
// ========== Action 区域布局 / 谐音梗揭示动画 ==========
|
||
|
||
/**
|
||
* 记录 InputLayout / punchLayout 的原始位置,计算"无梗居中态"下 InputLayout 的 Y
|
||
* 只在 onViewLoad 最早期执行一次,后续所有位移都以此为基准
|
||
*/
|
||
private _captureActionOriginalPositions(): void {
|
||
if (this.inputLayout && !this._inputLayoutOriginalPos) {
|
||
this._inputLayoutOriginalPos = this.inputLayout.position.clone();
|
||
}
|
||
if (this.punchLayout && !this._punchLayoutOriginalPos) {
|
||
this._punchLayoutOriginalPos = this.punchLayout.position.clone();
|
||
}
|
||
|
||
// 居中 Y:InputLayout 原始 Y 与 punchLayout 原始 Y 的中点
|
||
if (this._inputLayoutOriginalPos && this._punchLayoutOriginalPos) {
|
||
this._inputLayoutCenteredY = (this._inputLayoutOriginalPos.y + this._punchLayoutOriginalPos.y) / 2;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 应用"无梗态"布局:InputLayout 居中,分割线 + punchLayout 隐藏
|
||
* @param animated 为 true 时 InputLayout 走动画移动到居中位;false 时静默到位(关卡加载)
|
||
*/
|
||
private _applyNoPunchLayout(animated: boolean): void {
|
||
// 分割线:隐藏(用 active,避免残留一条线)
|
||
if (this.punchDivider) {
|
||
Tween.stopAllByTarget(this.punchDivider);
|
||
this.punchDivider.active = false;
|
||
}
|
||
|
||
// punchLayout:active 由调用方/setPunchline 控制,此处确保透明度复位
|
||
if (this.punchLayout) {
|
||
Tween.stopAllByTarget(this.punchLayout);
|
||
const uiOpacity = this.punchLayout.getComponent(UIOpacity);
|
||
if (uiOpacity) {
|
||
Tween.stopAllByTarget(uiOpacity);
|
||
uiOpacity.opacity = 255;
|
||
}
|
||
this.punchLayout.setScale(1, 1, 1);
|
||
}
|
||
|
||
// InputLayout:移动到居中 Y
|
||
if (this.inputLayout && this._inputLayoutOriginalPos && this._inputLayoutCenteredY !== null) {
|
||
Tween.stopAllByTarget(this.inputLayout);
|
||
const targetPos = new Vec3(
|
||
this._inputLayoutOriginalPos.x,
|
||
this._inputLayoutCenteredY,
|
||
this._inputLayoutOriginalPos.z
|
||
);
|
||
if (animated) {
|
||
tween(this.inputLayout)
|
||
.to(PageLevel.PUNCH_REVEAL_DURATION, { position: targetPos }, { easing: 'cubicOut' })
|
||
.start();
|
||
} else {
|
||
this.inputLayout.setPosition(targetPos);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 播放谐音梗揭示动画:
|
||
* 1) InputLayout 从居中位平滑回到原始位(cubicOut,让出下方空间)
|
||
* 2) divider 淡入(仅透明度)
|
||
* 3) punchLayout 淡入 + backOut 缩放回弹,延迟 100ms 让节奏错开
|
||
*/
|
||
private _playPunchRevealAnimation(): void {
|
||
if (!this._inputLayoutOriginalPos || !this._punchLayoutOriginalPos) {
|
||
// 未记录到原始位置时兜底:直接显示到位,不做动画
|
||
if (this.inputLayout && this._inputLayoutOriginalPos) {
|
||
this.inputLayout.setPosition(this._inputLayoutOriginalPos);
|
||
}
|
||
if (this.punchDivider) this.punchDivider.active = true;
|
||
return;
|
||
}
|
||
|
||
// 1) InputLayout 位移回原位
|
||
if (this.inputLayout) {
|
||
Tween.stopAllByTarget(this.inputLayout);
|
||
tween(this.inputLayout)
|
||
.to(
|
||
PageLevel.PUNCH_REVEAL_DURATION,
|
||
{ position: this._inputLayoutOriginalPos.clone() },
|
||
{ easing: 'cubicOut' }
|
||
)
|
||
.start();
|
||
}
|
||
|
||
// 2) 分割线淡入
|
||
if (this.punchDivider) {
|
||
let dividerOpacity = this.punchDivider.getComponent(UIOpacity);
|
||
if (!dividerOpacity) {
|
||
dividerOpacity = this.punchDivider.addComponent(UIOpacity);
|
||
}
|
||
Tween.stopAllByTarget(dividerOpacity);
|
||
dividerOpacity.opacity = 0;
|
||
this.punchDivider.active = true;
|
||
tween(dividerOpacity)
|
||
.to(PageLevel.PUNCH_REVEAL_DURATION, { opacity: 255 })
|
||
.start();
|
||
}
|
||
|
||
// 3) punchLayout 淡入 + 缩放回弹(延迟,节奏错开)
|
||
if (this.punchLayout) {
|
||
let punchOpacity = this.punchLayout.getComponent(UIOpacity);
|
||
if (!punchOpacity) {
|
||
punchOpacity = this.punchLayout.addComponent(UIOpacity);
|
||
}
|
||
Tween.stopAllByTarget(punchOpacity);
|
||
Tween.stopAllByTarget(this.punchLayout);
|
||
|
||
punchOpacity.opacity = 0;
|
||
this.punchLayout.setScale(
|
||
PageLevel.PUNCH_REVEAL_START_SCALE,
|
||
PageLevel.PUNCH_REVEAL_START_SCALE,
|
||
1
|
||
);
|
||
|
||
tween(punchOpacity)
|
||
.delay(PageLevel.PUNCH_REVEAL_DELAY)
|
||
.to(PageLevel.PUNCH_REVEAL_DURATION, { opacity: 255 })
|
||
.start();
|
||
|
||
tween(this.punchLayout)
|
||
.delay(PageLevel.PUNCH_REVEAL_DELAY)
|
||
.to(
|
||
PageLevel.PUNCH_REVEAL_DURATION,
|
||
{ scale: new Vec3(1, 1, 1) },
|
||
{ easing: 'backOut' }
|
||
)
|
||
.start();
|
||
}
|
||
}
|
||
|
||
// ========== 音效相关方法 ==========
|
||
|
||
/**
|
||
* 播放音效(通用方法)
|
||
*/
|
||
private playSound(clip: AudioClip | null): void {
|
||
if (!clip) return;
|
||
const audioSource = this.node.getComponent(AudioSource);
|
||
audioSource?.playOneShot(clip);
|
||
}
|
||
|
||
/**
|
||
* 播放点击音效
|
||
*/
|
||
private playClickSound(): void {
|
||
this.playSound(this.clickAudio);
|
||
}
|
||
|
||
/**
|
||
* 播放失败音效
|
||
*/
|
||
private playFailSound(): void {
|
||
this.playSound(this.failAudio);
|
||
}
|
||
|
||
/**
|
||
* 播放通关成功音效
|
||
*/
|
||
private playSuccessSound(): void {
|
||
this.playSound(this.successAudio);
|
||
}
|
||
|
||
/**
|
||
* 播放通关成功音效
|
||
*/
|
||
private playSuccessSound(): void {
|
||
this.playSound(this.successAudio);
|
||
}
|
||
|
||
// ========== 倒计时相关方法 ==========
|
||
|
||
/**
|
||
* 开始倒计时
|
||
*/
|
||
private startCountdown(): void {
|
||
// _countdown 已在 _applyLevelConfig 中根据 timeLimit 设置
|
||
this._isTimeUp = false;
|
||
this._levelStartTime = Date.now();
|
||
this.resetClockVisual();
|
||
this.updateClockLabel();
|
||
this.schedule(this.onCountdownTick, 1);
|
||
console.log(`[PageLevel] 开始倒计时 ${this._countdown} 秒`);
|
||
}
|
||
|
||
/**
|
||
* 停止倒计时
|
||
*/
|
||
private stopCountdown(): void {
|
||
this.unschedule(this.onCountdownTick);
|
||
}
|
||
|
||
/**
|
||
* 倒计时每秒回调
|
||
*/
|
||
private onCountdownTick(): void {
|
||
if (this._isTimeUp) return;
|
||
|
||
this._countdown--;
|
||
this.updateClockLabel();
|
||
|
||
// 进入紧迫区间(≤10s):每次 tick 都播一次脉冲,跟秒同步形成"心跳"节奏
|
||
if (this._countdown > 0 && this._countdown <= PageLevel.CLOCK_URGENT_THRESHOLD) {
|
||
this.playClockUrgentPulse();
|
||
}
|
||
|
||
if (this._countdown <= 0) {
|
||
this._isTimeUp = true;
|
||
this.stopCountdown();
|
||
this.onTimeUp();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 更新倒计时显示
|
||
*/
|
||
private updateClockLabel(): void {
|
||
if (!this.clockLabel) return;
|
||
|
||
this.clockLabel.string = `${this._countdown}s`;
|
||
|
||
// 首次使用时懒记录原色,后续用来还原
|
||
if (!this._clockLabelNormalColor) {
|
||
this._clockLabelNormalColor = this.clockLabel.color.clone();
|
||
}
|
||
|
||
// 颜色跟着数值走:进入紧迫区间变红,否则恢复原色
|
||
const isUrgent = this._countdown > 0 && this._countdown <= PageLevel.CLOCK_URGENT_THRESHOLD;
|
||
const targetColor = isUrgent ? PageLevel.CLOCK_URGENT_COLOR : this._clockLabelNormalColor;
|
||
if (!this.clockLabel.color.equals(targetColor)) {
|
||
this.clockLabel.color = targetColor;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 倒计时紧迫脉冲:每次 tick 触发一次心跳式缩放
|
||
* 用 scale 1 → 1.3 → 1(各 150ms),跟秒 tick 同步形成节奏
|
||
* 不使用 Tween 链的 delay,是为了让"下一次 tick"能立刻打断并重置到 1,避免动画叠加
|
||
*/
|
||
private playClockUrgentPulse(): void {
|
||
if (!this.clockLabel) return;
|
||
|
||
const node = this.clockLabel.node;
|
||
Tween.stopAllByTarget(node);
|
||
|
||
// 从 1.0 开始,保证即使上一次脉冲未完成也能重置
|
||
node.setScale(1, 1, 1);
|
||
|
||
tween(node)
|
||
.to(
|
||
PageLevel.CLOCK_PULSE_HALF_DURATION,
|
||
{ scale: new Vec3(PageLevel.CLOCK_PULSE_PEAK_SCALE, PageLevel.CLOCK_PULSE_PEAK_SCALE, 1) },
|
||
{ easing: 'quadOut' }
|
||
)
|
||
.to(
|
||
PageLevel.CLOCK_PULSE_HALF_DURATION,
|
||
{ scale: new Vec3(1, 1, 1) },
|
||
{ easing: 'quadIn' }
|
||
)
|
||
.start();
|
||
}
|
||
|
||
/**
|
||
* 重置倒计时视觉状态(颜色、缩放),用于关卡切换 / 倒计时重新开始
|
||
*/
|
||
private resetClockVisual(): void {
|
||
if (!this.clockLabel) return;
|
||
|
||
Tween.stopAllByTarget(this.clockLabel.node);
|
||
this.clockLabel.node.setScale(1, 1, 1);
|
||
|
||
if (this._clockLabelNormalColor) {
|
||
this.clockLabel.color = this._clockLabelNormalColor;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 倒计时结束
|
||
*/
|
||
private onTimeUp(): void {
|
||
console.log('[PageLevel] 倒计时结束!');
|
||
this.playFailSound();
|
||
this._showTimeoutModal();
|
||
}
|
||
|
||
// ========== 体力值相关方法 ==========
|
||
|
||
/** 上次显示的体力值,用于变更检测 */
|
||
private _lastDisplayedStamina: number = -1;
|
||
|
||
/** 上次显示的体力上限,用于变更检测 */
|
||
private _lastDisplayedStaminaMax: number = -1;
|
||
|
||
/**
|
||
* 获取体力上限,服务端未返回时使用默认值兜底
|
||
*/
|
||
private _getStaminaMax(stamina: StaminaInfo): number {
|
||
return typeof stamina.max === 'number' ? stamina.max : PageLevel.DEFAULT_STAMINA_MAX;
|
||
}
|
||
|
||
/**
|
||
* 更新体力值显示(仅值变化时更新 UI)
|
||
*/
|
||
private updateStaminaLabel(): void {
|
||
if (this._isShareMode) {
|
||
return;
|
||
}
|
||
|
||
if (this.liveLabel) {
|
||
const stamina = StaminaManager.instance.getStamina();
|
||
const maxStamina = this._getStaminaMax(stamina);
|
||
if (stamina.current !== this._lastDisplayedStamina || maxStamina !== this._lastDisplayedStaminaMax) {
|
||
this.liveLabel.string = `${stamina.current}/${maxStamina}`;
|
||
this._lastDisplayedStamina = stamina.current;
|
||
this._lastDisplayedStaminaMax = maxStamina;
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 启动体力恢复倒计时 UI
|
||
*/
|
||
private _startStaminaRecoverTimer(): void {
|
||
if (this._isShareMode) {
|
||
return;
|
||
}
|
||
|
||
this._stopStaminaRecoverTimer();
|
||
|
||
const stamina = StaminaManager.instance.getStamina();
|
||
const maxStamina = this._getStaminaMax(stamina);
|
||
if (!stamina.nextRecoverAt || stamina.current >= maxStamina) {
|
||
return;
|
||
}
|
||
|
||
const targetTime = new Date(stamina.nextRecoverAt).getTime();
|
||
if (isNaN(targetTime)) return;
|
||
|
||
this._staminaTimerId = setInterval(() => {
|
||
if (targetTime - Date.now() > 0) return;
|
||
|
||
// 恢复一点体力
|
||
const currentStamina = StaminaManager.instance.getStamina();
|
||
const currentMaxStamina = this._getStaminaMax(currentStamina);
|
||
const newCurrent = Math.min(currentStamina.current + 1, currentMaxStamina);
|
||
const newStamina: StaminaInfo = {
|
||
...currentStamina,
|
||
max: currentMaxStamina,
|
||
current: newCurrent,
|
||
nextRecoverAt: newCurrent < currentMaxStamina
|
||
? new Date(Date.now() + 10 * 60 * 1000).toISOString()
|
||
: null,
|
||
};
|
||
StaminaManager.instance.updateStamina(newStamina);
|
||
this.updateStaminaLabel();
|
||
|
||
this._stopStaminaRecoverTimer();
|
||
|
||
if (newCurrent < currentMaxStamina) {
|
||
this._startStaminaRecoverTimer();
|
||
}
|
||
}, 1000);
|
||
}
|
||
|
||
/**
|
||
* 停止体力恢复倒计时
|
||
*/
|
||
private _stopStaminaRecoverTimer(): void {
|
||
if (this._staminaTimerId !== null) {
|
||
clearInterval(this._staminaTimerId);
|
||
this._staminaTimerId = null;
|
||
}
|
||
}
|
||
|
||
// ========== 答案提交与关卡切换 ==========
|
||
|
||
/**
|
||
* 提交答案
|
||
*/
|
||
onSubmitAnswer(): void {
|
||
if (!this._currentConfig) return;
|
||
if (this._isTransitioning) return;
|
||
|
||
const userAnswer = this.getAnswer();
|
||
console.log(`[PageLevel] 提交答案: ${userAnswer}, 正确答案: ${this._currentConfig.answer}`);
|
||
|
||
if (this.normalizeAnswerForCompare(userAnswer) === this.normalizeAnswerForCompare(this._currentConfig.answer)) {
|
||
if (this._isShareMode) {
|
||
this._recordCurrentShareSubmission(userAnswer);
|
||
}
|
||
// 答案正确,只播放成功音效(不播放点击音效,避免重合)
|
||
this.showSuccess();
|
||
} else {
|
||
// 答案错误
|
||
this.showError();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 显示成功提示并上报通关
|
||
*/
|
||
private async showSuccess(): Promise<void> {
|
||
console.log('[PageLevel] 答案正确!');
|
||
|
||
// 标记正在切换关卡,防止重复提交
|
||
this._isTransitioning = true;
|
||
|
||
// 停止倒计时
|
||
this.stopCountdown();
|
||
|
||
// 通关音效不在这里播,改由 _showPassNode 在 PassNode 弹起时触发(_playSuccessSound)
|
||
// 产品节奏:玩家看到通关页的瞬间音效才响起,避免谐音梗揭示期间就被音效抢戏
|
||
|
||
const punchline = this.getValidPunchline(this._currentConfig?.punchline ?? null);
|
||
if (punchline) {
|
||
// 通关后根据 punchline 字数重建包袱答案块
|
||
this.setPunchline(punchline);
|
||
} else {
|
||
this.hidePunchline();
|
||
}
|
||
|
||
const levelId = this._currentConfig?.id ?? '';
|
||
const timeSpent = Math.max(0, Math.round((Date.now() - this._levelStartTime) / 1000));
|
||
|
||
this.reportLevelCompleted(levelId, timeSpent);
|
||
|
||
// 不论是否有谐音梗,都停留固定时间,保证玩家能看到答案反馈
|
||
await this.delay(PageLevel.PASS_MODAL_DELAY_MS);
|
||
|
||
// 显示通关弹窗
|
||
this._showPassNode();
|
||
}
|
||
|
||
private getValidPunchline(punchline: string | null): string | null {
|
||
if (!punchline?.trim()) {
|
||
return null;
|
||
}
|
||
|
||
return punchline;
|
||
}
|
||
|
||
/**
|
||
* 上报通关并获取下一关数据
|
||
*/
|
||
private reportLevelCompleted(levelId: string, timeSpent: number): void {
|
||
if (!this._isShareMode) {
|
||
// 乐观更新通关计数(用于称号展示)
|
||
const previousCount = AuthManager.instance.completedLevelCount;
|
||
AuthManager.instance.addCompletedLevelCount();
|
||
this._passCompletedLevelCount = AuthManager.instance.completedLevelCount;
|
||
// 本次预期为首次通关,起点 = 通关前计数;如果回调回退,则清掉避免误播动画
|
||
this._passPreviousCompletedLevelCount = previousCount;
|
||
|
||
void StaminaManager.instance.completeLevel(levelId, timeSpent).then((result) => {
|
||
if (result) {
|
||
// 保存 complete 返回的下一关数据
|
||
this._nextLevelData = result.nextLevel;
|
||
|
||
if (!result.firstClear) {
|
||
// 非首次通关,回退乐观更新
|
||
AuthManager.instance.addCompletedLevelCount(-1);
|
||
this._passCompletedLevelCount = AuthManager.instance.completedLevelCount;
|
||
this._passPreviousCompletedLevelCount = null;
|
||
}
|
||
console.log(`[PageLevel] 通关上报成功,首次通关: ${result.firstClear}, 有下一关: ${!!result.nextLevel}`);
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
|
||
this._passCompletedLevelCount = null;
|
||
this._passPreviousCompletedLevelCount = null;
|
||
this._recordCurrentShareSubmission(undefined, timeSpent);
|
||
}
|
||
|
||
private _recordCurrentShareSubmission(answer?: string, timeSpent?: number): void {
|
||
if (!this._isShareMode || !this._currentConfig?.id) {
|
||
return;
|
||
}
|
||
|
||
const elapsedSeconds = Math.max(0, Math.round((Date.now() - this._levelStartTime) / 1000));
|
||
const finalTimeSpent = Math.max(0, Math.round(timeSpent ?? elapsedSeconds));
|
||
const finalAnswer = answer ?? this.getAnswer();
|
||
|
||
this._shareSubmissions.set(this._currentConfig.id, {
|
||
levelId: this._currentConfig.id,
|
||
answer: finalAnswer,
|
||
timeSpent: finalTimeSpent,
|
||
});
|
||
|
||
console.log(
|
||
`[PageLevel] 记录分享挑战提交: ${this._currentConfig.id}, answer="${finalAnswer}", timeSpent=${finalTimeSpent}`,
|
||
);
|
||
}
|
||
|
||
private _buildShareSubmissionPayload(): SubmitShareLevel[] {
|
||
const levelIds = ShareManager.instance.getShareLevelIds();
|
||
const ids = levelIds.length > 0
|
||
? levelIds
|
||
: [...this._shareSubmissions.keys()];
|
||
|
||
return ids.map(levelId => {
|
||
const submission = this._shareSubmissions.get(levelId);
|
||
return submission ?? {
|
||
levelId,
|
||
answer: '',
|
||
timeSpent: 0,
|
||
};
|
||
});
|
||
}
|
||
|
||
private delay(ms: number): Promise<void> {
|
||
return new Promise(resolve => setTimeout(resolve, ms));
|
||
}
|
||
|
||
/**
|
||
* 通关展示:BottomLayout / TipsLayout 透明度淡出,PassNode 从屏幕左侧滑入,
|
||
* Caidai 彩带 spine 播放 "open" 动画;按 NextLevel 时再走 _hidePassNode。
|
||
*
|
||
* 与原 PassModal 弹窗的差异:
|
||
* - PassNode 是 PageLevel 自己的子节点,不是 instantiate 出的弹窗,无需挂到 Canvas
|
||
* - PassNode 不全屏遮盖,所以不能在它显示期间偷偷预换底图(用户能看见底图)
|
||
* - 分享模式不展示成就体系(passTitleLevelNode 整体隐藏)
|
||
*/
|
||
private _showPassNode(): void {
|
||
if (!this.passNode) {
|
||
console.warn('[PageLevel] passNode 未挂引用,跳过通关展示');
|
||
return;
|
||
}
|
||
|
||
if (this._isPassNodeShown || this._isPassNodeAnimating) {
|
||
return;
|
||
}
|
||
|
||
this._isPassNodeShown = true;
|
||
|
||
// 配置成就体系数据 / 按钮文案 / 事件
|
||
this._setupPassNodeContent();
|
||
this._bindPassNodeEvents();
|
||
|
||
// 启动彩带 + 滑入动画 + 淡出底部UI + 通关音效
|
||
this._playCaidai();
|
||
this._playPassNodeShowAnimation();
|
||
this.playSuccessSound();
|
||
|
||
console.log('[PageLevel] 显示通关页 PassNode');
|
||
}
|
||
|
||
private _getPassCompletedLevelCount(): number {
|
||
return this._passCompletedLevelCount ?? AuthManager.instance.completedLevelCount;
|
||
}
|
||
|
||
/**
|
||
* 配置通关页内容:
|
||
* - 成就体系(分享模式整体隐藏;普通模式根据通关数量绑定文案 / 进度条 / 跨称号动画)
|
||
* - 「下一关 / 提交答案」按钮文案:分享模式最后一题显示"提交答案"
|
||
*/
|
||
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 = false(next 进入时会重新 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;
|
||
}
|
||
|
||
this._isPassNodeAnimating = true;
|
||
|
||
// 底部两层淡入恢复
|
||
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 _finalizeHidePassNode(): void {
|
||
// 隐藏 PassNode 并归位(_passNodeOriginalPos 已记录)
|
||
if (this.passNode) {
|
||
this.passNode.active = false;
|
||
if (this._passNodeOriginalPos) {
|
||
this.passNode.setPosition(this._passNodeOriginalPos);
|
||
}
|
||
}
|
||
|
||
// 关掉彩带
|
||
if (this.caidaiNode) {
|
||
this.caidaiNode.active = false;
|
||
}
|
||
|
||
this._unbindPassNodeEvents();
|
||
this._titleAnimator?.stop();
|
||
this._isPassNodeShown = false;
|
||
this._isPassNodeAnimating = false;
|
||
}
|
||
|
||
/**
|
||
* 同步重置 PassNode 与底部两层到「未通关」状态。
|
||
* 用于:销毁、模式切换、IconSetting 离场、自动推进下一关 等需要立即清场的场景。
|
||
* 不带动画,直接归位。
|
||
*/
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* 显示错误弹窗
|
||
*/
|
||
private _showWrongModal(): void {
|
||
if (!this.wrongModalPrefab) {
|
||
console.warn('[PageLevel] wrongModalPrefab 未设置');
|
||
return;
|
||
}
|
||
|
||
// 如果弹窗已显示,不再重复创建
|
||
if (this._wrongModalNode && this._wrongModalNode.isValid) {
|
||
return;
|
||
}
|
||
|
||
const modalNode = instantiate(this.wrongModalPrefab);
|
||
modalNode.setPosition(PageLevel.ZERO_POS);
|
||
modalNode.setSiblingIndex(WrongModal.MODAL_Z_INDEX);
|
||
|
||
const canvasNode = this.node.parent;
|
||
if (canvasNode) {
|
||
canvasNode.addChild(modalNode);
|
||
} else {
|
||
this.node.addChild(modalNode);
|
||
}
|
||
this._wrongModalNode = modalNode;
|
||
|
||
const wrongModal = modalNode.getComponent(WrongModal);
|
||
if (wrongModal) {
|
||
wrongModal.setCallbacks({
|
||
onContinue: () => {
|
||
this._closeWrongModal();
|
||
this.clearInputText();
|
||
}
|
||
});
|
||
wrongModal.onViewLoad();
|
||
wrongModal.onViewShow();
|
||
}
|
||
|
||
console.log('[PageLevel] 显示错误弹窗');
|
||
}
|
||
|
||
/**
|
||
* 关闭错误弹窗
|
||
*/
|
||
private _closeWrongModal(): void {
|
||
if (this._wrongModalNode && this._wrongModalNode.isValid) {
|
||
this._wrongModalNode.destroy();
|
||
this._wrongModalNode = null;
|
||
console.log('[PageLevel] 关闭错误弹窗');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 显示超时弹窗
|
||
*/
|
||
private _showTimeoutModal(): void {
|
||
if (!this.timeoutModalPrefab) {
|
||
console.warn('[PageLevel] timeoutModalPrefab 未设置');
|
||
return;
|
||
}
|
||
|
||
// 如果弹窗已显示,不再重复创建
|
||
if (this._timeoutModalNode && this._timeoutModalNode.isValid) {
|
||
return;
|
||
}
|
||
|
||
const modalNode = instantiate(this.timeoutModalPrefab);
|
||
modalNode.setPosition(PageLevel.ZERO_POS);
|
||
modalNode.setSiblingIndex(TimeoutModal.MODAL_Z_INDEX);
|
||
|
||
const canvasNode = this.node.parent;
|
||
if (canvasNode) {
|
||
canvasNode.addChild(modalNode);
|
||
} else {
|
||
this.node.addChild(modalNode);
|
||
}
|
||
this._timeoutModalNode = modalNode;
|
||
|
||
const timeoutModal = modalNode.getComponent(TimeoutModal);
|
||
if (timeoutModal) {
|
||
timeoutModal.setParams({
|
||
levelIndex: this.getDisplayLevelNumber(),
|
||
shareMode: this._isShareMode,
|
||
});
|
||
timeoutModal.setCallbacks({
|
||
onShare: () => {
|
||
console.log('[PageLevel] 超时弹窗分享完成');
|
||
},
|
||
onRestart: () => {
|
||
this._closeTimeoutModal();
|
||
this._enterAndInitLevel().catch(err => {
|
||
console.error('[PageLevel] 重新进入关卡失败:', err);
|
||
});
|
||
},
|
||
onNext: () => {
|
||
this._showShareNextConfirmModal(() => {
|
||
this._closeTimeoutModal();
|
||
this._recordCurrentShareSubmission();
|
||
void this.goToNextLevel();
|
||
});
|
||
},
|
||
onHome: () => {
|
||
this._closeTimeoutModal();
|
||
if (this._isShareMode) {
|
||
ShareManager.instance.clearShareMode();
|
||
ViewManager.instance.replace('PageHome');
|
||
} else {
|
||
ViewManager.instance.back();
|
||
}
|
||
}
|
||
});
|
||
timeoutModal.onViewLoad();
|
||
timeoutModal.onViewShow();
|
||
}
|
||
|
||
console.log('[PageLevel] 显示超时弹窗');
|
||
}
|
||
|
||
/**
|
||
* 关闭超时弹窗
|
||
*/
|
||
private _closeTimeoutModal(): void {
|
||
if (this._timeoutModalNode && this._timeoutModalNode.isValid) {
|
||
this._timeoutModalNode.destroy();
|
||
this._timeoutModalNode = null;
|
||
console.log('[PageLevel] 关闭超时弹窗');
|
||
}
|
||
}
|
||
|
||
private _closeCommonModal(): void {
|
||
if (this._commonModalNode && this._commonModalNode.isValid) {
|
||
this._commonModalNode.destroy();
|
||
this._commonModalNode = null;
|
||
console.log('[PageLevel] 关闭通用确认弹窗');
|
||
}
|
||
}
|
||
|
||
private _showShareNextConfirmModal(onConfirm: () => void): void {
|
||
if (!this._isShareMode) {
|
||
onConfirm();
|
||
return;
|
||
}
|
||
|
||
if (this._commonModalNode && this._commonModalNode.isValid) {
|
||
return;
|
||
}
|
||
|
||
if (!this.commonModalPrefab) {
|
||
console.warn('[PageLevel] commonModalPrefab 未设置,直接进入下一题');
|
||
onConfirm();
|
||
return;
|
||
}
|
||
|
||
const isFinalShareLevel = this._isFinalShareLevel();
|
||
const modal = CommonModal.show(this.commonModalPrefab, {
|
||
title: '提示',
|
||
content: isFinalShareLevel
|
||
? '确认提交挑战答案吗?'
|
||
: '还有时间\n确认进入下一题吗?',
|
||
buttonConfirm: '确认',
|
||
buttonCancel: '再想想',
|
||
zIndex: CommonModal.MODAL_Z_INDEX + 1,
|
||
onClose: () => {
|
||
this._commonModalNode = null;
|
||
},
|
||
onCancel: () => {
|
||
this._commonModalNode = null;
|
||
},
|
||
onConfirm: () => {
|
||
this._commonModalNode = null;
|
||
onConfirm();
|
||
},
|
||
}, this.node.parent ?? this.node);
|
||
|
||
this._commonModalNode = modal?.node ?? null;
|
||
}
|
||
|
||
/**
|
||
* 显示错误提示
|
||
*/
|
||
private showError(): void {
|
||
console.log('[PageLevel] 答案错误!');
|
||
|
||
// 播放失败音效
|
||
this.playFailSound();
|
||
|
||
// 触发手机震动
|
||
WxSDK.vibrateLong();
|
||
|
||
// 显示错误弹窗
|
||
this._showWrongModal();
|
||
}
|
||
|
||
/**
|
||
* 进入下一关
|
||
* 正常模式:使用 complete 返回的 nextLevel 数据
|
||
* 分享模式:按索引递增
|
||
*/
|
||
private async goToNextLevel(): Promise<void> {
|
||
if (this._isShareMode) {
|
||
// 分享模式:按索引递增
|
||
this._shareLevelIndex++;
|
||
const totalLevels = ShareManager.instance.getShareLevelCount();
|
||
if (this._shareLevelIndex >= totalLevels) {
|
||
console.log('[PageLevel] 分享关卡全部完成');
|
||
this.stopCountdown();
|
||
await this._showShareEndPage();
|
||
return;
|
||
}
|
||
|
||
this._refreshModeUI();
|
||
} else {
|
||
// 正常模式:使用 complete 返回的 nextLevel
|
||
if (!this._nextLevelData) {
|
||
// 没有下一关 → 全部通关
|
||
console.log('[PageLevel] 恭喜通关!所有关卡已完成');
|
||
this.stopCountdown();
|
||
ViewManager.instance.back();
|
||
return;
|
||
}
|
||
|
||
// 切换到下一关
|
||
this._currentLevelId = this._nextLevelData.id;
|
||
this._currentLevelNumber = this._nextLevelData.level;
|
||
this._nextLevelData = null;
|
||
}
|
||
|
||
// 重置并加载下一关(包含进入关卡接口调用)
|
||
await this._enterAndInitLevel();
|
||
console.log(`[PageLevel] 进入关卡 第${this._currentLevelNumber}关`);
|
||
}
|
||
|
||
private _isFinalShareLevel(): boolean {
|
||
if (!this._isShareMode) {
|
||
return false;
|
||
}
|
||
|
||
const totalLevels = ShareManager.instance.getShareLevelCount();
|
||
return totalLevels > 0 && this._shareLevelIndex >= totalLevels - 1;
|
||
}
|
||
|
||
private async _ensureShareParticipantUserInfo(): Promise<void> {
|
||
if (!this._isShareMode || this._hasRequestedShareUserInfo) {
|
||
return;
|
||
}
|
||
this._hasRequestedShareUserInfo = true;
|
||
|
||
const cachedUserInfo = StorageManager.getUserInfo();
|
||
if (cachedUserInfo && this._hasUsableUserInfo(cachedUserInfo)) {
|
||
await this._uploadShareParticipantUserInfo(cachedUserInfo);
|
||
return;
|
||
}
|
||
|
||
if (!WxSDK.isWechat()) {
|
||
console.log('[PageLevel] 非微信环境,跳过获取用户头像昵称');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const userInfo = await getUserProfile();
|
||
StorageManager.setUserInfo(userInfo);
|
||
await this._uploadShareParticipantUserInfo(userInfo);
|
||
} catch (err) {
|
||
console.warn('[PageLevel] 获取用户头像昵称失败,继续提交挑战:', err);
|
||
ToastManager.show('未授权头像昵称,将使用默认资料提交');
|
||
}
|
||
}
|
||
|
||
private _hasUsableUserInfo(userInfo: WxUserInfo): boolean {
|
||
return !!userInfo.avatarUrl?.trim()
|
||
&& !!userInfo.nickName?.trim()
|
||
&& userInfo.nickName !== '微信用户';
|
||
}
|
||
|
||
private async _uploadShareParticipantUserInfo(userInfo: WxUserInfo): Promise<void> {
|
||
const userId = AuthManager.instance.userId;
|
||
if (!userId) {
|
||
console.warn('[PageLevel] 用户未登录,跳过上传头像昵称');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await HttpUtil.post<ApiEnvelope<unknown>>(
|
||
API_ENDPOINTS.USER_INFO,
|
||
{
|
||
userId,
|
||
avatarUrl: userInfo.avatarUrl,
|
||
nickName: userInfo.nickName,
|
||
},
|
||
API_TIMEOUT.DEFAULT,
|
||
);
|
||
|
||
if (response.success) {
|
||
console.log('[PageLevel] 分享挑战用户头像昵称上传成功');
|
||
} else {
|
||
console.warn('[PageLevel] 分享挑战用户头像昵称上传失败:', response.message);
|
||
}
|
||
} catch (err) {
|
||
console.warn('[PageLevel] 分享挑战用户头像昵称上传异常:', err);
|
||
}
|
||
}
|
||
|
||
private async _showShareEndPage(): Promise<void> {
|
||
if (this._isSubmittingShareResult) {
|
||
return;
|
||
}
|
||
|
||
console.log('[PageLevel] 分享关卡全部完成,进入 PK 结算页');
|
||
this.stopCountdown();
|
||
|
||
if (this._currentConfig?.id && !this._shareSubmissions.has(this._currentConfig.id)) {
|
||
this._recordCurrentShareSubmission();
|
||
}
|
||
|
||
const payload = this._buildShareSubmissionPayload();
|
||
if (payload.length === 0) {
|
||
ToastManager.show('挑战数据异常,请重新进入');
|
||
this._isTransitioning = false;
|
||
return;
|
||
}
|
||
|
||
this._isSubmittingShareResult = true;
|
||
ToastManager.show('正在结算挑战...');
|
||
|
||
await this._ensureShareParticipantUserInfo();
|
||
const result = await ShareManager.instance.submitShareChallenge(payload);
|
||
this._isSubmittingShareResult = false;
|
||
|
||
if (!result) {
|
||
ToastManager.show('提交挑战结果失败,请稍后重试');
|
||
this._isTransitioning = false;
|
||
return;
|
||
}
|
||
|
||
ViewManager.instance.replace('PagePKEnd', {
|
||
params: {
|
||
result,
|
||
},
|
||
});
|
||
}
|
||
}
|