feat: 优化通关效果

This commit is contained in:
richarjiang
2026-05-31 21:15:21 +08:00
parent 7355cb9c2e
commit 8d2fbbbcf0
8 changed files with 499 additions and 225 deletions

View File

@@ -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;
/**
* 通关赞美 spinepose 节点)档位:[最小通关数, 动画名],倒序匹配。
* 1-5 关 → "1"6-10 关 → "2"11+ 关 → "3"。
@@ -346,6 +352,9 @@ export class PageLevel extends BaseView {
/** PassNode 原始 local positionprefab 摆放位),动画结束后用来回归 */
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;