feat: 添加线索解锁动画及倒计时紧迫状态处理

This commit is contained in:
richarjiang
2026-05-06 14:32:35 +08:00
parent 168288f6ae
commit 84f45ebfdf
2 changed files with 306 additions and 3 deletions

View File

@@ -7890,6 +7890,9 @@
"punchLayout": {
"__id__": 295
},
"punchDivider": {
"__id__": 316
},
"submitButton": null,
"inputTemplate": {
"__id__": 188

View File

@@ -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;
// 从紧迫态跳回安全区:停掉残留脉冲并复位 scaleupdateClockLabel 会负责把颜色改回)
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();
}
// 居中 YInputLayout 原始 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;
}
// punchLayoutactive 由调用方/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;
}
}