feat: 优化通关逻辑
This commit is contained in:
17
CLAUDE.md
17
CLAUDE.md
@@ -29,6 +29,23 @@ assets/
|
|||||||
- **运行预览**: 在 Cocos Creator 编辑器中点击 "Play" 按钮
|
- **运行预览**: 在 Cocos Creator 编辑器中点击 "Play" 按钮
|
||||||
- **构建**: 使用编辑器菜单 `Project > Build` 或快捷键 `Cmd+B`
|
- **构建**: 使用编辑器菜单 `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
|
## TypeScript Coding
|
||||||
|
|
||||||
遵循 Cocos Creator 3.x 组件系统架构:
|
遵循 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 { BaseView } from 'db://assets/scripts/core/BaseView';
|
||||||
import { ViewManager } from 'db://assets/scripts/core/ViewManager';
|
import { ViewManager } from 'db://assets/scripts/core/ViewManager';
|
||||||
import { StaminaManager } from 'db://assets/scripts/utils/StaminaManager';
|
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 { StorageManager } from 'db://assets/scripts/utils/StorageManager';
|
||||||
import { HttpUtil } from 'db://assets/scripts/utils/HttpUtil';
|
import { HttpUtil } from 'db://assets/scripts/utils/HttpUtil';
|
||||||
import { API_ENDPOINTS, API_TIMEOUT } from 'db://assets/scripts/config/ApiConfig';
|
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 { WrongModal } from 'db://assets/prefabs/WrongModal';
|
||||||
import { TimeoutModal } from 'db://assets/prefabs/TimeoutModal';
|
import { TimeoutModal } from 'db://assets/prefabs/TimeoutModal';
|
||||||
import { CommonModal } from 'db://assets/prefabs/CommonModal';
|
import { CommonModal } from 'db://assets/prefabs/CommonModal';
|
||||||
import { ApiEnvelope, StaminaInfo, NextLevelData, SubmitShareLevel } from 'db://assets/scripts/types/ApiTypes';
|
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 { applyRoundedCorner } from 'db://assets/scripts/utils/roundedMaterial.utils';
|
||||||
import { AudioManager } from 'db://assets/scripts/utils/AudioManager';
|
import { AudioManager } from 'db://assets/scripts/utils/AudioManager';
|
||||||
const { ccclass, property } = _decorator;
|
const { ccclass, property } = _decorator;
|
||||||
@@ -82,6 +82,15 @@ export class PageLevel extends BaseView {
|
|||||||
/** 分享模式最后一题提交文案 */
|
/** 分享模式最后一题提交文案 */
|
||||||
private static readonly SHARE_SUBMIT_BUTTON_TEXT = '提交答案';
|
private static readonly SHARE_SUBMIT_BUTTON_TEXT = '提交答案';
|
||||||
|
|
||||||
|
/** PassNode 滑入 / 滑出动画时长(秒) */
|
||||||
|
private static readonly PASS_NODE_SLIDE_DURATION = 0.4;
|
||||||
|
|
||||||
|
/** BottomLayout / TipsLayout 透明度淡入淡出动画时长(秒) */
|
||||||
|
private static readonly BOTTOM_LAYER_FADE_DURATION = 0.25;
|
||||||
|
|
||||||
|
/** 彩带 spine 动画名(一次播放) */
|
||||||
|
private static readonly CAIDAI_ANIMATION_NAME = 'open';
|
||||||
|
|
||||||
// ========== 节点引用 ==========
|
// ========== 节点引用 ==========
|
||||||
@property(Node)
|
@property(Node)
|
||||||
inputLayout: Node | null = null;
|
inputLayout: Node | null = null;
|
||||||
@@ -186,6 +195,47 @@ export class PageLevel extends BaseView {
|
|||||||
@property(Node)
|
@property(Node)
|
||||||
pkNextLevelButton: Node | null = null;
|
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)
|
@property(AudioClip)
|
||||||
clickAudio: AudioClip | null = null;
|
clickAudio: AudioClip | null = null;
|
||||||
@@ -196,9 +246,6 @@ export class PageLevel extends BaseView {
|
|||||||
@property(AudioClip)
|
@property(AudioClip)
|
||||||
failAudio: AudioClip | null = null;
|
failAudio: AudioClip | null = null;
|
||||||
|
|
||||||
@property(Prefab)
|
|
||||||
passModalPrefab: Prefab | null = null;
|
|
||||||
|
|
||||||
@property(Prefab)
|
@property(Prefab)
|
||||||
wrongModalPrefab: Prefab | null = null;
|
wrongModalPrefab: Prefab | null = null;
|
||||||
|
|
||||||
@@ -271,14 +318,23 @@ export class PageLevel extends BaseView {
|
|||||||
/** 下一个待解锁的线索序号(2 或 3),超过 3 表示全部已解锁 */
|
/** 下一个待解锁的线索序号(2 或 3),超过 3 表示全部已解锁 */
|
||||||
private _nextClueIndex: number = 2;
|
private _nextClueIndex: number = 2;
|
||||||
|
|
||||||
/** 通关弹窗实例 */
|
/** 通关页(PassNode)当前是否已展示 */
|
||||||
private _passModalNode: Node | null = null;
|
private _isPassNodeShown: boolean = false;
|
||||||
|
|
||||||
/** 本次通关弹窗使用的已通关数量 */
|
/** PassNode 进出动画是否在进行中(防止重入) */
|
||||||
private _passModalCompletedLevelCount: number | null = null;
|
private _isPassNodeAnimating: boolean = false;
|
||||||
|
|
||||||
/** 本次通关弹窗动画起点(通关前)的已通关数量;为 null 表示不播动画 */
|
/** PassNode 原始 local position(prefab 摆放位),动画结束后用来回归 */
|
||||||
private _passModalPreviousCompletedLevelCount: number | null = null;
|
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;
|
private _wrongModalNode: Node | null = null;
|
||||||
@@ -364,6 +420,7 @@ export class PageLevel extends BaseView {
|
|||||||
this.initUnlockButtons();
|
this.initUnlockButtons();
|
||||||
this.initSubmitButton();
|
this.initSubmitButton();
|
||||||
this.initPkNextLevelButton();
|
this.initPkNextLevelButton();
|
||||||
|
this._initPassNodeState();
|
||||||
|
|
||||||
// 异步加载关卡资源并调用进入关卡接口,完成后启动倒计时
|
// 异步加载关卡资源并调用进入关卡接口,完成后启动倒计时
|
||||||
this._enterAndInitLevel().catch(err => {
|
this._enterAndInitLevel().catch(err => {
|
||||||
@@ -405,12 +462,12 @@ export class PageLevel extends BaseView {
|
|||||||
// - 提交按钮被 _isTransitioning 锁住,无法重新提交
|
// - 提交按钮被 _isTransitioning 锁住,无法重新提交
|
||||||
// - 倒计时已停、谐音梗已揭示
|
// - 倒计时已停、谐音梗已揭示
|
||||||
// 此时玩家从首页再次进入会看到一个无法操作的"死局"。必须把会话推进到下一关。
|
// 此时玩家从首页再次进入会看到一个无法操作的"死局"。必须把会话推进到下一关。
|
||||||
// 注意:这只可能在主线模式发生 —— 分享模式下点 iconSetting / PassModal 的 home
|
// 注意:这只可能在主线模式发生 —— 分享模式下点 iconSetting / PassNode 的入口
|
||||||
// 都会调用 ShareManager.clearShareMode + ViewManager.replace,再次进入会被
|
// 都会调用 ShareManager.clearShareMode + ViewManager.replace,再次进入会被
|
||||||
// 上面的 modeChanged / shareCodeChanged 分支拦截走 _reinitLevelSession。
|
// 上面的 modeChanged / shareCodeChanged 分支拦截走 _reinitLevelSession。
|
||||||
if (this._isTransitioning) {
|
if (this._isTransitioning) {
|
||||||
console.log('[PageLevel] 上次离场时停留在通关后状态,自动推进到下一关');
|
console.log('[PageLevel] 上次离场时停留在通关后状态,自动推进到下一关');
|
||||||
this._closePassModal();
|
this._resetPassNode();
|
||||||
this._closeWrongModal();
|
this._closeWrongModal();
|
||||||
this._closeTimeoutModal();
|
this._closeTimeoutModal();
|
||||||
this._closeCommonModal();
|
this._closeCommonModal();
|
||||||
@@ -433,7 +490,7 @@ export class PageLevel extends BaseView {
|
|||||||
this._isShareMode = shareMode;
|
this._isShareMode = shareMode;
|
||||||
|
|
||||||
// 上一场可能遗留的弹窗 / 倒计时一并清掉,避免主线模式还看到分享态弹窗
|
// 上一场可能遗留的弹窗 / 倒计时一并清掉,避免主线模式还看到分享态弹窗
|
||||||
this._closePassModal();
|
this._resetPassNode();
|
||||||
this._closeWrongModal();
|
this._closeWrongModal();
|
||||||
this._closeTimeoutModal();
|
this._closeTimeoutModal();
|
||||||
this._closeCommonModal();
|
this._closeCommonModal();
|
||||||
@@ -493,7 +550,7 @@ export class PageLevel extends BaseView {
|
|||||||
this.clearInputNodes();
|
this.clearInputNodes();
|
||||||
this.clearPunchBlocks();
|
this.clearPunchBlocks();
|
||||||
this.stopCountdown();
|
this.stopCountdown();
|
||||||
this._closePassModal();
|
this._resetPassNode();
|
||||||
this._closeWrongModal();
|
this._closeWrongModal();
|
||||||
this._closeTimeoutModal();
|
this._closeTimeoutModal();
|
||||||
this._closeCommonModal();
|
this._closeCommonModal();
|
||||||
@@ -908,11 +965,11 @@ export class PageLevel extends BaseView {
|
|||||||
AudioManager.instance.playButtonClick();
|
AudioManager.instance.playButtonClick();
|
||||||
|
|
||||||
// 离开 PageLevel 时把所有挂在 Canvas 上的关卡级弹窗一起清掉。
|
// 离开 PageLevel 时把所有挂在 Canvas 上的关卡级弹窗一起清掉。
|
||||||
// PassModal / WrongModal / TimeoutModal / CommonModal 都是 addChild 到 this.node.parent
|
// WrongModal / TimeoutModal / CommonModal 都是 addChild 到 this.node.parent
|
||||||
// 也就是 PageLevel 的兄弟节点,PageLevel 被 ViewManager 隐藏后它们并不会自动消失,
|
// 也就是 PageLevel 的兄弟节点,PageLevel 被 ViewManager 隐藏后它们并不会自动消失,
|
||||||
// 否则会孤儿地盖在 PageHome 上。同时清掉 PassModal 也避免再次进入时缓存实例
|
// 否则会孤儿地盖在 PageHome 上。
|
||||||
// 残留 _passModalNode 引用让 _swapToNextLevelImagesIfReady 误判弹窗仍在打开。
|
// PassNode 是 PageLevel 自身子节点(不是弹窗),用 _resetPassNode 同步归位即可。
|
||||||
this._closePassModal();
|
this._resetPassNode();
|
||||||
this._closeWrongModal();
|
this._closeWrongModal();
|
||||||
this._closeTimeoutModal();
|
this._closeTimeoutModal();
|
||||||
this._closeCommonModal();
|
this._closeCommonModal();
|
||||||
@@ -1662,6 +1719,20 @@ export class PageLevel extends BaseView {
|
|||||||
this.playSound(this.failAudio);
|
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();
|
this.stopCountdown();
|
||||||
|
|
||||||
// 通关音效不在这里播,改由通关弹窗 onViewShow 触发(见 PassModal._playSuccessSound)
|
// 通关音效不在这里播,改由 _showPassNode 在 PassNode 弹起时触发(_playSuccessSound)
|
||||||
// 产品节奏:玩家看到弹窗的瞬间音效才响起,避免谐音梗揭示期间就被音效抢戏
|
// 产品节奏:玩家看到通关页的瞬间音效才响起,避免谐音梗揭示期间就被音效抢戏
|
||||||
|
|
||||||
const punchline = this.getValidPunchline(this._currentConfig?.punchline ?? null);
|
const punchline = this.getValidPunchline(this._currentConfig?.punchline ?? null);
|
||||||
if (punchline) {
|
if (punchline) {
|
||||||
@@ -1922,7 +1993,7 @@ export class PageLevel extends BaseView {
|
|||||||
await this.delay(PageLevel.PASS_MODAL_DELAY_MS);
|
await this.delay(PageLevel.PASS_MODAL_DELAY_MS);
|
||||||
|
|
||||||
// 显示通关弹窗
|
// 显示通关弹窗
|
||||||
this._showPassModal();
|
this._showPassNode();
|
||||||
}
|
}
|
||||||
|
|
||||||
private getValidPunchline(punchline: string | null): string | null {
|
private getValidPunchline(punchline: string | null): string | null {
|
||||||
@@ -1941,9 +2012,9 @@ export class PageLevel extends BaseView {
|
|||||||
// 乐观更新通关计数(用于称号展示)
|
// 乐观更新通关计数(用于称号展示)
|
||||||
const previousCount = AuthManager.instance.completedLevelCount;
|
const previousCount = AuthManager.instance.completedLevelCount;
|
||||||
AuthManager.instance.addCompletedLevelCount();
|
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) => {
|
void StaminaManager.instance.completeLevel(levelId, timeSpent).then((result) => {
|
||||||
if (result) {
|
if (result) {
|
||||||
@@ -1953,20 +2024,17 @@ export class PageLevel extends BaseView {
|
|||||||
if (!result.firstClear) {
|
if (!result.firstClear) {
|
||||||
// 非首次通关,回退乐观更新
|
// 非首次通关,回退乐观更新
|
||||||
AuthManager.instance.addCompletedLevelCount(-1);
|
AuthManager.instance.addCompletedLevelCount(-1);
|
||||||
this._passModalCompletedLevelCount = AuthManager.instance.completedLevelCount;
|
this._passCompletedLevelCount = AuthManager.instance.completedLevelCount;
|
||||||
this._passModalPreviousCompletedLevelCount = null;
|
this._passPreviousCompletedLevelCount = null;
|
||||||
}
|
}
|
||||||
console.log(`[PageLevel] 通关上报成功,首次通关: ${result.firstClear}, 有下一关: ${!!result.nextLevel}`);
|
console.log(`[PageLevel] 通关上报成功,首次通关: ${result.firstClear}, 有下一关: ${!!result.nextLevel}`);
|
||||||
|
|
||||||
// 若此时通关弹窗已打开但当时 nextLevel 还没到,补一次底图预替换
|
|
||||||
this._swapToNextLevelImagesIfReady();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this._passModalCompletedLevelCount = null;
|
this._passCompletedLevelCount = null;
|
||||||
this._passModalPreviousCompletedLevelCount = null;
|
this._passPreviousCompletedLevelCount = null;
|
||||||
this._recordCurrentShareSubmission(undefined, timeSpent);
|
this._recordCurrentShareSubmission(undefined, timeSpent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2011,158 +2079,357 @@ export class PageLevel extends BaseView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 显示通关弹窗
|
* 通关展示:BottomLayout / TipsLayout 透明度淡出,PassNode 从屏幕左侧滑入,
|
||||||
* 将弹窗添加到 Canvas 根节点下(而非 PageLevel 子节点)
|
* Caidai 彩带 spine 播放 "open" 动画;按 NextLevel 时再走 _hidePassNode。
|
||||||
* 这样 Widget 可以正确对齐到全屏
|
*
|
||||||
|
* 与原 PassModal 弹窗的差异:
|
||||||
|
* - PassNode 是 PageLevel 自己的子节点,不是 instantiate 出的弹窗,无需挂到 Canvas
|
||||||
|
* - PassNode 不全屏遮盖,所以不能在它显示期间偷偷预换底图(用户能看见底图)
|
||||||
|
* - 分享模式不展示成就体系(passTitleLevelNode 整体隐藏)
|
||||||
*/
|
*/
|
||||||
private _showPassModal(): void {
|
private _showPassNode(): void {
|
||||||
if (!this.passModalPrefab) {
|
if (!this.passNode) {
|
||||||
console.warn('[PageLevel] passModalPrefab 未设置');
|
console.warn('[PageLevel] passNode 未挂引用,跳过通关展示');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果弹窗已显示,不再重复创建
|
if (this._isPassNodeShown || this._isPassNodeAnimating) {
|
||||||
if (this._passModalNode && this._passModalNode.isValid) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 实例化弹窗
|
this._isPassNodeShown = true;
|
||||||
const modalNode = instantiate(this.passModalPrefab);
|
|
||||||
modalNode.setPosition(PageLevel.ZERO_POS);
|
|
||||||
modalNode.setSiblingIndex(PassModal.MODAL_Z_INDEX);
|
|
||||||
|
|
||||||
// 找到 Canvas 根节点并添加弹窗
|
// 配置成就体系数据 / 按钮文案 / 事件
|
||||||
const canvasNode = this.node.parent;
|
this._setupPassNodeContent();
|
||||||
if (canvasNode) {
|
this._bindPassNodeEvents();
|
||||||
canvasNode.addChild(modalNode);
|
|
||||||
} else {
|
|
||||||
this.node.addChild(modalNode);
|
|
||||||
}
|
|
||||||
this._passModalNode = modalNode;
|
|
||||||
|
|
||||||
// 获取 PassModal 组件并设置回调
|
// 启动彩带 + 滑入动画 + 淡出底部UI + 通关音效
|
||||||
const passModal = modalNode.getComponent(PassModal);
|
this._playCaidai();
|
||||||
if (passModal) {
|
this._playPassNodeShowAnimation();
|
||||||
const completedCount = this._getPassModalCompletedLevelCount();
|
this.playSuccessSound();
|
||||||
const titleInfo = AchievementTitleManager.getTitleInfo(completedCount);
|
|
||||||
const previousCompletedCount = this._passModalPreviousCompletedLevelCount;
|
|
||||||
const previousTitleInfo = (previousCompletedCount !== null && previousCompletedCount !== completedCount)
|
|
||||||
? AchievementTitleManager.getTitleInfo(previousCompletedCount)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
passModal.setParams({
|
console.log('[PageLevel] 显示通关页 PassNode');
|
||||||
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 {
|
private _getPassCompletedLevelCount(): number {
|
||||||
return this._passModalCompletedLevelCount ?? AuthManager.instance.completedLevelCount;
|
return this._passCompletedLevelCount ?? AuthManager.instance.completedLevelCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 在通关弹窗遮盖期间,把底层的主图预先替换为下一关的图
|
* 配置通关页内容:
|
||||||
* 目的:点击"下一关"关闭弹窗的瞬间,底图已经是新的,避免闪屏
|
* - 成就体系(分享模式整体隐藏;普通模式根据通关数量绑定文案 / 进度条 / 跨称号动画)
|
||||||
*
|
* - 「下一关 / 提交答案」按钮文案:分享模式最后一题显示"提交答案"
|
||||||
* 三种情况:
|
|
||||||
* - 分享模式:直接通过 ShareManager.ensureShareLevelReady(index+1) 取下一关 config
|
|
||||||
* - 正常模式 & nextLevel 已到达:从 LevelDataManager 缓存里取(enter 时已 preloadLevel 过)
|
|
||||||
* - 正常模式 & nextLevel 尚未到达(complete 接口还没回包):由 reportLevelCompleted 的回调补偿调用
|
|
||||||
*/
|
*/
|
||||||
private _swapToNextLevelImagesIfReady(): void {
|
private _setupPassNodeContent(): void {
|
||||||
if (this._isShareMode) {
|
const showTitle = !this._isShareMode;
|
||||||
const nextIndex = this._shareLevelIndex + 1;
|
if (this.passTitleLevelNode) {
|
||||||
if (nextIndex >= ShareManager.instance.getShareLevelCount()) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
// ensureShareLevelReady 若已缓存会立即 resolve;首次则会加载(此时图已在弹窗后面偷偷替换,用户无感)
|
|
||||||
ShareManager.instance.ensureShareLevelReady(nextIndex)
|
|
||||||
.then(config => this._applyNextLevelImagesFromConfig(config))
|
|
||||||
.catch(() => {});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextLevel = this._nextLevelData ?? AuthManager.instance.nextLevel;
|
this._isPassNodeAnimating = true;
|
||||||
if (!nextLevel) {
|
|
||||||
// complete 接口还没回包,走回调补偿路径
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
private _finalizeHidePassNode(): void {
|
||||||
if (!config) return;
|
// 隐藏 PassNode 并归位(_passNodeOriginalPos 已记录)
|
||||||
|
if (this.passNode) {
|
||||||
// 只在弹窗仍然打开时换图;否则说明用户已经点过下一关进入后续流程了,不再重复设置
|
this.passNode.active = false;
|
||||||
if (!this._passModalNode || !this._passModalNode.isValid) {
|
if (this._passNodeOriginalPos) {
|
||||||
return;
|
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);
|
this._unbindPassNodeEvents();
|
||||||
}
|
this._titleAnimator?.stop();
|
||||||
console.log('[PageLevel] 弹窗遮盖期间已预替换下一关底图');
|
this._isPassNodeShown = false;
|
||||||
|
this._isPassNodeAnimating = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 关闭通关弹窗
|
* 同步重置 PassNode 与底部两层到「未通关」状态。
|
||||||
|
* 用于:销毁、模式切换、IconSetting 离场、自动推进下一关 等需要立即清场的场景。
|
||||||
|
* 不带动画,直接归位。
|
||||||
*/
|
*/
|
||||||
private _closePassModal(): void {
|
private _resetPassNode(): void {
|
||||||
if (this._passModalNode && this._passModalNode.isValid) {
|
if (this.passNode) {
|
||||||
this._passModalNode.destroy();
|
Tween.stopAllByTarget(this.passNode);
|
||||||
this._passModalNode = null;
|
this.passNode.active = false;
|
||||||
console.log('[PageLevel] 关闭通关弹窗');
|
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
|
"__id__": 86
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"_active": true,
|
"_active": false,
|
||||||
"_components": [
|
"_components": [
|
||||||
{
|
{
|
||||||
"__id__": 98
|
"__id__": 98
|
||||||
@@ -1632,7 +1632,7 @@
|
|||||||
},
|
},
|
||||||
"_contentSize": {
|
"_contentSize": {
|
||||||
"__type__": "cc.Size",
|
"__type__": "cc.Size",
|
||||||
"width": 442.03125,
|
"width": 461.40625,
|
||||||
"height": 75.6
|
"height": 75.6
|
||||||
},
|
},
|
||||||
"_anchorPoint": {
|
"_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