简介 · Introduction

Go-MC 是一个用 Go 语言编写的用于与 Minecraft 交互的库。最初从网络库开始,用纯 Go 实现了 MC 基于 TCP 的网络通信协议。 并提供了一个 bot 包以方便编写命令行MC机器人程序。

Go-MC 也提供了用于编写服务端的框架 server,并且有一个独立的仓库作为一个服务端实现。服务端的开发暂不包含在本教程中。

在使用 bot 包的过程中,随着发送消息、玩家移动、解析区块等等新功能实现的需要, 逐步对游戏使用的基础数据结构如 基于JSON的聊天消息, NBT 二进制命名标签, 区域储存格式 .mca 文件 等编写了 Go 版本的实现。

  • net包实现MC的网络传输协议及VarInt等数据结构。
  • chat包实现基于 JSON 的聊天消息格式的编解码。
  • level包实现表示区块的数据结构、BitStorage、调色板等等。
  • level/block包提供了方块表示数据结构及方块状态表。
  • nbt包实现基于反射的二进制命名标签格式编解码,提供与标准库json类似的API。
  • offline包提供离线模式下将玩家名转换为UUID的算法。
  • save/region包中实现了.mca文件的读取和写入。
  • yggdrasilrealms包已经不再维护了。

在添加这些功能时,都同时提供了客户端和服务端侧的接口,例如我们的 RCON 库就提供了服务端的实现。 所以 Go-MC 不仅能用来编写客户端,也可以用来实现服务端。在 server 包中还提供了相应的框架。

翻译 · Translation

本文本应使用英语编写,但受限于英语表达水平与有限的精力,使用中文才能保证文档的清晰、准确与全面。 对于母语非中文的读者,请尝试使用浏览器机器翻译功能。若机器翻译的效果不足以能够理解文章,请告知我。

许可 · License

本作品采用 知识共享署名-相同方式共享 4.0 国际许可协议 进行许可。

知识共享许可协议

快速上手 · Getting Started

编写自动化 Minecraft 机器人程序是 Go-MC 开发的初衷之一, 通过编写简单的客户端程序并登入官方服务端,可以验证 Go-MC 提供的其他功能性模块实现是否与原版游戏保持一致。 继承于 Go-MC 的前身项目 gomcbotbot包是 Go-MC 最早被开发出来的模块。

bot 包提供了一个轻量级的机器人框架,实现了获取服务器状态(Ping and List)、连接服务器登入游戏(Join Game)等功能, 还包含了一个简单的数据包 Handler 注册器,统一地管理从收到的数据包,使得对聊天、区块、窗口等功能的模块化处理成为可能。

加入游戏 · Join Game

虽然看上去简单,但是玩家连接服务器、进入游戏其实是一个有点复杂的过程。在客户端和服务端创建连接成功后,还需要经过握手、认证、加密、压缩等几个过程, 经过一系列数据包的交换,登录阶段以一个服务器发给客户端的0x02数据包结束,玩家正式开始游戏。

处理登录实在有点烦人,不过好在 bot 包可以帮你完成整个步骤,而你只需要调用一个 JoinServer 函数,就能成功加入游戏!

首先快速看一下一个最基础的 bot 程序长什么样:

package main

import (
	"log"

	"github.com/Tnze/go-mc/bot"
)

func main() {
	client := bot.NewClient()
	// ...

	err := client.JoinServer("localhost:25565")
	if err != nil {
		log.Fatal(err)
	}

	log.Println("Login success")

	if err = client.HandleGame(); err != nil {
		log.Fatal(err)
	}
}

第一步 · 创建客户端

调用 bot.NewClient() 函数会返回一个 *bot.Client 客户端对象,表示一个游戏客户端。没有需要特别注意的, 但是同一时刻一个客户端只能连接一个服务器,不能在多个 goroutine 中共用一个客户端连接服务器。

玩家加入游戏后客户端对象也会被用到,一般要把它保存在一个变量里以供后续使用,变量名一般是client或者c

client := bot.NewClient()

第二步 · 设置玩家名

更改玩家名的方法比较直接,但是请注意不要和 client.Name 搞混了, client.Auth 里的才是登入时发送给服务器的认证信息。 而 client.Name 是登录成功后服务器发回来的。

client.Auth.Name = "Tnze"

第三步 · 登录正版账号

如果你想加入开启了正版验证的服务器(online-mode),还需要把 UUIDAccessToken 一起填上。

client.Auth = bot.Auth{
    Name: "Tnze",
    UUID: "58f6356e-b30c-4811-8bfc-d72a9ee99e73",
    AsTk: "*******************",
}

以前 GO-MC 自带的 yggdrasil 包可以直接获取 AccessToken , 但转为使用微软账户之后,这事儿变得有点麻烦1,并且似乎需要调用浏览器或 WebView。

将这些东西集成在 Go-MC 里并不合适,所以请使用好心人编写的工具库,已知的库如下(排名不分先后,请自行选择判断),也欢迎新的库添加到这个列表来。

1

Issue #106

第四步 · 加入游戏

调用 JoinServer() 并传入服务器地址即可加入游戏。服务器地址可以是域名也可以是IP地址,可以带端口号也可以不带(默认为25565)。 设计上,该地址字符串与原版游戏客户端加入服务器的地址输入框保持一致。

err := client.JoinServer("localhost:25565")
if err != nil {
    log.Fatal(err)
}

第五步 · 处理游戏

在登录游戏成功后 JoinServer() 方法就会返回,如果什么都不做,程序就会结束,连接就会断开,自然也就达不到机器人的目的了。 所以在最后需要调用客户端上的 HandleGame() 方法,开始收发游戏数据包:

if err := client.HandleGame(); err != nil {
    log.Fatal(err)
}

该方法会持续不断地接收数据包,并根据数据包ID执行相应的处理函数。 当任意处理函数产生了错误时 HandleGame() 就会返回,如果不希望程序就此退出,可以在处理完错误后再次调用 HandleGame() 继续游戏。 多次调用不会产生问题,设计如此,无需担心。

var err error
for {
    if err = client.HandleGame(); err == nil {
        panic("HandleGame never return nil")
    }
	log.Printf(err)
}

HandleGame() 返回错误时,需要区分错误是否可恢复,如果是连接断开等不可恢复错误则需要停止程序,如果是单个数据包的处理出错则可以恢复。 判断返回的错误是否可以恢复的方法是使用 errors.As() 函数,判断 err 是否是一个 bot.PacketHandlerError ,具体使用方法如下:

  var err error
  var perr bot.PacketHandlerError
  for {
      if err = client.HandleGame(); err == nil {
          panic("HandleGame never return nil")
      }
+     if errors.As(err, &perr) {
+         log.Print(perr)
+     } else {
+         log.Fatal(err)
+     }
  }

下一步 · The next step

由于服务器的 “KeepAlive” 机制,当前编写的 bot 程序不会响应服务器的心跳包,就会在一定时间后会被服务器判定为无响应并自动踢出。 为了解决这个问题,需要使用下一章提到的 bot/basic 模块。

基础功能 · Basic Module

bot 包实现了最基础的登录逻辑之后,其他可选的功能都放在子包下以便按需引用, 其中一个几乎必选的子包就是 bot/basic ,从名字你就能听出来它提供的功能有多基础了。

bot/basic 包提供了以下功能:

  • KeepAlive 用于服务器测试连接状态,客户端不处理会被踢出游戏
  • Client Settings 向服务器发送的一些客户端设置,例如语言、主手、装备可见性、客户端名称等
  • Player Info 储存玩家的实体ID,游戏模式等
  • World Info 储存服务器信息,包括世界维度、视距、最大玩家总数等

整个框架以多级模块化的方式构建,bot 下的各个包并不是完全平级的关系,目前 bot/msgbot/world 就都依赖 bot/basic 包。 各个模块之间的依赖在初始化时显式指定,不需要特殊关注。

使用方法 · Usage

功能模块的加载需要在 JoinServer 之前完成。

  package main

  import (
      "log"

      "github.com/Tnze/go-mc/bot"
  )

  var (
      client *bot.Client
      player *basic.Player
  )

  func main() {
      client = bot.NewClient()

+     player = basic.NewPlayer(client, basic.DefaultSettings, basic.EventsListener{})

      err := client.JoinServer("localhost:25565")
      if err != nil {
          log.Fatal(err)
      }

      log.Println("Login success")

      var perr bot.PacketHandlerError
      for {
          if err = client.HandleGame(); err == nil {
              panic("HandleGame never return nil")
          }
          if errors.As(err, &perr) {
              log.Print(perr)
          } else {
              log.Fatal(err)
          }
      }
  }

通过调用 basic.NewPlayer()basic 会将 player 对象注册到传入的 client 上, client 收到响应的数据包会自动解析之后储存到 player 内部以供读取。

现在,clientplayer 是两个单独的对象,client 相当于打开的游戏程序窗口,而 player 相当于加入服务器的一个游戏会话, 前者承载了网络连接与客户端设置,后者承载了游戏过程中角色的状态数据。这是一种合理的设计,但一开始并不是显而易见的。

监听事件 · Events

basic 包还提供了几个可供注册的事件,如游戏开始血量变化角色死亡等。 以血量变化为例,在调用 basic.NewPlayer() 传入事件响应函数即可。

func main() {
	// ...
    player = basic.NewPlayer(client, basic.DefaultSettings, basic.EventsListener{
        HealthChange: onHealthChange,
    })
	// ...
}

func onHealthChange(health float32) error {
    log.Printf("HealthChanged: %v", health)
    return nil
}

完整的例子可在 go-mc/examples/daze 查看

聊天消息 · Chat Message

聊天消息是游戏中玩家们的主要沟通方式。一些服务器要求玩家在进入后输入指令登录。 聊天功能不仅可用于向服务器内的玩家报告当前状态,也可以用于接收玩家给的指令,是机器人所必备的重要功能之一

bot 包启用消息功能需要引入 bot/msg 模块,并同时依赖于 bot/basicbot/playerlist

var (
    chatHandler *msg.Manager
    playerList  *playerlist.PlayerList
)


playerList = playerlist.New(client)
chatHandler = msg.New(client, player, playerList, msg.EventsHandler{})

发送消息 · Send Messages

要发送消息,请调用 msg.Manager 上的 SendMessage(msg string) error 方法。

if err := chatHandler.SendMessage("Hello, world"); err != nil {
	return err
}

接收消息 · Receive Messages

目前(1.19.3)在协议层面有三种消息可被接收:

  • 玩家消息(Player Chat):玩家发送的消息,可以被签名确保是由玩家本人发送的。
  • 系统消息(System Chat):系统发送的提示,如“Tnze加入了游戏”,分为显示在消息栏和屏幕中央两种。
  • 伪消息(Disguised Chat):在服务器后台用“/say”命令发送的消息。

以下是接收聊天消息的代码示例:

chatHandler = msg.New(client, player, playerList, msg.EventsHandler{
    SystemChat:        onSystemMsg,
    PlayerChatMessage: onPlayerMsg,
    DisguisedChat:     onDisguisedMsg,
})


func onSystemMsg(msg chat.Message, overlay bool) error {
	log.Printf("System: %v", c)
	return nil
}

func onPlayerMsg(msg chat.Message, validated bool) error {
	log.Printf("Player: %s", msg)
	return nil
}

func onDisguisedMsg(msg chat.Message) error {
	log.Printf("Disguised: %v", msg)
	return nil
}

消息处理 · Handle Message

Minecraft 使用 JSON 格式储存结构化的聊天消息,在消息中可以包含颜色、加粗、斜体等格式,以及点击事件、鼠标悬浮显示内容等功能。

go-mc/chat 包提供了操作聊天消息的 API,请查看处理聊天消息 · Chat

网络连接 · Network

本章假设读者理解 TCP 协议的工作方式并有一定使用经验。
关于 Minecraft 网络协议的详细说明可阅读 https://wiki.vg/Protocol

众所周知,Minecraft Java 版使用的是基于 TCP 的网络传输协议,而 TCP 具有点对点、传输可靠、保证顺序、不保留消息边界等特点。 除了消息边界以外,这些特点同样适用于 Minecraft 协议,请在编写代码时时刻谨记。

Minecraft 在 TCP 的基础上构造了基于长度的分包、基于 zlib 算法的压缩以及基于 RSA 和 AES/CFB8 的加密等协议。

如需收发网络包,请导入 Go-MC 提供的网络库:

import (
	"github.com/Tnze/go-mc/net"
	pk "github.com/Tnze/go-mc/net/packet"
)

特殊情况下,如需同时使用标准库的 net ,请为 Go-MC 的网络库设置别名:

import (
    "net"
    mcnet "github.com/Tnze/go-mc/net"
    pk "github.com/Tnze/go-mc/net/packet"
)

本章提到的 net 包如无特殊说明均指的是 go-mc/net 包,而不是 Go 标准库的 net 包。

创建连接 · Create Connection

使用 go-mc/net 包创建连接的方式与使用 Go 标准库创建 TCP 连接的方式非常类似。

客户端 · Client

以下代码连接运行于 localhost:25565 的服务器:

conn, err := net.DialMC("localhost:25565")
if err != nil {
    log.Fatal(err)
}
defer conn.Close()

服务端 · Server

以下代码在所有本机 IP 地址的 25565 端口监听连接:

listener, err := net.ListenMC("0.0.0.0:25565")
if err != nil {
    log.Fatal(err)
}

for {
    conn, err := listener.Accept()
    if err != nil {
        log.Fatal(err)
    }
    go handle(&conn)
}

发送数据包 · Sending Packets

要发送数据包,你需要有一个网络连接 net.Conn(conn) 与一个数据包 pk.Packet(p)。 通过简单地调用 conn.WritePacket(p) 即可将数据包发送。

关于如何获得 pk.Packet,请参考下一节数据打包 · Packing

err := conn.WritePacket(p)
if err != nil {
	log.Print(err)
	return
}

接收数据包 · Receiving Packets

接收数据包与发送数据包类似,你同样需要有一个 net.Conn(conn) 与一个 pk.Packet(p)。 不同的是这次的操作是从连接读取数据并写入数据包。

var p pk.Packet
err := conn.ReadPacket(&p)
if err != nil {
	log.Print(err)
	return
}

之所以设计为需要传入 &p 而不是直接返回一个新的 p,是为了方便复用 pk.Packet 对象。

当循环接收数据包时,每个连接只使用一个 pk.Packet,可以复用内部缓冲区,起到减少内存分配,减轻 GC 压力的作用。

var p pk.Packet
for {
	err := conn.ReadPacket(&p)
	if err != nil {
		log.Print(err)
		break
	}
	handle(p) // 需要注意 p 只在下次 ReadPacket 调用前有效
}

数据打包 · Packing

当连接建立成功,在 net.Conn 实例上能调用的方法有三个:

  • 发送数据 WritePacket(p pk.Packet) error
  • 接收数据 ReadPacket(p *pk.Packet) error
  • 关闭连接 Close() error

在 Login 阶段还需要按协议调用 SetCipherSetThreshold,这里不做讨论。

无论是发送数据还是接收数据,Minecraft 协议都是以数据包(pk.Packet)为单位处理的。 包长度、加密、压缩等传输细节1 Go-MC 都已经实现并封装到 pk.Packet 结构体内了,用户直接使用即可。

结构体 pk.Packet 定义如下:

type Packet struct {
	ID   int32
	Data []byte
}

ID 是一个枚举值,表示数据包的功能,Data 的格式也需要根据它判断。可用的值在 data/packetid 中找到。

Data 逻辑上包含了一个或多个数据项,例如客户端在玩家移动时可能会发送的 Move 包可能格式如下:

Field NameField TypeNotes
XDoubleAbsolute position.
Feet YDoubleAbsolute feet position, normally Head Y - 1.62.
ZDoubleAbsolute position.
On GroundBooleanTrue if the client is on the ground, false otherwise.

Data 包含4个数据字段:三个 Double 和一个 Boolean。

所有的字段类型都定义在 net/packet 包,例如 pk.Doublepk.Boolean。 这些类型都实现了 pk.Field 接口,你可以调用它们的 ReadFromWriteTo 方法。

以下代码可以用来生成一个这样的数据包。

var buffer bytes.Buffer
_, _ = pk.Double(x).WriteTo(&buffer)
_, _ = pk.Double(y).WriteTo(&buffer)
_, _ = pk.Double(z).WriteTo(&buffer)
_, _ = pk.Boolean(onGround).WriteTo(&buffer)

p := pk.Packet {
	ID: packetid.ServerboundMovePlayerPos,
	Data: buffer.Bytes(),
}

操作起来有点繁琐,好在 Go-MC 为这样的操作提供了一个帮助函数:pk.Marshal()。经过改进的代码如下:

p := pk.Marshal(
    packetid.ServerboundMovePlayerPos,
    pk.Double(x),
    pk.Double(y),
    pk.Double(z),
	pk.Boolean(onGround),
)

反过来,如果服务器想接收一个这样的数据包,那么需要用到 ReadFrom 方法,用笨办法就是如下这样:

var (
	x        pk.Double
	y        pk.Double
	z        pk.Double
	onGround pk.Boolean
)
r := bytes.NewReader(p.Data)
_, _ = x.ReadFrom(r)
_, _ = y.ReadFrom(r)
_, _ = z.ReadFrom(r)
_, _ = onGround.ReadFrom(r)

加上错误处理更加繁琐了!好在 Go-MC 也提供了一个帮助函数:p.Scan()。经过改进的代码如下:

var (
    x        pk.Double
    y        pk.Double
    z        pk.Double
    onGround pk.Boolean
)
_ = p.Scan(&x, &y, &z, &onGround)

在实际使用中,一般需要根据情况选择是否使用帮助函数,请自行判断。

使用 pk.Array

在一些数据包格式中,会存在 Array of X 类型的数组字段。 这些数组的前一个字段通常是一个 VarInt 类型的长度字段, Go-MC 提供了一个帮助类型来处理这种常见情况。

以 Commands 包为例,目前该数据包格式如下:

Field NameField TypeNotes
CountVarIntNumber of elements in the following array.
NodesArray of NodeAn array of nodes.
Root indexVarIntIndex of the root node in the previous array.

我们假设你已经定义好 Node 类型并为其实现了 pk.Field 接口,为了接收这个数据包中的 Nodes 数组, 我们可以采用以下繁琐的代码:

var (
	count pk.VarInt
	nodes []Node
	root  pk.VarInt
)
r := bytes.NewReader(p.Data)

_, _ = count.ReadFrom(r)
nodes = make([]Node, count)
for i := range nodes {
	_, _ = nodes[i].ReadFrom(r)
}
root.ReadFrom(r)

为了使用 p.Scan() 简化工作量,我们需要用到 pk.Array() 函数:

var (
	nodes []Node
	root  pk.VarInt
)
_ = p.Scan(
	pk.Array(&nodes),
	&root,
)

使用 pk.Array() ,会自动处理数组长度的 VarInt。返回值实现 pk.Field 接口,可用于 pk.Marshal()pk.Scan()

注意,当切片的元素类型不支持相应调用的 WriteToReadFrom 方法时,会 panic。 请确保将 pk.Array() 的返回值用作 FieldEncoder 时,切片元素类型也实现 FieldEncoder, 将 pk.Array() 的返回值用作 FieldDecoder 时,切片元素类型也实现 FieldDecoder

使用 pk.Option

一些数据包格式中,会存在 Optional X 类型的可选字段。顾名思义,这种字段在数据包中是可选的。 可选字段是否存在需要根据上下文进行判断,上下文通常指其前面的 Boolean 值,例如:

Field NameField TypeNotes
Is SignedBoolean
SignatureOptional StringExist only if Is Signed is true

读取该数据包的伪代码如下:

IsSigned = ReadBoolean()
if IsSigned {
	Signature = ReadString()
}

为了能在 p.Scan() 函数调用中提供这样的判断逻辑,Go-MC 提供了四个帮助类型:pk.Optionpk.OptionDecoderpk.OptionEncoderpk.Opt

pk.Opt 是一个一般不会用到的原始类型,如需使用请自行阅读注释及源码,在此不做说明。

pk.Option 是一个泛型类型,有两个泛型参数:TP,其中后者是前者的指针类型。 T 必须满足 pk.FieldEncoder 接口,P 必须满足 FieldDecoder 接口。

例如:pk.Option[pk.String, *pk.String]pk.Option[pk.VarInt, *pk.VarInt]等,都是合法的类型。

需要手动指定 P 是因为当前 Go 编译器泛型实现不够完善2,在 Go 支持推导结构体泛型参数类型之后, 上面的两个例子就可以简单写成 pk.Option[pk.String]pk.Option[pk.VarInt] 了。

2 Type Parameters Proposal

pk.OptionDecoderpk.OptionEncoder 是两个变体,分别去掉了 对T的约束 和 对P的约束,很好理解。

以下给出读写上面例子数据包的完整代码

var Signature pk.Option[pk.String, *pk.String]
_ = p.Scan(&Signature)

编解码 NBT

Go-MC 提供了一个功能完善的 NBT 操作库,可以用于方便、高效地处理各种NBT结构。这里会介绍这个 NBT 库提供的核心API。

使用 NBT 库,需要导入以下包:

import "github.com/Tnze/go-mc/nbt"

Go Reference

该 NBT 库的用法与标准库的 encoding/json 库比较相似,查看文档及示例很容易理解,这里是一些注意事项。

不同类型的Go变量会被转换为不同的 NBT 标签,对应关系如下:

Go 类型NBT 标签
bool, int8, uint8TagByte
int16, uint16TagShort
int32, uint32TagInt
int64, uint64TagLong
float32TagFloat
float64TagDouble
stringTagString
struct, mapTagCompound
[]bool, []int8, []uint8TagByteArray
[]int32, []uint32TagIntArray
[]int64, []uint64TagLongArray
[]TTagList

NBT库内部使用反射来实现以上功能,但是实现了 nbt.Marshalernbt.Unmarshaler 接口的值不会被反射,NBT 库会调用它们自身的实现。

你可以通过实现这两个接口实现自定义类型的支持。NBT库也有三个特殊类型实现了这两个接口,你可以利用它们完成一些特殊功能:

  • nbt.RawMessage - “原始数据”类型,NBT 数据将被原样保存,可以容纳任意合法的NBT数据。
  • nbt.StringifiedMessage - 以 S-NBT 格式保存数据,可以与 string 相互转换。
  • dynbt.Value - “动态”类型,可以保存任意数据,并且提供了方便的API用于操控 NBT。

结构体标签和选项 · Supported Struct Tags and Options

你可以给结构体标记 Tag 以指定 nbt 库如何将其编码为 NBT 及 如何将 NBT 解码到其之上。目前支持的标签有以下两个:

  • nbt - 用于指定名称和参数
  • nbtkey - 只用于指定名称 (可以包含逗号 ,

The nbt tag

大多数情况下,你只需要用这个 tag 来设置 NBT 标签名。

nbt tag 的格式: <nbt tag>[,opt].

这是一个逗号分隔的列表。 第一项是标签名,其余部分为选项。

像这样:

type MyStruct struct {
    Name string `nbt:"name"`
}

使用 - 可以让 NBT 库忽略某个字段:

type MyStruct struct {
    Internal string `nbt:"-"`
}

使用 omitempty 可以让 NBT 库仅在为零值时忽略某个字段:

type MyStruct struct {
    Name string `nbt:"name,omitempty"`
}

类型为 []byte[]int32[]int64 的值默认将被分别编码为 TagByteArrayTagIntArrayTagLongArray
你可以通过 list 标记将它们指定为 TagList

type MyStruct struct {
    Data []byte `nbt:"data,list"`
}

The nbtkey tag

官方 JSON 标准库有一个严重的问题:无法指定包含逗号的key(逗号后面的会被识别为其他选项) (例如 {"a,b" : "c"}

Go官方是非常自傲的,仅仅因为他们“觉得”key里不应该有逗号,他们就不让你加逗号,尽管JSON标准是完全支持的。
但是我们不一样,对这种需求我们提供了支持的方案:

type MyStruct struct {
    AB string `nbt:",omitempty" nbtkey:"a,b"`
}

使用 S-NBT · Stringified NBT

这是一种“人类可读”的NBT表示形式,也是在游戏原版命令中输入任何NBT数据使用的格式。

动态 NBT · Dynamic NBT

这是另一种风格的 NBT 库,它允许你动态地添加、删除和查询数据,而无需预先定义数据类型或者操纵讨厌的 map[string]any

如何使用?请看 Go Reference

聊天 · Chat

Go-MC 在 go-mc/chat 包的提供了操作聊天消息的 API。

import "github.com/Tnze/go-mc/chat"

其中 chat.Message 是主要结构,每个 chat.Message 结构体可表示一条聊天消息。

使用 · Usage

该结构体实现了 pk.Filed 接口,可以直接用于网络包字段的发送与接收:

func HandleDisguisedChatMessagePacket(p pk.Packet) error {
    var (
        Message chat.Message
        ChatType pk.VarInt
        ChatTypeName chat.Message
        TargetName pk.Option[chat.Message, *chat.Message]
    )
    err := p.Scan(&Message, &ChatType, &ChatTypeName, &TargetName)
    // ...
}

该结构体通过与 json 标准库交互,也可以实现和 []byte 互转:

func ToJson(msg chat.Message) (data []byte, err error) {
    return json.Marshal(msg)
}

func FromJson(data []byte) (msg chat.Message, err error) {
    err = json.Unmarshal(data, &msg)
    return
}

通过 fmt 库可以直接输出带样式的消息文本,原理是 .String() 方法将颜色等样式转换为ANSI 转义序列。 所以在类 Unix 系统上的大部分终端中,都支持通过 fmt.Print(msg) 直接实现输出带样式的聊天消息。 注意 为了支持 Windows 系统 ,需要使用 go-colorable 这个库。

如果你需要将 chat.Message 转换为 不包含 ANSI 转义序列 的纯文本,则可以选择调用 .ClearString() 方法。

构造 · Construction

聊天消息按使用到的特性可分为如下三种

  • 普通消息
  • 附加消息
  • 翻译消息

普通消息 · Normal

普通消息的格式很简单,仅仅是包含了 .Text 字段(而不包含 .Translate.With.Extra 等)。

msg := chat.Message {
    Text: "hello, world",
}
hello, world

普通消息像其他消息一样,也可以包含一些样式,包括:

  • 加粗:Bold
  • 倾斜:Italic
  • 下划线:UnderLined
  • 删除线:StrikeThrough
  • 随机化:Obfuscated
  • 指定字体:Font
  • 指定颜色:Color

附加消息 · Extra

附加消息就是将一个或多个另外的额外消息连接在主消息之后。可以用于对消息不同部分设置不同样式。

附加的部分都放在 .Extra 字段中:

msg := chat.Message {
    Text: "hello",
    Extra: []chat.Message {
        chat.Message {
            Text: ", ",
        },
        chat.Message {
            Text: "world",
        },
    },
}
hello, world

翻译消息 · Translate

翻译消息与普通消息完全不同,它 不包含 .Text 字段 ,取而代之的是 .Translate.With

翻译消息用于向设置不同语言的客户端展示不同的消息,其原理可以简单理解为格式化字符串(printf)。

例如,玩家加入服务器时服务器会发送一条 .Translate == "multiplayer.player.joined" 的消息。 该消息在中文客户端中内容为 "%s加入了游戏",而在英语客户端中则为 %s joined the game。而玩家名则通过 .With 字段提供。

以下是一个简单的例子:

msg := chat.Message {
    Trasnlate: "multiplayer.player.joined",
    With: []chat.Message {
        chat.Message {
            Text: "Tnze",
        },
    },
}
Tnze加入了游戏

在这里 Tnze 会被填充到翻译消息的 %s 处,最终在中文客户端中显示为 Tnze加入了游戏,而在英语客户端中显示为 Tnze joined the game

所有可用的消息都可以在 go-mc/data/lang 下找到。

如果你想用 Go-MC 实现一个自己的服务端,但是又想为自定义消息提供多语言功能,默认提供的翻译消息列表可能就不够用了。 你可以通过读取客户端发送的 ClientInformation 包内的 Local 字段获知客户端设置的语言,并在服务端进行翻译工作。

快捷方式 · Shortcrust

Go-MC 提供了一些常用的构造函数,用来快速生成你想要的消息结构体。我相信,这些函数不需要说明也能很容易的被理解和使用。

chat.Text("hello, world")
chat.Text("hello").Append(chat.Text(", "), chat.Text("world"))
chat.Text("Waring").SetColor(chat.Yellow)
chat.TranslateMsg("multiplayer.player.joined", chat.Text("Tnze"))

更多 · More

聊天消息还支持悬停事件、点击事件等功能,详细的使用方法请参考 https://wiki.vg/Chat 以及 go-mc/chat 包源代码。

编写服务端 · Writing Server

Go-MC 提供了一个轻量级服务端框架,可以用来快速实现一个服务端。

架构说明

从协议角度,玩家进入服务器游玩一共分为三个阶段:

名称功能
Ping & List玩家进入服务器前,展示服务器信息与在线玩家数
登录玩家进入服务器游玩,处理登录协议,设置网络连接的压缩与加密
游玩玩家登录成功进入游戏,处理游戏逻辑与玩家交互

Go-MC 围绕这三个阶段设计,通过接口提供了一个自由度极高的服务器框架。

以下是 Go-MC 提供的三个主要接口:

  • LoginHandler:用于提供登录协议,默认提供 Mojang 登录验证的实现,用户可以通过编写自己的 LoginHandler 来实现自定义登录验证。
  • ListPingHandler:为外部提供服务器状态信息的查询功能,用户可以通过编写自己的 ListPingHandler 来实现显示自定义的服务器状态信息。
  • GamePlay:最有趣的部分,留给用户自行实现,用户可以参考 https://github.com/go-mc/server 由 Tnze 编写的版本。

//TODO