Room 桌游、房间类
介绍
该模块是桌游类和房间类游戏的解决方案,比较适合桌游类、房间类的游戏基础搭建, 基于该模型可以做一些如,炉石传说、三国杀、斗地主、麻将 ...等类似的桌游。 或者说只要是房间类的游戏,该模型都适用,比如,CS、泡泡堂、飞行棋、坦克大战 ...等。
如果你计划做一些桌游类的游戏,那么推荐你基于该模块做扩展。 该模块遵循面向对象的设计原则,没有强耦合,可扩展性强。 且帮助开发者屏蔽了很多重复性的工作,并可为项目中的功能模块结构、开发流程等进行清晰的组织定义,减少了后续的项目维护成本。
整个房间模块的概念只有几个,通过这几个概念可以创建出无限可能的游戏
- Room、Player : 房间与玩家。
- RoomService : 房间的管理。
- OperationHandler : 操作接口,也是核心接口之一,用于扩展业务功能。通过该接口,可以为你的游戏提供任意功能。
解决的问题
桌游、房间类的游戏在功能职责上可以分为 3 大类,分别是
- 房间管理相关的
- 管理着所有的房间、查询房间列表、房间的添加、房间的删除、房间与玩家之间的关联、房间查找(通过 roomId 查找、通过 userId 查找)。
- 开始游戏流程相关的
- 通常桌游、房间类的游戏都有一些固定的流程,如创建房间、玩家进入房间、玩家退出房间、解散房间、玩家准备、开始游戏 ...等。
- 开始游戏时,需要做开始前的验证,如房间内的玩家是否符足够 ...等,当一切符合业务时,才是真正的开始游戏。
- 玩法操作相关的
- 游戏开始后,由于不同游戏之间的具体操作是不相同的。如坦克的射击,炉石的战前选牌、出牌,麻将的吃、碰、杠、过、胡,回合制游戏的普攻、防御、技能 ...等。
- 由于玩法操作的不同,所以我们的玩法操作需要是可扩展的,并用于处理具体的玩法操作。同时这种扩展方式更符合单一职责,使得我们后续的扩展与维护成本更低。
以上功能职责(房间管理相关、流程相关、玩法操作相关)属于相对通用的功能。 如果每款游戏都重复的做这些工作,除了枯燥之外,还将浪费巨大的人力成本。
而当前模块则能很好的帮助开发者屏蔽这些重复性的工作, 并可为项目中的功能模块结构、开发流程等进行清晰的组织定义,减少了后续的项目维护成本。 更重要的是有相关文档,将来当你的团队有新进成员时,可以快速的上手。
如何安装
由于房间模块是单独的、按需选择的功能模块,使用时需要在 pom.xml 中引入
see https://central.sonatype.com/artifact/com.iohao.game/light-game-room
<dependency>
<groupId>com.iohao.game</groupId>
<artifactId>light-game-room</artifactId>
<version>${ioGame.version}</version>
</dependency>
房间与玩家
桌游、房间类的游戏最基本的要素是房间与玩家。现在,我们大体介绍一下房间与玩家的概念。
概念介绍
房间与玩家的关系 : 房间是提供给玩家游玩的一个地方,而玩家则在房间内游玩、娱乐。
房间是一局游戏中的一个场地,起到范围管理与隔离的作用。 这就好比正式的足球、篮球比赛中的成员,大体是不能变换的。
房间的职责
- 游戏规则:房间是规则的实行者,游戏规则是通常根据游戏来确定。 比如篮球比赛是 5v5 ,那么就不能超出这个人数规则。
- 调度通信:房间的信息变更,玩家的操作 ...等,需要由房间将信息的变化广播给房间内的其他玩家。
玩家是一局游戏中在房间内所扮演的角色,这取决于具体的游戏类型。 在房间内,玩家可以是坦克、飞机、人物、扑克手 ... 等。
在不同的游戏中,玩家在房间内所持有的资源也是不同的(资源指的是玩家所持有的子弹、牌、道具 ...等,需根据游戏来确定)。 而玩家如何操作这些资源,即玩法操作(如,射击子弹、出牌、使用道具 ...等)。
接口介绍
房间模块为开发者提供了 Room 和 Player 两个接口,分别表示房间和玩家。 为了让开发者更好的理解 Room 、Player 接口的使用,这里将举两个例子。
假如现在我们需要做一款坦克类的游戏,考虑到将来可能会做多款不同玩法的坦克。 那么,我们可以建立一个坦克房间的父类,因为坦克类的游戏是有很多共通点的, 而具体的子游戏玩法则继承该通用父类。
同样的,玩家的扩展也是如此,如果有特殊需要时才考虑扩展玩家子类。 如图、坦克的玩家只使用通用的 TankPlayer 玩家即可,只有当需求不能满足时,才需要考虑扩展。
图中还有一个麻将类的游戏,由于与其他游戏并没有共通点,所以我们直接实现 Room 接口即可,麻将玩家也是如此。
伪代码
现在我们实现一个自定义的房间和玩家,MyRoom、MyPlayer, 分别继承自 SimpleRoom、SimplePlayer。
是的,你没有看错,这么简单的几行代码就完成了房间与玩家的扩展。
public class MyRoom extends SimpleRoom {
}
public class MyPlayer extends SimplePlayer {
}
SimpleRoom、SimplePlayer 是框架内置的一个通用房间与通用玩家的实现类, 分别实现了 Room、Player 接口。 大部分情况下,开发者可以通过这种继承的方式来扩展。
房间管理
房间管理主要是管理房间及房间与玩家之间的关系。
框架提供了一个房间管理接口 RoomService,开发者可实现该接口。 接口提供了一些默认方法,如查询房间列表、添加房间、删除房间、房间与玩家之间的关联、房间查找。
如何扩展
自定义实现类 MyRoomService,并实现 RoomService 接口。 配合 lombok 我们只需要添加如下代码就能拥有房间管理相关的功能了。
@Getter
public final class MyRoomService implements RoomService {
final Map<Long, Room> roomMap = new NonBlockingHashMap<>();
final Map<Long, Long> userRoomMap = new NonBlockingHashMap<>();
}
如何使用
添加房间
RoomService roomService = new MyRoomService();
var room = new MyRoom();
roomService.addRoom(room);
删除房间
roomService.removeRoom(room);
房间与玩家关联
var player = new MyPlayer();
roomService.addPlayer(room, player);
从房间移除玩家,并移除与玩家的关联
roomService.removePlayer(room, userId);
查找房间
var room = roomService.getRoom(roomId);
var room = roomService.getRoomByUserId(userId);
房间列表
var roomList = roomService.listRoom();
更多便捷方法请阅读相关 javadoc。
创建房间
之前,我们的类实现了 RoomService 接口,就拥有了房间管理相关的功能了。 现在,我们实现 RoomCreator 接口, 让我们的类拥有创建房间的能力。
@Getter
public final class MyRoomService implements RoomService, RoomCreator {
final Map<Long, Room> roomMap = new NonBlockingHashMap<>();
final Map<Long, Long> userRoomMap = new NonBlockingHashMap<>();
@Override
public Room createRoom(RoomCreateContext createContext) {
var room = new MyRoom();
...
return room;
}
}
OperationHandler 操作接口
OperationHandler 接口是房间模块的核心接口之一。
开发者实现该接口,可以扩展任意功能,如进入房间、退出房间、解散房间、准备、开始游戏 ...等。 以及一些房间内玩法上的操作,如攻击、防御、使用道具 ...等。
游戏开始后,由于不同游戏之间的具体操作是不相同的。 如坦克的射击,炉石的战前选牌、出牌,麻将的吃、碰、杠、过、胡,回合制游戏的普攻、防御、技能 ...等。
由于玩法操作的不同,所以我们的玩法操作需要是可扩展的,并用于处理具体的玩法操作。 同时这种扩展方式更符合单一职责,使得我们后续的扩展与维护成本更低。
如何扩展
OperationHandler 接口提供了两个方法
- processVerify : 是验证方法,当返回值为 true 时,会进入 process 方法。
- process : 是我们编写业务逻辑的地方。
这么设计的好处是可以使开发者的代码更加的简洁,与减少心智上的负担。 我们可以在 processVerify 方法中使用断言 + 异常机制来简化我们的验证逻辑。
当通过了 processVerify 方法的校验后,将会执行 process 方法。 那么我们就可以在 process 方法编写真正的业务逻辑代码了,而不是那种业务代码与验证混合在一起的代码。
public final class MyOperationHandler implements OperationHandler {
@Override
public boolean processVerify(PlayerOperationContext context) {
// Your biz code
return true;
}
@Override
public void process(PlayerOperationContext context) {
// Your biz code
}
}
扩展示例
进入房间示例
- code 6,通过断言机制来验证房间空间,当房间空间不足时将错误返回给请求端。
- code 15~21,如果房间内有没找到玩家,就创建玩家并加入房间。
public final class EnterRoomOperationHandler implements OperationHandler {
...
@Override
public boolean processVerify(PlayerOperationContext context) {
MyRoom room = context.getRoom();
GameCode.roomSpaceSizeNotEnough.assertTrue(room.hasSeat());
return true;
}
@Override
public void process(PlayerOperationContext context) {
long userId = context.getUserId();
MyRoom room = context.getRoom();
room.ifPlayerNotExist(userId, () -> {
var player = new MyPlayer();
player.setUserId(userId);
player.setNickname(name.fullName());
roomService.addPlayer(room, player);
});
}
}
退出房间示例
- code 10,将玩家从房间中移除。
public final class QuitRoomOperationHandler implements OperationHandler {
...
@Override
public void process(PlayerOperationContext context) {
MyRoom room = context.getRoom();
long userId = context.getUserId();
log.info("QuitRoom : {}", userId);
roomService.removePlayer(room, userId);
}
}
这个示例中,我们没有重写 processVerify 方法,而是直接写业务逻辑。
因为 processVerify 方法不是必须实现的方法。
房间内的玩法操作 : 攻击示例
public final class AttackOperationHandler implements OperationHandler {
@Override
public void process(PlayerOperationContext context) {
long userId = context.getUserId();
log.info("userId : {} attack!", userId);
}
}
房间内的玩法操作 : 防御示例
public final class DefenseOperationHandler implements OperationHandler {
@Override
public void process(PlayerOperationContext context) {
long userId = context.getUserId();
log.info("userId : {} defense!", userId);
}
}
如何获取 OperationHandler 实现类
上面,我们扩展了一些 OperationHandler 实现类。 那么,我们如何获取这些实现类来处理具体的业务呢?
在获取 OperationHandler 实现类之前,我们先扩展我们之前编写的 MyRoomService, 让其实现 OperationService 接口。
通过 OperationService 接口, 我们可以获取 OperationHandler 相关实现类,关键代码如下。
@Getter
public final class MyRoomService implements RoomService,
RoomCreator, OperationService {
...
final OperationFactory operationFactory = OperationFactory.of();
}
配置 OperationHandler 实现类
我们可以使用枚举类来管理这些 OperationHandler 实现类
- code 5~6,配置了内部操作。
- code 9~10,配置了玩法操作。
...
var factory = roomService.getOperationFactory();
// ------ mapping internal operation ------
factory.mapping(InternalOperationEnum.enterRoom, new EnterRoomOperationHandler());
factory.mapping(InternalOperationEnum.quitRoom, new QuitRoomOperationHandler());
// ------ mapping user operation ------
factory.mappingUser(MyOperationEnum.attack, new AttackOperationHandler());
factory.mappingUser(MyOperationEnum.defense, new DefenseOperationHandler());
因为我们的实现类符合单一职责,将来想变更业务逻辑时无需修改调用端的代码,只需将新的实现类在配置中替换即可。
另外一点是,这些实现类是可重用的。 比如你开发的子游戏中,只有几个玩法规则不同,那么我们只需要增加或减少配置类。
枚举类
枚举类实现了 OperationCode 接口,表示操作码。
- code 10,操作码使用框架内置的递增值。
- code 23,操作码使用自定义值。
@Getter
public enum InternalOperationEnum implements OperationCode {
enterRoom,
quitRoom,
;
final int operationCode;
InternalOperationEnum() {
this.operationCode = OperationCode.getAndIncrementCode();
}
}
@Getter
public enum MyOperationEnum implements OperationCode {
attack(1001),
defense(1002),
;
final int operationCode;
MyOperationEnum(int operationCode) {
this.operationCode = operationCode;
}
}
mapping 与 mappingUser 的区别
- mapping : 主要是关联内部操作。
- mappingUser : 主要是关联玩家玩法操作的,玩家可以通过请求参数来获取的玩法操作。
下面的示例,我们根据请求参数来获取对应的玩法操作, 当没有找到对应的玩法操作实现类时,返回错误给请求端。
@ActionMethod(RoomCmd.operation)
public void operation(MyOperationCommandMessage command, FlowContext flowContext) {
var operationHandler = roomService.getUserOperationHandler(command.operation);
GameCode.illegalOperation.assertNonNull(operationHandler);
...
// execute operationHandler
}
@ProtobufClass
public final class MyOperationCommandMessage {
/** game operation */
public MyOperationEnum operation;
}
如何使用
由于我们实现了 OperationService 接口,那么可以在创建房间时将自己设置到房间对象中, 之后可以通过 room.operation 方法来获取对应的实现类了。
@Getter
public final class MyRoomService implements RoomService, OperationService, RoomCreator {
...
@Override
public MyRoom createRoom(RoomCreateContext createContext) {
var room = new MyRoom();
...
room.setOperationService(this);
return room;
}
}
使用示例
整合好后,我们可以通过
room.operation
来调用我们的实现类了。
@ActionMethod(RoomCmd.enterRoom)
public void enterRoom(long roomId, FlowContext flowContext) {
...
room.operation(InternalOperationEnum.enterRoom, flowContext);
}
@ActionMethod(RoomCmd.quitRoom)
public void quitRoom(FlowContext flowContext) {
...
room.operation(InternalOperationEnum.quitRoom, flowContext);
}
@ActionMethod(RoomCmd.operation)
public void operation(MyOperationCommandMessage command, FlowContext flowContext) {
var operationHandler = roomService.getUserOperationHandler(command.operation);
GameCode.illegalOperation.assertNonNull(operationHandler);
Room room = getRoomByUserId(userId);
room.operation(operationHandler, flowContext, command);
}
private Room getRoomByUserId(long userId) {
Room room = this.roomService.getRoomByUserId(userId);
GameCode.roomNotExist.assertNullThrows(room);
return room;
}
小结
通过 OperationHandler 接口,可以扩展任意业务功能。 同时这种扩展方式更符合单一职责,使得我们后续的扩展与维护成本更低。
我们推荐使用枚举来管理这些扩展类,并区分内部操作和外部的玩法操作。 外部玩法操作通常是由玩家的请求参数来决定业务的走向,比如攻击、防御、使用道具等。
房间内的广播
Room 接口提供了一些便捷的广播方法。
指定玩家广播
room.broadcastToUser(cmd, userId, bizData);
房间内的范围广播 - 1
将数据广播给该房间内的所有玩家。
room.broadcastRange(cmdInfo, message);
房间内的范围广播 - 2
- code 1,创建 RangeBroadcast,默认会添加上当前房间内的所有玩家。
- code 2,设置需要广播的数据。
- code 3,排除玩家,不给该玩家广播数据。
- code 4,执行广播
room.ofRangeBroadcast()
.setResponseMessage(cmdInfo, message)
.removeUserId(1)
.execute();
房间内的范围广播 - 3
- code 1,创建 RangeBroadcast,默认没有玩家。
- code 2,设置需要广播的数据。
- code 3,添加玩家,只有这些玩家能接收到广播的数据。
- code 4,执行广播
room.ofRangeBroadcast()
.setResponseMessage(cmdInfo, message)
.addUserId(1).addUserId(2)
.execute();
房间内的延时任务
房间提供了延时任务的便捷使用。
room.executeTask(() -> {
// Your code
});
room.executeDelayTask(() -> {
// Your code
}, 200);
延时任务需要结合领域事件来使用,以确保线程安全。
重写 Operation 方法
如果结合了领域事件,你还可以重写 Room.operation
方法,让其任务在领域事件中消费。
小结
我们介绍了房间与玩家的概念、房间的管理、功能的扩展以及房间内的广播。 通过这个模块,只需要少量的代码就能扩展任意游戏。
整个房间模块的概念是比较少的,只有这么几个
- Room、Player : 房间与玩家。
- RoomService : 房间的管理。
- OperationHandler : 操作接口,也是核心接口之一,用于扩展业务功能。 通过该接口,可以为你的游戏提供任意功能。
因为文档篇幅的原因,更多的功能介绍请阅读相关 javadoc。
Example Source Code
see https://github.com/iohao/ioGameExamples
path : SimpleExample/example-room
- MyRoomApplication
- MyRoomClient1
- MyRoomClient2
实战
我们提供了一个 Room 桌游类、房间类的实战, 多名玩家在房间里猜拳(石头、剪刀、布), 该实战结合了房间模块 + 领域事件 + 内置 Kit 的综合运用。 同时,该实战还演示了如何解决同一房间内多玩家的并发问题。
基于 Room 模块的扩展,只用数百行代码就完成了下述功能
- 登录
- 玩家进入大厅(地图)
- 玩家可在大厅移动
- 玩家移动时相互可见
- 玩家离开大厅(玩家下线)
- 查询房间列表
- 房间信息实时变更通知(房间内有玩家数量变化,等待中、游戏中 ...等状态)
- 玩家创建房间
- 玩家进入房间
- 玩家退出房间
- 解散房间
- 玩家准备
- 开始游戏
- 玩家在房间内的玩法操作
- 及各种验证,断言 + 异常机制 = 清晰简洁的代码
Room 模块很好的帮助开发者屏蔽这些重复性的工作, 并可为项目中的功能模块结构、开发流程等进行清晰的组织定义,减少了后续的项目维护成本。 更重要的是有相关文档,将来当你的团队有新进成员时,可以快速的上手。
部分截图展示
启动游戏后玩家会将加入大厅(类似地图),多名玩家相互可见并能在大厅内移动。