feat: 接入激励视频
This commit is contained in:
756
软著源代码/13-成就动画与通用Modal.md
Normal file
756
软著源代码/13-成就动画与通用Modal.md
Normal file
@@ -0,0 +1,756 @@
|
||||
# 梗中作乐 V1.0 - 源代码文档(第 13 部分)
|
||||
|
||||
软件名称:梗中作乐
|
||||
版本号:V1.0
|
||||
|
||||
---
|
||||
|
||||
## 第 13 部分:成就动画与通用弹窗
|
||||
|
||||
本部分包含通用确认弹窗、成就称号动画组件、成就称号管理器。
|
||||
|
||||
### 13.1 通用 Modal 弹窗 (assets/prefabs/CommonModal.ts)
|
||||
|
||||
```typescript
|
||||
import { _decorator, error, instantiate, Label, Node, Prefab, Size, UITransform, Vec3, view } from 'cc';
|
||||
import { BaseModal } from 'db://assets/scripts/core/BaseModal';
|
||||
import { ViewManager } from 'db://assets/scripts/core/ViewManager';
|
||||
const { ccclass, property } = _decorator;
|
||||
|
||||
export type CommonModalAction = () => void | boolean;
|
||||
|
||||
export interface CommonModalCallbacks {
|
||||
/** 点击关闭按钮回调,返回 false 可阻止默认关闭 */
|
||||
onClose?: CommonModalAction;
|
||||
/** 点击确认按钮回调,返回 false 可阻止默认关闭 */
|
||||
onConfirm?: CommonModalAction;
|
||||
/** 点击取消按钮回调,返回 false 可阻止默认关闭 */
|
||||
onCancel?: CommonModalAction;
|
||||
}
|
||||
|
||||
export interface CommonModalParams extends CommonModalCallbacks {
|
||||
/** 弹窗标题 */
|
||||
title?: string;
|
||||
/** 弹窗内容 */
|
||||
content?: string;
|
||||
/** 确认按钮文案 */
|
||||
buttonConfirm?: string;
|
||||
/** 取消按钮文案,传入后展示双按钮区域 */
|
||||
buttonCancel?: string;
|
||||
/** 点击关闭按钮后是否自动关闭弹窗,默认 true */
|
||||
closeOnClose?: boolean;
|
||||
/** 点击确认按钮后是否自动关闭弹窗,默认 true */
|
||||
closeOnConfirm?: boolean;
|
||||
/** 点击取消按钮后是否自动关闭弹窗,默认 true */
|
||||
closeOnCancel?: boolean;
|
||||
/** 关闭时是否销毁节点,默认 true */
|
||||
destroyOnClose?: boolean;
|
||||
/** 弹窗层级,默认 CommonModal.MODAL_Z_INDEX */
|
||||
zIndex?: number;
|
||||
}
|
||||
|
||||
@ccclass('CommonModal')
|
||||
export class CommonModal extends BaseModal {
|
||||
public static readonly MODAL_Z_INDEX = 999;
|
||||
|
||||
private static readonly DEFAULT_TITLE = '温馨提示';
|
||||
private static readonly DEFAULT_CONTENT = '';
|
||||
private static readonly DEFAULT_BUTTON_CONFIRM = '确定';
|
||||
private static readonly DEFAULT_BUTTON_CANCEL = '取消';
|
||||
|
||||
@property({ type: Label, tooltip: '标题文本' })
|
||||
titleLabel: Label | null = null;
|
||||
|
||||
@property({ type: Label, tooltip: '内容文本' })
|
||||
contentLabel: Label | null = null;
|
||||
|
||||
@property({ type: Label, tooltip: '确认按钮文本' })
|
||||
buttonConfirmLabel: Label | null = null;
|
||||
|
||||
@property({ type: Label, tooltip: '双按钮确认文本' })
|
||||
actionConfirmLabel: Label | null = null;
|
||||
|
||||
@property({ type: Label, tooltip: '取消按钮文本' })
|
||||
buttonCancelLabel: Label | null = null;
|
||||
|
||||
@property({ type: Node, tooltip: '关闭按钮节点' })
|
||||
closeBtn: Node | null = null;
|
||||
|
||||
@property({ type: Node, tooltip: '单确认按钮节点' })
|
||||
buttonConfirm: Node | null = null;
|
||||
|
||||
@property({ type: Node, tooltip: '双按钮容器节点' })
|
||||
actionDouble: Node | null = null;
|
||||
|
||||
@property({ type: Node, tooltip: '双按钮确认节点' })
|
||||
actionConfirm: Node | null = null;
|
||||
|
||||
@property({ type: Node, tooltip: '双按钮取消节点' })
|
||||
actionCancel: Node | null = null;
|
||||
|
||||
private _title: string = CommonModal.DEFAULT_TITLE;
|
||||
private _content: string = CommonModal.DEFAULT_CONTENT;
|
||||
private _buttonConfirmText: string = CommonModal.DEFAULT_BUTTON_CONFIRM;
|
||||
private _buttonCancelText: string = CommonModal.DEFAULT_BUTTON_CANCEL;
|
||||
private _callbacks: CommonModalCallbacks = {};
|
||||
private _closeOnClose: boolean = true;
|
||||
private _closeOnConfirm: boolean = true;
|
||||
private _closeOnCancel: boolean = true;
|
||||
private _destroyOnClose: boolean = true;
|
||||
private _useDoubleActions: boolean = false;
|
||||
private _screenSize: Size | null = null;
|
||||
private _loaded: boolean = false;
|
||||
|
||||
/**
|
||||
* 直接弹出通用弹窗。
|
||||
*
|
||||
* 使用示例:
|
||||
* CommonModal.show(this.commonModalPrefab, {
|
||||
* title: '提示',
|
||||
* content: '是否继续?',
|
||||
* buttonConfirm: '继续',
|
||||
* onConfirm: () => this.startGame()
|
||||
* });
|
||||
*/
|
||||
static show(prefab: Prefab, params: CommonModalParams = {}, parent?: Node): CommonModal | null {
|
||||
const container = parent ?? ViewManager.instance.getContainer();
|
||||
if (!container) {
|
||||
error('[CommonModal] 缺少弹窗挂载节点');
|
||||
return null;
|
||||
}
|
||||
|
||||
const modalNode = instantiate(prefab);
|
||||
const modal = modalNode.getComponent(CommonModal);
|
||||
if (!modal) {
|
||||
error('[CommonModal] 预制体缺少 CommonModal 组件');
|
||||
modalNode.destroy();
|
||||
return null;
|
||||
}
|
||||
|
||||
modal.setParams(params);
|
||||
modalNode.setPosition(Vec3.ZERO);
|
||||
modalNode.setSiblingIndex(params.zIndex ?? CommonModal.MODAL_Z_INDEX);
|
||||
container.addChild(modalNode);
|
||||
modal.onViewLoad();
|
||||
modal._doShow();
|
||||
|
||||
return modal;
|
||||
}
|
||||
|
||||
protected start(): void {
|
||||
this.onViewLoad();
|
||||
|
||||
if (!this.isShowing) {
|
||||
this._doShow();
|
||||
}
|
||||
}
|
||||
|
||||
setParams(params: CommonModalParams = {}): void {
|
||||
super.setParams(params);
|
||||
this.setConfig(params);
|
||||
}
|
||||
|
||||
setConfig(params: CommonModalParams = {}): void {
|
||||
this._title = params.title ?? CommonModal.DEFAULT_TITLE;
|
||||
this._content = params.content ?? CommonModal.DEFAULT_CONTENT;
|
||||
this._buttonConfirmText = params.buttonConfirm ?? CommonModal.DEFAULT_BUTTON_CONFIRM;
|
||||
this._buttonCancelText = params.buttonCancel ?? CommonModal.DEFAULT_BUTTON_CANCEL;
|
||||
this._callbacks = {
|
||||
onClose: params.onClose,
|
||||
onConfirm: params.onConfirm,
|
||||
onCancel: params.onCancel
|
||||
};
|
||||
this._closeOnClose = params.closeOnClose ?? true;
|
||||
this._closeOnConfirm = params.closeOnConfirm ?? true;
|
||||
this._closeOnCancel = params.closeOnCancel ?? true;
|
||||
this._destroyOnClose = params.destroyOnClose ?? true;
|
||||
this._useDoubleActions = this._shouldUseDoubleActions(params);
|
||||
this._applyContent();
|
||||
}
|
||||
|
||||
setTitle(title: string): void {
|
||||
this._title = title;
|
||||
this._applyContent();
|
||||
}
|
||||
|
||||
setContent(content: string): void {
|
||||
this._content = content;
|
||||
this._applyContent();
|
||||
}
|
||||
|
||||
setButtonConfirm(buttonConfirm: string): void {
|
||||
this._buttonConfirmText = buttonConfirm;
|
||||
this._applyContent();
|
||||
}
|
||||
|
||||
setButtonCancel(buttonCancel: string): void {
|
||||
this._buttonCancelText = buttonCancel;
|
||||
this._useDoubleActions = true;
|
||||
this._applyContent();
|
||||
}
|
||||
|
||||
setCallbacks(callbacks: CommonModalCallbacks): void {
|
||||
this._callbacks = callbacks;
|
||||
}
|
||||
|
||||
close(destroy: boolean = this._destroyOnClose): void {
|
||||
if (!this.node?.isValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isShowing) {
|
||||
this._doHide();
|
||||
}
|
||||
|
||||
if (destroy) {
|
||||
this.node.destroy();
|
||||
} else {
|
||||
this.node.active = false;
|
||||
}
|
||||
}
|
||||
|
||||
onViewLoad(): void {
|
||||
if (this._loaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._loaded = true;
|
||||
this._resolveNodes();
|
||||
this._bindButtonEvents();
|
||||
this._applyContent();
|
||||
this._updateWidget();
|
||||
}
|
||||
|
||||
onViewShow(): void {
|
||||
this._resolveNodes();
|
||||
this._applyContent();
|
||||
this._updateWidget();
|
||||
super.onViewShow();
|
||||
}
|
||||
|
||||
onViewDestroy(): void {
|
||||
this._unbindButtonEvents();
|
||||
this._callbacks = {};
|
||||
}
|
||||
|
||||
private _resolveNodes(): void {
|
||||
const panelNode = this._findNode('dialogPanel');
|
||||
|
||||
this.backdropNode = this.backdropNode ?? this._findNode('BgMask');
|
||||
this.animationNodes = this.animationNodes.length > 0 ? this.animationNodes : (panelNode ? [panelNode] : []);
|
||||
|
||||
this.closeBtn = this.closeBtn ?? this._findNode('dialogPanel/closeBtn');
|
||||
this.buttonConfirm = this.buttonConfirm ?? this._findNode('dialogPanel/ButtonConfirm');
|
||||
this.actionDouble = this.actionDouble ?? this._findNode('dialogPanel/ActionDouble');
|
||||
this.actionConfirm = this.actionConfirm ?? this._findNode('dialogPanel/ActionDouble/ButtonConfirm');
|
||||
this.actionCancel = this.actionCancel ?? this._findNode('dialogPanel/ActionDouble/ButtonCancel');
|
||||
this.titleLabel = this.titleLabel ?? this._findNode('dialogPanel/Title')?.getComponent(Label) ?? null;
|
||||
this.contentLabel = this.contentLabel ?? this._findNode('dialogPanel/Content')?.getComponent(Label) ?? null;
|
||||
this.buttonConfirmLabel = this.buttonConfirmLabel
|
||||
?? this._findNode('dialogPanel/ButtonConfirm/Label')?.getComponent(Label)
|
||||
?? null;
|
||||
this.actionConfirmLabel = this.actionConfirmLabel
|
||||
?? this._findNode('dialogPanel/ActionDouble/ButtonConfirm/Label')?.getComponent(Label)
|
||||
?? null;
|
||||
this.buttonCancelLabel = this.buttonCancelLabel
|
||||
?? this._findNode('dialogPanel/ActionDouble/ButtonCancel/Label')?.getComponent(Label)
|
||||
?? null;
|
||||
}
|
||||
|
||||
private _applyContent(): void {
|
||||
this._resolveNodes();
|
||||
this._applyActionMode();
|
||||
|
||||
if (this.titleLabel) {
|
||||
this.titleLabel.string = this._title;
|
||||
}
|
||||
|
||||
if (this.contentLabel) {
|
||||
this.contentLabel.string = this._content;
|
||||
}
|
||||
|
||||
if (this.buttonConfirmLabel) {
|
||||
this.buttonConfirmLabel.string = this._buttonConfirmText;
|
||||
}
|
||||
|
||||
if (this.actionConfirmLabel) {
|
||||
this.actionConfirmLabel.string = this._buttonConfirmText;
|
||||
}
|
||||
|
||||
if (this.buttonCancelLabel) {
|
||||
this.buttonCancelLabel.string = this._buttonCancelText;
|
||||
}
|
||||
}
|
||||
|
||||
private _updateWidget(): void {
|
||||
if (!this._screenSize) {
|
||||
this._screenSize = view.getVisibleSize();
|
||||
}
|
||||
|
||||
const uiTransform = this.node.getComponent(UITransform);
|
||||
if (uiTransform) {
|
||||
uiTransform.setContentSize(this._screenSize.width, this._screenSize.height);
|
||||
}
|
||||
}
|
||||
|
||||
private _bindButtonEvents(): void {
|
||||
this._unbindButtonEvents();
|
||||
|
||||
if (this.closeBtn) {
|
||||
this.closeBtn.on(Node.EventType.TOUCH_END, this._onCloseClick, this);
|
||||
}
|
||||
|
||||
if (this.buttonConfirm) {
|
||||
this.buttonConfirm.on(Node.EventType.TOUCH_END, this._onConfirmClick, this);
|
||||
}
|
||||
|
||||
if (this.actionConfirm) {
|
||||
this.actionConfirm.on(Node.EventType.TOUCH_END, this._onConfirmClick, this);
|
||||
}
|
||||
|
||||
if (this.actionCancel) {
|
||||
this.actionCancel.on(Node.EventType.TOUCH_END, this._onCancelClick, this);
|
||||
}
|
||||
}
|
||||
|
||||
private _unbindButtonEvents(): void {
|
||||
if (this.closeBtn?.isValid) {
|
||||
this.closeBtn.off(Node.EventType.TOUCH_END, this._onCloseClick, this);
|
||||
}
|
||||
|
||||
if (this.buttonConfirm?.isValid) {
|
||||
this.buttonConfirm.off(Node.EventType.TOUCH_END, this._onConfirmClick, this);
|
||||
}
|
||||
|
||||
if (this.actionConfirm?.isValid) {
|
||||
this.actionConfirm.off(Node.EventType.TOUCH_END, this._onConfirmClick, this);
|
||||
}
|
||||
|
||||
if (this.actionCancel?.isValid) {
|
||||
this.actionCancel.off(Node.EventType.TOUCH_END, this._onCancelClick, this);
|
||||
}
|
||||
}
|
||||
|
||||
private _onCloseClick(): void {
|
||||
const shouldContinue = this._callbacks.onClose?.();
|
||||
if (shouldContinue !== false && this._closeOnClose) {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
private _onConfirmClick(): void {
|
||||
const shouldContinue = this._callbacks.onConfirm?.();
|
||||
if (shouldContinue !== false && this._closeOnConfirm) {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
private _onCancelClick(): void {
|
||||
const shouldContinue = this._callbacks.onCancel?.();
|
||||
if (shouldContinue !== false && this._closeOnCancel) {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
private _applyActionMode(): void {
|
||||
if (this.buttonConfirm?.isValid) {
|
||||
this.buttonConfirm.active = !this._useDoubleActions;
|
||||
}
|
||||
|
||||
if (this.actionDouble?.isValid) {
|
||||
this.actionDouble.active = this._useDoubleActions;
|
||||
}
|
||||
}
|
||||
|
||||
private _shouldUseDoubleActions(params: CommonModalParams): boolean {
|
||||
return params.buttonCancel !== undefined;
|
||||
}
|
||||
|
||||
private _findNode(path: string): Node | null {
|
||||
const names = path.split('/').filter(Boolean);
|
||||
let current: Node | null = this.node;
|
||||
|
||||
for (const name of names) {
|
||||
current = current?.getChildByName(name) ?? null;
|
||||
if (!current) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 13.2 成就称号动画 (assets/scripts/utils/AchievementTitleAnimator.ts)
|
||||
|
||||
```typescript
|
||||
import { Label, Node, ProgressBar, tween, Tween } from 'cc';
|
||||
|
||||
/**
|
||||
* 称号进度展示数据
|
||||
* 字段对应 AchievementTitleManager.getTitleInfo 的产物,但所有字段都是可选的,
|
||||
* 调用方可以只更新需要的部分(例如分享模式只想清空文字)。
|
||||
*/
|
||||
export interface TitleProgressData {
|
||||
/** 当前称号文案(如「冷场小白1级」) */
|
||||
titleText?: string;
|
||||
/** 进度提示文案(如「还差3题,解锁新成就等级」) */
|
||||
progressText?: string;
|
||||
/** 当前称号到下一称号的进度,0-1 */
|
||||
nextTitleProgress?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 进度条 / 称号视图所需的节点引用集合
|
||||
*/
|
||||
export interface TitleAnimatorBindings {
|
||||
/** 称号文案 Label(如「冷场小白1级」),可空 */
|
||||
titleLabel?: Label | null;
|
||||
/** 进度提示 Label(如「还差3题,解锁新成就等级」),可空 */
|
||||
progressLabel?: Label | null;
|
||||
/** 进度条组件 */
|
||||
progressBar?: ProgressBar | null;
|
||||
/** 进度条上跟随移动的 anchor 节点(若其下有 Label 子节点会自动写百分比) */
|
||||
progressAnchor?: Node | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 进度条动画起始前的等待时长(秒)。让弹窗 / 弹起动画稳定后再开播
|
||||
*/
|
||||
const PROGRESS_ANIM_START_DELAY = 0.4;
|
||||
/** 单段进度条填充动画时长(秒) */
|
||||
const PROGRESS_ANIM_SEGMENT_DURATION = 0.6;
|
||||
/** 跨称号切换时的等级信息刷新停顿(秒),让玩家看清称号变更 */
|
||||
const PROGRESS_ANIM_LEVELUP_PAUSE = 0.12;
|
||||
|
||||
/**
|
||||
* 九宫格 Bar Left+Right border = 240px、totalLength = 925px。
|
||||
* width < 240px 时圆角会畸变,因此 progress > 0 时强制最小值。
|
||||
*/
|
||||
const MIN_PROGRESS_RATIO = 240 / 925;
|
||||
|
||||
/** anchor 起点的视觉微调(与 PageHome 等其他页面保持一致) */
|
||||
const PROGRESS_ANCHOR_VISUAL_OFFSET = -30;
|
||||
|
||||
/**
|
||||
* 把称号文字、进度条、跟随气泡这三件事打包成一个可复用的动画/展示工具。
|
||||
* 没有引擎组件依赖(不是 cc.Component),可以被任何持有节点引用的对象 new 一个出来用。
|
||||
*
|
||||
* 起源:原本只在 PassModal 内部实现。PassNode 替换 PassModal 后,
|
||||
* PageLevel 也需要同一套行为,所以抽出来共用。
|
||||
*/
|
||||
export class AchievementTitleAnimator {
|
||||
private _bindings: TitleAnimatorBindings = {};
|
||||
/** anchor 起点 X(在 progressBar 父节点空间下),首次解析后缓存 */
|
||||
private _progressAnchorStartX: number | null = null;
|
||||
/** Tween 共享的目标对象,方便 stopAllByTarget */
|
||||
private readonly _tweenTarget: { progress: number } = { progress: 0 };
|
||||
|
||||
/** 绑定 / 重新绑定节点引用。任何重新绑定都会清掉缓存的 anchor 起点 */
|
||||
bind(bindings: TitleAnimatorBindings): void {
|
||||
this._bindings = bindings;
|
||||
this._progressAnchorStartX = null;
|
||||
}
|
||||
|
||||
/** 直接展示终态,无动画 */
|
||||
setTarget(data: TitleProgressData): void {
|
||||
this.stop();
|
||||
this._applyTitleText(data.titleText);
|
||||
this._applyProgressText(data.progressText);
|
||||
this._applyProgressValue(data.nextTitleProgress);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 prev → current 播过渡动画
|
||||
* - prev 为空:直接展示 current 终态
|
||||
* - 同称号:单段 tween
|
||||
* - 跨称号:先填满旧称号,再切到新称号 + 进度从 0 涨到 current
|
||||
*/
|
||||
playTransition(prev: TitleProgressData | null | undefined, current: TitleProgressData): void {
|
||||
if (!prev) {
|
||||
this.setTarget(current);
|
||||
return;
|
||||
}
|
||||
|
||||
const startProgress = prev.nextTitleProgress;
|
||||
const endProgress = current.nextTitleProgress;
|
||||
if (startProgress === undefined || endProgress === undefined) {
|
||||
this.setTarget(current);
|
||||
return;
|
||||
}
|
||||
|
||||
const isSameTitle = prev.titleText === undefined
|
||||
|| current.titleText === undefined
|
||||
|| prev.titleText === current.titleText;
|
||||
|
||||
// 同称号且起止相同,没必要播动画
|
||||
if (isSameTitle && Math.abs(startProgress - endProgress) < 1e-4) {
|
||||
this.setTarget(current);
|
||||
return;
|
||||
}
|
||||
|
||||
this.stop();
|
||||
|
||||
if (isSameTitle) {
|
||||
// 先展示文字 + 起点进度,再 tween 到终点
|
||||
this._applyTitleText(current.titleText);
|
||||
this._applyProgressText(current.progressText);
|
||||
this._applyProgressValue(startProgress);
|
||||
this._runSegmentTween(startProgress, endProgress, PROGRESS_ANIM_START_DELAY);
|
||||
return;
|
||||
}
|
||||
|
||||
// 跨称号:先展示旧称号 + 起点进度
|
||||
this._applyTitleText(prev.titleText);
|
||||
this._applyProgressText(prev.progressText);
|
||||
this._applyProgressValue(startProgress);
|
||||
|
||||
const target = this._tweenTarget;
|
||||
target.progress = this._clamp(startProgress);
|
||||
const onUpdate = () => this._applyAnimatedProgress(target.progress);
|
||||
|
||||
tween(target)
|
||||
.delay(PROGRESS_ANIM_START_DELAY)
|
||||
.to(PROGRESS_ANIM_SEGMENT_DURATION, { progress: 1 }, { easing: 'sineOut', onUpdate })
|
||||
.call(() => {
|
||||
this._applyTitleText(current.titleText);
|
||||
this._applyProgressText(current.progressText);
|
||||
target.progress = 0;
|
||||
this._applyAnimatedProgress(0);
|
||||
})
|
||||
.delay(PROGRESS_ANIM_LEVELUP_PAUSE)
|
||||
.to(
|
||||
PROGRESS_ANIM_SEGMENT_DURATION,
|
||||
{ progress: this._clamp(endProgress) },
|
||||
{ easing: 'sineOut', onUpdate },
|
||||
)
|
||||
.start();
|
||||
}
|
||||
|
||||
/** 停止当前动画(不影响已展示的进度值) */
|
||||
stop(): void {
|
||||
Tween.stopAllByTarget(this._tweenTarget);
|
||||
}
|
||||
|
||||
private _runSegmentTween(from: number, to: number, delay: number): void {
|
||||
const target = this._tweenTarget;
|
||||
target.progress = this._clamp(from);
|
||||
this._applyAnimatedProgress(from);
|
||||
|
||||
const chain = tween(target);
|
||||
if (delay > 0) {
|
||||
chain.delay(delay);
|
||||
}
|
||||
|
||||
chain.to(
|
||||
PROGRESS_ANIM_SEGMENT_DURATION,
|
||||
{ progress: this._clamp(to) },
|
||||
{
|
||||
easing: 'sineOut',
|
||||
onUpdate: () => this._applyAnimatedProgress(target.progress),
|
||||
},
|
||||
).start();
|
||||
}
|
||||
|
||||
private _applyTitleText(text: string | undefined): void {
|
||||
if (text === undefined) return;
|
||||
const label = this._bindings.titleLabel;
|
||||
if (label?.isValid) {
|
||||
label.string = text;
|
||||
}
|
||||
}
|
||||
|
||||
private _applyProgressText(text: string | undefined): void {
|
||||
if (text === undefined) return;
|
||||
const label = this._bindings.progressLabel;
|
||||
if (label?.isValid) {
|
||||
label.string = text;
|
||||
}
|
||||
}
|
||||
|
||||
private _applyProgressValue(progress: number | undefined): void {
|
||||
if (progress === undefined) return;
|
||||
this._applyAnimatedProgress(progress);
|
||||
}
|
||||
|
||||
private _applyAnimatedProgress(progress: number): void {
|
||||
const clamped = this._clamp(progress);
|
||||
const bar = this._bindings.progressBar;
|
||||
if (bar?.isValid) {
|
||||
bar.progress = this._normalize(clamped);
|
||||
}
|
||||
this._updateProgressAnchor(clamped);
|
||||
}
|
||||
|
||||
private _updateProgressAnchor(progress: number): void {
|
||||
const anchor = this._bindings.progressAnchor;
|
||||
if (!anchor?.isValid) return;
|
||||
|
||||
this._cacheProgressAnchorStartX();
|
||||
|
||||
const startX = this._progressAnchorStartX ?? anchor.position.x;
|
||||
const travelWidth = this._getProgressAnchorTravelWidth();
|
||||
anchor.setPosition(
|
||||
startX + travelWidth * progress,
|
||||
anchor.position.y,
|
||||
anchor.position.z,
|
||||
);
|
||||
|
||||
const percentLabel = anchor.getChildByName('Label')?.getComponent(Label);
|
||||
if (percentLabel) {
|
||||
percentLabel.string = `${Math.round(progress * 100)}%`;
|
||||
}
|
||||
}
|
||||
|
||||
private _getProgressAnchorTravelWidth(): number {
|
||||
const bar = this._bindings.progressBar;
|
||||
if (!bar) return 0;
|
||||
return Math.abs(bar.totalLength * bar.node.scale.x);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bar 节点 anchor 为 (0, 0.5),其本地 position.x 即进度条可视左端。
|
||||
* ProgressBar 与 ProgressAnchor 共享同一父节点,因此把 Bar 的本地 X
|
||||
* 按 ProgressBar 自身的位移与缩放映射到父节点空间,才是真正的「0% 起点」。
|
||||
* 直接拿 anchor.position.x 当起点会被 prefab 摆放偏移量带跑。
|
||||
*/
|
||||
private _cacheProgressAnchorStartX(): void {
|
||||
if (this._progressAnchorStartX !== null) return;
|
||||
const bar = this._bindings.progressBar;
|
||||
const barSprite = bar?.barSprite;
|
||||
if (!bar || !barSprite) return;
|
||||
|
||||
const barLocalX = barSprite.node.position.x;
|
||||
this._progressAnchorStartX = bar.node.position.x
|
||||
+ barLocalX * bar.node.scale.x
|
||||
+ PROGRESS_ANCHOR_VISUAL_OFFSET;
|
||||
}
|
||||
|
||||
private _normalize(progress: number): number {
|
||||
if (!Number.isFinite(progress) || progress <= 0) return 0;
|
||||
return Math.max(MIN_PROGRESS_RATIO, Math.min(1, progress));
|
||||
}
|
||||
|
||||
private _clamp(progress: number): number {
|
||||
if (!Number.isFinite(progress) || progress <= 0) return 0;
|
||||
return Math.min(1, progress);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 13.3 成就称号管理器 (assets/scripts/utils/AchievementTitleManager.ts)
|
||||
|
||||
```typescript
|
||||
import { ACHIEVEMENT_TITLE_CONFIG, AchievementTitleConfigItem, INFINITE_ACHIEVEMENT_TITLE } from '../config/AchievementTitleConfig';
|
||||
|
||||
export interface AchievementTitleInfo {
|
||||
readonly titleText: string;
|
||||
readonly nextTitleText: string;
|
||||
readonly nextTitleProgress: number;
|
||||
readonly progressText: string;
|
||||
readonly completedLevelCount: number;
|
||||
readonly currentTitleStartCount: number;
|
||||
readonly nextTitleRequiredCount: number;
|
||||
readonly remainingToNextTitle: number;
|
||||
}
|
||||
|
||||
interface AchievementTitleStage {
|
||||
readonly titleText: string;
|
||||
readonly clearsToNext: number;
|
||||
readonly startCount: number;
|
||||
}
|
||||
|
||||
export class AchievementTitleManager {
|
||||
public static getTitleInfo(completedLevelCount: number): AchievementTitleInfo {
|
||||
const safeCompletedCount = AchievementTitleManager.normalizeCompletedCount(completedLevelCount);
|
||||
const stages = AchievementTitleManager.buildFiniteStages();
|
||||
const finiteResult = AchievementTitleManager.findFiniteStage(safeCompletedCount, stages);
|
||||
|
||||
if (finiteResult) {
|
||||
return AchievementTitleManager.createTitleInfo(safeCompletedCount, finiteResult.currentStage, finiteResult.nextStage);
|
||||
}
|
||||
|
||||
return AchievementTitleManager.createInfiniteTitleInfo(safeCompletedCount, stages[stages.length - 1]);
|
||||
}
|
||||
|
||||
private static normalizeCompletedCount(completedLevelCount: number): number {
|
||||
if (!Number.isFinite(completedLevelCount) || completedLevelCount < 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.floor(completedLevelCount);
|
||||
}
|
||||
|
||||
private static buildFiniteStages(): AchievementTitleStage[] {
|
||||
let startCount = 0;
|
||||
|
||||
return ACHIEVEMENT_TITLE_CONFIG.map((item: AchievementTitleConfigItem) => {
|
||||
const stage: AchievementTitleStage = {
|
||||
titleText: item.levelName,
|
||||
clearsToNext: item.clearsToNext,
|
||||
startCount
|
||||
};
|
||||
startCount += item.clearsToNext;
|
||||
return stage;
|
||||
});
|
||||
}
|
||||
|
||||
private static findFiniteStage(completedLevelCount: number, stages: AchievementTitleStage[]): { currentStage: AchievementTitleStage; nextStage: AchievementTitleStage } | null {
|
||||
for (let index = 0; index < stages.length; index++) {
|
||||
const currentStage = stages[index];
|
||||
const nextStage = stages[index + 1] ?? {
|
||||
titleText: `${INFINITE_ACHIEVEMENT_TITLE.levelNamePrefix}1级`,
|
||||
clearsToNext: INFINITE_ACHIEVEMENT_TITLE.clearsToNext,
|
||||
startCount: currentStage.startCount + currentStage.clearsToNext
|
||||
};
|
||||
|
||||
if (completedLevelCount < nextStage.startCount) {
|
||||
return { currentStage, nextStage };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static createInfiniteTitleInfo(completedLevelCount: number, lastFiniteStage: AchievementTitleStage): AchievementTitleInfo {
|
||||
const infiniteStartCount = lastFiniteStage.startCount + lastFiniteStage.clearsToNext;
|
||||
const completedAfterInfiniteStart = Math.max(0, completedLevelCount - infiniteStartCount);
|
||||
const currentInfiniteLevel = Math.floor(completedAfterInfiniteStart / INFINITE_ACHIEVEMENT_TITLE.clearsToNext) + 1;
|
||||
const currentStageStartCount = infiniteStartCount + (currentInfiniteLevel - 1) * INFINITE_ACHIEVEMENT_TITLE.clearsToNext;
|
||||
const nextStage: AchievementTitleStage = {
|
||||
titleText: `${INFINITE_ACHIEVEMENT_TITLE.levelNamePrefix}${currentInfiniteLevel + 1}级`,
|
||||
clearsToNext: INFINITE_ACHIEVEMENT_TITLE.clearsToNext,
|
||||
startCount: currentStageStartCount + INFINITE_ACHIEVEMENT_TITLE.clearsToNext
|
||||
};
|
||||
|
||||
return AchievementTitleManager.createTitleInfo(
|
||||
completedLevelCount,
|
||||
{
|
||||
titleText: `${INFINITE_ACHIEVEMENT_TITLE.levelNamePrefix}${currentInfiniteLevel}级`,
|
||||
clearsToNext: INFINITE_ACHIEVEMENT_TITLE.clearsToNext,
|
||||
startCount: currentStageStartCount
|
||||
},
|
||||
nextStage
|
||||
);
|
||||
}
|
||||
|
||||
private static createTitleInfo(completedLevelCount: number, currentStage: AchievementTitleStage, nextStage: AchievementTitleStage): AchievementTitleInfo {
|
||||
const interval = Math.max(1, nextStage.startCount - currentStage.startCount);
|
||||
const completedInCurrentTitle = Math.max(0, completedLevelCount - currentStage.startCount);
|
||||
const remainingToNextTitle = Math.max(0, nextStage.startCount - completedLevelCount);
|
||||
const nextTitleProgress = Math.max(0, Math.min(1, completedInCurrentTitle / interval));
|
||||
|
||||
return {
|
||||
titleText: currentStage.titleText,
|
||||
nextTitleText: nextStage.titleText,
|
||||
nextTitleProgress,
|
||||
progressText: `还差${remainingToNextTitle}题,解锁新成就等级`,
|
||||
completedLevelCount,
|
||||
currentTitleStartCount: currentStage.startCount,
|
||||
nextTitleRequiredCount: nextStage.startCount,
|
||||
remainingToNextTitle
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user