feat: 优化通关效果
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
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 { _decorator, Node, EditBox, instantiate, Vec3, Button, Label, Sprite, SpriteFrame, AudioClip, AudioSource, Prefab, EffectAsset, UITransform, UIOpacity, ProgressBar, tween, Tween, Color, Layout, Widget, sp, view, resources } 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';
|
||||
@@ -41,7 +41,7 @@ export class PageLevel extends BaseView {
|
||||
private static readonly DEFAULT_STAMINA_MAX = 50;
|
||||
|
||||
/** 答案正确后到弹出通关弹窗之间的停留时间(不论是否有谐音梗都保持一致) */
|
||||
private static readonly PASS_MODAL_DELAY_MS = 1000;
|
||||
private static readonly PASS_MODAL_DELAY_MS = 800;
|
||||
|
||||
/** 图片2描述默认文案 */
|
||||
private static readonly DEFAULT_IMAGE2_DESCRIPTION = '这是什么?';
|
||||
@@ -91,6 +91,12 @@ export class PageLevel extends BaseView {
|
||||
/** 彩带 spine 动画名(一次播放) */
|
||||
private static readonly CAIDAI_ANIMATION_NAME = 'open';
|
||||
|
||||
/** pose 赞美音效 resources 路径(assets/resources/audios/good.mp3) */
|
||||
private static readonly GOOD_AUDIO_RESOURCE_PATH = 'audios/good';
|
||||
|
||||
/** pose 赞美段提前量:比通关动效 / 成功音效结束点提前 1 秒启动 */
|
||||
private static readonly POSE_PRAISE_ADVANCE_SECONDS = 1.5;
|
||||
|
||||
/**
|
||||
* 通关赞美 spine(pose 节点)档位:[最小通关数, 动画名],倒序匹配。
|
||||
* 1-5 关 → "1",6-10 关 → "2",11+ 关 → "3"。
|
||||
@@ -346,6 +352,9 @@ export class PageLevel extends BaseView {
|
||||
/** PassNode 原始 local position(prefab 摆放位),动画结束后用来回归 */
|
||||
private _passNodeOriginalPos: Vec3 | null = null;
|
||||
|
||||
/** PassNode Widget 初始启用状态;进出场动画期间临时关闭,避免激活首帧回写位置 */
|
||||
private _passNodeWidgetOriginalEnabled: boolean | null = null;
|
||||
|
||||
/** 通关页所用「已通关数量」(业务数据,给成就体系展示用) */
|
||||
private _passCompletedLevelCount: number | null = null;
|
||||
|
||||
@@ -358,6 +367,18 @@ export class PageLevel extends BaseView {
|
||||
/** pose Spine 隐藏延时定时器(setTimeout 句柄);切关 / 关页时需清理避免穿屏触发 */
|
||||
private _poseHideTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
/** pose 赞美延迟播放定时器;等待通关动画与成功音效结束后再触发 */
|
||||
private _posePraiseDelayTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
/** pose 赞美延迟播放序号,用于取消 resources.load 异步回调里的过期播放 */
|
||||
private _posePraiseSequenceId: number = 0;
|
||||
|
||||
/** good.mp3 加载缓存 */
|
||||
private _goodAudioClip: AudioClip | null = null;
|
||||
|
||||
/** good.mp3 加载中的 Promise,避免重复请求 resources */
|
||||
private _goodAudioLoadPromise: Promise<AudioClip | null> | null = null;
|
||||
|
||||
/** 通关页动画起点(通关前)的已通关数量;为 null 表示不播跨称号过渡 */
|
||||
private _passPreviousCompletedLevelCount: number | null = null;
|
||||
|
||||
@@ -703,10 +724,8 @@ export class PageLevel extends BaseView {
|
||||
// 设置图片描述
|
||||
this.setImageDescriptions(config.image1Description, config.image2Description);
|
||||
|
||||
// 设置关卡标题
|
||||
this.updateTitleLevelLabel();
|
||||
this.updatePkLevelProgressLabel();
|
||||
this.updatePkNextLevelButtonText();
|
||||
// 设置关卡标题与主线 / 分享模式常驻 UI 可见性
|
||||
this._refreshModeUI();
|
||||
|
||||
// 隐藏包袱答案,通关后再按 punchline 展示
|
||||
this.hidePunchline();
|
||||
@@ -1178,10 +1197,36 @@ export class PageLevel extends BaseView {
|
||||
if (this._isSubmittingShareResult) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 通关页已展示:分享模式下 passNextLevelButton 被整体隐藏(见 _setupPassNodeContent),
|
||||
// pkNextLevelButton 同时承担「通关后下一题」的入口。
|
||||
// 已经答对的题不需要二次确认(确认弹窗是「未答对就跳过」时的兜底),直接切关。
|
||||
// 注意切关顺序:先在 PassNode 仍覆盖屏幕时切换到下一关内容(分享模式题目均预加载,
|
||||
// _enterAndInitLevel 几乎瞬时完成),再播放 PassNode 滑出动画。否则 _hidePassNode 先滑出 →
|
||||
// 异步加载下一关 → _applyLevelConfig,期间玩家会看到上一题的提示 / 谐音梗 / 已填答案闪现。
|
||||
// 用 _isPassNodeAnimating 整段加锁防止异步过程中再次点击。
|
||||
if (this._isPassNodeShown) {
|
||||
if (this._isPassNodeAnimating) return;
|
||||
|
||||
this._isPassNodeAnimating = true;
|
||||
AudioManager.instance.playButtonClick();
|
||||
void (async () => {
|
||||
try {
|
||||
await this.goToNextLevel();
|
||||
await this._hidePassNode();
|
||||
} catch (err) {
|
||||
console.error('[PageLevel] 分享模式切换下一题失败:', err);
|
||||
this._isPassNodeAnimating = false;
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._isTransitioning) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 未通关就点下一题 = 跳过当前题,弹二次确认避免误触
|
||||
this.playClickSound();
|
||||
this._showShareNextConfirmModal(() => {
|
||||
this._recordCurrentShareSubmission();
|
||||
@@ -2135,11 +2180,17 @@ export class PageLevel extends BaseView {
|
||||
this._setupPassNodeContent();
|
||||
this._bindPassNodeEvents();
|
||||
|
||||
// 启动彩带 + 滑入动画 + 淡出底部UI + 通关音效 + 赞美 spine
|
||||
this._playCaidai();
|
||||
this._playPosePraise(this._sessionPassCount);
|
||||
this._playPassNodeShowAnimation();
|
||||
this.playSuccessSound();
|
||||
// 先让 PassNode 完整入场,再启动通关动效和成功音效;pose 赞美段延后到它们结束后播放。
|
||||
this._playPassNodeShowAnimation(() => {
|
||||
const caidaiDuration = this._playCaidai();
|
||||
this.playSuccessSound();
|
||||
if (this._isShareMode) {
|
||||
this._playPosePraise(this._sessionPassCount);
|
||||
return;
|
||||
}
|
||||
|
||||
this._schedulePosePraiseAfterPassEffects(this._sessionPassCount, caidaiDuration);
|
||||
});
|
||||
|
||||
console.log('[PageLevel] 显示通关页 PassNode');
|
||||
}
|
||||
@@ -2186,6 +2237,16 @@ export class PageLevel extends BaseView {
|
||||
}
|
||||
|
||||
// NextLevelButton 文案
|
||||
// 分享模式下「下一题 / 提交答案」入口由 pkNextLevelButton(黄色 PK 按钮)承担,
|
||||
// PassNode 内部的 passNextLevelButton / passShareButton 整体隐藏避免出现重复入口,
|
||||
// 但通关页的滑入 / 彩带 / 赞美等动画样式保持一致。
|
||||
if (this.passNextLevelButton) {
|
||||
this.passNextLevelButton.active = !this._isShareMode;
|
||||
}
|
||||
if (this.passShareButton) {
|
||||
this.passShareButton.active = !this._isShareMode;
|
||||
}
|
||||
|
||||
const isFinalShareSubmit = this._isShareMode && this._isFinalShareLevel();
|
||||
const nextLabel = this.passNextLevelButton?.getChildByName('Label')?.getComponent(Label);
|
||||
if (nextLabel) {
|
||||
@@ -2193,6 +2254,10 @@ export class PageLevel extends BaseView {
|
||||
? PageLevel.SHARE_SUBMIT_BUTTON_TEXT
|
||||
: '下一关';
|
||||
}
|
||||
|
||||
// 分享模式下 PassNode 展示后,让 pkNextLevelButton 文案与最终态保持同步
|
||||
// (SHARE_SUBMIT_BUTTON_TEXT vs SHARE_NEXT_BUTTON_TEXT),由 updatePkNextLevelButtonText 统一处理
|
||||
this.updatePkNextLevelButtonText();
|
||||
}
|
||||
|
||||
private _ensureTitleAnimator(): AchievementTitleAnimator {
|
||||
@@ -2213,6 +2278,8 @@ export class PageLevel extends BaseView {
|
||||
private _initPassNodeState(): void {
|
||||
if (this.passNode) {
|
||||
this._passNodeOriginalPos = this.passNode.position.clone();
|
||||
const widget = this.passNode.getComponent(Widget);
|
||||
this._passNodeWidgetOriginalEnabled = widget?.enabled ?? null;
|
||||
this.passNode.active = false;
|
||||
}
|
||||
if (this.caidaiNode) {
|
||||
@@ -2224,6 +2291,7 @@ export class PageLevel extends BaseView {
|
||||
if (this.poseNode) {
|
||||
this.poseNode.active = false;
|
||||
}
|
||||
this._clearPosePraiseDelayTimer();
|
||||
this._clearPoseHideTimer();
|
||||
|
||||
this._isPassNodeShown = false;
|
||||
@@ -2231,7 +2299,7 @@ export class PageLevel extends BaseView {
|
||||
}
|
||||
|
||||
/** PassNode 进入动画:底部两层淡出 + PassNode 从屏幕左侧滑入 */
|
||||
private _playPassNodeShowAnimation(): void {
|
||||
private _playPassNodeShowAnimation(onShown?: () => void): void {
|
||||
if (!this.passNode) return;
|
||||
|
||||
this._isPassNodeAnimating = true;
|
||||
@@ -2250,8 +2318,9 @@ export class PageLevel extends BaseView {
|
||||
const startX = originalPos.x - screenWidth;
|
||||
|
||||
Tween.stopAllByTarget(this.passNode);
|
||||
this.passNode.active = true;
|
||||
this._setPassNodeWidgetEnabledForAnimation(false);
|
||||
this.passNode.setPosition(startX, originalPos.y, originalPos.z);
|
||||
this.passNode.active = true;
|
||||
|
||||
tween(this.passNode)
|
||||
.to(
|
||||
@@ -2260,7 +2329,9 @@ export class PageLevel extends BaseView {
|
||||
{ easing: 'cubicOut' },
|
||||
)
|
||||
.call(() => {
|
||||
this._setPassNodeWidgetEnabledForAnimation(true);
|
||||
this._isPassNodeAnimating = false;
|
||||
onShown?.();
|
||||
})
|
||||
.start();
|
||||
}
|
||||
@@ -2269,7 +2340,7 @@ export class PageLevel extends BaseView {
|
||||
* PassNode 退出(用户点下一关时调用):滑出 PassNode + 底部两层淡入
|
||||
* 返回 Promise,外部链路通常 await 后再调 goToNextLevel
|
||||
*/
|
||||
private _hidePassNode(): Promise<void> {
|
||||
private _hidePassNode(restoreBottomLayers: boolean = true): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
if (!this.passNode || !this._isPassNodeShown) {
|
||||
resolve();
|
||||
@@ -2279,7 +2350,9 @@ export class PageLevel extends BaseView {
|
||||
this._isPassNodeAnimating = true;
|
||||
|
||||
// 底部两层淡入恢复
|
||||
this._fadeInBottomLayers();
|
||||
if (restoreBottomLayers) {
|
||||
this._fadeInBottomLayers();
|
||||
}
|
||||
|
||||
// 滑出 PassNode(向左滑出屏幕)
|
||||
const screenWidth = view.getVisibleSize().width;
|
||||
@@ -2287,6 +2360,7 @@ export class PageLevel extends BaseView {
|
||||
const exitX = originalPos.x - screenWidth;
|
||||
|
||||
Tween.stopAllByTarget(this.passNode);
|
||||
this._setPassNodeWidgetEnabledForAnimation(false);
|
||||
tween(this.passNode)
|
||||
.to(
|
||||
PageLevel.PASS_NODE_SLIDE_DURATION,
|
||||
@@ -2308,6 +2382,7 @@ export class PageLevel extends BaseView {
|
||||
if (this._passNodeOriginalPos) {
|
||||
this.passNode.setPosition(this._passNodeOriginalPos);
|
||||
}
|
||||
this._setPassNodeWidgetEnabledForAnimation(true);
|
||||
}
|
||||
|
||||
// 关掉彩带
|
||||
@@ -2319,6 +2394,7 @@ export class PageLevel extends BaseView {
|
||||
if (this.poseNode) {
|
||||
this.poseNode.active = false;
|
||||
}
|
||||
this._clearPosePraiseDelayTimer();
|
||||
this._clearPoseHideTimer();
|
||||
|
||||
this._unbindPassNodeEvents();
|
||||
@@ -2339,6 +2415,7 @@ export class PageLevel extends BaseView {
|
||||
if (this._passNodeOriginalPos) {
|
||||
this.passNode.setPosition(this._passNodeOriginalPos);
|
||||
}
|
||||
this._setPassNodeWidgetEnabledForAnimation(true);
|
||||
}
|
||||
|
||||
if (this.caidaiNode) {
|
||||
@@ -2349,6 +2426,7 @@ export class PageLevel extends BaseView {
|
||||
if (this.poseNode) {
|
||||
this.poseNode.active = false;
|
||||
}
|
||||
this._clearPosePraiseDelayTimer();
|
||||
this._clearPoseHideTimer();
|
||||
|
||||
this._restoreBottomLayersImmediate();
|
||||
@@ -2358,6 +2436,19 @@ export class PageLevel extends BaseView {
|
||||
this._isPassNodeAnimating = false;
|
||||
}
|
||||
|
||||
private _setPassNodeWidgetEnabledForAnimation(enabled: boolean): void {
|
||||
const widget = this.passNode?.getComponent(Widget);
|
||||
if (!widget) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._passNodeWidgetOriginalEnabled === null) {
|
||||
this._passNodeWidgetOriginalEnabled = widget.enabled;
|
||||
}
|
||||
|
||||
widget.enabled = enabled ? this._passNodeWidgetOriginalEnabled : false;
|
||||
}
|
||||
|
||||
/** PassNode 事件绑定(NextLevel + Share) */
|
||||
private _bindPassNodeEvents(): void {
|
||||
// 防御:先解绑,避免重复绑定
|
||||
@@ -2390,11 +2481,38 @@ export class PageLevel extends BaseView {
|
||||
|
||||
AudioManager.instance.playButtonClick();
|
||||
|
||||
// 普通模式 _showShareNextConfirmModal 内部首行就 onConfirm(),所以两条路统一
|
||||
this._showShareNextConfirmModal(async () => {
|
||||
await this._hidePassNode();
|
||||
void this.goToNextLevel();
|
||||
});
|
||||
if (this._isShareMode) {
|
||||
// 分享模式题目已预加载,继续保持“先换题、再退场”,避免露出上一题内容。
|
||||
this._showShareNextConfirmModal(() => {
|
||||
if (this._isPassNodeAnimating) return;
|
||||
|
||||
this._isPassNodeAnimating = true;
|
||||
void (async () => {
|
||||
try {
|
||||
await this.goToNextLevel();
|
||||
await this._hidePassNode();
|
||||
} catch (err) {
|
||||
console.error('[PageLevel] 分享模式切换下一题失败:', err);
|
||||
this._isPassNodeAnimating = false;
|
||||
}
|
||||
})();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
// 主线模式需要等 PassNode 退场动画结束后再进入下一关;
|
||||
// 下一关会走 enter 接口和图片刷新,提前切换会在动画过程中露出新题。
|
||||
await this._hidePassNode(false);
|
||||
await this.goToNextLevel();
|
||||
this._fadeInBottomLayers();
|
||||
} catch (err) {
|
||||
console.error('[PageLevel] 主线模式切换下一关失败:', err);
|
||||
this._isPassNodeAnimating = false;
|
||||
this._fadeInBottomLayers();
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2409,13 +2527,73 @@ export class PageLevel extends BaseView {
|
||||
});
|
||||
}
|
||||
|
||||
/** 启动彩带 spine 动画 "open",单次播放 */
|
||||
private _playCaidai(): void {
|
||||
if (!this.caidaiNode || !this.caidaiSkeleton) return;
|
||||
/** 启动彩带 spine 动画 "open",单次播放,返回动画时长(秒)用于串联后续赞美段 */
|
||||
private _playCaidai(): number {
|
||||
if (!this.caidaiNode || !this.caidaiSkeleton) return 0;
|
||||
|
||||
this.caidaiNode.active = true;
|
||||
// setAnimation 三参:trackIndex, name, loop
|
||||
this.caidaiSkeleton.setAnimation(0, PageLevel.CAIDAI_ANIMATION_NAME, false);
|
||||
const trackEntry = this.caidaiSkeleton.setAnimation(0, PageLevel.CAIDAI_ANIMATION_NAME, false);
|
||||
if (!trackEntry) return 0;
|
||||
|
||||
return Math.max(0, trackEntry.animationEnd - trackEntry.animationStart);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通关页主氛围顺序:
|
||||
* 1) PassNode 入场完成后播放彩带 + successAudio
|
||||
* 2) 等彩带动画和 successAudio 都结束
|
||||
* 3) 再播放 pose 赞美 Spine + good.mp3
|
||||
*/
|
||||
private _schedulePosePraiseAfterPassEffects(count: number, caidaiDuration: number): void {
|
||||
this._clearPosePraiseDelayTimer();
|
||||
|
||||
const successDuration = this.successAudio?.getDuration() ?? 0;
|
||||
const delaySeconds = Math.max(0, Math.max(caidaiDuration, successDuration) - PageLevel.POSE_PRAISE_ADVANCE_SECONDS);
|
||||
const delayMs = delaySeconds * 1000;
|
||||
const sequenceId = ++this._posePraiseSequenceId;
|
||||
|
||||
void this._loadGoodAudioClip();
|
||||
|
||||
this._posePraiseDelayTimer = setTimeout(() => {
|
||||
this._posePraiseDelayTimer = null;
|
||||
void this._playPosePraiseWithGoodAudio(count, sequenceId);
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
private async _playPosePraiseWithGoodAudio(count: number, sequenceId: number): Promise<void> {
|
||||
const goodAudio = await this._loadGoodAudioClip();
|
||||
if (sequenceId !== this._posePraiseSequenceId || !this._isPassNodeShown || !this.node.isValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._playPosePraise(count);
|
||||
this.playSound(goodAudio);
|
||||
}
|
||||
|
||||
private _loadGoodAudioClip(): Promise<AudioClip | null> {
|
||||
if (this._goodAudioClip) {
|
||||
return Promise.resolve(this._goodAudioClip);
|
||||
}
|
||||
if (this._goodAudioLoadPromise) {
|
||||
return this._goodAudioLoadPromise;
|
||||
}
|
||||
|
||||
this._goodAudioLoadPromise = new Promise<AudioClip | null>((resolve) => {
|
||||
resources.load(PageLevel.GOOD_AUDIO_RESOURCE_PATH, AudioClip, (err, clip) => {
|
||||
if (err) {
|
||||
console.warn('[PageLevel] good.mp3 音效加载失败:', err);
|
||||
this._goodAudioLoadPromise = null;
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
this._goodAudioClip = clip;
|
||||
resolve(clip);
|
||||
});
|
||||
});
|
||||
|
||||
return this._goodAudioLoadPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2447,6 +2625,15 @@ export class PageLevel extends BaseView {
|
||||
}
|
||||
}
|
||||
|
||||
/** 清理尚未触发的 pose 赞美延迟播放,避免快速切关后串到下一题。 */
|
||||
private _clearPosePraiseDelayTimer(): void {
|
||||
this._posePraiseSequenceId++;
|
||||
if (this._posePraiseDelayTimer !== null) {
|
||||
clearTimeout(this._posePraiseDelayTimer);
|
||||
this._posePraiseDelayTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 底部两层淡出(透明度 → 0),完成后 active = false */
|
||||
private _fadeOutBottomLayers(): void {
|
||||
for (const layer of [this.bottomLayoutNode, this.tipsLayout]) {
|
||||
@@ -2476,6 +2663,13 @@ export class PageLevel extends BaseView {
|
||||
for (const layer of [this.bottomLayoutNode, this.tipsLayout]) {
|
||||
if (!layer) continue;
|
||||
|
||||
// 分享模式下 bottomLayoutNode 由 _refreshModeUI 强制隐藏(pkNextLevelButton 接管该位置),
|
||||
// 不能在 _fadeInBottomLayers 里偷偷打开它,否则两个「下一题」入口又会同时出现。
|
||||
// tipsLayout 在两种模式下都需要恢复,这里只跳过 bottomLayoutNode。
|
||||
if (layer === this.bottomLayoutNode && this._isShareMode) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const opacity = this._ensureUIOpacity(layer);
|
||||
Tween.stopAllByTarget(opacity);
|
||||
layer.active = true;
|
||||
|
||||
Reference in New Issue
Block a user