feat: 优化通关逻辑

This commit is contained in:
richarjiang
2026-05-25 11:00:44 +08:00
parent 2a599b0356
commit 3bfce30607
18 changed files with 7593 additions and 177 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@@ -29,6 +29,23 @@ assets/
- **运行预览**: 在 Cocos Creator 编辑器中点击 "Play" 按钮
- **构建**: 使用编辑器菜单 `Project > Build` 或快捷键 `Cmd+B`
## Editor Operations (强制约定)
**凡是涉及 prefab、scene、node、component 或 Cocos 编辑器内的任何操作,必须使用 `cocos-creator` MCP不要手工编辑 `.prefab` / `.scene` 的 YAML/JSON 文件。**
适用范围(非穷举):
- 创建 / 修改 / 删除 prefab`mcp__cocos-creator__prefab_*`
- 场景增删改、打开/保存场景:`mcp__cocos-creator__scene_*`
- 节点结构(增删、移动、改 transform、改属性`mcp__cocos-creator__node_*`
- 组件挂载、属性赋值、引用绑定Sprite/Label/Button/自定义脚本等):`mcp__cocos-creator__component_*`
- 资源管理(导入、查询 UUID、刷新、引用校验`mcp__cocos-creator__project_*` / `mcp__cocos-creator__assetAdvanced_*`
- 场景脚本执行、调试日志、性能数据:`mcp__cocos-creator__debug_*` / `mcp__cocos-creator__sceneAdvanced_*`
允许直接编辑的文件仍是:`.ts` 脚本源代码(`assets/**/*.ts`)、纯文本配置(`tsconfig.json``package.json` 等)。`.prefab` / `.scene` / `.meta` 一律走 MCP避免 UUID 错位、引用丢失、序列化格式被破坏。
操作前先用 `scene_get_current_scene` / `node_get_all_nodes` / `prefab_get_prefab_info` 等查询类工具确认当前编辑器状态,不要凭记忆操作。
## TypeScript Coding
遵循 Cocos Creator 3.x 组件系统架构:

BIN
assets/.DS_Store vendored

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
import { _decorator, Node, EditBox, instantiate, Vec3, Button, Label, Sprite, SpriteFrame, AudioClip, AudioSource, Prefab, EffectAsset, UITransform, UIOpacity, tween, Tween, Color, Layout } from 'cc';
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';
@@ -11,12 +11,12 @@ 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 { 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 { ApiEnvelope, StaminaInfo, NextLevelData, SubmitShareLevel } from 'db://assets/scripts/types/ApiTypes';
import { AchievementTitleManager } from 'db://assets/scripts/utils/AchievementTitleManager';
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;
@@ -82,6 +82,15 @@ export class PageLevel extends BaseView {
/** 分享模式最后一题提交文案 */
private static readonly SHARE_SUBMIT_BUTTON_TEXT = '提交答案';
/** PassNode 滑入 / 滑出动画时长(秒) */
private static readonly PASS_NODE_SLIDE_DURATION = 0.4;
/** BottomLayout / TipsLayout 透明度淡入淡出动画时长(秒) */
private static readonly BOTTOM_LAYER_FADE_DURATION = 0.25;
/** 彩带 spine 动画名(一次播放) */
private static readonly CAIDAI_ANIMATION_NAME = 'open';
// ========== 节点引用 ==========
@property(Node)
inputLayout: Node | null = null;
@@ -186,6 +195,47 @@ export class PageLevel extends BaseView {
@property(Node)
pkNextLevelButton: Node | null = null;
// ========== PassNode通关后展示==========
/** PassNode 根节点:用户通关后从屏幕左侧滑入 */
@property(Node)
passNode: Node | null = null;
/** PassNode 内的成就体系容器TitlteLevel。分享模式下整体隐藏 */
@property(Node)
passTitleLevelNode: Node | null = null;
/** 称号文案 Label如「冷场小白1级」 */
@property(Label)
passTitleLevelLabel: Label | null = null;
/** 称号进度条 */
@property(ProgressBar)
passTitleProgressBar: ProgressBar | null = null;
/** 进度提示 Label如「还差3题解锁新成就等级」 */
@property(Label)
passProgressLabel: Label | null = null;
/** 进度条上跟随移动的 anchor 节点(其下 Label 显示百分比) */
@property(Node)
passProgressAnchor: Node | null = null;
/** 通关页「下一关 / 提交答案」按钮 */
@property(Node)
passNextLevelButton: Node | null = null;
/** 通关页「分享」按钮 */
@property(Node)
passShareButton: Node | null = null;
/** 彩带 spine 根节点(与 PassNode 同级,挂在 PageLevel 下) */
@property(Node)
caidaiNode: Node | null = null;
/** 彩带 sp.Skeleton 组件,用于播放 "open" 动画 */
@property(sp.Skeleton)
caidaiSkeleton: sp.Skeleton | null = null;
// ========== 配置属性 ==========
@property(AudioClip)
clickAudio: AudioClip | null = null;
@@ -196,9 +246,6 @@ export class PageLevel extends BaseView {
@property(AudioClip)
failAudio: AudioClip | null = null;
@property(Prefab)
passModalPrefab: Prefab | null = null;
@property(Prefab)
wrongModalPrefab: Prefab | null = null;
@@ -271,14 +318,23 @@ export class PageLevel extends BaseView {
/** 下一个待解锁的线索序号2 或 3超过 3 表示全部已解锁 */
private _nextClueIndex: number = 2;
/** 通关弹窗实例 */
private _passModalNode: Node | null = null;
/** 通关PassNode当前是否已展示 */
private _isPassNodeShown: boolean = false;
/** 本次通关弹窗使用的已通关数量 */
private _passModalCompletedLevelCount: number | null = null;
/** PassNode 进出动画是否在进行中(防止重入) */
private _isPassNodeAnimating: boolean = false;
/** 本次通关弹窗动画起点(通关前)的已通关数量;为 null 表示不播动画 */
private _passModalPreviousCompletedLevelCount: number | null = null;
/** PassNode 原始 local positionprefab 摆放位),动画结束后用来回归 */
private _passNodeOriginalPos: Vec3 | null = null;
/** 通关页所用「已通关数量」(业务数据,给成就体系展示用) */
private _passCompletedLevelCount: number | null = null;
/** 通关页动画起点(通关前)的已通关数量;为 null 表示不播跨称号过渡 */
private _passPreviousCompletedLevelCount: number | null = null;
/** 通关页称号 / 进度条动画工具,惰性初始化 */
private _titleAnimator: AchievementTitleAnimator | null = null;
/** 错误弹窗实例 */
private _wrongModalNode: Node | null = null;
@@ -364,6 +420,7 @@ export class PageLevel extends BaseView {
this.initUnlockButtons();
this.initSubmitButton();
this.initPkNextLevelButton();
this._initPassNodeState();
// 异步加载关卡资源并调用进入关卡接口,完成后启动倒计时
this._enterAndInitLevel().catch(err => {
@@ -405,12 +462,12 @@ export class PageLevel extends BaseView {
// - 提交按钮被 _isTransitioning 锁住,无法重新提交
// - 倒计时已停、谐音梗已揭示
// 此时玩家从首页再次进入会看到一个无法操作的"死局"。必须把会话推进到下一关。
// 注意:这只可能在主线模式发生 —— 分享模式下点 iconSetting / PassModal 的 home
// 注意:这只可能在主线模式发生 —— 分享模式下点 iconSetting / PassNode 的入口
// 都会调用 ShareManager.clearShareMode + ViewManager.replace再次进入会被
// 上面的 modeChanged / shareCodeChanged 分支拦截走 _reinitLevelSession。
if (this._isTransitioning) {
console.log('[PageLevel] 上次离场时停留在通关后状态,自动推进到下一关');
this._closePassModal();
this._resetPassNode();
this._closeWrongModal();
this._closeTimeoutModal();
this._closeCommonModal();
@@ -433,7 +490,7 @@ export class PageLevel extends BaseView {
this._isShareMode = shareMode;
// 上一场可能遗留的弹窗 / 倒计时一并清掉,避免主线模式还看到分享态弹窗
this._closePassModal();
this._resetPassNode();
this._closeWrongModal();
this._closeTimeoutModal();
this._closeCommonModal();
@@ -493,7 +550,7 @@ export class PageLevel extends BaseView {
this.clearInputNodes();
this.clearPunchBlocks();
this.stopCountdown();
this._closePassModal();
this._resetPassNode();
this._closeWrongModal();
this._closeTimeoutModal();
this._closeCommonModal();
@@ -908,11 +965,11 @@ export class PageLevel extends BaseView {
AudioManager.instance.playButtonClick();
// 离开 PageLevel 时把所有挂在 Canvas 上的关卡级弹窗一起清掉。
// PassModal / WrongModal / TimeoutModal / CommonModal 都是 addChild 到 this.node.parent
// WrongModal / TimeoutModal / CommonModal 都是 addChild 到 this.node.parent
// 也就是 PageLevel 的兄弟节点PageLevel 被 ViewManager 隐藏后它们并不会自动消失,
// 否则会孤儿地盖在 PageHome 上。同时清掉 PassModal 也避免再次进入时缓存实例
// 残留 _passModalNode 引用让 _swapToNextLevelImagesIfReady 误判弹窗仍在打开
this._closePassModal();
// 否则会孤儿地盖在 PageHome 上。
// PassNode 是 PageLevel 自身子节点(不是弹窗),用 _resetPassNode 同步归位即可
this._resetPassNode();
this._closeWrongModal();
this._closeTimeoutModal();
this._closeCommonModal();
@@ -1662,6 +1719,20 @@ export class PageLevel extends BaseView {
this.playSound(this.failAudio);
}
/**
* 播放通关成功音效
*/
private playSuccessSound(): void {
this.playSound(this.successAudio);
}
/**
* 播放通关成功音效
*/
private playSuccessSound(): void {
this.playSound(this.successAudio);
}
// ========== 倒计时相关方法 ==========
/**
@@ -1902,8 +1973,8 @@ export class PageLevel extends BaseView {
// 停止倒计时
this.stopCountdown();
// 通关音效不在这里播,改由通关弹窗 onViewShow 触发(见 PassModal._playSuccessSound
// 产品节奏:玩家看到弹窗的瞬间音效才响起,避免谐音梗揭示期间就被音效抢戏
// 通关音效不在这里播,改由 _showPassNode 在 PassNode 弹起时触发(_playSuccessSound
// 产品节奏:玩家看到通关页的瞬间音效才响起,避免谐音梗揭示期间就被音效抢戏
const punchline = this.getValidPunchline(this._currentConfig?.punchline ?? null);
if (punchline) {
@@ -1922,7 +1993,7 @@ export class PageLevel extends BaseView {
await this.delay(PageLevel.PASS_MODAL_DELAY_MS);
// 显示通关弹窗
this._showPassModal();
this._showPassNode();
}
private getValidPunchline(punchline: string | null): string | null {
@@ -1941,9 +2012,9 @@ export class PageLevel extends BaseView {
// 乐观更新通关计数(用于称号展示)
const previousCount = AuthManager.instance.completedLevelCount;
AuthManager.instance.addCompletedLevelCount();
this._passModalCompletedLevelCount = AuthManager.instance.completedLevelCount;
this._passCompletedLevelCount = AuthManager.instance.completedLevelCount;
// 本次预期为首次通关,起点 = 通关前计数;如果回调回退,则清掉避免误播动画
this._passModalPreviousCompletedLevelCount = previousCount;
this._passPreviousCompletedLevelCount = previousCount;
void StaminaManager.instance.completeLevel(levelId, timeSpent).then((result) => {
if (result) {
@@ -1953,20 +2024,17 @@ export class PageLevel extends BaseView {
if (!result.firstClear) {
// 非首次通关,回退乐观更新
AuthManager.instance.addCompletedLevelCount(-1);
this._passModalCompletedLevelCount = AuthManager.instance.completedLevelCount;
this._passModalPreviousCompletedLevelCount = null;
this._passCompletedLevelCount = AuthManager.instance.completedLevelCount;
this._passPreviousCompletedLevelCount = null;
}
console.log(`[PageLevel] 通关上报成功,首次通关: ${result.firstClear}, 有下一关: ${!!result.nextLevel}`);
// 若此时通关弹窗已打开但当时 nextLevel 还没到,补一次底图预替换
this._swapToNextLevelImagesIfReady();
}
});
return;
}
this._passModalCompletedLevelCount = null;
this._passModalPreviousCompletedLevelCount = null;
this._passCompletedLevelCount = null;
this._passPreviousCompletedLevelCount = null;
this._recordCurrentShareSubmission(undefined, timeSpent);
}
@@ -2011,158 +2079,357 @@ export class PageLevel extends BaseView {
}
/**
* 显示通关弹窗
* 将弹窗添加到 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(),
nextButtonText: this._isShareMode && this._isFinalShareLevel()
? PageLevel.SHARE_SUBMIT_BUTTON_TEXT
: undefined,
titleInfo,
previousTitleInfo
});
passModal.setCallbacks({
onNextLevel: () => {
if (this._isShareMode) {
this._closePassModal();
void this.goToNextLevel();
return;
}
this._showShareNextConfirmModal(() => {
this._closePassModal();
void this.goToNextLevel();
});
},
onShare: () => {
// 分享后不关闭弹窗,用户可继续点击下一关
console.log('[PageLevel] 分享完成');
},
onHome: () => {
this._closePassModal();
if (this._isShareMode) {
ShareManager.instance.clearShareMode();
ViewManager.instance.replace('PageHome');
return;
}
ViewManager.instance.back();
}
});
// 动画消费完一次后清除起点,避免弹窗多次打开时复用
this._passModalPreviousCompletedLevelCount = null;
// 手动调用 onViewLoad 和 onViewShow
passModal.onViewLoad();
passModal.onViewShow();
}
// 弹窗已全屏遮盖当前关卡,利用这个时机把底层图片静默替换为下一关
// 这样用户点"下一关"关闭弹窗时,底图已经是新的,不会出现闪烁
this._swapToNextLevelImagesIfReady();
console.log('[PageLevel] 显示通关弹窗');
}
private _getPassModalCompletedLevelCount(): number {
return this._passModalCompletedLevelCount ?? AuthManager.instance.completedLevelCount;
}
/**
* 在通关弹窗遮盖期间,把底层的主图预先替换为下一关的图
* 目的:点击"下一关"关闭弹窗的瞬间,底图已经是新的,避免闪屏
* 通关展示BottomLayout / TipsLayout 透明度淡出PassNode 从屏幕左侧滑入,
* Caidai 彩带 spine 播放 "open" 动画;按 NextLevel 时再走 _hidePassNode。
*
* 三种情况
* - 分享模式:直接通过 ShareManager.ensureShareLevelReady(index+1) 取下一关 config
* - 正常模式 & nextLevel 已到达:从 LevelDataManager 缓存里取enter 时已 preloadLevel 过
* - 正常模式 & nextLevel 尚未到达complete 接口还没回包):由 reportLevelCompleted 的回调补偿调用
* 与原 PassModal 弹窗的差异
* - PassNode 是 PageLevel 自己的子节点,不是 instantiate 出的弹窗,无需挂到 Canvas
* - PassNode 不全屏遮盖,所以不能在它显示期间偷偷预换底图(用户能看见底图
* - 分享模式不展示成就体系passTitleLevelNode 整体隐藏)
*/
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(() => {});
private _showPassNode(): void {
if (!this.passNode) {
console.warn('[PageLevel] passNode 未挂引用,跳过通关展示');
return;
}
const nextLevel = this._nextLevelData ?? AuthManager.instance.nextLevel;
if (!nextLevel) {
// complete 接口还没回包,走回调补偿路径
if (this._isPassNodeShown || this._isPassNodeAnimating) {
return;
}
const config = LevelDataManager.instance.getLevelConfig(nextLevel.id);
this._applyNextLevelImagesFromConfig(config);
this._isPassNodeShown = true;
// 配置成就体系数据 / 按钮文案 / 事件
this._setupPassNodeContent();
this._bindPassNodeEvents();
// 启动彩带 + 滑入动画 + 淡出底部UI + 通关音效
this._playCaidai();
this._playPassNodeShowAnimation();
this.playSuccessSound();
console.log('[PageLevel] 显示通关页 PassNode');
}
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 _getPassCompletedLevelCount(): number {
return this._passCompletedLevelCount ?? AuthManager.instance.completedLevelCount;
}
/**
* 关闭通关弹窗
* 配置通关页内容:
* - 成就体系(分享模式整体隐藏;普通模式根据通关数量绑定文案 / 进度条 / 跨称号动画)
* - 「下一关 / 提交答案」按钮文案:分享模式最后一题显示"提交答案"
*/
private _closePassModal(): void {
if (this._passModalNode && this._passModalNode.isValid) {
this._passModalNode.destroy();
this._passModalNode = null;
console.log('[PageLevel] 关闭通关弹窗');
private _setupPassNodeContent(): void {
const showTitle = !this._isShareMode;
if (this.passTitleLevelNode) {
this.passTitleLevelNode.active = showTitle;
}
if (showTitle) {
const completedCount = this._getPassCompletedLevelCount();
const titleInfo = AchievementTitleManager.getTitleInfo(completedCount);
const previousCompletedCount = this._passPreviousCompletedLevelCount;
const previousTitleInfo: AchievementTitleInfo | null = (
previousCompletedCount !== null && previousCompletedCount !== completedCount
)
? AchievementTitleManager.getTitleInfo(previousCompletedCount)
: null;
const animator = this._ensureTitleAnimator();
animator.bind({
titleLabel: this.passTitleLevelLabel,
progressLabel: this.passProgressLabel,
progressBar: this.passTitleProgressBar,
progressAnchor: this.passProgressAnchor,
});
animator.playTransition(previousTitleInfo, titleInfo);
// 跨称号动画起点是一次性的,消费完清掉,避免下次重复
this._passPreviousCompletedLevelCount = null;
} else {
// 分享模式不展示成就,停掉可能的旧动画
this._titleAnimator?.stop();
}
// NextLevelButton 文案
const isFinalShareSubmit = this._isShareMode && this._isFinalShareLevel();
const nextLabel = this.passNextLevelButton?.getChildByName('Label')?.getComponent(Label);
if (nextLabel) {
nextLabel.string = isFinalShareSubmit
? PageLevel.SHARE_SUBMIT_BUTTON_TEXT
: '下一关';
}
}
private _ensureTitleAnimator(): AchievementTitleAnimator {
if (!this._titleAnimator) {
this._titleAnimator = new AchievementTitleAnimator();
}
return this._titleAnimator;
}
/**
* 在 onViewLoad 阶段把 PassNode / Caidai 的可见性归到「未通关」初始态。
* Prefab 里 PassNode 默认 active=true方便编辑预览运行时必须先关掉
* 否则页面一打开就直接展示通关页。
*
* 同时记录 PassNode 当前 local position 作为「滑入终点位置」。
* Widget 的 alignMode=ON_WINDOW_RESIZE运行时不会每帧覆盖所以 clone 一次即可。
*/
private _initPassNodeState(): void {
if (this.passNode) {
this._passNodeOriginalPos = this.passNode.position.clone();
this.passNode.active = false;
}
if (this.caidaiNode) {
this.caidaiNode.active = false;
}
this._isPassNodeShown = false;
this._isPassNodeAnimating = false;
}
/** PassNode 进入动画:底部两层淡出 + PassNode 从屏幕左侧滑入 */
private _playPassNodeShowAnimation(): void {
if (!this.passNode) return;
this._isPassNodeAnimating = true;
// 底部两层淡出后置 active = 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;
}
this._unbindPassNodeEvents();
this._titleAnimator?.stop();
this._isPassNodeShown = false;
this._isPassNodeAnimating = false;
}
/**
* 同步重置 PassNode 与底部两层到「未通关」状态。
* 用于销毁、模式切换、IconSetting 离场、自动推进下一关 等需要立即清场的场景。
* 不带动画,直接归位。
*/
private _resetPassNode(): void {
if (this.passNode) {
Tween.stopAllByTarget(this.passNode);
this.passNode.active = false;
if (this._passNodeOriginalPos) {
this.passNode.setPosition(this._passNodeOriginalPos);
}
}
if (this.caidaiNode) {
this.caidaiNode.active = false;
}
this._restoreBottomLayersImmediate();
this._unbindPassNodeEvents();
this._titleAnimator?.stop();
this._isPassNodeShown = false;
this._isPassNodeAnimating = false;
}
/** PassNode 事件绑定NextLevel + Share */
private _bindPassNodeEvents(): void {
// 防御:先解绑,避免重复绑定
this._unbindPassNodeEvents();
if (this.passNextLevelButton) {
this.passNextLevelButton.on(Node.EventType.TOUCH_END, this._onPassNextLevelClick, this);
}
if (this.passShareButton) {
this.passShareButton.on(Node.EventType.TOUCH_END, this._onPassShareClick, this);
}
}
private _unbindPassNodeEvents(): void {
if (this.passNextLevelButton?.isValid) {
this.passNextLevelButton.off(Node.EventType.TOUCH_END, this._onPassNextLevelClick, this);
}
if (this.passShareButton?.isValid) {
this.passShareButton.off(Node.EventType.TOUCH_END, this._onPassShareClick, this);
}
}
/**
* 「下一关 / 提交答案」点击:与原 PassModal.onNextLevel 等价,
* - 分享模式弹「确认进入下一题 / 确认提交挑战答案」二次确认
* - 普通模式直接走 _hidePassNode + goToNextLevel
*/
private _onPassNextLevelClick(): void {
if (this._isPassNodeAnimating) return;
AudioManager.instance.playButtonClick();
// 普通模式 _showShareNextConfirmModal 内部首行就 onConfirm(),所以两条路统一
this._showShareNextConfirmModal(async () => {
await this._hidePassNode();
void this.goToNextLevel();
});
}
/**
* 「分享」点击:与原 PassModal.onShare 行为一致——发普通 query=level=N 的微信卡片
* (这条仍是 CLAUDE.md Current Gaps #2 列出的已知缺陷:不是好友挑战的 shareCode 链路)
*/
private _onPassShareClick(): void {
AudioManager.instance.playButtonClick();
WxSDK.shareAppMessage({
title: '快来一起玩这款游戏吧',
query: `level=${this.getDisplayLevelNumber()}`,
});
}
/** 启动彩带 spine 动画 "open",单次播放 */
private _playCaidai(): void {
if (!this.caidaiNode || !this.caidaiSkeleton) return;
this.caidaiNode.active = true;
// setAnimation 三参trackIndex, name, loop
this.caidaiSkeleton.setAnimation(0, PageLevel.CAIDAI_ANIMATION_NAME, false);
}
/** 底部两层淡出(透明度 → 0完成后 active = false */
private _fadeOutBottomLayers(): void {
for (const layer of [this.bottomLayoutNode, this.tipsLayout]) {
if (!layer) continue;
const opacity = this._ensureUIOpacity(layer);
Tween.stopAllByTarget(opacity);
opacity.opacity = 255;
tween(opacity)
.to(
PageLevel.BOTTOM_LAYER_FADE_DURATION,
{ opacity: 0 },
{ easing: 'sineOut' },
)
.call(() => {
if (layer.isValid) {
layer.active = false;
}
})
.start();
}
}
/** 底部两层淡入active = true透明度 0 → 255 */
private _fadeInBottomLayers(): void {
for (const layer of [this.bottomLayoutNode, this.tipsLayout]) {
if (!layer) continue;
const opacity = this._ensureUIOpacity(layer);
Tween.stopAllByTarget(opacity);
layer.active = true;
opacity.opacity = 0;
tween(opacity)
.to(
PageLevel.BOTTOM_LAYER_FADE_DURATION,
{ opacity: 255 },
{ easing: 'sineOut' },
)
.start();
}
}
/** 立即把底部两层恢复成「关卡进行中」状态(无动画),用于销毁 / 切关时清场 */
private _restoreBottomLayersImmediate(): void {
for (const layer of [this.bottomLayoutNode, this.tipsLayout]) {
if (!layer) continue;
const opacity = layer.getComponent(UIOpacity);
if (opacity) {
Tween.stopAllByTarget(opacity);
opacity.opacity = 255;
}
// 注意:是否 active=true 由 _refreshModeUI 决定PK 模式下 BottomLayout 应隐藏)
// 这里只负责把透明度归位,可见性由模式刷新控制
}
}
private _ensureUIOpacity(node: Node): UIOpacity {
let opacity = node.getComponent(UIOpacity);
if (!opacity) {
opacity = node.addComponent(UIOpacity);
}
return opacity;
}
/**

View File

@@ -652,7 +652,7 @@
"__id__": 86
}
],
"_active": true,
"_active": false,
"_components": [
{
"__id__": 98
@@ -1632,7 +1632,7 @@
},
"_contentSize": {
"__type__": "cc.Size",
"width": 442.03125,
"width": 461.40625,
"height": 75.6
},
"_anchorPoint": {

BIN
assets/resources/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,9 @@
{
"ver": "1.2.0",
"importer": "directory",
"imported": true,
"uuid": "56ca3b87-0258-46ac-a7fe-6fa57bd4fdcd",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,125 @@
prize_fx.png
size: 668,525
format: RGBA8888
filter: Linear,Linear
repeat: none
caidai01
rotate: true
xy: 313, 234
size: 42, 33
orig: 42, 33
offset: 0, 0
index: -1
caidai02
rotate: true
xy: 638, 265
size: 25, 28
orig: 25, 28
offset: 0, 0
index: -1
cd1
rotate: true
xy: 238, 2
size: 26, 150
orig: 37, 160
offset: 6, 0
index: -1
cd2
rotate: false
xy: 289, 235
size: 22, 145
orig: 37, 160
offset: 12, 4
index: -1
cd3
rotate: true
xy: 390, 2
size: 26, 150
orig: 37, 160
offset: 6, 0
index: -1
cd4
rotate: true
xy: 391, 247
size: 29, 66
orig: 46, 80
offset: 10, 7
index: -1
gh_0
rotate: false
xy: 238, 30
size: 193, 195
orig: 200, 200
offset: 3, 2
index: -1
guang
rotate: false
xy: 348, 235
size: 41, 41
orig: 66, 69
offset: 17, 16
index: -1
guang1
rotate: false
xy: 433, 50
size: 65, 66
orig: 66, 69
offset: 0, 1
index: -1
ks
rotate: false
xy: 289, 382
size: 141, 141
orig: 155, 155
offset: 7, 7
index: -1
light_glow
rotate: false
xy: 433, 292
size: 233, 231
orig: 279, 278
offset: 23, 24
index: -1
light_line1
rotate: true
xy: 2, 5
size: 220, 234
orig: 285, 263
offset: 43, 23
index: -1
light_line2
rotate: true
xy: 2, 227
size: 296, 285
orig: 309, 298
offset: 9, 12
index: -1
star1
rotate: false
xy: 433, 200
size: 20, 19
orig: 26, 26
offset: 3, 3
index: -1
xx02
rotate: false
xy: 433, 221
size: 24, 24
orig: 32, 32
offset: 4, 4
index: -1
yd_c1_light
rotate: true
xy: 459, 118
size: 172, 177
orig: 200, 200
offset: 17, 12
index: -1
yd_light
rotate: false
xy: 313, 278
size: 102, 102
orig: 112, 112
offset: 5, 5
index: -1

View File

@@ -0,0 +1,12 @@
{
"ver": "1.0.0",
"importer": "*",
"imported": true,
"uuid": "4b5c9209-f28d-4e6a-9eae-905c0e9dbbb7",
"files": [
".atlas",
".json"
],
"subMetas": {},
"userData": {}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,13 @@
{
"ver": "1.2.7",
"importer": "spine-data",
"imported": true,
"uuid": "fff92c24-e843-4fca-b0aa-6e4e4f2caef5",
"files": [
".json"
],
"subMetas": {},
"userData": {
"atlasUuid": "4b5c9209-f28d-4e6a-9eae-905c0e9dbbb7"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

View File

@@ -0,0 +1,134 @@
{
"ver": "1.0.27",
"importer": "image",
"imported": true,
"uuid": "4d9aae14-3f9b-44b6-96a9-3535d097fed4",
"files": [
".json",
".png"
],
"subMetas": {
"6c48a": {
"importer": "texture",
"uuid": "4d9aae14-3f9b-44b6-96a9-3535d097fed4@6c48a",
"displayName": "prize_fx",
"id": "6c48a",
"name": "texture",
"userData": {
"wrapModeS": "clamp-to-edge",
"wrapModeT": "clamp-to-edge",
"imageUuidOrDatabaseUri": "4d9aae14-3f9b-44b6-96a9-3535d097fed4",
"isUuid": true,
"visible": false,
"minfilter": "linear",
"magfilter": "linear",
"mipfilter": "none",
"anisotropy": 0
},
"ver": "1.0.22",
"imported": true,
"files": [
".json"
],
"subMetas": {}
},
"f9941": {
"importer": "sprite-frame",
"uuid": "4d9aae14-3f9b-44b6-96a9-3535d097fed4@f9941",
"displayName": "prize_fx",
"id": "f9941",
"name": "spriteFrame",
"userData": {
"trimThreshold": 1,
"rotated": false,
"offsetX": 0,
"offsetY": 0,
"trimX": 2,
"trimY": 2,
"width": 664,
"height": 521,
"rawWidth": 668,
"rawHeight": 525,
"borderTop": 0,
"borderBottom": 0,
"borderLeft": 0,
"borderRight": 0,
"packable": true,
"pixelsToUnit": 100,
"pivotX": 0.5,
"pivotY": 0.5,
"meshType": 0,
"vertices": {
"rawPosition": [
-332,
-260.5,
0,
332,
-260.5,
0,
-332,
260.5,
0,
332,
260.5,
0
],
"indexes": [
0,
1,
2,
2,
1,
3
],
"uv": [
2,
523,
666,
523,
2,
2,
666,
2
],
"nuv": [
0.0029940119760479044,
0.0038095238095238095,
0.9970059880239521,
0.0038095238095238095,
0.0029940119760479044,
0.9961904761904762,
0.9970059880239521,
0.9961904761904762
],
"minPos": [
-332,
-260.5,
0
],
"maxPos": [
332,
260.5,
0
]
},
"isUuid": true,
"imageUuidOrDatabaseUri": "4d9aae14-3f9b-44b6-96a9-3535d097fed4@6c48a",
"atlasUuid": "",
"trimType": "auto"
},
"ver": "1.0.12",
"imported": true,
"files": [
".json"
],
"subMetas": {}
}
},
"userData": {
"type": "sprite-frame",
"fixAlphaTransparencyArtifacts": false,
"hasAlpha": true,
"redirect": "4d9aae14-3f9b-44b6-96a9-3535d097fed4@6c48a"
}
}

View File

@@ -0,0 +1,252 @@
import { Label, Node, ProgressBar, tween, Tween } from 'cc';
/**
* 称号进度展示数据
* 字段对应 AchievementTitleManager.getTitleInfo 的产物,但所有字段都是可选的,
* 调用方可以只更新需要的部分(例如分享模式只想清空文字)。
*/
export interface TitleProgressData {
/** 当前称号文案如「冷场小白1级」 */
titleText?: string;
/** 进度提示文案如「还差3题解锁新成就等级」 */
progressText?: string;
/** 当前称号到下一称号的进度0-1 */
nextTitleProgress?: number;
}
/**
* 进度条 / 称号视图所需的节点引用集合
*/
export interface TitleAnimatorBindings {
/** 称号文案 Label如「冷场小白1级」可空 */
titleLabel?: Label | null;
/** 进度提示 Label如「还差3题解锁新成就等级」可空 */
progressLabel?: Label | null;
/** 进度条组件 */
progressBar?: ProgressBar | null;
/** 进度条上跟随移动的 anchor 节点(若其下有 Label 子节点会自动写百分比) */
progressAnchor?: Node | null;
}
/**
* 进度条动画起始前的等待时长(秒)。让弹窗 / 弹起动画稳定后再开播
*/
const PROGRESS_ANIM_START_DELAY = 0.4;
/** 单段进度条填充动画时长(秒) */
const PROGRESS_ANIM_SEGMENT_DURATION = 0.6;
/** 跨称号切换时的等级信息刷新停顿(秒),让玩家看清称号变更 */
const PROGRESS_ANIM_LEVELUP_PAUSE = 0.12;
/**
* 九宫格 Bar Left+Right border = 240px、totalLength = 925px。
* width < 240px 时圆角会畸变,因此 progress > 0 时强制最小值。
*/
const MIN_PROGRESS_RATIO = 240 / 925;
/** anchor 起点的视觉微调(与 PageHome 等其他页面保持一致) */
const PROGRESS_ANCHOR_VISUAL_OFFSET = -30;
/**
* 把称号文字、进度条、跟随气泡这三件事打包成一个可复用的动画/展示工具。
* 没有引擎组件依赖(不是 cc.Component可以被任何持有节点引用的对象 new 一个出来用。
*
* 起源:原本只在 PassModal 内部实现。PassNode 替换 PassModal 后,
* PageLevel 也需要同一套行为,所以抽出来共用。
*/
export class AchievementTitleAnimator {
private _bindings: TitleAnimatorBindings = {};
/** anchor 起点 X在 progressBar 父节点空间下),首次解析后缓存 */
private _progressAnchorStartX: number | null = null;
/** Tween 共享的目标对象,方便 stopAllByTarget */
private readonly _tweenTarget: { progress: number } = { progress: 0 };
/** 绑定 / 重新绑定节点引用。任何重新绑定都会清掉缓存的 anchor 起点 */
bind(bindings: TitleAnimatorBindings): void {
this._bindings = bindings;
this._progressAnchorStartX = null;
}
/** 直接展示终态,无动画 */
setTarget(data: TitleProgressData): void {
this.stop();
this._applyTitleText(data.titleText);
this._applyProgressText(data.progressText);
this._applyProgressValue(data.nextTitleProgress);
}
/**
* 从 prev → current 播过渡动画
* - prev 为空:直接展示 current 终态
* - 同称号:单段 tween
* - 跨称号:先填满旧称号,再切到新称号 + 进度从 0 涨到 current
*/
playTransition(prev: TitleProgressData | null | undefined, current: TitleProgressData): void {
if (!prev) {
this.setTarget(current);
return;
}
const startProgress = prev.nextTitleProgress;
const endProgress = current.nextTitleProgress;
if (startProgress === undefined || endProgress === undefined) {
this.setTarget(current);
return;
}
const isSameTitle = prev.titleText === undefined
|| current.titleText === undefined
|| prev.titleText === current.titleText;
// 同称号且起止相同,没必要播动画
if (isSameTitle && Math.abs(startProgress - endProgress) < 1e-4) {
this.setTarget(current);
return;
}
this.stop();
if (isSameTitle) {
// 先展示文字 + 起点进度,再 tween 到终点
this._applyTitleText(current.titleText);
this._applyProgressText(current.progressText);
this._applyProgressValue(startProgress);
this._runSegmentTween(startProgress, endProgress, PROGRESS_ANIM_START_DELAY);
return;
}
// 跨称号:先展示旧称号 + 起点进度
this._applyTitleText(prev.titleText);
this._applyProgressText(prev.progressText);
this._applyProgressValue(startProgress);
const target = this._tweenTarget;
target.progress = this._clamp(startProgress);
const onUpdate = () => this._applyAnimatedProgress(target.progress);
tween(target)
.delay(PROGRESS_ANIM_START_DELAY)
.to(PROGRESS_ANIM_SEGMENT_DURATION, { progress: 1 }, { easing: 'sineOut', onUpdate })
.call(() => {
this._applyTitleText(current.titleText);
this._applyProgressText(current.progressText);
target.progress = 0;
this._applyAnimatedProgress(0);
})
.delay(PROGRESS_ANIM_LEVELUP_PAUSE)
.to(
PROGRESS_ANIM_SEGMENT_DURATION,
{ progress: this._clamp(endProgress) },
{ easing: 'sineOut', onUpdate },
)
.start();
}
/** 停止当前动画(不影响已展示的进度值) */
stop(): void {
Tween.stopAllByTarget(this._tweenTarget);
}
private _runSegmentTween(from: number, to: number, delay: number): void {
const target = this._tweenTarget;
target.progress = this._clamp(from);
this._applyAnimatedProgress(from);
const chain = tween(target);
if (delay > 0) {
chain.delay(delay);
}
chain.to(
PROGRESS_ANIM_SEGMENT_DURATION,
{ progress: this._clamp(to) },
{
easing: 'sineOut',
onUpdate: () => this._applyAnimatedProgress(target.progress),
},
).start();
}
private _applyTitleText(text: string | undefined): void {
if (text === undefined) return;
const label = this._bindings.titleLabel;
if (label?.isValid) {
label.string = text;
}
}
private _applyProgressText(text: string | undefined): void {
if (text === undefined) return;
const label = this._bindings.progressLabel;
if (label?.isValid) {
label.string = text;
}
}
private _applyProgressValue(progress: number | undefined): void {
if (progress === undefined) return;
this._applyAnimatedProgress(progress);
}
private _applyAnimatedProgress(progress: number): void {
const clamped = this._clamp(progress);
const bar = this._bindings.progressBar;
if (bar?.isValid) {
bar.progress = this._normalize(clamped);
}
this._updateProgressAnchor(clamped);
}
private _updateProgressAnchor(progress: number): void {
const anchor = this._bindings.progressAnchor;
if (!anchor?.isValid) return;
this._cacheProgressAnchorStartX();
const startX = this._progressAnchorStartX ?? anchor.position.x;
const travelWidth = this._getProgressAnchorTravelWidth();
anchor.setPosition(
startX + travelWidth * progress,
anchor.position.y,
anchor.position.z,
);
const percentLabel = anchor.getChildByName('Label')?.getComponent(Label);
if (percentLabel) {
percentLabel.string = `${Math.round(progress * 100)}%`;
}
}
private _getProgressAnchorTravelWidth(): number {
const bar = this._bindings.progressBar;
if (!bar) return 0;
return Math.abs(bar.totalLength * bar.node.scale.x);
}
/**
* Bar 节点 anchor 为 (0, 0.5),其本地 position.x 即进度条可视左端。
* ProgressBar 与 ProgressAnchor 共享同一父节点,因此把 Bar 的本地 X
* 按 ProgressBar 自身的位移与缩放映射到父节点空间才是真正的「0% 起点」。
* 直接拿 anchor.position.x 当起点会被 prefab 摆放偏移量带跑。
*/
private _cacheProgressAnchorStartX(): void {
if (this._progressAnchorStartX !== null) return;
const bar = this._bindings.progressBar;
const barSprite = bar?.barSprite;
if (!bar || !barSprite) return;
const barLocalX = barSprite.node.position.x;
this._progressAnchorStartX = bar.node.position.x
+ barLocalX * bar.node.scale.x
+ PROGRESS_ANCHOR_VISUAL_OFFSET;
}
private _normalize(progress: number): number {
if (!Number.isFinite(progress) || progress <= 0) return 0;
return Math.max(MIN_PROGRESS_RATIO, Math.min(1, progress));
}
private _clamp(progress: number): number {
if (!Number.isFinite(progress) || progress <= 0) return 0;
return Math.min(1, progress);
}
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "54354f70-d976-4008-b1a9-40473c99e872",
"files": [],
"subMetas": {},
"userData": {}
}

BIN
extensions/.DS_Store vendored Normal file

Binary file not shown.

Submodule extensions/cocos-mcp-server added at 754adecdb8