框架之网络模块

Socket负责和游服的通信,包括网络的连接、消息的接收、心跳包的发送、断线重连的监听和处理

那一个完整的网络模块包括几方面呢?(仅讨论客户端)

1.建立和服务端的socket连接,实现客户端-服务端两端的接收和发送功能。

2.消息协议的选择,网络消息的解析可以是json、xml、protobuf,本篇使用的是protobuf

3.消息缓存

4.消息的监听、分发、移除

5.客户端身份验证,由客户端、服务端生成密钥进行验证。

6.心跳包的实现,主要是检测客户端的连接情况,避免浪费服务端资源

如上所述,一套完整的unity的socket网络通信模块所包含的内容大概就是这些。

示例工程:链接: https://pan.baidu.com/s/1vJbo0ThXhShk9eJv3VNCuw 提取码: fngy 本篇文章资源连接

该工程主要是实现客户端-服务端两端的连接,以及消息的监听、派发、发送、接受等功能,心跳包未实现。

一、创建一个socekt连接

客户端代码如下:创建一个Socket对象,这个对象在客户端是唯一的,连接指定服务器IP和端口号

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
public void Connect(string host, int port)
{
if (string.IsNullOrEmpty(host))
{
Debug.LogError("NetMgr.Connect host is null");
return;
}

//IP验证
IPEndPoint ipEndPoint = null;
Regex regex = new Regex("((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)\\.){3}(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|[1-9])");
Match match = regex.Match(host);
if (match.Success)
{
// IP
ipEndPoint = new IPEndPoint(IPAddress.Parse(host), port);
}
else
{
// 域名
IPAddress[] addresses = Dns.GetHostAddresses(host);
ipEndPoint = new IPEndPoint(addresses[0], port);
}

//新建连接,连接类型
mSocket = new Socket(ipEndPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp);

try
{
mSocket.Connect(ipEndPoint);//链接IP和端口
}
catch (System.Exception e)
{
Debug.LogError(e.Message);
}
}

服务端代码:创建一个服务器Socket对象,并绑定服务器IP地址和端口号

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
public void InitSocket(string host, int port)
{
if (string.IsNullOrEmpty(host))
{
Debug.LogError("NetMgr.Connect host is null");
return;
}

//IP验证
IPEndPoint ipEndPoint = null;
Regex regex = new Regex("((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)\\.){3}(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|[1-9])");
Match match = regex.Match(host);
if (match.Success)
{
// IP
ipEndPoint = new IPEndPoint(IPAddress.Parse(host), port);
}
else
{
// 域名
IPAddress[] addresses = Dns.GetHostAddresses(host);
ipEndPoint = new IPEndPoint(addresses[0], port);
}

//新建连接,连接类型
mSocket = new Socket(ipEndPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp);

try
{
mSocket.Bind(ipEndPoint);//绑定IP和端口
mSocket.Listen(5);//设置监听数量
}
catch (System.Exception e)
{
Debug.LogError(e.Message);
}
}

二.protobuf协议生成、解析

我们在存储一串数据的时候,无论这串数据里包含了哪些数据以及哪些数据类型,当我们拿到这串数据在解析的时候能够知道该怎么解析,这是定义协议格式的目标。它是协议解析的规则。

简单的来说就是,当你传给我一串数据的时候,我是用什么样的规则知道这串数据里的内容的。JSON就制定了这么一个规则,这个规则以字符串KEY-VALUE,以及一些辅助的符号‘{’,’}’,’[‘,’]’组合而成,这个规则非常通用,以至于任何人拿到任何JSON数据都能知道里面有什么数据。

protobuf优势:这里只比较json(JSON与同是纯文本类型格式的XML相比较,JSON不需要结束标签,JSON更短,JSON解析和读写的速度更快,所以json是优于xml的)

序列化和反序列化效率比 xml 和 json 都高,序列化的二进制文件更小(传输就更快,节省流量)适合网络传输节省io,Protobuf 数据使用二进制形式,把原来在JSON,XML里用字符串存储的数字换成用byte存储,大量减少了浪费的存储空间。与MessagePack相比,Protobuf减少了Key的存储空间,让原本用字符串来表达Key的方式换成了用整数表达,不但减少了存储空间也加快了反序列化的速度。
Json明文,维护麻烦。
protobuf提供的多语言支持,所以使用protobuf作为数据载体定制的网络协议具有很强的跨语言特性

缺点:
通用性差
二进制存储易读性很差,除非你有 .proto 定义,否则你没法直接读出 Protobuf 的任何内容
需要依赖于工具生成代码
需要生成数据解析类,占用空间
协议序号也要占空间,序号越大占空间越大,当序号小于16时无需额外增加字节就可以表示。

1.protobuf语法:官方网站:https://developers.google.com/protocol-buffers/docs/proto3,英文不好可参考下面的中文语法,这边不做赘述

中文语法:https://blog.csdn.net/u011518120/article/details/54604615

大概样子如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
package protocol;

//握手验证
message Handshake{
required string token= 1;
}

//玩家信息
message PlayerInfo{
required int32 account= 1;
required string password= 2;
required string name= 3;
}

2.协议解析类的生成,如下图所示,双击protoToCs.bat文件就可以把proto文件夹下的.proto协议生成c#文件并存储在generate目录下,proto和生成的cs目录更改在protoToCs文件里面

注:原文这里应有一张 protoToCs.bat 的目录或执行结果示意图,但当前仓库未保留对应图片。

1
2
3
4
5
6
7
8
9
10
11
12
13
@echo off
@rem 对该目录下每个*.prot文件做转换
set curdir=%cd%
set protoPath=%curdir%\proto\
set generate=%curdir%\generate\
echo %curdir%
echo %protoPath%

for /r %%j in (*.proto) do (
echo %%j
protogen -i:"%%j" -o:%generate%%%~nj.cs
)
pause

3.协议的解包、封包(解析类的使用),这边协议的格式是 协议数据长度+协议id+协议数据

当要发送消息给服务端(或客户端)时,调用PackNetMsg封装成二进制流数据,接受到另一端的消息时调用UnpackNetMsg解析成对应的数据类,在分发给客户端使用

协议封包:

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
/// <summary>  
/// 序列化
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="msg"></param>
/// <returns></returns>
static public byte[] Serialize<T>(T msg)
{
byte[] result = null;
if (msg != null)
{
using (var stream = new MemoryStream())
{
Serializer.Serialize<T>(stream, msg);
result = stream.ToArray();
}
}
return result;
}

  //封包,依次写入协议数据长度、协议id、协议内容
public static byte[] PackNetMsg(NetMsgData data)
{
ushort protoId = data.ProtoId;
MemoryStream ms = null;
using (ms = new MemoryStream())
{
ms.Position = 0;
BinaryWriter writer = new BinaryWriter(ms);
byte[] pbdata = Serialize(data.ProtoData);
ushort msglen = (ushort)pbdata.Length;
writer.Write(msglen);
writer.Write(protoId);
writer.Write(pbdata);
writer.Flush();
return ms.ToArray();
}
}

解包:

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
/// <summary>  
/// 反序列化
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="message"></param>
/// <returns></returns>
static public T Deserialize<T>(byte[] message)
{
T result = default(T);
if (message != null)
{
using (var stream = new MemoryStream(message))
{
result = Serializer.Deserialize<T>(stream);
}
}
return result;
}

  //解包,依次写出协议数据长度、协议id、协议数据内容
public static NetMsgData UnpackNetMsg(byte[] msgData)
{
MemoryStream ms = null;

using (ms = new MemoryStream(msgData))
{
BinaryReader reader = new BinaryReader(ms);
ushort msgLen = reader.ReadUInt16();
ushort protoId = reader.ReadUInt16();

if (msgLen <= msgData.Length - 4)
{
IExtensible protoData = CreateProtoBuf.GetProtoData((ProtoDefine)protoId, reader.ReadBytes(msgLen));
return NetMsgDataPool.GetMsgData((ProtoDefine)protoId, protoData, msgLen);
}
else
{
Debug.LogError("协议长度错误");
}
}

return null;
}

然后这边会需要根据协议的id去生成对应的解析类,有两种方式,一种使用switch,一种是用反射的方式去生成,放射应该效率会高一点,本篇使用的是第一种(反射玩不转,我知道怎么根据类名生成指定的类,但是当参数是泛型是就盟了,评论如果有知道欢迎指出来,例如我知道类名xxx,我怎么调用Serializer.Deserialize(stream);这个方法呢,就是我要怎么用xxx替换T呢)

switch实现方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//动态修改,不要手动修改

using protocol;
public class CreateProtoBuf
{
public static ProtoBuf.IExtensible GetProtoData(ProtoDefine protoId, byte[] msgData)
{
switch (protoId)
{
case ProtoDefine.Handshake:
return NetUtilcs.Deserialize<Handshake>(msgData);
case ProtoDefine.ReqLogin:
return NetUtilcs.Deserialize<ReqLogin>(msgData);
case ProtoDefine.ReqRegister:
return NetUtilcs.Deserialize<ReqRegister>(msgData);
case ProtoDefine.RetLogin:
return NetUtilcs.Deserialize<RetLogin>(msgData);
case ProtoDefine.RetRegister:
return NetUtilcs.Deserialize<RetRegister>(msgData);
default:
return null;
}
}
}

createbuf这个类如果手撸的话,几百种协议还是很头疼的,所以我这边是写了个工具去生成这个类,模板也是可以实现这个功能的

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
public static void WriteCreateBufClass()
{
using (StreamWriter sw = new StreamWriter(Application.dataPath + "/Scripts/Engine/Net/CreateProtoBuf.cs", false))
{
sw.WriteLine("//动态修改,不要手动修改\n");
sw.WriteLine("using protocol;");
sw.WriteLine("public class CreateProtoBuf");
sw.WriteLine("{");
sw.WriteLine(" public static ProtoBuf.IExtensible GetProtoData(ProtoDefine protoId, byte[] msgData)");
sw.WriteLine(" {");
sw.WriteLine(" switch (protoId)");
sw.WriteLine(" {");

foreach (int value in Enum.GetValues(typeof(ProtoDefine)))
{
string strName = Enum.GetName(typeof(ProtoDefine), value);//获取名称
sw.WriteLine(string.Format(" case ProtoDefine.{0}:", strName));
sw.WriteLine(string.Format(" return NetUtilcs.Deserialize<{0}>(msgData);", strName));
}

sw.WriteLine(" default:");
sw.WriteLine(" return null;");
sw.WriteLine(" }");
sw.WriteLine(" }");
sw.WriteLine("}");
}
}

这样协议的生成、解析都有了,剩下的就是消息的管理了

三、消息的缓存、接受、发送

客户端消息队列:总共生成四个缓存队列,两个子线程,一个用于发送消息,一个用于接收消息,主要是防止同时接受、发送多条信息,以及实现转菊花的效果(发送消息开始转菊花,服务器回包后结束菊花,防止重复发送消息)

发送代码如下:创建两个队列,一个用于存储主线程的等待发送的队列(由各模块调用),一个用于子线程向服务器发送消息(使用支线程向socket发送消息,减少主线程压力)

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
void Send()
{
while (this.mIsRunning)
{
if (mSendingMsgQueue.Count == 0)
{
lock (this.mSendLock)
{
while (this.mSendWaitingMsgQueue.Count == 0)
Monitor.Wait(this.mSendLock);
Queue<NetMsgData> temp = this.mSendingMsgQueue;
this.mSendingMsgQueue = this.mSendWaitingMsgQueue;
this.mSendWaitingMsgQueue = temp;
}
}
else
{
try
{
NetMsgData msg = this.mSendingMsgQueue.Dequeue();
byte[] data = NetUtilcs.PackNetMsg(msg);
mSocket.Send(data, data.Length, SocketFlags.None);
Debug.Log("client send: " + (ProtoDefine)msg.ProtoId);
}
catch (System.Exception e) {
Debug.LogError(e.Message);
Disconnect();
}
}
}

this.mSendingMsgQueue.Clear();
this.mSendWaitingMsgQueue.Clear();
}

  //业务调用接口
public void SendMsg(ProtoDefine protoType, IExtensible protoData)
{
if (!this.mIsRunning) return;
lock (this.mSendLock)
{
mSendWaitingMsgQueue.Enqueue(NetMsgDataPool.GetMsgData(protoType, protoData));
Monitor.Pulse(this.mSendLock);
}
}

数据的接受:创建两个队列,一个用于缓存子线程从服务器接受的消息,一个用于向主线程分发消息

这边的update方法需要由主线程调用,或者使用协程也是可以实现的。

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
void Receive()
{
byte[] data = new byte[1024];
while (this.mIsRunning)
{
try
{
//将收到的数据取出来
int len = mSocket.Receive(data);
NetMsgData receive = NetUtilcs.UnpackNetMsg(data);
Debug.Log("client receive : " + (ProtoDefine)receive.ProtoId);

lock (this.mRecvLock)
{
this.mRecvWaitingMsgQueue.Enqueue(receive);
}
}
catch (System.Exception e)
{
Debug.LogError(e.Message);
Disconnect();
}

}
}

public void Update()
{
if (!this.mIsRunning) return;

if (this.mRecvingMsgQueue.Count == 0)
{
lock (this.mRecvLock)
{
if (this.mRecvWaitingMsgQueue.Count > 0)
{
Queue<NetMsgData> temp = this.mRecvingMsgQueue;
this.mRecvingMsgQueue = this.mRecvWaitingMsgQueue;
this.mRecvWaitingMsgQueue = temp;
}
}
}
else
{
while (this.mRecvingMsgQueue.Count > 0)
{
NetMsgData msg = this.mRecvingMsgQueue.Dequeue();
//发送给逻辑处理
NetMsg.DispatcherMsg(msg);
}
}
}

四、消息的监听、派发,业务通过这个类和socket交互

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
using System;
using System.Collections.Generic;
using ProtoBuf;
using protocol;

public delegate void NetCallBack(IExtensible msgData);

/// <summary>
/// 业务和socket交互的中间层
/// </summary>
public class NetMsg
{
private static Dictionary<ProtoDefine, Delegate> m_EventTable = new Dictionary<ProtoDefine, Delegate>();

/// <summary>
/// 监听指定的消息协议
/// </summary>
/// <param name="protoType"></param> 需要监听的消息
/// <param name="callBack"></param> 当接收到服务端的消息时,需要触发的消息
public static void ListenerMsg(ProtoDefine protoType, NetCallBack callBack)
{
if (!m_EventTable.ContainsKey(protoType))
{
m_EventTable.Add(protoType, null);
}

m_EventTable[protoType] = (NetCallBack)m_EventTable[protoType] + callBack;
}

/// <summary>
/// 移除监听某条消息
/// </summary>
/// <param name="protoType"></param>
/// <param name="callBack"></param>
public static void RemoveListenerMsg(ProtoDefine protoType, NetCallBack callBack)
{
if (m_EventTable.ContainsKey(protoType))
{
m_EventTable[protoType] = (NetCallBack)m_EventTable[protoType] - callBack;

if (m_EventTable[protoType] == null)
{
m_EventTable.Remove(protoType);
}
}
}

/// <summary>
/// 接收到服务端消息时,会调用这个接口通知监听这调协议的业务
/// </summary>
/// <param name="msgData"></param>
public static void DispatcherMsg(NetMsgData msgData)
{
ProtoDefine protoType = (ProtoDefine)msgData.ProtoId;
Delegate d;
if (m_EventTable.TryGetValue(protoType, out d))
{
NetCallBack callBack = d as NetCallBack;
if (callBack != null)
{
callBack(msgData.ProtoData);
}
}
}

/// <summary>
/// 向服务端发送消息
/// </summary>
/// <param name="protoType"></param>
/// <param name="protoData"></param>
public static void SendMsg(ProtoDefine protoType, IExtensible protoData)
{
SocketClint.Instance.SendMsg(protoType, protoData);
}
}

五、客户端身份验证,做完上面的步骤,你已经可以生成、解析、使用消息协议,也可以和服务端通信了,其实通信功能就已经做完了,但是客户端验证和心跳包又是游戏绕不过去的一个步骤,所以 我们继续~

认证的过程大概是这样子的(以我当前的项目为例)

1.客户端随机生成一个密钥client_key,使用某种加密算法通过刚生成的密钥client_key将自己的client_token加密,然后将加密后的client_token和密钥发送给登录服(client_token只是一个字符串,客户端和服务端都有,这边的加密算法加密时需要一个密钥,服务端和客户端的加密算法是一样的)

2.登录服收到客户端的消息,通过客户端发送的密钥client_key解密出客户端的client_token,通过比对这个client_token能确定是不是正确的客户端,如果是,登录服随机生成一个密钥server_key,并将使用server_key加密后的登录服server_token连同server_key发送给客户端

3.客户端收到登录服返回的消息,通过登录服发送的密钥server_key解密出登录服的server_token,通过比对这个server_token能确定是不是正确的登录服

4.双方身份验证后进行账号验证,客户端重新生成密钥client_key2,将自己的账号、密码、设备id等信息加密成client_info连同client_key2发送给登录服

5.登录服接收到客户端消息后,过客户端发送的密钥client_key2解密出客户端的client_info,通过比对账号、密码信息,返回一个游服的token,并把该token同步给游服

6.客户端通过登录服返回的游服token登录游服,关闭登录服连接

那么为什么要有登录服呢,我个人的理解是1.登录服可以很大的分摊游服的压力,特别是开服的时候2.游戏服一般会有很多(例如slg的王国),而登录服只会有一个?好吧 这个有知道的大神麻烦在评论告诉我下

六、心跳包,具体可以参考https://gameinstitute.qq.com/community/detail/101837

心跳包主要用于长连接的保活和断线处理,socket本身的断开通知不是很靠谱,有时候客户端断开网络,Socket并不能实时监测到,服务器还维持这个客户端不必要的引用

心跳包之所以叫心跳包是因为:它像心跳一样每隔固定时间发一次,以此来告诉服务器,这个客户端还活着加了服务器的负荷

怎么发送心跳?

1:轮询机制:概括来说是服务端定时主动的与客户端通信,询问当前的某种状态,客户端返回状态信息,客户端没有返回,则认为客户端已经宕机,然后服务端把这个客户端的宕机状态保存下来,如果客户端正常,那么保存正常状态。如果客户端宕机或者返回的是定义

的失效状态那么当前的客户端状态是能够及时的监控到的,如果客户端宕机之后重启了那么当服务端定时来轮询的时候,还是可以正常的获取返回信息,把其状态重新更新。

2:心跳机制:最终得到的结果是与轮询一样的但是实现的方式有差别,心跳不是服务端主动去发信息检测客户端状态,而是在服务端保存下来所有客户端的状态信息,然后等待客户端定时来访问服务端,更新自己的当前状态,如果客户端超过指定的时间没有来更新状态,则认为客户端已经宕机。
心跳比起轮询有两个优势:1.避免服务端的压力2.灵活好控制

游戏前端架构师养成之路

前言
架构师,一个非常牛逼的称呼。待遇好、工资高,属于公司大牛级别。 希望这篇文章,能成为您成为牛逼架构师的垫脚石。

国内前端项目开发的框架,一般由 前端主程 负责搭建维护,少部分是有专门的架构师岗位。

框架来源,一般是自己搭建 或者使用比较优秀的开源框架。

游戏框架必要性
设计框架是为了提高开发效率、降低维护成本、提高代码可复用性和可扩展性,以及促进团队协作。设计好的框架能够提高项目的开发质量和效率,使得项目能够更好地满足用户需求和市场需求。

(下面列举的,可以简单扫一眼)

架构的好处:

提高开发效率:框架提供了一种标准化的开发方式和一系列工具和组件,能够快速搭建项目基础结构,减少重复劳动,提高开发效率。
降低维护成本:框架能够规范化项目代码结构和开发流程,使得代码易于阅读和维护,降低项目的维护成本。
提高代码可复用性:框架提供了一系列的组件和工具,能够把开发中常用的功能封装成可复用的组件,提高代码的可复用性。
提高代码的可扩展性:框架能够规范化代码结构和开发流程,使得代码易于扩展和修改,降低项目的开发风险。
促进团队协作:框架可以统一团队开发标准和流程,使得团队成员之间代码交流和沟通更加顺畅,提高团队协作效率。
前端游戏架构师
前端游戏架构师是负责设计和搭建前端游戏架构的专业人员。他们负责设计和实现游戏前端系统的架构,制定技术方案和技术规范,并领导和指导开发团队完成游戏的前端开发工作。

前端游戏架构师是一位具备丰富的前端开发经验和游戏开发经验,能够负责游戏前端系统的设计和实现,制定技术规范和管理开发团队的专业人员。

(下面列举的,可以简单扫一眼)

前端游戏架构师需要具备以下技能和能力:

熟练掌握游戏开发的相关技术,如Unity引擎、Cocos2d-x、HTML5等。
具备丰富的前端开发经验,熟悉前端技术的发展趋势和最佳实践。
熟悉游戏开发的工作流程,具备较强的项目管理能力和团队协作能力。
能够对游戏前端开发的技术选型、系统架构和性能优化等方面进行深入思考和决策。
具备较强的解决问题的能力,能够快速定位和解决游戏前端开发中遇到的各种问题。
前端框架设计
我们设计框架的时候,需要UI管理模块:这个模块负责管理游戏中所有UI元素的创建、显示、隐藏和销毁等操作。在设计时,需要考虑如何实现UI元素的层级管理、布局管理、动画效果和交互处理等功能。

下面介绍一般框架里面需要包含的模块:

UI管理:这个模块负责所有UI元素的管理和呈现,例如创建、显示、隐藏、销毁UI元素,以及切换不同UI画面之间的逻辑。在设计时需要考虑到UI元素的复杂度和嵌套层次,以及UI画面的切换和缓存机制。
数据绑定:这个模块允许UI元素和游戏数据之间的双向绑定,以确保UI元素的显示和游戏数据的同步更新。这个模块需要设计一个可靠的数据绑定机制,以便在游戏运行时及时地更新UI显示。同时需要考虑到数据绑定的性能和可扩展性。
事件处理:这个模块负责处理各种用户交互事件,例如点击、拖拽、滑动等等。它需要设计一个事件派发系统,以确保事件能够被正确地传递给目标UI元素。同时需要考虑到事件的优先级、多点触控和手势识别等问题。
资源管理:这个模块需要设计一个可靠的资源管理系统,以确保游戏所需的资源能够及时地加载和释放。这个模块需要考虑到游戏资源的优先级、加载顺序和内存占用等问题。同时需要支持资源的异步加载和缓存机制。
效果展示:这个模块负责各种视觉和声音效果的展示,例如场景过渡动画、音效和音乐等等。它需要设计一个可扩展的效果展示系统,以便开发人员能够轻松地添加和修改游戏效果。同时需要支持效果的异步加载和缓存机制。
网络通信:这个模块负责游戏客户端和服务器之间的通信。它需要设计一个可靠的网络协议和消息处理机制,以便实现游戏的多人联机和服务器通知等功能。同时需要考虑到网络延迟和带宽限制等问题。
游戏逻辑:这个模块负责游戏的核心逻辑实现。它需要设计一个可扩展的游戏逻辑框架,以便开发人员能够快速地添加和修改游戏逻辑。同时需要考虑到游戏的性能和稳定性。
工具扩展:这个模块提供了一系列实用工具,例如UI编辑器、图形工具、资源打包工具等等。在设计时,需要考虑如何实现工具集的模块化和可扩展性、工具集和游戏引擎的集成、工具集的功能和性能优化等功能。
结语
我们了解了架构师需要掌握的技能,以及常用的框架的组成。在以后的工作和学习中,多学习借鉴优秀的框架,提炼出一套属于自己的框架。

游戏性能优化总结

性能优化是游戏开发中非常重要的一环,它直接影响到游戏的流畅度和用户体验。我们的游戏基本都是UI为主

UI的一些建议

显示和隐藏我们可以使用移除到Canvas外,而不是利用SetActive(false)

UI的批处理
将更新频率不一样的UI放在不同的canvas上
相同canvas中的UI元素的Z值相同,这样才不会打断合批
相同的canvasUI元素使用相同的材质和纹理,有动态变化不影响合批
相同的canvas中的UI元素要使用相同的裁剪矩阵。
Graphic Paycaster
Text 、spine, image 禁用 caster target

全屏UI的处理
3d场景完全被遮挡,关闭3d场景
被遮挡的UI, disable canvas,不是setActive(false)
尽可能降低帧率,UI不需要刷新那么频繁。

其他的一些优化

Gameobject 层级结构
某些情况下,场景中的物体坑有很深的嵌套结构,
对父节点的Gameobject 移动,会产生OnTransformCHanged 会传递给子对象,所有子对象
即使没有渲染组件的,会造成一些不必要的转换运算。
较深的结构也会导致GC更耗时,

避免在awake 和 start 中添加大量的逻辑,游戏启动黑屏太久。因为游戏是在、Start 方法后执行渲染第一个画面

删除空的unity 事件,Awake() Start() Update
即使是空的也会带来微性能消耗

accelerometer Frequency
Project setting - player -ios - other settings ,不需要加速仪的游戏中,关闭,或者设置低频。会增加cpu负担。

移动物体,
如果对象需要碰撞检测,不用tranform。Translate, 用rigidBody.MovePosition, addForce 并且是在fixedupdate 方法中执行。

添加组件
在运行时调用AddComponet其实很没有效率,尤其是一帧中多次启用这类调用
当我们添加一个组件的时候,
影响性能,增加GC的处理时间

数据结构,array,list 和dictionary
array list使用索引成本低,适合要经常通过索引读取的情况,而频繁增加和移除对象,使用dictionary

audio的优化

project settings
DSF buffer:FMOD声音引擎有个buff,就是DSF buffer,当buff满了才会去向CPU发送一次播放声音的指令。Default、Best Latency,Good latency, Best performance

audio source
Force To Mono:强制单声道
Compression:Android: Vorbis 、IOS:ADPCM、MP3
LoadType:Decompress OnLoad 必须<200k Complress In memory 中型音频文件,streaming,大型,背景音
Bitrate 减低文件的比特率,前提是不会破坏太严重。

静音处理:disable audiosource组件,建议在内存中卸载音频相关的来源

Code size;代码也占内存,模版泛型的滥用。

assetbundle ,typeTree,不同版本数据结构的兼容,TypeTree build 的版本没有升级,关闭它
BuildAssetBundle.option.DisableWriteTypeTree

压缩方式 lz4, chunkBasedCompression。
大小和数量 ;官方建议是一个1-2m,现在是5g时间可以适当加大,

禁用Resources

Texture
Project setting
Async Upload Buffsize 16

Read/Write 关闭,不然会出现两份(内存和显存都在内存)
mipmap, ui 元素禁用
format ASTC
alpha 对完全不透明的纹理 关闭alpha通道

max size 设置成该平台最小值
合并,尽量多张纹理合并成大图

mesh 网格
read/write 关闭
compression 关闭(有些版本卡了,解压后内存占用会很大)
Rig 没有动画的比如静态的房子,石头 关闭
Blend shapes 没有用的的也禁用
met额日阿里 没有用的法向量和切线,也可以关闭。

优化managed memory

游戏对象,显示的调用Destroy 不要用null,销毁不完全。

Class 和 Struct 根据具体使用情况选择class 或struct
减少装箱拆箱操作
减少LINQ表达式,String。Fromat()

对象池
创建和销毁游戏对象很常见,频繁创建和销毁GC会导致负载很重而波动
还会导致堆内存碎片化。对象池大小要合适。

闭包和匿名函数( 在编译成IL代码中会被new成匿名类。
所有函数,变量,以及new出来的东西都要占用内存。

协程
协程属于闭包和匿名函数的特例,建议new的时候生存一个协程,不用的时候丢掉

配置表
分关卡加载配置。

慎用单例,不要什么都往里放,变量会一直占用内存。

scriptable object ,只会消耗一组数据内存。

变量Or 属性
属性本质上是函数的调用,会在堆栈上被分配内存,调用属性也是如此,当然一般来说问题不大,
可以用宏命令,开发时使用属性,发布时使用变量。

缓存hash值
int h = Animator.StringToHash(“idle”)
animation:SetTrigger(h)

int s = Shader.PropertyToID(“color”)
material.SetColor(s,Color.white)

缓存引用对象
不要多次查询相同的对象或组件,查询一次后将其缓存起来,
方便后续使用。

图像(Graphics) 的一些优化建议
使用批处理来尽量减少draw call。
静态批处理满足:要合批的对象必须引用一样的材质,并使用相同的纹理(纹理合并在这里就体现出来了),使用的模型可以不一样。

动态批处理: 减少移动对象的drawcall,只能用于少用900个顶点的信息。包含坐标,法线,uv0,uv1,q切线,每帧评估一次,cpu处理

castShadows
meshRender 组件的Cast Shadow 开启阴影渲染,增加真实度和深度感,增加多余的阴影计算,最后看不见。如果对阴影没影响,可以关闭。

light Culling mask 分层,确保只影响特定层( 专门给角色打光的光源,设置成只影响角色)。尤其是多光源,多对象。

避免使用手机原生分辨率
现代手机分辨率很高,在手机上程序影响性能和手机过热
使用Screen。SetResolution来降低游戏预设的解析设置,来提高性能。

xlua-framework框架简介

由于slot项目是一个需要快速迭代,经常修改和发布的游戏,因此我们决定用lua来写业务逻辑,选择了使用这个xlua-framework,在此基础上架构开发。

xlua-framework框架,是一个纯lua的框架,基于XLua,整合ToLua的Proto-gen-lua,以及大部分ToLua作者整合的库,除了Assetbundle模块,大部分游戏逻辑全部用lua实现,目前实现的内容包括:

UI管理模块:使用UGUI,基于MVC架构,View层实现一套组件管理系统,提供类似于Unity侧Mono脚本的调度机制。
场景管理模块:场景调度、Loading界面、资源预加载
网络模块:Protobuff、TCP连接器
资源管理模块:打包工具、资源热更、资源加载、资源缓存
定时器管理
协程管理
配置表管理
工程目录
AssetBundles:AB打包输出目录

ConfigData:配置表目录

ProtoToCS:Proto文件目录

ProtoToLua:proto-gen-lua工作目录

Qudao:渠道目录

Tools:XLua工具目录

Xlua-lib-build:xlua库构建工程(另设Git项目:https://github.com/smilehao/xlua-lib-build。)

Assets目录说明
AssetsPackage:取代Resources目录,存放项目资源

EasyTouchBundle:EasyTouch插件

Editor:编辑器脚本

LuaScripts:Lua脚本

Plugins:插件

Reporter:真机日志输出插件

Scenes:场景

Scripts:CS脚本

StreamingAssets:AB打包资源

Lua脚本目录说明
Common:通用工具脚本,其中Tools目录下的脚本由ToLua迁移过来,其它脚本为自定义的扩展和工具类脚本

Config:配置文件目录,其中Data目录为Lua配置自动生成工具的输出目录

Framework:框架基础脚本,这部分代码具有一定通用性,与游戏逻辑无关。

GameModule:游戏逻辑脚本

GameTheme:游戏机台主题脚本

Global:全局配置脚本

Net:网络逻辑相关脚本

Resource:资源逻辑相关脚本

Scenes:场景逻辑相关脚本

UI:UI逻辑相关脚本

UITest:单元测试

XLua:XLua热修复脚本,其中HotfixMain.lua为lua热修复逻辑入口。

GameMain:游戏逻辑入口

Scripts目录说明
XLua:其中Gen为生成脚本、Support为自定义支持脚本

FrameWork:框架基础脚本

其它:Common通用模块、Config配置、GameLaunch游戏入口及资源热更、Platform渠道相关、Test测试和调试相关

游戏启动流程
启动场景:LauchScene场景,场景启动后GameLaunch.cs脚本启动

启动资源管理模块AssetBundleManager

启动资源热更新模块AssetBundleUpdater

资源热更新:如果需要更新,则更新完毕后重启资源管理器和Lua虚拟机(热修复模块)

启动游戏:进入Lua脚本执行后续游戏逻辑

Lua端启动流程
GameMain.lua:lua的入口类,启动lua端的框架,如ConfigManager,DataManager,SceneManager,UIManager,UpdaterManager,等等
加载场景:比如:LoginScene,打开UI页面比如:UILoginUI,接下来就可编写自己的逻辑和页面跳转了。
详细可参考框架demo:

xlua-framework:https://github.com/smilehao/xlua-framework

游戏设计模式学习

架构,性能和游戏 (已看)
再探设计模式
命令模式 (已看)
享元模式 (已看)
观察者模式 (已看)
原型模式 (已看)
单例模式 (已看)
状态模式 (已看)
序列型模式
双缓冲 (已看)
游戏循环 (已看)
更新方法 (已看)
行为型模式
字节码 (已看)
子类沙盒 (已看)
类型对象 (已看)
解耦型模式
组件模式 (已看)
事件队列 (已看)
服务定位器 (已看)
优化型模式
数据局部性
脏标记模式
对象池 (已看)
空间分区
第1章 架构,性能和游戏
架构就是关于代码的组织方式
架构意味着变化.衡量一个设计好坏的方法就是看它应对变化的灵活性
一旦你理解了问题和它涉及的代码,则实际的编码有时是微不足道的
你可以用一堆方式来定义”解耦”,但我认为如果两块代码耦合,意味着你必须同时了解这两块代码.如果你让它们解耦,那么你只需了解其一.
当然,对解耦的另一个定义就是当改变了一块代码时不必更改另外一块代码.很明显,我们需要更改一些东西,但是耦合得越低,更改所波及得范围就越小
良好的架构需要很大的努力及一系列准则.每当你做出一个改变或者实现一个功能时,你必须很优雅地将它们融入到程序的其余部分.
你必须非常谨慎地组织代码并保证其在开发周期中经过数以千计的小变化之后仍然具有良好的组织性
没有人可以在纸上设计出一个平衡的游戏.这需要迭代和实验.
原型(把那些仅仅在功能上满足一个设计问题的代码融合在一起)是一个完全正确的编程实践
开发中我们有几个因素需要考虑

  1. 我们想获得一个良好的架构,这样在项目的生命周期中便会更容易理解代码
  2. 我们希望获得快速的运行时性能
  3. 我们希望快速完成今天的功能
    这些目标至少部分是相冲突的.好的架构从长远来看,改进了生产力,但维护一个良好的架构就意味着每一个变化都需要更多的努力来保持代码的干净
    最快编写的代码实现却很少是运行最快的.相反,优化需要消耗工程时间.一旦完成,也会使代码库僵化:高度优化过的代码缺乏灵活性,很难改变
    完成今日的工作并担心明天的一切总伴随着压力.但是,如果我们尽可能快的完成功能,我们的代码库就会充满了补丁,bug和不一致的混乱,会一点点地消磨掉我们未来的生产力
    这里没有简单的答案,只有权衡
    抽象和解耦能够使得你的程序开发变得更快和更简单.但不要浪费时间来做这件事,除非你确信存在问题的代码需要这种灵活性
    在你的开发周期中要对性能进行思考和设计,但是要推迟那些降低灵活性的,底层的,详尽的优化,能晚则晚
    尽快地探索你的游戏的设计空间,但是不要走得太快留下一个烂摊子给自己.毕竟你将不得不面对它
    如果你将要删除代码,那么不要浪费时间将它整理得很整洁.摇滚明星把酒店房间弄得很乱是因为他们知道第二天就要结账走人.
    但是,最重要得是,若要做一些有趣得玩意,那就乐在其中地做吧
    第2章 命令模式
    将一个请求封装成一个对象,从而允许你使用不同的请求,队列或日志将客户端参数化,同时支持请求操作的撤销和恢复
    命令就是一个对象化(实例化)的方法调用
    这个术语意味着,将某个概念转化为一块数据,一个对象,或者你可以认为是传入函数的变量等.
    命令就是面向对象化的回调
    一些语言的反射系统可以让你在运行时命令式地处理系统中的类型.你可以获取到一个对象,它代表着某些其他对象的类,你可以通过它试试看这个类型能做些什么.话句话说,反射是一个对象化的类型系统
  4. 你可能最终会有很多不同的命令类.为了更容易地实现这些类,可以定义一个具体的基类,里面有着一些实用的高层次的方法,这样便可以通过对派生出来的命令组合来定义其行为,这么做通常是有帮助的.它会将命令的主要方法变成子类沙盒
  5. 在我们的例子中,我们明确地选择了那些会执行命令的角色.在某些情况下,尤其是在对象模型分层的情况下,它可能没有这么直观.一个对象可以响应一个命令,而它也可以决定将命令下放给其从属对象.如果你这样做,你需要了解下责任链
  6. 一些命令如第一个例子中的JumpCommand是无状态的纯行为的代码块.在类似这样的情况下,拥有不止一个这样命令类的实例会浪费内存,因为所有的实例是等价的.享元模式就是解决这个问题的.
    第3章 享元模式

享元,一般来说当你有太多对象并考虑对其进行轻量化时它便能派上用场
享元模式通过将对象数据切分成两种类型来解决问题.
第一种类型数据是那些不属于单一实例对象并且能够被所有对象共享的数据.称为内部状态,但我更喜欢将它认为是”上下文无关”的状态.在本例中,这指的便是数木的几何形状和纹理数据等.
你并不会在一开始便创建所有的享元.如果你不能预测哪些是你真正需要的,则最好按需创建它们.为了获得共享优势,当你需要一个对象时,你要先看看你是否已经创建了一个相同的对象.如果是,则只需返回这个实例.这通常意味着在一些用来查找现有对象的接口背后,你必须做些结构上的封装.像这样隐藏构造函数,其中一个例子就是工厂方法模式
为了找到以前创建的享元,你必须追踪哪些你已经实例化过的对象的池,对象池模式对于存储它们会很有用
在使用状态模式时,你经常会拥有一些”状态”对象,对于状态所处的状态机而言它们没有特定的字段.状态的标识和方法也足够有用.在这种情况下,你可以同时在多个状态机中始使用这种模式,并且重用这个相同的状态实例并不会带来任何问题
第4章 观察者模式
在对象间定义一种一对多的依赖关系,以便当某对象的状态改变时,与它存在依赖关系的所有对象都能收到通知并自动进行更新
在计算机上随便打开一个应用,它就很有可能就是采用MVC架构开发,而其底层就是观察者模式.观察者模式应用十分广泛,Java甚至直接把它集成到了系统库里面(java.util.Observer),C#更是直接将它集成在了语言层面(event关键字)
通知方法会被正在被观察的对象调用.被观察对象,它有两个职责.首先,它拥有观察者的一个列表,这些观察者在随时候命接收各种各样的通知,其次就是发送通知
允许外部的代码来控制谁可以接收通知.这个被观察者对象负责和观察者对象进行沟通,但是,它并不与它们耦合
同时,被观察者对象拥有一个观察者对象的集合,而不是单个观察者,这也是很重要的.它保证了观察者们并不会隐式地耦合在一起
这意味着,两个系统会相互干扰对方—-而且是以一种很不恰当的方式,因为第二个观察者使第一个观察者失效了.观察者集合的存在,可以让每一个观察者都互相不干扰.在它们各自的眼里,都认为被观察者对象眼里只有它自己
这就是”观察者”系统和”事件”系统的区别.前者,你观察一个事情,它做了一些你感兴趣的事.后者,你观察一个对象,这个对象代表了已经发生的有趣的事情.
发送一个通知,只不过需要遍历一个列表,然后调用一些虚函数.老实讲,它比普通的函数调用会慢一些,但是虚函数带来的开销几乎可以忽略不计,除了对性能要求极其高的程序
设计模式会遭人诟病,大部分是由于人们用一个好的设计模式去处理错误的问题,所以事情变得更加糟糕了
当一个被观察者对象被删除时,观察者本身应该负责把它自己从被观察者对象中移除.通常情况下,观察者都知道它在观察着哪些被观察者,所以需要做的只是在析构器中添加一个removeObserver()方法
当一个被观察者对象被删除时,如果不我们不想让观察者来处理问题,则可以修改以下做法.我们只需要在被观察者对象被删除之前,给所有的观察者发送一个”死亡通知”就可以了.这样,所有已注册的观察者都可以收到通知并进行相应的处理
观察者模式非常适合于一些不相关的模块之间的通信问题.它不适合于单个紧凑的模块内部的通信.
这也是为什么它适合我们的例子: 成就系统和物理系统是完全不相关的领域,而且很有可能是由不同的人实现的.我们想让它们的通信尽可能地减少,这样任何一个模块都不用依赖另一个模块就可以工作
第5章 原型模式
使用特定原型实例来创建特定种类的对象,并且通过拷贝原型来创建新的对象.
第6章 单例模式
确保一个类只有一个实例,并为其提供一个全局访问入口
后悔使用单例的原因,它是一个全局变量
我们学到的一个教训就是,全局变量是有害的
我见过的游戏中的许多单例类都是”managers” —-这些保姆类只是为了管理其他对象.有时为了区别,它们叫做”System”或”Engine”,不过只是改了名字而已
尽管保姆类有时是有用的,不过这通常反映出它们对OOP不熟悉.比如下面这两个虚构的类
就这样.没有管理器也没有问题.设计糟糕的单例通常会”帮助”你往其他类中添加功能.如果可以,你只需要将这些功能移动到它所帮助的类中去就可以了.毕竟,面向对象就是让对象自己管理自己
通用的原则是,在保证功能的情况下将变量限制在一个狭窄的范围内.对象的作用越小,我们需要记住它的地方就越少.在我们盲目地采用具有全局作用域的单例对象之前,让我们考虑下代码库访问一个对象的其他途径
  传递进去: 最简的解决方式,通常也是最好的方式,就是将这个对象当作一个参数传递给需要它的函数
  在基类中获取它: 许多游戏架构有浅层次但是有宽度的继承体系,通常只有一层继承 面向切面编程
  通过其他全局对象访问它: 我们可以通过将全局对象类包装到现有类里面来减少它们的数量.
  通过服务定位器来访问: 到现在为止, 我们假设全局类就是像Game那样的具体类.另外一个选择就是定义一个类专门用来给对象做全局访问. 这个模式被称为服务定位器模式
还有一个问题,我们应该在什么情况下使用真正的单例呢?
为了确保只实例化一次,我通常只是简单地使用一个静态类.如果那不起作用,我就会用一个静态的标识位在运行时检查是否只有一个类实例被创建
第7章 状态模式
允许一个对象在其内部状态改变时改变自身的行为.对象看起来好像是在修改自身类.
有限状态机(FSM)可以看作最简单的图灵机
整个状态机可以分为: 状态,输入和转换
  你拥有一组状态,并且可以在这组状态之间进行切换
  状态机同一时刻智能处于一种状态
  状态机会接收一组输入或者事件
  每一个状态有一组转换,每一个转换都关联着一个输入并指向另外一个状态
层次状态机:一个状态有一个父状态.当有一个事件进来的时候,如果子状态不处理它,那么沿着继承链传给它的父状态来处理.换句话说,它有点像覆盖继承的方法
  你可以把新的状态放入栈里面.当前的状态永远存在栈顶,所以你总能转换到当前状态.但是当前状态会将前一个状态压在栈中自身的下面而不是抛弃掉它
  你可以弹出栈顶的状态,改状态将被抛弃.与此同时,上一个状态就变成了新的栈顶状态了
即使有了这些通用的状态机扩展,它们的使用范围仍然是有限的.在游戏的AI领域,最近的趋势是越来越倾向于行为树和规划系统.
但是这并不意味着有限状态机,下推自动机和其他简单的状态机没有用.它们对于解决某些特定的问题是一个很好的建模工具.当你的问题满足以下几点要求的时候,有限状态机将会非常有用
  你有一个游戏实体,它的行为基于它的内部状态而改变
  这些状态被严格划分为相对数目较少的小集合
  游戏实体随着时间的变化会响应用户输入和一些游戏事件
第8章 双缓冲
诸如计算机显示器的显示设备在每一时刻仅绘制一个像素.显示设备从左至右地扫描屏幕屏幕每行中的像素,并如此从上至下地扫描屏幕上的每一行.当它扫描至屏幕的右下角时,它将重定位至屏幕的左上角并如前述那样地重复扫描屏幕.这一扫描过程是如此地快速(大概每秒60次),以至于我们的眼睛无法察觉这一过程.对于我们而言,扫描的结果就是屏幕一块彩色像素组成的静态区域,即一张图片
我们的程序一次只渲染一个像素,同时我们要求显示器一次性显示所有的像素—-可能这一帧看不到任何东西,但下一帧显示的就是完整的笑脸.双缓冲模式解决了这一问题
双缓冲中的一个缓存用于展示当前帧,于此同时,渲染代码正在另一个缓冲区中写入数据.当渲染代码完成时,通过交换缓冲区,使得显卡驱动开始从第一个缓冲区转向第二个缓冲区以读取其数据进行渲染.只要它掌握好时机在每次刷新显示器结束时进行切换,我们就不会看到任何衔接的裂隙,且整个场景能一次性的瞬间显示出来
定义一个缓冲区类来封装一个缓冲区:一块能被修改的状态区域.这块缓冲区能被逐步地修改,但我们希望任何外部的代码将对该缓冲区的修改都视为原子操作.为实现这一点,此类中维护两个缓冲区实例:后台缓冲区和当前缓冲区
当要从缓冲区读取信息时,总是从当前缓冲区读取.当要往缓冲区中写入数据时,则总在后台缓冲区上进行.当改动完成后,则执行”交换”操作来将当前缓冲区与后台缓冲区进行瞬时的交换,以便让新的缓冲区为我们所见,同时刚被换下来的当前缓冲区则成为现在的后台缓冲区以供复用
当下面这些条件都成立时,使用双缓冲模式:
  我们需要维护一些被逐步改变着的状态量
  同个状态可能会在其被修改的同时被访问到
  我们希望避免访问状态的代码能看到具体的工作过程
  我们希望能够读取状态但不希望等待写入操作的完成
双缓冲模式需要在状态写入完成后进行一次交换操作,操作必须是原子性的:也就是说任何代码都无法在这个交换期间对缓冲区内的任何状态进行访问.通常这个交换过程和分配一个指针的速度差不多,但如果交换用去了比修改初始状态更多的时间,那这样模式就毫无助益了
我们必须有两份缓冲区,这个模式的另外一个后果就是增加了内存使用.
并非只针对图形
双缓冲模式所解决的核心问题就是对状态同时进行修改与访问的冲突.造成此问题的原因通常有两个,我们已经通过上述图形示例描述了第一种情况—-状态直接被另一个线程或中断的代码所直接访问
而另一种情况同样很常见:进行状态修改的代码访问到了其正在修改的那个状态.这会在很多地方发生:尤其是实体的AI和物理部分,在它与其他实体进行交互时会发生这样的情况,双缓冲模式往往能在此情形下奏效
缓冲区如何交换
交换缓冲区指针或者引用
在两个缓冲区之间进行数据的拷贝
第9章 游戏循环
实现用户输入和处理器速度在游戏行进时间上的解耦
假如有哪个模式是本书最无法删减的,那么非游戏循环模式莫属.游戏循环模式是游戏编程模式种的精髓.几乎所有的游戏都包含着它,无一雷同,相比而言那些非游戏程序中却难见它的身影
不同于其他大多数软件,游戏即便在用户不提供任何输入时也一直在运行.加入你坐下来盯着屏幕,游戏也不会卡住.动画依旧在播放,各种效果也在闪动跳跃
这是真实的游戏循环的第一个关键点:它处理用户输入,但并不等待输入.游戏循环始终在运转:
假如用现实时间来衡量游戏循环的速度,我们就得到了游戏的帧率.
两个因素决定了帧率.
  第一个是循环每一帧要处理的信息量.复杂的物理运算,一堆对象的数据更新,许多图形细节等都将让你的CPU和GPU忙个不停,这都会让一帧消耗更多的时间
  第二个是底层平台的速度.速度越快的芯片相同时间内能够处理更多的代码.多核,多GPU,专用声卡以及操作系统的调度器都会影响着你的一帧中所能处理的代码量
游戏循环模式的另一个要点:这一模式让游戏在一个与硬件无关的速度常量下运行.
一个游戏循环会在游戏过程中持续地运转.每循环一次,它非阻塞地处理用户输入,更新游戏状态,并渲染游戏.它跟踪流逝的时间并控制游戏的速率
它的问题在于你无法控制游戏运转的快慢.在较快的机器上游戏循环可能会快得令玩家看不清楚游戏在做什么,在慢的机器上游戏则会变慢变卡
你希望游戏运行在60帧,假如你确定每16ms甚至更短的时间就能处理所有的信息
对于超过16ms时间的处理时间,则无能为力
使用平台的事件循环
使用游戏引擎的游戏循环
自己编写游戏循环
你如何解决能量耗损
限制帧率
如何控制游戏速度
非同步的固定时间步长
同步的固定时长
变时时长
定时更新迭代,变时渲染
第10章 更新方法
通过对所有对象实例同时进行帧更新来模拟一系列相互独立的游戏对象
你会发现这代码的可维护性不高.我们维护着一堆其值不断增长的变量,并不可避免地将所有代码都塞进游戏循环里,每段代码处理一个游戏中特殊的实体.为达到让所有实体同时运行的目的,我们把它们杂糅在一起了.
你可能猜到我们所要运用的设计模式该干些什么了? 它要为游戏中的每个实体封装其自身的行为.这将使得游戏循环保持整洁并便于往循环中增加或移除实体
为了做到这一点,我们需要一个抽象层,为此定义一个update()的抽象方法.游戏循环维护对象集合,但它并不关心这些对象的具体类型.它只是更新它们.这将每个对象的行为从游戏循环以及其他对象那里分离了出来
每一帧,游戏循环遍历游戏对象集合并调用它们的update().这在每帧都给与每个对象一次更新自己行为的机会.通过逐帧调用update方法,使得这些对象的表现得到同步
游戏循环维护一个动态对象集合,这使得向关卡里添加或者移除对象十分便捷—-只要往集合里增加或移除就好
游戏世界维护一个对象集合.每个对象实现一个更新方法以在每帧模拟自己的行为.而游戏循环在每帧对集合中所有的对象调用其更新方法,以实现和游戏世界同步更新
假如把游戏循环比作有史以来最好的东西,那么更新方法模式就会让它锦上添花.
更新方法模式在如下情境最为适用:
  你的游戏中含有一系列对象或系统需要同步地运转
  各个对象之间的行为几乎是相互独立的
  对象的行为与时间相关
1 将代码划分至单帧之中使其变得更加复杂
2 你需要在每帧结束前存储游戏状态以便下一帧继续
3 所有对象都在每帧进行模拟,但并非真正同步
在本设计模式中,游戏循环在每帧遍历对象集并逐个更新对象.在update()的调用中,多数对象能够访问到游戏世界的其他部分,包括那些正在更新的其他对象.这意味着,游戏循环遍历更新对象的顺序意义重大
在更新期间修改对象列表时必须谨慎
不要在本帧处理新添加的对象
不要在迭代时移除对象
一种方法是小心地移除对象并在更新任何计数器时把被移除的对象也算在内.还有一个办法是将移除操作推迟到本次循环遍历结束之后.将要被移除的对象标记为”死亡”,但并不从列表中移除它.在更新期间,确保跳过那些被标记死亡的对象接着等到遍历更新结束,再次遍历列表来移除这些”尸体”
你显然必须决定好该把update()方法放在哪一个类中
  实体类中  假如你已经创建了实体类,那么这是最简单的选项.因为这不会往游戏中增加额外的类.假如你不需要很多种类的实体,那么这种方法可行,但实际项目中很少这么做
  组件类中  更新方法模式与组件模式享有相同的功能—-让实体/组件独立更新,它们都使得每个实体/组件在游戏世界中能够独立于其他实体/组件.渲染,物理,AI都仅需专注于自己
  代理类中  将一个类的行为代理给另一个类,设计了其他几种设计模式.状态模式可以让你通过改变一个对象的代理来改变其行为.对象类型模式可以让你在多个相同类型的实体之间共享行为
那些未被利用的对象该如何处理
你常需要在游戏中维护这样一些对象:不论处于何种原因,它们暂时无需被更新.一种方法是单独维护一个需要被更新的”存活”对象表.
这一模式与游戏循环和组件模式共同构成了多数游戏引擎的核心部分
当你开始考虑实体集合或循环中组件在更新时的缓存功能,并希望它们更快地运转时,数据局部性模式将会有所帮助
Unity 的引擎框架在许多类模块中使用了本模式,比如Monobehaviour类
第11章 字节码
通过将行为编码成虚拟机指令,而使其具备数据的灵活性
指令集定义了一套可以执行的底层操作.一系列指令被编码为字节序列.虚拟机逐条执行指令栈上这些指令.通过组合指令,既可完成很多高级行为
这是本书中最复杂的模式,它可不是轻易就能放进你的游戏里的,仅当你的游戏中需要定义大量行为,而且实现游戏的语言出现下列情况才应该使用:
  编程语言太底层了,编写起来繁琐易错
  因编译时间太长或工具问题,导致迭代缓慢
  它的安全性太依赖编码者.你想确保定义的行为不会让程序崩溃,就得把它们从代码库转移至安全沙箱中
当然,这个列表复合大多数游戏的情况.谁不想提高迭代速度,让程序更安全?但那是有代价的,字节码比本地码要慢,所以它并不适合用作对性能要求极高的核心部分
字节码虚拟机有两种大风格: 基于栈和基于寄存器.在基于栈的虚拟机中,指令总是操作栈顶
基于寄存器的虚拟机也有一个堆栈.唯一的区别是指令可以从栈的更深层次中读取输入.它在字节码中存储两个索引来表示应该从堆栈的哪个位置读取操作数
如果你想定义一种基于文本的语言
  你得定义一种语法,外部基本操作,内部基本操作,控制流,抽象化
  你要实现一个分析器
  你必须处理语法错误
  对非技术人员没有亲和力
如果你设计了一个图形化编辑器
  你要实现一个用户界面,这样不易出错,但可移植性差
这个模式是Gof解释器模式的姊妹版.它们都会为你提供一种用数据组合行为的方法.事实上,你经常会将两个模式一起使用.你用来生成字节码的工具通常会有一个内部对象树来表达代码.这正是解释器模式能做的事情.为了将它编译成字节码,你需要递归遍历整棵树,正如你在解释器模式中解析它那样.唯一的不同是你并不是直接执行一段代码而是将它们输出成字节码指令并在以后执行它们
Lua编程语言是游戏中广泛使用的编程语言.它内部实现了一个紧凑的基于寄存器的字节码虚拟机
第12章 子类沙盒
使用基类提供的操作集合来定义子类中的行为
一个基类定义了一个抽象的沙盒模式方法和一些预定义的操作集合.通过将它们设置为受保护的状态以确保它们仅供子类使用.每个派生出的沙盒子类根据父类提供的操作来实现沙盒函数
沙盒模式适用于以下情况
  你有一个带有大量子类的函数
  基类能够提供所有子类可能需要执行的操作集合
  在子类之间有重叠的代码,你希望在它们之间更简便地共享代码
  你希望使这些继承类与程序其他代码之间的耦合最小化
经验法则
  如果所提供的操作仅仅被一个或者少数的子类所使用,那么不必将它加入基类.这只会给基类增加复杂度,同时将影响每个子类,而仅有少数子类从中受益.将该操作与其他提供的操作保持一致或许值得,但让这些特殊子类直接调用外部系统或许更为简单和清晰
  当你在游戏的其他模块进行某个方法调用时,如果它不修改任何状态,那么它就不具备侵入性.它仍然产生了耦合,但这是个”安全”的耦合,因为在游戏中它不带来任何破坏.而另一方面,如果这些调用确实改变了状态,则将与代码库产生更大的耦合,你需要对这些耦合更上心.因此此时这些方法更适合由更可视化的基类提供
  如果提供的操作,其实现仅仅是对一些外部系统调用的二次封装,那么它并没有带来多少价值.在这种情况下,直接调用外部系统更为简单.然而,极其简单的转向调用也仍有用—-这些函数通常访问基类不像直接暴露给子类的状态
是直接提供函数,还是由包含它们的对象提供
把提供的操作分流到一个像这样的辅助类中能给你带来些好处
  减少了基类的函数数量.
  在辅助类中的代码通常更容易维护
  降低了基类和其他系统之间的耦合
基类如何获取其所需的状态
把它传递给基类构造函数
当你采用更新方法模式的时候,你的更新函数通常也是一个沙盒函数
模板方法模式正好与本模式相反.在这两个模式中,你都使用一系列操作原语来实现一个函数.使用子类沙盒模式时,函数在继承类中,原语操作则在基类中.使用模板方法时,基类定义函数骨架,而原语操作被继承类实现
你可以将这个模式看作是在外观模式上的一个变种.外观模式将许多不同的系统隐藏在了一个简化的API之下.在子类沙盒模式中,基类对于子类来说充当着隐藏游戏引擎实现细节的角色
第13章 类型对象
通过创建一个类来支持新类型的灵活创建,其每个实例都代表一个不同的对象类型
类型对象模式,定义一个类型对象类和一个持有类型对象类.每个类型对象的实例表示一个不同的逻辑类型.每个持有类型对象类的实例引用一个描述其类型的类型对象
实例数据被存储在持有类型对象的实例中,而所有同概念类型所共享的数据和行为被存储在类型对象中.引用同一个类型的对象之间能表现出”同类”的性状.这让我们可以在相似对象集合中共享数据和行为,这与类派生的作用有几分相似,但却无需硬编码出一批派生类
当你需要定义一系列不同”种类”的东西,但又不想把那些种类硬编码进你的类型系统时,本模式都适用.尤其是当下面任何一项成立的时候:
  你不知道将来会有什么类型
  你需要在不重新编译或修改代码的情况下,修改或添加新的类型,类型对象必须手动跟踪,为每个类型定义行为更困难
通过类型对象去定义类型相关的数据非常容易,但是定义数据类型相关的行为却很难.
有几种方法可以跨越这个限制.
一个简单的方法是创建一个固定的预定义行为集合,让类型对象中的数据从中任选其一
另一个更强大,更彻底的解决方案是支持在数据中定义行为.如果我们能读取数据文件并提供给上述任意一种模式来实现,行为定义就完全从代码中脱离出来,而被放进数据文件内容中.
类型对象应该封装还是暴露
如果类型对象被封装
  类型对象模式的复杂性对代码库的其他部分不可见.它成为了持有类型对象才需关心的实现细节
  持有类型对象的类可以有选择性地重写类型对象的行为
如果类型对象被公开
  外部代码在没有持有类型对象类实例的情况下就能访问类型对象
  类型对象现在是对象公共API的一部分
持有类型对象如何创建
通过这种模式,每个”对象”现在都成了一对对象:主对象以及它所使用的类型对象.那么我们如何创建并将它们绑定起来呢?
  构造对象并传入类型对象
  在类型对象上调用”构造”函数
类型能否改变
类型不变
  无论编码还是理解起来都更简单
  易于调试
类型可变
  减少对象创建
  做约束时要更加小心
没有派生
  简单
  可能会导致重复劳动
单继承
  仍然相对简单
  属性查找会更慢
多重派生
  能避免绝大多数的数据重复
  复杂
这个模式所围绕的高级问题是如何在不同对象之间共享数据.从另一个不同角度尝试解决这个问题的是原型模式
类型对象与享元模式很接近.它们都让你在实例间共享数据.享元模式倾向于节约内存,并且共享的数据可能不会以实际的”类型”呈现.类型对象模式的重点在于组织性和灵活性
这个模式与状态模式也有诸多相似性.它们都把对象的部分定义工作交给另一个代理对象实现.在类型对象中,我们通常代理的对象是:宽泛地描述对象的静态数据.在状态模式中,我们代理的是对象当前的状态,即描述对象当前配置的临时数据.当我们讨论到可改变类型对象的时候,你可以认为是类型对象在状态模式的基础上身兼二职
第14章 组件模式
允许一个单一的实体跨越多个不同域而不会导致耦合
继承有它的用处,但是对某些代码重用来说实现起来太麻烦了.相反,软件设计的趋势应该是尽可能地使用组合而不是继承.为实现两个类之间的代码共享,我们应该让它们拥有同一个类的实例而不是继承同一个类
单一实体跨越了多个域.为了能保持域之间相互隔离,每个域的代码都独立地放在自己的组件类中.实体本身则可以简化为这些组件的容器
组件最常见于游戏中定义实体的核心类,但是它们也能够用在别的地方.当如下条件成立时,组件模式就能够发挥它的作用
  你有一个涉及多个域的类,但是你希望让这些域保持相互解耦
  一个类越来越庞大,越来越难以开发
  你希望定义许多共享不同能力的对象,但采用继承的办法却无法令你精确地重用代码
组件模式相较直接在类中编码的方式为类本身引入了更多的复杂性.每个概念上的”对象”成为一系列必须被同时实例化,初始化,并正确关联的对象的集群.不同组件之间的通信变得更具挑战性,而且对它们所占用内存的管理将更复杂
使用组件的另外一个后果是你经常需要通过一系列间接引用来处理问题,考虑容器对象,首先你必须得到你需要的组件,然后你才可以做你需要做的事情,在一些性能要求较高的内部循环代码中,这个组件指针可能会导致低劣的性能
关于这个设计模式的最重要的问题是:你需要的组件集合是什么?答案取决于你的游戏需求与风格.引擎越大越复杂,你就越想要将组件切分得更细
对象如何获得组件
如果这个类创建了自己的组件
  它确保了这个类一定有它所需要的组件
  但是这么做将导致重新配置这个类变得困难
如果由外部代码提供组件
  对象将变得灵活.我们完全可以通过添加不同的组件来改变类的行为
  对象可以从具体的组件类型中解耦出来
完美地将组件互相解耦并且保证功能隔离是个很好的想法,但这通常是不现实的.这些组件同属于一个对象的事实暗示了它们都是整体的一部分因此需要相互协作—-亦即通信
所以组件之间又是如何传递信息的呢?有好几个选择
  通过修改容器对象的状态
    它使得组件间保持解耦
    它要求组件间任何需要共享的数据都由容器对象进行共享
    这使得信息传递变得隐秘,同时对组件执行的顺序产生依赖
直接互相引用
通过传递信息的方式
这是选项中最复杂的一个.我们可以在容器类中建立一个小的消息传递系统,让需要传递信息的组件通过广播的方式去建立组件间的联系.GoF称之为中介模式,两个或者两个以上的对象通过将信息传递到一个中介的方法来取得相互之间的联系.而本章节中,容器类则充当了中间的角色
意料之外的是,没有哪个选择是最好的.你最终有可能将上述所说的三种方法都使用到
Unity框架的核心GameObject类完全围绕组件来设计
第15章 事件队列
对消息或事件的发送与受理进行时间上的解耦
事件驱动式编程
问题1: 在音效引擎完全处理完播放请求前,API的调用一直阻塞着调用者
问题2: 不能批量地处理请求
问题3: 请求在错误的线程被处理
事件队列是一个按照先进先出顺序存储一系列通知或请求的队列.发出通知时系统会将该请求置入队列并随即返回,请求处理器随后从事件队列中获取并处理这些请求.请求可由处理器直接处理或转交给对其感兴趣的模块.这一模式对消息的发送者与受理者进行了解耦,使消息的处理变得动态且非实时
如果你只想对一条消息的发送者和接收者进行解耦,那么诸如观察者模式和命令模式都能以更低的复杂度满足你.需要在某个问题上对时间进行解耦时,一个队列往往足矣
按照推送和拉取的方式思考:代码A希望另一个代码块B做一些事情.A发起这一请求最自然的方式就是将它推送给B
同时,B在其自身的循环中适时地拉取该请求并进行处理也是十分自然的.当你具备推送端和拉取端之后,在两者之间需要一个缓冲.这正是缓冲队列比简单的解耦模式多出来的优势
队列提供给拉取请求的代码块一些控制权:接收者可以延迟处理,聚合请求或者完全废弃它们.但这是通过”剥夺”发送者对队列的控制来实现的.所有的发送端能做的就是往队列里投递消息.这使得队列在发送端需要实时反馈时显得很不适用
事件队列会更复杂一些并且对你的游戏框架产生广泛而深远的影响.这意味着你在决定如何使用,是否适用本模式时须三思
中心事件队列是个全局变量
该模式的一种普遍用法被称为”中央枢纽站”,游戏中所有模块的消息都可以通过它来传递.它是游戏中强大的基础设施,然而强大并不总意味着好用
关于”全局变量是糟糕的”这点,大多数人在走过不少弯路后才恍然大悟.当你有一些系统的任何部分都能访问的状态时,各种细小部分不知不觉地产生了相互依赖.本模式将这些状态封装成为一种不错的小协议,但让然是全局性的,故仍具有任何全局变量所包含的危险性da
游戏世界的状态任你掌控
当你接收到一个事件,你要十分谨慎,不可认为当前世界的状态反映的是消息发出时世界的状态.这就意味着队列事件视图比同步系统中的事件具有更重量级的数据结构.后者只需通知”某事发生了”然后接收者可以检查系统环境来深入细节,而适用队列时,这些细节必须在事件发生时被记录以便稍后处理消息时适用
你会在反馈系统循环中绕圈子,任何一个事件或消息系统都得留意循环
当你的消息系统是同步的,你很块就能发现死循环—-它们会导致栈溢出并造成游戏崩溃.对于队列来说,异步的放开栈处理会适这些伪事件在系统中来回徘徊,但游戏可能会保持运行.一个常用的规避法则是避免在处理事件末端代码中发送事件
许多游戏将事件队列作为通讯架构的一个关键部分,你可以花大量的时间来设计各种复杂的路由和消息过滤机制.但在你准备建立类似于洛杉矶电话交换机系统那样的东西之前,我建议你开始要简单点.下面是入门时要考虑的一些问题
“事件”和”消息”总是被我替换着使用,因为这无伤大雅.无论你往队列里塞什么,它都具备相同的解耦与聚合能力,但二者仍然有一些概念上的不同
如果队列中是事件
  一个”事件”或”通知”描述已经发生的事情,比如”怪物死亡”.你将它入队,所以其他对象可以响应事件,有几分像一个异步的观察者模式
  你可能会允许多个监听器.由于队列包含的事件已经发生.因此发送者不关心谁会接收到它.从这个角度来看,这个事件已经过去并且已经被忘记了
  可访问队列的域往往更广.事件队列经常用于给任何和所有感兴趣的部分广播事件.为了允许感兴趣的部分有更大的灵活性,这些队列往往有更多的全局可见性
如果队列中是消息
  一个”消息”或”请求”描述一种”我们期望”发生在”将来”的行为,类似于”播放音乐”.你可以认为这是一个异步API服务
  你更可能只有单一的监听器.示例中,队列中的消息专门向音频API请求播放声音.如果游戏的其他任何部分开始从队列中偷窃消息,那并不会起到好的作用
谁能从队列中读取
单播队列  当一个队列是一个类的API本身的一部分时,单播再合适不过了
广播队列  这是大多数”事件”系统所做的事情.当一个事件进来时,如果你有十个监听器,则它们都能看见该事件,事件可以被删除,可能需要过滤事件
工作队列  类似于一个广播队列,此时你也有多个监听器.不同的是队列中的每一项只会被投递到一个监听器中.这是一种对于并发线程支持不好的系统中常见的工作分配模式
  你必须做好规划
谁可以写入队列
一个写入者  这种风格尤其类似于同步式观察者模式.你拥有一个可以生成事件的特权对象,以供其他模块接收
  你隐式地知道事件的来源
  通常允许多个读取者.你可以创造一对一接收者的队列,但是,这样不太像通信系统,而更像是一个普通的队列数据结构
多个写入者  这是我们的音频引擎例子的工作原理.因为”playSound()”函数是一个公共方法,所以任何代码库部分都可以为队列添加一个请求,”全局”或”中央”事件总线工作原理类似,你必须小心反馈循环,你可能会想要一些发送方在事件本身的引用
队列中对象的生命周期是什么
转移所有权  这是手动管理内存时的一种传统方法.当一个消息排队时,队列声明它,发送者不再拥有它.当消息处理时,接收者取走所有权并负责释放它
共享所有权  当前,虽然C++程序员能更舒服地进行垃圾回收了,但分享所有权会容易接受.这样一来,只要任何事情对它有一个引用,消息就依然存在.当被忘记时它就会自动释放
队列拥有它  另一个观点是消息总是存在于队列中.不用自己释放消息,发送者会从队列中请求一个新的消息.队列返回一个已经存在于队列内存中的消息引用,接着发送者会填充队列.消息处理时,接收者参考队列中相同消息的操作.
但在很多方面,这个模式可以看成是我们所熟知的观察者模式的异步版本
和很多模式一样,事件队列有过一些其他别名.其中一个概念叫做”消息队列”,它通常是指一个更高层面的概念.当事件队列应用于应用程序内部时,消息队列通常用于消息之间的通信
另一个术语是”发布/订阅”,有时缩写为”订阅”.类似于”消息队列”,它通常在大型分布式系统中被提及,而不专用于像我们例子这阿姨那个简陋的编码模式中
第16章 服务定位器
为某服务提供一个全局访问入口来避免使用者与该服务具体实现类之间产生耦合
在游戏编程中,某些对象或者系统几乎出现在程序的每个角落.在某些时刻,你很难找到一个不需要内存分配,日志记录或者随机数生成的游戏.我们通常认为类似这样的系统是在整个游戏中需要被随时访问的服务
尽管我们实现了想要的目的,但整个过程却带来了很多耦合.游戏中每一处调用音频系统的地方,都直接引用了具体的AudioSystem类和访问AudioSystem类的机制—-使用静态类或者单例
这些调用音频系统的地方,的确需要耦合到某些东西上以便播放声音,但直接耦合到音频具体实现类上就好像让一百个陌生人知道你家的地址,而仅仅是因为需要它们投递信件.这不仅是隐私问题,而且当你搬家时必须告诉每个人你的新地址,这实在是太痛苦了
这里有个更好的解决办法:电话簿.每一个想要联系我们的人能够通过查找名字来得到我们当前的地址.当我们搬家时,我们告诉电话公司,它们更新电话簿,这样每个人都能得到新的地址了.实际上,我们甚至不必给出我们真正的地址.我们能够列出一个邮政信箱,或者其他能够”代表”我们的东西.通过让访问者查询电话簿来找到我们,我们便有了一个方便的可以控制如何查找我们的地方
这就是服务定位器模式的简单介绍—-它将一个服务的”是什么(具体实现类型)”和”在什么地方(我们如何得到它的实例)”与需要使用整个服务的代码解耦了
一个服务类为一系列操作定义了一个抽象的接口.一个具体的服务提供器实现了这个接口.一个单独的服务定位器通过查找一个合适的提供器来提供这个服务的访问,它同时屏蔽了提供器的具体类型和定位这个服务的过程.
每当你将东西变得全局都能访问的时候,你就是在自找麻烦.这就是单例模式存在的主要问题,而这个模式存在的问题也没有什么不同.对于何时使用服务定位器,我的简单建议就是: 谨慎使用
与其给需要使用的地方提供一个全局机制来访问一个对象,不如首先考虑将这个对象传递进去.这极其简单易用,而且将耦合变得直观.这可以满足绝大部分需求
但是,有时手动地将一个对象传来传去显得毫无理由或者使得代码难以阅读.有些系统,比如日志系统或内存管理系统,不应该是某个模块公开API的一部分.渲染代码的参数应该必须和渲染相关,而不是像日志系统那样的东西
同样地,它也适用于一些类似功能的单一系统.你的游戏可能只有一个音频设备或者显示系统让玩家与之打交道.传递的参数是一项环境属性,所以将它传递10层函数以便让一个底层的函数能够访问,为代码增加了毫无意义的复杂度
在这些情况下,这个模式能够起到作用.它用起来像一个更灵活,更可配置的单例模式.当被合理地使用时,它能够让你的代码更有弹性,而且几乎没有运行时的损失.
服务定位器的关键困难在于,它要有所依赖(连接两份代码),并且在运行时才连接起来.这给与了你弹性,但付出的代价就是阅读代码时比较难以理解依赖的是什么.
服务必须被定位
当使用单例或者一个静态类时,我们需要的实例不可能变得不可用.但是,既然这个模式需要定位服务,那么我们可能需要处理定位失败的情况
服务不知道被谁定位
既然定位器是全局可访问的,那么游戏中的任何代码都有可能请求一个服务然后操作它.这意味着这个服务在任何情况下都必须能正确工作.
静态函数getAudio()负责定位工作.我们能在代码的任何地方调用它,它能返回一个Audio服务的实例提供我们使用
它”定位”的方法十分简单—-在使用这个服务之前它依赖一些外部代码来注册一个服务提供器.
这里使用的技术叫做依赖注入,这个术语表示了一个基本的思想.假设你有一个类,依赖另外一个类.在我们的例子中,我们的Locator类需要Audio服务的一个实例.通常,这个定位器应该负责为自己构建这个实例.依赖注入却说外部代码应该负责为这个对象注入它所需要的这个依赖实例
这里关键需要注意的地方是调用playSound()的代码对Audio具体实现毫不知情.它只知道Audio的抽象接口,同样重要的是,甚至是定位器本身和具体服务提供器也没有耦合.代码中唯一知道具体实现类的地方,是提供这个服务的初始化代码
这里还有更深一层的解耦—-通过服务定位器,Audio接口在绝大多数地方并不知道自己正在被访问.一旦它知道了,它就是一个普通的抽象基类了.这十分有用,因为这意味着我们可以将这个模式应用到一些已经存在的但并不是围绕这个来设计的类上.这和单例有个对比,后者影响了”服务”类本身的设计
“时序解耦”—-两份单独的代码必须按正确的顺序调用来保证程序正确工作.每个状态软件都有不同程度的”时序耦合”,但是就像其他耦合那样,消除时序耦合会使得代码易于管理
装饰器模式
服务是如何被定位的
外部代码注册
  它简单快捷
  我们控制提供器如何被构建
  我们可以在游戏运行的时候更换服务提供器
  定位器依赖外部代码
在编译时绑定
  你能保证服务可用
  你不能方便地更改服务提供器
在运行时配置
  我们不需重编译就能切换服务提供器
  非程序员能够更换服务提供器
  一份代码库能够同时支持多份配置
  不像前几个解决方案,这方案比较复杂且十分重量级
  定位服务需要时间
当服务不能被定位时发生了什么
让使用者处理
  它让使用者决定如何处理查找失败
  服务使用者必须处理查找失败
终止游戏
  使用者不需要处理一个丢失的服务
  如果服务没有被找到,游戏将会中断
返回一个空服务
  使用者不需要处理丢失的服务
  当服务不可用时,游戏还能继续
服务的作用域多大
如果是全局访问
  它鼓励整个代码库使用同一个服务
  我们对何时何地使用服务完全失去了控制
如果访问被限制到类中
  我们控制了耦合.
  它可能导致重复的工作
我的一般原则是,如果服务被限制在游戏的一个单独域中,那么就把服务的作用域限制到类中.比如,获取网络访问的服务就可能被限制在联网的类中.而更广泛使用的服务,比如日志服务应该是全局的
 
第17章 数据局部性
通过合理组织数据利用CPU的缓存机制来加快内存访问速度
RAM的存取速度远远跟不上CPU的速度
对刚访问数据的邻近数据进行访问的术语叫做访问局部性
当代计算机有多级缓存,也就是你所听到的那些”L1”, “L2”, “L3”等.它们的大小按照其等级递增,但速度却随等级递减
当代CPU带有多级缓存以提高内存访问速度.这一机制加快了对最近访问过的数据的邻近内存的访问速度.通过增加数据局部性并利用这一点可以提高性能—-保持数据位于连续的内存中以提供程序进行处理
如果多数优化措施,知道我们使用数据局部性模式的第一条准则就是找到出现性能问题的地方.不要在那些代码库里非频繁执行的部分浪费时间,它们不需要本模式.对那些非必要的代码进行优化将使你的人生变得艰难—-因为结果总是更加复杂且笨拙.由于此模式的特殊性,因此你可能还希望确定你的性能问题是否是由缓存未命中引起的,如果不是,那么这个模式也帮不上忙
为了做到缓存友好,你可能需要牺牲一些之前所做的抽象化.你越是在程序的数据局部性上下工夫,你就越要牺牲继承,接口以及这些手段所带来的好处.这里并没有高招,只有利弊权衡的挑战.而乐趣便在这里
这种设计模式更适合叫做一种思维模式.它提醒着你,数据的组织方式是游戏性能的一个关键部分.这一块的实际拓展空间很大,你可以让你的数据局部性影响到游戏的整个架构,又或者它只是应用在一些核心模块的数据结构上.对这一模式的应用,你最需要关心的就是该何时何地使用它.而随着这个问题我们也会看到一些新的顾虑
如何处理多态
避开继承
  安全而容易
  速度更快
  灵活性差
为不同的对象类型使用相互独立的数组
  这样的一系列集合让对象紧密地封包
  你可以进行静态地调用分发
  你必须时刻追踪这些集合
  你必须注意每一个类型
使用指针集合
  这样做灵活性高
  这样做并不缓存友好
假如游戏实体通过类中的指针来索引其组件
  你可以将组件存于相邻的数组中
  对于给定实体,你可以很容易地获取它的组件
  在内存中移动组件很困难
假如游戏实体通过一系列ID来索引其组件
  这更加复杂
  这样做更慢
  你需要访问组件管理器
假如游戏实体本身就只是个ID
  你的游戏实体类完全消失了,取而代之的是一个优雅的数值包装
  实体类本身是空的
  你无须管理其生命周期
  检索一个实体的所有组件会很慢
第18章 脏标记模式
许多游戏都有一个称之为场景图的东西.这是一个庞大的数据结构,包含了游戏世界中所有的物体.渲染引擎使用它来决定将物体绘制到屏幕的什么地方
就最简单的来说,一个场景图只是包含多个物体的列表.每个物体都含有一个模型(或其他图元)和一个”变换”.变换描述了物体在世界中的位置,旋转角度和缩放大小.想要移动或者旋转物体,我们可以简单地修改它的变换
当渲染器绘制一个物体时,它将这个物体的变换作用到这个物体的模型上,然后将它渲染出来.如果我们有的是一个场景”袋”而不是场景”图”的话,事情会变得简单很多
然而,许多场景图是分层的.场景中的一个物体会绑定在一个父物体上.在这种情况下,它的变换就依赖于其父物体的位置,而不是游戏世界中的一个绝对位置.
举个例子,想象我们的游戏中有一艘海盗船在海上.桅杆的顶部是一个瞭望塔,一个海盗靠在这个瞭望塔上,抓在海盗肩膀上的是只鹦鹉.这艘船的局部变换标记了它在海中的位置,瞭望塔的变换标记了它在船上的位置,等等
鹦鹉->海盗->瞭望塔->海盗船
这样,当一个父物体移动时,它的子物体也会自动地跟着移动.如果我们修改船的局部变换,瞭望塔,海盗,鹦鹉也会随之变动.如果在船移动时我们必须手动调整船上所有物体的变换来防止相对滑动,那会是一件很头疼的事情
计算一个物体的世界变换是相当直观的—-只要从根节点沿着它的父链将变换组合起来就行.也就是说鹦鹉的世界变换就是
鹦鹉世界变换 = 船的局部变换 x 瞭望塔的局部变换 x 海盗的局部变换 x 鹦鹉的局部变换
我们每帧都需要世界中每个物体的世界变换.所以即使每个模型中只有少数的几个矩阵相乘,却也是代码中影响性能的关键所在.保持它们及时更新是棘手的,因为当一个父物体移动,这会影响它自己和它所有的子物体,以及子物体的子物体等的世界变换
最简单的途径是在渲染的过程中计算变换.每一帧中,我们从顶层开始递归地遍历场景图.对每个物体,我们计算它们的世界变换并立刻绘制它
但是这对我们宝贵的CPU资源是一种可怕的浪费.许多物体并不是每一帧都移动.想想关卡中那些静止的几何体,它们没有移动,但每一帧都要重计算它们的世界变换是一种多么大的浪费
一个明显的解决方法是将它”缓存”起来.在每个物体中,我们保存它的局部变换和它派生物体的世界变换.当我们渲染时,我们只使用预先计算好的世界变换.如果物体从不移动,那么缓存的变换始终是最新的,一切都很美好
当一个物体缺失移动了,简单的方法就是立即刷新它的世界变换.但是不要忘了继承连!当一个父物体移动时,我们需要重计算它的世界变换并递归地计算它所有子物体的世界变换
想象某些比较繁重的游戏场景.在一个单独帧中,船被扔进海里,瞭望塔在风中晃动,海盗斜靠在边上,鹦鹉跳到他的头上.我们修改了4个局部变换.如果我们在每个局部变换变动时都匆忙地重新计算世界变换,结果会发生什么
我们只移动了4个物体,但是我们做了10次世界变换计算.这6次无意义的计算在渲染器使用之前就被扔掉了.我们计算了4次鹦鹉的世界变换,但是只渲染了一次
问题的关键是一个世界变换可能依赖于好几个局部变换.由于我们在每个这些变换变化时都立刻重计算,所以最后当一帧内有好几个关联的局部变换改变时,我们就将这个变换重新计算了好多遍
我们通过将修改局部变换和更新世界变换解耦来解决这个问题.这让我们在单次渲染中修改多个局部变换,然后在所有变动完成之后,在实际渲染器使用之前仅需要计算一次世界变换
要做到这点,我们为图中每个物体添加一个”flag”.”flag”和”bit”在编程中是同义词—-它们都表示单个小单元数据,能够存储两种状态中的一个.我们称之为”true”和”false”,有时也叫”set”和”cleared”.
我们在局部变换改动时设置它.当我们需要这个物体的世界变换时,我们检查这个flag.如果它被标记为”set”了,我们计算这个世界变换,然后将这个flag置为”clear”.这个flag代表,”这个世界变换是不是过期了?”由于某些原因,传统上这个”过期的”被称作”脏的”.也就是”脏标记”,”Dirty bit”也是这个模式常见的名字.但是我想我会坚持使用那种看起来每那么”污秽”的名字
如果我们运用这个模式,然后将我们上个例子中的所有物体都移动,那么游戏看起来如下:
这是你能期望的最好的办法.每个被影响的物体的世界变换只需要计算一次.只需要一个简单的位数据,这个模式位我们做了不少事:
  它将父链上物体的多个局部变换的改动分解为每个物体的一次重计算
  它避免了没有移动的物体的重计算
  一个额外的好处: 如果一个物体在渲染之前移除了,那就根本不用计算它的世界变换
一组原始数据随时间变化.一组衍生数据经过一些代价昂贵的操作由这些数据确定.一个脏标记跟踪这个衍生数据是否和原始数据同步.它在原始数据改变时被设置.如果它被设置了,那么当需要衍生数据时,它们就会被重新计算并且标记被清除.否则就使用缓存的数据
脏位标记设计两个关键词:”计算”和”同步”.在这两种情况下,处理原始数据到衍生数据的过程在时间或其他方面会有很大的开销
这里也有些其他的要求:
原始数据的修改次数比衍生数据的使用次数多,
递增地更新数据十分困难,
延时太长会有代价,
这个模式把某些耗时的工作推迟到真正需要时才进行,而到有需要时,往往刻不容缓.
必须保证每次状态改动时都设置脏标记
既然衍生数据是通过原始数据计算而来,那它本质上就是一份缓存.当你获取缓存数据时,棘手的问题是缓存失效—-当缓存和原始数据不同步时,什么都不正确了.在这个模式中,它意味着当任何原始数据变动时,都要设置脏标记
必须在内存中保存上次的衍生数据
何时清除脏标记
当需要计算结果时
  当计算结果从不使用时,它完全避免了计算
  如果计算十分耗时,会造成明显的卡顿
在精心设定的检查点
  这些工作并不影响用户体验.
  当工作执行时,你失去了控制权
在后台
  你可以调整工作执行的频率
  你可以做更多冗余的工作
  需要支持异步操作
脏标记追踪的粒度多大
更精细的粒度
  你只需要处理真正变动了的数据,你将船的真正变动的木块数据发送给服务器
更粗糙的粒度
  你最终需要处理未变动的数据
  存储脏标记消耗更少的内存
  固定开销花费的时间要更少
物理引擎跟踪着物体的运动和空闲状态.一个空闲的物体直到受到力的作用才会移动,它在受力之前不需要处理.这个”是否在移动”就是一个脏标记,用来标记哪些物体收到了力的作用并需要计算它们的物理状态
第19章 对象池
使用固定的对象池重用对象,取代单独地分配和释放对象,以此来达到提升性能和优化内存使用的目的
定义一个保持着可重用对象集合的对象池类.其中的每个对象支持对其”使用(in use)”状态的访问,以确定这一对象目前是否”存活(alive)”.在对象池初始化时,它预先创建整个对象的集合(通常为一块连续堆区域),并将它们都置为”未使用(not in use)”状态
当你想要创建一个新对象时就向对象池请求.它将搜索到一个可用的对象,将其初始化未”使用中(in use)”状态并返回给你.当该对象不再被使用时,它将被置回”未使用(not in use)”状态.使用该方法,对象便可以在无需进行内存或其他资源分配的情况下进行任意的创建和销毁
对象池模式被广泛地应用于游戏中的可见物体,如游戏实体对象,各种视觉特效,但同时也被使用于非可见的数据结构中,如当前播放的声音.我们在以下情况使用对象池:
当你需要频繁地创建和销毁对象时,对象的大小一致时,在堆上进行对象内存分配较慢或者会产生内存碎片时,每个对象封装着获取代价昂贵且可重用的资源,如数据库,网络的连接
注意问题:
1 对象池可能在闲置的对象上浪费内存
2 任意时刻处于存活状态的对象数目恒定
3 每个对象的内存大小是固定的
4 重用对象不会被自动清理
5 未使用的对象将占用内存
对象与对象池耦合
  实现很简单,你可以简单地为那些池中的对象增加一个”使用中”的标志位或者函数,这就能解决问题了
  你可以避免存储一个”使用中”的标志位,许多对象已经维护了可以表示自身是否仍然存活的状态
对象独立于对象池
  任意类型的对象可以被置入池中.这是个巨大的优点.通过对象与对象池的解绑,你将能够实现一个通用,可重用的对象池类
  “使用中”状态必须能够在对象外部被追踪.最简单的做法是在对象池中额外创建一块独立的空间:
对象池模式与享元模式看起来很相似.它们都管理着一系列可重用对象.其差异在于”重用”的含义.享元模式中的对象通过在多个持有者中并发地共享相同的实例以实现重用.它避免了因在不同上下文中使用相同对象而导致的重复内存使用.对象池的对象也被重用,但此”重用”意味着在原对象持有者使用完对象之后,将其内存回收.对象池里的对象在其生命周期中不存在着因为被共享而引致的异常
将那些类型相同的对象在内存上整合,能够帮助你在遍历这些对象时利用好CPU的缓存区.数据局部性设计模式阐释了这一点
第20章 空间分区
将对象存储在根据位置组织的数据结构中来高效地定位它们
对于一组对象而言,每一个对象在空间都有一个位置.将对象存储在一个根据对象的位置来组织的数据结构中,该数据结构可以让你高效地查询位于或靠近某处的对象.当对象的位置变化时,应更新该空间数据结构以便可以继续这样查找对象
这是一个用来存储活跃的,移动的对象以及静态图像和游戏世界的几何形状等对象的常见模式.复杂的游戏常常有多个空间分区来应对不同类型的存储内容
该模式的基本要求是你有一组对象,每个对象都具备某种位置信息,而你因为要根据位置做大量的查询来查找对象从而遇到了性能问题
空间分区将O(n)或者O(n2)复杂度的操作拆解为更易于管理的结构.对象越多,模式的价值就越大.相反,如果你的n值很小,则可能不值得使用该模式.由于该模式要根据对象的位置来组织对象,故对象位置的改变就变得难以处理了.你必须重新组织数据结构来跟踪物体的新位置,这会增加代码的复杂性并产生额外的CPU周期开销.你必须确保这么做是值得的
空间分区会使用额外的内存来保存数据结构.就像许多的优化一样,它是以空间换取速度的.如果你的内存比时钟周期更吃紧的话,这可能是个亏本生意
分区是层级的还是扁平的
在网格例子中,我们将网格划分成了一个单一扁平的单元格集合.与此相反,层级空间分区则是将空间划分成几个区域.然后,如果这些区域中仍然包含着许多的对象,就会继续划分.整个递归过程持续到每个区域的对象数目都少于某个约定的最大对象数量为止
如果它是一个扁平的分区
  相对简单
  内存使用量恒定
  当对象改变位置时可以更为快速地更新
如果它是一个层级的分区
  它可以更有效地处理空白的空间
  它在处理对象稠密区域时更为有效
如果分区依赖于对象
  对象可以被逐步地添加
  对象可以快速地移动
  分区可以不平衡
如果分区自适应于对象集合
  你可以确保分区间的平衡
  对整个对象集合进行一次性的分区时更为高效
如果分区不依赖于对象,而层级却依赖于对象
  可以逐步地增加对象
  对象可以快速地移动
  分区是平衡的
如果它是对象唯一存储的地方,这避免了两个集合的内存开销和复杂性
如果存在存储对象的另外一个集合,遍历所有的对象会更为快速
在这章中我避开对具体空间分区结构的详细讨论,以保持章节的高层次概括性(并且也不会太长),但是下一步你应该要去了解一些常见的结构.尽管它们的名字吓人,但却出奇的简单明了.常见的有网格、四叉树、二叉空间分割、k-d树、层次包围盒
每一个空间数据结构基本都是从一个现有已知的一维数据结构扩展到多维,了解它们的线性结构会帮助你判断它们是否适合于解决你的问题:
  网格是一个连续的桶排序
  二叉空间分割,k-d树,以及层次包围盒都是二叉查找树
  四叉树和八叉树都是Trie树

slot 游戏知识

slot 游戏知识

网络图,如侵权请联系我删除

图片来源于网络,如侵权请联系我删除。

slot 术语

Reels 滚轴
symbol 符号(出现在滚轴上的图案,玩家需要组合出特定符合才能赢钱,体现slot主题玩法的载体和主题辨识度的直接视觉元素)
basic symbols
数字和字母(典型扑克牌AKQJ10)
经典符号水果(🍒,🍋,🍉,🍇)传统的BAR符号
High-Value symbol
与主题相关的符号,如埃及神话, 法老, 法杖,公主等
Special symbol
特殊符号:在特定情况下可以带来高额奖励或者特殊玩法。宝石,宝箱,

百搭符合(WILD )
可以替代其他符合来形成盈利组合的特殊符号(Scatter,Bonus,Jackpot等特殊symbol除外)Wild pays substitutes for all symbols except(Scatter,Bonus, japkot)
*** 在设计slot有一个常识,就是当使用百搭符合来匹配组合时,奖金翻倍。
注意百搭符合匹配出来的组合,要根据优先级顺序进行赔付,slot的共通规则是每条赔付线上赔付最好的奖金,而不是所有满足赔付的组合都赔付。***

Scatter symbol 不需要再赔付线上连续出现,只要出现在滚轴上就可以赢得奖励。通常3、4、5个Scatter停在界面就可以激活free spin game。
(如果每个reel 同时停留两个堆叠的Scatter,可以改变游戏RTP,)
bonus symbol 跟Scatter symbol 类似,以特定组合出现触发额外奖励游戏(bonus game,bingo,开箱子,点泡泡等小游戏)
4、其他符号,
累计奖池符号
扩展符号
固定符号
叠加符号
膨胀符号
分散符合
合并符合

3游戏区域Play window
3X5,5x3,4x5,6x4

4、Base Game
5、Feature Game(Free Game、bonus Game)
6、赔付线
在数量和布局上数值上不太重要,因为RTP跟游戏中赔付线数量无关,与之相关的事命中率和获胜率会随着赔付线的增加而线性增加。所以在设计赌注也会相应增加以保持RTP恒定。
每个主题游戏的最大赔付线是每个Reel的各个窗口高度的乘积(3x5 -> 3^5)
7 乘数(multiplier)增加盈利的倍数(x2,x3)
8 赔付表(paytable)
9、 命中率(hit Rate)

玩家在一定的旋转次数内,能够获得任何奖励的概率,命中率会受到赔付表和符号的影响,形成高命中率和低命中率。
玩家更频繁的触发赢钱组合,更多低价值符号,让玩家更频繁地获得小额盈利
玩家可能需要更多spin 才能触发赢钱,游戏中更多高价值符号和特殊符号,需当特色符号触发时,让玩家获得更大奖励,导致较低命中率。

hitRate = 1/probability = cycle/hit
hitFrequency = 100% / hitRate
probability 获奖概率
cycle 游戏窗口每个滚轴中符号个数的乘积
比如1x3 Reel,每个Reel包含10个符号,则cycle = 10x10x10
hits 符号在赔付线上命中的次数。
probability = hits / cycle

10 波动性Volatility
游戏输赢的波动指标,可以用于衡量玩家在游戏中输赢的大小。输赢频率,输赢快慢,用数据来解释,也可以称之为方差。用来衡量数据点相对均值的偏离程度。波动高低和RTP关系不大。

11 、奖池(JackPot)
固定奖池
累计奖池(单机累计,多人累计)
设计者设定规则(5%投注金额将加入到奖池中,奖池金额一直在涨,假的。只不过是设计机器人陪玩。
等级奖池,分多个级别的奖池,比如mini,minor,major,mega,grand)

12、True 随机数生成器
random.org提供了真随机数的免费服务接口
真随机,基于物理过程(如放射性衰变,光电效应,大气噪声)来生成随机数,通常与硬件设备结合使用。有自然界不可预测性决定。
Pseudorandom number generator。基于一个或者多个初始值,(seed)使用确定性算法生成的数字序列,当我们知道该初值和背后算法的工作原理,就可以重现该随机结果。具有周期性。
伪随机数算法:线性同余发生器。
Xn+1 = (a* Xn+c) mod m
通过递推生成伪随机数,Xn位当前随机值,Xn+1 为下一个随机数。
a:multiplier
c:increment
m: modulus.

在设计online slot 上根据游戏的属性不同会使用不同的随机数生成器。
如果 设计的游戏仅仅是一款social slots,采用为随机数生成器就可以
如果 设计的游戏为了活动游戏许可证(Gaminglicense) 要符合当地监管机构的法律法规,需要采用真随机数生成器,
如果设计的游戏采用了经过监管认证的为随机数生成器,并且符合相关的监管要求和技术标准。
任然可以使用伪随机当真人娱乐场。

slot监管和测试随机数生成器的机构

GLI(gaming laboratories internationnal)美国内华达州拉斯维加斯,认证和检测

TST(Technical Systems Testing)加拿大
BMM Testlabs 测试
Gambling Commission 英国博彩委员会(审查和测试)
5、iTech Labs 澳大利亚墨尔本 测试和认证

玩家回报率
RTP return to Player
SRTP standard Return to Player
RTP = Total Win /Total Bet

SRTP = averageWin/ averageBet

averageWin = prize * probability of prize
prize 代表符号的赔付值
probability 赔付符号的获奖概率。

SRTP = prize * hits/ cycle * averagebet * 100%

SRTP = (win/bet)/TotalBetTimes

设计一款slot 设定好它的数学模型,赔率设定,游戏规则之后,计算出游戏所有的结果加权平均回报值(确定SRTP =(91.68%)
在实际游戏过程中,要考虑到一个重要的因素:游戏的波动性,
它决定了理论RTP允许的容差,当spin 次数有限时,容差会更宽

假设游戏设计者在设计时波动性=5.6 那么95%置信区间计算出来的平均值偏差

当游戏上线一段时间后,玩家在这个游戏中累计投资¥1.200.000 获得1.085.000

老虎机(Slot Machine)的核心算法整理

老虎机(Slot Machine)核心算法详解

老虎机是博彩业中最流行的游戏之一,其看似简单的表面下隐藏着复杂而精密的数学模型和算法。作为资深的Slots游戏开发,我将深入解析老虎机的核心算法,包括随机数生成、符号权重分配、RTP控制、波动性设计以及特殊功能触发等关键技术。

随机数生成系统(RNG)

伪随机数生成器(PRNG)
老虎机的核心是一个高质量的伪随机数生成器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import random
import time
from typing import List, Dict, Tuple, Optional
from enum import Enum

class SlotRNG:
def __init__(self):
"""使用系统时间和随机源初始化随机数生成器"""
self.generator = random.Random()
self.generator.seed(time.time() + random.randint(0, 1000000))

def next(self, min_val: int, max_val: int) -> int:
"""生成[min_val, max_val]范围内的随机整数"""
return self.generator.randint(min_val, max_val)

def next_float(self) -> float:
"""生成[0.0, 1.0)范围内的随机浮点数"""
return self.generator.random()

随机数映射技术

随机数需要映射到游戏中的具体元素(如符号、位置等):

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
class Symbol(Enum):
WILD = 0
HIGH1 = 1
HIGH2 = 2
LOW1 = 3
LOW2 = 4
SCATTER = 5
BONUS = 6
BLANK = 7

def map_random_to_symbol(random_value: int, symbols: List[Symbol], weights: List[int]) -> Symbol:
"""根据权重表将随机数映射到具体符号"""
total_weight = sum(weights)
scaled_random = random_value % total_weight

accumulated_weight = 0
for i, symbol in enumerate(symbols):
accumulated_weight += weights[i]
if scaled_random < accumulated_weight:
return symbol

# 默认返回第一个符号
return symbols[0]


RNG审计与验证

高质量的老虎机算法必须通过严格的随机性测试:

频率测试:确保每个符号出现频率符合其权重
序列测试:验证符号序列没有可预测模式
周期性分析:确保不存在可被利用的循环
统计分布测试:应用卡方检验等统计方法验证随机性

符号分布与权重系统

虚拟转轮设计
现代老虎机通常使用虚拟转轮技术,每个转轮有不同的符号分布:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class VirtualReel:
def __init__(self, symbols: List[Symbol] = None, weights: List[int] = None):
self.symbols = symbols if symbols else []
self.weights = weights if weights else []

def get_total_stops(self) -> int:
"""获取虚拟转轮的总长度(总权重)"""
return sum(self.weights)

def get_random_symbol(self, rng: SlotRNG) -> Symbol:
"""从此转轮获取一个随机符号"""
total_weight = self.get_total_stops()
random_value = rng.next(0, total_weight - 1)
return map_random_to_symbol(random_value, self.symbols, self.weights)


不均匀分布设计

关键符号(如散布符号、Wild、高付款符号)通常有特别设计的分布:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
def create_virtual_reels() -> List[VirtualReel]:
"""构建一个5x3老虎机的虚拟转轮集,有不同的符号分布"""
reels = [VirtualReel() for _ in range(5)] # 5轴老虎机

# 第一个转轮配置
reels[0].symbols = [Symbol.WILD, Symbol.HIGH1, Symbol.HIGH2, Symbol.LOW1, Symbol.LOW2]
reels[0].weights = [3, 10, 15, 30, 42] # 总权重100

# 第二个转轮配置(减少Wild出现概率)
reels[1].symbols = [Symbol.WILD, Symbol.HIGH1, Symbol.HIGH2, Symbol.LOW1, Symbol.LOW2]
reels[1].weights = [2, 12, 18, 35, 33] # 总权重100

# 第三至五个转轮 - 进一步减少高价值符号出现率
reels[2].symbols = [Symbol.WILD, Symbol.HIGH1, Symbol.HIGH2, Symbol.LOW1, Symbol.LOW2]
reels[2].weights = [1, 15, 20, 40, 24]

reels[3].symbols = [Symbol.WILD, Symbol.HIGH1, Symbol.HIGH2, Symbol.LOW1, Symbol.LOW2]
reels[3].weights = [1, 18, 22, 45, 14]

reels[4].symbols = [Symbol.WILD, Symbol.HIGH1, Symbol.HIGH2, Symbol.LOW1, Symbol.LOW2]
reels[4].weights = [1, 20, 25, 50, 4]

return reels

近似错过(Near Miss)算法

增强游戏性的关键技术之一是创造”差一点”的情况:

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


class SpinResult:
def __init__(self):
self.visible_symbols = [] # 存储每轴显示的符号

class SymbolCombination:
def __init__(self, symbols: List[Symbol], payout: int):
self.symbols = symbols
self.payout = payout

NEAR_MISS_PROBABILITY = 5 # 5%的概率生成近似错过效果
# 生成带有近似错过效果的结果
def generate_near_miss_effect(rng: SlotRNG, reels: List[VirtualReel]) -> SpinResult:
"""生成带有近似错过效果的结果"""
result = SpinResult()

# 计算是否应生成近似错过效果
should_create_near_miss = (rng.next(1, 100) <= NEAR_MISS_PROBABILITY)

if should_create_near_miss:
# 选择一个高价值组合来"差一点"实现
target_combo = select_high_value_combination(rng)

# 设置前几轴符合目标组合
result.visible_symbols = []
for i in range(len(reels) - 1):
result.visible_symbols.append(target_combo.symbols[i])

# 最后一轴刻意选择不同的符号
last_symbol = None
while True:
last_symbol = reels[-1].get_random_symbol(rng)
if last_symbol != target_combo.symbols[-1]:
break
result.visible_symbols.append(last_symbol)
else:
# 生成完全随机的结果
result.visible_symbols = [reel.get_random_symbol(rng) for reel in reels]

return result

def select_high_value_combination(rng: SlotRNG) -> SymbolCombination:
"""选择高价值组合用于近似错过效果"""
# 这里简化实现,实际游戏可能有更复杂的逻辑
high_value_combos = [
SymbolCombination([Symbol.WILD, Symbol.WILD, Symbol.WILD, Symbol.WILD, Symbol.WILD], 1000),
SymbolCombination([Symbol.HIGH1, Symbol.HIGH1, Symbol.HIGH1, Symbol.HIGH1, Symbol.HIGH1], 500),
SymbolCombination([Symbol.HIGH2, Symbol.HIGH2, Symbol.HIGH2, Symbol.HIGH2, Symbol.HIGH2], 300)
]
return rng.choice(high_value_combos)

RTP(Return to Player)控制

RTP数学模型设计
RTP是老虎机设计中最核心的参数,通常在88%-98%之间:

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

def calculate_combination_rtp(combo: SymbolCombination, reels: List[VirtualReel]) -> float:
"""计算特定符号组合的RTP贡献"""
probability = calculate_combination_probability(combo, reels)
return probability * combo.payout

def calculate_combination_probability(combo: SymbolCombination, reels: List[VirtualReel]) -> float:
"""计算组合出现的概率"""
probability = 1.0
for i, symbol in enumerate(combo.symbols):
reel = reels[i]
if symbol in reel.symbols:
index = reel.symbols.index(symbol)
weight = reel.weights[index]
probability *= weight / sum(reel.weights)
else:
return 0.0 # 如果符号不在转轮上,概率为0
return probability

def calculate_theoretical_rtp(reels: List[VirtualReel], paytable: List[SymbolCombination]) -> float:
"""计算整个游戏的理论RTP"""
total_rtp = 0.0

# 基础游戏RTP来自所有可能的赢付组合
for combo in paytable:
total_rtp += calculate_combination_rtp(combo, reels)

# 这里可以加上特殊功能的RTP贡献
# total_rtp += calculate_bonus_game_rtp(reels)
# total_rtp += calculate_free_spins_rtp(reels)
# total_rtp += calculate_jackpot_rtp(reels)

return total_rtp

RTP组成分解

现代老虎机的RTP通常由多个部分组成:

典型的RTP分配例子:

  • 基础游戏:60-65% RTP
  • 免费游戏特性:20-25% RTP
  • 小游戏/奖金游戏:5-10% RTP
  • 累进奖池贡献:1-3% RTP
  • 其他特殊功能:2-5% RTP

动态RTP调整(部分地区允许)
某些司法管辖区允许的动态RTP调整系统:

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

class DynamicRTPController:
def __init__(self, base_rtp: float, min_rtp: float, max_rtp: float):
self.base_rtp = base_rtp
self.current_rtp = base_rtp
self.min_rtp = min_rtp
self.max_rtp = max_rtp
self.adjustment_factor = 0.01

def adjust_reels(self, reels: List[VirtualReel], metrics: dict):
"""基于当前性能调整虚拟转轮配置"""
allowed_deviation = 0.01 # 1%的允许偏差

if abs(self.current_rtp - self.base_rtp) > allowed_deviation:
# 计算需要的调整幅度
adjustment = self.calculate_required_adjustment()

# 通过细微调整符号权重来影响RTP
for reel in reels:
self.adjust_symbol_weights(reel, adjustment)

# 验证调整后的理论RTP
new_theoretical_rtp = self.recalculate_rtp(reels)

# 确保调整后的RTP在合法范围内
assert self.min_rtp <= new_theoretical_rtp <= self.max_rtp

def calculate_required_adjustment(self) -> float:
"""计算需要的调整量"""
return (self.base_rtp - self.current_rtp) * self.adjustment_factor

def adjust_symbol_weights(self, reel: VirtualReel, adjustment: float):
"""调整符号权重"""
# 简化实现:按比例调整所有权重
for i in range(len(reel.weights)):
reel.weights[i] = max(1, int(reel.weights[i] * (1 + adjustment)))

def recalculate_rtp(self, reels: List[VirtualReel]) -> float:
"""重新计算RTP"""
# 这里简化实现,实际需要完整的paytable
return calculate_theoretical_rtp(reels, [])

波动性与风险设计

波动性数学模型
波动性(Volatility)决定了游戏的风险和回报特性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14

def calculate_volatility(reels: List[VirtualReel], paytable: List[SymbolCombination]) -> float:
"""计算游戏波动性"""
expected_payout = calculate_theoretical_rtp(reels, paytable)
variance = 0.0

for combo in paytable:
probability = calculate_combination_probability(combo, reels)
payout = combo.payout
variance += probability * (payout - expected_payout) ** 2

return variance ** 0.5 # 标准差作为波动性指标


赢利分布设计

赢利分布的设计是老虎机算法的关键部分:

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
class WinCategory(Enum):
NO_WIN = 0
SMALL_WIN = 1
MEDIUM_WIN = 2
BIG_WIN = 3
HUGE_WIN = 4
JACKPOT = 5

class WinDistributionTarget:
def __init__(self):
self.target_percentages = {
WinCategory.NO_WIN: 50.0, # 50%无赢利
WinCategory.SMALL_WIN: 30.0, # 30%小赢利
WinCategory.MEDIUM_WIN: 15.0, # 15%中等赢利
WinCategory.BIG_WIN: 4.0, # 4%大赢利
WinCategory.HUGE_WIN: 0.9, # 0.9%巨大赢利
WinCategory.JACKPOT: 0.1 # 0.1%头奖
}

def get_percentage(self, category: WinCategory) -> float:
return self.target_percentages.get(category, 0.0)

ALLOWED_DEVIATION = 0.5 # 允许0.5%的偏差

def validate_win_distribution(reels: List['VirtualReel'],
paytable: List['SymbolCombination'],
target: WinDistributionTarget) -> bool:
"""
验证赢利分布是否符合设计目标

参数:
reels: 虚拟转轮列表
paytable: 赔付表
target: 赢利分布目标

返回:
bool: 是否通过验证
"""
# 模拟大量旋转,记录各级别赢利的频率
win_counts = defaultdict(int)
simulation_count = 1000000

rng = SlotRNG()
for _ in range(simulation_count):
result = simulate_spin(rng, reels)
win_amount = calculate_win(result, paytable)
category = categorize_win(win_amount)
win_counts[category] += 1

# 检查每个类别的实际分布是否接近目标
for category, count in win_counts.items():
actual_percentage = count / simulation_count * 100
target_percentage = target.get_percentage(category)

if abs(actual_percentage - target_percentage) > ALLOWED_DEVIATION:
print(f"验证失败: {category.name} 实际{actual_percentage:.2f}% vs 目标{target_percentage:.2f}%")
return False

print("赢利分布验证通过!")
return True



def simulate_spin(rng: SlotRNG, reels: List[VirtualReel]) -> SpinResult:
"""模拟一次旋转"""
result = SpinResult()
result.visible_symbols = [reel.get_random_symbol(rng) for reel in reels]
return result

def calculate_win(result: SpinResult, paytable: List[SymbolCombination]) -> int:
"""计算赢利金额"""
# 简化实现 - 实际游戏会有更复杂的赢线计算
for combo in paytable:
if result.visible_symbols == combo.symbols:
return combo.payout
return 0

def categorize_win(win_amount: int) -> WinCategory:
"""将赢利金额分类"""
if win_amount == 0:
return WinCategory.NO_WIN
elif win_amount <= 10:
return WinCategory.SMALL_WIN
elif win_amount <= 50:
return WinCategory.MEDIUM_WIN
elif win_amount <= 200:
return WinCategory.BIG_WIN
elif win_amount <= 1000:
return WinCategory.HUGE_WIN
else:
return WinCategory.JACKPOT

热力学模型设计

一些现代老虎机采用热力学模型来控制玩家体验曲线:

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
class ThermalModel:
def __init__(self, cooling_rate: float = 0.01,
heating_increment: float = 0.001,
max_temperature: float = 100.0,
max_probability_boost: float = 2.0):
"""
初始化热力学模型

参数:
cooling_rate: 温度下降速率 (默认0.01表示每帧下降1%)
heating_increment: 每次投注增加的温度量 (默认0.001)
max_temperature: 最高温度上限 (默认100.0)
max_probability_boost: 最大概率提升倍数 (默认2.0)
"""
self.temperature = 0.0
self.cooling_rate = cooling_rate
self.heating_increment = heating_increment
self.max_temperature = max_temperature
self.max_probability_boost = max_probability_boost

def update(self, bet_amount: float) -> None:
"""
更新系统温度

参数:
bet_amount: 当前投注金额
"""
# 投注增加系统温度
self.temperature += self.heating_increment * bet_amount

# 随时间冷却
self.temperature *= (1.0 - self.cooling_rate)

# 确保温度在有效范围内
self.temperature = max(0.0, min(self.temperature, self.max_temperature))

def get_jackpot_probability_factor(self) -> float:
"""
获取基于当前温度的大奖概率修正因子

返回:
float: 概率修正因子 (1.0表示基础概率,2.0表示双倍概率等)
"""
# 温度越高,大奖概率越大
return 1.0 + (self.temperature / self.max_temperature) * self.max_probability_boost

def get_temperature_percentage(self) -> float:
"""
获取当前温度百分比 (0.0到1.0)
"""
return self.temperature / self.max_temperature


特殊功能触发系统

免费游戏触发算法
免费游戏是老虎机中最常见的特殊功能:

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


def should_trigger_free_spins(result: SpinResult) -> bool:
"""
判断是否应该触发免费游戏

参数:
result: 旋转结果

返回:
bool: 是否触发免费游戏
"""
scatter_count = 0
for reel_symbols in result.visible_symbols:
for symbol in reel_symbols:
if symbol == Symbol.SCATTER:
scatter_count += 1

# 通常3个或更多散布符号触发免费游戏
return scatter_count >= 3

def determine_free_spin_count(scatter_count: int, rng: SlotRNG) -> int:
"""
确定免费游戏数量

参数:
scatter_count: 散布符号数量
rng: 随机数生成器

返回:
int: 免费旋转次数
"""
# 基础免费游戏数量
if scatter_count == 3:
base_spins = 10
elif scatter_count == 4:
base_spins = 15
elif scatter_count == 5:
base_spins = 20
else:
return 0 # 不足3个散布符号,不触发免费游戏

# 可能有额外奖励
bonus_spins = 0
if VARIABLE_FREE_SPINS_ENABLED:
# 随机额外0-5次免费旋转
bonus_spins = rng.next(0, 5)

return base_spins + bonus_spins

# 示例使用

特殊符号行为算法

像Wild和扩展符号这样的特殊元素需要特定的处理:

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


def process_wild_symbols(original_grid: List[List[Symbol]]) -> List[List[Symbol]]:
"""
处理各种Wild符号逻辑

参数:
original_grid: 原始符号网格

返回:
处理后的符号网格
"""
processed_grid = [row.copy() for row in original_grid]

# 扩展Wild处理
for reel in range(len(processed_grid)):
for row in range(len(processed_grid[reel])):
if processed_grid[reel][row] == Symbol.EXPANDING_WILD:
# 扩展到整个转轮
for expand_row in range(len(processed_grid[reel])):
processed_grid[reel][expand_row] = Symbol.WILD
break # 已处理此转轮,跳至下一转轮

# 粘性Wild处理 (示例实现)
for reel in range(len(processed_grid)):
for row in range(len(processed_grid[reel])):
if processed_grid[reel][row] == Symbol.STICKY_WILD:
# 粘性Wild会保留在当前位置多轮旋转
pass

# 行走Wild处理 (示例实现)
walking_wild_positions = []
for reel in range(len(processed_grid)):
for row in range(len(processed_grid[reel])):
if processed_grid[reel][row] == Symbol.WALKING_WILD:
walking_wild_positions.append((reel, row))

for reel, row in walking_wild_positions:
# 行走Wild每次旋转移动一个位置
new_row = (row + 1) % len(processed_grid[reel])
processed_grid[reel][row] = Symbol.BLANK # 假设BLANK表示空白
processed_grid[reel][new_row] = Symbol.WALKING_WILD

# 乘数Wild处理 (示例实现)
for reel in range(len(processed_grid)):
for row in range(len(processed_grid[reel])):
if processed_grid[reel][row] == Symbol.MULTIPLIER_WILD:
# 乘数Wild会增加赢利倍数
pass

return processed_grid


小游戏与奖金游戏触发

复杂的老虎机通常包含多种小游戏:

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


def select_and_trigger_bonus_game(result: SpinResult, rng: SlotRNG) -> BonusGame:
"""
选择并触发小游戏

参数:
result: 旋转结果
rng: 随机数生成器

返回:
BonusGame: 触发的小游戏对象
"""
available_games = []

# 检查各种触发条件
if has_bonus_symbol_combination(result):
available_games.append(BonusGameType.PICK_AND_WIN)

if has_jackpot_symbols(result):
available_games.append(BonusGameType.JACKPOT_WHEEL)

if has_special_pattern(result):
available_games.append(BonusGameType.SKILL_GAME)

# 如果有多个可能的小游戏,选择一个
if available_games:
if PLAYER_CHOICE_ENABLED:
selected_type = present_choice_to_player(available_games)
else:
selected_type = select_bonus_game_by_strategy(available_games, rng)

return initialize_bonus_game(selected_type, result, rng)

# 没有触发小游戏
return BonusGame(BonusGameType.NONE)

# 辅助函数
def has_bonus_symbol_combination(result: SpinResult) -> bool:
"""检查是否有奖金符号组合"""
bonus_count = sum(row.count(Symbol.BONUS) for row in result.visible_symbols)
return bonus_count >= 3 # 假设3个或更多BONUS符号触发

def has_jackpot_symbols(result: SpinResult) -> bool:
"""检查是否有Jackpot符号组合"""
jackpot_count = sum(row.count(Symbol.JACKPOT) for row in result.visible_symbols)
return jackpot_count >= 2 # 假设2个或更多JACKPOT符号触发

def has_special_pattern(result: SpinResult) -> bool:
"""检查是否有特殊图案"""
# 简化的特殊图案检查逻辑
for reel in result.visible_symbols:
if all(symbol == Symbol.WILD for symbol in reel):
return True
return False

def present_choice_to_player(available_games: List[BonusGameType]) -> BonusGameType:
"""让玩家选择小游戏"""
print("请选择要玩的小游戏:")
for i, game_type in enumerate(available_games, 1):
print(f"{i}. {game_type.name}")

while True:
try:
choice = int(input("请输入选择: ")) - 1
if 0 <= choice < len(available_games):
return available_games[choice]
print("无效选择,请重试")
except ValueError:
print("请输入数字")

def select_bonus_game_by_strategy(available_games: List[BonusGameType], rng: SlotRNG) -> BonusGameType:
"""根据策略选择小游戏"""
# 简单实现: 随机选择
return rng.choice(available_games)

def initialize_bonus_game(game_type: BonusGameType, result: SpinResult, rng: SlotRNG) -> BonusGame:
"""初始化选中的小游戏"""
print(f"触发小游戏: {game_type.name}")
return BonusGame(game_type)

高级优化算法

符号评估优化
高效计算赢线是性能关键点:

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

def evaluate_wins_optimized(grid: List[List[Symbol]],
paylines: List[PayLine],
paytable: PayTable) -> WinResult:
"""
优化的赢线评估算法

参数:
grid: 符号网格 (reels x rows)
paylines: 所有赢线
paytable: 赔付表

返回:
WinResult: 包含所有赢利结果
"""
result = WinResult()

# 预处理 - 创建符号位置查找表
symbol_positions: DefaultDict[Symbol, List[Position]] = defaultdict(list)
for reel_idx, reel in enumerate(grid):
for row_idx, symbol in enumerate(reel):
symbol_positions[symbol].append(Position(reel_idx, row_idx))

# 对每个可能获奖的符号只处理一次
for symbol, positions in symbol_positions.items():
if symbol == Symbol.BLANK:
continue

# 检查此符号在每条赢线上的状态
for payline in paylines:
consecutive_count = count_consecutive_symbols(grid, payline, symbol)

if consecutive_count >= 3: # 假设最小赢线为3连
win_amount = paytable.get_payout_amount(symbol, consecutive_count)
if win_amount > 0:
result.add_win(payline, symbol, consecutive_count, win_amount)

return result

def count_consecutive_symbols(grid: List[List[Symbol]],
payline: PayLine,
symbol: Symbol) -> int:
"""
计算赢线上连续相同符号的数量

参数:
grid: 符号网格
payline: 赢线
symbol: 要检查的符号

返回:
int: 连续相同符号的数量
"""
count = 0
for position in payline.positions:
if grid[position.reel][position.row] in (symbol, Symbol.WILD): # Wild可以替代其他符号
count += 1
else:
break # 连续中断
return count

并行计算与分布式系统

对于大型模拟和分析,并行计算至关重要:

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

def simulate_spin(rng: SlotRNG, reels: List[List[Symbol]]) -> SpinResult:
"""模拟一次旋转"""
result = SpinResult()
for reel in reels:
stop_pos = rng.next(0, len(reel)-1)
# 获取可见符号(假设显示3行)
visible = [reel[(stop_pos+i) % len(reel)] for i in range(3)]
result.visible_symbols.append(visible)
return result

def evaluate_win(result: SpinResult, pay_table: PayTable) -> float:
"""评估赢利金额"""
# 简化的赢线评估(只检查第一条水平线)
line_symbols = [reel[1] for reel in result.visible_symbols] # 中间行
return pay_table.get_payout(line_symbols)

def calculate_variance(results: List[float], mean: float) -> float:
"""计算方差"""
return statistics.variance(results) if len(results) > 1 else 0.0

def worker_simulation(config: SlotGameConfiguration, sims: int, seed: int) -> float:
"""工作线程的模拟任务"""
rng = SlotRNG(seed)
total_bet = 0.0
total_win = 0.0

for _ in range(sims):
result = simulate_spin(rng, config.reels)
win = evaluate_win(result, config.pay_table)
total_bet += config.bet_amount
total_win += win

return total_win / total_bet if total_bet > 0 else 0.0

def parallel_rtp_validation(config: SlotGameConfiguration, simulation_count: int):
"""并行RTP验证"""
# 确定线程数(不超过CPU核心数)
try:
threads = min(8, os.cpu_count() or 1) # 限制最大8线程
except:
threads = 4

sims_per_thread = simulation_count // threads
remaining = simulation_count % threads

print(f"使用 {threads} 个线程并行模拟,每个线程 {sims_per_thread} 次...")

with concurrent.futures.ThreadPoolExecutor(max_workers=threads) as executor:
# 分配任务
futures = []
for i in range(threads):
# 最后一个线程处理剩余模拟次数
count = sims_per_thread + (remaining if i == threads-1 else 0)
futures.append(
executor.submit(worker_simulation, config, count, i+1)
)

# 收集结果
results = []
total_rtp = 0.0
for future in concurrent.futures.as_completed(futures):
thread_rtp = future.result()
results.append(thread_rtp)
total_rtp += thread_rtp

# 计算统计信息
average_rtp = total_rtp / threads
std_dev = np.sqrt(calculate_variance(results, average_rtp)) if len(results) > 1 else 0.0

print(f"\n模拟结果 (总旋转次数: {simulation_count}):")
print(f"平均 RTP: {average_rtp*100:.2f}%")
print(f"标准差: {std_dev*100:.2f}%")
print(f"各线程 RTP: {[round(r*100,2) for r in results]}")

巨型奖池管理

累进式奖池需要特殊的算法:

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
class ProgressiveJackpotSystem:
def __init__(self, tiers):
self.tiers = tiers
self.reserve_pool = 0.0

def process_contribution(self, bet_amount):
for tier in self.tiers:
contribution = bet_amount * tier.contribution_rate
tier.current_amount += contribution * 0.7
self.reserve_pool += contribution * 0.3

def check_jackpot_trigger(self, rng, bet_amount):
result = JackpotResult()
result.triggered = False

for i, tier in enumerate(self.tiers):
adjusted_probability = calculate_adjusted_probability(tier, bet_amount)

if rng.next(0, 1) < adjusted_probability:
result.triggered = True
result.tier_index = i
result.amount = tier.current_amount

tier.last_hit_timestamp = time.time()

reset_amount = max(tier.seed_amount, min(self.reserve_pool, tier.seed_amount * 2))
tier.current_amount = reset_amount
self.reserve_pool -= (reset_amount - tier.seed_amount)

break

return result

合规与安全考量

合规验证算法
确保游戏符合各司法管辖区的法规:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def verify_regulatory_compliance(config, requirements):
theoretical_rtp = calculate_theoretical_rtp(config)
if theoretical_rtp < requirements.min_rtp or theoretical_rtp > requirements.max_rtp:
return False

max_possible_win = find_max_possible_win(config)
if max_possible_win > requirements.max_win_multiplier * config.max_bet:
return False

cycle_period = calculate_minimum_cycle_period(config)
if cycle_period < requirements.min_cycle_period:
return False

if requirements.near_miss_restrictions and has_prohibited_near_miss(config):
return False

# 其他特定司法管辖区的规定...

return True

反作弊与安全措施

防止黑客和作弊行为的算法:

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
import hashlib

class SecuritySystem:
def __init__(self):
self.game_history = []
self.crypto = CryptoProvider()

def verify_game_integrity(self, session_id, spin_id):
record = next((record for record in self.game_history if record.session_id == session_id and record.spin_id == spin_id), None)
if not record:
return False

computed_hash = self.calculate_result_hash(record)
return computed_hash == record.integrity_hash

def detect_anomalies(self, session_id):
report = SuspiciousActivityReport()

session_records = [record for record in self.game_history if record.session_id == session_id]

report.unusual_win_patterns = detect_unusual_win_patterns(session_records)
report.rng_anomalies = detect_rng_anomalies(session_records)
report.timing_anomalies = detect_timing_anomalies(session_records)

# 其他安全检查...

return report

def calculate_result_hash(self, record):
data_to_hash = f"{record.session_id}|{record.spin_id}|{record.rng_seed}|{serialize_result(record.result)}|{record.win:.6f}|{record.timestamp}"
return self.crypto.compute_hash(data_to_hash)

模拟测试与验证系统

蒙特卡洛模拟
大规模模拟是验证老虎机设计的关键:

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
from concurrent.futures import ThreadPoolExecutor
import time

class SlotSimulationSystem:
def __init__(self, config, simulation_count=10000000000, threads=0):
self.config = config
self.simulation_count = simulation_count
self.thread_count = threads or os.cpu_count()

def run_full_simulation(self):
results = SimulationResults()
results.start_time = time.time()

with ThreadPoolExecutor(max_workers=self.thread_count) as executor:
futures = [executor.submit(self.simulate_on_thread, sims_per_thread, t) for t in range(self.thread_count)]

for future in futures:
thread_results = future.result()
results.total_bet += thread_results.total_bet
results.total_win += thread_results.total_win

for key, value in thread_results.feature_trigger_counts.items():
results.feature_trigger_counts[key] += value

for key, value in thread_results.win_distribution.items():
results.win_distribution[key] += value

if thread_results.max_win > results.max_win:
results.max_win = thread_results.max_win
results.max_win_details = thread_results.max_win_details

results.end_time = time.time()
results.actual_rtp = results.total_win / results.total_bet
results.simulation_time = results.end_time - results.start_time

return results

def simulate_on_thread(self, sim_count, thread_id):
results = ThreadSimResults()
rng = SlotRNG()
rng.seed += thread_id

for _ in range(sim_count):
spin_result = simulate_spin(rng, self.config)
win = evaluate_win(spin_result, self.config)

results.total_bet += self.config.bet_amount
results.total_win += win

category = categorize_win(win, self.config.bet_amount)
results.win_distribution[category] += 1

track_feature_triggers(spin_result, results.feature_trigger_counts)

if win > results.max_win:
results.max_win = win
results.max_win_details = spin_result

if _ % 1000000000 == 0 and _ > 0:
print(f"Thread {thread_id}: {_ // 1000000000} billion spins completed")

return results

极端情况与边缘案例测试

测试罕见场景和边缘情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
def test_edge_cases(config):
# 测试最高赢利组合
max_win_setup = create_max_win_scenario(config)
win = evaluate_win(max_win_setup, config)
print(f"Maximum theoretical win: {win} (multiplier: {win / config.bet_amount:.2f}x)")

# 测试特殊功能级联
cascading_features = create_cascading_features_scenario(config)
cascade = simulate_feature_cascade(cascading_features, config)
print(f"Maximum feature cascade depth: {cascade.depth} with total win: {cascade.total_win}")

# 测试边缘RTP情况
sensitivity = analyze_rtp_sensitivity(config)
print(f"Most sensitive parameter: {sensitivity.most_sensitive_param} with impact of {sensitivity.max_impact:.2f}%")

# 测试极端长度的游戏会话
long_session = simulate_long_player_session(config)
print(f"Long session results: RTP convergence after {long_session.convergence_spins} spins to {long_session.converged_rtp:.2f}%")

个人总结

老虎机的核心算法是一个精密的数学和软件工程系统,结合了随机数生成、概率分布、经济模型和游戏设计原则。现代老虎机的复杂性远超表面所见,需要大量的数学验证和技术优化才能创造既公平又有吸引力的游戏体验。

作为一名资深的Slots游戏开发,深入理解这些算法原理并确保它们的正确实现,对于创造成功的游戏产品至关重要。通过精心设计的核心算法,我们可以在保证游戏公平性和合规性的同时,为玩家提供引人入胜的娱乐体验。

Cash Club 上半年研发复盘:多主题 Slot、运营框架和性能优化到底沉淀了什么

从 1 月到 5 月,这一组文章把我在 Cash Club 这条产品线里最值得沉淀的部分基本都梳理了一遍。

回头看,这段经历最重要的收获并不是“我做过多少功能”,而是我越来越确定:强运营 Slot 项目真正拼的是系统化能力,而不是某一个局部功能写得多快。

一、这一阶段最重要的不是功能数量,而是分层是否稳定

前面几篇已经分别讲过:

  • Unity + C# 负责运行时和底层能力
  • xLua-framework 负责业务逻辑组织
  • Lua 承担高频变化的业务和主题流程
  • Python 后端守住可信结果和关键状态

这套分层看起来像架构话题,但它直接决定了项目后续能不能继续扩展。

如果边界不稳,主题一多、活动一密、外围系统一上量,客户端就会很快失控。

二、多主题机台真正难的,从来不是做第一个,而是做第十个

一个主题机台做出来并不代表项目架构是对的。

真正能说明问题的是:

  • 新主题接入会不会越来越快
  • 通用 bug 能不能一次修好
  • 配置和资源规范能不能撑住后续量产

所以回过头看,模板、工具、导表流程、资源规范这些基础能力,比某个主题机台的单点实现更值得投入。

三、外围系统的价值,不比核心玩法低

商店、支付、任务、大师任务、成就、日周月活动,这些内容表面上像机台之外的“配套系统”,实际上它们才是长期留存和付费节奏的主要支撑点。

尤其当项目进入稳定运营阶段以后,很多时候玩家感知到的并不是底层算法,而是:

  • 今天有没有事可做
  • 有没有目标和奖励
  • 有没有限时内容和节奏变化
  • 支付和领奖是否顺畅

所以这些系统做得稳不稳,和核心玩法一样重要。

四、性能优化是整个阶段里最实际的一条主线

我在项目里体感最深的一个问题始终是性能。

原因并不复杂:

  • 多主题机台资源重
  • 商店、活动、弹窗等 UI 很密
  • 图集、AB、下载资源和内存管理都容易出问题
  • 低端机对稳定性非常敏感

不论是把资源下载策略从全量预载调整为场景流式加载,还是做 AssetBundle 依赖分析、UI 刷新优化、图集整理、低端机降级,背后都说明一件事:

真正影响线上体验的,常常不是某段花哨逻辑,而是这些基础工程能力。

五、这段经历让我更确定一件事:平台型产品必须做框架化

Cash Club 不是一个做完就结束的单次项目,而是一个不断生长的平台型产品。

这种产品只靠“谁熟悉代码谁就多顶一点”是撑不住的。

必须把重复问题抽成框架能力:

  • 活动基类
  • 页面模板
  • 配置导表流程
  • 资源与包体分析工具
  • 稳定的前后端边界

这些能力一开始未必最显眼,但到中后期会越来越值钱。

六、总结

这一阶段的 Cash Club 开发经历,对我来说最有价值的沉淀主要有三点:

  1. 强运营 Slot 项目首先要把分层和边界拆清楚。
  2. 多主题、活动和外围系统必须依赖框架化能力,而不是复制式开发。
  3. 性能优化和工程工具不是附属工作,它们直接决定项目能不能长期稳定迭代。

如果后面还继续写这个系列,我会更想往两个方向再挖深一点:

  • 一是性能优化里的具体方法论,比如内存、AB、UI 和低端机治理。
  • 二是 Slot 数学模型、RTP 和主题玩法设计与客户端实现之间的关系。

这几个月的文章先写到这里,算是把这一段开发经历做了一次比较完整的整理。

Bingo 玩法接入 Cash Club 的实现思路:房间、卡面、判定和奖励循环

如果说 Coin Master 风格小游戏更偏玩法外扩,那么 Bingo 更像是一套结构相对完整的小玩法系统。

它不只是换一个界面,而是有自己比较稳定的玩法循环、奖励节奏和展示结构。

一、Bingo 为什么适合出现在 Slot 项目里

因为它天然具备几个优点:

  • 规则容易理解
  • 节奏和主机台不同,能形成体验切换
  • 奖励反馈很清晰
  • 很适合和活动系统联动

对于强运营产品来说,这类玩法能很好地承担阶段性留存和内容丰富度。

二、客户端实现时最核心的四块

1. 房间或局结构

玩家进入哪一个玩法上下文,规则和奖励档位是否不同,这些决定了整体体验结构。

2. 卡面组织

卡面不是纯 UI,它和判定、状态刷新、完成反馈紧密相关。

3. 判定流程

哪些条件算完成,完成后怎么反馈,是否需要和服务端同步关键状态,这些都要先定义清楚。

4. 奖励循环

奖励怎么领取、怎么领回主系统、是否影响任务和活动,这些都是 Bingo 接入时必须考虑的链路。

三、为什么 Bingo 很适合检验“系统联动能力”

一个成熟的 Bingo 玩法,通常不会只和自己发生关系。

它会和:

  • 活动系统
  • 任务系统
  • 奖励系统
  • 商店或礼包入口

这些模块同时产生联动。

也就是说,Bingo 做得顺不顺,能很直接地反映整个项目的系统边界是否清楚。

四、总结

Bingo 玩法的重点,不在于单独做出一个小游戏页面,而在于房间、卡面、判定、奖励和系统联动能不能形成一条完整链路。

下一篇就是这个阶段的收束文章。我会把前面这些机台、系统、活动和扩展玩法的经验放在一起,做一次 Cash Club 上半年研发复盘。