feat: 添加线索解锁动画及倒计时紧迫状态处理
This commit is contained in:
@@ -7890,6 +7890,9 @@
|
||||
"punchLayout": {
|
||||
"__id__": 295
|
||||
},
|
||||
"punchDivider": {
|
||||
"__id__": 316
|
||||
},
|
||||
"submitButton": null,
|
||||
"inputTemplate": {
|
||||
"__id__": 188
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user