- MVP架构重构聊天逻辑

- 优化聊天气泡体验
- 修复prompt bug
- 实现聊天次数限制功能(未接服务端)
- 实现情绪切视频功能
This commit is contained in:
2025-08-27 16:12:19 +08:00
parent 9004f77e8d
commit e57818d498
27 changed files with 1713 additions and 687 deletions
@@ -5,6 +5,7 @@ import {
Node,
UITransform,
Vec3,
view,
} from "cc";
import { DialogManager } from "../../manager/DialogManager";
import { DialogBubble } from "./DialogBubble";
@@ -31,7 +32,7 @@ export class ChatContentsLayout extends Component {
protected start(): void {
this.lBubble.node.active = false;
this.rBubble.node.active = false;
this.fixMaxWidth = this.node.getComponent(UITransform).contentSize.y * 0.6;
this.fixMaxWidth = view.getVisibleSize().width * 0.8;
}
protected onEnable(): void {
@@ -92,7 +93,7 @@ export class ChatContentsLayout extends Component {
? instantiate(this.rBubble.node)
: instantiate(this.lBubble.node);
newBubble = newBubbleNode.getComponent(DialogBubble);
newBubble.init(this.fixMaxWidth);
newBubble.init();
newBubbleNode.setParent(this.node);
}
@@ -109,7 +110,7 @@ export class ChatContentsLayout extends Component {
if (pendingUpdates === 0) {
updateNextBubble(index - 1);
}
});
}, dialog.isLoading || false);
this.bubbles.push(newBubble);
};
@@ -6,6 +6,7 @@ import {
Overflow,
Size,
UITransform,
view,
} from "cc";
import Tools from "../../utils/tools";
const { ccclass, property } = _decorator;
@@ -20,12 +21,36 @@ export class DialogBubble extends Component {
contentT: UITransform = null;
fixMaxWidth: number;
init(maxWidth: number = 650) {
this.fixMaxWidth = maxWidth;
private loadingAnimationId: number = null;
init(maxWidth?: number) {
// 如果没有提供maxWidth,使用屏幕宽度的80%
if (maxWidth === undefined) {
this.fixMaxWidth = view.getVisibleSize().width * 0.8;
} else {
this.fixMaxWidth = maxWidth;
}
}
updateBubbleContent(str: string, callback?: (actualHeight: number) => void) {
updateBubbleContent(str: string, callback?: (actualHeight: number) => void, isLoading: boolean = false) {
// 停止之前的加载动画
this.stopLoadingAnimation();
this.content.overflow = Overflow.NONE;
// 如果是加载状态,使用固定的"..."
if (isLoading && str === "...") {
this.content.string = "...";
// 设置固定尺寸用于加载显示
const contentWidth = 60;
this.contentT.setContentSize(new Size(contentWidth, 40));
this.bg.setContentSize(new Size(contentWidth + 30, 50));
if (callback) {
callback(40); // 返回固定高度
}
return;
}
// 设置文本内容
this.content.string = str;
@@ -77,4 +102,8 @@ export class DialogBubble extends Component {
const estimatedHeight = Math.max(lines.length * 35 + 20, 60); // 最小高度60
return estimatedHeight;
}
private stopLoadingAnimation() {
// 预留方法,当前实现中不需要动画,但保留接口以备将来使用
}
}
+190 -101
View File
@@ -24,12 +24,16 @@ import ResManager from "db://assets/Scripts/Main/Manager/ResManager";
import LanguageUtils from "../../../Main/Common/LanguageUtils";
import { UITransitionHelper } from "../../utils/UITransitionHelper";
import { VideoEmotion, purchase } from "../../../schema/schema";
import { PayToTalkSubpanel } from "./PayToTalkSubpanel";
const { ccclass, property } = _decorator;
@ccclass("ChatPanel")
export class ChatPanel extends li_BaseView implements IChatPanelCallback {
manager: DialogManager = null;
private chatController: ChatController = new ChatController();
private chatController: ChatController = null;
@property(PayToTalkSubpanel)
payToTalkPanel: PayToTalkSubpanel = null;
@property(EditBox)
editBox: EditBox = null;
@@ -62,25 +66,44 @@ export class ChatPanel extends li_BaseView implements IChatPanelCallback {
};
openUIDataCT(data) {
// 处理新的数据格式,支持过渡动画参数
if (typeof data === "object" && data.roleId !== undefined) {
this.id = data.roleId;
const newRoleId =
typeof data === "object" && data.roleId !== undefined
? data.roleId
: data;
const withAnimation = typeof data === "object" && data.withSlideTransition;
// 如果标记了需要滑入动画,则执行动画
if (data.withSlideTransition && this.node && this.node.isValid) {
// 延迟一帧执行动画,确保节点已正确加载到场景中
this.scheduleOnce(() => {
UITransitionHelper.slideInFromRight(this.node, 0.3);
}, 0);
}
} else {
// 兼容原来的数字格式
this.id = data;
// 检查是否是切换角色
const isRoleSwitch = this.id && this.id !== newRoleId;
this.id = newRoleId;
// 如果标记了需要滑入动画,则执行动画
if (withAnimation && this.node && this.node.isValid) {
// 延迟一帧执行动画,确保节点已正确加载到场景中
this.scheduleOnce(() => {
UITransitionHelper.slideInFromRight(this.node, 0.3);
}, 0);
}
// 初始化ChatController
// 获取ChatController单例并绑定当前Panel
this.chatController = ChatController.Instance;
this.chatController.bindView(this);
// 初始化或切换ChatController
if (this.id && this.id > 0) {
this.chatController.initialize(this.id, this);
if (isRoleSwitch && this.chatController.hasRoleData(this.id)) {
// 如果是角色切换且有缓存数据,使用switchRole
console.log(
`ChatPanel: Switching from role ${this.chatController.getCurrentRoleId()} to role ${
this.id
}`
);
this.chatController.switchRole(this.id);
} else {
// 首次初始化或没有缓存数据,使用initialize
console.log(`ChatPanel: Initializing role ${this.id}`);
this.chatController.initialize(this.id);
}
}
}
@@ -89,7 +112,7 @@ export class ChatPanel extends li_BaseView implements IChatPanelCallback {
this.register();
this.refresh(this.id);
this.payToTalkPanel.node.active = false;
// Add video loaded event callback
if (this.girlVideo) {
this.girlVideo.node.on(
@@ -147,9 +170,10 @@ export class ChatPanel extends li_BaseView implements IChatPanelCallback {
);
}
// 销毁ChatController
// 解绑ChatController(不销毁,因为它是单例)
if (this.chatController) {
this.chatController.destroy();
this.chatController.unbindView();
this.chatController = null;
}
}
refresh(id: number) {
@@ -178,59 +202,53 @@ export class ChatPanel extends li_BaseView implements IChatPanelCallback {
}
);
// Store commercialVideos for emotion-based switching
if (dataDetail && dataDetail.commercialVideos) {
this.commercialVideos = dataDetail.commercialVideos;
console.log(`Loaded ${this.commercialVideos.length} videos for role ${this.id}`);
// 通过ChatController获取商业视频数据
const chatModel = this.chatController.getChatModel();
const commercialVideos = chatModel.getCommercialVideos();
this.commercialVideos = commercialVideos;
if (commercialVideos.length > 0) {
console.log(
`Loaded ${commercialVideos.length} videos for role ${this.id}`
);
// Load initial video based on current emotion
if (this.commercialVideos.length > 0 && this.girlVideo) {
if (this.girlVideo) {
// Get current emotion from ChatController
let currentEmotion = VideoEmotion.calm_down; // Default fallback
try {
if (this.chatController) {
currentEmotion = this.chatController.getCurrentEmotion();
console.log(`Current emotion for role ${this.id}: ${VideoEmotion[currentEmotion]}`);
}
} catch (error) {
console.warn("Failed to get current emotion during initialization, using calm_down as default:", error);
}
// Find video matching current emotion or fallback
const initialVideo = this.commercialVideos.find(video => video.emotion === currentEmotion)
|| this.commercialVideos.find(video => video.emotion === VideoEmotion.calm_down)
|| this.commercialVideos[0]; // Final fallback to first video
console.log(`Loading initial video: ${initialVideo.path} (emotion: ${VideoEmotion[initialVideo.emotion]})`);
// Set current emotion to the loaded video's emotion
this.currentEmotion = initialVideo.emotion;
ResManager.I.changeBundleVideo(
this.girlVideo,
initialVideo.path,
"Chat18x"
const currentEmotion = this.chatController.getCurrentEmotion();
console.log(
`Current emotion for role ${this.id}: ${VideoEmotion[currentEmotion]}`
);
// Also immediately try to adjust scale (in case already loaded)
this.adjustVideoScale();
// Get appropriate video from ChatModel
const initialVideo =
chatModel.getVideoByEmotion(currentEmotion) ||
chatModel.getDefaultVideo();
if (initialVideo) {
console.log(
`Loading initial video: ${initialVideo.path} (emotion: ${
VideoEmotion[initialVideo.emotion]
})`
);
// Set current emotion to the loaded video's emotion
this.currentEmotion = initialVideo.emotion;
ResManager.I.changeBundleVideo(
this.girlVideo,
initialVideo.path,
"Chat18x"
);
// Also immediately try to adjust scale (in case already loaded)
this.adjustVideoScale();
}
}
} else {
this.commercialVideos = [];
this.currentEmotion = null; // Reset current emotion when no videos available
console.log("No commercialVideos data available for role", this.id);
}
// if (dataDetail && dataDetail.commercialVideos && dataDetail.commercialVideos.length > 0) {
// let uiTransform: UITransform =
// this._nodeTab.BgFrame.getComponent(UITransform);
// Utils.sendInnerMsg(InnerMsgCode.SimplePlayVide, {
// path: dataDetail.commercialVideos[0].path,
// size: uiTransform.contentSize,
// bgFrameNode: this._nodeTab.BgFrame,
// });
// }
}
adjustVideoScale() {
@@ -278,68 +296,101 @@ export class ChatPanel extends li_BaseView implements IChatPanelCallback {
}
private switchVideoByEmotion(emotion: VideoEmotion): void {
if (!this.girlVideo || !this.commercialVideos || this.commercialVideos.length === 0) {
console.warn("Cannot switch video: missing video player or commercialVideos data");
if (!this.girlVideo) {
console.warn("Cannot switch video: missing video player");
return;
}
// Get video data from ChatModel through ChatController
const chatModel = this.chatController.getChatModel();
const commercialVideos = chatModel.getCommercialVideos();
if (commercialVideos.length === 0) {
console.warn("Cannot switch video: no commercialVideos data");
return;
}
// Check if emotion has changed
if (this.currentEmotion === emotion) {
console.log(`Emotion ${VideoEmotion[emotion]} unchanged, skipping video switch`);
console.log(
`Emotion ${VideoEmotion[emotion]} unchanged, skipping video switch`
);
return;
}
// Find video matching the emotion
const matchingVideo = this.commercialVideos.find(video => video.emotion === emotion);
// Get video matching the emotion from ChatModel
const matchingVideo = chatModel.getVideoByEmotion(emotion);
if (matchingVideo) {
console.log(`Switching to video for emotion ${VideoEmotion[emotion]}: ${matchingVideo.path} (from ${this.currentEmotion !== null ? VideoEmotion[this.currentEmotion] : 'null'})`);
console.log(
`Switching to video for emotion ${VideoEmotion[emotion]}: ${
matchingVideo.path
} (from ${
this.currentEmotion !== null
? VideoEmotion[this.currentEmotion]
: "null"
})`
);
// Load the new video
ResManager.I.changeBundleVideo(
this.girlVideo,
matchingVideo.path,
"Chat18x"
);
// Update current emotion state
this.currentEmotion = emotion;
// Adjust scale and enable playback
this.adjustVideoScale();
this.setVideoEnable(true);
} else {
console.warn(`No video found for emotion ${VideoEmotion[emotion]}, falling back to first available video`);
// Fallback to first video if no match found
if (this.commercialVideos.length > 0) {
const fallbackVideo = this.commercialVideos[0];
console.log(`Loading fallback video: ${fallbackVideo.path} (emotion: ${VideoEmotion[fallbackVideo.emotion]})`);
console.warn(
`No video found for emotion ${VideoEmotion[emotion]}, falling back to default video`
);
// Fallback to default video from ChatModel
const fallbackVideo = chatModel.getDefaultVideo();
if (fallbackVideo) {
console.log(
`Loading fallback video: ${fallbackVideo.path} (emotion: ${
VideoEmotion[fallbackVideo.emotion]
})`
);
ResManager.I.changeBundleVideo(
this.girlVideo,
fallbackVideo.path,
"Chat18x"
);
// Update current emotion state to the fallback video's emotion
this.currentEmotion = fallbackVideo.emotion;
this.adjustVideoScale();
this.setVideoEnable(true);
}
}
}
private onEmotionInitialized(data: {roleId: number, emotion: VideoEmotion}): void {
private onEmotionInitialized(data: {
roleId: number;
emotion: VideoEmotion;
}): void {
// 检查是否是当前角色
if (data.roleId === this.id) {
console.log(`Emotion initialized for current role ${this.id}: ${VideoEmotion[data.emotion]}`);
console.log(
`Emotion initialized for current role ${this.id}: ${
VideoEmotion[data.emotion]
}`
);
// 切换到对应的视频
this.switchVideoByEmotion(data.emotion);
} else {
console.log(`Emotion initialized for role ${data.roleId} (not current role ${this.id}), ignoring`);
console.log(
`Emotion initialized for role ${data.roleId} (not current role ${this.id}), ignoring`
);
}
}
@@ -358,14 +409,11 @@ export class ChatPanel extends li_BaseView implements IChatPanelCallback {
const str = this.editBox.string;
if (!str || str == "") return;
// 清空输入框
this.editBox.string = "";
// 通过ChatController发送消息
await this.chatController.sendMessage(str);
//测试
//this.popUpImage.refresh("Image/1/blur_naked_1");
const succeed = await this.chatController.sendMessage(str);
if (succeed)
// 清空输入框
this.editBox.string = "";
}
// === IChatPanelCallback 接口实现 ===
@@ -376,7 +424,7 @@ export class ChatPanel extends li_BaseView implements IChatPanelCallback {
*/
public onMessageSent(message: string): void {
console.log("Message sent:", message);
// 可以在这里添加发送中的UI状态显示,比如显示loading等
// 消息发送后开始显示加载动画,由DialogManager处理
}
/**
@@ -385,7 +433,7 @@ export class ChatPanel extends li_BaseView implements IChatPanelCallback {
*/
public onMessageReceived(response: string): void {
console.log("Message received:", response);
// 可以在这里添加接收到回复的UI效果,比如播放声音等
// AI回复收到后加载动画会被自动移除,可以在这里添加其他UI效果
}
/**
@@ -401,20 +449,25 @@ export class ChatPanel extends li_BaseView implements IChatPanelCallback {
* @param error 错误信息
*/
public onError(error: Error): void {
console.error("ChatPanel error:", error);
// 可以在这里显示错误提示给用户
console.error("Chat error:", error);
// 出错时加载动画会被自动移除,可以在这里显示错误提示给用户
}
onChatLimitReached(): void {
this.payToTalkPanel.show();
}
/**
* 情绪状态更新回调 (实现IChatPanelCallback接口)
* @param emotion 当前情绪状态
*/
public onEmotionUpdated(emotion: VideoEmotion): void {
console.log(`ChatPanel: Emotion updated to ${VideoEmotion[emotion]} (${emotion})`);
console.log(
`ChatPanel: Emotion updated to ${VideoEmotion[emotion]} (${emotion})`
);
// Switch video based on the new emotion
this.switchVideoByEmotion(emotion);
// Additional UI updates can be added here based on emotion
// 例如:改变角色表情、背景色、播放相应的动画等
switch (emotion) {
@@ -450,4 +503,40 @@ export class ChatPanel extends li_BaseView implements IChatPanelCallback {
onClickRecord() {
ViewManager.I.openBundlesView("RecordPanel", this.id);
}
/**
* 获取当前聊天状态信息(用于调试)
*/
public getChatStatus(): any {
if (!this.chatController) {
return { error: "ChatController not initialized" };
}
return {
currentPanelRoleId: this.id,
controllerStats: this.chatController.getDialogStats(),
modelSummary: this.chatController.getModelSummary(),
allRolesStats: this.chatController.getAllRolesStats(),
};
}
/**
* 手动清除指定角色的数据(调试用)
* @param roleId 角色ID,不传则清除当前角色
*/
public clearRoleDataDebug(roleId?: number): void {
if (!this.chatController) {
console.warn("ChatController not initialized");
return;
}
const targetRoleId = roleId || this.id;
if (!targetRoleId) {
console.warn("No role ID specified");
return;
}
this.chatController.clearRoleData(targetRoleId);
console.log(`Cleared data for role ${targetRoleId}`);
}
}
@@ -1,4 +1,13 @@
import { _decorator, Component, Label, Node } from "cc";
import {
_decorator,
Component,
Label,
Node,
Sprite,
tween,
UIOpacity,
} from "cc";
import { ChatController } from "../../core/ChatController";
const { ccclass, property } = _decorator;
@ccclass("PayToTalkSubpanel")
@@ -14,15 +23,38 @@ export class PayToTalkSubpanel extends Component {
@property(Label)
vipBtnLabel: Label = null;
base: UIOpacity;
protected onLoad(): void {
this.base = this.getComponent(UIOpacity);
}
show() {}
show() {
this.node.active = true;
this.base.opacity = 0;
tween(this.base).to(0.3, { opacity: 255 }).start();
}
hide() {
tween(this.base)
.to(
0.3,
{ opacity: 0 },
{
onComplete: () => {
this.base.node.active = false;
},
}
)
.start();
}
refresh() {}
onClick_BuyTime() {
//购买次数
console.log("购买聊天次数,未实现功能");
ChatController.Instance.resetChatCount();
this.hide();
}
onClick_BuyVip() {