1169 lines
36 KiB
TypeScript
1169 lines
36 KiB
TypeScript
import { _decorator, Node, EditBox, instantiate, Vec3, Button, Label, Sprite, SpriteFrame, AudioClip, AudioSource, Prefab } from 'cc';
|
||
import { BaseView } from 'db://assets/scripts/core/BaseView';
|
||
import { ViewManager } from 'db://assets/scripts/core/ViewManager';
|
||
import { StorageManager } from 'db://assets/scripts/utils/StorageManager';
|
||
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 { 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 { StaminaInfo } from 'db://assets/scripts/types/ApiTypes';
|
||
const { ccclass, property } = _decorator;
|
||
|
||
/**
|
||
* 关卡页面组件
|
||
* 继承 BaseView,实现页面生命周期
|
||
*/
|
||
@ccclass('PageLevel')
|
||
export class PageLevel extends BaseView {
|
||
/** 静态常量:零位置 */
|
||
private static readonly ZERO_POS = new Vec3(0, 0, 0);
|
||
|
||
/** 默认体力上限,服务端未返回 max 时使用 */
|
||
private static readonly DEFAULT_STAMINA_MAX = 50;
|
||
|
||
// ========== 节点引用 ==========
|
||
@property(Node)
|
||
inputLayout: Node | null = null;
|
||
|
||
@property(Node)
|
||
punchLayout: 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;
|
||
|
||
// ========== 配置属性 ==========
|
||
@property({
|
||
min: 0,
|
||
tooltip: '当前关卡索引'
|
||
})
|
||
currentLevelIndex: number = 0;
|
||
|
||
@property(AudioClip)
|
||
clickAudio: AudioClip | null = null;
|
||
|
||
@property(AudioClip)
|
||
successAudio: AudioClip | null = null;
|
||
|
||
@property(AudioClip)
|
||
failAudio: AudioClip | null = null;
|
||
|
||
@property(Prefab)
|
||
passModalPrefab: Prefab | null = null;
|
||
|
||
// ========== 内部状态 ==========
|
||
/** 当前创建的输入框节点数组 */
|
||
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;
|
||
|
||
/** 关卡开始时间戳(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 _isShareMode: boolean = false;
|
||
|
||
/** 体力恢复倒计时定时器 */
|
||
private _staminaTimerId: ReturnType<typeof setInterval> | null = null;
|
||
|
||
/**
|
||
* 页面首次加载时调用
|
||
*/
|
||
onViewLoad(): void {
|
||
console.log('[PageLevel] onViewLoad');
|
||
|
||
const params = this.getParams();
|
||
this._isShareMode = params?.shareMode === true;
|
||
|
||
if (this._isShareMode) {
|
||
this.currentLevelIndex = 0;
|
||
console.log('[PageLevel] 进入分享挑战模式');
|
||
} else {
|
||
// 根据关卡列表找到第一个未通关的关卡
|
||
this.currentLevelIndex = LevelDataManager.instance.getFirstUncompletedIndex();
|
||
StorageManager.setCurrentLevelIndex(this.currentLevelIndex);
|
||
console.log(`[PageLevel] 进入第一个未通关关卡: 第 ${this.currentLevelIndex + 1} 关`);
|
||
}
|
||
this.updateStaminaLabel();
|
||
this.initIconSetting();
|
||
this.initUnlockButtons();
|
||
this.initSubmitButton();
|
||
|
||
// 异步加载关卡资源并调用进入关卡接口,完成后启动倒计时
|
||
this._enterAndInitLevel().catch(err => {
|
||
console.error('[PageLevel] 进入关卡失败:', err);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 页面每次显示时调用
|
||
*/
|
||
onViewShow(): void {
|
||
console.log('[PageLevel] onViewShow');
|
||
this.updateStaminaLabel();
|
||
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._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);
|
||
}
|
||
|
||
/**
|
||
* 进入关卡并初始化
|
||
* 1. 加载关卡图片资源
|
||
* 2. 调用进入关卡接口(消耗体力,获取答案和线索)
|
||
* 3. 启动倒计时
|
||
*/
|
||
private async _enterAndInitLevel(): Promise<void> {
|
||
// 先加载关卡图片资源
|
||
let config: RuntimeLevelConfig | null = null;
|
||
|
||
if (this._isShareMode) {
|
||
config = await ShareManager.instance.ensureShareLevelReady(this.currentLevelIndex);
|
||
} else {
|
||
config = LevelDataManager.instance.getLevelConfig(this.currentLevelIndex);
|
||
if (!config) {
|
||
console.log(`[PageLevel] 关卡 ${this.currentLevelIndex + 1} 资源未缓存,开始加载...`);
|
||
config = await LevelDataManager.instance.ensureLevelReady(this.currentLevelIndex);
|
||
}
|
||
}
|
||
|
||
if (!config) {
|
||
console.warn(`[PageLevel] 没有找到关卡配置,索引: ${this.currentLevelIndex}`);
|
||
return;
|
||
}
|
||
|
||
// 非分享模式下,调用进入关卡接口获取答案和线索
|
||
if (!this._isShareMode) {
|
||
const levelId = LevelDataManager.instance.getLevelId(this.currentLevelIndex);
|
||
if (levelId) {
|
||
const enterData = await StaminaManager.instance.enterLevel(levelId);
|
||
if (!enterData) {
|
||
// 进入关卡失败(可能是体力不足)
|
||
const stamina = StaminaManager.instance.getStamina();
|
||
if (stamina.current <= 0) {
|
||
ToastManager.show('体力不足,请等待恢复');
|
||
this._startStaminaRecoverTimer();
|
||
} else {
|
||
ToastManager.show('进入关卡失败,请重试');
|
||
}
|
||
this.updateStaminaLabel();
|
||
return;
|
||
}
|
||
|
||
// 提示用户消耗体力
|
||
ToastManager.show(`消耗1点体力,剩余 ${enterData.stamina.current}/${this._getStaminaMax(enterData.stamina)}`);
|
||
|
||
// 用 enter 接口返回的数据更新关卡配置(填充答案和线索)
|
||
LevelDataManager.instance.updateLevelDetails(
|
||
this.currentLevelIndex,
|
||
{
|
||
answer: enterData.answer,
|
||
hint1: enterData.hint1,
|
||
hint2: enterData.hint2,
|
||
hint3: enterData.hint3,
|
||
}
|
||
);
|
||
|
||
// 重新获取更新后的配置
|
||
config = LevelDataManager.instance.getLevelConfig(this.currentLevelIndex);
|
||
if (!config) {
|
||
console.error('[PageLevel] 更新关卡详情后获取配置失败');
|
||
return;
|
||
}
|
||
|
||
// 更新体力显示
|
||
this.updateStaminaLabel();
|
||
}
|
||
}
|
||
|
||
console.log(`[PageLevel] 初始化关卡 ${this.currentLevelIndex + 1}: ${config.name}`);
|
||
this._applyLevelConfig(config);
|
||
this.startCountdown();
|
||
}
|
||
|
||
/**
|
||
* 应用关卡配置(通用初始化逻辑)
|
||
*/
|
||
private _applyLevelConfig(config: RuntimeLevelConfig): void {
|
||
this._currentConfig = config;
|
||
|
||
// 重置关卡切换状态,允许再次提交
|
||
this._isTransitioning = false;
|
||
|
||
// 重置倒计时状态
|
||
this._isTimeUp = false;
|
||
this._countdown = 60;
|
||
|
||
// 设置主图(图片1)
|
||
this.setMainImage(config.spriteFrame1);
|
||
|
||
// 设置图片2
|
||
this.setMainImage2(config.spriteFrame2);
|
||
|
||
// 设置图片描述
|
||
this.setImageDescriptions(config.image1Description, config.image2Description);
|
||
|
||
// 隐藏谐音梗说明(通关后才显示)
|
||
this.setPunchline(null);
|
||
|
||
// 设置线索1(默认解锁,如果有的话)
|
||
if (config.clue1) {
|
||
this.setClue(1, config.clue1);
|
||
}
|
||
|
||
// 重置线索解锁进度
|
||
this._nextClueIndex = 2;
|
||
|
||
// 线索2、3 保持显示,写入"待解锁"占位文案
|
||
this.setClue(2, '待解锁');
|
||
this.setClue(3, '待解锁');
|
||
|
||
// 显示解锁按钮(单个统一按钮)
|
||
this.showUnlockButton();
|
||
|
||
// 根据答案字数创建输入格
|
||
if (config.answer) {
|
||
this.createInputBlocks(config.answer);
|
||
}
|
||
|
||
// 更新倒计时显示
|
||
this.updateClockLabel();
|
||
|
||
// 预加载下一关图片(静默加载,不阻塞)
|
||
if (this._isShareMode) {
|
||
const nextIndex = this.currentLevelIndex + 1;
|
||
if (nextIndex < ShareManager.instance.getShareLevelCount()) {
|
||
ShareManager.instance.ensureShareLevelReady(nextIndex).catch(() => {});
|
||
}
|
||
} else {
|
||
LevelDataManager.instance.preloadNextLevel(this.currentLevelIndex);
|
||
}
|
||
|
||
console.log(`[PageLevel] 初始化关卡 ${this.currentLevelIndex + 1}, 答案长度: ${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.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.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 onInputTextChanged(editBox: EditBox): void {
|
||
if (this._isSyncingInputText) return;
|
||
|
||
const inputIndex = this._inputNodes.findIndex(node => node === editBox.node);
|
||
if (inputIndex < 0) return;
|
||
|
||
this.distributeInputText(inputIndex, editBox.string);
|
||
this.tryAutoSubmitAnswer();
|
||
}
|
||
|
||
/**
|
||
* 输入框编辑结束回调
|
||
*/
|
||
private onInputEditingEnded(_editBox: EditBox): void {
|
||
console.log('[PageLevel] 输入编辑结束');
|
||
}
|
||
|
||
private distributeInputText(startIndex: number, text: string): void {
|
||
const chars = Array.from(text);
|
||
this._isSyncingInputText = true;
|
||
|
||
try {
|
||
if (chars.length <= 1) {
|
||
const editBox = this._inputNodes[startIndex]?.getComponent(EditBox);
|
||
if (editBox) {
|
||
editBox.string = chars[0] ?? '';
|
||
}
|
||
return;
|
||
}
|
||
|
||
for (let i = startIndex; i < this._inputNodes.length; i++) {
|
||
const editBox = this._inputNodes[i].getComponent(EditBox);
|
||
if (editBox) {
|
||
editBox.string = chars[i - startIndex] ?? '';
|
||
}
|
||
}
|
||
} 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;
|
||
|
||
// 查找 TipsLabel 节点:Content -> TipsLabel
|
||
const contentNode = tipsItem.getChildByName('Content');
|
||
if (!contentNode) return;
|
||
|
||
const tipsLabelNode = contentNode.getChildByName('TipsLabel');
|
||
if (!tipsLabelNode) return;
|
||
|
||
const label = tipsLabelNode.getComponent(Label);
|
||
if (label) {
|
||
label.string = `提示 ${index}: ${content}`;
|
||
console.log(`[PageLevel] 设置线索${index}: ${content}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 显示线索
|
||
*/
|
||
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;
|
||
console.log('[PageLevel] 显示解锁按钮');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 隐藏解锁按钮
|
||
*/
|
||
private hideUnlockButton(_index?: number): void {
|
||
if (this.unLockTipsBtn) {
|
||
this.unLockTipsBtn.active = false;
|
||
console.log('[PageLevel] 隐藏解锁按钮');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 初始化解锁按钮事件
|
||
*/
|
||
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] 提交按钮事件已绑定');
|
||
}
|
||
|
||
/**
|
||
* 点击解锁线索(顺序解锁:先线索2,再线索3;全部解锁后提示已解锁完毕)
|
||
*/
|
||
private onUnlockClue(): void {
|
||
// 全部已解锁,提示用户
|
||
if (this._nextClueIndex > 3) {
|
||
ToastManager.show('已解锁完毕');
|
||
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);
|
||
|
||
// 推进到下一条待解锁线索
|
||
this._nextClueIndex++;
|
||
|
||
// 全部解锁完毕则隐藏按钮
|
||
if (this._nextClueIndex > 3) {
|
||
this.hideUnlockButton();
|
||
}
|
||
|
||
console.log(`[PageLevel] 解锁线索${index}`);
|
||
}
|
||
|
||
/**
|
||
* 点击增加时间按钮(倒计时增加 60 秒)
|
||
*/
|
||
private onAddTime(): void {
|
||
if (this._isTimeUp) {
|
||
ToastManager.show('时间已结束,无法增加');
|
||
return;
|
||
}
|
||
|
||
this._countdown += 60;
|
||
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;
|
||
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;
|
||
console.log('[PageLevel] 设置主图2');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 设置图片描述文本
|
||
*/
|
||
private setImageDescriptions(desc1: string | null, desc2: string | null): void {
|
||
if (this.image1DescLabel) {
|
||
this.image1DescLabel.string = desc1 ?? '';
|
||
}
|
||
if (this.image2DescLabel) {
|
||
this.image2DescLabel.string = desc2 ?? '';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 设置谐音梗说明(通关后逐字展示,未通关时传 null 隐藏)
|
||
*/
|
||
private setPunchline(punchline: string | null): void {
|
||
if (!this.punchLayout) return;
|
||
|
||
const chars = Array.from(punchline ?? '');
|
||
if (chars.length === 0) {
|
||
this.punchLayout.active = false;
|
||
this.clearPunchBlocks();
|
||
return;
|
||
}
|
||
|
||
const template = this.getPunchBlockTemplateNode();
|
||
if (!template) {
|
||
console.error('[PageLevel] punchLayout 下未找到默认 block 节点');
|
||
return;
|
||
}
|
||
|
||
this.clearPunchBlocks();
|
||
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.string = chars[i];
|
||
}
|
||
|
||
if (blockNode.parent !== this.punchLayout) {
|
||
this.punchLayout.addChild(blockNode);
|
||
}
|
||
this._punchBlockNodes.push(blockNode);
|
||
}
|
||
}
|
||
|
||
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.destroy();
|
||
}
|
||
}
|
||
}
|
||
this._punchBlockNodes = [];
|
||
}
|
||
|
||
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 blockNode.getChildByName('Label')?.getComponent(Label) ?? blockNode.getComponent(Label);
|
||
}
|
||
|
||
// ========== 音效相关方法 ==========
|
||
|
||
/**
|
||
* 播放音效(通用方法)
|
||
*/
|
||
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 playSuccessSound(): void {
|
||
this.playSound(this.successAudio);
|
||
}
|
||
|
||
/**
|
||
* 播放失败音效
|
||
*/
|
||
private playFailSound(): void {
|
||
this.playSound(this.failAudio);
|
||
}
|
||
|
||
// ========== 倒计时相关方法 ==========
|
||
|
||
/**
|
||
* 开始倒计时
|
||
*/
|
||
private startCountdown(): void {
|
||
this._countdown = 60;
|
||
this._isTimeUp = false;
|
||
this._levelStartTime = Date.now();
|
||
this.updateClockLabel();
|
||
this.schedule(this.onCountdownTick, 1);
|
||
console.log('[PageLevel] 开始倒计时 60 秒');
|
||
}
|
||
|
||
/**
|
||
* 停止倒计时
|
||
*/
|
||
private stopCountdown(): void {
|
||
this.unschedule(this.onCountdownTick);
|
||
}
|
||
|
||
/**
|
||
* 倒计时每秒回调
|
||
*/
|
||
private onCountdownTick(): void {
|
||
if (this._isTimeUp) return;
|
||
|
||
this._countdown--;
|
||
this.updateClockLabel();
|
||
|
||
if (this._countdown <= 0) {
|
||
this._isTimeUp = true;
|
||
this.stopCountdown();
|
||
this.onTimeUp();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 更新倒计时显示
|
||
*/
|
||
private updateClockLabel(): void {
|
||
if (this.clockLabel) {
|
||
this.clockLabel.string = `${this._countdown}s`;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 倒计时结束
|
||
*/
|
||
private onTimeUp(): void {
|
||
console.log('[PageLevel] 倒计时结束!');
|
||
this.playFailSound();
|
||
// 可以在这里添加游戏结束逻辑
|
||
}
|
||
|
||
// ========== 体力值相关方法 ==========
|
||
|
||
/** 上次显示的体力值,用于变更检测 */
|
||
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.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 {
|
||
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 < currentStamina.max) {
|
||
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) {
|
||
// 答案正确,只播放成功音效(不播放点击音效,避免重合)
|
||
this.showSuccess();
|
||
} else {
|
||
// 答案错误
|
||
this.showError();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 显示成功提示并上报通关
|
||
*/
|
||
private async showSuccess(): Promise<void> {
|
||
console.log('[PageLevel] 答案正确!');
|
||
|
||
// 标记正在切换关卡,防止重复提交
|
||
this._isTransitioning = true;
|
||
|
||
// 停止倒计时
|
||
this.stopCountdown();
|
||
|
||
// 播放成功音效
|
||
this.playSuccessSound();
|
||
|
||
// 通关后展示谐音梗说明
|
||
if (this._currentConfig?.punchline) {
|
||
this.setPunchline(this._currentConfig.punchline);
|
||
}
|
||
|
||
const levelId = this._currentConfig?.id ?? '';
|
||
const timeSpent = Math.max(0, Math.round((Date.now() - this._levelStartTime) / 1000));
|
||
|
||
if (!this._isShareMode) {
|
||
// 上报通关耗时
|
||
const result = await StaminaManager.instance.completeLevel(levelId, timeSpent);
|
||
if (result) {
|
||
console.log(`[PageLevel] 通关上报成功,首次通关: ${result.firstClear}`);
|
||
}
|
||
// 标记关卡为已通关(本地缓存)
|
||
LevelDataManager.instance.markLevelCompleted(this.currentLevelIndex);
|
||
} else {
|
||
// fire-and-forget: errors are logged inside reportLevelProgress
|
||
void ShareManager.instance.reportLevelProgress(levelId, true, timeSpent);
|
||
}
|
||
|
||
// 显示通关弹窗
|
||
this._showPassModal();
|
||
}
|
||
|
||
/**
|
||
* 显示通关弹窗
|
||
* 将弹窗添加到 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) {
|
||
passModal.setParams({ levelIndex: this.currentLevelIndex + 1 });
|
||
passModal.setCallbacks({
|
||
onNextLevel: () => {
|
||
this._closePassModal();
|
||
this.nextLevel();
|
||
},
|
||
onShare: () => {
|
||
// 分享后不关闭弹窗,用户可继续点击下一关
|
||
console.log('[PageLevel] 分享完成');
|
||
}
|
||
});
|
||
// 手动调用 onViewLoad 和 onViewShow
|
||
passModal.onViewLoad();
|
||
passModal.onViewShow();
|
||
}
|
||
|
||
console.log('[PageLevel] 显示通关弹窗');
|
||
}
|
||
|
||
/**
|
||
* 关闭通关弹窗
|
||
*/
|
||
private _closePassModal(): void {
|
||
if (this._passModalNode && this._passModalNode.isValid) {
|
||
this._passModalNode.destroy();
|
||
this._passModalNode = null;
|
||
console.log('[PageLevel] 关闭通关弹窗');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 显示错误提示
|
||
*/
|
||
private showError(): void {
|
||
console.log('[PageLevel] 答案错误!');
|
||
|
||
// 播放失败音效
|
||
this.playFailSound();
|
||
|
||
// 触发手机震动
|
||
WxSDK.vibrateLong();
|
||
|
||
// 显示 Toast 提示
|
||
ToastManager.show('答案错误,再试试吧!');
|
||
}
|
||
|
||
/**
|
||
* 进入下一关
|
||
*/
|
||
private async nextLevel(): Promise<void> {
|
||
// 标记当前关卡已通关
|
||
if (!this._isShareMode) {
|
||
StorageManager.onLevelCompleted(this.currentLevelIndex);
|
||
LevelDataManager.instance.markLevelCompleted(this.currentLevelIndex);
|
||
}
|
||
|
||
// 查找下一个未通关的关卡
|
||
if (this._isShareMode) {
|
||
this.currentLevelIndex++;
|
||
const totalLevels = ShareManager.instance.getShareLevelCount();
|
||
if (this.currentLevelIndex >= totalLevels) {
|
||
console.log('[PageLevel] 分享关卡全部完成');
|
||
this.stopCountdown();
|
||
ShareManager.instance.clearShareMode();
|
||
ViewManager.instance.replace('PageHome');
|
||
return;
|
||
}
|
||
} else {
|
||
const nextIndex = LevelDataManager.instance.getNextUncompletedIndex(this.currentLevelIndex);
|
||
if (nextIndex < 0) {
|
||
// 所有关卡全部通关
|
||
console.log('[PageLevel] 恭喜通关!所有关卡已完成');
|
||
this.stopCountdown();
|
||
ViewManager.instance.back();
|
||
return;
|
||
}
|
||
this.currentLevelIndex = nextIndex;
|
||
StorageManager.setCurrentLevelIndex(this.currentLevelIndex);
|
||
}
|
||
|
||
// 重置并加载下一关(包含进入关卡接口调用)
|
||
await this._enterAndInitLevel();
|
||
console.log(`[PageLevel] 进入关卡 ${this.currentLevelIndex + 1}`);
|
||
}
|
||
}
|