《模拟龙生》|500行Go代码写一个随机冒险游戏|巨龙修为挑战开启
一、前言
新年就要到了,祝大家新的一年:
新年就要到了,祝大家新的一年:
在网页里实现文字聊天是比较容易的,但若要实现视频聊天,就比较麻烦了。本文将实现一个纯网页版的视频聊天和桌面分享的Demo,可直接在浏览器中运行,不需要安装任何插件。
1.本Demo的主要功能有
(1)一对一语音视频聊天。
(3)当客户端掉线时,会进行自动重连,当网络恢复后,重连成功。
2.支持的平台
(1)支持的操作系统包括:Windows、信创国产Linux(银河麒麟、统信UOS)、Android、iOS、Mac、鸿蒙OS。
(2)支持的CPU架构:X86/X64、ARM、MIPS、Loongarch。
(3)支持几乎所有的主流浏览器:Chrome、Edge、Firefox、Safari、360浏览器、QQ浏览器等。
(4)另外,我们测试过,使用APP套壳,在WebView控件中加载Demo页面,也是可以正常视频聊天的。
如此,可以在C/S架构的客户端或手机APP中嵌入WebView控件来引入视频聊天或桌面分享功能的。
1. 服务端:
服务端开发环境是 Visual Sudio 2022 ,开发语音是 C# 。
2. Web端:
PC版Web开发环境是 VS Code 1.85 ,使用 vue 3。
手机版Web开发环境是 HBuilder 3.8.12,uni-app(导出H5)。
此Demo的源码分为三个部分,分别是服务端,PC端Web(横版)和手机端Web(竖版)。接下来首先来看移动端Web的运行效果。
(1)首先是登录界面,在登录界面有三个输入框,依次为服务器IP、用户账号和用户密码,在本Demo中,用户账号和用户密码均可随便填写。
(2)接下来是首页界面,首页界面有一个已连接的提示框,代表的意思是目前与服务端是连接状态,当因为网络原因或者其他原因断开时,则会提示已断开连接。
(3)发起视频聊天,输入对方的账号,然后点击请求视频会话按钮即可向对方发起视频聊天请求,对方接受请求和即可聊天了。
下图是手机端与PC端的视频聊天效果:
注意:手机端是不支持分享自己的桌面的,但是移动端可以观看PC端桌面。
(4)接下来看看一下PC端的运行效果。
登录之后主页界面,左上角是关于自己的一些信息,右边窗口则是显示连接对方的摄像头或者桌面。
(4)下图是在PC端观看他人桌面。
输入对方的账号,然后点击请求远程桌面,在对方同意后便可以观看别人的屏幕了。
注意,由于浏览器的限制,如果你要将Web端部署到公网上,需要使用HTTPS协议,否则无法访问摄像头。
与之对应的,服务端也需要使用到WSS协议,因此需要准备一份SSL证书用于部署。如果你仅仅只是在本地运行看一下效果,则无需准备。
上图为服务端初始化代码,若不打算部署只是在浏览器中加载本地Demo页面,则应将上图中的第六行注释掉,并将第七行中MultimediaServerFactory.CreateMultimediaServer方法中的wssOption用null替换掉。
若打算将网站部署在服务器上,则需要将第五行X509Certificate2中的两个参数分别修改为你证书的路径和密码。
本Demo的中的Web端包含两套代码,其中移动端Web采用Uniapp进行开发,PC端Web采用Vue框架进行开发。为了便于理解,下面对源码中的关键点进行讲解说明,两套代码逻辑基本相同,因此这里不作区分。
在本Demo中,我们定义了10个消息类型,用于Web端之间进行通信,其定义如下:
const informationTypes ={//视频请求 VideoRequest: 0,//回复视频请求的结果 VideoResult: 1,//通知对方 挂断 视频连接 CloseVideo: 2,//通知好友 网络原因,导致 视频中断 NetReasonCloseVideo: 3,//通知对方(忙线中) 挂断 视频连接 BusyLine: 4,//远程桌面请求 DesktopRequest: 5,//回复远程桌面请求的结果 DesktopResult: 6,//主动取消远程桌面请求 CancelDesktop: 7,//对方(主人端)主动断开远程桌面 OwnerCloseDesktop: 8,//客人端断开远程桌面连接 GuestCloseDesktop: 9};
由于这些消息类型经常会使用到,因此需要将其放到一个全局都能访问到的地方,在移动端Web源码中,它被放在了Vuex中。而在PC端Web源码中,它放在src目录下的omcs目录下。
在登录成功后的这个时机,通过调用多媒体管理器上的
SetCustomMessageReceivedCallback
方法,我们向
multimediaManager
(多媒体管理器)注册一个回调函数,这个回调函数会在接收到其他用户或服务端的消息时被触发。
这个回调函数会接收一个对象类型的参数,其中包含了消息的类型和消息发起者的用户名数据,然后就可以根据消息的类型来完成自己的业务操作了。下图是本Demo中定义的消息处理器:
在本Demo中,一对一语音视频聊天功能的实现逻辑简而言之就是:例如用户A想要与用户B视频聊天,那么用户A向用户B发送VideoRequest消息,在用户B收到来自用户A的VideoRequest消息时选择同意与否,并将携带用户B意愿数据的VideoResult消息发送用户A。
//请求视频会话 const videoRequest = async () =>{//... multimediaManager.sendCustomMessage(targetUsername.value, InformationTypes.VideoRequest, null, null);//... };//响应视频会话 const videoResult = (flag) =>{
//... multimediaManager.sendCustomMessage(targetUsername.value, InformationTypes.VideoResult, [flag ? 1 : 0], "");
//... };
与一对一语音视频聊天功能类似,实现桌面分享也是一方发起请求,一方进行回应。与语音视频对应的,桌面分享的请求的消息类型为DesktopRequest,响应的消息类型为DesktopResult。
在网络断开时,用户进入掉线状态(与服务器断开),每5秒会进行与服务器的重新连接。提前向多媒体管理器注入ConnectionInterrupted和ConnectionRebuildSucceed回调,能够在与媒体服务器断开和重新连接成功时做一些事情。
Web端包含两套代码,其中移动端Web的目录是H5MediaDemo_WebH5,PC端Web的目录是H5MediaDemo_WebPC。
由于移动端web是采用uniapp开发的,而uniapp项目需要通过HBuilder X来运行,因此,你需要在电脑上安装一个
HBuilder X
,然后在HBuilderX中打开运行——>运行到浏览器,然后选择一个浏览器就可以运行起来了,如下图:
PC端采用Vue3开发的,需要依赖NodeJS环境,因此,你需要在电脑上安装一个
NodeJS
(建议安装长期维护版)。在安装完后,通过在命令行窗口输入node -v和npm - v来检查是否安装成功:
确定安装成功后,通过命令行进入到H5MediaDemo_WebPC的项目根目录,然后输入npm run dev即可将项目运行起来。
(1)
PC版源码
(2)
手机版源码
另外,我们已经部署好了测试服务器,以方便测试。
(1)
PC Web 测试网址
(2)
手机 Web 测试网址
网页版视频聊天Demo实现的介绍就到这里了,谢谢!
之前已经分享过多篇关于Spring Boot中使用
Java 21新特性虚拟线程
的性能测试案例:
早上看到群友问到一个关于虚拟线程遇到MySQL连接不兼容导致的性能问题:
这个问题确实之前就有看到过相关的评测,顺着个这个问题,重新把相关评测找出来,给大家分享一下。
评测采用现实场景中的处理流程,具体如下:
这个场景其实是
Spring Boot 虚拟线程与Webflux在JWT验证和MySQL查询上的性能比较
测试的后续。前文主要对比了虚拟线程和WebFlux的,但没有对比虚拟线程与物理线程的区别。所以,接下来的内容就是本文关心的重点:在物理线程和虚拟线程下,MySQL驱动是否有性能优化。
测试工具:Bombardier
采用了开源负载测试工具:Bombardier。在测试场景中预先创建 100,000 个 JWT 列表。
在测试期间,Bombardier 从该池中随机选择了JWT,并将它们包含在HTTP请求的Authorization标头中。
MySQL表结构与数据准备
User表结构如下:
mysql> desc users;
+--------+--------------+------+-----+---------+-------+
| Field | Type | Null | Key | Default | Extra |
+--------+--------------+------+-----+---------+-------+
| email | varchar(255) | NO | PRI | NULL | |
| first | varchar(255) | YES | | NULL | |
| last | varchar(255) | YES | | NULL | |
| city | varchar(255) | YES | | NULL | |
| county | varchar(255) | YES | | NULL | |
| age | int | YES | | NULL | |
+--------+--------------+------+-----+---------+-------+
6 rows in set (0.00 sec)
准备大约10w条数据:
mysql> select count(*) from users;
+----------+
| count(*) |
+----------+
| 99999 |
+----------+
1 row in set (0.01 sec)
配置文件:
server.port=3000
spring.datasource.url= jdbc:mysql://localhost:3306/testdb?useSSL=false&allowPublicKeyRetrieval=true
spring.datasource.username= dbuser
spring.datasource.password= dbpwd
spring.jpa.hibernate.ddl-auto= update
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect
User实体定义:
@Entity
@Table(name = "users")
public class User {
@Id
private String email;
private String first;
private String last;
private String city;
private String county;
private int age;
// 省略了getter和setter
}
数据访问实现:
public interface UserRepository extends CrudRepository<User, String> {
}
API实现:
@RestController
public class UserController {
@Autowired
UserRepository userRepository;
private SignatureAlgorithm sa = SignatureAlgorithm.HS256;
private String jwtSecret = System.getenv("JWT_SECRET");
@GetMapping("/")
public User handleRequest(@RequestHeader(HttpHeaders.AUTHORIZATION) String authHdr) {
String jwtString = authHdr.replace("Bearer","");
Claims claims = Jwts.parser()
.setSigningKey(jwtSecret.getBytes())
.parseClaimsJws(jwtString).getBody();
Optional<User> user = userRepository.findById((String)claims.get("email"));
return user.get();
}
}
应用主类:
@SpringBootApplication
public class UserApplication {
public static void main(String[] args) {
SpringApplication.run(UserApplication.class, args);
}
}
主要调整应用主类,其他一样,具体修改如下:
@SpringBootApplication
public class UserApplication {
public static void main(String[] args) {
SpringApplication.run(UserApplication.class, args);
}
@Bean
public TomcatProtocolHandlerCustomizer<?> protocolHandlerVirtualThreadExecutorCustomizer() {
return protocolHandler -> {
protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
};
}
}
server.port=3000
spring.r2dbc.url=r2dbc:mysql://localhost:3306/testdb?allowPublicKeyRetrieval=true&ssl=false
spring.r2dbc.username=dbuser
spring.r2dbc.password=dbpwd
spring.r2dbc.pool.initial-size=10
spring.r2dbc.pool.max-size=10
@Table(name = "users")
public class User {
@Id
private String email;
private String first;
private String last;
private String city;
private String county;
private int age;
// 省略getter、setter和构造函数
}
数据访问实现:
public interface UserRepository extends R2dbcRepository<User, String> {
}
业务逻辑实现:
@Service
public class UserService {
@Autowired
UserRepository userRepository;
public Mono<User> findById(String id) {
return userRepository.findById(id);
}
}
API实现:
@RestController
@RequestMapping("/")
public class UserController {
@Autowired
UserService userService;
private SignatureAlgorithm sa = SignatureAlgorithm.HS256;
private String jwtSecret = System.getenv("JWT_SECRET");
@GetMapping("/")
@ResponseStatus(HttpStatus.OK)
public Mono<User> getUserById(@RequestHeader(HttpHeaders.AUTHORIZATION) String authHdr) {
String jwtString = authHdr.replace("Bearer","");
Claims claims = Jwts.parser()
.setSigningKey(jwtSecret.getBytes())
.parseClaimsJws(jwtString).getBody();
return userService.findById((String)claims.get("email"));
}
}
应用主类:
@EnableWebFlux
@SpringBootApplication
public class UserApplication {
public static void main(String[] args) {
SpringApplication.run(UserApplication.class, args);
}
}
每次测试都包含 100 万个请求,分别评估了它们在不同并发(50、100、300)水平下的性能。下面是结果展示:
在这个测试案例中使用了MySQL驱动,虚拟线程的实现方式性能最差,WebFlux依然保持领先。所以,主要原因在于这个MySQL的驱动对虚拟线程不友好。如果涉及到数据库访问的情况下,需要寻找对虚拟线程支持最佳的驱动程序。另外,该测试使用的是Java 20和Spring Boot 3.1。对于Java 21和Spring Boot 3.2建议读者在使用的时候自行评估。
最后,对于MySQL驱动对虚拟线程支持好的,欢迎留言区推荐一下。如果您学习过程中如遇困难?可以加入我们超高质量的
Spring技术交流群
,参与交流与讨论,更好的学习与进步!更多
Spring Boot教程可以点击直达!
,欢迎收藏与转发支持!
欢迎关注我的公众号:程序猿DD。第一时间了解前沿行业消息、分享深度技术干货、获取优质学习资源
大家好,本文提出了ECS模式。ECS模式是游戏引擎中常用的模式,通常用来组织游戏场景。本文出自我写的开源书《3D编程模式》,该书的更多内容请详见:
Github
在线阅读
我们需要开发一个游戏,游戏中有两种人物:普通英雄和超级英雄,他们具有下面的行为:
我们使用下面的方法来渲染:
应该有一个游戏世界,它由多个普通英雄和多个超级英雄组成
一个模块对应一个普通英雄,一个模块对应一个超级英雄。模块应该维护该英雄的数据和实现该英雄的行为
领域模型
总体来看,领域模型分为用户、游戏世界、英雄这三个部分
我们看下用户、游戏世界这两个部分:
Client是用户
World是游戏世界,由多个普通英雄和多个超级英雄组成。World负责管理所有的英雄,并且实现了初始化和主循环的逻辑
我们看下英雄这个部分:
一个NormalHero对应一个普通英雄,维护了该英雄的数据,实现了移动的行为
一个SuperHero对应一个超级英雄, 维护了该英雄的数据,实现了移动、飞行的行为
首先,我们看下Client的代码;
然后,我们依次看下Client代码中前两个步骤的代码,它们包括:
然后,因为创建场景时操作了普通英雄和超级英雄,所以我们看下它们的代码,它们包括:
然后,我们依次看下Client代码中剩余的两个步骤的代码,它们包括:
然后,我们看下主循环的一帧中每个步骤的代码,它们包括:
最后,我们运行Client的代码
Client
let worldState = World.createState()
worldState = _createScene(worldState)
worldState = WorldUtils.init(worldState)
WorldUtils.loop(worldState, [World.update, World.renderOneByOne, World.renderInstances])
Client首先创建了WorldState,用来保存游戏世界中所有的数据;然后创建了场景;然后进行了初始化;最后开始了主循环
World
export let createState = (): worldState => {
return {
normalHeroes: Map(),
superHeroes: Map()
}
}
createState函数创建了WorldState,它包括两个分别用来保存所有的普通英雄和所有的超级英雄的容器
Client
let _createScene = (worldState: worldState): worldState => {
创建和加入normalHero1到worldState.normalHeroes
创建和加入normalHero2到worldState.normalHeroes
normalHero1移动
创建和加入superHero1到worldState.superHeroes
创建和加入superHero2到worldState.superHeroes
superHero1移动
superHero1飞行
return worldState
}
_createScene函数创建了场景,创建和加入了两个普通英雄和两个超级英雄到游戏世界中。其中第一个普通英雄进行了移动,第一个超级英雄进行了移动和飞行
NormalHero
//创建一个普通英雄
export let create = (): [normalHeroState, normalHero] => {
创建它的state数据:
position设置为[0,0,0]
velocity设置为1.0
其中:position为位置,velocity为速度
返回该英雄
}
NormalHero的create函数创建了一个普通英雄,初始化了它的数据
SuperHero
//创建一个超级英雄
export let create = (): [superHeroState, superHero] => {
创建它的state数据:
position设置为[0,0,0]
velocity设置为1.0
maxVelocity设置为1.0
其中:position为位置,velocity为速度,maxVelocity为最大速度
返回该英雄
}
SuperHero的create函数创建了一个超级英雄,初始化了它的数据
NormalHero
//一个普通英雄的移动
export let move = (worldState: worldState, normalHero: normalHero): worldState => {
从worldState中获得该英雄的position和velocity
根据velocity,更新position
更新worldState中该英雄的数据
}
move函数实现了移动的行为逻辑,更新了位置
SuperHero
//一个超级英雄的移动
export let move = (worldState: worldState, superHero: superHero): worldState => {
从worldState中获得该英雄的position和velocity
根据velocity,更新position
更新worldState中该英雄的数据
}
//一个超级英雄的飞行
export let fly = (worldState: worldState, superHero: superHero): worldState => {
从worldState中获得该英雄的position和velocity、maxVelocity
根据maxVelocity、velocity,更新position
更新worldState中该英雄的数据
}
SuperHero的move函数的逻辑跟NormalHero的move函数的逻辑是一样的
fly函数实现了飞行的行为逻辑。它跟move函数一样,也是更新英雄的position。只是因为两者在计算时使用的速度的算法不一样,所以更新position的幅度不同
WorldUtils
export let init = (worldState) => {
console.log("初始化...")
return worldState
}
init函数实现了初始化。这里没有任何逻辑,只是进行了打印
WorldUtils
export let loop = (worldState, [update, renderOneByOne, renderInstances]) => {
worldState = update(worldState)
renderOneByOne(worldState)
renderInstances(worldState)
...
requestAnimationFrame(
(time) => {
loop(worldState, [update, renderOneByOne, renderInstances])
}
)
}
loop函数实现了主循环。在主循环的一帧中,首先进行了更新;然后一个一个地渲染了所有的超级英雄;然后一次性批量渲染了所有的普通英雄;最后执行下一帧
World
export let update = (worldState: worldState): worldState => {
遍历worldState.normalHeroes:
更新每个normalHero
遍历worldState.superHeroes:
更新每个superHero
}
update函数实现了更新,它会遍历所有的normalHero和superHero,调用它们的update函数来更新自己
我们看下NormalHero的update函数的代码:
//更新一个普通英雄
export let update = (normalHeroState: normalHeroState): normalHeroState => {
更新该英雄的position
}
它更新了自己的position
我们看下SuperHero的update函数的代码:
//更新一个超级英雄
export let update = (superHeroState: superHeroState): superHeroState => {
更新该英雄的position
}
它的逻辑跟NormalHero的update是一样的,这是因为两者都使用同样的算法来更新自己的position
World
export let renderOneByOne = (worldState: worldState): void => {
worldState.superHeroes.forEach(superHeroState => {
console.log("OneByOne渲染 SuperHero...")
})
}
export let renderInstances = (worldState: worldState): void => {
let normalHeroStates = worldState.normalHeroes
console.log("批量Instance渲染 NormalHeroes...")
}
renderOneByOne函数实现了超级英雄的渲染,它遍历每个超级英雄,一个一个地渲染
renderInstances函数实现了普通英雄的渲染,它一次性获得所有的普通英雄,批量渲染
下面,我们运行Client的代码,打印的结果如下:
初始化...
更新NormalHero
更新NormalHero
更新SuperHero
更新SuperHero
OneByOne渲染 SuperHero...
OneByOne渲染 SuperHero...
批量Instance渲染 NormalHeroes...
{"normalHeroes":{"144891":{"position":[0,0,0],"velocity":1},"648575":{"position":[2,2,2],"velocity":1}},"superHeroes":{"497069":{"position":[6,6,6],"velocity":1,"maxFlyVelocity":10},"783438":{"position":[0,0,0],"velocity":1,"maxFlyVelocity":10}}}
通过打印的数据,可以看到运行的步骤如下:
1.进行了初始化
2.更新了所有的人物,包括两个普通英雄和两个超级英雄
3.渲染了2个超级英雄
4.一次性批量渲染了所有的普通英雄
5.打印了WorldState
我们看下打印的WorldState:
值得注意的是:
因为WorldState的normalHeroes和superHeroes中的Key是随机生成的id值,所以每次打印时Key都不一样
NormalHero和SuperHero中的update、move函数的逻辑是重复的
如果英雄增加更多的行为,NormalHero和SuperHero模块会越来越复杂,不容易维护
虽然这两个问题都可以通过继承来解决,即最上面是Hero基类,然后不同种类的Hero层层继承,但是继承的方式很死板,不够灵活
基于组件化的思想,用组合代替继承。具体修改如下:
这样就通过GameObject组合不同的组件来代替人物层层继承,从而更加灵活
领域模型
总体来看,领域模型分为用户、游戏世界、GameObject、组件这四个部分
我们看下用户、游戏世界这两个部分:
Client是用户
World是游戏世界,由多个GameObject组成。World负责管理所有的GameObject,并且实现了初始化和主循环的逻辑
我们看下GameObject这个部分:
一个GameObject对应一个人物。GameObject负责管理挂载的组件,它可以挂载PositionComponent、VelocityComponent、FlyComponent、InstanceComponent这四种组件,每种组件最多挂载一个
我们看下组件这个部分:
组件负责维护自己的数据,实现自己的行为逻辑。具体来说,是将NormalHero、SuperHero的position数据和move函数、update函数移到了PositionComponent中;将NormalHero、SuperHero的velocity数据移到了VelocityComponent中;将SuperHero的maxVelocity数据和fly函数移到了FlyComponent中
InstanceComponent没有数据和逻辑,它只是一个标记,用来表示挂载该组件的GameObject使用一次性批量渲染的算法来渲染
现在只需要实现一次Position组件中的update、move函数,然后将它挂载到不同的GameObject中,就可以实现普通英雄和超级英雄的更新、移动的逻辑,从而消除了之前在NormalHero、SuperHero中因共实现了两次的update、move函数而造成的重复代码
因为NormalHero、SuperHero都是GameObject,而GameObject本身只负责管理组件,没有行为逻辑,所以随着人物的行为的增加,GameObject并不会增加逻辑,而只需要增加对应行为的组件,让GameObject挂载该组件即可
通过这样的设计,将行为的逻辑和数据从人物移到了组件中,从而可以通过组合的方式使人物具有多个行为,避免了庞大的人物模块的出现
首先,我们看下Client的代码;
然后,我们依次看下Client代码中前两个步骤的代码,它们包括:
然后,因为创建场景时操作了普通英雄和超级英雄,所以我们看下它们的代码,它们包括:
然后,我们依次看下Client代码中剩余的两个步骤的代码,它们包括:
然后,我们看下主循环的一帧中每个步骤的代码,它们包括:
最后,我们运行Client的代码
Client的代码跟之前的Client的代码基本上一样,故省略。不一样的地方是_createScene函数中创建场景的方式不一样,这个等会再讨论
World
export let createState = (): worldState => {
return {
gameObjects: Map()
}
}
createState函数创建了WorldState,它保存了一个用来保存所有的gameObject的容器
Client
let _createScene = (worldState: worldState): worldState => {
创建和加入normalHero1到worldState.gameObjects:
创建gameObject
创建positionComponent
创建velocityComponent
创建instanceComponent
挂载positionComponent、velocityComponent、instanceComponent到gameObject
加入gameObject到worldState.gameObjects
创建和加入normalHero2到worldState.gameObjects
normalHero1移动:
调用normalHero1挂载的positionComponent的move函数
创建和加入superHero1到worldState.gameObjects:
创建gameObject
创建positionComponent
创建velocityComponent
创建flyComponent
挂载positionComponent、velocityComponent、flyComponent到gameObject
加入gameObject到worldState.gameObjects
创建和加入superHero2到worldState.gameObjects
superHero1移动:
调用superHero1挂载的positionComponent的move函数
superHero1飞行:
调用superHero1挂载的flyComponent的fly函数
return worldState
}
_createScene函数创建了场景,场景的内容跟之前一样,都包括了2个普通英雄和2个超级英雄,只是现在创建一个英雄的方式改变了,具体变为:首先创建一个GameObject和相关的组件;然后挂载组件到GameObject;最后加入该GameObject到World中
普通英雄对应的GameObject挂载的组件跟超级英雄对应的GameObject挂载的组件也不一样,其中前者挂载了InstanceComponent(因为普通英雄需要一次性批量渲染),后者则挂载了FlyComponent(因为超级英雄多出了飞行的行为)
另外,现在改为通过调用对应组件的函数而不是直接操作英雄模块,从而实现英雄的“移动”、“飞行”
GameObject
//创建一个gameObject
export let create = (): [gameObjectState, gameObject] => {
创建它的state数据:
没有挂载任何的组件
返回该gameObject
}
GameObject的create函数创建了一个gameObject,初始化了它的数据
PositionComponent
//创建一个positionComponent
export let create = (): positionComponentState => {
创建它的state数据:
gameObject设置为null
position设置为[0,0,0]
其中:position为位置,gameObject为挂载到的gameObject
返回该组件
}
PositionComponent的create函数创建了一个positionComponent,初始化了它的数据
VelocityComponent
//创建一个velocityComponent
export let create = (): velocityComponentState => {
创建它的state数据:
gameObject设置为null
velocity设置为1.0
其中:velocity为速度,gameObject为挂载到的gameObject
返回该组件
}
FlyComponent
//创建一个flyComponent
export let create = (): flyComponentState => {
创建它的state数据:
gameObject设置为null
maxVelocity设置为1.0
其中:maxVelocity为最大速度,gameObject为挂载到的gameObject
返回该组件
}
InstanceComponent
//创建一个instanceComponent
export let create = (): instanceComponentState => {
创建它的state数据:
gameObject设置为null
其中:gameObject为挂载到的gameObject
返回该组件
}
这三种组件的create函数的职责跟PositionComponent的create函数的职责一样,不一样的是InstanceComponent的state数据中只有挂载到的gameObject,没有自己的数据
我们可以看到,组件的state数据中都保存了挂载到的gameObject,这样做的目的是可以通过它来获得挂载到它上的其它组件,从而一个组件可以操作其它挂载的组件
PositionComponent
...
//获得一个组件的position
export let getPosition = (positionComponentState: positionComponentState) => {
return positionComponentState.position
}
//设置一个组件的position
export let setPosition = (positionComponentState: positionComponentState, position) => {
return {
...positionComponentState,
position: position
}
}
...
//一个gameObject的移动
export let move = (worldState: worldState, positionComponentState: positionComponentState): worldState => {
//获得该组件的position、gameObject
let [x, y, z] = getPosition(positionComponentState)
//通过该组件的gameObject,获得挂载到该gameObject的velocityComponent组件
//获得它的velocity
let gameObject = getExnFromStrictNull(positionComponentState.gameObject)
let velocity = VelocityComponent.getVelocity(GameObject.getVelocityComponentExn(getGameObjectStateExn(worldState, gameObject)))
//根据velocity,更新该组件的position
positionComponentState = setPosition(positionComponentState, [x + velocity, y + velocity, z + velocity])
更新worldState中该组件挂载的gameObject中的该组件的数据
}
VelocityComponent
//获得一个组件的velocity
export let getVelocity = (velocityComponentState: velocityComponentState) => {
return velocityComponentState.velocity
}
PositionComponent维护了position数据,提供了它的get、set函数。VelocityComponent维护了velocity数据,,提供了它的get函数
另外,PositionComponent的move函数实现了移动的行为逻辑
FlyComponent
//获得一个组件的maxVelocity
export let getMaxVelocity = (flyComponentState: flyComponentState) => {
return flyComponentState.maxVelocity
}
//设置一个组件的maxVelocity
export let setMaxVelocity = (flyComponentState: flyComponentState, maxVelocity) => {
return {
...flyComponentState,
maxVelocity: maxVelocity
}
}
//一个gameObject的飞行
export let fly = (worldState: worldState, flyComponentState: flyComponentState): worldState => {
//获得该组件的maxVelocity、gameObject
let maxVelocity = getMaxVelocity(flyComponentState)
let gameObject = getExnFromStrictNull(flyComponentState.gameObject)
//通过该组件的gameObject,获得挂载到该gameObject的positionComponent组件
//获得它的position
let [x, y, z] = PositionComponent.getPosition(GameObject.getPositionComponentExn(getGameObjectStateExn(worldState, gameObject)))
//通过该组件的gameObject,获得挂载到该gameObject的velocityComponent组件
//获得它的velocity
let velocity = VelocityComponent.getVelocity(GameObject.getVelocityComponentExn(getGameObjectStateExn(worldState, gameObject)))
//根据maxVelocity、velocity,更新positionComponent组件的position
velocity = velocity < maxVelocity ? (velocity * 2.0) : maxVelocity
let positionComponentState = PositionComponent.setPosition(GameObject.getPositionComponentExn(getGameObjectStateExn(worldState, gameObject)), [x + velocity, y + velocity, z + velocity])
更新worldState中该组件挂载的gameObject中的该组件的数据
}
FlyComponent维护了maxVelocity数据,,提供了它的get、set函数。另外,FlyComponent的fly函数实现了飞行的行为逻辑
初始化和主循环的逻辑跟之前一样,故省略代码
World
export let update = (worldState: worldState): worldState => {
遍历worldState.gameObjects:
if(gameObject挂载了positionComponent){
更新positionComponent
}
}
update函数实现了更新,它会遍历所有的gameObject,调用它挂载的PositionComponent组件的update函数来更新该组件。我们看下PositionComponent的update函数的代码:
//更新一个组件
export let update = (positionComponentState: positionComponentState): positionComponentState => {
更新该组件的position
}
它的逻辑跟之前的NormalHero和SuperHero中的update函数的逻辑是一样的
World
export let renderOneByOne = (worldState: worldState): void => {
let superHeroGameObjects = worldState.gameObjects.filter(gameObjectState => {
//判断gameObject是不是没有挂载InstanceComponent
return !GameObject.hasInstanceComponent(gameObjectState)
})
superHeroGameObjects.forEach(gameObjectState => {
console.log("OneByOne渲染 SuperHero...")
})
}
export let renderInstances = (worldState: worldState): void => {
let normalHeroGameObejcts = worldState.gameObjects.filter(gameObjectState => {
//判断gameObject是不是挂载了InstanceComponent
return GameObject.hasInstanceComponent(gameObjectState)
})
console.log("批量Instance渲染 NormalHeroes...")
}
renderOneByOne函数实现了超级英雄的渲染,它首先得到了所有没有挂载InstanceComponent组件的gameObject;最后遍历它们,一个一个地渲染
renderInstances函数实现了普通英雄的渲染,它首先得到了所有挂载了InstanceComponent组件的gameObject;最后批量渲染
下面,我们运行Client的代码,打印的结果如下:
初始化...
更新PositionComponent
更新PositionComponent
更新PositionComponent
更新PositionComponent
OneByOne渲染 SuperHero...
OneByOne渲染 SuperHero...
批量Instance渲染 NormalHeroes...
{"gameObjects":{"304480":{"positionComponent":{"gameObject":304480,"position":[0,0,0]},"velocityComponent":{"gameObject":304480,"velocity":1},"flyComponent":{"gameObject":304480,"maxVelocity":10},"instanceComponent":null},"666533":{"positionComponent":{"gameObject":666533,"position":[2,2,2]},"velocityComponent":{"gameObject":666533,"velocity":1},"flyComponent":null,"instanceComponent":{"gameObject":666533}},"838392":{"positionComponent":{"gameObject":838392,"position":[0,0,0]},"velocityComponent":{"gameObject":838392,"velocity":1},"flyComponent":null,"instanceComponent":{"gameObject":838392}},"936933":{"positionComponent":{"gameObject":936933,"position":[6,6,6]},"velocityComponent":{"gameObject":936933,"velocity":1},"flyComponent":{"gameObject":936933,"maxVelocity":10},"instanceComponent":null}}}
通过打印的数据,可以看到运行的步骤与之前一样
不同之处在于:
我们看下打印的WorldState:
值得注意的是:
因为WorldState的gameObjects中的Key是随机生成的id值,所以每次打印时Key都不一样
基于Data Oriented的思想进行改进
组件可以按角色分为Data Oriented组件和其它组件,其中前者的特点是属于该角色的每个组件都有数据,且组件的数量较多;后者的特点是属于该角色的每个组件都没有数据,或者组件的数量很少。这里具体说明一下各种组件的角色:目前一共有四种组件,它们是PositionComponent、VelocityComponent、FlyComponent、InstanceComponent。其中,InstanceComponent组件因为没有组件数据,所以属于“其它组件”;另外三种组件则都属于“Data Oriented组件”。
属于Data Oriented组件的三种组件的所有组件数据将会分别集中起来,保存在各自的一块连续的地址空间中,具体就是分别保存在三个ArrayBuffer中
将GameObject和各个组件扁平化
GameObject不再有数据和逻辑了,而只是一个全局唯一的id。组件也不再有数据和逻辑了,其中属于“Data Oriented组件”的组件只是一个ArrayBuffer上的索引;属于“其它组件”的组件只是一个全局唯一的id
增加Component+GameObject这一层,将扁平的GameObject和组件放在该层中
增加Manager这一层,来管理GameObject和组件的数据
这一层有GameObjectManager和四种组件的Manager,其中GameObjectManager负责管理所有的gameObject;四种组件的Manager负责管理自己的ArrayBuffer,操作属于该种类的所有组件
值得注意的是:
领域模型
总体来看,领域模型分为五个部分:用户、World、System层、Manager层、Component+GameObject层,它们的依赖关系是前者依赖后者
我们看下用户、World这两个部分:
Client是用户
World是游戏世界,虽然仍然实现了初始化和主循环的逻辑,不过不再管理所有的GameObject了
我们看下System这一层:
有多个System,每个System实现一个行为逻辑。每个System的职责如下:
我们看下Manager这一层:
每个Manager都有一个state数据
GameObjectManager负责管理所有的gameObject
PositionComponentManager、VelocityComponentManager、FlyComponentManager、InstanceComponentManager负责管理属于各自种类的所有的组件
PositionComponentManager的state数据包括一个buffer字段和一个positions字段,其中前者是一个ArrayBuffer,保存了该种组件的所有组件数据;后者是buffer的视图,用于读写buffer中的position数据
PositionComponentManager的batchUpdate函数负责批量更新所有的positionComponent组件的position
因为VelocityComponentManager、FlyComponentManager与PositionComponentManager一样,都属于Data Oriented组件的Manager,所以它们的数据和函数类似(只是没有batchUpdate函数),故在图中省略它们的数据和函数
我们看下Component+GameObject这一层:
因为PositionComponent、VelocityComponent、FlyComponent属于Data Oriented组件,所以它们是一个index,也就是各自Manager的state的buffer中的索引值
因为InstanceComponent属于其它组件,所以它是一个全局唯一的id
GameObject是一个全局唯一的id
我们来看下依赖关系:
System层:
因为CreateSystem需要调用各个Manager的createState函数来创建它们的state,所以依赖了整个Manager层
因为UpdateSystem需要调用PositionComponentManager的batchUpdate函数来更新,所以依赖了PositionComponentManager
因为MoveSystem需要调用PositionComponentManager来获得和设置position,并且调用VelocityComponentManager来获得velocity,所以依赖了PositionComponentManager、VelocityComponentManager
因为FlySystem需要调用PositionComponentManager来获得和设置position,并且调用VelocityComponentManager、FlyComponentManager来分别获得velocity和maxVelocity,所以依赖了PositionComponentManager、VelocityComponentManager、FlyComponentManager
因为RenderOneByOneSystem和RenderInstancesSystem需要调用GameObjectManager来获得所有的gameObject,并调用各种组件的Manager来获得组件数据,所以依赖了整个Manager层
Manager层:
因为GameObjectManager需要操作GameObject,所以依赖了GameObject
因为各种组件的Manager需要操作所有的该种组件,所以依赖了对应的组件
现在各种组件的数据都集中保存在各自Manager的state的buffer(ArrayBuffer)中,遍历同一种组件的所有组件数据即是遍历一个ArrayBuffer。因为ArrayBuffer的数据是连续地保存在内存中的,所以缓存命中不会丢失,从而提高了性能
现在将涉及多种组件的行为放在对应的System中。因为System很轻,没有数据,只有逻辑,所以增加和维护System的成本较低;另外,因为System位于最上层,所以修改System也不会影响Manager层和Component+GameObject层
首先,我们看下Client的代码;
然后,我们看下Client代码中第一步的代码:
然后,因为创建WorldState时会创建Data Oriented组件的Manager的state,其中的关健是创建各自的ArrayBuffer,所以我们看下创建它的代码;
然后,我们看下Client代码中第二步的代码:
然后,因为创建场景时操作了普通英雄和超级英雄,所以我们看下它们的代码,它们包括:
然后,我们依次看下Client代码中剩余的两个步骤的代码,它们包括:
然后,我们看下主循环的一帧中每个步骤的代码,它们包括:
最后,我们运行Client的代码
Client
let worldState = World.createState({ positionComponentCount: 10, velocityComponentCount: 10, flyComponentCount: 10 })
跟之前一样...
Client的代码跟之前的Client的代码基本一样,除了createState函数的参数和_createScene函数中创建场景的方式不一样,这个等会再讨论
World
export let createState = CreateStateSystem.createState
CreateStateSystem
export let createState = ({ positionComponentCount, velocityComponentCount, flyComponentCount }): worldState => {
return {
gameObjectManagerState: GameObjectManager.createState(),
positionComponentManagerState: PositionComponentManager.createState(positionComponentCount),
velocityComponentManagerState: VelocityComponentManager.createState(velocityComponentCount),
flyComponentManagerState: FlyComponentManager.createState(flyComponentCount),
instanceComponentManagerState: InstanceComponentManager.createState()
}
}
CreateStateSystem的createState函数创建了WorldState,它保存了各个Manager的state
因为Data Oriented组件的Manager的state在创建时要创建包括该种组件的所有组件数据的ArrayBuffer,需要知道该种组件的最大个数,所以这里的createState函数接收了三种Data Oriented组件的最大个数
我们以PositionComponentManager为例,来看下它的createState函数的相关代码:
position_component/ManagerStateType
export type state = {
maxIndex: number,
buffer: ArrayBuffer,
positions: Float32Array,
...
}
这是PositionComponentManager的state的类型定义,它的字段解释如下:
position_component/Manager
let _setAllTypeArrDataToDefault = ([positions]: Array<Float32Array>, count, [defaultPosition]) => {
range(0, count - 1).forEach(index => {
OperateTypeArrayUtils.setPosition(index, defaultPosition, positions)
})
return [positions]
}
let _initBufferData = (count, defaultDataTuple): [ArrayBuffer, Array<Float32Array>] => {
let buffer = BufferUtils.createBuffer(count)
let typeArrData = _setAllTypeArrDataToDefault(CreateTypeArrayUtils.createTypeArrays(buffer, count), count, defaultDataTuple)
return [buffer, typeArrData]
}
export let createState = (positionComponentCount: number): state => {
let defaultPosition = [0, 0, 0]
let [buffer, [positions]] = _initBufferData(positionComponentCount, [defaultPosition])
return {
maxIndex: 0,
buffer,
positions,
...
}
}
这是PositionComponentManager的createState函数的代码,其中调用的_initBufferData函数创建了buffer和positions,它的步骤如下:
1.调用BufferUtils的createBuffer函数来创建包括最大组件个数的数据的ArrayBuffer
2.调用CreateTypeArrayUtils的createTypeArrays函数来创建所有的TypeArray,它们是操作ArrayBuffer的视图。这里具体是只创建了一个视图:positions
3.调用_setAllTypeArrDataToDefault函数来将positions的所有的值写为默认值:[0,0,0]
下面是BufferUtils的createBuffer函数和CreateTypeArrayUtils的createTypeArrays函数的相关代码:
position_component/BufferUtils
let _getPositionSize = () => 3
export let getPositionOffset = (count) => 0
export let getPositionLength = (count) => count * _getPositionSize()
export let getPositionIndex = index => index * _getPositionSize()
let _getTotalByteLength = (count) => {
return count * Float32Array.BYTES_PER_ELEMENT * _getPositionSize()
}
export let createBuffer = (count) => {
return new ArrayBuffer(_getTotalByteLength(count))
}
position_component/CreateTypeArrayUtils
export let createTypeArrays = (buffer, count) => {
return [
new Float32Array(buffer, BufferUtils.getPositionOffset(count), BufferUtils.getPositionLength(count))
]
}
另外两种Data Oriented组件的Manager(VelocityComponentManager、FlyComponentManager)的createState函数的逻辑跟PositionComponentManager的createState函数的逻辑一样,故省略相关代码
Client
let _createScene = (worldState: worldState): worldState => {
创建normalHero1:
创建gameObject
创建positionComponent
创建velocityComponent
创建instanceComponent
挂载positionComponent、velocityComponent、instanceComponent到gameObject
创建normalHero2
normalHero1移动:
调用MoveSystem的move函数,传入normalHero1的positionComponent、velocityComponent
创建superHero1
创建gameObject
创建positionComponent
创建velocityComponent
创建flyComponent
挂载positionComponent、velocityComponent、flyComponent到gameObject
创建superHero2
superHero1移动:
调用MoveSystem的move函数,传入superHero1的positionComponent、velocityComponent
superHero1飞行:
调用FlySystem的fly函数,传入superHero1的positionComponent、velocityComponent、flyComponent
return worldState
}
_createScene函数创建了场景,场景的内容跟之前一样,都包括了2个普通英雄和2个超级英雄,只是现在创建一个英雄的方式又改变了,具体变为:现在不需要加入GameObject到World中
另外,现在改为通过调用MoveSystem和FlySystem的函数来操作对应的组件,从而实现英雄的“移动”、“飞行”
gameObject/Manager
export let createState = (): state => {
return {
maxUID: 0
}
}
//创建一个gameObject
//一个gameObject就是一个uid
export let createGameObject = (state: state): [state, gameObject] => {
let uid = state.maxUID
//生成一个uid
//uid的意思是unique id,即全局唯一的id
let newUID = uid + 1
state = {
...state,
maxUID: newUID
}
return [state, uid]
}
GameObjectManager的createGameObject函数创建了一个gameObject,它就是一个全局唯一的id
position_component/Manager
//创建一个positionComponent
//一个positionComponent就是一个index
export let createComponent = (state: state): [state, component] => {
let index = state.maxIndex
//生成一个index
let newIndex = index + 1
state = {
...state,
maxIndex: newIndex
}
return [state, index]
}
PositionComponentManager的createComponent函数创建了一个positionComponent,它就是一个PositionComponentManager的state的buffer上的索引
VelocityComponentManager、FlyComponentManager的相关代码跟PositionComponentManager类似,故省略相关代码
instance_component/Manager
//创建一个instanceComponent
//一个instanceComponent就是一个uid
export let createComponent = (state: state): [state, component] => {
let uid = state.maxUID
//生成一个id
let newUID = uid + 1
state = {
...state,
maxUID: newUID
}
return [state, uid]
}
InstanceComponentManager的createComponent函数创建了一个instanceComponent,因为InstanceComponent组件属于“其它组件”,所以它跟GameObject一样都是一个全局唯一的id而不是一个index
MoveSystem
//一个gameObject的移动
export let move = (worldState: worldState, positionComponent, velocityComponent): worldState => {
let [x, y, z] = PositionComponentManager.getPosition(worldState.positionComponentManagerState, positionComponent)
let velocity = VelocityComponentManager.getVelocity(worldState.velocityComponentManagerState, velocityComponent)
//根据velocity,更新positionComponent的position
let positionComponentManagerState = PositionComponentManager.setPosition(worldState.positionComponentManagerState, positionComponent, [x + velocity, y + velocity, z + velocity])
return {
...worldState,
positionComponentManagerState: positionComponentManagerState
}
}
MoveSystem的move函数实现移动的行为逻辑。这里涉及到读写Data Oriented组件的ArrayBuffer上的数据。我们来看下读写PositionComponentManager的positions的相关代码:
position_component/Manager
export let getPosition = (state: state, component: component) => {
return OperateTypeArrayUtils.getPosition(component, state.positions)
}
export let setPosition = (state: state, component: component, position) => {
OperateTypeArrayUtils.setPosition(component, position, state.positions)
return state
}
position_component/OperateTypeArrayUtils
export let getPosition = (index, typeArr) => {
return TypeArrayUtils.getFloat3Tuple(BufferUtils.getPositionIndex(index), typeArr)
}
export let setPosition = (index, data, typeArr) => {
TypeArrayUtils.setFloat3(BufferUtils.getPositionIndex(index), data, typeArr)
}
TypeArrayUtils
export let getFloat3Tuple = (index, typeArray) => {
return [
typeArray[index],
typeArray[index + 1],
typeArray[index + 2]
]
}
export let setFloat3 = (index, param, typeArray) => {
typeArray[index] = param[0]
typeArray[index + 1] = param[1]
typeArray[index + 2] = param[2]
}
position_component/BufferUtils
let _getPositionSize = () => 3
...
export let getPositionIndex = index => index * _getPositionSize()
通过代码可知,实现“读写PositionComponentManager的ArrayBuffer上的数据”的思路是:
因为一个positionComponent的值是ArrayBuffer的索引,所以使用它来读写ArrayBuffer的视图positions中的对应数据
FlySystem
//一个gameObject的飞行
export let fly = (worldState: worldState, positionComponent, velocityComponent, flyComponent): worldState => {
let [x, y, z] = PositionComponentManager.getPosition(worldState.positionComponentManagerState, positionComponent)
let velocity = VelocityComponentManager.getVelocity(worldState.velocityComponentManagerState, velocityComponent)
let maxVelocity = FlyComponentManager.getMaxVelocity(worldState.flyComponentManagerState, flyComponent)
//根据maxVelociy、velocity,更新positionComponent的position
velocity = velocity < maxVelocity ? (velocity * 2.0) : maxVelocity
let positionComponentManagerState = PositionComponentManager.setPosition(worldState.positionComponentManagerState, positionComponent, [x + velocity, y + velocity, z + velocity])
return {
...worldState,
positionComponentManagerState: positionComponentManagerState
}
}
FlySystem的fly函数实现了飞行的行为逻辑
初始化和主循环的逻辑跟之前一样,故省略代码
World
export let update = UpdateSystem.update
UpdateSystem
export let update = (worldState: worldState): worldState => {
let positionComponentManagerState = PositionComponentManager.batchUpdate(worldState.positionComponentManagerState)
return {
...worldState,
positionComponentManagerState: positionComponentManagerState
}
}
UpdateSystem的update函数实现了更新,它调用了PositionComponentManager的batchUpdate函数来批量更新所有的positionComponent组件。我们看下PositionComponentManager的相关代码:
position_component/Manager
export let getAllComponents = (state: state): Array<component> => {
从state中获得所有的positionComponents
}
export let batchUpdate = (state: state) => {
return getAllComponents(state).reduce((state, component) => {
更新position
}, state)
}
batchUpdate函数遍历所有的positionComponent,更新它们的position。更新的逻辑跟之前一样
World
export let renderOneByOne = RenderOneByOneSystem.render
RenderOneByOneSystem
export let render = (worldState: worldState): void => {
let superHeroGameObjects = GameObjectManager.getAllGameObjects(worldState.gameObjectManagerState).filter(gameObject => {
//判断gameObject是不是没有挂载InstanceComponent
return !InstanceComponentManager.hasComponent(worldState.instanceComponentManagerState, gameObject)
})
superHeroGameObjects.forEach(gameObjectState => {
console.log("OneByOne渲染 SuperHero...")
})
}
gameObject/Manager
export let getAllGameObjects = (state: state): Array<gameObject> => {
let { maxUID } = state
//返回[0, 1, ..., maxUID-1]的数组
return range(0, maxUID - 1)
}
RenderOneByOneSystem的render函数实现了超级英雄的渲染,它首先得到了所有没有挂载InstanceComponent组件的gameObject;最后一个一个地渲染
World
export let renderInstances = RenderInstancesSystem.render
RenderInstancesSystem
export let render = (worldState: worldState): void => {
let normalHeroGameObejcts = GameObjectManager.getAllGameObjects(worldState.gameObjectManagerState).filter(gameObject => {
//判断gameObject是不是挂载了InstanceComponent
return InstanceComponentManager.hasComponent(worldState.instanceComponentManagerState, gameObject)
})
console.log("批量Instance渲染 NormalHeroes...")
}
RenderInstancesSystem的render函数实现了普通英雄的渲染,它首先得到了所有挂载了InstanceComponent组件的gameObject;最后一次性批量渲染
下面,我们运行Client的代码,打印的结果如下:
初始化...
更新PositionComponent: 0
更新PositionComponent: 1
更新PositionComponent: 2
更新PositionComponent: 3
OneByOne渲染 SuperHero...
OneByOne渲染 SuperHero...
批量Instance渲染 NormalHeroes...
{"gameObjectManagerState":{"maxUID":4},"positionComponentManagerState":{"maxIndex":4,"buffer":{},"positions":{"0":2,"1":2,"2":2,"3":0,"4":0,"5":0,"6":6,"7":6,"8":6,"9":0,"10":0,"11":0,"12":0,"13":0,"14":0,"15":0,"16":0,"17":0,"18":0,"19":0,"20":0,"21":0,"22":0,"23":0,"24":0,"25":0,"26":0,"27":0,"28":0,"29":0},"gameObjectMap":{"0":0,"1":1,"2":2,"3":3},"gameObjectPositionMap":{"0":0,"1":1,"2":2,"3":3}},"velocityComponentManagerState":{"maxIndex":4,"buffer":{},"velocitys":{"0":1,"1":1,"2":1,"3":1,"4":1,"5":1,"6":1,"7":1,"8":1,"9":1},"gameObjectMap":{"0":0,"1":1,"2":2,"3":3},"gameObjectVelocityMap":{"0":0,"1":1,"2":2,"3":3}},"flyComponentManagerState":{"maxIndex":1,"buffer":{},"maxVelocitys":{"0":10,"1":10,"2":10,"3":10,"4":10,"5":10,"6":10,"7":10,"8":10,"9":10},"gameObjectMap":{"0":2,"1":3},"gameObjectFlyMap":{"2":0,"3":1}},"instanceComponentManagerState":{"maxUID":1,"gameObjectMap":{"0":0,"1":1},"gameObjectInstanceMap":{"0":0,"1":1}}}
通过打印的数据,可以看到运行的步骤与之前一样
不同之处在于:
我们看下打印的WorldState:
组合代替继承;连续地保存组件数据;分离逻辑和数据
“组合代替继承”是指基于组件化思想,通过GameObject组合不同的组件代替GameObject层层继承
“连续地保存组件数据”是指基于Data Oriented思想,将Data Oriented组件的组件数据集中起来,保存在内存中连续的地址空间
“分离逻辑和数据”是指将GameObject和组件扁平化,将它们的数据放到Manager层,将它们的逻辑放到System层和Manager层。其中,只操作自己数据的逻辑(如getPosition、setPosition)被移到了Manager中,其它逻辑(通常为行为逻辑,需要操作多种组件)被移到了System中
领域模型
总体来看,领域模型分为用户、World、System层、Manager层、Component+GameObject层五个部分,它们的依赖关系是前者依赖后者。其中,System层负责实现行为的逻辑;Manager层负责管理场景数据,即管理GameObject和组件的数据;Component+GameObject层为组件和GameObject,它们现在只是有一个number类型的数据的值对象
我们看下用户这个部分:
我们看下World这个部分:
我们看下System这一层:
一个System实现一个行为的逻辑
CreateStateSystem
该角色负责创建WorldState
OtherSystem
该角色是除了CreateStateSystem以外的所有System,它们各自有一个函数action,用于实现某个行为
我们看下Manager这一层:
每个Manager都有一个state数据
GameObjectManager
该角色负责管理所有的gameObject
DataOrientedComponentManager
该角色是一种Data Oriented组件的Manager,负责维护和管理该种组件的所有组件数据,将其集中地保存在各自的state的buffer中
DataOrientedComponentManager的state数据包括一个buffer字段和多个“typeArray of buffer”字段,其中前者是一个ArrayBuffer,保存了该种组件的所有组件数据;后者是buffer的多个视图,用于读写buffer中对应的数据
OtherComponentManager
该角色是一种其它组件的Manager,负责维护和管理该种组件的所有组件数据
OtherComponentManager的state数据包括多个“value map”字段,它们是多个Hash Map,每个Hash Map保存一类组件数据
我们看下Component+GameObject这一层:
DataOrientedComponent
该角色是一种Data Oriented组件中的一个组件,它是一个ArrayBuffer上的索引
OtherComponent
该角色是一种其它组件中的一个组件,它是一个全局唯一的id
GameObject
该角色是一个gameObject,它是一个全局唯一的id
角色之间的关系
只有一个CreateStateSystem
可以有多个OtherSystem,每个OtherSystem实现一个行为
只有一个GameObjectManager
可以有多个DataOrientedComponentManager,每个对应一种Data Oriented组件
可以有多个DataOrientedComponent,每个对应一种Data Oriented组件
可以有多个OtherComponentManager,每个对应一种其它组件
System层:
Manager层:
因为DataOrientedComponentManager和DataOrientedComponent对应同一种Data Oriented组件,且前者管理所有的后者,所以前者和后者是一对多的依赖关系
因为OtherComponentManager和OtherComponent对应同一种其它组件,且前者管理所有的后者,所以前者和后者是一对多的依赖关系
Component+GameObject层:
下面我们来看看各个角色的抽象代码:
我们按照依赖关系,从上往下依次看下领域模型中用户、World、System层、Manager层、Component+GameObject层这五个部分的抽象代码:
首先,我们看下属于用户的抽象代码
然后,我们看下World的抽象代码
然后,我们看下System层的抽象代码,它们包括:
然后,我们看下Manager层的抽象代码,它们包括:
最后,我们看下Component+GameObject层的抽象代码,它们包括:
Client
let _createScene = (worldState: worldState): worldState => {
创建gameObject1
创建组件
挂载组件
触发gameObject1的行为
创建更多的gameObjects...
return worldState
}
let worldState = World.createState({ dataOrientedComponent1Count: xx })
worldState = _createScene(worldState)
worldState = World.init(worldState)
World.loop(worldState)
World
export let createState = CreateStateSystem.createState
export let action1 = OtherSystem1.action
export let init = (worldState: worldState): worldState => {
初始化...
return worldState
}
//假实现
let requestAnimationFrame = (func) => {
}
export let loop = (worldState: worldState) => {
调用OtherSystem来更新
调用OtherSystem来渲染
requestAnimationFrame(
(time) => {
loop(worldState)
}
)
}
CreateStateSystem
export let createState = ({ dataOrientedComponent1Count }): worldState => {
return {
gameObjectManagerState: GameObjectManager.createState(),
dataOrientedComponent1ManagerState: DataOrientedComponent1Manager.createState(dataOrientedComponent1Count),
otherComponent1ManagerState: OtherComponent1Manager.createState(),
创建更多的DataOrientedManagerState和OtherComponentManagerState...
}
}
OtherSystem1
export let action = (worldState: worldState, gameObject?: gameObject, dataOrientedComponentX?: dataOrientedComponentX, otherComponentX?: otherComponentX) => {
行为的逻辑...
return worldState
}
有多个OtherSystem,这里只给出一个OtherSystem的抽象代码
gameObject/ManagerStateType
export type state = {
maxUID: number
}
gameObject/Manager
export let createState = (): state => {
return {
maxUID: 0
}
}
export let createGameObject = (state: state): [state, gameObject] => {
let uid = state.maxUID
let newUID = uid + 1
state = {
...state,
maxUID: newUID
}
return [state, uid]
}
export let getAllGameObjects = (state: state): Array<gameObject> => {
let { maxUID } = state
return range(0, maxUID - 1)
}
dataoriented_component1/ManagerStateType
export type TypeArrayType = Float32Array | Uint8Array | Uint16Array | Uint32Array
export type state = {
maxIndex: number,
//buffer保存了该种组件所有的value1、value2、...、valueX数据
buffer: ArrayBuffer,
//该种组件所有的value1数据的视图
value1s: TypeArrayType,
//该种组件所有的value2数据的视图
value2s: TypeArrayType,
更多valueXs...,
...
}
dataoriented_component1/Manager
let _setAllTypeArrDataToDefault = ([value1s, value2s]: Array<Float32Array>, count, [defaultValue1, defaultValue2]) => {
range(0, count - 1).forEach(index => {
OperateTypeArrayUtils.setValue1(index, defaultValue1, value1s)
OperateTypeArrayUtils.setValue2(index, defaultValue2, value2s)
})
return [value1s, value2s]
}
let _initBufferData = (count, defaultDataTuple): [ArrayBuffer, Array<TypeArrayType>] => {
let buffer = BufferUtils.createBuffer(count)
let typeArrData = _setAllTypeArrDataToDefault(CreateTypeArrayUtils.createTypeArrays(buffer, count), count, defaultDataTuple)
return [buffer, typeArrData]
}
export let createState = (dataorientedComponentCount: number): state => {
let defaultValue1 = default value1
let defaultValue2 = default value2
let [buffer, [value1s, value2s]] = _initBufferData(dataorientedComponentCount, [defaultValue1, defaultValue2])
return {
maxIndex: 0,
buffer,
value1s,
value2s,
...
}
}
export let createComponent = (state: state): [state, component] => {
let index = state.maxIndex
let newIndex = index + 1
state = {
...state,
maxIndex: newIndex
}
return [state, index]
}
...
export let getAllComponents = (state: state): Array<component> => {
从state中获得所有的dataorientedComponent1s
}
export let getValue1 = (state: state, component: component) => {
return OperateTypeArrayUtils.getValue1(component, state.value1s)
}
export let setValue1 = (state: state, component: component, position) => {
OperateTypeArrayUtils.setValue1(component, position, state.value1s)
return state
}
get/set value2...
export let batchOperate = (state: state) => {
let allComponents = getAllComponents(state)
console.log("批量操作")
return state
}
dataoriented_component1/BufferUtils
// 这里只给出了两个value的情况
// 更多的value也以此类推...
let _getValue1Size = () => value1 size
let _getValue2Size = () => value2 size
export let getValue1Offset = () => 0
export let getValue2Offset = (count) => getValue1Offset() + getValue1Length(count) * TypeArray2.BYTES_PER_ELEMENT
export let getValue1Length = (count) => count * _getValue1Size()
export let getValue2Length = (count) => count * _getValue2Size()
export let getValue1Index = index => index * _getValue1Size()
export let getValue2Index = index => index * _getValue2Size()
let _getTotalByteLength = (count) => {
return count * (TypeArray1.BYTES_PER_ELEMENT * (_getValue1Size() + TypeArray2.BYTES_PER_ELEMENT * (_getValue2Size())))
}
export let createBuffer = (count) => {
return new ArrayBuffer(_getTotalByteLength(count))
}
dataoriented_component1/CreateTypeArrayUtils
export let createTypeArrays = (buffer, count) => {
return [
new Float32Array(buffer, BufferUtils.getValue1Offset(), BufferUtils.getValue1Length(count)),
new Float32Array(buffer, BufferUtils.getValue2Offset(count), BufferUtils.getValue2Length(count)),
]
}
有多个DataOrientedComponentManager,这里只给出一个DataOrientedComponentManager的抽象代码
other_component1/ManagerStateType
export type state = {
maxUID: number,
//value1Map用来保存该种组件所有的value1数据
value1Map: Map<component, value1 type>,
更多valueXMap...,
...
}
other_component1/Manager
export let createState = (): state => {
return {
maxUID: 0,
value1Map: Map(),
...
}
}
export let createComponent = (state: state): [state, component] => {
let uid = state.maxUID
let newUID = uid + 1
state = {
...state,
maxUID: newUID
}
return [state, uid]
}
...
export let getAllComponents = (state: state): Array<component> => {
从state中获得所有的otherComponent1s
}
export let getValue1 = (state: state, component: component) => {
return getExnFromStrictUndefined(state.value1Map.get(component))
}
export let setValue1 = (state: state, component: component, value1) => {
return {
...state,
value1Map: state.value1Map.set(component, value1)
}
}
export let batchOperate = (state: state) => {
let allComponents = getAllComponents(state)
console.log("批量操作")
return state
}
有多个OtherComponentManager,这里只给出一个OtherComponentManager的抽象代码
GameObjectType
type id = number
export type gameObject = id
DataOrientedComponent1Type
type index = number
export type component = index
有多个DataOrientedComponent,这里只给出一个DataOrientedComponent的抽象代码
OtherComponent1Type
type id = number
export type component = id
有多个OtherComponent,这里只给出一个OtherComponent的抽象代码
ECS模式主要遵循下面的设计原则:
组件的数据集中连续地保存在ArrayBuffer中,增加了缓存命中,提高了读写的性能
创建和删除组件的性能也很好,因为在这个过程中不会分配或者销毁内存,所以没有垃圾回收的开销
这是因为在创建ArrayBuffer时就预先按照最大组件个数分配了一块连续的内存,所以在创建组件时,只是返回一个当前最大索引(maxIndex)的值而已;在删除组件时,只是将ArrayBuffer中该组件对应的数据还原为默认值而已
职责划分明确,行为的逻辑应该放在哪里很清楚
对于只涉及到操作一种组件的行为逻辑,则将其放在该组件对应的Manager(如将batchUpdate position的逻辑放到PositionComponentManager的batchUpdate函数中);涉及到多种组件的行为逻辑则放在对应的System中(如将飞行行为放到FlySystem中);
增加行为很容易
因为一个行为对应一个System,所以要增加一个行为,则只需增加一个对应的System即可,这不会影响到Manager。另外,因为System只有逻辑没有数据,所以增加和维护System很容易
游戏的场景中有很多种类的人物,人物的行为很多或者很复杂
有很多种类的游戏人物
通过挂载不同的组件到GameObject,来实现不同种类的游戏人物,代替继承
游戏人物有很多的行为,而且还经常会增加新的行为
因为每个行为对应一个System,所以增加一个新的行为就是增加一个System。不管行为如何变化,只影响System层,不会影响作为下层的Manager层和GameObject、Component层
对于引擎而言,ECS模式主要用在场景管理这块
如果引擎开了多个线程,那么可以将组件的ArrayBuffer改为SharedArrayBuffer。这样的话就可以将其直接共享到各个线程中而不需要拷贝,从而提高了性能
如果引擎使用了管道模式,那么可以去掉System,而使用管道的Job来代替。其中一个Job就是一个System
另外,可以去掉WorldState,而使用PipelineManagerState来代替
如果游戏的人物种类很少,行为简单,那么就可以使用最开始给出的人物模块的方案,即使用一个人物模块对应一种人物,并通过继承实现多种人物,这样最容易实现
ECS的概念最先是由“守望先锋”游戏的开发者提出的,详细资料可以在网上搜索“《守望先锋》架构设计和网络同步”
ECS模式是在“组件化”、“Data Oriented”基础上发展而来,可以在网上搜索更多关于“组件化”、“Data Oriented”、“ECS”的资料
欢迎阅读本系列文章!我将带你一起探索如何利用OpenAI API开发GPT应用。无论你是编程新手还是资深开发者,都能在这里获得灵感和收获。
本文,我们将继续展示聊天API中插件的使用方法,让你能够轻松驾驭这个强大的工具。
首先给大家展示下插件的运行效果,如下图所示:
可以看到,每次询问GPT,它都会返回指定城市的实时天气信息,这个天气是真实的,不是GPT瞎编的,是GPT通过一个实时天气插件查询到的。
知己知彼,百战不殆!首先让我们来了解下插件的运行原理。如下图所示:
首先我们在客户端发起一个聊天会话,比如询问GPT:“今天天气怎么样?”
为了使用我们自己的插件,我们还需要告诉GPT有哪些插件可用,目前这需要我们在发起聊天时传递一个支持的插件列表给GPT。
然后GPT收到我们的聊天后,它会根据用户的聊天内容去匹配插件,并在返回的消息中指示命中了哪些插件,这个匹配是根据GPT的语言理解能力做出的。
然后客户端就可以检查命中了哪些插件,并调用执行本地相应的插件方法。插件方法是在本地执行的,这也比较合理,如果放到GPT服务端,GPT不仅要适配各种计算能力,还可能面临巨大的安全风险。
然后客户端将插件的执行结果附加到本次聊天会话中,再次发起聊天请求,GPT收到后,会根据首次聊天请求和插件生成的内容组织本次聊天响应结果,再返回给用户。
这样就完成了一次基于插件的GPT会话。
基于上面的运行原理,我们来编写一个GPT插件的示例程序。
在这个示例程序中,我将提供一个天气查询的插件,当用户询问GPT今天的天气时,GPT就会命中这个插件,然后插件会调用外部API获取实时的天气情况,最后GPT会使用插件生成的结果组织一段文字回复返回给用户。
这里我们将使用“心知天气”提供的免费天气查询服务,大家感兴趣的可以去这里注册个账号:
https://www.seniverse.com/
,注册成功后,需要复制账号的私钥,调用天气接口时会用到。
然后我们就可以编写天气查询插件了,这里直接给出我的代码:
def get_city_weather(param):
city = json.loads(param)["city"]
params = {
"key": "这里换成你的天气产品私钥",
"location": city,
"language": "zh-Hans",
"unit": "c",
}
url = "https://api.seniverse.com/v3/weather/now.json"
r = requests.get(url, params=params)
data = r.json()["results"]
address = data[0]["location"]['path']
temperature = data[0]['now']["temperature"]
text = data[0]['now']["text"]
return address+",当前天气:"+text+",温度:"+temperature+ "℃"
可以看到就是一个Python函数,接收json格式的参数,返回天气描述信息。
注意这里的参数格式(包括有哪些参数)是和GPT大模型匹配过的,下文会讲到怎么定义参数。
接口的主要逻辑就是使用城市名称,调用实时天气接口获取天气信息,然后再拼接成一段话并返回。
我这里只使用了天气的部分指标,详细指标大家可以看接口文档:
话不多说,看代码:
client = OpenAI(api_key='sk-xxx')
# 聊天消息上下文
messages=[{
"role": "user",
"content": "请问现在天气怎么样?",
}]
# 天气插件
weather_tool = {
"type": "function",
"function": {
"name": "get_city_weather",
"description": "获取某个城市的天气",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "城市名称",
},
},
"required": ["city"],
},
}
}
# 发起聊天请求
response = client.chat.completions.create(
messages=messages,
model='gpt-3.5-turbo-1106',
stream=False,
# 插件相关
tool_choice="auto",
tools=[weather_tool]
)
在上面这段代码中,我们首先声明了一个OpenAI客户端,没有API Key的同学可以看文章最后。
然后我们创建了一个很普通的聊天会话,就是以普通用户的身份询问GPT今天的天气情况。
然后我们定义了一个天气插件,其实就是一个Json对象。
最后我们向GPT发起本次聊天请求,其中增加了关于插件的两个参数:
如果GPT大模型命中了插件,它会在返回值中携带一些信息。根据这些信息,我们可以知道要调用哪个插件的函数,然后再把函数的执行结果附加到消息上下文中,再请求GPT大模型,GPT大模型会使用函数返回值组织文本内容,最终返回给用户。
相关代码如下:
response_message = response.choices[0].message
if response_message.tool_calls is not None:
tool_call = response_message.tool_calls[0]
messages.append(response_message)
messages.append({
"role": "tool",
"content": get_city_weather(tool_call.function.arguments),
"tool_call_id": tool_call.id
})
response = client.chat.completions.create(
messages=messages,
model='gpt-3.5-turbo-1106',
stream=False,
tool_choice="auto",
tools=[weather_tool]
)
print(response_message.choices[0].message.content)
判断是否命中插件使用的是 response_message.tool_calls is not None,也就是返回值中的 tool_calls 不为空,因为这里只有一个插件,所以我们没有做进一步的判断;如果有多个插件,可以遍历tool_calls,根据插件关联函数的 name,选择执行不同的方法。
注意这里我们把本次响应的消息又追加到了上下文中:messages.append(response_message)。
然后我们又追加了插件生成的消息,就是下面这段:
messages.append({
"role": "tool",
"content": get_city_weather(tool_call.function.arguments),
"tool_call_id": tool_call.id
})
介绍下这几个字段:
然后我们又通过 client.chat.completions.create 向GPT大模型发起请求 ,并拿到最终的返回结果。
因为上文中两次请求GPT大模型的方法都是一样的,所以我们这里把它抽象为一个方法。
另外为了充分展现插件的使用方法,这里会向GPT询问三个城市的天气信息,通过循环发起。
from openai import OpenAI
import json
import requests
import time
# 获取天气的方法
def get_city_weather(param):
city = json.loads(param)["city"]
params = {
"key": "这里换成你的天气产品私钥",
"location": city,
"language": "zh-Hans",
"unit": "c",
}
url = "https://api.seniverse.com/v3/weather/now.json"
r = requests.get(url, params=params)
data = r.json()["results"]
#print(json.dumps(data))
address = data[0]["location"]['path']
temperature = data[0]['now']["temperature"]
text = data[0]['now']["text"]
return address+",当前天气:"+text+",温度:"+temperature+ "℃"
# 天气插件的定义
weather_tool = {
"type": "function",
"function": {
"name": "get_city_weather",
"description": "获取某个城市的天气",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "城市名称",
},
},
"required": ["city"],
},
}
}
# 创建OpenAI客户端,获取API Key请看文章最后
client = OpenAI(api_key='sk-xxx')
# 定义请求GPT的通用方法
def create_completion():
return client.chat.completions.create(
messages=messages,
model='gpt-3.5-turbo-1106',
stream=False,
tool_choice="auto",
tools=[weather_tool]
)
# 我的三个问题
questions = ["请问现在天气怎么样?","请问上海天气怎么样?","请问广州天气怎么样?"]
# 聊天上下文,初始为空
messages=[]
print("---GPT天气插件演示--- ")
# 遍历询问我的问题
for question in questions:
# 将问题添加到上下文中
messages.append({
"role": "user",
"content": question,
})
print("路人甲: ",question)
# 请求GPT,并拿到响应
response_message = create_completion().choices[0].message
# 把响应添加到聊天上下文中
messages.append(response_message)
#print(response_message)
# 根据插件命中情况,执行插件逻辑
if response_message.tool_calls is not None:
tool_call = response_message.tool_calls[0]
#print("tool_call: ",tool_call.id)
# 追加插件生成的天气内容到聊天上下文
weather_info = get_city_weather(tool_call.function.arguments)
#print(weather_info)
messages.append({
"role": "tool",
"content": weather_info,
"tool_call_id": tool_call.id
})
# 再次发起聊天
second_chat_completion = create_completion()
gpt_output = second_chat_completion.choices[0].message.content
# 打印GPT合成的天气内容
print("GPT: ",gpt_output)
time.sleep(0.2)
# 将GPT的回答也追加到上下文中
messages.append({
"role": "assistant",
"content": gpt_output,
})
以上就是本文的主要内容,有没有感受到插件的强大能力!
后续我还会继续分享图片、语音、文档助手等API的使用方法。
如需GPT账号、学习陪伴群、AI编程训练营,推荐关注小册:
大模型应用开发 | API 实操
关注萤火架构,加速技术提升!