Flappy Bird 可热更化实战:C# → XLua 迁移

目标:把传统 C# 玩法完整迁到脚本热更新架构,做到“玩法用 Lua 快速迭代C# 只做稳定桥接”,并可平滑切换到 Addressables 远程脚本加载。
仓库:https://github.com/xiaoyunhai0/flappy_bird_xlua-

一、项目概览

  • 边界划分

    • C#(Bridge):生命周期转发、依赖注入、加载器、平台差异、AOT 配置。
    • Lua(Gameplay):输入、重力/位姿、管道生成与对象池、计分/死亡、UI 同步、背景滚动。
  • 性能策略对象池复用跨语言类型局部缓存GC Tick 秒级节流DOTween Sequence 替协程

  • 兼容策略:优先 TMP_Text,自动回落 UI.Text

  • 上线策略:本地 Resources → 可切换 Addressables 远程 Loader;IL2CPP 用 link.xml 与白名单保活。


二、目录结构与迁移策略

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Assets/
├─ Scripts_Bird/ # 旧 C# 玩法(保留用于对齐&回滚)
│ ├─ GameManager.cs
│ ├─ Player.cs
│ ├─ Pipes.cs
│ ├─ Spawner.cs
│ └─ Parallax.cs
├─ Resources/
│ └─ Lua/
│ ├─ flappy_bird.lua.txt # 主玩法(鸟/生成/计分/UI)
│ └─ util.lua.txt # 通用工具(TMP/Text 兼容等)
└─ Runtime/Lua/
├─ WidgetLuaBehaviour.cs # C#↔Lua 桥(生命周期/注入/GC/Loader)
├─ GenConfig.cs # xLua 暴露/回调类型配置
└─ link.xml # IL2CPP 剪裁保活

策略:先并行(C# 与 Lua 共存)→ 体验对齐 → 逐步下线旧 C#;全程可回滚。


三、运行时数据流与分层

1
2
3
4
5
UI(TMP_Text / Button)
└─ Lua 玩法(flappy_bird.lua / util.lua)
└─ C# 桥(WidgetLuaBehaviour:生命周期/注入/Loader/GC)
└─ 引擎(Transform / Physics2D / DOTween)
└─ 系统(MonoBehaviour / Time / Input)
  • Awake:C# 创建 LuaEnv + LuaTable,注入 selfInjection[]DoString() 执行 Lua。
  • Start/Update/OnEnable/…:C# 缓存同名 Lua 函数并逐帧转发。
  • Lua:统一输入→速度/位姿;Sequence 驱动帧动画与生成;对象池复用管道;文本优先 TMP,回落 Text

四、C#→Lua 桥(核心节选:Runtime/Lua/WidgetLuaBehaviour.cs

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
// File: WidgetLuaBehaviour.cs
#region using
using System;
using System.Collections.Generic;
using UnityEngine;
using XLua;
using DG.Tweening;
#endregion

namespace Project.Runtime.Lua
{
#region XLua Expose Types
public static class Gen
{
[LuaCallCSharp] public static List<Type> LuaCallTypes = new()
{
typeof(GameObject), typeof(Transform),
typeof(Vector2), typeof(Vector3), typeof(Quaternion),
typeof(Time), typeof(Debug),
typeof(TMPro.TextMeshProUGUI), typeof(TMPro.TextMeshPro),
typeof(DOVirtual), typeof(Tween), typeof(Sequence), typeof(Tweener)
};
[CSharpCallLua] public delegate void LuaAction();
}
#endregion

[Serializable] public class Injection { public string name; public UnityEngine.Object value; }

public sealed class WidgetLuaBehaviour : MonoBehaviour
{
#region Inspector
[SerializeField] private string resourcesPath = "Lua/flappy_bird";
[SerializeField] private TextAsset luaScript;
[SerializeField] private Injection[] injections;
#endregion

#region LuaEnv & Cache
private static readonly LuaEnv Env = new();
private const float GcInterval = 1f;
private static float _lastGc;
private LuaTable _env;
private Action _onStart, _onUpdate, _onEnable, _onDisable, _onDestroy, _onAwake;
#endregion

#region Unity Lifecycle
private void Awake()
{
Env.AddLoader(CustomLoader);

luaScript ??= Resources.Load<TextAsset>(resourcesPath);
if (!luaScript) { Debug.LogError("Lua script missing."); return; }

_env = Env.NewTable();
using var meta = Env.NewTable();
meta.Set("__index", Env.Global);
_env.SetMetaTable(meta);

_env.Set("self", this);
if (injections != null) foreach (var it in injections) _env.Set(it.name, it.value);

Env.DoString(luaScript.text, luaScript.name, _env);
_onAwake = _env.Get<Action>("Awake");
_onStart = _env.Get<Action>("Start");
_onUpdate = _env.Get<Action>("Update");
_onEnable = _env.Get<Action>("OnEnable");
_onDisable = _env.Get<Action>("OnDisable");
_onDestroy = _env.Get<Action>("OnDestroy");

_onAwake?.Invoke();
}

private void Start() => _onStart?.Invoke();
private void OnEnable() => _onEnable?.Invoke();
private void OnDisable() => _onDisable?.Invoke();

private void Update()
{
_onUpdate?.Invoke();
if (Time.time - _lastGc > GcInterval) { Env.Tick(); _lastGc = Time.time; }
}

private void OnDestroy()
{
_onDestroy?.Invoke();
_onStart = _onUpdate = _onEnable = _onDisable = _onDestroy = _onAwake = null;
_env?.Dispose(); _env = null; injections = null;
}
#endregion

#region Loader(示例:固定加载 util,可换 Addressables)
private static byte[] CustomLoader(ref string fileName)
{
var ta = Resources.Load<TextAsset>("Lua/util");
return ta ? System.Text.Encoding.UTF8.GetBytes(ta.text) : null;
}
#endregion
}
}

五、Lua 玩法(对象池 + 无 GC + UI 兼容)

Resources/Lua/util.lua.txt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
-- File: util.lua
local TMP_Text = CS.TMPro.TMP_Text
local LegacyText = CS.UnityEngine.UI.Text
local M = {}

function M.SetText(go, val)
if not go then return end
local tmp = go:GetComponent(typeof(TMP_Text))
if tmp then tmp.text = tostring(val); return end
local ui = go:GetComponent(typeof(LegacyText))
if ui then ui.text = tostring(val); return end
end

return M

Resources/Lua/flappy_bird.lua.txt(节选)

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
-- File: flappy_bird.lua
local U = require "util"
local UE = CS.UnityEngine
local V3, V2 = UE.Vector3, UE.Vector2
local Time = UE.Time
local DOTween = CS.DG.Tweening.DOTween

-- #region 注入对象(由 C# Inspector 提供)
Bird = Bird; Spawner = Spawner; prefab = prefab
scoreText = scoreText; playBtn = playBtn; gameOver = gameOver
Background = Background; Ground = Ground
Sprites1, Sprites2, Sprites3 = Sprites1, Sprites2, Sprites3
-- #endregion

-- #region 运行数据(局部缓存,减少分配)
local spriteIndex, sprites = 0, nil
local direction = V3.zero
local gravity, strength = -9.8, 5.0
local animBg, animGround = 0.05, 1.0
local spawnRate, minH, maxH = 1.0, -1.0, 2.0
local speed, leftEdge = 5.0, nil
local cached, score = {}, 0
local clicked, isOver = false, false
local sr, mrBg, mrG = nil, nil, nil
local seqAnim, seqSpawn = nil, nil
-- #endregion

function Awake() clicked = false; Pause() end
function Start() leftEdge = UE.Camera.main:ScreenToWorldPoint(V3.zero).x - 1.0 end
function OnEnable()
sr = Bird:GetComponent('SpriteRenderer')
mrBg = Background:GetComponent('MeshRenderer')
mrG = Ground:GetComponent('MeshRenderer')
sprites = {Sprites1, Sprites2, Sprites3}
RegisterButton(playBtn, PlayGame)
end

function Update()
if not clicked then return end
local input = UE.Input
if input.GetKeyDown(UE.KeyCode.Space) or input.GetMouseButtonDown(0) then
direction = V3.up * strength
end
if input.touchCount > 0 then
local t = input.GetTouch(0)
if t.phase == UE.TouchPhase.Began then direction = V3.up * strength end
end

direction.y = direction.y + gravity * Time.deltaTime
Bird.transform.position = Bird.transform.position + direction * Time.deltaTime

if mrBg then local m = mrBg.material; m.mainTextureOffset = m.mainTextureOffset + V2(animBg * Time.deltaTime, 0) end
if mrG then local m = mrG.material; m.mainTextureOffset = m.mainTextureOffset + V2(animGround * Time.deltaTime, 0) end

for i = 1, #cached do
local it = cached[i]
if it.gameObject.activeSelf then
local tf = it.gameObject.transform
tf.position = tf.position + V3.left * speed * Time.deltaTime

local x = tf.position.x
if x > -0.1 and x < 0.1 then
local y, top, bot = Bird.transform.position.y, it.scorezone.position.y + 1.0, it.scorezone.position.y - 1.0
if y > bot and y < top then IncreaseScore()
elseif not isOver then isOver = true; if seqSpawn then seqSpawn:Kill() end; GameOver() end
end

if tf.position.x < leftEdge then it.gameObject:SetActive(false) end
end
end
end

local function Cache(go)
local t = go.transform
return { gameObject = go, top = t:Find('Top Pipe'), bottom = t:Find('Bottom Pipe'), scorezone = t:Find('Scoring Zone') }
end
local function Rent()
for i = 1, #cached do if not cached[i].gameObject.activeSelf then return cached[i] end end
end
function Spawn()
local node = Rent()
if not node then
local go = UE.GameObject.Instantiate(prefab, Spawner.transform.position, UE.Quaternion.identity)
go.transform.position = go.transform.position + V3.up * math.random(minH, maxH)
table.insert(cached, Cache(go))
else
local tf = node.gameObject.transform
tf.position = Spawner.transform.position + V3.up * math.random(minH, maxH)
node.gameObject:SetActive(true)
end
end

local function AnimateSprite()
spriteIndex = spriteIndex + 1; if spriteIndex > #sprites then spriteIndex = 1 end
sr.sprite = sprites[spriteIndex]
end
function PlayGame()
isOver, clicked, score = false, true, 0
U.SetText(scoreText, score)
playBtn.gameObject:SetActive(false); gameOver:SetActive(false)
UE.Time.timeScale = 1.0

for i = 1, 5 do Spawn() end
for i = 1, #cached do cached[i].gameObject:SetActive(false) end

if seqAnim then seqAnim:Kill() end
if seqSpawn then seqSpawn:Kill() end
seqAnim = DOTween.Sequence():AppendCallback(AnimateSprite):AppendInterval(0.15):SetLoops(-1)
seqSpawn = DOTween.Sequence():AppendCallback(Spawn):AppendInterval(spawnRate):SetLoops(-1)
end
function GameOver() playBtn.gameObject:SetActive(true); gameOver:SetActive(true); Pause(); clicked = false end
function Pause() UE.Time.timeScale = 0.0 end
function IncreaseScore() score = score + 1; U.SetText(scoreText, score) end
function RegisterButton(btn, fn) btn.onClick:AddListener(fn) end

六、Addressables 远程 Loader(可替换 Resources)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// File: AddressablesLuaLoader.cs
using System.Text;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;

public static class AddressablesLuaLoader
{
public static byte[] Load(ref string fileName)
{
string key = $"lua/{fileName}.txt";
var h = Addressables.LoadAssetAsync<TextAsset>(key);
h.WaitForCompletion();
var ta = h.Status == AsyncOperationStatus.Succeeded ? h.Result : null;
Addressables.Release(h);
return ta ? Encoding.UTF8.GetBytes(ta.text) : null;
}
}

WidgetLuaBehaviour.Awake() 中使用:Env.AddLoader(AddressablesLuaLoader.Load)


七、IL2CPP / AOT 剪裁保活

Runtime/Lua/GenConfig.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
using System;
using System.Collections.Generic;
using XLua;
using UnityEngine;
using DG.Tweening;

public static class GenConfig
{
[LuaCallCSharp] public static List<Type> LuaCallCSharp = new()
{
typeof(GameObject), typeof(Transform), typeof(Time), typeof(Debug),
typeof(TMPro.TextMeshProUGUI), typeof(TMPro.TextMeshPro),
typeof(Sequence), typeof(Tween), typeof(Tweener), typeof(DOVirtual)
};
[CSharpCallLua] public static List<Type> CSharpCallLua = new() { typeof(Action) };
}

Runtime/Lua/link.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<linker>
<assembly fullname="UnityEngine">
<type fullname="UnityEngine.GameObject" preserve="all" />
<type fullname="UnityEngine.Transform" preserve="all" />
<type fullname="UnityEngine.Time" preserve="all" />
</assembly>
<assembly fullname="Unity.TextMeshPro">
<type fullname="TMPro.TextMeshProUGUI" preserve="all" />
<type fullname="TMPro.TextMeshPro" preserve="all" />
</assembly>
<assembly fullname="DOTween">
<type fullname="DG.Tweening.Sequence" preserve="all" />
<type fullname="DG.Tweening.Tween" preserve="all" />
<type fullname="DG.Tweening.Tweener" preserve="all" />
<type fullname="DG.Tweening.DOVirtual" preserve="all" />
</assembly>
<assembly fullname="XLua" preserve="all" />
</linker>

八、落地步骤

  1. 场景创建 LuaRunner,挂 WidgetLuaBehaviour
  2. Injections 注入:Bird(SpriteRenderer+Collider2D)、Spawnerprefab(含 Top/Bottom/Scoring Zone)、scoreText(TMP 或 Text)、playBtngameOverBackground/Ground(MeshRenderer)、Sprites1/2/3
  3. 放置 flappy_bird.lua.txtutil.lua.txtResources/Lua/
  4. 运行:点击 Play,空格/鼠标/触摸控制小鸟。
  5. (可选)切 Addressables Loader,实现脚本远程热更。

九、性能要点与排错表

  • 对象池:只 SetActive 与位置重置;首帧预热 5~10 组元素避免卡顿。
  • 无 GC:跨语言类型/字符串局部缓存;Update 避免临时表;Sequence 替协程。
  • GC TickluaEnv.Tick() 默认 1 秒一次足够。
  • 判定窗口:仅在 x≈0 的窄窗口做计分/死亡判定。
  • UI 兼容:统一 util.SetText,优先 TMP→Text 回落。
现象 可能原因 快速处理
NullReferenceException: TMP_Text 传入的是 Text 或未注入 已做 TMP→Text 回落;若仍 NRE,多半 scoreText 未注入
Lua attempt to index nil 注入名不一致 / 未注入 对照 Injection.name 与 Lua 变量名
DOTween 序列不执行 未导入/未初始化 导入 DOTween;运行 Utility Panel 生成链接
移动端触摸无效 EventSystem 在 Canvas 下添加 EventSystem
首波卡顿 首次 Instantiate 对象池预热或延迟首波生成
IL2CPP 崩溃/丢类型 剪裁导致 GenConfig.cs + link.xml 保活

十、可扩展路线

  • 难度曲线:分数↑ → spawnRate↓、speed↑(线性/指数/阶梯)。
  • 皮肤系统:小鸟帧图/背景材质抽成 ScriptableObject 或远程配置。
  • 音效:点击/得分/死亡接入 AudioMixer;Lua 切换音效无需重打包。
  • 存档 & 排行:本地 JSON(persistentDataPath)/ 第三方 Leaderboard。
  • 远程热更:脚本文本加签名校验、增量下载、版本回滚。
  • 关卡编辑:固定序列 + 随机扰动的混合生成表。