Skip to content

开发详解

可以通过在 backend/src/games 目录下添加新的游戏模块来扩展游戏内容。游戏界面组件位于 frontend/src/components/ 目录下。

命名规范

游戏文件名需按照如下规则命名:

  • 后端游戏逻辑文件:<GameName>.ts
  • 前端游戏组件文件:<GameName>Room.vue
  • 前端游戏小窗组件(可选):<GameName>Lite.vue
  • 前端游戏回放组件(可选):<GameName>Replay.vue
  • 右上角房间控制扩展按钮组件(可选):<GameName>RoomControls.vue
  • 创建房间表单字段扩展组件(可选):<GameName>Attrs.vue

后端开发

后端需暴露如下接口,并导出一个继承自 GameRoom 的类:

typescript
import { GameRoom, IGameCommand } from '.';

export const name = '游戏名称';
export const minSize = 2; // 最小玩家数
export const maxSize = 2; // 最大玩家数
export const description = `游戏描述`;
export const points = { // 可选,房间开局所需积分
  '我就玩玩': 1,
  '小博一下': 100,
  '大赢家': 1000,
  '梭哈!': 10000,
}
export const rates = { // 可选,房间奖励积分倍率
  '我就玩玩': 1,
  '双倍奖励': 2,
  '玩的就是心跳': 5,
}
// export const rewardDescription = '' // 可选,房间积分奖励说明,若自行实现积分奖励,需填写此字段
// export const extendPages = [ ... ] // 可选,游戏扩展页面配置,具体见 `游戏数据 > 扩展页面` 章节

export default class MyGameRoom extends GameRoom {
  // ... 实现游戏逻辑
  onStart() {
    // 游戏开始时调用
  }

  onCommand(message: IGameCommand) {
    // 处理玩家指令
    super.onCommand(message); // 处理通用指令
    // ...
  }
}

IMPORTANT

游戏结束一定要调用 this.room.end() 方法,否则房间状态没有变更,由此会导致玩家无法离开房间。

前端开发

前端游戏组件需接收如下 Props:

typescript
import { RoomPlayer, Room } from 'tiaoom/client';
import { GameCore } from '@/core/game';

const props = defineProps<{
  roomPlayer: RoomPlayer & { room: Room }
  game: GameCore
}>()

可以使用 GameView 组件搭建游戏界面:

vue
<template>
    <GameView :room-player="roomPlayer" :game="game" @command="onCommand">
    <!-- 左侧:游戏区域 -->
    <div class="flex-1 flex flex-col items-center justify-center">
      
    </div>

    <!-- 右侧:操作区域 -->
    <template #actions>
      <p>操作区域内容</p>
    </template>

    <!-- 游戏规则 -->
    <template #rules>
      <p>游戏规则描述</p>
    </template>
  </GameView>
</template>

数据持久化

GameRoom 会自动处理数据持久化。也可以调用 this.save() 方法,手动将当前类实例的属性保存到数据库。

在游戏恢复时(如服务器重启),系统会自动重新实例化 GameRoom 并将保存的数据赋值回实例属性。

如果某些属性不需要保存(例如临时的计算缓存),可以将其名称添加到 saveIgnoreProps 数组中。

typescript
export default class MyGame extends GameRoom {
  score = 0; // 会被保存
  tempData = 'xxx'; // 也会被保存

  constructor(room: Room) {
    super(room);
    this.saveIgnoreProps.push('tempData'); // tempData 不会被保存
  }
  
  // ...
}

在如下节点,系统会自动调用 this.save() 保存数据:

  • 游戏开始/结束后
  • 玩家准备/取消准备后
  • 玩家加入/离开房间后
  • 收到消息后
  • 服务重启前

积分奖励与成就保存

GameRoom 提供了积分奖励功能,可在游戏结束时调用 this.saveAchievements([winner]) 方法保存成就数据并执行积分奖励。

  • winner:获胜玩家对象数组,传入 null 则表示平局。

保存后会在玩家个人页面显示胜负记录。

也可以调用 this.saveScore(score) 方法保存玩家分数,个人页面将显示历史最高分数。此方法不会进行积分奖励,若需要奖励积分,需调用 this.saveAchievements 或自行实现积分奖励。

积分奖励规则:

  • 若房间配置了 points,则在游戏开始时扣除相应积分。
  • 若房间配置了 rates,则在游戏结束时根据获胜玩家的 points 乘以相应倍率奖励积分。
  • 若未配置 rates,则默认倍率为 1。
  • 若游戏平局,则不进行积分奖励。

自定义积分奖励

如果需要自定义积分奖励逻辑,重载 saveAchievements 方法。另外需要配置 rewardDescription 字段,说明自定义积分奖励的规则。

typescript
export const rewardDescription = '自定义积分奖励规则说明';
export default class MyGame extends GameRoom {
  // ...

  saveAchievements(winner: RoomPlayer[] | null = null): Promise<void> {
    // 自定义积分奖励逻辑
    // ...
  }
}

游戏回放

GameRoom 提供了游戏回放功能。可以通过重载 getData 方法,返回需要保存的游戏回放数据。每局结束后,调用 this.saveAchievementsthis.saveScore 时会自动保存回放数据。也可以调用 this.saveRecord 方法手动保存回放数据。三个方法仅可选择其一调用!

若要实现游戏回放,还需实现前端回放组件 <GameName>Replay.vue,并在组件 Prop 中接收 getData 返回的数据,属性名称与返回值的 Key 相同。

若要实时回放,可以在每个游戏行为记录中增加 time 字段,值取 new Date().getTime() - this.beginTime 获取当前行为距离游戏开始毫秒数。

typescript
export default class MyGame extends GameRoom {
  // ...
  getData() {
    return {
      moves: this.moves, // 假设 moves 是记录游戏行为的数组
    }
  }

  // ...
  this.moves.push({
    // ...行为数据
    time: new Date().getTime() - this.beginTime, // 行为时间
  })
}

也可以直接记录当前时间戳,前端回放组件的 Prop 可以通过 props.beginTime 获取游戏开始时间戳。

前端状态管理

useGameStore 提供游戏状态管理,方便在组件间共享游戏状态。

属性/方法类型描述
gameGameCore游戏核心实例,用于与服务器通信,继承自 Tiaoom
playerUser当前登录的用户信息
playersPlayer[]当前在线玩家列表
roomsRoom[]当前房间列表
gamesRecord<string, GameConfig>游戏配置信息
globalMessagesMessage[]全局聊天消息列表
roomPlayerRoomPlayer当前用户在房间中的玩家对象(含房间信息)
playerStatusstring当前用户的状态
initConfig()Promise<void>初始化加载游戏配置,游戏内无需调用
checkSession()Promise<boolean>检查用户登录会话状态,游戏内无需调用
initGame()GameCore初始化游戏连接,建立 WebSocket,游戏内无需调用
login(name)Promise<boolean>用户登录,游戏内无需调用
logout()Promise<boolean>用户登出,游戏内无需调用

使用示例

typescript
import { useGameStore } from '@/stores/game';

const gameStore = useGameStore();

// 获取当前用户信息
const myId = gameStore.player?.id;

// 获取当前房间信息
const currentRoom = gameStore.roomPlayer?.room;

// 判断是否为房主
const isOwner = gameStore.roomPlayer?.isCreator;

// 获取游戏核心实例
const game = gameStore.game;

前端游戏事件监听

有时候我们需要监听 game 对象的事件,以便处理一些自定义逻辑。可以通过如下方式监听:

typescript
function onCommand(msg: IGameCommand) {
  // 处理消息
}
function onRoomStart() {
  // 游戏开始
}
function onRoomEnd() {
  // 游戏结束
}
game.on('player.command', onCommand);
game.on('room.command', onCommand);
game.on('room.start', onRoomStart);
game.on('room.end', onRoomEnd);
// ...等等

当游戏组件卸载时,则需要调用 off 方法移除监听:

typescript
game.off('player.command', onCommand);
game.off('room.command', onCommand);
game.off('room.start', onRoomStart);
game.off('room.end', onRoomEnd);

为了方便开发,可以使用 useGameEvents 组合函数来自动管理事件监听:

typescript
import { useGameEvents } from '@/hook/useGameEvents';

useGameEvents(game, {
  'room.start': onRoomStart,
  'room.end': onRoomEnd,
  'player.command': onCommand,
  'room.command': onCommand,
});

游戏倒计时

GameRoom 内置了倒计时功能,可用于限制玩家操作时间。

创建与使用

使用 startTimer 方法启动倒计时,使用 stopTimer 方法停止倒计时。

typescript
// 启动名为 'turn' 的倒计时,时长 30 秒
// 会自动广播 { type: 'countdown', data: { name: 'turn', seconds: 30 } }
this.startTimer(() => {
  this.handleTurnTimeout();
}, 30 * 1000, 'turn');

// 停止倒计时
this.stopTimer('turn');

响应倒计时

后端调用 startTimer 时,会自动向房间广播 countdown 指令。前端需监听该指令并显示倒计时。

typescript
game.on('command', (msg) => {
  if (msg.type === 'countdown') {
    // 设置倒计时,有需要可根据 name 字段区分不同倒计时
    const { seconds, name } = msg.data;
    countdown.value = seconds;
    
    // 启动本地计时器递减
    if (timer) clearInterval(timer);
    timer = setInterval(() => {
      countdown.value--;
      if (countdown.value <= 0) clearInterval(timer);
    }, 1000);
  }
});

初始化与恢复

为了防止服务器重启导致倒计时丢失,需要在 init 方法中注册恢复回调。同时,在 getStatus 中返回剩余时间,以便玩家重连时同步。

typescript
init() {
  // 注册倒计时恢复回调(用于服务器重启后恢复)
  this.restoreTimer({
    turn: () => this.handleTurnTimeout(),
  });
  return super.init();
}

前端可以从 status 指令中获取结束时间来计算剩余时间:

typescript
game.on('command', (msg) => {
  if (msg.type === 'status') {
    countdown.value = msg.data.tickTimeEnd['turn'] 
      ? Math.max(0, Math.ceil((msg.data.tickTimeEnd['turn'] - Date.now()) / 1000)) 
      : 0;
    
    // 启动本地计时器递减
    if (timer) clearInterval(timer);
    timer = setInterval(() => {
      countdown.value--;
      if (countdown.value <= 0) clearInterval(timer);
    }, 1000);
  }
});

完整示例

后端代码

typescript
export default class MyGame extends GameRoom {
  // ...

  init() {
    // 注册倒计时恢复回调
    this.restoreTimer({
      turn: () => this.handleTurnTimeout(),
    });
    return super.init();
  }

  startTurn() {
    this.startTimer(() => {
      this.handleTurnTimeout();
    }, 30 * 1000, 'turn');
  }

  handleTurnTimeout() {
    this.room.emit('message', { content: '时间到!' });
  }

  stopTurn() {
    this.stopTimer('turn');
  }
}

前端代码

typescript
// useGame.ts
import { ref } from 'vue';

export function useGame(game: GameCore) {
  const countdown = ref(0);
  let timer: any = null;

  function startLocalTimer() {
    if (timer) clearInterval(timer);
    timer = setInterval(() => {
      countdown.value--;
      if (countdown.value <= 0) clearInterval(timer);
    }, 1000);
  }

  game.on('command', (msg) => {
    switch(msg.type) {
      case 'countdown':
        countdown.value = msg.data.seconds;
        startLocalTimer();
        break;
      case 'status':
        if (msg.data.tickEndTime['turn']) {
          countdown.value = msg.data.tickEndTime['turn'] 
            ? Math.max(0, Math.ceil((msg.data.tickEndTime['turn'] - Date.now()) / 1000)) 
            : 0;
          startLocalTimer();
        }
        break;
    }
  });

  return { countdown };
}

GameRoom 核心属性与方法

核心属性

  • room: Room 实例,用于操作房间和玩家。
  • messageHistory: 聊天消息历史。
  • achievements: 玩家成就数据。
  • publicCommands: 允许观众使用的指令列表,默认为 ['say', 'status']
  • saveIgnoreProps: 保存状态时忽略的属性名列表。
  • beginTime: 游戏开始时间戳(毫秒)。

核心方法

  • init(): 初始化游戏房间。可在此监听房间事件(如 join, player-offline)。需调用 super.init()
  • onStart(): [必须实现] 游戏开始时调用。
  • onCommand(message: IGameCommand): 处理玩家指令。建议调用 super.onCommand(message) 以处理通用指令(如聊天)。
  • onSay(message: IGameCommand): 处理玩家聊天消息。
  • onWatcherSay(message: IGameCommand): 处理观众在游戏过程中的聊天消息,默认为玩家不可见的临时消息。
  • getStatus(sender: IRoomPlayer): 获取当前游戏状态。用于玩家重连或获取最新状态。需调用 super.getStatus(sender) 并合并自定义状态。在玩家登录或进入房间时前端会通过 status 指令获取到此状态。
  • getData(): 获取游戏回放数据。需返回可序列化的对象,系统会在游戏结束时保存此数据。
  • say(content: string, sender?: IRoomPlayer): 广播聊天消息。
  • sayTo(content: string, receiver: RoomPlayer): 向指定玩家发送私聊消息。
  • command(type: string, data?: any): 向所有玩家发送游戏指令。
  • commandTo(type: string, data: any, receiver: RoomPlayer): 向指定玩家发送游戏指令。
  • virtualCommand(type: string, data: any, receiver: RoomPlayer): 模拟玩家发出房间指令,用于模拟触发玩家行为。
  • save(): 保存当前游戏状态。可以在游戏逻辑中调用此方法手动持久化数据。
  • saveAchievements(winner?: RoomPlayer[] | null, saveRecord: boolean = true): 保存成就数据(胜/负/平),不传则表示平局。若有胜负且配置了积分奖励,将会执行积分奖励。若只需执行积分奖励,无需保存游戏记录,可将 saveRecord 设为 false
  • saveScore(score: number): 保存玩家分数,若保存分数,则个人页面将不会显示胜负数据,而只会显示历史最高得分。
  • saveRecord(winners?: RoomPlayer[] | null | undefined, score?: number): 保存游戏记录,包括回放数据、玩家列表、胜者列表、分数等。
  • getMaxScore(player: RoomPlayer): 获取玩家历史最高分数。
  • startTimer(callback, ms, name): 启动倒计时。
  • stopTimer(name): 停止倒计时。
  • restoreTimer(timer): 恢复倒计时(用于服务器重启后恢复状态)。

前端通用组件

前端可使用如下封装组件实现通用功能:

这些组件无需手动引入,可直接通过组件名使用。