跳到主要内容

Room 桌游、房间类

提示
房间模块 + 领域事件 + 内置 Kit = 轻松搞定桌游类游戏

介绍

该模块是桌游类和房间类游戏的解决方案,比较适合桌游类、房间类的游戏基础搭建, 基于该模型可以做一些如,炉石传说、三国杀、斗地主、麻将 ...等类似的桌游。 或者说只要是房间类的游戏,该模型都适用,比如,CS、泡泡堂、飞行棋、坦克大战 ...等。

如果你计划做一些桌游类的游戏,那么推荐你基于该模块做扩展。 该模块遵循面向对象的设计原则,没有强耦合,可扩展性强。 且帮助开发者屏蔽了很多重复性的工作,并可为项目中的功能模块结构、开发流程等进行清晰的组织定义,减少了后续的项目维护成本。

整个房间模块的概念只有几个,通过这几个概念可以创建出无限可能的游戏

  • Room、Player : 房间与玩家。
  • RoomService : 房间的管理。
  • OperationHandler : 操作接口,也是核心接口之一,用于扩展业务功能。通过该接口,可以为你的游戏提供任意功能。

解决的问题

桌游、房间类的游戏在功能职责上可以分为 3 大类,分别是

  1. 房间管理相关的
    • 管理着所有的房间、查询房间列表、房间的添加、房间的删除、房间与玩家之间的关联、房间查找(通过 roomId 查找、通过 userId 查找)。
  2. 开始游戏流程相关的
    • 通常桌游、房间类的游戏都有一些固定的流程,如创建房间、玩家进入房间、玩家退出房间、解散房间、玩家准备、开始游戏 ...等。
    • 开始游戏时,需要做开始前的验证,如房间内的玩家是否符足够 ...等,当一切符合业务时,才是真正的开始游戏。
  3. 玩法操作相关的
    • 游戏开始后,由于不同游戏之间的具体操作是不相同的。如坦克的射击,炉石的战前选牌、出牌,麻将的吃、碰、杠、过、胡,回合制游戏的普攻、防御、技能 ...等。
    • 由于玩法操作的不同,所以我们的玩法操作需要是可扩展的,并用于处理具体的玩法操作。同时这种扩展方式更符合单一职责,使得我们后续的扩展与维护成本更低。

以上功能职责(房间管理相关、流程相关、玩法操作相关)属于相对通用的功能。 如果每款游戏都重复的做这些工作,除了枯燥之外,还将浪费巨大的人力成本。

而当前模块则能很好的帮助开发者屏蔽这些重复性的工作, 并可为项目中的功能模块结构、开发流程等进行清晰的组织定义,减少了后续的项目维护成本。 更重要的是有相关文档,将来当你的团队有新进成员时,可以快速的上手。

如何安装

由于房间模块是单独的、按需选择的功能模块,使用时需要在 pom.xml 中引入

see https://central.sonatype.com/artifact/com.iohao.game/light-game-room

pom.xml
<dependency>
<groupId>com.iohao.game</groupId>
<artifactId>light-game-room</artifactId>
<version>${ioGame.version}</version>
</dependency>

房间与玩家

桌游、房间类的游戏最基本的要素是房间与玩家。现在,我们大体介绍一下房间与玩家的概念。

概念介绍

房间与玩家的关系 : 房间是提供给玩家游玩的一个地方,而玩家则在房间内游玩、娱乐。


房间是一局游戏中的一个场地,起到范围管理与隔离的作用。 这就好比正式的足球、篮球比赛中的成员,大体是不能变换的。

房间的职责

  • 游戏规则:房间是规则的实行者,游戏规则是通常根据游戏来确定。 比如篮球比赛是 5v5 ,那么就不能超出这个人数规则。
  • 调度通信:房间的信息变更,玩家的操作 ...等,需要由房间将信息的变化广播给房间内的其他玩家。

玩家是一局游戏中在房间内所扮演的角色,这取决于具体的游戏类型。 在房间内,玩家可以是坦克、飞机、人物、扑克手 ... 等。

在不同的游戏中,玩家在房间内所持有的资源也是不同的(资源指的是玩家所持有的子弹、牌、道具 ...等,需根据游戏来确定)。 而玩家如何操作这些资源,即玩法操作(如,射击子弹、出牌、使用道具 ...等)。


接口介绍

房间模块为开发者提供了 Room 和 Player 两个接口,分别表示房间和玩家。 为了让开发者更好的理解 Room 、Player 接口的使用,这里将举两个例子。

ioGame

假如现在我们需要做一款坦克类的游戏,考虑到将来可能会做多款不同玩法的坦克。 那么,我们可以建立一个坦克房间的父类,因为坦克类的游戏是有很多共通点的, 而具体的子游戏玩法则继承该通用父类。

同样的,玩家的扩展也是如此,如果有特殊需要时才考虑扩展玩家子类。 如图、坦克的玩家只使用通用的 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());
提示

因为我们的实现类符合单一职责,将来想变更业务逻辑时无需修改调用端的代码,只需将新的实现类在配置中替换即可。

另外一点是,这些实现类是可重用的。 比如你开发的子游戏中,只有几个玩法规则不同,那么我们只需要增加或减少配置类。

ioGame


枚举类

枚举类实现了 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 模块的扩展,只用数百行代码就完成了下述功能

  1. 登录
  2. 玩家进入大厅(地图)
  3. 玩家可在大厅移动
  4. 玩家移动时相互可见
  5. 玩家离开大厅(玩家下线)
  6. 查询房间列表
  7. 房间信息实时变更通知(房间内有玩家数量变化,等待中、游戏中 ...等状态)
  8. 玩家创建房间
  9. 玩家进入房间
  10. 玩家退出房间
  11. 解散房间
  12. 玩家准备
  13. 开始游戏
  14. 玩家在房间内的玩法操作
  15. 及各种验证,断言 + 异常机制 = 清晰简洁的代码

Room 模块很好的帮助开发者屏蔽这些重复性的工作, 并可为项目中的功能模块结构、开发流程等进行清晰的组织定义,减少了后续的项目维护成本。 更重要的是有相关文档,将来当你的团队有新进成员时,可以快速的上手。


部分截图展示

启动游戏后玩家会将加入大厅(类似地图),多名玩家相互可见并能在大厅内移动。

An

An

An