使用MVC模式开发cocos游戏功能
1.MVC 模式说明
MVC(Model - View - Controller)是一种软件设计模式,它将应用程序分为三个主要部分:模型(Model)、视图(View)和控制器(Controller),其核心思想是将数据处理、用户界面展示和业务逻辑分离,从而提高代码的可维护性、可扩展性和可测试性。在 Cocos Creator 游戏开发中,MVC 模式同样适用,以下是各部分的具体解释:
- 模型(Model):负责存储和管理应用程序的数据和业务逻辑,它不依赖于视图和控制器,是独立的数据处理单元。例如,在游戏中,模型可以是玩家的角色信息、游戏关卡数据等。
- 视图(View):负责将模型中的数据以可视化的方式呈现给用户,它只关注界面的显示和用户交互,不涉及业务逻辑。在 Cocos Creator 中,视图通常是由场景、节点和组件组成的游戏界面。
- 控制器(Controller):作为模型和视图之间的桥梁,负责接收用户的输入,根据输入更新模型,并根据模型的变化更新视图。它处理业务逻辑,协调模型和视图之间的交互。
2.简单的demo
我们以游戏最简单的登录流程来实现这个模式,效果图如下:
2.1.视图组件
cocos创建一个登录窗口预制体。cocos客户目录下assets\resources\prefabs新建一个预制体,比较简单,如下:
新建一个ts文件,命名为:LoginPaneView。代码如下:
import { _decorator, Button, EditBox } from 'cc';
import { BaseUiView } from '../../ui/BaseUiView';
const { ccclass, property } = _decorator;
@ccclass('LoginPaneView')
export class LoginPaneView extends BaseUiView {
@property(EditBox)
usernameInput: EditBox = null!;
@property(EditBox)
passwordInput: EditBox = null!;
@property(Button)
loginButton: Button = null!;
@property(Button)
logoutButton: Button = null!;
start() {
// 注册登录按钮点击事件
this.passwordInput.inputFlag = EditBox.InputFlag.PASSWORD;
this.registerClickEvent(
this.loginButton.node,
() => {
this.node.emit('accountLogin');
},
this
);
}
public getUserId(): string {
return this.usernameInput.string;
}
public getUserPwd(): string {
return this.passwordInput.string;
}
}
界面绑定
在cocos刚才的预制体文件里,选择ui子节点,在属性检查器里新建一个脚本组件,完成控件绑定
2.2.模型组件
登录的模型比较简单,模型一般采用单例模式,这里,把网络请求的代码也放到模型了。
import GameContext from '../../GameContext';
import ReqLogin from '../../net/protocol/ReqLogin';
import RespLogin from '../../net/protocol/RespLogin';
export class LoginModel {
private static _instance: LoginModel;
private userId: string = '';
private userPwd: string = '';
public static get instance(): LoginModel {
if (!LoginModel._instance) {
LoginModel._instance = new LoginModel();
}
return LoginModel._instance;
}
public setUserId(userId: string) {
this.userId = userId;
}
public setUserPwd(userPwd: string) {
this.userPwd = userPwd;
}
public getUserId(): string {
return this.userId;
}
public getUserPwd(): string {
return this.userPwd;
}
public login(): Promise<RespLogin> {
return new Promise<RespLogin>((resolve, reject) => {
GameContext.instance.WebSocketClient.sendMessage(
ReqLogin.cmd,
{
id: this.userId,
pwd: this.userPwd,
},
(msg: RespLogin) => {
resolve(msg);
}
);
});
}
}
2.3.控制器组件
控制器承担主要的业务逻辑,需要同时依赖组件及模型,典型地,控制器也申明为单例模式,如下:
import { _decorator } from 'cc';
import { BaseController } from '../../ui/BaseController';
import { MainPaneController } from '../main/MainPaneController';
import { LoginModel } from './LoginModel';
import { LoginPaneView } from './LoginPaneView';
const { ccclass, property } = _decorator;
@ccclass('LoginPaneController')
export class LoginPaneController extends BaseController {
@property(LoginPaneView)
loginView: LoginPaneView | null = null;
loginModel: LoginModel = LoginModel.instance;
start() {
this.initView(this.loginView);
}
protected bindViewEvents() {
this.loginView.node.on('accountLogin', this.onLoginClick, this);
}
async onLoginClick() {
const username = this.loginView.getUserId();
const password = this.loginView.getUserPwd();
// 这里添加登录验证逻辑
if (username && password) {
this.loginModel.setUserId(username);
this.loginModel.setUserPwd(password);
try {
const response = await this.loginModel.login();
console.log('登录成功');
MainPaneController.openUi();
} catch (error) {
console.error('登录失败:', error);
}
} else {
console.log('请输入用户名和密码');
}
}
}
回到 cocos界面,点击刚刚创建的预制体,选择LoginPane根节点,在右边属性检查器界面,新建一个脚本组件,选择上面的控制器,并把预制体的UI节点拖到LoginView控件上。
3.三者依赖关系
- 视图依赖模型:视图需要从模型获取数据来进行展示。
- 控制器依赖模型和视图:控制器既需要操作模型来实现业务逻辑,又需要与视图进行交互以响应用户操作和更新视图。例如,LoginPaneController类代码上同时依赖LoginPaneView和LoginModel
- 模型不依赖视图和控制器:模型是独立的,它只负责存储和管理数据以及执行相关的业务逻辑,不关心数据是如何展示给用户的,也不关心用户如何与界面进行交互。
关于第一点,视图依赖模型是有一定的争议性的。有些观点认为,如果视图不依赖模型,那么它可以更加独立地进行设计和开发。一个视图组件可以在不同的场景或项目中被复用,而不需要受到特定模型的限制。例如,一个通用的按钮视图组件,它只负责处理自身的外观和交互逻辑,不关心具体的业务数据,这样可以提高代码的复用性和可维护性。当需要在不同的地方使用按钮时,只需要将按钮视图引入即可,而不必担心它与不同的模型之间的兼容性问题。然而,考虑到大部分游戏界面需要当模型属性发生时,界面实时刷新,例如升级后,同时刷新剩余的金币数量。如果把这部分逻辑放在控制器,代码比较啰嗦。因此,从开发效率来讲,笔者更倾向于视图依赖模型。 读者请自行斟酌。
4.MVC通用基类
考虑到MVC的开发模式会贯穿到大部分游戏业务, 这里对三层进行了抽象。
4.1.视图基类
import { _decorator, Button, Component, Node, Toggle } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('BaseUiView')
export class BaseUiView extends Component {
public display() {
this.node.active = true;
this.onDisplay();
}
protected onDisplay() {}
public hide() {
this.node.active = false;
this.onHide();
}
protected onHide() {}
/**
* 为节点注册点击事件
* @param node 需要注册点击事件的节点
* @param callback 点击回调函数
* @param target 回调函数的this指向,默认为当前组件
*/
protected registerClickEvent(node: Node, callback: () => void, target: any = this) {
// 检查节点上是否有Button组件
const button = node.getComponent(Button);
if (button) {
// 如果有Button组件,注册Button的点击事件
button.node.on(Button.EventType.CLICK, callback, target);
return;
}
// 检查节点上是否有Toggle组件
const toggle = node.getComponent(Toggle);
if (toggle) {
// 如果有Toggle组件,注册Toggle的点击事件
toggle.node.on(Toggle.EventType.CLICK, callback, target);
return;
}
// 如果既没有Button也没有Toggle,作为普通节点注册点击事件
node.on(Node.EventType.TOUCH_END, callback, target);
}
}
4.2.控制器基类
import { Component } from 'cc';
export class BaseController extends Component {
protected view: any;
public initView(view: any) {
this.view = view;
// 进行一些通用的视图初始化操作,比如设置视图的父节点等
this.view.node.parent = this.node;
// 调用绑定事件的方法
this.bindViewEvents();
}
protected bindViewEvents() {
// 子类可以重写此方法来绑定具体的事件
}
}
4.3.模型基类
export class BaseModel {
private changeCallbacks: Map<string, ((value: any) => void)[]> = new Map();
// Register change listener for any field
onChange(field: string, callback: (value: any) => void) {
if (!this.changeCallbacks.has(field)) {
this.changeCallbacks.set(field, []);
}
this.changeCallbacks.get(field)!.push(callback);
return () => {
const callbacks = this.changeCallbacks.get(field);
if (callbacks) {
const index = callbacks.indexOf(callback);
if (index > -1) {
callbacks.splice(index, 1);
}
}
};
}
// Notify change for any field
protected notifyChange(field: string, value: any) {
const callbacks = this.changeCallbacks.get(field);
if (callbacks) {
callbacks.forEach((callback) => callback(value));
}
}
}
该基类主要是赋予模型通知视图的能力。当模型的数据发生变更的时候,视图可以马上监听到属性变更。例如英雄主界面上,需要实时更新剩余的金币数量和道具数据,如下:
这种需求可以直接在英雄主界面的逻辑里进行注册,如下:
@ccclass('HeroMainPaneController')
export class HeroMainPaneController extends BaseController {
protected start(): void {
this.initView(this.mainView);
// 监听道具变化
BagpackModel.getInstance().onChange('item', (items: Map<number, Item>) => {
let itemCount = items.get(2003);
if (this.mainView.itemLabel) {
this.mainView.itemLabel.string = itemCount?.count.toString() || '0';
}
});
// 监听金币变化
PurseModel.getInstance().onChange('gold', (value) => {
if (this.mainView.goldLabel) {
this.mainView.goldLabel.string = value.toString();
}
});
}
}
当钱包金币发生变化时,只需广播一个属性变更事件即可:
import { BaseModel } from '../../ui/BaseModel';
export class PurseModel extends BaseModel {
private static instance: PurseModel;
private _diamond: number = 0;
private _gold: number = 0;
public static getInstance(): PurseModel {
if (!PurseModel.instance) {
PurseModel.instance = new PurseModel();
}
return PurseModel.instance;
}
// 使用 getter/setter 来触发数据变化通知
get diamond(): number {
return this._diamond;
}
set diamond(value: number) {
if (this._diamond !== value) {
this._diamond = value;
this.notifyChange('diamond', value);
}
}
get gold(): number {
return this._gold;
}
set gold(value: number) {
if (this._gold !== value) {
this._gold = value;
this.notifyChange('gold', value);
}
}
}
完整项目请参考: cocos客户端+go服务器