1.项目要求
- 基础分(2分):有博客;
- 1-3分钟视频(2分):视频呈现游戏主要游玩过程;
- 地形(2分):使用地形组件,上面有草、树;
- 天空盒(2分):使用天空盒,天空可随玩家位置 或 时间变化 或 按特定按键切换天空盒;
- 固定靶(2分):有一个以上固定的靶标;
- 运动靶(2分):有一个以上运动靶标,运动轨迹,速度使用动画控制;
- 射击位(2分):地图上应标记若干射击位,仅在射击位附近可以拉弓射击,每个位置有 n 次机会;
- 驽弓动画(2分):支持蓄力半拉弓,然后 hold,择机 shoot;
- 游走(2分):玩家的驽弓可在地图上游走,不能碰上树和靶标等障碍;
- 碰撞与计分(2分):在射击位,射中靶标的相应分数,规则自定;
2.游戏简介
打靶游戏为第一人称射击游戏,玩家通过长按鼠标左键实现拉弓,松开坐标鼠标左键则弓箭飞出。玩家通过鼠标的移动实现瞄准,通过按键盘的WSAD或上下左右四个方向键实现在地图内的移动。此外,为了营造有趣的射击环境,玩家可以按C键实现正午和夕阳模式下的天空盒的切换。游戏中有5个射击位,玩家只有在射击位上才能进行拉弓射击。游戏中有2个固定靶和3个移动靶,靶子分为红色靶心和白色靶白,击中不同颜色区域有不同的得分,移动靶在树木左右移动,难度较固定靶更大。
3.游戏规则
1)击中移动靶靶心得3分,靶白得2分。
2)击中固定靶靶心得2分,靶白得1分。
3)游戏中有5个射击位,只有在射击位上可以进行射击,每个射击位有5次射击机会。
4)当地图上所有射击位的所有射击次数被用光时,即用完25次射击次数,游戏结束。
4.游戏编写
本游戏基于Unity引擎编写,使用的系统为Windows10,使用的脚本文件语言为C#,首先需要确保保证电脑中安装Unity引擎,我的版本是2023.3.8f1c1,还需下载VSCode或VStudio用于编写代码,还需要Unity的Plastic SCM用于管理代码,具体安装方法这里不过多介绍。
接着我们打开Unity,找到项目,选择新项目。
选择3D,项目命名为Target Games,保存地址我选择的是D盘。(启动版本管理可选可不选)。
本项目中运用到的代码、文件非常多,为了保证界面的简洁性,建议在Assests项目栏中如下图建
立几个文件夹方便管理。
其中只有Prefabs、Scenes、Scripts、Materials和Terrain文件夹是我们要自己处理的,其它的只要需要从Assets Store中导入。
建立方法如下:在Assets栏中空白区用鼠标右键点击,选择Create中的Folder生成我们需要的游戏文件。
然后去Assets Store中导入资源,在Window中选择Asset Store。
然后点击Search online,程序会自动跳转到浏览器。
首先导入天空盒,选择添加至我的资源。
然后同理导入弓弩和树。
然后在Window中选择Package Manager,Unity会弹出Package Manager窗口。
Package Manager窗口中选择My Assets。我们以导入Classical Crobow为例,选择Import。
全选后选择Import。
这样就成功导入资源,对于树木以及天空盒也同理。
接着我们可以布置地形,布置地形、树木和草地的具体方式可以在这些链接中的教程进行。
【Unity3D】地形Terrain - 知乎 (zhihu.com)
如何在地形上绘制草丛和树木 - 技术问答 - Unity官方开发者社区
在Unity中 改变地形(Terrain),并加上水面、树、草地、材质(地板上色)_unity创建地形山草树水房子-CSDN博客
我的地形图如下所示,其中白色长条为射击位。
为了实现玩家不会碰到靶子这一要求,可以在靶子四个方向周围设置透明的长方体Cube,但记住Cube的高度不要太高,以免射出的弓箭碰到Cube。
现在我们观察下靶子,游戏中靶子如图所示,有三片区域,红色靶心、白色区域和黑色区域,击中不同区域有不同的得分。
对于移动靶,我们需要自己制作动画,我们制作了一个名称为text的动画,具体制作过程可以·参考这个教程:Unity动画系统详解1:在Unity中如何制作动画? - 知乎 (zhihu.com)
注意该动画只有能移动一次,至于如何来回重复移动我们需要使用脚本TargetMove来实现。
此外我们还需要制作了名称为Text的Animator。
制作完动画后需要放入Animator,连线方式为右键动画,选择Make Transition然后选择你需要链接到的状态。
另外我们还设置了射击位,玩家在射击位中才能进行射击,如图所示:
同样为了防止玩家走出地图,我们用四个透明的大Cube来实现空气墙效果。
对于拉弓,老师课程上演示说使用Blend Tree实现,但课后我觉得十分难使用,于是制作了另外一个动画Energy Storage,这个动画事实上只是为了保持拉弓的状态,并不用设计动画的画面帧。
然后将动画按照如图所示放入Animator中,该Animator有3个trigger,一个为pull,一个为shooting,一个是hold,还有两个float,一个是Blend,一个holdTime,具体如下图所示。
其中,new hold状态为一个blend tree,由自带的动画Empty和Hold混合而成。
此外,我们还需要设置状态机的转换条件,其中Empty状态跳转到new hold状态的条件为Holding==false。
new hold状态跳转到shoot状态的条件为shooting==false。
Shoot状态跳转到Empty状态无需额外的条件。
我们还使用了UGUI来显示游戏得分、击中位置、游戏结束。游戏标题等信息,还在canvas中添加了Restart按钮重启游戏。
对于天空盒的布置,直接拉入Scene中即可。
我们刚刚编写的游戏界面场景Main,此外我们还要创建开始界面场景Start。
创建过程如图所示:
在Start中创建画布canvas,如图所示:
画布的canvas布置可以按照参考这个教程:【精选】【Unity3D-UGUI系列】(一)Canvas 画布组件详解_unity为什么显示画布内容了-CSDN博客
然后我的Start场景中布置如下:
我们的场景布置完成,现在开始编写脚本代码,在Scripts文件夹中有这些文件夹。
我们先来实现视角移动,首先将弓弩拉入场景并命名为player,然后将主相机Main Camera移动到Player作为Player的子类。
然后我们用脚本CameraMove来实现用鼠标进行视角360度移动,具体代码如下:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
//鼠标控制视角
public class CameraMove : MonoBehaviour
{
//鼠标x轴灵敏度
public float mouseXSensitivity = 80f;
//人物
private Transform player;
//旋转角度
float xRotation = 0f;
private void Start()
{
player = transform.parent.transform;
}
// Update is called once per frame
void Update()
{
float mouseX = Input.GetAxis("Mouse X") * mouseXSensitivity * Time.deltaTime;
float mouseY = Input.GetAxis("Mouse Y") * mouseXSensitivity * Time.deltaTime;
xRotation -= mouseY;
//y轴最大旋转角度为正负90;
xRotation = Mathf.Clamp(xRotation, -45f, 10f);
transform.localRotation = Quaternion.Euler(xRotation, 0f, 0f);
player.Rotate(Vector3.up * mouseX);
}
}
记得把CameraMove脚本挂到Main Camera上。
然后我们还需要实现玩家的移动,玩家可以通过WSAD或者方向键进行地图内的四个方向运动,我们用脚本PlayerMove实现,具体代码如下:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class PlayerMove : MonoBehaviour
{
//人物控制器
private CharacterController controller;
//人物移动速度
public float speed = 2f;
public float gravity = -15f;
Vector3 velocity;
private void Start()
{
controller = GetComponent<CharacterController>();
}
// Update is called once per frame
void Update()
{
Move();
}
public void Move()
{
//键盘输入
float x = Input.GetAxis("Horizontal");
float z = Input.GetAxis("Vertical");
Vector3 move = transform.right * x + transform.forward * z;
controller.Move(move * speed * Time.deltaTime);
velocity.y += gravity * Time.deltaTime;
controller.Move(velocity * Time.deltaTime);
}
}
记得把PlayerMove脚本挂到Player上。
然后我们通过TargetMove脚本实现移动靶的来回移动,由于我们做的Text动画只有一个方向的单次移动,我们需要用代码实现靶子在水平方向上的多次来回移动,具体代码如下:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class TargetMove : MonoBehaviour
{
public float speed = 5f; // 物体移动的速度
public float distance = 10f; // 物体移动的距离
private Vector3 startPosition;
private float direction = 1f;
void Start()
{
startPosition = transform.position;
}
void Update()
{
// 计算物体下一帧的位置
Vector3 nextPosition = transform.position + new Vector3(speed * direction * Time.deltaTime, 0f, 0f);
// 判断物体是否超出移动范围,如果超出则改变移动方向
if (Vector3.Distance(startPosition, nextPosition) > distance)
{
direction *= -1f;
}
// 更新物体的位置
transform.position = nextPosition;
}
}
记得把TargetMove脚本挂到移动靶上。
现在我们来实现天空盒的切换,在该游戏中我们实现了按C键实现正午与夕阳的天空盒切换,我们是通过布尔变量isSkybox1Active来实现,默认isSkybox1Active=true,当玩家按下c键时isSkybox1Active=false,再按一次isSkybox1Active=true,我们用脚本SkyboxSwitcher来实现这一功能,具体代码如下:
using UnityEngine;
public class SkyboxSwitcher : MonoBehaviour
{
public Material skybox1; // 第一个天空盒材质
public Material skybox2; // 第二个天空盒材质
private bool isSkybox1Active = true; // 当前激活的天空盒
private void Update()
{
if (Input.GetKeyDown(KeyCode.C))
{
SwitchSkybox();
}
}
private void SwitchSkybox()
{
isSkybox1Active = !isSkybox1Active;
if (isSkybox1Active)
{
RenderSettings.skybox = skybox1;
}
else
{
RenderSettings.skybox = skybox2;
}
}
}
我们在本次的界面UI设计都是UGUI,在游戏界面时,当弓箭击中靶子时会显示“在XX号射击位上射中XX号靶心/靶白,加XX分”,为了能保证在击中后才显示该文本,我们需要额外编写函数设置激活态,我们用TipsText脚本实现提示隐藏,具体代码如下:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class TipsText : MonoBehaviour
{
public void Close()
{
gameObject.SetActive(false);
}
}
在游戏界面,我们除了要显示“在XX号射击位上射中XX号靶心/靶白,加XX分”,还需要实时显示玩家已经得了几分,我们额外用函数void SetScore(int score)来实现,脚本Tips的具体代码如下所示:
using UnityEngine.UI;
public class Tips : MonoBehaviour
{
public static Tips Instance;
public GameObject tips;
public Text tipsText;
private int score;
public Text scoreText;
private void Awake()
{
Instance = this;
}
public void SetText(string str)
{
tipsText.text = str;
tips.SetActive(true);
}
public void SetScore(int score)
{
this.score += score;
scoreText.text = "当前分数:" + this.score;
}
}
记得要把脚本挂到控件上。
现在我们需要实现场景切换,选择File->Build Settings。
将我们创建的场景拖入到Scenes in Build中。
然后创建脚本Load Scene实现场景切换,具体代码如下:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;
public class LoadScene : MonoBehaviour
{
public void Load(int level)
{
SceneManager.LoadScene(level);
}
}
记得要把脚本挂到控件上。
我们现在编写击中靶子得分的脚本Target,变量isSportTarget用于判断是否为运动靶,当isSportTarget==true时为运动靶,弓箭击中运动靶的靶心得3分,击中靶白得2分;当isSportTarget==false时为固定靶,弓箭击中固定靶的靶心得2分,击中靶白得1分;其中Circle是靶白,Bullseye是靶心。具体代码如下:
using UnityEngine;
public class Target : MonoBehaviour
{
public int score = 1; // 分数
//是否为运动靶
public bool isSportsTarget;
private Transform point;
public int indexTarget;
private void Start()
{
point = transform.parent;
}
private void OnCollisionEnter(Collision collision)
{
if (collision.gameObject.CompareTag("Bullet"))
{
// 检测到子弹碰撞
CalculateScore();
collision.transform.GetComponent<Rigidbody>().isKinematic = true;
collision.transform.position = new Vector3(collision.contacts[0].point.x, collision.contacts[0].point.y, collision.contacts[0].point.z - Random.Range(-0.3f, -0.5f));
collision.gameObject.transform.parent = point;
}
}
private void CalculateScore()
{
if (gameObject.tag == "Bullseye")
{
// 碰撞到红色靶心
if (isSportsTarget)
{
// 在这里处理得分逻辑
Tips.Instance.SetScore(3);
Tips.Instance.SetText("在" + indexTarget + "号射击位上射中" + indexTarget + "号靶心,加3分");
}
else
{
Debug.Log("得到二分!");
// 在这里处理得分逻辑,例如增加两分
Tips.Instance.SetScore(2);
Tips.Instance.SetText("在" + indexTarget + "号射击位上射中" + indexTarget + "号靶心,加2分");
}
}
else if (gameObject.tag == "Circle")
{
// 碰撞到白色圆圈
if (isSportsTarget)
{
Debug.Log("得二分!");
// 在这里处理得分逻辑
Tips.Instance.SetScore(2);
Tips.Instance.SetText("在" + indexTarget + "号射击位上射中" + indexTarget + "号靶白,加2分");
}
else
{
Debug.Log("得到一分!");
// 在这里处理得分逻辑,例如增加两分
Tips.Instance.SetScore(1);
Tips.Instance.SetText("在" + indexTarget + "号射击位上射中" + indexTarget + "号靶白,加1分");
}
}
}
}
记得要把Targer脚本挂到每个靶子上,且记得靶白的Tag选择为Circle。
靶心的Tag选择为Bullseye。
此外,记得移动靶中Target脚本需要勾选变量isSportTarget,而固定靶不要勾选isSportTarget。
除了考虑弓箭与靶子的碰撞,我们还需要考虑弓箭与树木和地形的碰撞。
对于弓箭与树木的碰撞,我们使用脚本Tree实现碰撞判断,具体代码如下:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Tree : MonoBehaviour
{
private void OnCollisionEnter(Collision collision)
{
if (collision.gameObject.CompareTag("Bullet"))
{
// 检测到子弹碰撞
collision.transform.GetComponent<Rigidbody>().isKinematic = true;
collision.transform.position = new Vector3(collision.contacts[0].point.x, collision.contacts[0].point.y, collision.contacts[0].point.z - Random.Range(-0.1f, -0.2f));
}
}
}
记得要把Tree脚本挂在树上。
对于弓箭与地形的碰撞,我们使用脚本Terrain实现碰撞判断,具体代码如下:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Terrain : MonoBehaviour
{
private void OnCollisionEnter(Collision collision)
{
if (collision.gameObject.CompareTag("Bullet"))
{
// 检测到子弹碰撞
collision.transform.GetComponent<Rigidbody>().isKinematic = true;
collision.transform.position = new Vector3(collision.contacts[0].point.x, collision.contacts[0].point.y, collision.contacts[0].point.z - Random.Range(-0.5f, -0.8f));
}
}
}
记得地形组件上需要挂载Tree和Terrain脚本。
现在我们编写判断玩家是否在射击位上的脚本ShootingArea,我们用isPlayer和isArrow判断玩家是否在射击位上,OnTriggerStay(Collider other)函数实现玩家在射击位上能做的操作,OnTriggerExit(Collider other)函数规定玩家在不在射击位上能不能做的操作,具体代码如下:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class ShootingArea : MonoBehaviour
{
//弓箭次数
public int arrowCount = 5;
//是否可以射箭
public bool isArrow;
private bool isPlayer;
private void OnTriggerStay(Collider other)
{
if (isPlayer) return;
if (other.gameObject.tag == "Player")
{
isPlayer = true;
isArrow = true;
other.gameObject.transform.GetComponent<Bow>().shootingArea = this;
}
}
private void OnTriggerExit(Collider other)
{
if (other.gameObject.tag == "Player")
{
isPlayer = false;
if (other.gameObject.transform.GetComponent<Bow>().shootingArea != null)
{
arrowCount = other.gameObject.transform.GetComponent<Bow>().shootingArea.arrowCount;
}
isArrow = false;
other.gameObject.transform.GetComponent<Bow>().shootingArea = null;
}
}
}
记得要把脚本ShootingArea挂到射击位控件上。
现在我们编写控制弓箭的脚本Bow,该脚本有以下几个函数,下面简单介绍每个函数。
UpdateBowStretch():根据拉弓的距离设置弓的拉伸效果。
ShootArrow():计算蓄力时间和箭矢初速度,并实例化箭矢。
FindBullet():销毁上一次射出的箭,以免出现新箭射到射出的老箭上面。
FindShootingArea():查找判断还有发射的靶场。
LockCursor(bool a):隐藏鼠标锁鼠标
using UnityEngine;
using UnityEngine.UI;
public class Bow : MonoBehaviour
{
public GameObject arrowPrefab; // 箭的预制体
public Transform arrowSpawnPoint; // 箭的生成点
public float maxPullDistance = 3f; // 最大拉弓距离
public float maxPullForce = 100f; // 最大拉弓力量
public float minPullTime = 1f; // 最小蓄力时间
public float maxPullTime = 5f; // 最大蓄力时间
public float arrowFlightSpeed = 10f; // 箭的飞行速度
private float pullStartTime; // 开始蓄力的时间
private float pullDistance; // 箭飞行距离
//播放动画
private Animator anim;
public ShootingArea shootingArea;
public Text arrowCountTxt;
public GameObject arrowCount;
public GameObject over;
void Start()
{
Time.timeScale = 1;
anim = GetComponent<Animator>();
LockCursor(true);
}
private void Update()
{
if (Input.GetKeyDown(KeyCode.Escape) && Cursor.visible) { LockCursor(false); }
if (Input.GetMouseButtonDown(0) && Cursor.visible == false) { LockCursor(true); }
if (shootingArea == null)
{
arrowCount.SetActive(false);
return;
}
else
{
arrowCount.SetActive(true);
arrowCountTxt.text = "剩余箭:" + shootingArea.arrowCount;
}
if (shootingArea.isArrow && shootingArea.arrowCount > 0)
{
if (Input.GetMouseButtonDown(0))
{
pullStartTime = 0;
anim.SetTrigger("hold");
//清除所有弓箭
FindBullet();
}
else if (Input.GetMouseButton(0))
{
//计算蓄力时间
pullStartTime += Time.deltaTime;
//设置蓄力动画
anim.SetFloat("holdTime", pullStartTime);
}//鼠标抬起阶段
else if (Input.GetMouseButtonUp(0))
{
pullDistance = pullStartTime;
pullStartTime = 0;
anim.SetTrigger("shoot");
ShootArrow();
Invoke("FindShootingArea", 1.5f);
}
}
}
private void ShootArrow()
{
// 实例化箭矢
GameObject arrow = Instantiate(arrowPrefab, arrowSpawnPoint.position, arrowSpawnPoint.rotation);
Rigidbody arrowRigidbody = arrow.GetComponent<Rigidbody>();
arrowRigidbody.velocity = transform.forward * pullDistance * 30f;
shootingArea.arrowCount -= 1;
arrowCountTxt.text = "剩余箭:" + shootingArea.arrowCount;
}
public void FindBullet()
{
var bullets = GameObject.FindGameObjectsWithTag("Bullet");
for (int i = 0; i < bullets.Length; i++)
{
Destroy(bullets[i]);
}
}
public void FindShootingArea()
{
var ShootingAreas = GameObject.FindGameObjectsWithTag("ShootingArea");
var temp = 0;
for (int i = 0; i < ShootingAreas.Length; i++)
{
if (ShootingAreas[i].transform.GetComponent<ShootingArea>().arrowCount > 0)
{
temp++;
}
}
if (temp <= 0)
{
LockCursor(false);
over.SetActive(true);
Time.timeScale = 0;
}
}
public void LockCursor(bool a)
{
if (a)
{
Cursor.lockState = CursorLockMode.Locked;
Cursor.visible = false;
}
else
{
Cursor.lockState = CursorLockMode.None;
Cursor.visible = true;
}
}
}
到此,我们的游戏就编写完成了。