From:M_Studio Unity2D教程《Robbie》
导入预制素材
导入Assets文件夹
_Extended |
Addons |
Gizmos |
VFX |
额外扩展包,包含预制(Prefabs) |
2D插件 |
CinemaChine摄像机插件 |
视觉特效 |
Tilemap
TilePalette
- windows -> 2D -> TilePalette(瓦片调色)
Hierarchy窗口显示-> 2D -> Tilemap
在对应 Tilemap 上绘制需要 TilePalette 对应绘画内容 Active Tilemap
背景 Tilemap 叠加透明 Tilemap 需要Active Tilemap对应层(如 platform 上叠加 shadow)
Shorting Layer
选中Hierarchy窗口中Tilemap对象,Inspector -> Tilemap Renderer
Order in Layer:同一个Shorting Layer,数值越大越在前面

Tilemap(Rule Tile)
Tiles 文件夹 create -> Tile插件 -> Rule Tile
新建的 Rule Tile(BG Details)拖至 TilePalette,绘制背景可以自动填充
转角的设置x的位置就是转角位置
绘制:B
擦除:连按shift


自制笔刷
Prob文件夹 -> 右键新建Brushes -> Prefab Brush
修改 Tile Palette 的笔刷,可以将自制图形按网格刷到图中

物理碰撞
产生碰撞的条件
- 双方有碰撞器(Collider),一方有刚体(Rigidbody)
- 触发器(Is Trigger)是Collider的一个属性,Is Trigger = true 发生碰撞
Collider
碰撞器,用于定义需要进行物理碰撞的GameObject的形状
Tilemap具有自己的碰撞器 Tilemap collider 2D,Inspector -> Component
Composite Collider


Rigidbody
Body Type
| Dynamic(动态刚体) | Kinematic(运动学刚体) | Static(静止刚体) |
| :—————————————- | ———————————- | ————————— |
| 用于模拟运动,具有质量和阻力 | 在无重力质量影响下移动 | 静止刚体,无位移 |
属性设定:collision Detection 选择 Continuous 连续判断是否碰撞
Interpolate 选择 Interpolate 碰撞产生微小形变

Constraints z轴固定使碰撞后z轴不发生变化
碰撞摩擦力
Collider 添加物理材质,可以修改物理材质的摩擦力数值,置0为无摩擦力

图层
用于代码判断Player、Enemy、Platform等,注意修改

移动
获取刚体、碰撞体
控制地上移动的函数,按键—左右:Horizontal
角色面向:vilocity scale
参数:角色移动速度—数值,数值基本都是浮点型
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| void GroundMovent() { xVelocity = Input.GetAxis("Horizontal");
rb.velocity = new Vector2(xVelocity * Speed, rb.velocity.y); }
void FilpDirection() { if (xVelocity < 0) transform.localScale = new Vector2(-1, 1); if (xVelocity > 0) transform.localScale = new Vector2(1, 1); }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| void GroundMovent() { if (Input.GetButton("Crouch") && !isCrouch && isOnGround) Crouch(); else if (!Input.GetButtonDown("Crouch") && isCrouch && !isHeadBlocked) StandUp(); else if (!isCrouch && isOnGround) StandUp(); if (isCrouch) xVelocity /= crouchSpeedDivisor;
xVelocity = Input.GetAxis("Horizontal");
rb.velocity = new Vector2(xVelocity * Speed, rb.velocity.y); }
|
下蹲
- InputManager—按 Jump 模板新建下蹲系统按键设置
下蹲的移动速度—减慢
下蹲状态碰撞体 y 轴减半

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| colliderStandSize = coll.size; colliderStandOffset = coll.offset; colliderCrouchSize = new Vector2(coll.size.x, colliderStandSize.y / 2f); colliderCrouchOffset = new Vector2(coll.offset.x, colliderStandOffset.y / 2f);
void Crouch() { isCrouch = true;
coll.size = colliderCrouchSize; coll.offset = colliderCrouchOffset; }
void StandUp() { isCrouch = false; coll.size = colliderStandSize; coll.offset = colliderStandOffset }
|
跳跃

按键参数设置
1 2 3 4 5
| jumpPressed = Input.GetButtonDown("Jump"); jumpHeld = Input.GetButton("Jump"); crouchHeld = Input.GetButton("Crouch"); crouchPressed = Input.GetButtonDown("Crouch");
|
1 2 3
|
Application.targetFrameRate = 50;
|
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
| void MidAirMovement() { if (jumpPressed && isOnGround && !isJump) { if (isCrouch && isOnGround) { StandUp(); rb.AddForce(new Vector2(0f, crouchJumpBoost), ForceMode2D.Impulse); }
isOnGround = false; isJump = true;
jumpTime = Time.time + jumpHoldDuration;
rb.AddForce(new Vector2(0f, jumpForce), ForceMode2D.Impulse); }
else if(isJump) { if (jumpHeld) rb.AddForce(new Vector2(0f, jumpHoldForce), ForceMode2D.Impulse); if (jumpTime < Time.time) isJump = false; } }
|
射线判断碰撞体
需要判断双腿分别是否在地面上,仅用一个GameObject触碰点判断难以实现,设置两个过于繁琐
射线
射线函数
1 2
| RaycastHit2D hit = Physics2D.Raycast(pos + offset, rayDirection, length, layer);
|
画射线
1
| Debug.DrawRay(pos + offset, Vector2.down, Color.red, 0.2f);
|
射线功能重载
1 2 3 4 5 6 7 8 9 10 11 12
| RaycastHit2D Raycast(Vector2 offset, Vector2 rayDirection, float length, LayerMask layer) { Vector2 pos = transform.position; RaycastHit2D hit = Physics2D.Raycast(pos + offset, rayDirection, length, layer);
Color color = hit ? Color.red : Color.green; Debug.DrawRay(pos + offset, rayDirection * length, color); return hit; }
|
物理环境判断
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| void PhysicsCheck() { RaycastHit2D leftCheck = Raycast(new Vector2(-footOffset, 0f), Vector2.down, groundDistance, groundLayers); RaycastHit2D rightCheck = Raycast(new Vector2(footOffset, 0f), Vector2.down, groundDistance, groundLayers); if (leftCheck || rightCheck) isOnGround = true; else isOnGround = false;
RaycastHit2D headCheck = Raycast(new Vector2(0f, coll.size.y), Vector2.up, groundDistance, groundLayers); if (headCheck) isHeadBlocked = true; else isHeadBlocked = false; }
|
悬挂
悬挂条件
- 头顶无额外平台— !blockedCheck
- 眼前有其他墙壁— wallCheck
- 角色离墙壁近— wallCheck.distance
- 头di顶超过上方平台— ledgeCheck

悬挂射线检测
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| float direction = transform.localScale.x;
Vector2 grabDir = new Vector2(direction, 0f);
RaycastHit2D blockedCheck = Raycast(new Vector2(footOffset * direction, playerHeight), grabDir, grabDistance, groundLayers);
RaycastHit2D wallCheck = Raycast(new Vector2(footOffset * direction, eyeHeight), grabDir, grabDistance, groundLayers);
RaycastHit2D ledgeCheck = Raycast(new Vector2(reachOffset * direction, playerHeight), Vector2.down, grabDistance, groundLayers);
if (!isOnGround && rb.velocity.y < 0f && ledgeCheck && wallCheck && !blockedCheck) { Vector3 pos = transform.position; pos.x += (wallCheck.distance-0.05f) * direction; pos.y -= ledgeCheck.distance; transform.position = pos; rb.bodyType = RigidbodyType2D.Static; isHanging = true; }
|
悬挂后动作
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| if (isHanging) { if (jumpPressed) { rb.bodyType = RigidbodyType2D.Dynamic; rb.velocity = new Vector2(rb.velocity.x, hangingJumpForce); isHanging = false; } if (crouchPressed) { rb.bodyType = RigidbodyType2D.Dynamic; isHanging = false; } }
|
摄像机(Cinemachine)

- Tilemap图层前后关系的设置可以通过transform->position->z轴

相机跟随,添加摄像机插件:Windows -> Package Manager -> Cinemachine
Cinemachine->Body属性设置镜头大小跟踪大小
摄像机边界:
Add Extension - Cinemachine Confiner | 新建GameObject
添加 Polygon Collider 设置为 is Trigger
摄像机焦点会在Polygon Collider边界上移动,大小为Camera Distance

2D灯光效果(法线贴图)
为对象增加材质(纹理,法线贴图),使其可以接受光照
角色法线贴图替换原本GameObject,调整图层
创建点光源:Hierarchy -> create -> light ->pointlight
环境光设置:Windows -> Rendering -> Lighting窗口 -> Environment
角色动画(Blend Tree)
Animator -> Blend Tree

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
| public class PlayAnimation : MonoBehaviour { Animator anim; PlayerMovement movement; Rigidbody2D rb;
int groundID; int hangingID; int crouchID; int speedID; int fallID;
void Start() { anim = GetComponent<Animator>(); movement = GetComponentInParent<PlayerMovement>(); rb = GetComponentInParent<Rigidbody2D>();
groundID = Animator.StringToHash("isOnGround"); hangingID = Animator.StringToHash("isHanging"); crouchID = Animator.StringToHash("isCrouching"); speedID = Animator.StringToHash("speed"); fallID = Animator.StringToHash("verticalVelocity"); }
void Update() { anim.SetBool(groundID, movement.isOnGround); anim.SetFloat(speedID, Mathf.Abs(movement.xVelocity)); anim.SetBool(hangingID, movement.isHanging); anim.SetBool(crouchID, movement.isCrouch); anim.SetFloat(fallID, rb.velocity.y); }
|
音效控制(Audio Manager)
Audio Manager
建立AudioManager GameObject和对应脚本控制音效
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
| public class AudioManager : MonoBehaviour { static AudioManager current; public AudioClip ambientClip;
AudioSource ambientSource; private void Awake() { if (current != null) { Destroy(gameObject); return; } current = this; DontDestroyOnLoad(gameObject);
ambientSource = gameObject.AddComponent<AudioSource>(); StartLevelAudio(); }
void StartLevelAudio() { current.ambientSource.clip = current.ambientClip; current.ambientSource.loop = true; current.ambientSource.Play(); }
|
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
| public class AudioManager : MonoBehaviour { static AudioManager current;
[Header("环境声音")] public AudioClip ambientClip; public AudioClip musicClip; [Header("Robbie音效")] public AudioClip[] walkStepClips; public AudioClip[] crouchStepClips; public AudioClip jumpClip;
public AudioClip jumpVoiceClip;
AudioSource ambientSource; AudioSource musicSource; AudioSource fxSource; AudioSource playSource; AudioSource voiceSource;
private void Awake() { if (current != null) { Destroy(gameObject); return; } current = this;
DontDestroyOnLoad(gameObject);
ambientSource = gameObject.AddComponent<AudioSource>(); musicSource = gameObject.AddComponent<AudioSource>(); fxSource = gameObject.AddComponent<AudioSource>(); playSource = gameObject.AddComponent<AudioSource>(); voiceSource = gameObject.AddComponent<AudioSource>();
StartLevelAudio(); }
void StartLevelAudio() { current.ambientSource.clip = current.ambientClip; current.ambientSource.loop = true; current.ambientSource.Play();
current.musicSource.clip = current.musicClip; current.musicSource.loop = true; current.musicSource.Play(); }
static public void PlayFootstepAudio() { int index = Random.Range(0, current.walkStepClips.Length);
current.playSource.clip = current.walkStepClips[index]; current.playSource.Play(); }
static public void PlayCrouchFootstepAudio() { int index = Random.Range(0, current.crouchStepClips.Length);
current.playSource.clip = current.crouchStepClips[index]; current.playSource.Play(); }
static public void PlayJumpAudio() { current.playSource.clip = current.jumpClip; current.playSource.Play();
current.voiceSource.clip = current.jumpVoiceClip; current.voiceSource.Play(); } }
|
音效延迟播放
1
| current.fxSource.PlayDelayed(1f)
|
Audio Mixer
- 调整每个声音的效果 window -> Audio -> Audio Mixer
1 2 3 4
| public AudioMixerGroup ambientGroup;
ambientSource.outputAudioMixerGroup = ambientGroup;
|

- play过程保存设置:Edit in Play Mode


视觉效果(Post Processing)
- Main Camera -> Post-processing Layers 在所选 layers 上增加特效

新建一个 GameObject 作为 Layer,增加 Post-processing Volume
并修改layer Profile选择特效

添加后无效果解决方法:
Edit > Projec Settings / Player / Other Settings 下的 Color Space 设置为 Linear
导出到移动设置时,Post Processing 会影响效果,有Fast Mode适用于移动设备

相机抖动(Camera Shake)
每次收集宝珠执行震动(player 碰撞 orb)
Camera 添加 Cinemachine Impulse Linster
Orb 添加 Cinemachine Collision Impulse Source 并添加Explosion Shake参数

死亡机制(Spikes&Death)
1 2
| void OnTriggerEnter2D(Collider2D collision)
|
1
| Instantiate(gameObject, position, rotation)
|
1
| gameObject.SetActive(false);
|
1 2
| using UnityEngine.SceneManager SceneManager.LoadScene(SceneManager.GetActiveScene().buildIndex当前编号)
|

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
| public class PlayHealth : MonoBehaviour { public GameObject deathVFXPrefab; public GameObject deathShadow; int trapslayer;
void Start() { trapslayer = LayerMask.NameToLayer("Traps"); }
private void OnTriggerEnter2D(Collider2D collision) { if (collision.gameObject.layer == trapslayer) { Instantiate(deathVFXPrefab, transform.position, transform.rotation); Instantiate(deathVFXPrefab, transform.position, Quaternion.Euler(0, 0, Random.Range(-45, 90)));
gameObject.SetActive(false);
AudioManager.PlayDeathAudio();
SceneManager.LoadScene(SceneManager.GetActiveScene().buildIndex); } } }
|

收集物品(Collection Orb)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| public class Orb : MonoBehaviour { int player; public GameObject explosionVFXPrefab;
void Start() { player = LayerMask.NameToLayer("Player"); }
private void OnTriggerEnter2D(Collider2D collision) { if (collision.gameObject.layer == player) { Instantiate(explosionVFXPrefab, transform.position, transform.rotation); gameObject.SetActive(false);
AudioManager.PlayOrbAudio(); } } }
|
GameManager
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
| public class GameManager : MonoBehaviour { static GameManager instance; SceneFader fader; List<Orb> orbs; Door lockedDoor;
float gameTime; bool gameIsOver = false;
public int deathNum;
private void Awake() { if (instance != null) { Destroy(gameObject); return; } instance = this; orbs = new List<Orb>(); DontDestroyOnLoad(this); }
private void Update() { if (gameIsOver) return; gameTime += Time.deltaTime; UIManager.UpdateTimeUI(gameTime); }
public static void RegisterDoor(Door door) { instance.lockedDoor = door; }
public static void RegisterSceneFader(SceneFader obj) { instance.fader = obj; }
public static void RegisterOrb(Orb orb) { if (instance == null) return; if (!instance.orbs.Contains(orb)) { instance.orbs.Add(orb); }
UIManager.UpdateOrbUI(instance.orbs.Count); }
public static void PlayerGrabbedOrb(Orb orb) { if (!instance.orbs.Contains(orb)) { return; } instance.orbs.Remove(orb);
if (instance.orbs.Count == 0) instance.lockedDoor.Open();
UIManager.UpdateOrbUI(instance.orbs.Count); }
public static void PlayerWon() { instance.gameIsOver = true; UIManager.DisplayGameOver(); AudioManager.PlayerWonAudio(); } public static bool GameOver() { return instance.gameIsOver; }
public static void PlayerDied() { instance.fader.FadeOut(); instance.deathNum++; UIManager.UpdateDeathUI(instance.deathNum); instance.Invoke("RestartScene", 1.5f); }
void RestartScene() { instance.orbs.Clear(); SceneManager.LoadScene(SceneManager.GetActiveScene().buildIndex); }
}
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| public class SceneFader : MonoBehaviour { Animator anim; int faderID;
public void Start() { anim = GetComponent<Animator>();
faderID = Animator.StringToHash("Fade");
GameManager.RegisterSceneFader(this); }
public void FadeOut() { anim.SetTrigger(faderID); } }
|
UIManager
场景切换动画先出现,重新加载场景重新出现,cull Transparent mesh关闭
- UI中属性显示的数量都是text类型,数值转文本,不同text接受不同数值输入

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
| using TMPro;
public class UIManager : MonoBehaviour { static UIManager instance;
public TextMeshProUGUI orbText, timeText, deathText, gameOverText;
public void Awake() { if (instance != null) { Destroy(gameObject); return; } instance = this; DontDestroyOnLoad(this); }
public static void UpdateOrbUI(int orbCout) { instance.orbText.text = orbCout.ToString(); }
public static void UpdateDeathUI(int deathCout) { instance.deathText.text = deathCout.ToString(); }
public static void UpdateTimeUI(float time) { int minuts = (int)(time / 60); float seconds = time % 60;
instance.timeText.text = minuts.ToString("00") + ":" + seconds.ToString("00"); }
public static void DisplayGameOver() { instance.gameOverText.enabled = true; } }
|
1 2 3 4 5 6 7
| public static void UpdateTimeUI(float time) { int minuts = (int)(time / 60); float seconds = time % 60;
instance.timeText.text = minuts.ToString("00") + ":" + seconds.ToString("00"); }
|
导出
