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

@@ -5471,7 +5471,7 @@
},
"_contentSize": {
"__type__": "cc.Size",
"width": 442.03125,
"width": 461.40625,
"height": 75.6
},
"_anchorPoint": {

View File

@@ -9346,8 +9346,8 @@
},
"_lpos": {
"__type__": "cc.Vec3",
"x": -8.80499999999995,
"y": 748.5280000000002,
"x": -8.805,
"y": 167.297,
"z": 0
},
"_lrot": {

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;

View File

@@ -2241,6 +2241,8 @@
"__id__": 0
},
"fileId": "90w8HRdbBPLYFqBkhPhWfM",
"instance": null,
"targetOverrides": null,
"nestedPrefabInstanceRoots": null
},
{
@@ -2254,9 +2256,6 @@
"_children": [
{
"__id__": 93
},
{
"__id__": 147
}
],
"_active": true,
@@ -2274,7 +2273,7 @@
"_lpos": {
"__type__": "cc.Vec3",
"x": -8.201,
"y": 492.399,
"y": -183.147,
"z": 0
},
"_lrot": {
@@ -2311,27 +2310,30 @@
"_children": [
{
"__id__": 94
},
{
"__id__": 140
}
],
"_active": true,
"_components": [
{
"__id__": 140
"__id__": 148
},
{
"__id__": 142
"__id__": 150
},
{
"__id__": 144
"__id__": 152
}
],
"_prefab": {
"__id__": 146
"__id__": 154
},
"_lpos": {
"__type__": "cc.Vec3",
"x": 0,
"y": -80.522,
"y": 46.995,
"z": 0
},
"_lrot": {
@@ -2382,7 +2384,7 @@
"_lpos": {
"__type__": "cc.Vec3",
"x": -10,
"y": 125,
"y": 540,
"z": 0
},
"_lrot": {
@@ -3459,7 +3461,7 @@
"_contentSize": {
"__type__": "cc.Size",
"width": 780,
"height": 400
"height": 1080
},
"_anchorPoint": {
"__type__": "cc.Vec2",
@@ -3485,6 +3487,181 @@
"targetOverrides": null,
"nestedPrefabInstanceRoots": null
},
{
"__type__": "cc.Node",
"_name": "ScrolViewMask",
"_objFlags": 0,
"__editorExtras__": {},
"_parent": {
"__id__": 93
},
"_children": [],
"_active": true,
"_components": [
{
"__id__": 141
},
{
"__id__": 143
},
{
"__id__": 145
}
],
"_prefab": {
"__id__": 147
},
"_lpos": {
"__type__": "cc.Vec3",
"x": 9.242999999999938,
"y": -517.9897285399853,
"z": 0
},
"_lrot": {
"__type__": "cc.Quat",
"x": 0,
"y": 0,
"z": 1,
"w": 6.123233995736766e-17
},
"_lscale": {
"__type__": "cc.Vec3",
"x": 0.7336757153338225,
"y": 0.7336757153338225,
"z": 0.7336757153338225
},
"_mobility": 0,
"_layer": 1073741824,
"_euler": {
"__type__": "cc.Vec3",
"x": 180,
"y": 180,
"z": 7.016709298534876e-15
},
"_id": ""
},
{
"__type__": "cc.UITransform",
"_name": "",
"_objFlags": 0,
"__editorExtras__": {},
"node": {
"__id__": 140
},
"_enabled": true,
"__prefab": {
"__id__": 142
},
"_contentSize": {
"__type__": "cc.Size",
"width": 1444.78,
"height": 60
},
"_anchorPoint": {
"__type__": "cc.Vec2",
"x": 0.5,
"y": 0.5
},
"_id": ""
},
{
"__type__": "cc.CompPrefabInfo",
"fileId": "57cb61nstNg7YGomw8vXcp"
},
{
"__type__": "cc.Sprite",
"_name": "",
"_objFlags": 0,
"__editorExtras__": {},
"node": {
"__id__": 140
},
"_enabled": true,
"__prefab": {
"__id__": 144
},
"_customMaterial": null,
"_srcBlendFactor": 2,
"_dstBlendFactor": 4,
"_color": {
"__type__": "cc.Color",
"r": 225,
"g": 245,
"b": 197,
"a": 255
},
"_spriteFrame": {
"__uuid__": "faab3d46-e885-4c46-8f19-9f872e7d6973@f9941",
"__expectedType__": "cc.SpriteFrame"
},
"_type": 0,
"_fillType": 0,
"_sizeMode": 0,
"_fillCenter": {
"__type__": "cc.Vec2",
"x": 0,
"y": 0
},
"_fillStart": 0,
"_fillRange": 0,
"_isTrimmedMode": true,
"_useGrayscale": false,
"_atlas": null,
"_id": ""
},
{
"__type__": "cc.CompPrefabInfo",
"fileId": "d5kG67QW9Nqplq9kKPqg4X"
},
{
"__type__": "cc.Widget",
"_name": "",
"_objFlags": 0,
"__editorExtras__": {},
"node": {
"__id__": 140
},
"_enabled": true,
"__prefab": {
"__id__": 146
},
"_alignFlags": 44,
"_target": null,
"_left": -130.757,
"_right": -149.243,
"_top": 0,
"_bottom": 0,
"_horizontalCenter": 0,
"_verticalCenter": 0,
"_isAbsLeft": true,
"_isAbsRight": true,
"_isAbsTop": true,
"_isAbsBottom": true,
"_isAbsHorizontalCenter": true,
"_isAbsVerticalCenter": true,
"_originalWidth": 1080,
"_originalHeight": 0,
"_alignMode": 2,
"_lockFlags": 4,
"_id": ""
},
{
"__type__": "cc.CompPrefabInfo",
"fileId": "60ik9u5ftG4oQNCowOqvJi"
},
{
"__type__": "cc.PrefabInfo",
"root": {
"__id__": 1
},
"asset": {
"__id__": 0
},
"fileId": "36lE6eex1I0Jo9+fLrBGoc",
"instance": null,
"targetOverrides": null,
"nestedPrefabInstanceRoots": null
},
{
"__type__": "cc.UITransform",
"_name": "",
@@ -3495,12 +3672,12 @@
},
"_enabled": true,
"__prefab": {
"__id__": 141
"__id__": 149
},
"_contentSize": {
"__type__": "cc.Size",
"width": 780,
"height": 400
"height": 1080
},
"_anchorPoint": {
"__type__": "cc.Vec2",
@@ -3523,7 +3700,7 @@
},
"_enabled": true,
"__prefab": {
"__id__": 143
"__id__": 151
},
"_type": 0,
"_inverted": false,
@@ -3545,7 +3722,7 @@
},
"_enabled": true,
"__prefab": {
"__id__": 145
"__id__": 153
},
"_customMaterial": null,
"_srcBlendFactor": 2,
@@ -3594,181 +3771,6 @@
"targetOverrides": null,
"nestedPrefabInstanceRoots": null
},
{
"__type__": "cc.Node",
"_name": "ScrolViewMask",
"_objFlags": 0,
"__editorExtras__": {},
"_parent": {
"__id__": 92
},
"_children": [],
"_active": true,
"_components": [
{
"__id__": 148
},
{
"__id__": 150
},
{
"__id__": 152
}
],
"_prefab": {
"__id__": 154
},
"_lpos": {
"__type__": "cc.Vec3",
"x": 9.242999999999938,
"y": -173.667,
"z": 0
},
"_lrot": {
"__type__": "cc.Quat",
"x": 0,
"y": 0,
"z": 1,
"w": 6.123233995736766e-17
},
"_lscale": {
"__type__": "cc.Vec3",
"x": 1,
"y": 1,
"z": 1
},
"_mobility": 0,
"_layer": 1073741824,
"_euler": {
"__type__": "cc.Vec3",
"x": 0,
"y": 0,
"z": 180
},
"_id": ""
},
{
"__type__": "cc.UITransform",
"_name": "",
"_objFlags": 0,
"__editorExtras__": {},
"node": {
"__id__": 147
},
"_enabled": true,
"__prefab": {
"__id__": 149
},
"_contentSize": {
"__type__": "cc.Size",
"width": 1080,
"height": 60
},
"_anchorPoint": {
"__type__": "cc.Vec2",
"x": 0.5,
"y": 0.5
},
"_id": ""
},
{
"__type__": "cc.CompPrefabInfo",
"fileId": "57cb61nstNg7YGomw8vXcp"
},
{
"__type__": "cc.Sprite",
"_name": "",
"_objFlags": 0,
"__editorExtras__": {},
"node": {
"__id__": 147
},
"_enabled": true,
"__prefab": {
"__id__": 151
},
"_customMaterial": null,
"_srcBlendFactor": 2,
"_dstBlendFactor": 4,
"_color": {
"__type__": "cc.Color",
"r": 225,
"g": 245,
"b": 197,
"a": 255
},
"_spriteFrame": {
"__uuid__": "faab3d46-e885-4c46-8f19-9f872e7d6973@f9941",
"__expectedType__": "cc.SpriteFrame"
},
"_type": 0,
"_fillType": 0,
"_sizeMode": 0,
"_fillCenter": {
"__type__": "cc.Vec2",
"x": 0,
"y": 0
},
"_fillStart": 0,
"_fillRange": 0,
"_isTrimmedMode": true,
"_useGrayscale": false,
"_atlas": null,
"_id": ""
},
{
"__type__": "cc.CompPrefabInfo",
"fileId": "d5kG67QW9Nqplq9kKPqg4X"
},
{
"__type__": "cc.Widget",
"_name": "",
"_objFlags": 0,
"__editorExtras__": {},
"node": {
"__id__": 147
},
"_enabled": true,
"__prefab": {
"__id__": 153
},
"_alignFlags": 40,
"_target": null,
"_left": -130.757,
"_right": -149.243,
"_top": 0,
"_bottom": 0,
"_horizontalCenter": 0,
"_verticalCenter": 0,
"_isAbsLeft": true,
"_isAbsRight": true,
"_isAbsTop": true,
"_isAbsBottom": true,
"_isAbsHorizontalCenter": true,
"_isAbsVerticalCenter": true,
"_originalWidth": 1080,
"_originalHeight": 0,
"_alignMode": 2,
"_lockFlags": 0,
"_id": ""
},
{
"__type__": "cc.CompPrefabInfo",
"fileId": "60ik9u5ftG4oQNCowOqvJi"
},
{
"__type__": "cc.PrefabInfo",
"root": {
"__id__": 1
},
"asset": {
"__id__": 0
},
"fileId": "36lE6eex1I0Jo9+fLrBGoc",
"instance": null,
"targetOverrides": null,
"nestedPrefabInstanceRoots": null
},
{
"__type__": "cc.UITransform",
"_name": "",
@@ -3784,7 +3786,7 @@
"_contentSize": {
"__type__": "cc.Size",
"width": 800,
"height": 400
"height": 1600
},
"_anchorPoint": {
"__type__": "cc.Vec2",
@@ -3837,6 +3839,8 @@
"__id__": 0
},
"fileId": "bdqvu61fVFTIU1TQqs/qES",
"instance": null,
"targetOverrides": null,
"nestedPrefabInstanceRoots": null
},
{

View File

@@ -5396,6 +5396,10 @@
"dataBtn": {
"__id__": 34
},
"commonModalPrefab": {
"__uuid__": "5379669e-7cd7-45b6-9dd3-4a021730b23e",
"__expectedType__": "cc.Prefab"
},
"roundedSpriteEffect": {
"__uuid__": "f0080a34-1786-4547-8d81-d89cc517b63e",
"__expectedType__": "cc.EffectAsset"
@@ -5483,4 +5487,4 @@
"instance": null,
"targetOverrides": null
}
]
]

View File

@@ -1,6 +1,7 @@
import { _decorator, Node, Button, Sprite, Label, Toggle, ScrollView, EditBox, instantiate, UITransform, Vec2, EventTouch, EffectAsset } from 'cc';
import { _decorator, Node, Button, Sprite, Label, Toggle, ScrollView, EditBox, instantiate, UITransform, Vec2, EventTouch, EffectAsset, Prefab } from 'cc';
import { BaseView } from 'db://assets/scripts/core/BaseView';
import { ViewManager } from 'db://assets/scripts/core/ViewManager';
import { CommonModal } from 'db://assets/prefabs/CommonModal';
import { CompletedLevelsManager } from 'db://assets/scripts/utils/CompletedLevelsManager';
import { ToastManager } from 'db://assets/scripts/utils/ToastManager';
import { ShareManager } from 'db://assets/scripts/utils/ShareManager';
@@ -30,6 +31,7 @@ const LAYOUT_CONFIG = {
CENTER_ROWS: 2,
VIEW_WIDTH: 900,
VIEW_HEIGHT: 1300,
LIST_BOTTOM_GAP_TO_TITLE: 54,
};
/** 必须选择的关卡数量 */
@@ -67,6 +69,9 @@ export class PageWriteLevels extends BaseView {
@property({ type: EffectAsset, tooltip: '关卡封面圆角材质 EffectAsset' })
roundedSpriteEffect: EffectAsset | null = null;
@property({ type: Prefab, tooltip: '通用弹窗预制体' })
commonModalPrefab: Prefab | null = null;
@property({ tooltip: '关卡封面圆角半径比例相对于短边0-0.5' })
coverCornerRadius: number = 0.1;
@@ -86,6 +91,7 @@ export class PageWriteLevels extends BaseView {
console.log('[PageWriteLevels] onViewLoad');
this._initButtons();
this._initScrollView();
this._resizeScrollViewport();
this._updateSelectionUI();
}
@@ -132,12 +138,51 @@ export class PageWriteLevels extends BaseView {
onViewShow(): void {
console.log('[PageWriteLevels] onViewShow');
this._resizeScrollViewport();
this._updateContentSize();
// 仅首次初始化列表,从预览页返回时保留选中状态
if (this._itemNodes.length === 0) {
void this._initLevelList();
}
}
private _resizeScrollViewport(): void {
if (!this.scrollView || !this._viewTransform || !this.shareTitleEditBox) {
return;
}
const rootTransform = this.node.getComponent(UITransform);
const scrollTransform = this.scrollView.getComponent(UITransform);
const scrollWidget = this.scrollView.getComponent('cc.Widget') as any;
const shareTitleTransform = this.shareTitleEditBox.getComponent(UITransform);
const shareTitleWidget = this.shareTitleEditBox.getComponent('cc.Widget') as any;
const bottomMaskNode = this.scrollView.getChildByName('ScrolViewMask');
if (!rootTransform || !scrollTransform || !shareTitleTransform || !scrollWidget || !shareTitleWidget) {
return;
}
const topInset = Number(scrollWidget.top ?? 0);
const shareBottomInset = Number(shareTitleWidget.bottom ?? 0);
const shareTitleHeight = shareTitleTransform.height * Math.abs(this.shareTitleEditBox.scale.y);
const nextHeight = Math.max(
LAYOUT_CONFIG.VIEW_HEIGHT,
rootTransform.height - topInset - shareBottomInset - shareTitleHeight - LAYOUT_CONFIG.LIST_BOTTOM_GAP_TO_TITLE,
);
scrollTransform.setContentSize(scrollTransform.width, nextHeight);
this._viewTransform.setContentSize(this._viewTransform.width, nextHeight);
if (bottomMaskNode) {
bottomMaskNode.setPosition(
bottomMaskNode.position.x,
-(nextHeight / 2) + 30,
bottomMaskNode.position.z,
);
}
}
private async _initLevelList(): Promise<void> {
this._clearList();
@@ -474,11 +519,11 @@ export class PageWriteLevels extends BaseView {
}
}
// 更新 CompleteButton 和 PreviewButton 的可用状态
// 预览与分享数量不足时也要允许点击,统一弹出提示弹窗。
if (this.completeBtn) {
const btn = this.completeBtn.getComponent(Button);
if (btn) {
btn.interactable = isFull;
btn.interactable = true;
}
}
if (this.previewBtn) {
@@ -501,18 +546,31 @@ export class PageWriteLevels extends BaseView {
}
/**
* 校验是否已选满关卡,未满则 Toast 提示
* 校验是否已选满关卡,未满则弹出统一提示弹窗
* @returns true 表示校验通过
*/
private _validateSelection(): boolean {
if (this._selectedIndices.size < MAX_SELECTION) {
const remaining = MAX_SELECTION - this._selectedIndices.size;
ToastManager.instance.show(`还需选择${remaining}个关卡`);
this._showSelectionRequiredModal();
return false;
}
return true;
}
private _showSelectionRequiredModal(): void {
if (!this.commonModalPrefab) {
console.warn('[PageWriteLevels] commonModalPrefab 未设置,回退为 Toast 提示');
ToastManager.instance.show(`请选择${MAX_SELECTION}个关卡后再预览或分享`);
return;
}
CommonModal.show(this.commonModalPrefab, {
title: '提示',
content: `要选择${MAX_SELECTION}个关卡才能分享和预览`,
buttonConfirm: '知道了',
});
}
private _onPreviewClick(): void {
AudioManager.instance.playButtonClick();
if (!this._validateSelection()) return;

Binary file not shown.

View File

@@ -0,0 +1,14 @@
{
"ver": "1.0.0",
"importer": "audio-clip",
"imported": true,
"uuid": "f1b26185-7493-4c10-b45a-e35ecc86c507",
"files": [
".json",
".mp3"
],
"subMetas": {},
"userData": {
"downloadMode": 0
}
}