Unity冰火人益智迷宫源码详解:C#双角色机关与Tilemap关卡设计
简介这套超级冰火人益智迷宫游戏完整项目源码基于Unity引擎并使用C#语言开发支持Unity 2021.1.18f1及以上版本完整还原了红孩儿与水女孩双人协作闯关的经典玩法适合希望学习Unity双角色解谜游戏设计的开发者和游戏爱好者研究使用。资源压缩包共包含两千个文件其中以meta配置、png图片、asset资源、unity场景、cs脚本为主另有prefab预制体、材质、动画控制器、着色器等类型整体容量约五十二兆目录结构清晰方便按模块查找与修改。目前已有两百零七人学习/下载是很好的双人合作游戏入门参考项目。源码内置三十张持续更新的迷宫地图设计有杠杆、推杆、镜子、黑球、光束、电梯、风力机、滑轮系统等多种机关解谜元素并集成了广告SDK支持轻松换皮和持续追加新关卡可直接作为二次开发基础帮助快速搭建同类益智游戏项目。1. 双角色规则先于迷宫算法冰火人 Unity 益智迷宫源码的切入口看到“冰火人 Unity 益智迷宫 源码 C#”最容易误判的是把重点放在迷宫生成和寻路上。这类游戏能玩起来靠的不是 A*而是两条规则链Red Boy 火人能踩岩浆、点燃藤蔓Blue Girl 冰人能渡水面、冻结机关。每个格子是否可走由角色当前携带的元素决定岔路和门只是把规则摆成谜题。下面按接手源码时的顺序展开先建立双角色与机关的交互规则并映射到 Tilemap再用 C# 做控制器、交互、关卡与存档最后处理双人输入和性能。新手可以顺着一套最小工程跑通第一关老手可以直接对照参数边界和时序坑。2. 冰火人机制表与 Tilemap 迷宫关卡建模把规则写进 Unity 场景迷宫关卡能不能做下去取决于规则在项目里有没有一个能落地的载体。源码工程里可以没有策划文档但不能没有一张交互规则表和一个统一的地图装载入口。下面从规则表和地图数据说起。2.1 先把双角色-机关交互表列出来再写 C# 枚举我一般会先把“谁能不能过、谁能操作什么”整理成交互判定表这张表会成为所有机关脚本的注释依据。以冰火人最基础的几类机关为例地形 / 机关Red Boy火人Blue Girl冰人岩浆安全通过接触受伤冰面 / 水面接触受伤正常站立藤蔓 / 柴捆点火烧毁开辟通路无法通过冰柱 / 凝水点不可交互冻结成可站地面熔炉 / 火把激活机关不可激活压力板 / 门可触发可触发表里的每一项都会对应到Element枚举和MapTile枚举。岩浆和冰面为什么都作为危险地形而不是普通地面因为对一半角色安全、对另一半角色危险这类地形是双角色解谜独有的不对称规则如果用 GameObject 去挂脚本一个 80x80 的迷宫就会多出一堆空物体。这也是下面改用 Tilemap 的主要原因。2.2 用二维数组组织迷宫源码Tilemap 负责绘制与碰撞地图数据的落点我一般选二维数组。数组只保存格子逻辑类型不保存坐标、不保存渲染对象渲染和碰撞由 Tilemap 负责。下面的MazeMapper是完整可跑的最小实现using UnityEngine; using UnityEngine.Tilemaps; public enum MapTile : byte { Empty 0, Ground, LavaGround, WaterGround, Vine } public class MazeMapper : MonoBehaviour { public Tilemap tilemap; // 地形 Tilemap带 TilemapCollider2D public TileBase groundTile; public TileBase lavaTile; public TileBase waterTile; public TileBase vineTile; public void Paint(MapTile[,] map) { tilemap.ClearAllTiles(); Vector3Int pos new Vector3Int(); for (int y 0; y map.GetLength(0); y) { for (int x 0; x map.GetLength(1); x) { // 二维数组从左上角开始Tilemap 原点默认在左下 pos.Set(x, map.GetLength(0) - 1 - y, 0); switch (map[y, x]) { case MapTile.Ground: tilemap.SetTile(pos, groundTile); break; case MapTile.LavaGround: tilemap.SetTile(pos, lavaTile); break; case MapTile.WaterGround: tilemap.SetTile(pos, waterTile); break; case MapTile.Vine: tilemap.SetTile(pos, vineTile); break; } } } tilemap.RefreshAllTiles(); } }Paint的核心是map[y, x]到Vector3Int的换算。这里pos.Set(x, map.GetLength(0) - 1 - y, 0)的减一是关键C# 二维数组的行习惯从顶部开始而 Tilemap 的单元格原点在左下不翻转的话整张迷宫会上下颠倒。RefreshAllTiles()用于批量改写后强制重建 Sprite 和 Collider单格调用SetTile时系统会刷新但一个循环写几百格后最好手动补一次。提示MapTile.LavaGround这类既涉及地形又涉及危险的格子我建议画两层GroundLayer 放可站立地块HazardLayer 放伤害地块而不是做一张“会受伤的地面”。这样物理碰撞和伤害判定可以分别控制关卡编辑器里也更容易看出危险区分布。2.3 地面 / 危险 / 交互三层拆分物理碰撞与 C# 判定分工三层拆分后角色每一步的判定顺序就固定了先由 Rigidbody2D 和 Collider 决定能否站立再由 C# 用WorldToCell查当前格子是否危险最后检查是否落到交互对象上。以伤害判定为例初版可以这样写public bool IsSafe(Vector2 worldPos, Element characterElement) { Vector3Int cell hazardLayer.WorldToCell(worldPos); TileBase hit hazardLayer.GetTile(cell); if (hit null) return true; // 初版快速实现按 Tile 名称判断后续会换成 Tile 数据资产 if (hit.name.Contains(Lava) characterElement Element.Fire) return true; if (hit.name.Contains(Lava)) return false; if (hit.name.Contains(Water) characterElement Element.Ice) return true; if (hit.name.Contains(Water)) return false; return true; }WorldToCell把世界坐标换算成 Tilemap 的格子坐标GetTile取出该格上的 TileBase。注意tile.name判断只是原型期最快的写法等机关一多Tile 别名和中英文命名都会让维护变得别扭后面第三章会换成一个带allowedElements字段的GameTileData。伤害判定不要在这里做让IsSafe只回答能不能站具体扣血逻辑交给HazardLayer的触发器处理。表层的规则确定后源码组织结构就清晰了MazeMapper管地图数据MapTile管格子类型Tilemap 的层管碰撞。接下来进入角色 C# 控制器的实现重点解决红蓝两套逻辑如何共用同一套移动代码。3. C# 双角色控制器与机关交互红蓝两套逻辑共用一套物理系统双角色共用一套控制器的前提是把差异从控制器里剥掉。移动、跳跃、下落都一样差异只体现在元素属性和机关判定上。先看最常用的PlayerMotor骨架。3.1 角色控制器与刚体参数直接赋值 velocity 的做法using UnityEngine; public enum PlayerState { Idle, Run, Jump, Fall, Interact, Dead } public class PlayerMotor : MonoBehaviour { public float moveSpeed 5.5f; public float jumpForce 9.5f; public float gravityScale 3.4f; public Rigidbody2D body; public Animator animator; public Transform footCheck; // 脚底空物体放在角色脚下 public LayerMask groundLayer; // 地面层的 Layer private PlayerState state; private string horizontalAxis Red_Horizontal; private void FixedUpdate() { if (state PlayerState.Dead) return; float axis Input.GetAxisRaw(horizontalAxis); Vector2 v body.velocity; v.x axis * moveSpeed; if (Input.GetButtonDown(Red_Jump) IsGrounded()) { v.y jumpForce; } body.velocity v; state body.velocity.y 0.1f ? PlayerState.Jump : body.velocity.y -0.1f ? PlayerState.Fall : Mathf.Abs(axis) 0.1f ? PlayerState.Run : PlayerState.Idle; animator.SetInteger(State, (int)state); } private bool IsGrounded() { return Physics2D.OverlapCircle(footCheck.position, 0.08f, groundLayer) ! null; } }参数建议范围说明moveSpeed5.0 ~ 6.5越高越难精确踩压力板和窄台jumpForce8.5 ~ 10.5与 gravityScale 一起调单改一个手感会变gravityScale3.0 ~ 4.0下落干脆陷阱判定才不黏FixedUpdate保持默认 50Hz物理运动放 FixedUpdate在 Update 里改 velocity 会跳帧body.velocity直接赋值是这类解谜项目里更可控的做法。AddForce会把力交给物理引擎累积惯性大、参数难复现解谜关卡对落点精度有要求直接赋值可以在 FixedUpdate 里得到稳定速度。IsGrounded()用脚底的一个小OverlapCircle检测地面层比OnCollisionEnter2D维护一个接触计数要省心。注意PlayerState在这里只驱动动画不参与碰撞和交互判定。示例里为了阅读把输入读取放进 FixedUpdate正式工程建议在 Update 缓存按键再给物理帧消费避免输入采样和物理步骤交错导致偶尔吞跳跃。3.2 用 Element 组件标记角色不要按角色名写分支最忌讳的写法是if (gameObject.name RedBoy)或者做成两个独立控制器。新增一种机关时分支会指数膨胀。正确做法是给两个 Prefab 挂同一个ElementComponent用Element枚举区分火人和冰人地图瓦片则换成自定义GameTileData把允许站立的元素列表挂到 Tile 资产上。using UnityEngine; using UnityEngine.Tilemaps; public enum Element { Fire, Ice } [CreateAssetMenu(fileName GameTileData, menuName Game/GameTileData)] public class GameTileData : Tile { public Element[] allowedElements; // 空数组表示所有角色都能站 public int damageOnTouch; // 0 表示不扣血 } public class ElementComponent : MonoBehaviour { public Element type; public bool CanPass(TileBase tile) { if (tile is GameTileData data) { if (data.allowedElements null || data.allowedElements.Length 0) return true; return System.Array.IndexOf(data.allowedElements, type) 0; } return true; } }GameTileData继承Tile是 ScriptableObject不挂在场景里也不参与 GameObject 的单位成本。岩浆 Tile 的allowedElements只填 Fire冰面只填 Ice普通地面留空数组表示任何角色都能站。CanPass判断当前角色类型在不在允许列表里如果不在移动层就不让角色进入。这个方案把规则和数据绑定在一起新增一种机关时改 Tile 资产就能得到一颗新地形不需要为角色继续写 if。3.3 机关交互接口压力板与门的开合时序机关交互统一用IInteractable。火把只能由 Red Boy 点燃压力板两个角色都能触发差异就落在CanInteract上public interface IInteractable { bool CanInteract(Element element); void Interact(GameObject who); } public class TorchInteract : MonoBehaviour, IInteractable { public Element needed Element.Fire; public bool CanInteract(Element element) { return element needed; } public void Interact(GameObject who) { // 点燃火把触发场景事件 } }压力板是踩坑最多的机关。如果只在OnTriggerEnter2D里开门、OnTriggerExit2D里关门门动画播放不到一半就复位玩家会觉得机关“根本没反应”。下面这个写法用一个保持时间挡住复位using System.Collections; using UnityEngine; public class PressurePlate : MonoBehaviour { public Door targetDoor; public float holdSeconds 0.4f; private Coroutine closeRoutine; private void OnTriggerStay2D(Collider2D other) { if (!other.CompareTag(Player)) return; targetDoor.Open(); if (closeRoutine ! null) StopCoroutine(closeRoutine); closeRoutine StartCoroutine(CloseAfterDelay()); } private IEnumerator CloseAfterDelay() { yield return new WaitForSeconds(holdSeconds); targetDoor.Close(); closeRoutine null; } }OnTriggerStay2D在接触期间每物理帧触发StopCoroutine每帧重置关门倒计时角色一直站在板上时门不会掉角色离开后到holdSeconds才关门。holdSeconds建议比门动画时长多 0.1~0.2 秒避免门刚合上就把站立位的角色夹住。这个时序是针对同一帧 Enter/Exit 交错问题最常见的兜底。3.4 燃烧、冰冻状态用独立组件管理动画不参与逻辑判定火人点燃藤蔓、冰人冻结水面之后场景状态可能被后续机关引用。状态存储建议单独做一个ElementStatus组件动画和特效只读它的值不反过来驱动逻辑。如果脚本写成animator.GetCurrentAnimatorStateInfo(0).IsName(Burn)动画剪辑一改名机关判定就悄悄失效。把状态放在字段里UI、特效、机关都能同步查。public enum StatusEffect { None, Burning, Frozen, Wet } public class ElementStatus : MonoBehaviour { public StatusEffect current; public void Set(StatusEffect next) { current next; // 动画、音效、粒子的切换统一走这个入口 } }ElementStatus的current被多个系统读取角色动画根据它切 Blend Tree机关根据它判断藤蔓是否已烧毁、水坑是否已冻结。状态变更的入口尽量收敛到Set方法这样以后加音效、加粒子、加成就判定都有统一位置。不要用“换贴图”代替状态记录贴图只是状态的表现层状态本身必须与渲染解耦。4. 关卡管理、存档与 UI 的 C# 组织从主菜单到 WebGL 发布迷宫能跑通后源码里真正影响交付节奏的是关卡装载、存档和 UI 刷新这三块。下面按我搭这类项目时的常见组织方式拆开说都是可以直接落到现有工程里的 C# 片段。4.1 单场景多关卡组织不把每关切成一个 Unity Scene小迷宫关卡很多但每关单独建一个 Unity Scene 会让 Build Settings 越来越难维护。常见做法是一个主场景只放 GameManager、LevelLoader、玩家出生点所有关卡地图数据放在 TextAsset 或 ScriptableObject 列表里运行时再灌给 Tilemap。LevelLoader 的加载入口长这样using UnityEngine; public class LevelLoader : MonoBehaviour { public TextAsset[] levelDataList; public MazeMapper mapper; public void LoadLevel(int index) { if (index 0 || index levelDataList.Length) return; MapTile[,] map Parse(levelDataList[index].text); mapper.Paint(map); // 重置玩家出生点、机关状态、倒计时 } private MapTile[,] Parse(string text) { string[] rows text.Split(\n); int h rows.Length; int w rows[0].Trim().Length; MapTile[,] map new MapTile[h, w]; for (int y 0; y h; y) { string line rows[y].Trim(); for (int x 0; x line.Length; x) { switch (line[x]) { case #: map[y, x] MapTile.Ground; break; case L: map[y, x] MapTile.LavaGround; break; case W: map[y, x] MapTile.WaterGround; break; case V: map[y, x] MapTile.Vine; break; default: map[y, x] MapTile.Empty; break; } } } return map; } }Parse把文本按行切开逐字符映射到MapTile字符表可以按团队习惯自己定“#”代表地面、“L”代表岩浆、“W”代表水面、“V”代表藤蔓。这套方案的优势是关卡数据可以用任何文本编辑器增删无需打开 Unity 编辑器就能改地图。换关时先mapper.Paint(map)再重置玩家位置和机关状态即可。如果某个关卡特别大再单独拆成 Scene 也不迟。数据存在位置说明关卡字符串TextAsset每个文件一张图按字符映射格子解锁进度PlayerPrefs键值对即可不要存机关运行状态机关状态场景内对象换关或检查点重置不做持久化4.2 存档用 PlayerPrefs但记住 WebGL 的存储限制存档这块不用引入数据库PlayerPrefs对益智类小游戏足够。关卡解锁只存整关不存每块机关状态机关状态属于运行期数据关内重开时应回到初始状态。解锁逻辑写成静态类public static class SaveManager { private const string UnlockPrefix Unlock_; public static bool IsUnlocked(int level) { return PlayerPrefs.GetInt(UnlockPrefix level, 0) 1; } public static void CompleteLevel(int level) { PlayerPrefs.SetInt(UnlockPrefix level, 1); PlayerPrefs.Save(); } }GetInt(key, 0)的第二个参数是默认值未解锁的关卡读出来 0过关后SetInt加Save()立刻写盘。key 的命名格式一旦发布就别改否则老玩家的解锁进度全部清空。WebGL 发布时 PlayerPrefs 的存储底层是 IndexedDB隐私模式、浏览器清理存储或磁盘占满时会出“unity 发布 webgl 使用 idbfs 写入失败”这类现象那是存储环境问题不是 C# 逻辑问题所以保存时机要勤、要早不能只在退出时写。4.3 UI 用事件推数据别在 Update 里拉数据C# 端常见的“循环数据采集和 UI 刷新卡顿”多数不是循环本身慢而是循环里每一帧都在给 Text 或 Slider 赋值。text.text value即使值没变UGUI 也会做字符比较和网格重建状态多时帧率就掉。血条这类界面应该监听数值变化事件using UnityEngine; using UnityEngine.UI; public class HealthHud : MonoBehaviour { public Text label; private void Awake() { Health h GetComponentInParentHealth(); if (h ! null) { h.OnHealthValueChanged Refresh; } } private void Refresh(int current, int max) { label.text current / max; } }OnHealthValueChanged由 Health 组件在数值变化时触发HUD 只响应不轮询。UI 事件的特点是写起来多一层订阅关系但复杂度增长比 Update 拉取更平稳。Slider 同理onValueChanged拿到 float 后直接赋给 AudioSource.volume不要在别的脚本里每帧读 slider.value。按钮点击范围小的问题可以在按钮节点下加一个透明度为 0 的 Image开启 Raycast Target尺寸比视觉元素大一圈点击判定就会落在它上面。透明 Image 的层级不要挡住其他 UI 元素的点击需要时把 Canvas 分层处理。4.4 机关联动走事件减少互相 Find压力板开门、箱子压板、解锁通道这几类联动如果直接做对象引用Inspector 拖一次固然快但关卡动态生成时引用会空掉。跨对象跨层级时我习惯加一个极简的静态事件总线public static class LevelEvents { public static event System.Actionint OnDoorOpened; public static void RaiseDoorOpened(int doorId) { OnDoorOpened?.Invoke(doorId); } }压力板调用LevelEvents.RaiseDoorOpened(1)门只订阅LevelEvents.OnDoorOpened双方不再需要互相持有引用。事件总线的定位是“跨模块的少量信号”不要把所有方法都塞进去单对单的机关还是直接拖引用维护成本最低。订阅事件的一方记得在OnDestroy里退订避免场景切换后静态事件继续触发已销毁对象。5. 双人输入、物理参数与性能验证把冰火人 Unity 源码调到可发布状态双人同屏的冰火人最终验证集中在两件事两个角色同时操作时手感是否独立机关全开时性能有没有明显掉帧。下面三个点是我每次都会过一遍的。5.1 同屏双人输入的 Input 映射双人同屏最常用的仍是 Input Manager 加两套轴。Project Settings Input 里把 Red 和 Blue 两组的键位分开火人用 A/D W冰人用方向键 UpArrow脚本用GetAxisRaw各取各的float redAxis Input.GetAxisRaw(Red_Horizontal); // A/D float redJump Input.GetKeyDown(KeyCode.W); float blueAxis Input.GetAxisRaw(Blue_Horizontal); // 左/右方向键 float blueJump Input.GetKeyDown(KeyCode.UpArrow);GetAxisRaw返回 -1、0、1 三档没有平滑缓冲平台游戏里响应更直接GetAxis适合菜单滑动条这种需要渐变的场景。在 Input Manager 里每个轴配 Positive Button 和 Alt Positive Button手柄接入时把两个轴对应到不同 Joystick Axis 即可。5.2 四组物理参数调完再做正式关卡参数位置建议注意Collision Detection Mode双角色 Rigidbody2DContinuous高速下落时防止穿透危险层PhysicsMaterial2D.Friction脚底碰撞体的 Material0.1 ~ 0.3摩擦设得过高会“糊”在平台上Contact Pairs LimitProject Settings Physics 2D4096 左右机关和门的接触丢失时先查它Composite Collider 2D静态 Tilemap 根物体开启合并合并后单格材质不再单独生效这四项里最容易漏的是 Composite Collider 2D。它要挂在一个带 Rigidbody2D 的父物体上参与合并的子 Collider 勾选 Used By CompositeTilemapCollider2D 也按这个方式处理。合并后大量小碰撞体变成一个物理引擎压力明显下降。合并时不要指望单个 Tile 的 Physics Material 单独生效最终摩擦由合成后的 Collider 的 sharedMaterial 控制统一设置反而更利于调手感。5.3 性能验证只盯两处Profiler 和 Frame Debugger发布前最值得做的验证不是反复试玩而是开两个窗口看数据。Profiler 里重点盯 Physics (2D) 的 Simulate 耗时如果它在双角色同屏、机关全开、粒子最多时涨一大截问题通常出在大量未合并的小碰撞体上把静态 Tilemap 合并成 Composite 后再回来看这栏。Frame Debugger 看 Draw Call地形 Tile 应当进同一张图集并依赖 Tilemap 合批散图情况下 Draw Call 很容易到几十以上优先打包进图集。粒子系统是移动端发热的常见来源。火焰、冰霜的特效粒子单角色控制在 30~50 个用对象池复用别在接触时 Instantiate。验证时把双角色同时放在机关最密集的一关打开 Profiler 录制 30 秒Simulate 时间和 Render 时间哪个高就顺着哪条线查下去。本文还有配套的精品资源点击获取