feat: 优化通关逻辑
This commit is contained in:
17
CLAUDE.md
17
CLAUDE.md
@@ -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
BIN
assets/.DS_Store
vendored
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -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 position(prefab 摆放位),动画结束后用来回归 */
|
||||
private _passNodeOriginalPos: Vec3 | null = null;
|
||||
|
||||
/** 通关页所用「已通关数量」(业务数据,给成就体系展示用) */
|
||||
private _passCompletedLevelCount: number | null = null;
|
||||
|
||||
/** 通关页动画起点(通关前)的已通关数量;为 null 表示不播跨称号过渡 */
|
||||
private _passPreviousCompletedLevelCount: number | null = null;
|
||||
|
||||
/** 通关页称号 / 进度条动画工具,惰性初始化 */
|
||||
private _titleAnimator: AchievementTitleAnimator | null = null;
|
||||
|
||||
/** 错误弹窗实例 */
|
||||
private _wrongModalNode: Node | null = null;
|
||||
@@ -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 可以正确对齐到全屏
|
||||
* 通关展示:BottomLayout / TipsLayout 透明度淡出,PassNode 从屏幕左侧滑入,
|
||||
* Caidai 彩带 spine 播放 "open" 动画;按 NextLevel 时再走 _hidePassNode。
|
||||
*
|
||||
* 与原 PassModal 弹窗的差异:
|
||||
* - PassNode 是 PageLevel 自己的子节点,不是 instantiate 出的弹窗,无需挂到 Canvas
|
||||
* - PassNode 不全屏遮盖,所以不能在它显示期间偷偷预换底图(用户能看见底图)
|
||||
* - 分享模式不展示成就体系(passTitleLevelNode 整体隐藏)
|
||||
*/
|
||||
private _showPassModal(): void {
|
||||
if (!this.passModalPrefab) {
|
||||
console.warn('[PageLevel] passModalPrefab 未设置');
|
||||
private _showPassNode(): void {
|
||||
if (!this.passNode) {
|
||||
console.warn('[PageLevel] passNode 未挂引用,跳过通关展示');
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果弹窗已显示,不再重复创建
|
||||
if (this._passModalNode && this._passModalNode.isValid) {
|
||||
if (this._isPassNodeShown || this._isPassNodeAnimating) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 实例化弹窗
|
||||
const modalNode = instantiate(this.passModalPrefab);
|
||||
modalNode.setPosition(PageLevel.ZERO_POS);
|
||||
modalNode.setSiblingIndex(PassModal.MODAL_Z_INDEX);
|
||||
this._isPassNodeShown = true;
|
||||
|
||||
// 找到 Canvas 根节点并添加弹窗
|
||||
const canvasNode = this.node.parent;
|
||||
if (canvasNode) {
|
||||
canvasNode.addChild(modalNode);
|
||||
} else {
|
||||
this.node.addChild(modalNode);
|
||||
}
|
||||
this._passModalNode = modalNode;
|
||||
// 配置成就体系数据 / 按钮文案 / 事件
|
||||
this._setupPassNodeContent();
|
||||
this._bindPassNodeEvents();
|
||||
|
||||
// 获取 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;
|
||||
// 启动彩带 + 滑入动画 + 淡出底部UI + 通关音效
|
||||
this._playCaidai();
|
||||
this._playPassNodeShowAnimation();
|
||||
this.playSuccessSound();
|
||||
|
||||
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] 显示通关弹窗');
|
||||
console.log('[PageLevel] 显示通关页 PassNode');
|
||||
}
|
||||
|
||||
private _getPassModalCompletedLevelCount(): number {
|
||||
return this._passModalCompletedLevelCount ?? AuthManager.instance.completedLevelCount;
|
||||
private _getPassCompletedLevelCount(): number {
|
||||
return this._passCompletedLevelCount ?? 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()) {
|
||||
private _setupPassNodeContent(): void {
|
||||
const showTitle = !this._isShareMode;
|
||||
if (this.passTitleLevelNode) {
|
||||
this.passTitleLevelNode.active = showTitle;
|
||||
}
|
||||
|
||||
if (showTitle) {
|
||||
const completedCount = this._getPassCompletedLevelCount();
|
||||
const titleInfo = AchievementTitleManager.getTitleInfo(completedCount);
|
||||
const previousCompletedCount = this._passPreviousCompletedLevelCount;
|
||||
const previousTitleInfo: AchievementTitleInfo | null = (
|
||||
previousCompletedCount !== null && previousCompletedCount !== completedCount
|
||||
)
|
||||
? AchievementTitleManager.getTitleInfo(previousCompletedCount)
|
||||
: null;
|
||||
|
||||
const animator = this._ensureTitleAnimator();
|
||||
animator.bind({
|
||||
titleLabel: this.passTitleLevelLabel,
|
||||
progressLabel: this.passProgressLabel,
|
||||
progressBar: this.passTitleProgressBar,
|
||||
progressAnchor: this.passProgressAnchor,
|
||||
});
|
||||
animator.playTransition(previousTitleInfo, titleInfo);
|
||||
|
||||
// 跨称号动画起点是一次性的,消费完清掉,避免下次重复
|
||||
this._passPreviousCompletedLevelCount = null;
|
||||
} else {
|
||||
// 分享模式不展示成就,停掉可能的旧动画
|
||||
this._titleAnimator?.stop();
|
||||
}
|
||||
|
||||
// NextLevelButton 文案
|
||||
const isFinalShareSubmit = this._isShareMode && this._isFinalShareLevel();
|
||||
const nextLabel = this.passNextLevelButton?.getChildByName('Label')?.getComponent(Label);
|
||||
if (nextLabel) {
|
||||
nextLabel.string = isFinalShareSubmit
|
||||
? PageLevel.SHARE_SUBMIT_BUTTON_TEXT
|
||||
: '下一关';
|
||||
}
|
||||
}
|
||||
|
||||
private _ensureTitleAnimator(): AchievementTitleAnimator {
|
||||
if (!this._titleAnimator) {
|
||||
this._titleAnimator = new AchievementTitleAnimator();
|
||||
}
|
||||
return this._titleAnimator;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 onViewLoad 阶段把 PassNode / Caidai 的可见性归到「未通关」初始态。
|
||||
* Prefab 里 PassNode 默认 active=true(方便编辑预览),运行时必须先关掉,
|
||||
* 否则页面一打开就直接展示通关页。
|
||||
*
|
||||
* 同时记录 PassNode 当前 local position 作为「滑入终点位置」。
|
||||
* Widget 的 alignMode=ON_WINDOW_RESIZE,运行时不会每帧覆盖,所以 clone 一次即可。
|
||||
*/
|
||||
private _initPassNodeState(): void {
|
||||
if (this.passNode) {
|
||||
this._passNodeOriginalPos = this.passNode.position.clone();
|
||||
this.passNode.active = false;
|
||||
}
|
||||
if (this.caidaiNode) {
|
||||
this.caidaiNode.active = false;
|
||||
}
|
||||
this._isPassNodeShown = false;
|
||||
this._isPassNodeAnimating = false;
|
||||
}
|
||||
|
||||
/** PassNode 进入动画:底部两层淡出 + PassNode 从屏幕左侧滑入 */
|
||||
private _playPassNodeShowAnimation(): void {
|
||||
if (!this.passNode) return;
|
||||
|
||||
this._isPassNodeAnimating = true;
|
||||
|
||||
// 底部两层淡出后置 active = false(next 进入时会重新 active = true)
|
||||
this._fadeOutBottomLayers();
|
||||
|
||||
// 记录原位置(首次记录后保留,反向动画 / reset 时复用)
|
||||
if (!this._passNodeOriginalPos) {
|
||||
this._passNodeOriginalPos = this.passNode.position.clone();
|
||||
}
|
||||
|
||||
// 从屏幕左侧外滑入:起点 X = 原 X - 屏幕宽度(确保完全在屏幕外)
|
||||
const screenWidth = view.getVisibleSize().width;
|
||||
const originalPos = this._passNodeOriginalPos;
|
||||
const startX = originalPos.x - screenWidth;
|
||||
|
||||
Tween.stopAllByTarget(this.passNode);
|
||||
this.passNode.active = true;
|
||||
this.passNode.setPosition(startX, originalPos.y, originalPos.z);
|
||||
|
||||
tween(this.passNode)
|
||||
.to(
|
||||
PageLevel.PASS_NODE_SLIDE_DURATION,
|
||||
{ position: originalPos.clone() },
|
||||
{ easing: 'cubicOut' },
|
||||
)
|
||||
.call(() => {
|
||||
this._isPassNodeAnimating = false;
|
||||
})
|
||||
.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* PassNode 退出(用户点下一关时调用):滑出 PassNode + 底部两层淡入
|
||||
* 返回 Promise,外部链路通常 await 后再调 goToNextLevel
|
||||
*/
|
||||
private _hidePassNode(): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
if (!this.passNode || !this._isPassNodeShown) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
// ensureShareLevelReady 若已缓存会立即 resolve;首次则会加载(此时图已在弹窗后面偷偷替换,用户无感)
|
||||
ShareManager.instance.ensureShareLevelReady(nextIndex)
|
||||
.then(config => this._applyNextLevelImagesFromConfig(config))
|
||||
.catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
const nextLevel = this._nextLevelData ?? AuthManager.instance.nextLevel;
|
||||
if (!nextLevel) {
|
||||
// complete 接口还没回包,走回调补偿路径
|
||||
return;
|
||||
}
|
||||
this._isPassNodeAnimating = true;
|
||||
|
||||
const config = LevelDataManager.instance.getLevelConfig(nextLevel.id);
|
||||
this._applyNextLevelImagesFromConfig(config);
|
||||
// 底部两层淡入恢复
|
||||
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 _applyNextLevelImagesFromConfig(config: RuntimeLevelConfig | null): void {
|
||||
if (!config) return;
|
||||
|
||||
// 只在弹窗仍然打开时换图;否则说明用户已经点过下一关进入后续流程了,不再重复设置
|
||||
if (!this._passModalNode || !this._passModalNode.isValid) {
|
||||
return;
|
||||
private _finalizeHidePassNode(): void {
|
||||
// 隐藏 PassNode 并归位(_passNodeOriginalPos 已记录)
|
||||
if (this.passNode) {
|
||||
this.passNode.active = false;
|
||||
if (this._passNodeOriginalPos) {
|
||||
this.passNode.setPosition(this._passNodeOriginalPos);
|
||||
}
|
||||
}
|
||||
|
||||
if (config.spriteFrame1) {
|
||||
this.setMainImage(config.spriteFrame1);
|
||||
// 关掉彩带
|
||||
if (this.caidaiNode) {
|
||||
this.caidaiNode.active = false;
|
||||
}
|
||||
if (config.spriteFrame2) {
|
||||
this.setMainImage2(config.spriteFrame2);
|
||||
}
|
||||
console.log('[PageLevel] 弹窗遮盖期间已预替换下一关底图');
|
||||
|
||||
this._unbindPassNodeEvents();
|
||||
this._titleAnimator?.stop();
|
||||
this._isPassNodeShown = false;
|
||||
this._isPassNodeAnimating = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭通关弹窗
|
||||
* 同步重置 PassNode 与底部两层到「未通关」状态。
|
||||
* 用于:销毁、模式切换、IconSetting 离场、自动推进下一关 等需要立即清场的场景。
|
||||
* 不带动画,直接归位。
|
||||
*/
|
||||
private _closePassModal(): void {
|
||||
if (this._passModalNode && this._passModalNode.isValid) {
|
||||
this._passModalNode.destroy();
|
||||
this._passModalNode = null;
|
||||
console.log('[PageLevel] 关闭通关弹窗');
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
BIN
assets/resources/.DS_Store
vendored
Normal file
Binary file not shown.
9
assets/resources/spine.meta
Normal file
9
assets/resources/spine.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "1.2.0",
|
||||
"importer": "directory",
|
||||
"imported": true,
|
||||
"uuid": "56ca3b87-0258-46ac-a7fe-6fa57bd4fdcd",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
125
assets/resources/spine/prize_fx.atlas
Normal file
125
assets/resources/spine/prize_fx.atlas
Normal 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
|
||||
12
assets/resources/spine/prize_fx.atlas.meta
Normal file
12
assets/resources/spine/prize_fx.atlas.meta
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"ver": "1.0.0",
|
||||
"importer": "*",
|
||||
"imported": true,
|
||||
"uuid": "4b5c9209-f28d-4e6a-9eae-905c0e9dbbb7",
|
||||
"files": [
|
||||
".atlas",
|
||||
".json"
|
||||
],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
3408
assets/resources/spine/prize_fx.json
Normal file
3408
assets/resources/spine/prize_fx.json
Normal file
File diff suppressed because it is too large
Load Diff
13
assets/resources/spine/prize_fx.json.meta
Normal file
13
assets/resources/spine/prize_fx.json.meta
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
assets/resources/spine/prize_fx.png
Normal file
BIN
assets/resources/spine/prize_fx.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 290 KiB |
134
assets/resources/spine/prize_fx.png.meta
Normal file
134
assets/resources/spine/prize_fx.png.meta
Normal 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"
|
||||
}
|
||||
}
|
||||
252
assets/scripts/utils/AchievementTitleAnimator.ts
Normal file
252
assets/scripts/utils/AchievementTitleAnimator.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
9
assets/scripts/utils/AchievementTitleAnimator.ts.meta
Normal file
9
assets/scripts/utils/AchievementTitleAnimator.ts.meta
Normal 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
BIN
extensions/.DS_Store
vendored
Normal file
Binary file not shown.
1
extensions/cocos-mcp-server
Submodule
1
extensions/cocos-mcp-server
Submodule
Submodule extensions/cocos-mcp-server added at 754adecdb8
Reference in New Issue
Block a user