398 lines
11 KiB
TypeScript
398 lines
11 KiB
TypeScript
import {
|
|
_decorator,
|
|
Component,
|
|
instantiate,
|
|
Node,
|
|
NodeEventType,
|
|
ScrollView,
|
|
Tween,
|
|
UIOpacity,
|
|
UITransform,
|
|
Vec3,
|
|
view,
|
|
} from "cc";
|
|
import { DialogManager } from "../../manager/DialogManager";
|
|
import { DialogBubble } from "./DialogBubble";
|
|
import { Dialog } from "../../data/DialogData";
|
|
const { ccclass, property } = _decorator;
|
|
|
|
@ccclass("ChatContentsLayout")
|
|
export class ChatContentsLayout extends Component {
|
|
manager: DialogManager = null;
|
|
|
|
@property(DialogBubble)
|
|
lBubble: DialogBubble = null;
|
|
@property(DialogBubble)
|
|
rBubble: DialogBubble = null;
|
|
|
|
// 当前显示的气泡
|
|
private bubbles: DialogBubble[] = [];
|
|
// 缓存池
|
|
private cachedBubbles: DialogBubble[] = [];
|
|
// 等待回复气泡的引用
|
|
//private waitingBubble: DialogBubble = null;
|
|
// 延时任务ID数组
|
|
private delayedTasks: number[] = [];
|
|
// ScrollView组件引用
|
|
@property(ScrollView)
|
|
private scrollView: ScrollView = null;
|
|
|
|
// 内容透明度组件
|
|
private contentOpacity: UIOpacity = null;
|
|
// 是否正在按下
|
|
private isPressing: boolean = false;
|
|
// 长按计时器
|
|
private longPressTimer: number = null;
|
|
// 已显示的AI对话数量(用于避免重复显示)
|
|
private displayedAIDialogCount: number = 0;
|
|
|
|
fixMaxWidth: number;
|
|
|
|
protected start(): void {
|
|
this.lBubble.node.active = false;
|
|
this.rBubble.node.active = false;
|
|
// 设置气泡最大宽度为屏幕宽度的70%
|
|
this.fixMaxWidth = view.getVisibleSize().width * 0.7;
|
|
|
|
// 获取或添加UIOpacity组件到content节点
|
|
this.contentOpacity =
|
|
this.node.getComponent(UIOpacity) || this.node.addComponent(UIOpacity);
|
|
|
|
// 注册触摸事件
|
|
if (this.scrollView && this.scrollView.node) {
|
|
this.scrollView.node.on(
|
|
NodeEventType.TOUCH_START,
|
|
this.onTouchStart,
|
|
this
|
|
);
|
|
this.scrollView.node.on(NodeEventType.TOUCH_END, this.onTouchEnd, this);
|
|
this.scrollView.node.on(
|
|
NodeEventType.TOUCH_CANCEL,
|
|
this.onTouchEnd,
|
|
this
|
|
);
|
|
}
|
|
}
|
|
|
|
protected onEnable(): void {
|
|
if (!this.manager) this.manager = DialogManager.getInstance();
|
|
this.manager.layoutout = this;
|
|
}
|
|
|
|
/**
|
|
* 更新玩家输入的对话(追加模式,不清空历史)
|
|
* @param dialog 玩家输入的对话数据
|
|
*/
|
|
updatePlayerDialog(dialog: Dialog): void {
|
|
if (!dialog) return;
|
|
|
|
// 不再清空气泡,改为追加模式以支持历史记录
|
|
|
|
// 创建玩家气泡
|
|
const playerBubble = this.createOrGetBubble(true);
|
|
playerBubble.node.active = true;
|
|
playerBubble.updateBubbleContent(dialog.content, true);
|
|
this.bubbles.push(playerBubble);
|
|
|
|
// 滚动到底部
|
|
this.scheduleOnce(() => {
|
|
this.scrollView.scrollToBottom();
|
|
}, 0);
|
|
}
|
|
/**
|
|
* 更新AI回复的对话(智能追加,避免重复显示)
|
|
* @param dialogs AI回复的对话数据数组
|
|
*/
|
|
updateAIDialogs(dialogs: Dialog[]): void {
|
|
if (!dialogs || dialogs.length === 0) return;
|
|
|
|
// 清理之前的延时任务
|
|
this.clearDelayedTasks();
|
|
|
|
// 只添加新的AI对话(避免重复显示历史记录)
|
|
const newDialogs = dialogs.slice(this.displayedAIDialogCount);
|
|
if (newDialogs.length > 0) {
|
|
// 更新已显示数量
|
|
this.displayedAIDialogCount = dialogs.length;
|
|
|
|
// 逐个添加新的AI对话,带延时
|
|
this.addAIDialogsWithDelay(newDialogs, 0);
|
|
}
|
|
|
|
this.node.position = Vec3.ZERO;
|
|
// 滚动到底部(等待下一帧Layout更新完成)
|
|
this.scheduleOnce(() => {
|
|
this.scrollView.scrollToBottom();
|
|
}, 0);
|
|
}
|
|
|
|
/**
|
|
* 清理所有气泡到缓存池(改为public,供外部调用)
|
|
*/
|
|
public clearAllBubbles(): void {
|
|
for (const bubble of this.bubbles) {
|
|
if (bubble && bubble.node) {
|
|
bubble.node.active = false;
|
|
this.cachedBubbles.push(bubble);
|
|
}
|
|
}
|
|
this.bubbles = [];
|
|
//this.waitingBubble = null;
|
|
|
|
// 重置AI对话计数器
|
|
this.displayedAIDialogCount = 0;
|
|
|
|
// 清理多余的缓存气泡
|
|
this.cleanupExcessCachedBubbles();
|
|
}
|
|
|
|
/**
|
|
* 加载所有历史对话(用于初始化)
|
|
* @param dialogs 所有对话记录
|
|
*/
|
|
public loadAllDialogs(dialogs: Dialog[]): void {
|
|
if (!dialogs || dialogs.length === 0) return;
|
|
|
|
// 清空现有气泡
|
|
this.clearAllBubbles();
|
|
this.clearDelayedTasks();
|
|
|
|
// 倒序遍历对话,从最后一个开始插入(保持正确的显示顺序)
|
|
for (let i = dialogs.length - 1; i >= 0; i--) {
|
|
const dialog = dialogs[i];
|
|
const bubble = this.createOrGetBubble(dialog.isPlayer);
|
|
bubble.node.active = true;
|
|
bubble.updateBubbleContent(dialog.content, dialog.isPlayer);
|
|
// 每个气泡插入到首位,确保旧消息在上面,新消息在下面
|
|
bubble.node.setSiblingIndex(0);
|
|
this.bubbles.push(bubble);
|
|
|
|
// 统计AI对话数量
|
|
if (!dialog.isPlayer) {
|
|
this.displayedAIDialogCount++;
|
|
}
|
|
}
|
|
|
|
// 滚动到底部
|
|
this.scheduleOnce(() => {
|
|
this.scrollView.scrollToBottom();
|
|
}, 0.1);
|
|
|
|
console.log(`ChatContentsLayout: Loaded ${dialogs.length} history dialogs`);
|
|
}
|
|
|
|
/**
|
|
* 从缓存池获取或创建新的气泡
|
|
* @param isPlayer 是否为玩家气泡
|
|
* @returns DialogBubble实例
|
|
*/
|
|
private createOrGetBubble(isPlayer: boolean): DialogBubble {
|
|
// 尝试从缓存中获取合适的气泡
|
|
for (let i = 0; i < this.cachedBubbles.length; i++) {
|
|
const cached = this.cachedBubbles[i];
|
|
if (cached && cached.node) {
|
|
// 通过原始模板判断气泡类型
|
|
const isRightBubble = cached.node.position.x > 0;
|
|
if ((isPlayer && isRightBubble) || (!isPlayer && !isRightBubble)) {
|
|
this.cachedBubbles.splice(i, 1);
|
|
return cached;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 缓存中没有合适的气泡,创建新的
|
|
const templateBubble = isPlayer ? this.rBubble : this.lBubble;
|
|
const newBubbleNode = instantiate(templateBubble.node);
|
|
const newBubble = newBubbleNode.getComponent(DialogBubble);
|
|
newBubble.init(this.fixMaxWidth);
|
|
|
|
// 设置为子节点,具体位置由调用者决定
|
|
newBubbleNode.setParent(this.node);
|
|
|
|
return newBubble;
|
|
}
|
|
|
|
/**
|
|
* 移除等待回复气泡
|
|
*/
|
|
// private removeWaitingBubble(): void {
|
|
// if (this.waitingBubble && this.waitingBubble.node) {
|
|
// this.waitingBubble.node.active = false;
|
|
// this.cachedBubbles.push(this.waitingBubble);
|
|
|
|
// // 从当前气泡列表中移除
|
|
// const index = this.bubbles.indexOf(this.waitingBubble);
|
|
// if (index > -1) {
|
|
// this.bubbles.splice(index, 1);
|
|
// }
|
|
// }
|
|
// this.waitingBubble = null;
|
|
// }
|
|
|
|
/**
|
|
* 带延时地添加AI对话
|
|
* @param dialogs AI对话数组
|
|
* @param index 当前处理的对话索引
|
|
*/
|
|
private addAIDialogsWithDelay(dialogs: Dialog[], index: number): void {
|
|
if (index >= dialogs.length) {
|
|
return;
|
|
}
|
|
|
|
const dialog = dialogs[index];
|
|
const delay = index > 0 ? Math.random() * 1000 + 500 : 0; // 第一条立即显示,后续延时500-1500ms
|
|
|
|
const addCurrentDialog = () => {
|
|
const aiBubble = this.createOrGetBubble(false);
|
|
aiBubble.node.active = true;
|
|
aiBubble.updateBubbleContent(dialog.content, false);
|
|
// AI消息自然追加到末尾,显示在最下面(与玩家消息保持一致)
|
|
this.bubbles.push(aiBubble);
|
|
|
|
this.scheduleOnce(() => {
|
|
this.scrollView.scrollToBottom();
|
|
}, 0);
|
|
|
|
// 递归处理下一条对话
|
|
this.addAIDialogsWithDelay(dialogs, index + 1);
|
|
};
|
|
|
|
if (delay > 0) {
|
|
const taskId = setTimeout(() => {
|
|
// 从任务列表中移除
|
|
const taskIndex = this.delayedTasks.indexOf(taskId);
|
|
if (taskIndex > -1) {
|
|
this.delayedTasks.splice(taskIndex, 1);
|
|
}
|
|
addCurrentDialog();
|
|
}, delay);
|
|
|
|
this.delayedTasks.push(taskId);
|
|
} else {
|
|
addCurrentDialog();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 清理多余的缓存气泡
|
|
*/
|
|
private cleanupExcessCachedBubbles(): void {
|
|
const maxCachedBubbles = 20;
|
|
if (this.cachedBubbles.length > maxCachedBubbles) {
|
|
const excessCount = this.cachedBubbles.length - maxCachedBubbles;
|
|
for (let i = 0; i < excessCount; i++) {
|
|
const bubble = this.cachedBubbles.shift();
|
|
if (bubble && bubble.node) {
|
|
bubble.node.destroy();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 清理所有延时任务
|
|
*/
|
|
private clearDelayedTasks(): void {
|
|
for (const taskId of this.delayedTasks) {
|
|
clearTimeout(taskId);
|
|
}
|
|
this.delayedTasks = [];
|
|
}
|
|
|
|
/**
|
|
* 保持向后兼容性的方法
|
|
* @param dialogs 对话数组
|
|
* @deprecated 请使用 updatePlayerDialog 和 updateAIDialogs 替代
|
|
*/
|
|
UpdateDialog(dialogs: Dialog[]): void {
|
|
// 为了保持兼容性,我们假设这是一个完整的对话更新
|
|
// 清理所有现有气泡
|
|
this.clearAllBubbles();
|
|
this.clearDelayedTasks();
|
|
|
|
// 逐个添加对话,从最后一个开始倒序插入(保持显示顺序)
|
|
for (let i = dialogs.length - 1; i >= 0; i--) {
|
|
const dialog = dialogs[i];
|
|
const bubble = this.createOrGetBubble(dialog.isPlayer);
|
|
bubble.node.active = true;
|
|
bubble.updateBubbleContent(dialog.content, dialog.isPlayer, undefined);
|
|
bubble.node.setSiblingIndex(0);
|
|
this.bubbles.push(bubble);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 触摸开始事件
|
|
*/
|
|
private onTouchStart(): void {
|
|
// 清除之前的计时器
|
|
if (this.longPressTimer) {
|
|
clearTimeout(this.longPressTimer);
|
|
}
|
|
|
|
// 设置长按计时器(300ms后触发)
|
|
this.longPressTimer = setTimeout(() => {
|
|
this.isPressing = true;
|
|
// 使用Tween动画平滑调整透明度
|
|
Tween.stopAllByTarget(this.contentOpacity);
|
|
new Tween(this.contentOpacity)
|
|
.to(0.2, { opacity: 50 }) // 调整到约20%透明度
|
|
.start();
|
|
}, 300);
|
|
}
|
|
|
|
/**
|
|
* 触摸结束事件
|
|
*/
|
|
private onTouchEnd(): void {
|
|
// 清除计时器
|
|
if (this.longPressTimer) {
|
|
clearTimeout(this.longPressTimer);
|
|
this.longPressTimer = null;
|
|
}
|
|
|
|
// 如果已经按下,恢复透明度
|
|
if (this.isPressing) {
|
|
this.isPressing = false;
|
|
Tween.stopAllByTarget(this.contentOpacity);
|
|
new Tween(this.contentOpacity)
|
|
.to(0.2, { opacity: 255 }) // 恢复到100%透明度
|
|
.start();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 组件销毁时清理资源
|
|
*/
|
|
protected onDestroy(): void {
|
|
// 清理事件监听
|
|
if (this.scrollView && this.scrollView.node) {
|
|
this.scrollView.node.off(
|
|
NodeEventType.TOUCH_START,
|
|
this.onTouchStart,
|
|
this
|
|
);
|
|
this.scrollView.node.off(NodeEventType.TOUCH_END, this.onTouchEnd, this);
|
|
this.scrollView.node.off(
|
|
NodeEventType.TOUCH_CANCEL,
|
|
this.onTouchEnd,
|
|
this
|
|
);
|
|
}
|
|
|
|
// 清理计时器
|
|
if (this.longPressTimer) {
|
|
clearTimeout(this.longPressTimer);
|
|
this.longPressTimer = null;
|
|
}
|
|
|
|
// 清理延时任务
|
|
this.clearDelayedTasks();
|
|
|
|
// 停止所有动画
|
|
if (this.contentOpacity) {
|
|
Tween.stopAllByTarget(this.contentOpacity);
|
|
}
|
|
}
|
|
}
|