本文乃Siliphen原创,转载请注明出处:http://blog.csdn.net/stevenkylelee
前言
最近开始学习unity。学习最好的方法是动手实践。
因为游戏2048画面简单,可以自己搞定,于是选择2048作为练手对象。
在动手练习的过程中,自己积累了实践经验,也加深了对unity的理解。
写下此文,作为学习总结。希望和大家交流,彼此促进进步。
我的2048游戏可执行文件在本文的末尾可以下载到。
做出来的效果如下:
2048玩法
2048是一个益智游戏。规则是:
在 4 × 4 大小的棋盘上,玩家可以选择向“上,下,左,右”四个方向滑动方块,每次滑动成功,所有方块向滑动的方向靠拢。
相邻的且数字相同的方块会合并成一个新的方块,这个方块的数字是原先2个方块的和。
每次滑动成功,或合并成功,都会在棋盘空白区域随机位置生成一个数字是2或4的新方块。
游戏初始时,棋盘上会出现2个方块。
输的条件:棋盘填满方块,且没有方块能够合并成新的方块。
赢的条件:棋盘上出现数字为2048的方块。
游戏玩法示意图如下:
玩法细节:方块的结合律
在动手实现游戏之前,需要把核心玩法的细节都确定好。
方块水平序列( 2 2 2 )向左滑动的结合结果是:(4 2)
方块水平序列( 2 2 2 )向右滑动的结合结果是:(2 4)
方块优先向滑动的方向结合。
方块水平序列 (2 2 2 2) 向左、向右滑动的结合结果是:(4 4)
每次滑动方块,方块之间只能结合一次。
若要使上述情况结合成8,需要滑动2次。
垂直序列以此类推。
总体设计
基本流程
核心玩法类图
资源文件组织
清晰的、有规律的文件组织,能够提高开发效率,降低维护成本。
“scenes”文件夹用于放置unity的场景文件。如下图:
“scripts”文件夹用于放置脚本文件。
“scripts/scenes”文件夹以场景为单位来分类代码,每个场景文件夹下有3个文件夹,分别是:
“controller”,放置控制器代码。
“model” , 放置模型代码。
“view”,放置和用户UI界面有关的代码。
代码分类结构如下图:
其中,game_play 表示核心玩法场景。home 表示首页场景。
场景代码入口函数
很多语言和框架,都有一个程序入口函数,例如C,C#的main,
而 Unity 并没有类似这样一个所谓的“代码入口”,代码都是作为组建挂载到 GameObject 上才能执行。
为了清晰,本项目每个场景都设置一个入口脚本,这个脚本是这个场景运行的第一个脚本,其他脚本通过这个脚本进行初始化和挂载。
核心玩法的入口脚本:
using UnityEngine;
using System.Collections;
public class SceneEntryGameplay : MonoBehaviour
{
// Use this for initialization
void Start ()
{
// 加上其他的脚本
gameObject.AddComponent<CtrlGameplay>();
gameObject.AddComponent<UserInput>();
}
// Update is called once per frame
void Update ()
{
}
}
这个入口脚本挂载到场景中的一个叫“Entry”的空GameObject上,
节点 Entry 和脚本 SceneEntryGameplay 就是这个场景所有代码来龙去脉的起点。
项目设置
因为做的游戏是2D的,所以,创建项目时,选择2D项目。
摄像机属性【Projection】选择【 orthographic】,【size】设置为“6.4”。如下图所示:
为什么【size】是6.4呢。这个是通过适配分辨率算出来的,
假设要做的游戏的分辨率是 720 * 1280 ,那么 size = 6.4 = 720 / 2 / 100 ,
unity会在任何分辨率的手机上,锁定高度,扩展宽度。
几乎所有的2D游戏的分辨率适配都是通过设置摄像机的size。
核心玩法之基础搭建
获取用户输入
在unity中接收PC鼠标的输入和接收手机触摸的输入,分别用不同的方法。
为了调试方便,我们要同时支持PC和手机的输入。
我们定义:PC的鼠标按下左键 等同于 手机触屏的按下手指。PC的鼠标按下左键滑动 等同于 手机触屏的手指滑动。
脚本“UserInput”用来接收用户的输入。路径:“scripts/scenes/game_play/view/input”
简化的框架代码如下:
using UnityEngine;
using System.Collections;
using System;
using UnityEngine.UI;
/*
获取用户输入
*/
public class UserInput : MonoBehaviour
{
// Use this for initialization
void Start()
{
}
// Update is called once per frame
void Update()
{
Mouse();
Touch();
}
// 处理鼠标的输入
void Mouse()
{
if( Input.GetMouseButtonDown( 0 ) )
{ // 按下
m_MouseMove = true;
}
else if( Input.GetMouseButtonUp( 0 ) )
{
m_MouseMove = false;
}
if( m_MouseMove == true )
{ // 滑动
}
}
// 处理移动设备的输入
void Touch()
{
if( Input.touchCount == 0 )
{
return;
}
var touch = Input.GetTouch( 0 );
if( touch.phase == TouchPhase.Began )
{ // 按下
}
else if( touch.phase == TouchPhase.Moved )
{ // 滑动
}
}
// 标示鼠标是否是按下的移动
bool m_MouseMove = false;
}
UserInput类同时支持PC鼠标和移动设备的输入,其中PC鼠标的输入需要一个变量 m_MouseMove 来标示是否是按下鼠标键的滑动,
按下鼠标左键时, m_MouseMove = true 。抬起鼠标左键时, m_MouseMove = false。
通过 m_MouseMove 变量,在按下鼠标左键和抬起左键之间的鼠标运动,都认为是按下左键的滑动,
这样就在PC上用鼠标模拟了移动设备的手指滑动。
UserInput类需要挂载到GameObject上才能运行,
代码入口类“SceneEntryGameplay”负责挂载这个脚本。
手势识别
手势识别的作用是确定用户的操作意图。
2048的手势规则是:
手指快速向左滑动一段距离,认为是向左滑动的操作。类推向右,上,下。
手指按下后滑动速度超过某个时间长度,操作无效。
滑动操作成功后,不松手再滑动,操作无效。
每次有效滑动操作需要经历:
1.手指按下。2.朝某个方向,在指定时间内,滑动指定长度距离。
在“UserInput”类中,我们已经兼容了PC和手机的操作,
现在需要一个手势识别类,可复用在PC和手机的操作上。
脚本“Direction”用来定义滑动方向。路径:“scripts/scenes/game_play/controller”。
public enum Direction
{
Left,
Right,
Up,
Down,
}
脚本“CtrlInput”是手势识别类。路径:“scripts/scenes/game_play/view/input”
代码如下:
using UnityEngine;
using System;
/*
处理用户输入
*/
public class CtrlInput
{
public class EventArgsCtrlInput : EventArgs
{
public Direction Direction { get; set; }
}
public event EventHandler<EventArgsCtrlInput> Move;
// 开始接收输入
public void Start( Vector3 pt )
{
// 记录原点位置
m_ptStart = pt;
// 记录触摸开始时间
m_TimeStart = Time.fixedTime;
// 本次操作有效
m_Flag = true;
}
// 检查输入
public void Check( Vector3 pt )
{
if( m_Flag == false )
{
return;
}
if( Time.fixedTime - m_TimeStart > 3 )
{ // 滑动超时
return;
}
var v = pt - m_ptStart;
// 相对于起始点的距离。
var len = v.magnitude;
if( len < m_Len )
{
return;
}
var degree = Mathf.Rad2Deg * Mathf.Atan2( v.x , v.y );
if( -45 >= degree && degree >= -135 )
{ // 左
m_Flag = false;
Move( this , new EventArgsCtrlInput() { Direction = Direction.Left } );
}
else if( 45 <= degree && degree <= 135 )
{ // 右
m_Flag = false;
Move( this , new EventArgsCtrlInput() { Direction = Direction.Right } );
}
else if( -45 <= degree && degree <= 45 )
{ // 上
m_Flag = false;
Move( this , new EventArgsCtrlInput() { Direction = Direction.Up } );
}
else if( 135 <= degree || degree <= 135 )
{ // 下
m_Flag = false;
Move( this , new EventArgsCtrlInput() { Direction = Direction.Down } );
}
}
// 触摸起点
Vector3 m_ptStart;
// 滑动的有效长度
int m_Len = 30;
// 时间起点
float m_TimeStart;
// 用于控制操作识别完成后,不松手再滑动,操作无效。
bool m_Flag = true;
}
class CtrlInput 识别出手势后会发出“Move”事件。
手势识别的原理是,逻辑上,以原点为中心把滑动空间分成4部分,上,下,左,右,每个部分90度,判断用户手指滑动的方向。
示意图如下:
Start 函数在手指按下时调用,用来定位原点在屏幕上的位置,也就是初始化原点。
Check 函数用来检查手指移动到的点是否符合手势条件,做滑动方向判断。
通过原点和手指滑动到的点算出来的角度来确定用户是往哪个方向滑动。
例如:(45)到(-45)这个范围表示向上滑动。以此类推左,右,下方向。
class CtrlInput需要被 class UserInput 调用,
class UserInput 实际代码如下:
using UnityEngine;
using UnityEngine.UI;
/*
获取用户输入
*/
public class UserInput : MonoBehaviour
{
// Use this for initialization
void Start()
{
m_Text = GameObject.Find( "txtInfo" ).GetComponent< Text >();
m_CtrlGameplay = GetComponent< CtrlGameplay >();
m_CtrlInput.Move += OnMove;
}
// Update is called once per frame
void Update()
{
Mouse();
Touch();
}
// 处理鼠标的输入
void Mouse()
{
if( Input.GetMouseButtonDown( 0 ) )
{ // 按下
m_MouseMove = true;
m_CtrlInput.Start( Input.mousePosition );
}
else if( Input.GetMouseButtonUp( 0 ) )
{
m_MouseMove = false;
}
if( m_MouseMove == true )
{ // 滑动
m_CtrlInput.Check( Input.mousePosition );
}
}
// 处理移动设备的输入
void Touch()
{
if( Input.touchCount == 0 )
{
return;
}
var touch = Input.GetTouch( 0 );
if( touch.phase == TouchPhase.Began )
{ // 按下
m_CtrlInput.Start( touch.position );
}
else if( touch.phase == TouchPhase.Moved )
{ // 滑动
m_CtrlInput.Check( touch.position );
}
}
void OnMove( object sender , CtrlInput.EventArgsCtrlInput e )
{
// 在UI上显示移动方向
m_Text.text = e.Direction.ToString();
// 主控制器尝试用户的合并操作。
m_CtrlGameplay.Merge( e.Direction );
}
// 标示鼠标是否是按下的移动
bool m_MouseMove = false;
// 处理输入
CtrlInput m_CtrlInput = new CtrlInput();
// 核心玩法主控制器
CtrlGameplay m_CtrlGameplay;
// 用于调试信息显示的文本控件
public UnityEngine.UI.Text m_Text = null;
}
class UserInput的事件处理器 OnMove 用来响应处理合法的用户手势操作,
这里会给游戏逻辑控制器 CtrlGameplay 发送控制指令。
棋盘地图
游戏是在 4 * 4 的矩阵中进行。可以用二维数组 List< List< Cell > > 来表示棋盘的逻辑结构。
其中Cell表示的是棋盘的每个单元格。
这里,我们直接用GameObject来定位棋盘的每个单元格。
在 Hierarchy 面板中创建一个叫“Map”的GameObject作为地图的根节点,
在“Map”下建立 4 * 4 = 16 个GameObject作为地图的每个单元格,单元格GameObject的命名规则是:“tile_行索引_列索引”。
如下图所示:
棋盘的背景可以自己发挥,这里是直接帖了一张图。
脚本“Map”用于表示整个棋盘。路径:“scripts/scenes/game_play/model”
代码如下:
using UnityEngine;
using System.Collections;
using System.Collections.Generic;
public class Map
{
List<List<Cell>> m_Cells;
static readonly Map m_Ins = new Map();
public static Map Instance
{
get
{
return m_Ins;
}
}
// 初始化。解析场景,生成棋盘地图逻辑结构。
public void Init()
{
m_Cells = new List<List<Cell>>();
for( int i = 0 ; i < 4 ; ++i )
{
var row = new List<Cell>();
m_Cells.Add( row );
for( int x = 0 ; x < 4 ; ++x )
{
row.Add( new Cell() );
}
}
for( int y = 0 ; y < 4 ; ++y )
{
for( int x = 0 ; x < 4 ; ++x )
{
var name = string.Format( "tile_{0}_{1}" , y , x );
var tile = GameObject.Find( name );
var theCell = m_Cells[ y ][ x ];
theCell.Position = tile.transform.position;
theCell.Coord = new Vector3( x , y , 0 );
}
}
}
public List<List<Cell>> Cells
{
get
{
return m_Cells;
}
}
}
public class Cell
{
// 单元格在棋盘中的逻辑坐标。
public Vector3 Coord
{
get; set;
}
// 单元格在世界空间中的坐标。
public Vector3 Position
{
get; set;
}
// 单元格绑定方块实体。
public GameObject Entity
{
get;set;
}
}
class Map 的 Init方法解析场景的棋盘数据,生成棋盘地图逻辑结构。
游戏实体
实体"Entity"的定义比较广泛,在游戏中一般表示一个游戏体。
本项目中,游戏实体“Entity”的定义是:在游戏中出现的方块。
方块需要一些属性用来进行计算。比如:方块所代表的分值。
Unity是组件式编程,GameObject基本上就相当于表示游戏实体了。
要为游戏实体增加属性和行为,只能用为GameObject增加脚本组件的方式。
脚本“EntityProperty”用于表示游戏实体属性。路径:“scripts/scenes/game_play/model”
代码如下:
using UnityEngine;
public class EntityProperty : MonoBehaviour
{
// Use this for initialization
void Start ()
{
}
// Update is called once per frame
void Update ()
{
}
// 分值
public int Score
{
get; set;
}
// 关联的单元格
public Cell AssociatedCell
{
get; set;
}
// 是否可以合并
public bool MergeEnabled
{
get; set;
}
}
路径:“Resources/prefabs/entities”下,创建表示”2,4,8,16,32,64,128,256,512,1024,2048“分值的几个游戏实体预制,如下图:
为了方便地创建出方块实体,可以写一个方块工厂类,一句话生成方块实体。
“FactoryEntity”游戏实体工厂类。路径:“scripts/scenes/game_play/controller”。
代码:
class FactoryEntity
{
static public GameObject Create( int Score )
{
var path = "prefabs/entities/entity_" + Score;
var prefab = Resources.Load( path ) as GameObject;
var theEntity = GameObject.Instantiate( prefab );
// 设置分值
var theEntityProperty = theEntity.GetComponent<EntityProperty>();
theEntityProperty.Score = Score;
theEntityProperty.MergeEnabled = true ;
return theEntity;
}
}
核心玩法之控制器
主控制器
using UnityEngine;
using System;
public class CtrlGameplay : MonoBehaviour
{
public void Merge( Direction dir )
{
m_CtrlMerge.Merge( dir );
if( m_CtrlMerge.HasMerged == true )
{
Audio.Instance.Play( "merge" );
}
if( m_CtrlMerge.HasMoved == true )
{
Audio.Instance.Play( "move" );
}
m_State = 1;
}
// Use this for initialization
void Start ()
{
// 初始化地图
Map.Instance.Init();
// 初始化实体生成器
m_EntityGenerator.Init();
// 开始游戏时生成的实体
m_EntityGenerator.GenerateFirst();
// 初始化合并控制器
m_CtrlMerge.Finish += OnFinishMerge;
m_CtrlMerge.CreateEntity += Tracker.Instance.OnCreateEntity ;
// 初始化UI
UiGameplay.Instance.Init();
UiGameplay.Instance.Refresh();
}
void Update()
{
if( m_State == 1 )
{
m_CtrlMerge.CheckFinish();
}
}
// 完成一轮行动
void Finish()
{
m_State = 0;
m_EntityGenerator.Generate();
var JudgeRet = m_Judgement.Judge();
if( JudgeRet == Judgement.JudgeRet.Lose )
{
int a = 1;
}
else if( JudgeRet == Judgement.JudgeRet.Win )
{
int a = 1;
}
}
void OnFinishMerge( object sender , EventArgs e )
{
if( m_CtrlMerge.HasMoved == true )
{
Finish();
}
}
#region Private Fields
// 实体生成
EntityGenerator m_EntityGenerator = new EntityGenerator() ;
// 合并
CtrlMerge m_CtrlMerge = new CtrlMerge();
// 判断
Judgement m_Judgement = new Judgement();
int m_State = 0;
#endregion
}
方块实体生成控制器
类EntityGenerator是方块实体生成控制器。路径:“scripts/scenes/game_play/controller”
它的作用是,移动或者合并完成后,在棋盘空余的地方生成新的方块。
代码如下:
using UnityEngine;
using System.Collections.Generic;
// 实体生成器
public class EntityGenerator
{
public void Init()
{
}
// 游戏初始时的生成逻辑。
public void GenerateFirst()
{
for( int i = 0 ; i < 2 ; ++i )
{
Generate();
}
}
// 生成实体
public bool Generate()
{
// 选出空余的位置
List<Cell> EmptyCells = new List<Cell>();
var Cells = Map.Instance.Cells;
for( int y = 0 ; y < Cells.Count ; ++y )
{
for( int x = 0 ; x < Cells[ y ].Count ; ++x )
{
if( Cells[ y ][ x ].Entity == null )
{
EmptyCells.Add( Cells[ y ][ x ] );
}
}
}
if( EmptyCells.Count == 0 )
{
return false;
}
int idx = Random.Range( 0 , EmptyCells.Count - 1 );
var theCell = EmptyCells[ idx ];
// 生成实体
var entity1 = FactoryEntity.Create( 2 );
entity1.transform.position = theCell.Position;
theCell.Entity = entity1;
return true;
}
}
方块实体合并控制器
实体合并控制器只完成一个工作:朝指定方向滑动方向,合并方块。
合并与滑动方块的算法用向左滑动为例来说明:
* 遍历地图的每一行,对于每行从左向右扫描单元格。
* 如果当前扫描到的地图单元格有方块的话,向左查找一个最近的方块。最近的方法的定义是,没有被其他方块阻隔。
* 找到了最近的方块,判断是否可以合并,如果可以合并,就销毁这2个方块,在左边的方块的位置上生成一个新合并后的方块。
* 如果没有找到可以合并的方块,就向左查找一个最左的空的位置,看是否可以移动到空的位置上。
算法图示如下:
向右,向上,向下的算法以此类推。
最终的效果如下所示:
代码有400行之多,这里就不贴了。
方块实体移动控制
cocos2d 对于物体的移动,缩放等有很好的支持,动作 MoveTo 一句话就可以执行运动。
而unity的物体运动,需要自己在Update函数中写代码。
如果是用位置不断加速度的做法,很容易会造成“穿越”目的点,而不是刚好停在目标点上。
这里,模仿cocos2d的Action MoveTo,设计一个移动控制器来控制方块实体的移动。
该控制器需要2个参数:时间长度,目的点。
功能:在指定的时间内达到指定的目的点。
代码如下:
using UnityEngine;
using System.Collections;
using System;
public class MoveTo : MonoBehaviour
{
public EventHandler Finish;
// Use this for initialization
void Start ()
{
}
// Update is called once per frame
void Update ()
{
m_Elapsed += Time.deltaTime;
var f = m_Elapsed / m_Duration;
if( f >= 1 )
{
gameObject.transform.position = m_Src + m_v;
enabled = false;
if( Finish != null ) Finish( this , null );
return;
}
var pt = m_Src + m_v * f;
gameObject.transform.position = pt ;
}
public void Start( float Duration , Vector3 dst )
{
this.m_Duration = Duration;
this.m_Dst = dst;
m_Src = gameObject.transform.position;
m_v = dst - m_Src;
m_Elapsed = 0;
enabled = true;
}
float m_Duration;
Vector3 m_Dst;
float m_Elapsed = 0;
Vector3 m_Src;
Vector3 m_v;
}
MoveTo.Start 函数用于启动运动,启动运动时,记录当前位置,算出当前位置和目标位置的向量。
然后 Update 函数,计算当前流逝的时间和目标时间的百分比,确定在当前时间点,应该朝向目标位置移动多少距离。
使用原位置不断执行偏移量加法的方法,可以避免计算误差,最后精确地落在目标点上。
判断游戏输赢
判定游戏输赢依次执行以下步骤:
* 如果出现了2048数字的方块,判赢。
* 如果棋盘还有空位,游戏继续。
* 如果棋盘没有空位,但有可以合并的方块,游戏继续。
* 判输。
下载本文的游戏demo
本文章的2048游戏demo下载地址(包含 PC ,Android 平台):
http://download.csdn.net/detail/stevenkylelee/9579545