Outcast
Outcast는 SF 잠입액션 컨셉의 VR 게임 콘텐츠입니다. 잡입 액션을 좀 더 실감나게 구현하기 위해 이동방식과 벽타기, 적 시야탐지 등을 개발했고, 추가로 여러 재미요소로 장전 방식이나 도탄, 드론 등을 추가했습니다. 개인적인 팀 사정으로 알파버전 중간에 중단했지만 나중에 더 나은 실력으로 다시 개발해보고 싶은 콘텐츠이기도 하고 많이 아쉬움이 남아서 기록 겸 동기부여 차원에서 작성했습니다.
트레일러 영상
개요
- Github Link : 프로젝트에 여러 유료 에셋을 사용했기 때문에, 저작권 문제로 직접 짠 코드만을 공개
- 메디치 어트랙션 기반 VR 개발자 양성 과정 최종 프로젝트
- 개발 기간 : 2020.04.28 ~ 2020.06.10
- 장비 : Vive Pro, Vive Tracker, KATVR mini
- 팀명 : 가산 메디치 연합
- 팀원 : 이경하(팀장, 에너미, 비주얼 이펙트, 어트랙션), 김형호(플레이어, 플레이어 IK, 특수 상호작용), 박철우(드론 AI, UI, 레벨 디자인)
조작법
- 이동 : 트랙패드 터치 시 방향으로 기본 이동, 트랙패드를 누르고 방향 설정 후 떼면 향하는 위치로 빠르게 이동(텔레포트)
- UI 및 플레이어, 드론 상호작용 : 트리거를 누르고 왼손을 좌에서 우로 흔들면 UI창 생성, 컨트롤러로 해당 UI 클릭 후 트리거 누르면 기능 실행
- 벽타기 : 벽에 컨트롤러를 대고 트리거를 누른 채 컨트롤러를 당김.
- 점프 : 양 손 컨트롤러 트리거를 누르고 위 아래로 힘껏 당김.
- 장전 : 총알을 다 쏘면 장전 홀더가 열림. 총알을 들어 해당 위치에 가져가면 홀더에 총알이 들어감. 그리고 홀더가 열린 방향으로 총을 돌리면 홀더가 닫히고 장전 완료.
게임 구성
싱글 모드
- 건물에 잡임, 적들을 죽이거나 잡임해서 해당 위치에 폭탄을 설치하면 완료
멀티 모드
- 협동이나 PVP를 추가하려 했으나 시간상 문제로 무산
핵심 로직
- 제가 개발한 로직만 정리했습니다.
벽타기 로직
private void ClimbPull()
{
// 태그를 이용 오를 수 있는 오브젝트를 구별
if (attachObject.tag == "CLIMB" || attachObject.tag == "GROUND")
{
// canGrip : 핸드 콜리전에 닿으면 true
// ClimbAction : 크리거 누르고 있으면 true
if (canGrip && ClimbAction.GetState(inputSource))
{
bodyRb.isKinematic = true;
bodyRb.useGravity = false;
// 컨트롤러 변동 값을 바디 trasfrom 값에 더해줌, 게임상에서는 손 위치는 변동이 없고 바디 위치값만 변하기 때문에 올라가는 것 처럼 보임
body.transform.position += (handPrevPos - this.transform.localPosition);
}
else if (ClimbAction.GetStateUp(inputSource))
{
bodyRb.isKinematic = false;
bodyRb.useGravity = true;
// 트리거를 땠을 때 손 위치 변동값을 velocity로 주면 해당 방향으로 점프하는 듯한 효과를 준다.
bodyRb.velocity += (handPrevPos - this.transform.localPosition) / Time.deltaTime;
}
handPrevPos = this.transform.localPosition;
}
}
텔레포트 포물선 로직
private void UpdatePath()
{
// 핸드 위치
Transform startPos = isLeft == true ? lHandPos : rHandPos;
// 이동할 위치가 감지 됐는지
Detected = false;
// 플레이어 이동을 위한 좌표와 라인을 그리기 위한 좌표
vertexList.Clear();
lineVertexList.Clear();
//포물선이 뻣어나갈 방향
velocity = Quaternion.AngleAxis(-angle, startPos.right) * startPos.forward * strength;
RaycastHit hit;
// 플레이어 이동은 월드 좌표로
Vector3 pos = startPos.position;
// 포물선은 터치패드를 클릭한 컨트롤러를 따라가야 하기 때문에 로컬로 설정
Vector3 linePos = startPos.localPosition;
// 시작 위치 배열에 저장
vertexList.Add(pos);
lineVertexList.Add(linePos);
// 리소스 사용을 줄이기 위해 정해진 버텍수까지만 그리도록
while (!Detected && vertexList.Count < maxVertexcount)
{
// 포물선이 뻣어나갈 다음 위치를 계산
Vector3 newPos = pos + velocity * vertexDelta
+ 0.5f * Physics.gravity * vertexDelta * vertexDelta;
Vector3 newLinePos = linePos + velocity * vertexDelta
+ 0.5f * Physics.gravity * vertexDelta * vertexDelta;
vertexList.Add(newPos);
lineVertexList.Add(newLinePos);
// 가중치
velocity += Physics.gravity * vertexDelta;
// 올바른 위치(tag)에 닿았는지 판단
if (Physics.Linecast(pos, newPos, out hit))
{
if (hit.transform.gameObject.tag == "GROUND"
&& hit.transform.position.y < this.transform.position.y + 1
&& hit.transform.position.y > this.transform.position.y - 1)
{
groundDetected = true;
}
else groundDetected = false;
Detected = true;
groundPos = hit.point;
lastNormal = hit.normal;
}
pos = newPos;
linePos = newLinePos;
}
// 감지하면 그 위치로 마커 활성화
if (Detected)
{
positionMarker.SetActive(true);
positionMarker.transform.position = groundPos + (lastNormal * 0.01f);
positionMarker.transform.rotation = Quaternion.LookRotation(lastNormal);
positionMarker.transform.Rotate(90.0f, 0, 0);
if (groundDetected)
{
positionMarker.GetComponent<MeshRenderer>().material = MatEnable;
}
else
{
groundPos = this.transform.position;
positionMarker.GetComponent<MeshRenderer>().material = MatInvaild;
}
}
// 위 좌표정보를 바탕으로 라인렌더러 생성
arcRenderer.positionCount = lineVertexList.Count;
arcRenderer.SetPositions(lineVertexList.ToArray());
}
}
도탄 로직
public void Beam()
{
// 도탄 이펙트를 아직 만들지 않은 경우 만듭니다. 라인 렌더러를 사용
if (beamGO == null)
{
beamGO = new GameObject(beamTypeName, typeof(LineRenderer));
beamGO.transform.parent = transform;
}
LineRenderer beamLR = beamGO.GetComponent<LineRenderer>();
beamLR.material = beamMaterial;
beamLR.material.SetColor("_TintColor", beamColor);
beamLR.startWidth = startBeamWidth;
beamLR.endWidth = endBeamWidth;
반사 횟수
int reflections = 0;
// 도탄 빔이 반사되는 모든 좌표
reflectionPoints = new List<Vector3>();
reflectionHitObjects = new List<GameObject>();
// 첫번째 좌표 저장
reflectionPoints.Add(raycastStartSpot.position);
// 마지막 반사 지점을 저장
Vector3 lastPoint = raycastStartSpot.position;
// 빔 계산을 위한 변수 선언
Vector3 incomingDirection;
Vector3 reflectDirection;
// 빔이 계속 반사를 해야되는지 판단
bool keepReflecting = true;
Ray ray = new Ray(lastPoint, raycastStartSpot.forward);
RaycastHit hit;
do
{
// 다음 좌표를 초기화, 레이캐스트 히트가 리턴되지 않으면 forward direction * 범위
Vector3 nextPoint = ray.direction * range;
if (Physics.Raycast(ray, out hit, range))
{
// 다음 좌표를 히트 좌표로 설정
nextPoint = hit.point;
// 레이를 쏠 다음 방향을 계산
incomingDirection = nextPoint - lastPoint;
reflectDirection = Vector3.Reflect(incomingDirection, hit.normal);
ray = new Ray(nextPoint, reflectDirection);
// lastPoint 업데이트
lastPoint = hit.point;
/*
Hit Effects 생략
/*
LastHitObject = hit.collider.gameObject;
// 반사 회수 증가
reflections++;
}
else
{
keepReflecting = false;
}
// 다음 좌표 값 배열에 저장
reflectionPoints.Add(nextPoint);
reflectionHitObjects.Add(hit.transform.gameObject);
} while (keepReflecting && reflections < maxReflections && reflect && (reflectionMaterial == null || (FindMeshRenderer(hit.collider.gameObject) != null && FindMeshRenderer(hit.collider.gameObject).sharedMaterial == reflectionMaterial)));
// 라인 렌더러 빔의 각 좌표 위치값 설정
//beamLR.SetVertexCount(reflectionPoints.Count);
beamLR.positionCount = reflectionPoints.Count;
/*
muzzleEffects 생략
*/
}
가속도 값 계산 (리볼버 장전 홀더 탈착 가속도 계산)
public Vector3 LinearAcceleration(out Vector3 vector, Vector3 position, int samples){
Vector3 averageSpeedChange = Vector3.zero;
Vector3 averageVelocity = Vector3.zero;
vector = Vector3.zero;
Vector3 deltaDistance;
float deltaTime;
Vector3 speedA = Vector3.zero;
Vector3 speedB = Vector3.zero;
// 샘플 양을 고정, 가속도 계산을 하려면 적어도 두가지 변화가 필요
// 속도가 빠르면 최소 3가지 이상의 위치 샘플이 필요
if(samples < 3){
samples = 3;
}
// 초기화
if(positionRegister == null) {
positionRegister = new Vector3[samples];
posTimeRegister = new float[samples];
}
// 위치 및 시간 샘플 값을 배열에 저장
for(int i = 0; i < positionRegister.Length - 1; i++){
positionRegister[i] = positionRegister[i+1];
posTimeRegister[i] = posTimeRegister[i+1];
}
positionRegister[positionRegister.Length - 1] = position;
posTimeRegister[posTimeRegister.Length - 1] = Time.time;
positionSamplesTaken++;
// 가속도는 충분한 샘플을 얻었을 때만 계산 가능
if(positionSamplesTaken >= samples){
//평균 속도 변화를 계산
for(int i = 0; i < positionRegister.Length - 2; i++){
deltaDistance = positionRegister[i+1] - positionRegister[i];
deltaTime = posTimeRegister[i+1] - posTimeRegister[i];
//If deltaTime is 0, the output is invalid.
if(deltaTime == 0){
return Vector3.zero;
}
speedA = deltaDistance / deltaTime;
deltaDistance = positionRegister[i+2] - positionRegister[i+1];
deltaTime = posTimeRegister[i+2] - posTimeRegister[i+1];
if(deltaTime == 0){
return Vector3.zero;
}
speedB = deltaDistance / deltaTime;
//누적된 속도 변화
averageSpeedChange += speedB - speedA;
averageVelocity += speedB;
}
//평균 속도 변화
averageSpeedChange /= positionRegister.Length - 2;
averageVelocity /= positionRegister.Length - 2;
// 시차
float deltaTimeTotal = posTimeRegister[posTimeRegister.Length - 1] - posTimeRegister[0];
// 샘플 수에 따른 가속도 계산
vector = averageSpeedChange / deltaTimeTotal;
//Vector3 curVelocity = (speedA + speedB) / 2.0f;
return averageVelocity;
}
else {
return Vector3.zero;
}
}