Article February 04, 2020

Pointa开发笔记

Words count 43k Reading time 39 mins. Read count 0

Pointa开发笔记

Pointa的github仓库

Pointa的itch.io主页

Part 0 - 总结

当我写下这段文字的时候,Pointa的当前版本是v0.23-alpha,在这个版本里,Pointa的Windows客户端(Launcher)实现了自动更新,用ps脚本重写了启动器,封装成了exe文件,虽然代价是启动时的超长延迟(pip更新检测),总而言之,Pointa玩起来更方便了。从itch.io下载,解压,打开,链接服务器,就可以开始匹配,要是运气好服务器里有别的等待匹配玩家,游戏就能开始,这个版本同样相当于Pointa的alpha阶段开发结束。也相当于我的第一个标准意义上的开源项目步入正轨……

这篇文字的标题我想了蛮久的,虽然在大部分人看来,比起现在的标题——“开发笔记”,这篇文章的内容更加偏重于叙述游戏机制的“设计”,而不是游戏实现的“开发”(即便这花的时间是最多的)。但仔细想想,对于游戏的“设计”,何尝不是一种“开发”?在况且Pointa所指,既可指作为一种桌游玩法的“Pointa!”,也自然可以指实现这个游戏的python库“Pointa”。

Pointa玩的人多吗?不多,从第一个可玩版本发布到如今也就只有测试性的游玩,这也导致Pointa服务端的性能一直得不到测试(同样导致我无法判断到底应不应该重写pointa服务器的实现)。

不过实际上这也没什么,我知道Pointa作为一个“实验性”的游戏会有什么下场,版本迭代的速度不快,早期版本公开过早,都算是造成Pointa没什么人玩的主要原因。

Part 1 - Pointa缘起

Pointa至始至终是一个游戏,一个实际上用纸和笔玩的游戏,在github上开发的,在itch.io上发布的,终究是Pointa的“实现”,Pointa的灵魂在于/doc/文件夹里面写着的规则书(这也是为什么我不厌其烦地在README里强调要去看一眼游戏规则书)。

Pointa的起源其实意外地简单——两三个高一学生、一个期考后的晚自习、一个没有班主任的教室,我带着突然出现的奇想,用一张作文纸,和两支刻了刻度的6面铅笔(这也是为什么pointa规则里支持2d6),大概也就是在那十几分钟内,一个新游戏的原型就这么被设计了,名字叫做“Point Duel”

Fun fact,Pointa原(chui)定(bi)是在“一个周末内”完成,但从开始开发到v0.1-alpha的发布实际上用了“两周”

仔细回想,最初版的规则与现在的比较,区别还是不小的,例如:

  • 每个玩家每回合只能指定一个行动
  • 所有行动同时进行
  • 本回合roll出点数要延迟到下一回合才能补充,但如果这回合选择使用,就相当于放弃所roll点数向下回合pt属性补充(听上去十分复杂,对吧)
  • 同时死亡判定血量绝对值最大者失败(实际上这个规则甚至保留到了v0.2-alpha
  • 支持两人以上混战(因为无法保证玩家阵营独立而取消)
  • 没有攻击判定

经过了大概一天左右的设计迭代后,就写在我们设计这个游戏的草稿纸上,Pointa原型的第一个规则书完成了(与现在的规则书几乎无差)大概是这样的:

Pointa(Point Duel)核心规则

  • 游戏分三个阶段,第一阶段2d6/1d12,记入玩家pt属性,第二阶段玩家独自向 攻击/防御/治疗 三个行动放入相应的值(从该玩家pt属性中抽取,0视为不作操作),第三阶段所有玩家所有行动(6个)按照分配的点数从少到多排序,同点数行动按照 治疗/防御/攻击 的顺序排列,顺序执行。

  • 血量小于0即死亡,回合结束后死亡玩家判负,双方死亡则血量绝对值最大者失败。

  • 攻击造成(a为攻击判定系数) a(0.3x^2) - 目标防御值 (向上取整,负数无效)点伤害。

  • 防御增加0.25x^2(向上取整)点防御值

可以说在原型里,Pointa的核心就已经确定了——在信息不对称的情况下抢夺行动顺序,争取最自己最有利的行动,因而,我愿意将Pointa描述为一个“需要策略、计算、运气和勇气的游戏”,而在v0.21-alpha引入的新失败判定机制(玩家在操作执行时便可判定死亡,最先死亡者失败)则是为了加深这一核心,Pointa的一切策略都是基于对己方最大输出、对方最大输出、对方最有可能的操作的判断基础上进行的(实际上在原型阶段我们就已经开发出来许多游戏策略,但都因为v0.*-alpha里的时间限制机制变得更加难以执行)。

Part 2 - 从import randomPointa v0.1-alpha

Pointa的技术框架从开始写下第一行代码到目前的版本,都没什么大变化,几乎是从写下第一行代码开始,“由可以支持多游戏同时进行的服务端提供服务,核心逻辑封装成类,交由服务器全权运算,服务器逻辑负责创建、储存、查询游戏。客户端通过向服务器发送请求同步本地游戏。”

以下是当前版本v0.23-alpha的Pointa核心逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
# Pointa.py
import asyncio
import random
import math
import time


class Player(object):
'''
Player Class
'''
def __init__(self, key):
self.key = key
self.properties = {
'hp': 100,
'pt': 0,
'def': 0
}
# 0 = atk, 1 = def, 2 = hel
self.actions = {
0: 0,
1: 0,
2: 0
}

self.temp = {}

self.localVar = {
'round': 0,
'phase': 0
}

def action(self, data):
if sum(list(data.values())) > self.properties['pt']:
return False
self.properties['pt'] -= sum(data.values())
self.actions = data
return True

def roundClear(self):
self.properties['def'] = 0
self.actions = {
0: 0,
1: 0,
2: 0
}

def roll(self):
self.temp['roll'] = random.randint(1, 12)
self.properties['pt'] += self.temp['roll']
return self.temp['roll']

def damage(self, pt):
'Interface Calculates the damage'

# Set a random judge number affacts actual damage
self.temp['judge'] = random.randint(1, 12)

# Calculate the actual damage by the judge number and used pt
if self.temp['judge'] in range(1, 6):
self.temp['dmg'] = math.ceil((0.30 * pow(pt, 2)) * 0.5)
elif self.temp['judge'] in range(6, 12):
self.temp['dmg'] = math.ceil(0.30 * pow(pt, 2))
elif self.temp['judge'] == 12:
self.temp['dmg'] = math.ceil((0.30 * pow(pt, 2)) * 1.5)

# Temporary stores the defense value
self.temp['def'] = self.properties['def']

# Calculate the final defense value
self.properties['def'] = [
0,
self.properties['def'] - self.temp['dmg']
][
self.properties['def'] > self.temp['dmg']
]

# Calculate the final damage to the player
self.temp['dmg'] = [
0,
self.temp['dmg'] - self.temp['def']
][
self.temp['dmg'] > self.temp['def']
]

# Calculate the player's hp
self.properties['hp'] -= self.temp['dmg']

# Return the judge number ready to be caught by logger
return self.temp['judge']


class Pointa(object):
'''
Game Logics
'''
def __init__(self, p1, p2, loop):
self.players = {
p1.key: p1,
p2.key: p2
}
self.round = {
'num': 0,
'phase': 0
}

asyncio.events.set_event_loop(loop)

self.actions = []
self.temp = {}
self.log = []

self.status = {}

def settleRound(self):
self.actions = []
# Setp 1, Sort out the actions.
for key, p in self.players.items():
for action, value in p.actions.items():
self.actions.append({
'own': key,
'action': action,
'value': value
})
self.actions.sort(key=lambda x: (x['value'], -x['action']))

# Step 2, Take actions.
for action in self.actions:
# Ignore null action
if action['value'] != 0:
if action['action'] == 0: # Attack
self.temp['target'] = list(filter(
lambda x: x[0] != action['own'],
self.players.items()
))[0][1]
self.logger(
action['own'],
'atkJudge',
self.temp['target'].damage(action['value'])
)
# Check Player's hp
if self.temp['target'].properties['hp'] <= 0:
return self.temp['target']

elif action['action'] == 1: # Defense
self.players[
action['own']
].properties[
'def'
] = math.ceil((0.25 * pow(action['value'], 2)))

elif action['action'] == 2: # Healing
self.players[
action['own']
].properties[
'hp'
] += math.ceil(((0.35 * pow(action['value'], 2))))

# Make sure the healing action won't break the max health
if self.players[action['own']].properties['hp'] > 100:
self.players[action['own']].properties['hp'] = 100
return 0

def logger(self, actor, action, value):
self.log.append(
{
'time': int(round(time.time() * 1000)),
'actor': actor,
'action': action,
'value': value
}
)

def getStat(self):
return {
'log': self.log,
'players': self.players,
}

def waitSync(self):
# Hanging the Emulator
while not (list(list(self.players.items())[0][1].localVar.values()) and list(list(self.players.items())[1][1].localVar.values()) == list(self.round.values())):
pass
return 0

async def main(self):
'Main game Emulator'
self.round['num'] += 1
self.logger('game', 'roundBegin', self.round['num'])

# Phase 1 - Roll the points
self.round['phase'] = 1
self.logger('game', 'phaseBegin', self.round)
for key, p in self.players.items():
self.logger(key, 'pointRolled', p.roll())
# Wait For Client Sync
self.waitSync()

# Phase 2 - Wait for the actions
self.round['phase'] = 2
self.logger('game', 'phaseBegin', self.round)
await asyncio.sleep(25)

# Phase 3 - Calculate the actions
result = self.settleRound()

self.round['phase'] = 3
self.logger('game', 'phaseBegin', self.round)

# Wait For Client Sync
self.waitSync()

# Step 3, Clear the actions and wait for next Cauculating
for key, p in self.players.items():
p.roundClear()

if result != 0: # Somebody dead
self.logger('game', 'playerKilled', result.key)
self.logger('game', 'gameEnd', 0)
return self.log # Stop Coro

# Next Round
await self.main()

有点长,总共200多行,包含了一个Player类和一个Pointa类,按照现在的服务器逻辑——服务器收到游戏开始的请求,将双方玩家封装成Player类传入Pointa类,以两名玩家的标识组成键值对应游戏实例,服务器读取Pointa里的Log,处理后和玩家信息一起传回客户端让客户端更新,Pointa内一切操作通过操控Player类变量实现。

从这个文件,到v0.1-alpha,用了两周。为什么?因为最早的版本,同样也是最复杂的,再加上Pointa的开发实际上触及到了许多我之前根本没有涉猎过的区域(Python的异步),自然导致开发时间的指数级增长。

Async IO

首先遇到的,也是最耗时的,是去理解python的异步库,或许是我第一次接触异步是在js(即便也是一知半解),导致我对python的异步处理几乎是“完全无法理解”,官方文档看不懂,网络的教程也不是很能解决问题(即便是现在我也不敢说我“理解”了python的异步库,最多就是“能用”异步库解决小部分的问题)。

运行Pointa服务,要求的是一个能够动态地添加、删除Pointa主模拟器(main方法协程)的事件循环,网上的大部分方案都集中在添加callback这一方法上,但添加callback总听上去不像那么回事,于是在经过了深入的搜索后,一个更加神奇的解决方案出现了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
import asyncio
import threading


class DynamicEventLoop:
'''Creating a thread runs the event loop that can insert coroutine dynamically.
'''

def __init__(
self,
loop: asyncio.AbstractEventLoop = None,
thread: threading.Thread = None
):
'Define a loop and run it in a seperate thread'

# Creating Event Loop
if loop is not None:
self.loop = loop
else:
try:
self.loop = asyncio.get_event_loop()
except RuntimeError:
self.loop = asyncio.new_event_loop()
asyncio.set_event_loop(self.loop)

# Creating thread
if thread is not None:
self.thread = thread
else:
self.thread = threading.Thread(
target=lambda x: x.run_forever(),
args=(self.loop,)
)

self.taskList = {}
self.temp = [None, None]

def run(self):
'Starting Event Loop'
self.thread.start()

def append(self, mark, coroutine):
'''Returns the list of tasks
Append a coroutine to eventloop
mark can be **Anything**
coroutine should be a coroutine
'''
self.taskList.update(
{
mark: self.loop.create_task(coroutine)
}
)
# Send updated signal to loop
self.loop._csock.send(b'\0')
return self.taskList

def pop(self, mark):
'''Returns the result of the marked task
Delete the marked task in loop'''
try:
self.taskList.pop(mark).cancel() # Cancel the task
except KeyError:
return 'Incorrect Mark!'

上面是Pointa服务器使用的DEL——“不知道该怎么取名字而取的名字”——类,是的,这个解决方案被我封装在了这个类里面,但究其核心,其实就是一行代码——

1
self.loop._csock.send(b'\0')

原理,我自然是一知半解,但它确实是有用的,也给了我一个将事件循环封装起来的思路。至此,异步库的问题才算了“大概”解决了。

HTTP ? Socket ?

我知道,这个小标题听上去绝对怪得要死——HTTP和Socket就不是一回事啊!结合上面的低技术力发言和规范性、可读性为0的代码,这篇文章应该已经把多数大佬给气走了。(我们应该庆幸github打不了差评)

但我当时确实是在思考这个问题——那时的Point客户端预计是使用Unity写成的(也已经写了不少,虽然在后面的更新中被移除了),我的是需要一个通用的,能满足Pointa的网络传输方式,最早的版本中(可以在Commit中查看到),Pointa服务端和客户端是直接使用socket交流的。

但直接基于socket的客户端/服务端终究没有写完,究其原因,最主要的便是socket高层封装API确实不多(python中仅仅有服务端的抽象库socketserver)导致许多有用的特性例如异步处理请求都需要自己手动实现,并且socket全双工的特性在Pointa的网络传输中确实没有特别重要的作用(现在使用的HTTP轮询机制进一步优化便可以大幅度减少请求数量)

并且,Python的HTTP开发环境成熟,并且Pointa的网络传输是可以用请求-响应模型实现的,因而Pointa就转向了基于HTTP协议的开发。

经过了大约两到三天的debug,第一个版本的Pointa——v0.1-alpha发布了。

Part 3 - 从v0.2-alpha到未来……

在第一场远程的Pointa游戏结束的那一刻(即便是在本机进行的),我几乎是兴奋地跳了起来,开发结束后的第一刻,github上就出现了Pointa的第一个发行版本——v0.1-alpha,拥有一个测试版的CLI客户端,基于Flask的服务端和基于Gevent的生产运行环境(实际上服务端的技术栈到现在也没有什么变化)。

但问题也很明显,客户端太不友好了——杂乱的输出、助记符等级的输入提示、直接显示服务端发来的原生结果,但直接编写基于Unity的客户端十分不现实(尤其是当时我确实没有没有素材可用),于是下一个版本的目标——重写客户端,其成果也自然被预计为一个同样是基于CLI的客户端。

Fun fact,Pointa的v0.2-alphav0.23-alpha是一天一个版本

新客户端的进行异常顺利,几乎是一天下来就完成了,这是Pointa的v0.2-alpha所加入的:

  • 可拓展的多语言支持
  • 更加好看的输出
  • 支持用户名(这点甚至波及了服务器)

总的而言,在这个版本里,Pointa更像个游戏了,它不再是一堆测试性的代码,而是有标准输入、输出,有一定健壮性(不会因为几个错误的输入而发生致命错误),在这个版本里,我也重写了Readme文件(感谢标准README库),重新组织文档,这也使得Pointa在另一方面看上去更像一个“开源项目”了,自然也获得了几颗珍贵的Star(感谢群友大佬的鼎力支持)以及开发,试玩的宝贵意见,这也使得我有了继续开发Pointa的信心。

v0.21-alphav0.23-alpha的更新,除去BUG修复,大致是这样的:

  • 玩家断线后会从服务器名单中剔除
  • 新的判负方式
  • 不再输出没有赋值的操作
  • 服务器仅在第三阶段返回双方操作
  • 服务器运行端口可通过标准配置文件配置
  • Windows专用的客户端(预安装了python环境)上线(同时上线itch.io)
  • 匹配机制
  • Windows客户端启动器被打包为exe

越来越多东西被加进来,虽然跟Pointa的核心机制没什么关系,但都让Pointa更像一个游戏。

未来?……

Pointa到v0.23-alpha告一段落,我也准备要开学了,之后的开发很大程度上就要看情况了,Pointa很有可能没有未来,以后也有可能会是像这样没几个人玩,但谁又在乎呢?Pointa的更新照样会做,我也会继续无偿地向这个游戏注入心血——

未来开发计划

  • 模块化的游戏进程,可以在游戏内插入更多玩法模组
  • 两人以上的游戏体验
  • Unity客户端

即便没人愿意玩,但我也会去这么做,因为这足够有趣,当然,最重要的是

我乐意

Red_Cell

2020/2/4

0%