New2048开发文档

New2048开发日志

项目信息:
项目负责人:买烤麸烤饼
主程序:买烤麸烤饼
美工:买烤麸烤饼
音效:洛一
配音:小鸠
项目开发周期:2021.6.25-2021.7.4

版本信息:

  • v1.0 完成2048的基础玩法
  • v2.0 增加球球新玩法

1. 玩法分析

1.1 经典模式

游戏原型:2048

游戏目标:
合成更多数字块以得到更高的分数

得分计算:
当两个相同数字块合并时,得分+=新数字块的数字

玩家操作:

  • 向上滑动
    上方向键
  • 向下滑动
    下方向键
  • 向左滑动
    左方向键
  • 向右滑动
    右方向键

失败判定:
棋盘被数字填满,无法进行有效移动,判负,游戏结束。

流程简述:
开始时棋盘内随机出现两个数字
出现的数字仅可能为2或4。

玩家可以选择上下左右四个方向
若棋盘内的数字出现位移或合并,视为有效移动。

玩家选择的方向上若有相同的数字则合并
每次有效移动可以同时合并,但不可以连续合并。

合并所得的所有新生成数字想加即为该步的有效得分。

玩家选择的方向行或列前方有空格则出现位移。

每有效移动一步,棋盘的空位(无数字处)随机出现一个数字(依然可能为2或4)。

棋盘被数字填满,无法进行有效移动,判负,游戏结束。

棋盘上出现2048,判胜,游戏结束。
也可以继续游戏,攒出更大的数字

1.1 球球模式

游戏原型:合成大西瓜

游戏目标:
合成更多数字球以得到更高的分数

得分计算:
当两个相同数字球合并时,得分+=新数字块的数字

玩家操作:

  • 触摸控制球球生成位置
    鼠标控制球球生成位置
  • 长按抖动水池
    按空格抖动水池

失败判断:
当水池被球球填满时游戏结束

流程简述:
随机生成2,4,8的球,掉落至水池中
遇到相同数字的球则合并

玩家可以在水池上方控制潜水艇的左右位置决定新生成球的位置

当水池被填满时游戏结束

2. 需求分析

2.1 游戏实体分析

2.1.1 经典模式

  • 数字块
    有数字2,4,8,16,32…
    不同数字使用不同的精灵图片
    但是是同一个预制体
  • 棋盘
    能放下4*4=16个数字块的棋盘

2.1.2 球球模式

  • 数字球
    有数字2,4,8,16,32…
    不同数字使用不同的精灵图片,有不同的大小
    但是是同一个预制体
  • 水池
    左,右,下三个方向有碰撞体积
    上方有触发器
    可以晃动
  • 潜水艇
    通过潜水艇来释放数字球
    潜水艇在水池上方固定高度移动

2.2 UI需求分析

2.3 音效需求分析

配音内容:

用于 内容 描述
游戏开始 Game Start! 日式大佐英语,元气语气
游戏开始 游戏开始了哦! 中文,认真系美少女语气
游戏结束 Game Over… 日式大佐英语,惋惜语气
游戏结束 失敗しました(失败啦…) 日语,无口少女棒读
新纪录 New Score! 日式大佐英语,元气语气

配音员:小鸠

  • BGM 一条
  • 人声配音5条

3.代码实现

3.1 工程类图

3.1.1 主场景(HomePageScene)

实体类图设计

主场景(HomePageScene)实体类图设计

活动图

主场景活动图

View视图类:

该场景为游戏开始时的主场景,用于玩家选择模式,加载游戏,查看游戏介绍等功能的实现。
在该场景下有一个主画布,该主画布下有3个功能面板,详细情况如下:

  • HomePanel
    主面板
    是游戏的入口界面
  • AboutPanel
    关于面板
    是游戏的介绍界面
  • ModeChosePanel
    模式选择面板
    用于选择开始与加载的游戏模式

该场景的所有视图对象(UI)由HomePageUIManager类控制,它作为组件挂载在主画布Canvas上,其中包含了各个面板的全部按键响应函数。

Controller控制类:

PlayerSetting类是该场景的唯一控制类,该类用于记录用户两种游戏设置:

  • bool Mute
    游戏是否静音
  • bool IsLoad
    是否加载之前保存的游戏进度

该类的脚本挂载至一个名叫PlayerSetting的空物体上,并设置为DontDestroyOnLoad(切换场景时不销毁该GO)。它会传递到下一个场景,即游戏场景,来决定是否加载之前保存的游戏进度。

3.1.2 经典模式场景(GameScene1)

实体类图设计:

经典模式实体类图

场景活动图:

View视图类:

该场景为经典模式游戏的场景,用于玩家游玩经典模式的2048小游戏。
在该场景下有三个画布,详细情况如下:

  • GameCanvas
    游戏画布
    是进行游戏的界面
  • PauseCanvas
    暂停画布
    是游戏暂停时的功能性画布
  • GGCanvas
    游戏失败画布
    是游戏失败后弹出的画布

该场景的所有视图对象(UI)由GamePageUIManager类控制,它作为组件挂载在EventSystem上,其中包含了各个画布的全部按键响应函数。

值得注意的是,因为UI与游戏物体高度融合,Map应当显示在UI背景的前方。因此画布应当设置为Screen Space-Camera渲染模式。相关知识详见UGUI详解-画布

3.1.3 球球模式场景(GameScene2)

实体类图设计:

2048-球球模式设计类图

3.2 UI实现

开始界面

开始界面

模式选择页面

模式选择页面

经典模式页面

经典模式页面

球球模式页面

球球模式页面

暂停页面

暂停页面

失败页面

失败页面

3.3 技术实现亮点与难点

3.3.1 2048经典模式核心玩法算法

数字块的抽象

将每个数字抽象为一个数字块类,放入棋盘的格子中。

是用Block类的二维数组存储数字块

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
using System.Collections;
using UnityEngine;
using static GameController_Mode1;

public class Map:MonoBehaviour
{
private int sideLength;
public int blockCounter;
private Block [][] blocks;
public int Score { get; set; }
public int BestScore { get; set; }

public void InitMap(int sideLength)
{
this.sideLength = sideLength;
blocks = new Block[sideLength][];
for (int i = 0; i < sideLength; i++)
{
blocks[i] = new Block[sideLength];
for (int j = 0; j < sideLength; j++)
{
blocks[i][j] = new Block(new Vector2Int(i, j), screenPosition[i, j]);
}
}
blockCounter = 0;
transform.position = originalPosition;
}
public void DestroyMap()
{
foreach(Block[] i in blocks)
{
foreach(Block j in i)
{
if(!j.isEmpty())
{
GameObject.Destroy(j.Entity.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
30
31
32
33
// 生成数字块
public void addNewNumber()
{
//在哪里生成
int putWhere = Random.Range(0, 15 - blockCounter);
int counter = 0;
for (int i = 0; i < sideLength; i++)
{
for (int j = 0; j < sideLength; j++)
{
if (!blocks[i][j].isEmpty()) continue;
if (counter == putWhere)
{
blocks[i][j].Entity = EntityControllerMode1.createNewEntity(blocks[i][j]);
blockCounter++;
return;
}
else counter++;
}
}
}

//合并数字块
private void mergeNumber(Block blockA,Block blockB)
{
blockA.Entity.isChanged = true;
Score += blockB.Entity.Num;
GameObject.Destroy(blockB.Entity.gameObject);
blockCounter--;
blockB.Entity = blockA.Entity;
blockA.Entity = null;
blockB.Entity.doEntityMove(blockB);
}

数字块的移动

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
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
private void moveNumber(Block blockA, Block blockB)//AtoB
{
blockB.Entity = blockA.Entity;
blockA.Entity = null;
blockB.Entity.doEntityMove(blockB);
}

//判断是否有数字块正在移动(其实可以用回调函数)
public bool isNowMoving()
{
if (transform.localPosition != originalPosition)
return true;
for (int i = 0; i < sideLength;i++)
{
for(int j=0;j<sideLength;j++)
{
if (!blocks[i][j].isEmpty() &&
!blocks[i][j].Entity.isOnPosition( blocks[i][j].ScreenPosition))
{
return true;
}
}
}
return false;
}
//向四个方向移动的算法
public bool moveUp()
{
bool isMoved = false;
for (int row = 1; row < sideLength; row++)
{
for (int col = 0; col < sideLength; col++)
{
if (blocks[row][col].isEmpty()) continue;
int nextRow = row - 1;
while (blocks[nextRow][col].isEmpty() && nextRow != 0)
nextRow--;
if (!blocks[nextRow][col].isEmpty())
{
if (blocks[row][col].Entity.Num == blocks[nextRow][col].Entity.Num && !blocks[nextRow][col].Entity.isChanged)
{
mergeNumber(blocks[row][col], blocks[nextRow][col]);
isMoved = true;
continue;
}
else
{
if (nextRow == (row - 1)) continue;
nextRow++;
}
}
moveNumber(blocks[row][col], blocks[nextRow][col]);
isMoved = true;
}
}
return isMoved;
}
public bool moveDown()
{
bool isMoved = false;
for (int row = sideLength-2; row >=0; row--)
{
for (int col = 0; col < sideLength; col++)
{
if (blocks[row][col].isEmpty()) continue;
int nextRow = row + 1;
while (blocks[nextRow][col].isEmpty() && nextRow != sideLength-1)
nextRow++;
if (!blocks[nextRow][col].isEmpty())
{
if (blocks[row][col].Entity.Num == blocks[nextRow][col].Entity.Num && !blocks[nextRow][col].Entity.isChanged)
{
mergeNumber(blocks[row][col], blocks[nextRow][col]);
isMoved = true;
continue;
}
else
{
if (nextRow == (row + 1)) continue;
nextRow--;
}
}
moveNumber(blocks[row][col], blocks[nextRow][col]);
isMoved = true;
}
}
return isMoved;
}
public bool moveLeft()
{
bool isMoved = false;
for (int row = 0; row < sideLength; row++)
{
for (int col = 1; col < sideLength; col++)
{
if (blocks[row][col].isEmpty()) continue;
int nextCol = col - 1;
while (blocks[row][nextCol].isEmpty() && nextCol != 0)
nextCol--;
if (!blocks[row][nextCol].isEmpty())
{
if (blocks[row][col].Entity.Num == blocks[row][nextCol].Entity.Num && !blocks[row][nextCol].Entity.isChanged)
{
mergeNumber(blocks[row][col], blocks[row][nextCol]);
isMoved = true;
continue;
}
else
{
if (nextCol == (col - 1)) continue;
nextCol++;
}
}
moveNumber(blocks[row][col], blocks[row][nextCol]);
isMoved = true;
}
}
return isMoved;
}
public bool moveRight()
{
bool isMoved = false;
for (int row = 0; row < sideLength; row++)
{
for (int col = sideLength - 2; col >=0; col--)
{
if (blocks[row][col].isEmpty()) continue;
int nextCol = col + 1;
while (blocks[row][nextCol].isEmpty() && nextCol != sideLength - 1)
nextCol++;
if (!blocks[row][nextCol].isEmpty())
{
if (blocks[row][col].Entity.Num == blocks[row][nextCol].Entity.Num && !blocks[row][nextCol].Entity.isChanged)
{
mergeNumber(blocks[row][col], blocks[row][nextCol]);
isMoved = true;
continue;
}
else
{
if (nextCol == (col + 1)) continue;
nextCol--;
}
}
moveNumber(blocks[row][col], blocks[row][nextCol]);
isMoved = true;
}
}
return isMoved;
}

以上是逻辑层面移动数字块,实际上每个数字块的移动是用协程实现的。
协程是用法可见:
https://mycroftcooper.github.io/2021/06/19/Unity-%E5%8D%8F%E7%A8%8B/

判断游戏结束

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public bool isLose()
{
if (blockCounter != (int)Mathf.Pow((float)sideLength, 2f)) return false;
for(int i=0;i<sideLength;i++)
{
for(int j=0;j<sideLength-1;j++)
{
if (blocks[i][j].Entity.Num == blocks[i][j + 1].Entity.Num)
return false;
if(blocks[j][i].Entity.Num == blocks[j+1][i].Entity.Num)
return false;
}
}
return true;
}

3.3.3 球球模式的潜水艇控制

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
//用户输入检测
private Touch touch;
public void PhoneInput()
{
if(!GC.IsPause() && !GC.isShaking)
{
if (Input.touchCount != 1 && Input.touchCount != 2)
return;
touch = Input.GetTouch(0);
if (Input.touchCount == 2)
{
GC.dropTheEntity();
GC.shakePool();
return;
}
if (Input.GetTouch(0).phase == TouchPhase.Moved)
GC.moveSubmarine(Camera.main.ScreenToWorldPoint(touch.position));
if (!GC.hasNewEntity()
&& touch.phase==TouchPhase.Began && Input.touchCount ==1)
GC.createNewEntity();
if (touch.phase == TouchPhase.Ended)
GC.dropTheEntity();
}
}

//潜水艇移动响应函数
private Vector3 LeftLimiter = new Vector3(-2f, 2.65f, 0f);
private Vector3 RightLimiter = new Vector3(2f, 2.65f, 0f);
public void Move_EventHandle(Vector3 targetPosition)
{
if (targetPosition.x < LeftLimiter.x)
transform.position = LeftLimiter;
else if (targetPosition.x > RightLimiter.x)
transform.position = RightLimiter;
else
transform.position = new Vector3(targetPosition.x, 2.65f, 0f);
}