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! |
日式大佐英语,元气语气 |
配音员:小鸠
3.代码实现
3.1 工程类图
3.1.1 主场景(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)
实体类图设计:
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) { 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); }
|