游戏

源码

方向

第一次做游戏,选择了个人Solo。

由于个人的游戏理念是做开心的游戏,对开心的游戏的理解有以下几点:

  • 可以联机且有对抗性;
  • 有各种骚操作可以作死(有创造性组合);
  • 人物有笨拙的动作(布娃娃系统)。

但是考虑到第一次做,所以选择了做一款二维对抗风格的游戏(引擎采用Unity,联机功能用Pun2实现)。

主题

游戏取名为Space Collider(太空碰撞),主要是通过碰撞来让对方掉下去死亡(无血条)。

游戏背景设定为太空,陨石是唯一的可以站立的物体,且每个陨石都添加了重力,故其本身便会一直下落。

开发

目前主要做了五个场景,分别是主界面、设置界面、发行界面、大厅界面以及游戏界面。其中大厅界面可以显示已经创建过的房间。

主界面

目前可以稳定实现的功能如下:

  • 主界面有开场动画和基础的UI;
  • 设置界面可以自定义更改设置;
  • 地图为随机生成;
  • 添加了几种技能以及子弹,玩家自主选择携带;
  • 人物实现分机控制(一台电脑一个人物,目前房间最多支持4人),可以实现多段跳(自定义),有完善的行走跳跃等动画;
  • 子弹射击爆炸有相应的音效和动画。
  • 陨石以及玩家射出的子弹的生命周期均与房间的相同(为主机创建);

视频演示

改进

  • 将陨石设置为可以被击碎,增强人物与原生地图的交互性;
  • 增加技能的comb。

日志

<2022.5.17> 角色移动以及多段跳

移动

角色移动主要是调用了现成的 CharacterController2D.cs 脚本,其内部可以实现移动,下蹲以及跳跃。

多段跳

多段跳只需记录一下跳跃次数,以及碰到起跳面清空跳跃次数即可。

1
2
3
4
5
6
7
8
9
10
// Jump
if (Input.GetKeyDown(GlobalControl.jump) && playerGlobalConfig.jumpTime < playerGlobalConfig.jumpMaxTime - 1)
{
playerGlobalConfig.jumpTime++;
playerGlobalConfig.isJump = true;
}
else
{
playerGlobalConfig.isJump = false;
}

<2022.5.18> 判断操作玩家是否为自己

由于终端只有一个,所以不同玩家的操作都会被终端视为同一种操作,因此需要在本地执行相应反馈前判断是否是自己的操作。

1
2
if (!photonView.IsMine || !PhotonNetwork.IsConnected)
return;

<2022.5.19> 房间列表

显示房间

显示房间主要可以通过重写官方给出的 API 接口来实现,没有一个房间就生成一个按钮组件到 Scroll View 下。

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
public override void OnRoomListUpdate(List<RoomInfo> room_list)
{
for(int i = 0; i < grid_layout.childCount; i ++)
{
if(grid_layout.GetChild(i).gameObject.GetComponentInChildren<Text>().text == room_list[i].Name)
{
Destroy(grid_layout.GetChild(i).gameObject);

// 如果房间没人就移除
if(room_list[i].PlayerCount == 0)
{
room_list.Remove(room_list[i]);
}
}
}

foreach(var room in room_list)
{
GameObject new_room = Instantiate(room_button, grid_layout.position, Quaternion.identity);

// 按钮显示的文字
new_room.GetComponentInChildren<Text>().text = room.Name;

new_room.transform.SetParent(grid_layout);
}
}

选择房间

考虑到玩家可能会点错房间,故若按钮被点击,则只是将相应的房间号传递给输入框,而非直接加入相应的房间。

1
2
3
4
5
6
7
8
room_name = select_button.GetComponentInChildren<Text>().text;

// 查找文本框
GameObject roomInputFiledGameObject = GameObject.Find("Canvas/Panel/RoomInputField");

InputField roomInputFiled = roomInputFiledGameObject.GetComponent<InputField>();

roomInputFiled.text = room_name;

其中由于 select_button 是组件,故无法给其直接传入相应的文本对象,只能通过查找的方式获取对象。

<2022.5.20> 显示不同玩家的名字

由于名字可以直接通过官方给的参数保存下来。

故可直接将玩家输入的名字传给本地网络下的对应参数,再在游戏界面调用显示该参数。

1
PhotonNetwork.NickName = player_name.text;
1
2
3
4
5
6
7
8
if (photonView.IsMine)
{
playerName.text = PhotonNetwork.NickName;
}
else
{
playerName.text = photonView.Owner.NickName;
}

<2022.5.21> 转换主从机

由于主机只能有一个(创建房间的为主机),其中主机可以创建与从机交互的物体,但是从机无法创建与主机交互的物体。

所以需要在每次从机创建物体前将从机设为主机,将原先的主机设为从机,否则就会出现从机创建了本地物体,但是别的电脑看不到从机创建的物体。

1
2
3
4
5
// 转换主机
Player player = photonView.Controller;

if (!player.IsMasterClient)
PhotonNetwork.SetMasterClient(player);

使用 SetMasterClient() 可以直接转换,无需主机同意请求。

<2022.5.23> 增加GameOver判定与玩家isReady判定

想法

已知需要一个统一变量存放角色是否存活,且可做出如下判断:

  • 不能存放在角色里,因为角色死亡会被销毁。
  • 不能通过判断玩家的数量来实现,因为角色死亡后玩家不一定会退出。

修改 PhotonView 以及 LocalPlayer 源文件(失败)

猜想可以通过在 PhotonViewLocalPlayer 增加静态变量 isAlive 来判断其所控制的角色是否存活。

在角色死亡时,修改当前的 LocalPlayerisAlive ,如果 isAlive == true 的只有一个,则游戏结束。

失败的原因当一台电脑上的玩家死亡后,其修改了该电脑的 LocalPlayer 的 ** isAlive** ,但是不会修改其他电脑上对应角色的 isAlive

改进

考虑到应该是本地参数没有传给服务器,因此换用官方推荐的 Hashtable

创建一个信号脚本去存储这些标志。

1
2
3
4
5
public class PlayerSignal : MonoBehaviour
{
public const string PLAYER_READY = "IsPlayerReady";
public const string PLAYER_ALIVE = "IsPlayerAlive";
}

创建角色伊始时,向其内部传参。

1
2
3
4
5
6
Hashtable props = new Hashtable
{
{PlayerSignal.PLAYER_READY, true},
{PlayerSignal.PLAYER_ALIVE, true}
};
PhotonNetwork.LocalPlayer.SetCustomProperties(props);

角色死亡时,向其内部传入新参数。

1
2
3
4
5
// Die
Hashtable props = new Hashtable
{
{PlayerSignal.PLAYER_ALIVE, false}
};

监听判断是否只有一个角色存活(之后为了提高性能,可以修改成每次角色数量发生变动后才启用监听)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Game over
foreach (Player player in PhotonNetwork.PlayerList)
{
object isPlayerAlive;

if (player.CustomProperties.TryGetValue(PlayerSignal.PLAYER_ALIVE, out isPlayerAlive))
{
if ((bool)isPlayerAlive)
{
aliveNum ++;
}
continue;
}

// No response
aliveNum = 0;
return;
}

这个方法是官方提供的函数方法,其好处就在于不用特地去修改源码添加参数,同时也更加的稳定。

其中 Player_READY 是用来记录玩家是否准备,以实现全体准备再开始游戏的效果。

<2022.5.24> 技能与子弹

在大厅界面挑选

为了实现如下图所示的挑选,首先得建立相应的文件夹然后将其内部的所有图片导入到动态数组中,通过按钮切换图片索引,从而显示不同的图片。

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
private List<string> LoadImages(string path)
{
List<string> filePaths = new List<string>();
string[] dirs = null;
string imgType = "*.BMP|*.JPG|*.GIF|*.PNG";
string[] imageTypeSplite = imgType.Split('|');
for (int i = 0; i < imageTypeSplite.Length; i++)
{
// 获取Application.dataPath文件夹下所有的图片路径
dirs = Directory.GetFiles((Application.dataPath + "/" + path), imageTypeSplite[i]);


//WebClient myWebClient = new WebClient();

//myWebClient.DownloadFile(new Uri("http://www.7soyo.com/Themes/Default/Images/recom_logo.jpg"), "D:\\temp.jpg");

//dirs = Directory.GetFiles(("https://i.postimg.cc/RhhLn48K/head.jpg"), imageTypeSplite[i]);


for (int j = 0; j < dirs.Length; j++)
{
filePaths.Add(dirs[j]);
}
}

return filePaths;
}

向该函数传入文件夹的相对路径,即可返回含文件夹内所有图片的名字的动态数组。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public void ShowNextSkill()
{
skillID ++;

if (skillID == skillsImage.Count)
skillID = 0;

image.sprite = (Sprite)skillsImage[skillID];
}


public void ShowPreviousSkill()
{
skillID --;

if (skillID == -1)
skillID = skillsImage.Count - 1;

image.sprite = (Sprite)skillsImage[skillID];
}

通过这两个函数,即可做到按钮切换显示图片的效果。

在游戏界面调用

由于大厅界面和游戏界面是不同的场景,所以需要先将挑选的技能和子弹存入静态变量中。

1
2
3
4
5
6
7
// Clear
skillsIndex.Clear();

skillsIndex.Add(firstChangeSkills.skillID);
skillsIndex.Add(secondChangeSkills.skillID);

bulletName = (string)thirdChangeSkills.skillsName[thirdChangeSkills.skillID];

其中 Clear 为把上一次游玩中选择的技能记录清空。

技能

技能的调用比较繁琐,因为技能的脚本并不相通,所以直接创建多个物体,每个物体挂载相应的技能脚本。

如果是选中的技能,则将其的名字改成 First 以及 Second 等样式。

1
2
transforms[(int)GetSkills.skillsIndex[0] + 1].name = "First";
transforms[(int)GetSkills.skillsIndex[1] + 1].name = "Second";

之后再每个技能脚本里添加如下代码,即可实现接口的统一。

1
2
3
4
5
6
7
8
9
10
11
switch (this.transform.name)
{
case "First":
useKeycode = GlobalControl.skillFirst;
break;
case "Second":
useKeycode = GlobalControl.skillSecond;
break;
default:
break;
}

不过这样也有一个好处,方便后期可以给每个技能附加相应的角色样貌或者状态,正如市面上的大部分游戏的解决方法一样。

子弹

子弹可以直接传入 GetSkills.bulletName 后生成,生成前将本机设为主机即可。

1
GameObject bullet_clone = PhotonNetwork.InstantiateRoomObject(GetSkills.bulletName, attack_point.position, attack_point.rotation);

<2022.5.25> 角色错位BUG

由于角色是由各电脑分别创建的,故如果直接给其 SetParent() 可能会造成只在自己的电脑上成功,从而导致不同电脑上的角色产生错位偏差。

1
2
GameObject player_clone = PhotonNetwork.Instantiate("Player", new Vector3(4.870117f, 5.25f, 0), this.transform.rotation);
//player_clone.GetComponent<Transform>().SetParent(players.transform);

<2022.5.26> 添加导弹并附加爆炸动画和音效

原本想创建静态的动态数组存放生成的每一个物体(刚体),但是由于物体是分开创建的,所以也会出现上述的各个电脑数组不统一的情况。

故采用Unity自带的标签属性(本质相同),可以发现其可以达到各台电脑的统一,说明Unity的标签属性也是多台电脑共享的属性。

可以使用以下代码访问每一个携带该标签的物体。

1
2
3
4
5
6
7
// Bomb
// Ground
GameObject[] grounds = GameObject.FindGameObjectsWithTag("Ground");
foreach (GameObject ground in grounds)
{
······
}

爆炸

实现爆炸需定义以下几个参数:

  • 爆炸半径
  • 爆炸中心的冲击能量
  • 爆炸衰减系数

但是由于个人默认了爆炸边缘无能量,且为了简单,所以定义了能量为线性递减,故可直接求得衰减系数为 float parameter = impact / impactRange

1
2
3
4
5
6
7
8
9
10
11
// Over range
float range = (this.transform.position - ground.transform.position).magnitude;

float arctan = Mathf.Atan2((ground.transform.position.y - this.transform.position.y), (ground.transform.position.x - this.transform.position.x));

if (range > impactRange)
continue;

float bombImpact = impact - parameter * range;

ground.GetComponent<Rigidbody2D>().velocity = new Vector2(bombImpact * Mathf.Cos(arctan) * Time.deltaTime, bombImpact * Mathf.Sin(arctan) * Time.deltaTime);

个人认位如果想做的更加拟真,可以按照距离远近从小到大排列,依次损耗爆炸的能量。

这样可以在不用定义爆炸半径的同时,保证了能量守恒的物体特性(忽视了空气传播的损耗)。

音效

由于碰撞后会销毁导弹组件,所以不能将声音直接挂载在导弹组件上,故可通过挂载 Audio Source 组件传递音源。

1
2
audioSource = this.GetComponent<AudioSource>();
audioClip = audioSource.clip;

再在触碰时的地点创建音频。

1
AudioSource.PlayClipAtPoint(audioClip, this.transform.position);

动画

由于同样的上述原因,故可单独做一个爆炸组件,并在碰撞的瞬间生成组件,同时在动画结束处添加事件 Destory(this.gameObject)

1
PhotonNetwork.InstantiateRoomObject("Bomb", this.transform.position, Quaternion.identity);