From 84f45ebfdf1b5a31bf6b5e02f96274405621a698 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Wed, 6 May 2026 14:32:35 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E7=BA=BF=E7=B4=A2?= =?UTF-8?q?=E8=A7=A3=E9=94=81=E5=8A=A8=E7=94=BB=E5=8F=8A=E5=80=92=E8=AE=A1?= =?UTF-8?q?=E6=97=B6=E7=B4=A7=E8=BF=AB=E7=8A=B6=E6=80=81=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/prefabs/PageLevel.prefab | 3 + assets/prefabs/PageLevel.ts | 306 +++++++++++++++++++++++++++++++- 2 files changed, 306 insertions(+), 3 deletions(-) diff --git a/assets/prefabs/PageLevel.prefab b/assets/prefabs/PageLevel.prefab index 2508963..2d24713 100644 --- a/assets/prefabs/PageLevel.prefab +++ b/assets/prefabs/PageLevel.prefab @@ -7890,6 +7890,9 @@ "punchLayout": { "__id__": 295 }, + "punchDivider": { + "__id__": 316 + }, "submitButton": null, "inputTemplate": { "__id__": 188 diff --git a/assets/prefabs/PageLevel.ts b/assets/prefabs/PageLevel.ts index e937e03..3d1d0cb 100644 --- a/assets/prefabs/PageLevel.ts +++ b/assets/prefabs/PageLevel.ts @@ -1,4 +1,4 @@ -import { _decorator, Node, EditBox, instantiate, Vec3, Button, Label, Sprite, SpriteFrame, AudioClip, AudioSource, Prefab, EffectAsset, UITransform } from 'cc'; +import { _decorator, Node, EditBox, instantiate, Vec3, Button, Label, Sprite, SpriteFrame, AudioClip, AudioSource, Prefab, EffectAsset, UITransform, UIOpacity, tween, Tween, Color } 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,6 +41,33 @@ export class PageLevel extends BaseView { /** 图片2描述默认文案 */ private static readonly DEFAULT_IMAGE2_DESCRIPTION = '这是什么?'; + /** 线索解锁出现动画时长(ms) */ + private static readonly CLUE_APPEAR_DURATION = 0.3; + + /** 线索解锁出现动画起始缩放 */ + private static readonly CLUE_APPEAR_START_SCALE = 0.8; + + /** 倒计时进入紧迫状态的阈值(秒,≤ 该值开始警示) */ + private static readonly CLOCK_URGENT_THRESHOLD = 10; + + /** 紧迫状态下倒计时字体颜色(红) */ + private static readonly CLOCK_URGENT_COLOR = new Color(230, 60, 60, 255); + + /** 倒计时 tick 脉冲峰值缩放 */ + private static readonly CLOCK_PULSE_PEAK_SCALE = 1.3; + + /** 倒计时 tick 脉冲单向时长(放大、回落各一半) */ + private static readonly CLOCK_PULSE_HALF_DURATION = 0.15; + + /** 谐音梗揭示动画:InputLayout 位移、divider 淡入时长 */ + private static readonly PUNCH_REVEAL_DURATION = 0.3; + + /** 谐音梗揭示动画:punchLayout 出现的起始缩放 */ + private static readonly PUNCH_REVEAL_START_SCALE = 0.85; + + /** 谐音梗揭示动画:punchLayout 在 InputLayout 动起来后再出现的延迟(让动画有节奏) */ + private static readonly PUNCH_REVEAL_DELAY = 0.1; + // ========== 节点引用 ========== @property(Node) inputLayout: Node | null = null; @@ -48,6 +75,10 @@ export class PageLevel extends BaseView { @property(Node) punchLayout: Node | null = null; + /** Action 区域内 InputLayout 与 punchLayout 之间的分割线节点(prefab 中的 border_dashline_wht) */ + @property(Node) + punchDivider: Node | null = null; + @property(Node) submitButton: Node | null = null; @@ -150,6 +181,18 @@ export class PageLevel extends BaseView { /** 倒计时剩余秒数 */ private _countdown: number = 60; + /** clockLabel 非紧迫状态下的原始颜色(首次渲染时懒记录,避免 hardcode prefab 颜色) */ + private _clockLabelNormalColor: Color | null = null; + + /** InputLayout 原始位置(prefab 中的初始 _lpos,作为"有梗揭示"的目标位置) */ + private _inputLayoutOriginalPos: Vec3 | null = null; + + /** punchLayout 原始位置(prefab 中的初始 _lpos,作为"有梗揭示"的目标位置) */ + private _punchLayoutOriginalPos: Vec3 | null = null; + + /** "无梗居中态"下 InputLayout 的 Y 坐标:InputLayout 原始 Y 与 punchLayout 原始 Y 的中点 */ + private _inputLayoutCenteredY: number | null = null; + /** 关卡开始时间戳(ms),用于准确计算耗时 */ private _levelStartTime: number = 0; @@ -206,6 +249,9 @@ export class PageLevel extends BaseView { onViewLoad(): void { console.log('[PageLevel] onViewLoad'); + // 必须在任何可能改动 InputLayout/punchLayout 位置的逻辑之前记录原始位置 + this._captureActionOriginalPositions(); + const params = this.getParams(); this._isShareMode = params?.shareMode === true; @@ -727,6 +773,45 @@ export class PageLevel extends BaseView { } } + /** + * 线索解锁出现动画:透明度淡入 + 轻微缩放回弹(backOut) + * 设计:淡入与缩放并行、同时长,用 backOut 轻微过冲 ~5% 带来"弹出"感但不夸张 + */ + private playClueAppearAnimation(tipsItem: Node): void { + // 透明度控制用 UIOpacity,避免污染子节点颜色 + let uiOpacity = tipsItem.getComponent(UIOpacity); + if (!uiOpacity) { + uiOpacity = tipsItem.addComponent(UIOpacity); + } + + // 停掉任何进行中的动画,保证重复点击/快速切换时状态一致 + Tween.stopAllByTarget(uiOpacity); + Tween.stopAllByTarget(tipsItem); + + // 起始态 + uiOpacity.opacity = 0; + tipsItem.setScale( + PageLevel.CLUE_APPEAR_START_SCALE, + PageLevel.CLUE_APPEAR_START_SCALE, + 1 + ); + + // 透明度:线性淡入即可(淡入在感知上本来就不需要额外缓动) + tween(uiOpacity) + .to(PageLevel.CLUE_APPEAR_DURATION, { opacity: 255 }) + .start(); + + // 缩放:backOut —— 轻微过冲再回落到 1.0,是"弹出"感的关键 + tween(tipsItem) + .to( + PageLevel.CLUE_APPEAR_DURATION, + { scale: new Vec3(1, 1, 1) }, + { easing: 'backOut' } + ) + .start(); + } + + /** * 更新底部线索/答案按钮文案 */ @@ -802,6 +887,12 @@ export class PageLevel extends BaseView { this.playClickSound(); this.setClue(index, clueContent); + // 解锁线索后播放"出现"动画,让内容刷新不突兀 + const tipsItem = this.getTipsItem(index); + if (tipsItem) { + this.playClueAppearAnimation(tipsItem); + } + // 推进到下一条待解锁线索 this._nextClueIndex++; @@ -822,7 +913,15 @@ export class PageLevel extends BaseView { return; } + const wasUrgent = this._countdown <= PageLevel.CLOCK_URGENT_THRESHOLD; this._countdown += 60; + + // 从紧迫态跳回安全区:停掉残留脉冲并复位 scale(updateClockLabel 会负责把颜色改回) + if (wasUrgent && this._countdown > PageLevel.CLOCK_URGENT_THRESHOLD && this.clockLabel) { + Tween.stopAllByTarget(this.clockLabel.node); + this.clockLabel.node.setScale(1, 1, 1); + } + this.updateClockLabel(); this.playClickSound(); ToastManager.show('已成功增加60秒!'); @@ -950,6 +1049,9 @@ export class PageLevel extends BaseView { } this._punchBlockNodes.push(blockNode); } + + // 揭示谐音梗:InputLayout 回到原位、divider 与 punchLayout 带动画出现 + this._playPunchRevealAnimation(); } private clearPunchBlocks(): void { @@ -979,6 +1081,7 @@ export class PageLevel extends BaseView { const template = this.getPunchBlockTemplateNode(); if (!template) { this.punchLayout.active = false; + this._applyNoPunchLayout(false); return; } @@ -996,6 +1099,9 @@ export class PageLevel extends BaseView { } this._punchBlockNodes = []; + + // 无梗态布局:InputLayout 居中、分割线与 punchLayout 隐藏 + this._applyNoPunchLayout(false); } private removeUnexpectedPunchLayoutChildren(template: Node): void { @@ -1032,6 +1138,140 @@ export class PageLevel extends BaseView { return null; } + // ========== Action 区域布局 / 谐音梗揭示动画 ========== + + /** + * 记录 InputLayout / punchLayout 的原始位置,计算"无梗居中态"下 InputLayout 的 Y + * 只在 onViewLoad 最早期执行一次,后续所有位移都以此为基准 + */ + private _captureActionOriginalPositions(): void { + if (this.inputLayout && !this._inputLayoutOriginalPos) { + this._inputLayoutOriginalPos = this.inputLayout.position.clone(); + } + if (this.punchLayout && !this._punchLayoutOriginalPos) { + this._punchLayoutOriginalPos = this.punchLayout.position.clone(); + } + + // 居中 Y:InputLayout 原始 Y 与 punchLayout 原始 Y 的中点 + if (this._inputLayoutOriginalPos && this._punchLayoutOriginalPos) { + this._inputLayoutCenteredY = (this._inputLayoutOriginalPos.y + this._punchLayoutOriginalPos.y) / 2; + } + } + + /** + * 应用"无梗态"布局:InputLayout 居中,分割线 + punchLayout 隐藏 + * @param animated 为 true 时 InputLayout 走动画移动到居中位;false 时静默到位(关卡加载) + */ + private _applyNoPunchLayout(animated: boolean): void { + // 分割线:隐藏(用 active,避免残留一条线) + if (this.punchDivider) { + Tween.stopAllByTarget(this.punchDivider); + this.punchDivider.active = false; + } + + // punchLayout:active 由调用方/setPunchline 控制,此处确保透明度复位 + if (this.punchLayout) { + Tween.stopAllByTarget(this.punchLayout); + const uiOpacity = this.punchLayout.getComponent(UIOpacity); + if (uiOpacity) { + Tween.stopAllByTarget(uiOpacity); + uiOpacity.opacity = 255; + } + this.punchLayout.setScale(1, 1, 1); + } + + // InputLayout:移动到居中 Y + if (this.inputLayout && this._inputLayoutOriginalPos && this._inputLayoutCenteredY !== null) { + Tween.stopAllByTarget(this.inputLayout); + const targetPos = new Vec3( + this._inputLayoutOriginalPos.x, + this._inputLayoutCenteredY, + this._inputLayoutOriginalPos.z + ); + if (animated) { + tween(this.inputLayout) + .to(PageLevel.PUNCH_REVEAL_DURATION, { position: targetPos }, { easing: 'cubicOut' }) + .start(); + } else { + this.inputLayout.setPosition(targetPos); + } + } + } + + /** + * 播放谐音梗揭示动画: + * 1) InputLayout 从居中位平滑回到原始位(cubicOut,让出下方空间) + * 2) divider 淡入(仅透明度) + * 3) punchLayout 淡入 + backOut 缩放回弹,延迟 100ms 让节奏错开 + */ + private _playPunchRevealAnimation(): void { + if (!this._inputLayoutOriginalPos || !this._punchLayoutOriginalPos) { + // 未记录到原始位置时兜底:直接显示到位,不做动画 + if (this.inputLayout && this._inputLayoutOriginalPos) { + this.inputLayout.setPosition(this._inputLayoutOriginalPos); + } + if (this.punchDivider) this.punchDivider.active = true; + return; + } + + // 1) InputLayout 位移回原位 + if (this.inputLayout) { + Tween.stopAllByTarget(this.inputLayout); + tween(this.inputLayout) + .to( + PageLevel.PUNCH_REVEAL_DURATION, + { position: this._inputLayoutOriginalPos.clone() }, + { easing: 'cubicOut' } + ) + .start(); + } + + // 2) 分割线淡入 + if (this.punchDivider) { + let dividerOpacity = this.punchDivider.getComponent(UIOpacity); + if (!dividerOpacity) { + dividerOpacity = this.punchDivider.addComponent(UIOpacity); + } + Tween.stopAllByTarget(dividerOpacity); + dividerOpacity.opacity = 0; + this.punchDivider.active = true; + tween(dividerOpacity) + .to(PageLevel.PUNCH_REVEAL_DURATION, { opacity: 255 }) + .start(); + } + + // 3) punchLayout 淡入 + 缩放回弹(延迟,节奏错开) + if (this.punchLayout) { + let punchOpacity = this.punchLayout.getComponent(UIOpacity); + if (!punchOpacity) { + punchOpacity = this.punchLayout.addComponent(UIOpacity); + } + Tween.stopAllByTarget(punchOpacity); + Tween.stopAllByTarget(this.punchLayout); + + punchOpacity.opacity = 0; + this.punchLayout.setScale( + PageLevel.PUNCH_REVEAL_START_SCALE, + PageLevel.PUNCH_REVEAL_START_SCALE, + 1 + ); + + tween(punchOpacity) + .delay(PageLevel.PUNCH_REVEAL_DELAY) + .to(PageLevel.PUNCH_REVEAL_DURATION, { opacity: 255 }) + .start(); + + tween(this.punchLayout) + .delay(PageLevel.PUNCH_REVEAL_DELAY) + .to( + PageLevel.PUNCH_REVEAL_DURATION, + { scale: new Vec3(1, 1, 1) }, + { easing: 'backOut' } + ) + .start(); + } + } + // ========== 音效相关方法 ========== /** @@ -1073,6 +1313,7 @@ export class PageLevel extends BaseView { // _countdown 已在 _applyLevelConfig 中根据 timeLimit 设置 this._isTimeUp = false; this._levelStartTime = Date.now(); + this.resetClockVisual(); this.updateClockLabel(); this.schedule(this.onCountdownTick, 1); console.log(`[PageLevel] 开始倒计时 ${this._countdown} 秒`); @@ -1094,6 +1335,11 @@ export class PageLevel extends BaseView { this._countdown--; this.updateClockLabel(); + // 进入紧迫区间(≤10s):每次 tick 都播一次脉冲,跟秒同步形成"心跳"节奏 + if (this._countdown > 0 && this._countdown <= PageLevel.CLOCK_URGENT_THRESHOLD) { + this.playClockUrgentPulse(); + } + if (this._countdown <= 0) { this._isTimeUp = true; this.stopCountdown(); @@ -1105,8 +1351,62 @@ export class PageLevel extends BaseView { * 更新倒计时显示 */ private updateClockLabel(): void { - if (this.clockLabel) { - this.clockLabel.string = `${this._countdown}s`; + if (!this.clockLabel) return; + + this.clockLabel.string = `${this._countdown}s`; + + // 首次使用时懒记录原色,后续用来还原 + if (!this._clockLabelNormalColor) { + this._clockLabelNormalColor = this.clockLabel.color.clone(); + } + + // 颜色跟着数值走:进入紧迫区间变红,否则恢复原色 + const isUrgent = this._countdown > 0 && this._countdown <= PageLevel.CLOCK_URGENT_THRESHOLD; + const targetColor = isUrgent ? PageLevel.CLOCK_URGENT_COLOR : this._clockLabelNormalColor; + if (!this.clockLabel.color.equals(targetColor)) { + this.clockLabel.color = targetColor; + } + } + + /** + * 倒计时紧迫脉冲:每次 tick 触发一次心跳式缩放 + * 用 scale 1 → 1.3 → 1(各 150ms),跟秒 tick 同步形成节奏 + * 不使用 Tween 链的 delay,是为了让"下一次 tick"能立刻打断并重置到 1,避免动画叠加 + */ + private playClockUrgentPulse(): void { + if (!this.clockLabel) return; + + const node = this.clockLabel.node; + Tween.stopAllByTarget(node); + + // 从 1.0 开始,保证即使上一次脉冲未完成也能重置 + node.setScale(1, 1, 1); + + tween(node) + .to( + PageLevel.CLOCK_PULSE_HALF_DURATION, + { scale: new Vec3(PageLevel.CLOCK_PULSE_PEAK_SCALE, PageLevel.CLOCK_PULSE_PEAK_SCALE, 1) }, + { easing: 'quadOut' } + ) + .to( + PageLevel.CLOCK_PULSE_HALF_DURATION, + { scale: new Vec3(1, 1, 1) }, + { easing: 'quadIn' } + ) + .start(); + } + + /** + * 重置倒计时视觉状态(颜色、缩放),用于关卡切换 / 倒计时重新开始 + */ + private resetClockVisual(): void { + if (!this.clockLabel) return; + + Tween.stopAllByTarget(this.clockLabel.node); + this.clockLabel.node.setScale(1, 1, 1); + + if (this._clockLabelNormalColor) { + this.clockLabel.color = this._clockLabelNormalColor; } }