GitHub - noxke/TencentGameClientOpenCourse: 腾讯游戏客户端公开课2023 腾讯菁英班
游戏分析
使用工具如下:
dnSpy v6.1.8
Cheat Engine 7.5
BepInEx 5.4.22
BepInEx.ConfigurationManager v18.0.1
UnityExplorer 4.9.0
Unity 脚本 API
游戏使用Unity Mono实现,主要游戏逻辑在./FlappyBird_Data/Managed/Assembly-CSharp.dll
文件中,使用C#语言编写
使用dnSpy加载游戏的程序集文件Assembly-CSharp.dll
根据类名可以知道每个类的主要功能,与小鸟有关的功能实现在BirdScripts
类中,有关于游戏控制的实现在GameControllers
类和GamePlayController
类中
首先分析BirdScripts
类
BirdScripts
类派生自MonoBehaviour
,其中的Awake
方法在加载实例时调用
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| private void Awake() { if (BirdScripts.instance == null) { BirdScripts.instance = this; } this.isAlive = true; this.score = 0; this.flapButton = GameObject.FindGameObjectWithTag("FlapButton").GetComponent<Button>(); this.flapButton.onClick.AddListener(delegate() { this.flapTheBird(); }); this.CameraX(); }
|
该方法主要是对小鸟的初始化,设置小鸟存活状态为true,设置分数0,绑定按钮事件等
FixUpdate
用于物理计算,循环调用
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
| private void FixedUpdate() { if (this.isAlive) { Vector3 position = base.transform.position; position.x += this.forwardSpeed * Time.deltaTime; base.transform.position = position; if (this.didFlap) { this.didFlap = false; this.myRigidBody.velocity = new Vector2(0f, this.bounceSpeed); this.audioSource.PlayOneShot(this.flapClick); this.anim.SetTrigger("Flap"); } if (this.myRigidBody.velocity.y >= 0f) { base.transform.rotation = Quaternion.Euler(0f, 0f, 0f); } else { float z = Mathf.Lerp(0f, -70f, -this.myRigidBody.velocity.y / 7f); base.transform.rotation = Quaternion.Euler(0f, 0f, z); } } }
|
主要逻辑是移动小鸟坐标,执行flap操作后为小鸟设置y方向上的速度向量,播放音乐,播放动画,当y方向上速度向量为0时小鸟不旋转,当y方向上速度向量小于0时小鸟绕z轴旋转
另外两个重要方法分别是OnCollisionEnter2D
而OnTriggerEnter2D
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| private void OnCollisionEnter2D(Collision2D target) { if (target.gameObject.tag == "Pipe" || target.gameObject.tag == "Ground" || target.gameObject.tag == "Enemy") { if (this.isAlive) { this.isAlive = false; this.anim.SetTrigger("BirdDied"); this.audioSource.PlayOneShot(this.diedClip); GamePlayController.instance.playerDiedShowScore(this.score); } } else if (target.gameObject.tag == "Flag" && this.isAlive) { this.isAlive = false; this.audioSource.PlayOneShot(this.cheerClip); GamePlayController.instance.finishGame(); } }
|
当小鸟刚体碰撞时调用OnCollisionEnter2D
方法,发生碰撞后,根据目标的标签判断游戏失败或游戏胜利
当碰撞的物体为管道Pipe
、地面Ground
或敌人Enemy
时,设置小鸟死亡,播放音乐,勃发动画,显示死亡分数等;当碰撞物体为棋子Flag
时,游戏胜利
1 2 3 4 5 6 7 8 9
| private void OnTriggerEnter2D(Collider2D target) { if (target.tag == "PipeHolder") { this.audioSource.PlayOneShot(this.pointClip); this.score++; GamePlayController.instance.setScore(this.score); } }
|
当小鸟进入附加到该对象的触发碰撞体时调用OnTriggerEnter2D
方法,该方法判断小鸟是否进入管道间隙PipeHolder
,进入到管道间隙时分数加一,更新排名显示的分数
instance
成员保存当前的小鸟实例
1
| public static BirdScripts instance;
|
myRigidBody
成员保存当前小鸟的刚体物理组件
游戏破解
根据上述分析可以了解,OnCollisionEnter2D
内实现小鸟的碰撞检测,删除该方法内触发小鸟死亡的分支,可实现无敌效果,OnTriggerEnter2D
内实现分数计算,修改该方法,可实增加游戏分数的效果
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| private void OnCollisionEnter2D(Collision2D target) { if (target.gameObject.tag == "Flag" && this.isAlive) { this.isAlive = false; this.audioSource.PlayOneShot(this.cheerClip); GamePlayController.instance.finishGame(); } } private void OnTriggerEnter2D(Collider2D target) { if (target.tag == "PipeHolder") { this.audioSource.PlayOneShot(this.pointClip); this.score += 233; GamePlayController.instance.setScore(this.score); } }
|
dnSpy修改方法并重新编译,替换原Assembly-CSharp.dll
程序集,运行游戏测试
无敌功能和分数修改功能已实现
开始游戏,暂停后使用CE打开游戏进程,选择Mono->.Net Info
查看进程加载的模块信息,选择Assembly-CSharp->BirdScript
模块
instance
为当前的小鸟实例,可以查看当前小鸟实例的成员属性和方法
可以查看和修改OnCollisionEnter2D
和OnTriggerEnter2D
的JIT代码
先分析OnCollisionEnter2D
的JIT代码,主要分析跳转指令
根据之前的源代码可以轻松分析处这部分调整是判断小鸟是否碰撞到会死亡的物体,当或语句中有任意一条成立时,跳转到0x121
行执行,如果或语句中3条判断都不成立,跳转到0x1a4
行,进入到0x121
行后,会判断小鸟的isAlive
是否为false
,为false
跳转到0x255
行退出调用,否则执行后续代码
另一个判断是否碰撞到棋子的分支类似
将第0x127
行分支修改为强制跳转,不执行后续代码,即可绕过小鸟死亡代码的执行
回到游戏进行测试
小鸟的无敌效果已经实现
查看OnTriggerEnter2D
方法的JIT代码
很明显,0x4a
行判断是否进入到管道间隙,中间的代码为修改分数的部分
将0x77
行inc指令修改为dec指令,使小鸟进入管道间隙分数减1
回到游戏进行测试
分数减为复数
也可以通过修改小鸟实例下的score
实现分数修改
UnityExplorer可以使用BepInEx、MelonLoader和Standalone三种加载方式加载,测试发现BepInEx的效率要高一点,比MelonLoader加载要流畅很多,而且BepInEx的文档更完善,方便插件的开发,因此后续使用BepInEx加载UnityExplorer进行分析,同时安装ConfigurationManager便于插件的管理和配置
BepInEx和UnityExplorer的安装在仓库Readme中都很详细,注意版本即可,此处选择的是BepInEx 5.4.22版本,安装后启用Logging.Console
设置,方便查看日志信息
在UnityExplorer中能看到小鸟的位置信息和加载的组件信息,主要关注RigidBody2D
组件和CircleCollider2D
组件
RigidBody2D
组件是小鸟的刚体组件信息,gravityScale
变量是刚体受重力的影响程度,将其修改为0可实现不受重力影响,实现漂浮效果,但需要同时将速度向量velocity
变量修改为0,避免小鸟持续向上或向下飞行,还需要将小鸟的bounceSpeed
修改为0,避免手贱点到屏幕让小鸟增加y方向的速度
CircleCollider2D
是小鸟的碰撞组件信息,将enable
修改为false可关闭小鸟的碰撞
修改后就实现了小鸟的悬浮飞行和穿墙效果,但是由于禁用了碰撞组件,因此不会检测与管道间隙的碰撞,分数不会增加,接触棋子也不会结束游戏,想要保存穿墙效果的同时能够增加分数,可以将所有管道、敌人的碰撞组件禁用
上述的修改方法中,除了修改程序集重新编译,另外两种均为手动修改,而且重新加载游戏后修改会失效,并且实现的功能有限,因此后续选择编写程序集的方法进行游戏破解
课堂视频中已经展示了使用SharpMonoInjector
工具注入程序集实现破解功能,但是仍然需要手动注入,不够优雅,因此这里选择用BepInEx插件的形式实现
插件开发文档:
https://docs.bepinex.dev/articles/dev_guide/plugin_tutorial/index.html
从0开始教你使用BepInEx为unity游戏制作插件Mod - 3DM Mod站
没写过C#代码,代码可能不够规范
Plugin.cs
是插件的入口类代码,Plugin
类派生自BaseUnityPlugin
GlobalVariables.cs
是代码使用的全局变量类,主要保存插件的配置信息已经从游戏内获取的对象信息
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| public static class GlobalVariables { public static BirdScripts birdInstances; public static Rigidbody2D birdRigidbody2D; public static Collider2D birdCollider2D; public static GameObject[] pipes; public static GameObject[] pipeHolders; public static GameObject[] enemies; public static GameObject[] flags; public static ConfigEntry<bool> invincible; public static ConfigEntry<bool> collision; public static ConfigEntry<bool> fly; public static ConfigEntry<float> speed; public static ConfigEntry<int> score; }
|
Cheat.cs
是插件作弊功能的核心实现
下面依次进行分析
与MonoBehaviour
类似,BaseUnityPlugin
中的Awake
方法在类实例加载时执行,该方法内用于绑定插件的设置,便于在ConfigurationManager插件中图形化地修改参数配置
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| private void Awake() { Logger.LogInfo($"Plugin {PluginInfo.PLUGIN_NAME} is loaded!"); GlobalVariables.invincible = Config.Bind("Config", "invincible", false, "碰撞无敌"); GlobalVariables.invincible.Value = false; GlobalVariables.collision = Config.Bind("Config", "collision", true, "开启碰撞"); GlobalVariables.collision.Value = true; GlobalVariables.fly = Config.Bind("Config", "fly", false, "开启飞行"); GlobalVariables.fly.Value = false; GlobalVariables.score = Config.Bind("Config", "score", 0, "游戏分数"); GlobalVariables.score.Value = 0; GlobalVariables.speed = Config.Bind("Config", "speed", 3f, "移动速度"); GlobalVariables.speed.Value = 3f; GlobalVariables.collision.SettingChanged += (sender, args) => Cheat.instance.SetCollision(GlobalVariables.collision.Value); GlobalVariables.fly.SettingChanged += (sender, args) => Cheat.instance.SetFly(GlobalVariables.fly.Value); GlobalVariables.score.SettingChanged += (sender, args) => Cheat.instance.SetScore(GlobalVariables.score.Value); GlobalVariables.speed.SettingChanged += (sender, args) => Cheat.instance.SetSpeed(GlobalVariables.speed.Value); }
|
Start
方法在所有插件加载后调用,此处用于实例化Cheat
类对象
1 2 3 4 5 6 7
| private void Start() { Logger.LogInfo($"Plugin {PluginInfo.PLUGIN_NAME} is started!"); Cheat.instance = new Cheat(); Cheat.instance.Start(); }
|
Update
方法会在游戏的每一帧调用,循环执行,此处用于调用Cheat
类的Update
方法
1 2 3 4 5 6
| private void Update() { Cheat.instance.Update(); }
|
OnGUI
方法用于在屏幕上显示标签,并显示当前开启的作弊功能
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| private void OnGUI() { GUI.skin.label.fontSize = 18; GUI.skin.label.normal.textColor = Color.blue; string labelText = "FlappyBird Cheat Plugin\n"; labelText += "written by noxke\n"; labelText += "[F1] : config and more feature\n"; labelText += "[ESC] : pause\n"; labelText += $"[1/2] invincible [{GlobalVariables.invincible.Value}]\n"; labelText += $"[3/4] : collision [{GlobalVariables.collision.Value}]\n"; labelText += $"[5/6] : fly [{GlobalVariables.fly.Value}]\n"; labelText += "[9] : add score\n"; labelText += "[up/down/left/right] : move\n"; labelText += "[0] : finish game\n"; labelText += $"speed [{GlobalVariables.speed.Value}]"; GUI.Label(new Rect(10, 10, 400, 300), labelText); }
|
Cheat
类为作弊功能的实现
类加载时调用Start
方法,进而调用SetPatch
方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| public void SetPatch()
{ Harmony harmony1 = new Harmony("com.noxke.patch1"); MethodInfo onCollisionEnter2D = AccessTools.Method(typeof(BirdScripts), "OnCollisionEnter2D"); harmony1.Patch(onCollisionEnter2D, prefix: new HarmonyMethod(typeof(Cheat).GetMethod("PrefixOnCollisionEnter2D"))); Debug.Log("Patch OnCollisionEnter2D");
Harmony harmony2 = new Harmony("com.noxke.patch2"); MethodInfo flapTheBird = AccessTools.Method(typeof(BirdScripts), "flapTheBird"); harmony2.Patch(flapTheBird, prefix: new HarmonyMethod(typeof(Cheat).GetMethod("PrefixFlapTheBird"))); Debug.Log("Patch flapTheBird"); }
|
SetPatch
方法内利用Harmony
提供的Patch
功能勾取BirdScripts
类的OnCollisionEnter2D
方法和flapTheBird
方法,由于OnCollisionEnter2D
是private
方法,需要使用反射的方法设置Patch
在OnCollisionEnter2D
方法被调用前执行Cheat
类的PrefixOnCollisionEnter2D
方法,判断发生碰撞的是否为管道、大地或者敌人,如果是,则跳过屏蔽OnCollisionEnter2D
方法的执行,屏蔽小鸟的死亡,如果为其他情况,即碰到棋子,则会执行原方法,触发游戏胜利
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| public static bool PrefixOnCollisionEnter2D(BirdScripts __instance, Collision2D target) { if (GlobalVariables.invincible.Value != true) { return true; } if (target.gameObject.tag == "Pipe" || target.gameObject.tag == "Ground" || target.gameObject.tag == "Enemy") { Debug.Log($"Collision {target.gameObject.tag}"); return false; } return true; }
|
在flapTheBird
方法调用前,即屏幕被点击触发小鸟向上飞之前,执行Cheat
类的PrefixFlapTheBird
方法,主要用来在飞行状态中屏蔽flap操作,避免小鸟获得向上的向量速度飞出屏幕为什么不之间修改bounceSpeed???
1 2 3 4 5 6 7 8 9 10 11
| public static bool PrefixFlapTheBird(BirdScripts __instance) { Debug.Log("Patch flapTheBird"); if (GlobalVariables.fly.Value == true) { return false; } return true; }
|
Update
方法循环调用,用于设置按键绑定,从游戏内获取对象,已经同步游戏分数到配置
1 2 3 4 5 6 7 8 9 10
| public void Update() { KeyBound(); GetObjects(); if (GlobalVariables.birdInstances != null) { GlobalVariables.score.Value = GlobalVariables.birdInstances.score; } }
|
KeyBound
方法用于监听按键操作,调用Cheat
中的功能
1 2 3 4 5 6 7 8 9 10 11 12 13
| private void KeyBound() { if (Input.GetKey(KeyCode.Escape)) { if (GamePlayController.instance != null) { Debug.Log("key ESC down, pause"); GamePlayController.instance.pauseGame();
} }
|
GetObjects
方法从游戏中获取小鸟的实例,获取小鸟的组件信息,获取游戏中的管道对象、管道间隙对象等
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| public void GetObjects() { GlobalVariables.birdInstances = BirdScripts.instance; if (GlobalVariables.birdInstances != null) { GlobalVariables.birdCollider2D = GlobalVariables.birdInstances.GetComponent<Collider2D>(); GlobalVariables.birdRigidbody2D = GlobalVariables.birdInstances.GetComponent<Rigidbody2D>(); } GlobalVariables.pipes = GameObject.FindGameObjectsWithTag("Pipe"); GlobalVariables.pipeHolders = GameObject.FindGameObjectsWithTag("PipeHolder"); GlobalVariables.enemies = GameObject.FindGameObjectsWithTag("Enemy"); GlobalVariables.flags = GameObject.FindGameObjectsWithTag("Flag"); }
|
小鸟的穿墙功能,即屏蔽碰撞的功能在SetCollision
方法中实现
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
| public void SetCollision(bool value) { GlobalVariables.collision.Value = value; for (int i = 0; i < GlobalVariables.pipes.Length; i++) { BoxCollider2D pipeBoxCollider2D = GlobalVariables.pipes[i].GetComponent<BoxCollider2D>(); if (pipeBoxCollider2D != null) { pipeBoxCollider2D.enabled = value; } } for (int i = 0; i < GlobalVariables.enemies.Length; i++) { BoxCollider2D enemyBoxCollider2D = GlobalVariables.enemies[i].GetComponent<BoxCollider2D>(); if (enemyBoxCollider2D == null) { Collider2D enemyCollider2D = GlobalVariables.enemies[i].GetComponent<Collider2D>(); if (enemyCollider2D != null) { enemyCollider2D.enabled = value; } } else { enemyBoxCollider2D.enabled = value; } } Debug.Log($"collision : {value}"); }
|
这里实现的方法不是禁用小鸟的碰撞组件,因为会影响游戏得分,使用的是禁用游戏中管道、敌人的碰撞组件
SetFly
方法开启悬浮飞行,设置小鸟的重力影响为0,将速度向量设为0
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| public void SetFly(bool value) { if (value == true) { GlobalVariables.birdRigidbody2D.gravityScale = 0f; GlobalVariables.birdRigidbody2D.velocity = new Vector2(0f, 0f); } else { GlobalVariables.birdRigidbody2D.gravityScale = 1f; } Debug.Log($"fly : {value}"); }
|
SetSocre
修改游戏分数,并刷新分数显示
1 2 3 4 5 6 7
| public void SetScore(int value) { GlobalVariables.birdInstances.score = value; GamePlayController.instance.setScore(value); Debug.Log($"set score {value}"); }
|
SetSpeed
设置x方向移动速度,由于forwardSpeed
是BirdScripts
的私有成员,不能能够小鸟的实例来直接修改,需要使用反射机制修改
1 2 3 4 5 6 7 8 9 10 11 12
| public void SetSpeed(float value) { GlobalVariables.speed.Value = value; FieldInfo privateField = typeof(BirdScripts).GetField("forwardSpeed", BindingFlags.NonPublic | BindingFlags.Instance); if (privateField != null) { privateField.SetValue(GlobalVariables.birdInstances, value); Debug.Log($"forward speed : {value}"); } }
|
BirdMove
将小鸟向指定方向移动
1 2 3 4 5 6 7 8 9 10 11
| public void BirdMove(float x, float y) { if (GlobalVariables.birdInstances != null) { Vector3 basePosition = GlobalVariables.birdInstances.transform.position; basePosition.x += x; basePosition.y += y; GlobalVariables.birdInstances.transform.position = basePosition; Debug.Log($"move bird x : {x}, y : {y}"); } }
|
FinishGame
触发游戏胜利,播放胜利音效
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| public void FinishGame() { if (GlobalVariables.birdInstances != null) { GlobalVariables.birdInstances.isAlive = false; FieldInfo cheerClipFiled = typeof(BirdScripts).GetField("cheerClip", BindingFlags.NonPublic | BindingFlags.Instance); AudioSource audioSource = GlobalVariables.birdInstances.GetComponent<AudioSource>(); audioSource.PlayOneShot((AudioClip)cheerClipFiled.GetValue(GlobalVariables.birdInstances)); GamePlayController.instance.finishGame(); Debug.Log("finish game!"); } }
|
编译插件得到CheatPlugin.dll
,复制到plugins插件文件下启动游戏
1 2 3 4 5 6 7 8 9 10 11
| PS C:\workspace\腾讯菁英班\外挂实现分析-PC端\CheatPlugin> dotnet build; cp .\bin\Debug\net35\CheatPlugin.dll ..\FlappyBird_BepInEx\BepInEx\plugins\ MSBuild version 17.3.2+561848881 for .NET 正在确定要还原的项目… 所有项目均是最新的,无法还原。 CheatPlugin -> C:\workspace\腾讯菁英班\外挂实现分析-PC端\CheatPlugin\bin\Debug\net35\CheatPlugin.dll
已成功生成。 0 个警告 0 个错误
已用时间 00:00:00.69
|
根据GUI提示,F1打开控制菜单,ESC暂停游戏,1/2切换无敌,3/4切换碰撞,5/6切换飞行,9增加分数,0结束游戏,上下左右控制小鸟移动