import { _decorator, Node, EditBox, instantiate, Vec3, Button, Label, Sprite, SpriteFrame, AudioClip, AudioSource, Prefab, EffectAsset, UITransform, UIOpacity, tween, Tween, Color } 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 } 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 { PassModal } from 'db://assets/prefabs/PassModal'; import { WrongModal } from 'db://assets/prefabs/WrongModal'; import { TimeoutModal } from 'db://assets/prefabs/TimeoutModal'; import { CommonModal } from 'db://assets/prefabs/CommonModal'; import { StaminaInfo, NextLevelData, SubmitShareLevel } from 'db://assets/scripts/types/ApiTypes'; import { AchievementTitleManager } from 'db://assets/scripts/utils/AchievementTitleManager'; import { applyRoundedCorner } from 'db://assets/scripts/utils/roundedMaterial.utils'; 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; // ========== 节点引用 ========== @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; // ========== 配置属性 ========== @property(AudioClip) clickAudio: AudioClip | null = null; @property(AudioClip) successAudio: AudioClip | null = null; @property(AudioClip) failAudio: AudioClip | null = null; @property(Prefab) passModalPrefab: Prefab | 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 _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; /** 通关弹窗实例 */ private _passModalNode: Node | null = null; /** 本次通关弹窗使用的已通关数量 */ private _passModalCompletedLevelCount: number | null = null; /** 本次通关弹窗动画起点(通关前)的已通关数量;为 null 表示不播动画 */ private _passModalPreviousCompletedLevelCount: number | null = null; /** 错误弹窗实例 */ private _wrongModalNode: Node | null = null; /** 超时弹窗实例 */ private _timeoutModalNode: Node | null = null; /** 通用确认弹窗实例 */ private _commonModalNode: Node | null = null; /** 是否处于分享挑战模式 */ private _isShareMode: boolean = false; /** 体力恢复倒计时定时器 */ private _staminaTimerId: ReturnType | 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 = new Map(); /** 是否正在提交分享挑战结果 */ private _isSubmittingShareResult: 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; console.log('[PageLevel] 进入分享挑战模式'); } else { // 从 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._enterAndInitLevel().catch(err => { console.error('[PageLevel] 进入关卡失败:', err); }); } /** * 页面每次显示时调用 */ onViewShow(): void { console.log('[PageLevel] onViewShow'); this._refreshModeUI(); this.updateStaminaLabel(); if (!this._isShareMode) { this._startStaminaRecoverTimer(); } } /** * 页面隐藏时调用 */ onViewHide(): void { console.log('[PageLevel] onViewHide'); this._stopStaminaRecoverTimer(); } /** * 页面销毁时调用 */ onViewDestroy(): void { console.log('[PageLevel] onViewDestroy'); this.clearInputNodes(); this.clearPunchBlocks(); this.stopCountdown(); this._closePassModal(); 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 { 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(); // 隐藏包袱答案,通关后再按 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 = ''; editBox.maxLength = chars.length; 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; const answer = this.getAnswer(); this._isSyncingInputText = true; try { for (let i = 0; i < this._inputNodes.length; i++) { const itemEditBox = this._inputNodes[i].getComponent(EditBox); if (itemEditBox) { itemEditBox.string = i === inputIndex ? answer : ''; } } } finally { this._isSyncingInputText = false; } } /** * 输入框文本变化回调 */ private onInputTextChanged(_editBox: EditBox): void { this._lastAutoSubmittedAnswer = ''; } /** * 输入框编辑结束回调 */ private onInputEditingEnded(editBox: EditBox): void { if (this._isSyncingInputText) return; const inputIndex = this._inputNodes.findIndex(node => node === editBox.node); if (inputIndex < 0) return; this.distributeInputText(editBox.string); this.tryAutoSubmitAnswer(); } private distributeInputText(text: string): void { const chars = Array.from(text); this._isSyncingInputText = true; try { for (let i = 0; i < this._inputNodes.length; i++) { const editBox = this._inputNodes[i].getComponent(EditBox); if (editBox) { editBox.string = chars[i] ?? ''; } } } 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(); } // ========== 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 点击,返回主页'); this.playClickSound(); // 分享模式下栈中没有 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 _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(); } private _refreshTipsModeUI(): void { if (this.tipsLayout) { this.tipsLayout.active = true; } if (this._isShareMode) { this.showClue(1); this.hideClue(2); this.hideClue(3); if (this.unLockTipsBtn) { this.unLockTipsBtn.active = false; } return; } this.showClue(1); this.showClue(2); this.showClue(3); if (this.unLockTipsBtn) { this.unLockTipsBtn.active = true; } } /** * 服务端 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 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 (userAnswer === this._currentConfig.answer) { if (this._isShareMode) { this._recordCurrentShareSubmission(userAnswer); } // 答案正确,只播放成功音效(不播放点击音效,避免重合) this.showSuccess(); } else { // 答案错误 this.showError(); } } /** * 显示成功提示并上报通关 */ private async showSuccess(): Promise { console.log('[PageLevel] 答案正确!'); // 标记正在切换关卡,防止重复提交 this._isTransitioning = true; // 停止倒计时 this.stopCountdown(); // 通关音效不在这里播,改由通关弹窗 onViewShow 触发(见 PassModal._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._showPassModal(); } 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._passModalCompletedLevelCount = AuthManager.instance.completedLevelCount; // 本次预期为首次通关,起点 = 通关前计数;如果回调回退,则清掉避免误播动画 this._passModalPreviousCompletedLevelCount = previousCount; void StaminaManager.instance.completeLevel(levelId, timeSpent).then((result) => { if (result) { // 保存 complete 返回的下一关数据 this._nextLevelData = result.nextLevel; if (!result.firstClear) { // 非首次通关,回退乐观更新 AuthManager.instance.addCompletedLevelCount(-1); this._passModalCompletedLevelCount = AuthManager.instance.completedLevelCount; this._passModalPreviousCompletedLevelCount = null; } console.log(`[PageLevel] 通关上报成功,首次通关: ${result.firstClear}, 有下一关: ${!!result.nextLevel}`); // 若此时通关弹窗已打开但当时 nextLevel 还没到,补一次底图预替换 this._swapToNextLevelImagesIfReady(); } }); return; } this._passModalCompletedLevelCount = null; this._passModalPreviousCompletedLevelCount = 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 { return new Promise(resolve => setTimeout(resolve, ms)); } /** * 显示通关弹窗 * 将弹窗添加到 Canvas 根节点下(而非 PageLevel 子节点) * 这样 Widget 可以正确对齐到全屏 */ private _showPassModal(): void { if (!this.passModalPrefab) { console.warn('[PageLevel] passModalPrefab 未设置'); return; } // 如果弹窗已显示,不再重复创建 if (this._passModalNode && this._passModalNode.isValid) { return; } // 实例化弹窗 const modalNode = instantiate(this.passModalPrefab); modalNode.setPosition(PageLevel.ZERO_POS); modalNode.setSiblingIndex(PassModal.MODAL_Z_INDEX); // 找到 Canvas 根节点并添加弹窗 const canvasNode = this.node.parent; if (canvasNode) { canvasNode.addChild(modalNode); } else { this.node.addChild(modalNode); } this._passModalNode = modalNode; // 获取 PassModal 组件并设置回调 const passModal = modalNode.getComponent(PassModal); if (passModal) { const completedCount = this._getPassModalCompletedLevelCount(); const titleInfo = AchievementTitleManager.getTitleInfo(completedCount); const previousCompletedCount = this._passModalPreviousCompletedLevelCount; const previousTitleInfo = (previousCompletedCount !== null && previousCompletedCount !== completedCount) ? AchievementTitleManager.getTitleInfo(previousCompletedCount) : undefined; passModal.setParams({ levelIndex: this.getDisplayLevelNumber(), titleInfo, previousTitleInfo }); passModal.setCallbacks({ onNextLevel: () => { this._showShareNextConfirmModal(() => { this._closePassModal(); void this.goToNextLevel(); }); }, onShare: () => { // 分享后不关闭弹窗,用户可继续点击下一关 console.log('[PageLevel] 分享完成'); } }); // 动画消费完一次后清除起点,避免弹窗多次打开时复用 this._passModalPreviousCompletedLevelCount = null; // 手动调用 onViewLoad 和 onViewShow passModal.onViewLoad(); passModal.onViewShow(); } // 弹窗已全屏遮盖当前关卡,利用这个时机把底层图片静默替换为下一关 // 这样用户点"下一关"关闭弹窗时,底图已经是新的,不会出现闪烁 this._swapToNextLevelImagesIfReady(); console.log('[PageLevel] 显示通关弹窗'); } private _getPassModalCompletedLevelCount(): number { return this._passModalCompletedLevelCount ?? AuthManager.instance.completedLevelCount; } /** * 在通关弹窗遮盖期间,把底层的主图预先替换为下一关的图 * 目的:点击"下一关"关闭弹窗的瞬间,底图已经是新的,避免闪屏 * * 三种情况: * - 分享模式:直接通过 ShareManager.ensureShareLevelReady(index+1) 取下一关 config * - 正常模式 & nextLevel 已到达:从 LevelDataManager 缓存里取(enter 时已 preloadLevel 过) * - 正常模式 & nextLevel 尚未到达(complete 接口还没回包):由 reportLevelCompleted 的回调补偿调用 */ private _swapToNextLevelImagesIfReady(): void { if (this._isShareMode) { const nextIndex = this._shareLevelIndex + 1; if (nextIndex >= ShareManager.instance.getShareLevelCount()) { return; } // ensureShareLevelReady 若已缓存会立即 resolve;首次则会加载(此时图已在弹窗后面偷偷替换,用户无感) ShareManager.instance.ensureShareLevelReady(nextIndex) .then(config => this._applyNextLevelImagesFromConfig(config)) .catch(() => {}); return; } const nextLevel = this._nextLevelData ?? AuthManager.instance.nextLevel; if (!nextLevel) { // complete 接口还没回包,走回调补偿路径 return; } const config = LevelDataManager.instance.getLevelConfig(nextLevel.id); this._applyNextLevelImagesFromConfig(config); } private _applyNextLevelImagesFromConfig(config: RuntimeLevelConfig | null): void { if (!config) return; // 只在弹窗仍然打开时换图;否则说明用户已经点过下一关进入后续流程了,不再重复设置 if (!this._passModalNode || !this._passModalNode.isValid) { return; } if (config.spriteFrame1) { this.setMainImage(config.spriteFrame1); } if (config.spriteFrame2) { this.setMainImage2(config.spriteFrame2); } console.log('[PageLevel] 弹窗遮盖期间已预替换下一关底图'); } /** * 关闭通关弹窗 */ private _closePassModal(): void { if (this._passModalNode && this._passModalNode.isValid) { this._passModalNode.destroy(); this._passModalNode = null; console.log('[PageLevel] 关闭通关弹窗'); } } /** * 显示错误弹窗 */ 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 modal = CommonModal.show(this.commonModalPrefab, { title: '切换下一题', content: '确认进入下一题吗?', buttonHint: '确认', zIndex: CommonModal.MODAL_Z_INDEX + 1, onClose: () => { 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 { 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 _showShareEndPage(): Promise { 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('正在结算挑战...'); const result = await ShareManager.instance.submitShareChallenge(payload); this._isSubmittingShareResult = false; if (!result) { ToastManager.show('提交挑战结果失败,请稍后重试'); this._isTransitioning = false; return; } ViewManager.instance.replace('PagePKEnd', { params: { result, }, }); } }