Files
mp-xieyingeng/assets/prefabs/PageLevel.ts
2026-05-30 22:37:07 +08:00

2858 lines
98 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 = 1000;
/** 图片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';
/**
* 通关赞美 spinepose 节点)档位:[最小通关数, 动画名],倒序匹配。
* 1-5 关 → "1"6-10 关 → "2"11+ 关 → "3"。
* count <= 0 不会匹配任何档位,自然不播放。
*/
private static readonly POSE_TIERS: ReadonlyArray<readonly [number, string]> = [
[11, '3'],
[6, '2'],
[1, '1'],
];
// ========== 节点引用 ==========
@property(Node)
inputLayout: Node | null = null;
@property(Node)
punchLayout: Node | null = null;
/** Action 区域内 InputLayout 与 punchLayout 之间的分割线节点prefab 中的 border_dashline_wht */
@property(Node)
punchDivider: Node | null = null;
@property(Node)
submitButton: Node | null = null;
@property(Node)
inputTemplate: Node | null = null;
@property(Node)
actionNode: Node | null = null;
@property(Node)
iconSetting: Node | null = null;
@property(Node)
tipsLayout: Node | null = null;
@property(Node)
mainImage: Node | null = null;
@property(Node)
mainImage2: Node | null = null;
@property(Label)
image1DescLabel: Label | null = null;
@property(Label)
image2DescLabel: Label | null = null;
@property(Node)
tipsItem1: Node | null = null;
@property(Node)
tipsItem2: Node | null = null;
@property(Node)
tipsItem3: Node | null = null;
@property(Node)
unLockTipsBtn: Node | null = null;
@property(Node)
addTimeBtn: Node | null = null;
@property(Label)
clockLabel: Label | null = null;
/** 体力值显示标签prefab 中序列化名为 liveLabel保持兼容 */
@property(Label)
liveLabel: Label | null = null;
/** 关卡标题标签,显示为"第 N 关" */
@property(Label)
titleLevelLabel: Label | null = null;
/** 普通模式背景 */
@property(Node)
bgNode: Node | null = null;
/** 分享 / PK 模式背景 */
@property(Node)
pkBgNode: Node | null = null;
/** 普通模式标题容器 */
@property(Node)
titleLevelNode: Node | null = null;
/** 分享 / PK 模式标题容器 */
@property(Node)
pkTitleLevelNode: Node | null = null;
/** 分享 / PK 模式标题标签 */
@property(Label)
pkTitleLevelLabel: Label | null = null;
/** 普通模式体力区域容器 */
@property(Node)
liveNode: Node | null = null;
/** 分享 / PK 模式进度容器 */
@property(Node)
pkLevelProgressNode: Node | null = null;
/** 分享 / PK 模式进度标签 */
@property(Label)
pkLevelProgressLabel: Label | null = null;
/** 普通模式底部按钮区域 */
@property(Node)
bottomLayoutNode: Node | null = null;
/** 分享 / PK 模式底部下一题按钮 */
@property(Node)
pkNextLevelButton: Node | null = null;
// ========== PassNode通关后展示==========
/** PassNode 根节点:用户通关后从屏幕左侧滑入 */
@property(Node)
passNode: Node | null = null;
/** PassNode 内的成就体系容器TitlteLevel。分享模式下整体隐藏 */
@property(Node)
passTitleLevelNode: Node | null = null;
/** 称号文案 Label如「冷场小白1级」 */
@property(Label)
passTitleLevelLabel: Label | null = null;
/** 称号进度条 */
@property(ProgressBar)
passTitleProgressBar: ProgressBar | null = null;
/** 进度提示 Label如「还差3题解锁新成就等级」 */
@property(Label)
passProgressLabel: Label | null = null;
/** 进度条上跟随移动的 anchor 节点(其下 Label 显示百分比) */
@property(Node)
passProgressAnchor: Node | null = null;
/** 通关页「下一关 / 提交答案」按钮 */
@property(Node)
passNextLevelButton: Node | null = null;
/** 通关页「分享」按钮 */
@property(Node)
passShareButton: Node | null = null;
/** 彩带 spine 根节点(与 PassNode 同级,挂在 PageLevel 下) */
@property(Node)
caidaiNode: Node | null = null;
/** 彩带 sp.Skeleton 组件,用于播放 "open" 动画 */
@property(sp.Skeleton)
caidaiSkeleton: sp.Skeleton | null = null;
/** 通关赞美 spine 节点PassNode 子节点,根据本场通关数选择 1/2/3 动画) */
@property(Node)
poseNode: Node | null = null;
/** 通关赞美 sp.Skeleton 组件,动画名 "1" / "2" / "3"loop=false */
@property(sp.Skeleton)
poseSkeleton: sp.Skeleton | null = null;
// ========== 配置属性 ==========
@property(AudioClip)
clickAudio: AudioClip | null = null;
@property(AudioClip)
successAudio: AudioClip | null = null;
@property(AudioClip)
failAudio: AudioClip | null = null;
@property(Prefab)
wrongModalPrefab: Prefab | null = null;
@property(Prefab)
timeoutModalPrefab: Prefab | null = null;
@property(Prefab)
commonModalPrefab: Prefab | null = null;
/** 主图圆角材质 EffectAsset */
@property(EffectAsset)
roundedSpriteEffect: EffectAsset | null = null;
/** 主图圆角半径比例相对于短边0-0.5 */
@property
mainImageCornerRadius: number = 0.1;
// ========== 内部状态 ==========
/** 当前创建的输入框节点数组 */
private _inputNodes: Node[] = [];
/** InputLayout 中默认放置的输入框模板节点 */
private _inputTemplateNode: Node | null = null;
/** 当前创建的包袱展示块节点数组 */
private _punchBlockNodes: Node[] = [];
/** punchLayout 中默认放置的展示块模板节点 */
private _punchBlockTemplateNode: Node | null = null;
/** 是否正在同步输入格内容,避免设置文本时重复触发事件 */
private _isSyncingInputText: boolean = false;
/** 最近一次自动提交的答案,避免填满后重复提交同一内容 */
private _lastAutoSubmittedAnswer: string = '';
/** 当前正在编辑的输入格索引 */
private _editingInputIndex: number = -1;
/** 倒计时剩余秒数 */
private _countdown: number = 60;
/** clockLabel 非紧迫状态下的原始颜色(首次渲染时懒记录,避免 hardcode prefab 颜色) */
private _clockLabelNormalColor: Color | null = null;
/** InputLayout 原始位置prefab 中的初始 _lpos作为"有梗揭示"的目标位置) */
private _inputLayoutOriginalPos: Vec3 | null = null;
/** punchLayout 原始位置prefab 中的初始 _lpos作为"有梗揭示"的目标位置) */
private _punchLayoutOriginalPos: Vec3 | null = null;
/** "无梗居中态"下 InputLayout 的 Y 坐标InputLayout 原始 Y 与 punchLayout 原始 Y 的中点 */
private _inputLayoutCenteredY: number | null = null;
/** 关卡开始时间戳ms用于准确计算耗时 */
private _levelStartTime: number = 0;
/** 倒计时是否结束 */
private _isTimeUp: boolean = false;
/** 当前关卡配置 */
private _currentConfig: RuntimeLevelConfig | null = null;
/** 是否正在切换关卡(防止重复提交) */
private _isTransitioning: boolean = false;
/** 是否正在解锁提示(防止双击重复触发) */
private _isUnlocking: boolean = false;
/** 下一个待解锁的线索序号2 或 3超过 3 表示全部已解锁 */
private _nextClueIndex: number = 2;
/** 通关页PassNode当前是否已展示 */
private _isPassNodeShown: boolean = false;
/** PassNode 进出动画是否在进行中(防止重入) */
private _isPassNodeAnimating: boolean = false;
/** PassNode 原始 local positionprefab 摆放位),动画结束后用来回归 */
private _passNodeOriginalPos: Vec3 | null = null;
/** 通关页所用「已通关数量」(业务数据,给成就体系展示用) */
private _passCompletedLevelCount: number | null = null;
/**
* 本次进入 PageLevel 后累计通关数onViewLoad / _reinitLevelSession 时归零)。
* 普通模式 / 分享模式都计入;用于驱动 pose 赞美动画档位。
*/
private _sessionPassCount: number = 0;
/** pose Spine 隐藏延时定时器setTimeout 句柄);切关 / 关页时需清理避免穿屏触发 */
private _poseHideTimer: ReturnType<typeof setTimeout> | 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}`);
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 = '';
// 不限制单格 maxLengthiOS 拼音 / 日韩 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;
// 从紧迫态跳回安全区:停掉残留脉冲并复位 scaleupdateClockLabel 会负责把颜色改回)
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();
}
// 居中 YInputLayout 原始 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;
}
// punchLayoutactive 由调用方/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 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;
// 记录本次进入答题页的累计通关数(普通 / 分享模式都计入)。
// 放在 _showPassNode 而不是 reportLevelCompleted分享模式不会经过 reportLevelCompleted
// 但两种模式都经过 _showPassNode且 _isPassNodeShown 防重入保证只 +1 一次。
this._sessionPassCount++;
// 配置成就体系数据 / 按钮文案 / 事件
this._setupPassNodeContent();
this._bindPassNodeEvents();
// 启动彩带 + 滑入动画 + 淡出底部UI + 通关音效 + 赞美 spine
this._playCaidai();
this._playPosePraise(this._sessionPassCount);
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;
}
// pose 节点 prefab 默认 active=true 且 defaultAnimation="1",运行时必须先关掉,
// 否则进入 PageLevel 时 pose 会立即显示并自动跑一次动画。
if (this.poseNode) {
this.poseNode.active = false;
}
this._clearPoseHideTimer();
this._isPassNodeShown = false;
this._isPassNodeAnimating = false;
}
/** PassNode 进入动画:底部两层淡出 + PassNode 从屏幕左侧滑入 */
private _playPassNodeShowAnimation(): void {
if (!this.passNode) return;
this._isPassNodeAnimating = true;
// 底部两层淡出后置 active = falsenext 进入时会重新 active = true
this._fadeOutBottomLayers();
// 记录原位置(首次记录后保留,反向动画 / reset 时复用)
if (!this._passNodeOriginalPos) {
this._passNodeOriginalPos = this.passNode.position.clone();
}
// 从屏幕左侧外滑入:起点 X = 原 X - 屏幕宽度(确保完全在屏幕外)
const screenWidth = view.getVisibleSize().width;
const originalPos = this._passNodeOriginalPos;
const startX = originalPos.x - screenWidth;
Tween.stopAllByTarget(this.passNode);
this.passNode.active = true;
this.passNode.setPosition(startX, originalPos.y, originalPos.z);
tween(this.passNode)
.to(
PageLevel.PASS_NODE_SLIDE_DURATION,
{ position: originalPos.clone() },
{ easing: 'cubicOut' },
)
.call(() => {
this._isPassNodeAnimating = false;
})
.start();
}
/**
* PassNode 退出(用户点下一关时调用):滑出 PassNode + 底部两层淡入
* 返回 Promise外部链路通常 await 后再调 goToNextLevel
*/
private _hidePassNode(): Promise<void> {
return new Promise<void>((resolve) => {
if (!this.passNode || !this._isPassNodeShown) {
resolve();
return;
}
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;
}
// 关掉赞美 spinePassNode 一关 pose 自然不可见,仍显式置 false 防御下次显示残留帧)
if (this.poseNode) {
this.poseNode.active = false;
}
this._clearPoseHideTimer();
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;
}
// 同步清掉赞美 spine防止跨模式 / 切关 / 销毁时残留
if (this.poseNode) {
this.poseNode.active = false;
}
this._clearPoseHideTimer();
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);
}
/**
* 根据本次会话累计通关数选择并播放赞美 spine 动画pose 节点)。
* 档位匹配规则见 PageLevel.POSE_TIERS1-5 → "1"6-10 → "2"11+ → "3"。
* 单次 spine 动画太短,这里用 loop=true 持续播放,配合 POSE_DISPLAY_DURATION
* 定时强制隐藏 poseNode。切关 / 销毁会通过 _clearPoseHideTimer 取消定时器。
*/
private _playPosePraise(count: number): void {
if (!this.poseNode || !this.poseSkeleton) return;
const tier = PageLevel.POSE_TIERS.find(([min]) => count >= min);
if (!tier) return;
const [, animName] = tier;
// 重置上一次可能仍在排队的隐藏定时器(极端情况下连击通关)
this._clearPoseHideTimer();
this.poseNode.active = true;
this.poseSkeleton.setAnimation(0, animName, false);
}
/** 清理 pose 隐藏定时器(用于切关 / 销毁 / 跨模式切换前的清场)。 */
private _clearPoseHideTimer(): void {
if (this._poseHideTimer !== null) {
clearTimeout(this._poseHideTimer);
this._poseHideTimer = null;
}
}
/** 底部两层淡出(透明度 → 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,
},
});
}
}