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); } } }