# 基于 Go 语言的 21 点游戏 RPC 交互系统

南京大学 计算机科学与技术学院 马一鸣 502024330038

## 项目概述

本项目旨在使用 Go 语言设计并实现一款基于远程过程调用（RPC）机制进行通信的 Blackjack（即 21 点）游戏系统。系统由用户端和服务器端组成，用户可以通过用户端与服务器端的 AI 发牌员进行对战。整个系统的设计目标是提供流畅且公平的游戏体验，并通过可靠的通信机制保证用户与服务器之间的有效互动。

## 需求分析

本项目的需求分析如下：

1. **通信机制：** 使用 RPC 作为用户和服务器之间的数据交互机制，以实现低延迟和高可靠性的远程通信。
2. **游戏规则：** 游戏采用 Blackjack 的基本规则，玩家与发牌员对战，以接近 21 点为目标。为了提高游戏性和互动性。
3. **策略设计：** 发牌员的策略可灵活设计，但必须遵循公平原则，保证游戏的可玩性。策略的制定应考虑不同场景下的最优选择，以增强游戏的挑战性。
4. **用户体验：** 系统应具备良好的用户交互设计，保证玩家在操作过程中的易用性和流畅性。

## 概要设计

本项目基于 Go 语言实现 Blackjack 游戏系统，主要分为用户端和服务器端两部分。

- **用户端：** 用户端提供玩家操作界面，通过 RPC 机制向服务器端发送请求，进行拿牌、停牌等操作，并接收游戏结果。
- **服务器端：** 服务器端负责处理所有的游戏逻辑，包括初始化牌组、为玩家和发牌员发牌、计算点数、判断胜负等。服务器端通过 RPC 服务对外提供功能。
- **通信机制：** 使用 Go 的 `net/rpc` 库实现用户端与服务器端的通信，保证数据传输的安全性与实时性。

## 详细设计

本项目的代码结构如下：

``` shell
❯ tree .
.
├── client
│   └── client.go
├── controller
│   ├── response.go
│   └── simulator.go
├── core
│   └── game.go
├── doc.md
├── go.mod
├── main.go
├── model
│   ├── card.go
│   └── deck.go
├── rpc
│   ├── request.go
│   └── response.go
├── server
│   └── server.go
├── util
│   ├── dict.go
│   └── status.go
└── view
    └── viewer.go

9 directories, 15 files
```

其中 21 点游戏的模拟部分遵循 `MVC` 设计模式。

* `model` 包负责对 21 点游戏中出现的各类对象（如卡牌 Card，卡组 Deck）等进行抽象。
* `view` 包负责将游戏的交互逻辑进行呈现。
* `controller` 包负责对于游戏的控制逻辑进行抽象，如发牌，要牌，判定胜负等交互逻辑。
* `game` 包负责将游戏整体进行封装，并实现了基于命令模式等指令解析模块。

对于 RPC 通信实现了如下包：

* `rpc` 包封装了 RPC 通信所需要的结构体。
* `server` 包实现了 RPC 通信的服务端构建。
* `client` 包实现了 RPC 通信的客户端构建。

此外 `util` 包实现了各类轻量化工具代码。

### 21 点游戏的模拟设计

#### 牌组设计

系统中的牌组包含 52 张牌，由四种花色（红桃、方块、梅花和黑桃）组成，每种花色有 13 张牌（2 到 10、J、Q、K、A）。每张牌的点数如下：

- **数字牌 (2-10)：** 牌的点数等于其面值。
- **人头牌 (J、Q、K)：** 每张牌的点数为 10。
- **A 牌：** 可以视为 1 分或 11 分，具体取决于对玩家更有利的值。

在 `model/card.go` 中实现了对于 `Card` 结构体的封装：

```go
type Card struct {
	Color string
	Value []int
	Name string
	Visible bool
}
```

在 `model/deck.go` 中实现了对于 `Deck` 结构体的封装：

```go
type Deck struct {
	cards []*Card
}
```

一副卡组（Deck）由若干卡牌（Card）组成。与此同时，在 `Deck` 中封装了若干 API，最为重要的是抽牌 `Deck.Draw()` 与洗牌 `Deck.Shuffle()` API：

```go
func (deck *Deck) Shuffle() *Deck {
	for i := range deck.cards {
		j := i + rand.Intn(len(deck.cards) - i)
		deck.cards[i], deck.cards[j] = deck.cards[j], deck.cards[i]
	}
	return deck
}

func (deck *Deck) Draw() *Card {
	if len(deck.cards) == 0 {
		return nil
	}
	card := deck.cards[0]
	deck.cards = deck.cards[1:]
	return card
}
```

#### 游戏流程设计

在 `controller/simulator.go` 中封装了游戏的交互逻辑：

```go
type BackJackSimulator struct {
	deck *model.Deck
	dealerCards *model.Deck
	playerCards *model.Deck
	dealerScore int
	playerScore int
	status util.Status
}
```

通过维护 `deck`，`dealerCards` 与 `playerCards` 三副卡组用来模拟 21 点的游戏流程。

设计实现游戏状态的初始化逻辑：

```go
func (simulator *BackJackSimulator) Init() *BackJackSimulator {
	simulator.deck = model.NewDeck()
	simulator.dealerCards = model.NewDeck()
	simulator.playerCards = model.NewDeck()

	simulator.deck = initDeck(simulator.deck)
	simulator.dealerCards = initDealerCards(simulator.dealerCards, simulator.deck)
	simulator.playerCards = initPlayerCards(simulator.playerCards, simulator.deck)
	 
	simulator.dealerScore, simulator.playerScore = 0, 0
	simulator.status = util.INITIALIZED

	return simulator
}
```

值得注意的是，在 `BackJackSimulator` 中基于状态机模型对游戏的状态进行管理。

在游戏中共包含了四个不同的状态，分别是初始化（INITIALIZED），闲家胜利（WINNED），庄家胜利（FAILED）以及平局（DRAW），相关状态封装在 `util/status.go` 中：

```go
type Status int

const (
	INITIALIZED Status = iota
	WINNED Status = iota
	FAILED Status = iota
	DRAW Status = iota
)
```

游戏的流程如下：

- **发牌阶段：** 游戏开始时，玩家和发牌员各自得到两张牌。玩家的两张牌均为明牌，发牌员有一张明牌和一张暗牌（底牌），例如发牌员的发牌逻辑如下：

```go
	if firstCard := deck.Draw(); deck != nil {
		dealerCards.Add(firstCard.Flip())
	}
	if secondCard := deck.Draw(); deck != nil {
		dealerCards.Add(secondCard)
	}
```

- **要牌阶段：** 玩家可以选择“要牌”（Hit，即再抽一张牌）或“停牌”（Stand，保持当前手牌）。玩家可以多次选择“要牌”，直到总点数超过 21 点（称为“爆牌”）或选择“停牌”。

对于要牌逻辑与爆牌判定逻辑实现如下：

```go
if card := simulator.deck.Draw(); card != nil {
    simulator.playerCards.Add(card)
    simulator.playerScore = 0

    for _, card := range simulator.playerCards.Cards() {
        simulator.playerScore += card.Value[0]
    }

    if simulator.playerScore > 21 {
        simulator.status = util.FAILED
        return NewBlackJackResponse(400, "[🐧] Your total score exceeds 21 ! You lose !\n")
    }
}
```

值得注意的是在 `simulator` 中封装了 `BlackJackResponse` 结构体以返回游戏的状态反馈信息：

```go
type BackJackResponse struct {
	Code int
	Description string
}
```

当玩家停止要牌之后即进入停牌阶段。

- **停牌阶段：** 发牌员揭开底牌后，必须继续“要牌”，直到手牌点数达到或超过 17 点：

```go
for maxDealerScore < 17 {
    if card := simulator.deck.Draw(); card != nil {
        simulator.dealerCards.Add(card)
        if card.Name == "A" {
            maxDealerScore += card.Value[1]
            minDealerScore += card.Value[0]
        } else {
            maxDealerScore += card.Value[0]
            minDealerScore += card.Value[0]
        }
    }

    if minDealerScore > 21 {
        simulator.status = util.WINNED
        return NewBlackJackResponse(200, "[🐧] The dealer burst and you have won the game !\n")
    }
}
```

- **胜负判定：** 游戏结束后，根据玩家和发牌员的点数判断胜负。若玩家爆牌则输掉游戏；若发牌员爆牌，则玩家获胜；若双方均未爆牌，则点数接近 21 的一方获胜：

```go
if maxDealerScore > maxPlayerScore {
    simulator.status = util.FAILED
    return NewBlackJackResponse(200, "[🐧] The dealer wins the game !\n")
} else if maxDealerScore < maxPlayerScore {
    simulator.status = util.WINNED
    return NewBlackJackResponse(200, "[🐧] You have won the game !\n")
} else if maxDealerScore == maxPlayerScore {
    simulator.status = util.DRAW
    return NewBlackJackResponse(200, "[🐧] It is a draw !\n")
}
```

#### 游戏界面设计

基于命令行对于游戏的界面进行设计。

在 `view/viewer.go` 中实现界面结构体：

```go
type LiteralViewer struct {
	response string
	simulator *controller.BackJackSimulator
}
```

该结构体通过 `GetResponse` 结构输出游戏的状态信息。

将上述基于 MVC 架构的代码封装在 `BackJackGame` 结构体中：

```go
type BackJackGame struct {
	simulator *controller.BackJackSimulator
	viewer *view.LiteralViewer
}
```

该结构通过命令模式对游戏逻辑进行控制：

```go
func (game *BackJackGame) AddCommand(command string) string {
	command = strings.Split(command, " ")[0]
	response := ""

	switch command {
		case "start": {}
		case "hit": {}
		case "stand": {}
		case "help": {}
		default: {}
	}

	return response
}
```

### RPC 通信的客户端与服务器端设计

#### 服务端设计

服务器端的核心任务是处理游戏逻辑，并通过 RPC 服务向用户端提供接口。

服务端的核心代码如下：

```go
commandService := new(CommandService)
rpc.Register(commandService)

listener, err := net.Listen("tcp", ":1234")
if err != nil {
    fmt.Println("[🐧] Listening Failed:", err)
    return
}
defer listener.Close()
fmt.Println("...")

for {
    conn, err := listener.Accept()
    if err != nil {
        fmt.Println("[🐧] Connecting Failed:", err)
        continue
    }
    go rpc.ServeConn(conn)
}
```

上述代码实现了一个简单的 RPC 服务器，使用了 `Go` 语言中的 `net` 和 `rpc` 包。

在 `main` 函数中创建一个 `CommandService` 实例并将 `commandService` 实例注册到 RPC 服务器，使其公开方法可以被远程调用。

服务器开始监听 TCP 连接，端口号为 1234。`net.Listen()` 函数返回一个 listener（监听器）对象和一个错误（err）。

使用 `defer` 关键字确保在函数退出时关闭监听器，释放资源。

服务器等待客户端连接。当有客户端连接时，`listener.Accept()` 会返回一个表示该连接的 `conn` 对象。

通过 `go` 关键字保证每个连接都通过一个新的 `goroutine` 来处理，`rpc.ServeConn()` 用于处理该连接上的 RPC 请求。

对于 `CommandService` 结构体封装了调用方法如下：

```go
func (cs *CommandService) ExecuteCommand(req brpc.Request, res *brpc.Response) error {
    command := req.Command
    response := backJackGame.AddCommand(command)
    res.Result = fmt.Sprint(response)
    return nil
}
```

#### 客户端设计

客户端负责与玩家交互，并通过 RPC 向服务器发送请求。

客户端的核心代码如下：

```go
client, err := rpc.Dial("tcp", "localhost:1234")
if err != nil {
    fmt.Println("[🐳] Connecting Failed:", err)
    return
}
defer client.Close()

reader := bufio.NewReader(os.Stdin)
for {
    command, _ := reader.ReadString('\n')
    command = command[:len(command)-1]

    if command == "exit" {
        break
    }

    req := brpc.Request{Command: command}
    var res brpc.Response

    err = client.Call("CommandService.ExecuteCommand", req, &res)
    if err != nil {
        fmt.Println("[🐳] RPC Failed:", err)
        continue
    }
}
```

上述代码使用 `rpc.Dial()` 函数创建一个 RPC 客户端，并通过 TCP 连接到 localhost 上的端口 1234。

使用 `defer` 关键字确保在函数结束时关闭 RPC 客户端，释放资源。

创建一个从标准输入读取用户输入的 `bufio.Reader`，用于从命令行接收用户输入。

通过 `client.Call()` 调用服务器端的 `CommandService.ExecuteCommand` 方法。

## 测试与验证

欲运行服务端和客户端程序需要分别编译运行客户端与服务端代码：

```shell
$ go run server.go
```

```shell
$ go run client.go
```

其中客户端的交互页面如下：

```shell
go run client.go 
[🐳] Input your Command (Input <exit> to quit):
start    
```

在输入 `start` 命令后即可开启一局 21 点游戏：

```shell
[🐳] Input your Command (Input <exit> to quit):
start     
[🃏] Dealer: 🂠 ♥️10 
[🎴] Player: ♦3 ♦7 
[🐧] You have started a new Blackjack game.
```

输入 `hit` 命令进行要牌操作：

```shell
[🐳] Input your Command (Input <exit> to quit):
hit
[🃏] Dealer: 🂠 ♥️10 
[🎴] Player: ♦3 ♦7 ♠A 
[🐧] You have drawn a card !
```

输入 `stand` 命令进行停牌操作：

```shell
[🐳] Input your Command (Input <exit> to quit):
stand
[🃏] Dealer: ♠K ♥️10 
[🎴] Player: ♦3 ♦7 ♠A 
[🐧] You decide to stand !
[🐧] You have won the game !
```

此时服务端也会输出对应的信息：

```shell
...
[🐧] Command Received: start
[🐧] Command Received: hit
[🐧] Command Received: stand
```

通过多轮测试与验证，本系统满足了 21 点游戏的交互逻辑的正确性。

# 结论

本项目通过使用 Go 语言和 RPC 通信机制，本项目实现了一款完整且可扩展的 21 点游戏系统。系统通过用户端与服务器端的分离，实现了逻辑处理和用户交互的独立性，从而提高了系统的扩展性和可维护性。项目通过详细的需求分析、概要设计和详细设计，确保了系统的功能完整性和用户体验。同时，通过系统测试，验证了系统的可靠性和稳定性。

未来可以考虑在以下方面进行改进：
- 增加复杂的游戏规则，例如双倍下注、投降等，以提高游戏的策略性和娱乐性。
- 优化发牌员的策略，使其更具挑战性。
- 增加图形化用户界面，以提供更直观的游戏体验。
- 使用更加安全和高效的通信机制，以提高数据传输的安全性和系统性能。
