기존에 TrackModifier 클래스에는 frequency변수가 있는데 이 변수가 현재 장애물이나, 기타 트랙에 나오는 모든 오브젝트들의 간격을 책임지고 있다.
나는 여기서 장애물이 이 일정한 간격을 유지한 채로 연속으로 나오게 하는 것을 구현하고 싶었다.
처음에는 코드에서 건드리지 말고 유니티에서 TrackObject를 추가해서 frequency로 차이를 둬서 연속으로 보이는 느낌이 나게 해 볼까 하다가 은근히 노가다일 것 같단 생각을 했다.
예전에는 귀찮아도 코드로 짜는 게 더 싫어서 노가다를 해봤겠지만 요즘엔 안되면 말지란 생각으로 그냥 코드를 건드려본다. 그런 걸 보면 예전보다는 코드랑 사이가 더 좋아졌는지도?
일단 내가 처음 생각했던 건 그냥 frequency 변수를 하나 더 추가하자였다.
연속으로 나오게 하고 싶은 장애물의 개수를 지정해 줄 변수로 groupSize로 정해주고, 그룹 내 장애물 간격을 정해줄 변수로 innerFrequency를 만들어 줬다.
생각보다 쉽게 빠르게 작업이 완료되었다. 아래 코드가 내가 만들어 둔 전체 함수 코드이다.
나중에 또 다른 곳에서 재사용하게 될 수도 있으니 groupSize, innerFrequency 변수를 매개변수로 빼서 관리해줄까도 싶었지만 필요하면 그때 바꿔주기로 하고, 지금은 매개변수로 너무 많은 애들이 들어가 있어서 추가해 주면 가독성이 너무 떨어질 것 같아 그냥 저대로 두었다.
m.label에 따라 트랙에 연속으로 그룹지게 나오게 하고 싶은 장애물이 있을 때 아래 AssignTrackGroupPosition함수를 불러와 사용해 주면 된다.
가끔 웹툰에서 보면 대사들 중간중간 멋진 그라데이션 효과가 들어가 있는 Text들을 볼 수 있다.
이런 방식으로 유니티에서 특정 단어들에 그라데이션 효과를 주고 싶을 때, 따라 해 보자.
1. 그라데이션 프리셋을 만들어 주기 전 프리셋을 저장해 줄 폴더를 추가해 주자.
< 프리셋을 만들어 주기 전 그라데이션 프리셋은 TextMesh Pro가 찾을 수 있는 특정 폴더에 저장해야 한다. > - 기본적으로 "TextMeshPro/Resources/Color Gradient Presets" 경로를 사용. **** 그라데이션 프리셋은 반드시 "TextMesh Pro/Resources/Color Gradient Presets" 폴더에 저장해야 한다. 폴더 구조를 다르게 하면 태그가 제대로 작동하지 않을 수 있다.(태그가 일반 텍스트로 표시될 수 있음)
>> 그렇기에 저 경로에 맞게 "Color Gradient Presets" 폴더를 우선 만들어주자.
2. 그라데이션 프리셋 생성하기
< 그라데이션 프리셋 생성하기. > - 만들어 준 폴더 내에서 Unity 메뉴 >> Assets >> Create >> TextMeshPro >> Color Gradient 클릭.
3. 새로 생성된 Color Gradient에셋에 원하는 타입과 색상 설정
< 새로 생성된 Color Gradient 에셋의 이름을 정해주고, Color Mode와 Colors를 선택해 주면 된다. >
< Color Mode는 총 4가지. > - Single : 단일 색상 적용. 텍스트 오브젝트의 버텍스 색상과 곱해짐. - Horizontal Gradient : 두 가지 색상을 사용하여 좌우로 그라데이션 적용. 각 문자에 대해 왼쪽에서 오른쪽으로 색상 변화. - Vertical Gradient : 두 가지 색상을 사용하여 위아래로 그라데이션 적용. 각 문자에 대해 위에서 아래로 색상 변화. - Four Corners Gradient : 네 가지 색상을 사용하여 가장 다양한 그라데이션 효과를 만들어 냄. 각 색상이 문자의 네 모서리에서 시작하여 퍼져나감. 다양한 조합으로 여러 종류의 그라데이션 만들 수 있음. (대각선, 3색, 복합적인 그라데이션)
4. 리치 텍스트 태그 사용(Rich Text)
< 원하는 특정 단어에 그라데이션을 적용하기 위해 리치 텍스트 태그를 사용. > 1. TextMesh Pro 텍스트 컴포넌트에서 텍스트를 입력할 때, 아래 형식으로 태그 사용해 주면 됨. : <gradient="그라데이션 프리셋 이름">적용하고 싶은 단어</gradient>
ex) "하늘에서는"을 그라데이션 해주고 싶음. 프리셋이름은 "RedToBlue"로 만들어 둠. >> 아주 먼 옛날 <gradient="RedToBlue">하늘에서는</gradient> 물고기를 잡고 있었다.
이렇게 하면 텍스트 특정 부분에만 그라데이션을 적용할 수 있으며, 나머지 텍스트에는 영향을 주지 않는다.
그라데이션 프리셋을 사용하게 되면 여러 텍스트 객체에서 동일한 그라데이션을 쉽게 재사용 할 수 있다.
하지만여러 그라데이션을 연속해서 사용할 때는 각각의 태그를 올바르게 열고 닫아야 하고, 과도하게 사용하면 렌더링 성능에 영향을 줄 수 있기에, 필요한 부분에만 제한적으로 사용하는 것이 좋을 듯하다.
++
추가로 어디서는, Text에 색깔이 존재한다면 원하는 그라데이션 효과를 보기에 한 가지 작업을 더 해줘야 된다 했다.
위에서 말해준 것처럼 TextMesh Pro에서 그라데이션은 기존 텍스트 색상에 곱셈으로 적용되기 때문에 기존 텍스트 색상이 흰색이 아니라면 기본 색상과 혼합이 된다는 말인데,
테스트해 보니 아래처럼 결과가 나온다. 아마도 업데이트가 된 거 같다.
- Text1 기본 색깔 : 분홍색.
- Text2 기본 색깔 : 흰색.
이전에는 텍스트 기본 색상이 빨간색이고, 그라데이션이 흰색이서 검은색으로 설정된 경우, 결과값이 그라데이션의 흰색부분은 원래의 빨간색으로 보이고 검은색 부분은 검은색으로 나왔나보다. 그래서 순수하게 내가 원하는 그라데이션 색을 나타내게 하고 싶으면 "<color>"태그를 사용해서 흰색으로 만들어 주고 </color>태그를 달아 준 것 같다.
그라데이션 태그 적용 전에 텍스트 색상을 흰색으로 리셋하는 건데 바로 아래 예시처럼 말이다.(#FFFFFF == 흰색)
ex ) <color=#FFFFFF><gradient="MyGradient">그라데이션 텍스트</gradient></color>
유니티가 업데이트되면서 main Camera의 stack에 새로운 카메라를 쌓을 수 있게 되었다.
여러 카메라의 출력을 레이어링하여 하나의 결합된 출력을 만들 수 있는 기능이다.
이걸 사용하면 하나의 씬에서 여러 개의 카메라를 관리해 줄 수 있으니 사용해 보자.
나의 경우는 하나의 씬에서 프리팹화 시켜둔 미니 게임 여러 개를 불러오게 하려고 시작했다.
< 스택 설정 방법 > Base Camera 설정 : - 메인 카메라의 Render Type을 "Base"로 설정. - Stack 섹션에서 오버레이 카메라들을 추가 가능. Overlay Camera 설정 : - 새 카메라 생성 후 Render Type을 "Overlay"로 변경. - Culling Mask를 통해 렌더링 할 레이어 지정.
위에서 정리해 둔 것처럼 설정해 보자.
1. 우선 기본 main Camera의 Render Type을 Base로 바꿔 준다.
2. 추가해주고 싶은 Camera의 Render Type을 Overlay로 바꿔 준다.
++ 여기서 잊지 말아야 할 부분, 추가하는 Camera의 Tag을 MainCamera로 남겨두지 말자.
++ Audio Listener도 꺼주거나 지워주자.
3. 코드에서 카메라 등록 해주고 싶은 부분에 써야 할 부분.
베이스 카메라의 스택에 오버레이 카메라 추가.
4. 코드에서 카메라 제거 해주고 싶은 부분에 써야 할 부분.
추가해줬던 카메라 제거.
예외처리 해서 최종 작업한 스크립트
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering.Universal;
public class CameraManager : MonoBehaviour
{
private Camera baseCamera;
private Dictionary<string, Camera> cameras = new Dictionary<string, Camera>();
private void Awake()
{
baseCamera = Camera.main;
}
public void RegisterCamera(string miniGameId, Camera overlayCamera)
{
if (!cameras.ContainsKey(miniGameId))
{
cameras.Add(miniGameId, overlayCamera);
var cameraData = baseCamera.GetUniversalAdditionalCameraData();
cameraData.cameraStack.Add(overlayCamera);
}
}
public void UnregisterCamera(string miniGameId)
{
// miniGameId가 null이거나 비어있는지 체크
if (string.IsNullOrEmpty(miniGameId))
{
Debug.LogWarning("MiniGame ID is null or empty");
return;
}
// 카메라 딕셔너리에서 해당 ID의 카메라를 찾음
if (cameras.TryGetValue(miniGameId, out Camera camera))
{
// 베이스 카메라가 존재하고 카메라가 유효한지 확인
if (baseCamera != null && camera != null && camera.gameObject != null)
{
try
{
var cameraData = baseCamera.GetUniversalAdditionalCameraData();
if (cameraData != null && cameraData.cameraStack.Contains(camera))
{
cameraData.cameraStack.Remove(camera);
}
}
catch (MissingReferenceException e)
{
Debug.LogWarning($"Camera reference was already destroyed: {e.Message}");
}
}
// 딕셔너리에서 카메라 제거
cameras.Remove(miniGameId);
}
}
private void OnDestroy()
{
// 카메라 스택 정리
if (cameras != null)
{
foreach(var camera in cameras.Values)
{
if (camera != null && baseCamera != null)
{
var cameraData = baseCamera.GetUniversalAdditionalCameraData();
if (cameraData != null)
{
cameraData.cameraStack.Remove(camera);
}
}
}
cameras.Clear();
}
}
}
++ baseCamera 가 null 이 될 수 있는 상황들이 있다면 null 체크도 해줘야 함.
요즘엔 유니티 에셋중에 scratch card라는 게 있다던데 찾아보니 유료에셋인 것 같아 gpt와 같이 만들어 봤다.
1. 우선 원하는 복권 이미지를 찾고, 스크래치 부분도 복권 이미지에 맞게 하나 만들어 준다.
2. 쉐이더가 두개 필요하다. mask shader와 mask Construction shader를 만들어 주자.
mask shader의 코드
// 메인 텍스쳐와 마스크 텍스쳐를 사용하여 투명도를 조절하는 마스킹 효과를 구현.
// 마스크 텍스쳐의 알파값에 따라 메인 텍스쳐의 투명도가 결정.
Shader "Masked" {
Properties {
_MainTex ("Main", 2D) = "white" {} //메인 텍스쳐
_MaskTex ("Mask", 2D) = "white" {} // 마스크 텍스쳐
}
SubShader {
//렌더링 설정
Tags {"Queue"="Transparent" "IgnoreProjector"="True" "RenderType"="Transparent"}
ZWrite Off // z버퍼 쓰기 비활성화
ZTest Off // z테스트 비활성화
Blend SrcAlpha OneMinusSrcAlpha // 알파 블렌딩 설정
Pass {
CGPROGRAM
#pragma vertex vert // 버텍스 쉐이더 함수 지정
#pragma fragment frag // 프래그먼트 쉐이더 함수 지정
#pragma fragmentoption ARB_precision_hint_fastest //프래그먼트 쉐이더 최적화 힌트
#include "UnityCG.cginc" // 유니티 내장 함수 포함
//텍스쳐 샘플러와 변환 매개변수 선언
uniform sampler2D _MainTex;
uniform sampler2D _MaskTex;
uniform float4 _MainTex_ST;
uniform float4 _MaskTex_ST;
// 버텍스 쉐이더 입력 구조체
struct app2vert
{
float4 position: POSITION; // 오브젝트 공간 위치
float2 texcoord: TEXCOORD0; // 텍스쳐 좌표
};
// 버텍스 쉐이더 입려 구조체
struct vert2frag
{
float4 position: POSITION; // 클립 공간 위치
float2 texcoord: TEXCOORD0; // 텍스쳐 좌표
};
// 버텍스 쉐이더 함수
vert2frag vert(app2vert input)
{
vert2frag output;
output.position = UnityObjectToClipPos(input.position); //오브젝트 공간에서 클립 공간으로 변환
output.texcoord = TRANSFORM_TEX(input.texcoord, _MainTex); //텍스쳐 좌표 변환
return output;
}
// 프래그먼트 쉐이더 함수
fixed4 frag(vert2frag input) : COLOR
{
fixed4 main_color = tex2D(_MainTex, input.texcoord); // 메인 텍스쳐 샘플링
fixed4 mask_color = tex2D(_MaskTex, input.texcoord); // 마스크 텍스쳐 샘플링
// 최종 색상 계산 : RGB는 메인 텍스쳐에서, 알파는 메인 텍스쳐와 마스크의 조합
return fixed4(main_color.r, main_color.g, main_color.b, main_color.a * (1.0f - mask_color.a));
}
ENDCG
}
}
}
maskConstruction shader 코드
// 기본 텍스처와 마스크 텍스처를 사용하여 마스킹 효과를 구현.
// 마스크 텍스처의 빨간 채널 값에 따라 기본 텍스처의 알파 값(투명도) 조절.
// 마스크 텍스처의 빨간 채널 값이 높을 수록 해당 부분 투명 up.
Shader "MaskConstruction"
{
//인스펙터에서 조정 가능한 속성 정의
Properties
{
_MainTex ("Base Texture", 2D) = "white" {} // 기본 텍스쳐
_MaskTex ("Mask Texture", 2D) = "black" {} // 마스크 텍스쳐
}
SubShader
{
Tags { "Queue"="Transparent" } //투명 객체로 렌더링 큐 설정
Pass
{
ZWrite Off // z버퍼 쓰기 비활성화
Blend SrcAlpha OneMinusSrcAlpha // 알파 블렌딩 설정
CGPROGRAM
#pragma vertex vert // 버텍스 쉐이더 함수 지정
#pragma fragment frag // 프래그먼트 쉐이더 함수 지정
#include "UnityCG.cginc" //유니티 내장 함수 포함
sampler2D _MainTex; // 기본 텍스처 샘플러
sampler2D _MaskTex; // 마스크 텍스처 샘플러
// 버텍스 쉐이더 입력 구조체
struct appdata_t
{
float4 vertex : POSITION; // 오브젝트 공간 위치
float2 uv : TEXCOORD0; // 텍스처 좌표
};
// 프래그먼트 쉐이더 입력 구조체
struct v2f
{
float2 uv : TEXCOORD0; // 텍스처 좌표
float4 vertex : SV_POSITION; // 클립 공간 위치
};
// 버텍스 쉐이더 함수
v2f vert (appdata_t v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex); // 오브젝트 공간에서 클립 공간으로 변환
o.uv = v.uv; // 텍스처 좌표 전달
return o;
}
// 프래그먼트 쉐이더 함수
fixed4 frag (v2f i) : SV_Target
{
fixed4 baseColor = tex2D(_MainTex, i.uv); // 기본 텍스처 샘플링
fixed4 maskColor = tex2D(_MaskTex, i.uv); // 마스크 텍스처 샘플링
// 마스크를 기본 텍스처에 적용
baseColor.a *= (1 - maskColor.r); // 마스크 텍스처의 빨간 채널을 기반으로 지우기 효과 적용
return baseColor; //최종 색상 반환
}
ENDCG
}
}
}
주석으로 설명은 달아뒀으니 shader가 궁금한 사람은 주석을 보며 더 공부해 보는 걸 추천한다.
3. 유니티에서 Scratch_Result 오브젝트를 만들어 주고 이전에 준비했던 복권 이미지를 넣어준다.
4. Scratch_Surface 오브젝트를 만들어 주고 자식으로 camera 하나와 surface 오브젝트 하나를 준비해 준다.
카메라 옵션을 위와 같이 바꿔주고 Surface 오브젝트에는 스프라이트 렌더러 컴포넌트를 추가해 준다. 여기서 복권의 스크래치 회색 부분을 sprite에 넣어 주면 되고 material을 새롭게 만들어 넣어주자.
카메라의 Clear Flags를 Don't Clear로 설정해주면서 프레임 누적효과로 이전 프레임위에 프레임이 그려져 누적되는 효과를 만들 수 있는데 이걸로 지워지는 효과를 줄 수 있다. 이 설정으로 번짐이나 잔상 효과도 만들어 낼 수 있을 듯?
우선, Assets 폴더에 Textures 폴더를 만들어 주고 우클릭을 통해 Render Texture를 만들어 준다.
다시 Assets파일로 가서 Materials 폴더를 만들어 주고 우클릭을 통해 Material을 만들어 준다.
여기서 중요한 점은 shader를 아까 만들어 준 Masked로 연결해 주는 것. 그리고 저 별표 쳐둔 곳에 아까 만든 Render Texture를 연결해 주자.
다 만들어 줬다면 Material을 복사해서 하나 더 만들어 주고 이름을 Eraser로 바꿔준다. 그리고 shader를 maskConstruction으로 설정해 두자.
Surface 오브젝트로 돌아와서 mask shader로 만들어 준 Material을 연결해 준다.
여기서 Order in Layer값은 아까 만들어 준 Scratch_result보다 높게 수치를 정해주자.
5. 이제 거의 다 왔다. camera에 달아줄 스크립트를 만들어주자.(main camera 아님 주의!)
>> 픽셀로 검사해서 전체 진행률을 확인해보려 하다가 내 surface의 크기를 전체 픽셀로 체크를 해야 되는데 제대로 체크가 안돼서 박스 콜라이더로 진행률을 확인하는 걸로 바꿨다.
>> 박스 콜라이더로 작업을 하면 원하는 방향대로 진행되지 않음을 확인, 렌더 텍스처와 박스콜라이더를 연동해서 작업해 보고 있는 중이다. 이건 추후에 업로드하겠다.
진행률을 안 할 거라면 그냥 이 코드를 적용해 주면 된다. 진행률 부분이 잘 안되는 것 빼고 잘 된다. 작업할 때 잘 안돼서 디버그가 많은데 필요 없다면 알아서 지워주자.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System;
public class MaskCamera : MonoBehaviour
{
public GameObject Dust; // 긁을 수 있는 표면 오브젝트
public Material EraserMaterial; // 지우개 효과를 위한 머티리얼
private RenderTexture renderTexture; // 마스크용 렌더 텍스처
private Texture2D tex; // 픽셀 데이터를 읽기 위한 텍스처
private Rect screenRect; // 긁기 가능한 영역
private Action<float> progressCallback; // 진행률 콜백
private bool requestReadPixels = false;
private void Start()
{
InitializeRenderTexture();
CalculateScreenRect();
Debug.Log(Dust.GetComponent<Renderer>().material.GetTexture("_MaskTex"));
}
private void InitializeRenderTexture()
{
// 렌더 텍스처 초기화
renderTexture = new RenderTexture(Screen.width, Screen.height, 0, RenderTextureFormat.Default);
renderTexture.Create();
tex = new Texture2D(renderTexture.width, renderTexture.height, TextureFormat.RGB24, false);
// 카메라와 Dust 오브젝트에 렌더 텍스처 연결
GetComponent<Camera>().targetTexture = renderTexture;
Dust.GetComponent<Renderer>().material.SetTexture("_MaskTex", renderTexture);
Debug.Log($"RenderTexture width: {renderTexture.width}, height: {renderTexture.height}");
}
private void CalculateScreenRect()
{
// Dust 오브젝트의 화면 영역 계산
Renderer dustRenderer = Dust.GetComponent<Renderer>();
screenRect = new Rect(
dustRenderer.bounds.min.x,
dustRenderer.bounds.min.y,
dustRenderer.bounds.size.x,
dustRenderer.bounds.size.y
);
Debug.Log($"ScreenRect initialized: {screenRect}"); // screenRect 값 확인
}
public void SetProgressCallback(Action<float> callback)
{
progressCallback = callback;
}
private void Update()
{
if (Input.GetMouseButton(0))
{
// 카메라의 viewport 좌표로 변환
Vector2 viewportPoint = GetComponent<Camera>().ScreenToViewportPoint(Input.mousePosition);
// viewport 좌표를 직접 사용 (0-1 범위)
Vector2 localPosition = new Vector2(viewportPoint.x, viewportPoint.y);
// screenRect 체크는 유지
Vector2 worldPosition = GetComponent<Camera>().ScreenToWorldPoint(Input.mousePosition);
if (screenRect.Contains(worldPosition))
{
Debug.Log($"Viewport Position: {viewportPoint}");
CutHole(localPosition);
}
}
if (requestReadPixels)
{
ReadPixelsAndCalculateProgress();
requestReadPixels = false;
}
}
private void CutHole(Vector2 normalizedPosition)
{
Debug.Log($"CutHole called at position: {normalizedPosition}");
RenderTexture.active = renderTexture; // RenderTexture 활성화
GL.PushMatrix();
GL.LoadPixelMatrix(0, renderTexture.width, renderTexture.height, 0);
if (!EraserMaterial.SetPass(0))
{
Debug.LogError("Failed to set material pass!");
return;
}
float size = 50f; // 지우개 크기를 픽셀 단위로 설정
Vector2 pixelPos = new Vector2(
normalizedPosition.x * renderTexture.width,
(1- normalizedPosition.y) * renderTexture.height
);
// 사각형 그리기 시작과 종료. color는 사각형 색상, Vertex3()는 사각형 꼭지점 정의.
// pixelPos는 사각형 중심 위치, size는 사각형의 절반 크기, z값은 0으로 2D 평면에 그림.
GL.Begin(GL.QUADS);
GL.Color(Color.white);
GL.Vertex3(pixelPos.x - size, pixelPos.y - size, 0);
GL.Vertex3(pixelPos.x + size, pixelPos.y - size, 0);
GL.Vertex3(pixelPos.x + size, pixelPos.y + size, 0);
GL.Vertex3(pixelPos.x - size, pixelPos.y + size, 0);
GL.End();
GL.PopMatrix();
RenderTexture.active = null;
}
private void ReadPixelsAndCalculateProgress()
{
tex.ReadPixels(new Rect(0, 0, renderTexture.width, renderTexture.height), 0, 0);
float percentScratched = CalculateScratchedPercent(tex);
progressCallback?.Invoke(percentScratched);
}
private float CalculateScratchedPercent(Texture2D texture)
{
int scratchedPixels = 0;
for (int x = 0; x < texture.width; x++)
{
for (int y = 0; y < texture.height; y++)
{
if (texture.GetPixel(x, y).r == 1) scratchedPixels++;
}
}
return scratchedPixels * 100f / (texture.width * texture.height);
}
public void RequestProgressUpdate()
{
requestReadPixels = true;
}
}
여기서 CutHole() 함수가 제일 중요하다. 렌더 텍스처에 흰 사각형을 그려주고 이 사각형이 마스크로 사용되면서, 아까 설정해주었던 Surface에 자식으로 만들어 준 카메라의 Don't Clear 설정으로 인해 이전에 그려진 사각형들이 지워지지 않고 누적되어서 긁히고 있는 것처럼 보여준다. 플레이어가 마우스를 누를 때마다 반복적으로 함수를 호출해서 실제로 복권을 긁는 것처럼 시각적 효과를 낼 수 있다.
참고로 여기 프로젝트에서 도로의 스프라이트는 존재하지 않는다. 오로지 메터리얼로 그려주고 있기 때문. 따라서 이 스크립트를 이용한다면 도로의 스프라이트를 따로 줄 수 없다. 지금으로서는 메테리얼의 색깔만 바꿔 줄 수 있다. 이 부분은 수정해 볼 수 있으면 수정해 볼 예정이다.
이 프로젝트에선 크게 HqRenderer, TrackObject, Line 스크립트를 보면 된다.
1. Line.cs에서는 도로의 각 세그먼트에 대한 데이터를 저장하는 구조체이다. 이미지에서 볼 수 있는 속성들을 포함하는 곳이기에 이 구조체를 통해 TrackObject에서 배열로 관리가 된다.
using UnityEngine;
public struct Line
{
public float x, y, z, w;//3d center of line
public float X, Y, W; //screen coord
public float curve, spriteX, clip, scale;
public Sprite sprite;
public bool flipX;
//장애물 충돌처리 때문에 추가
public bool isObstacle; // 장애물 여부 체크
public void project(int camX, int camY, int camZ, int screenWidth2, int screenHeight2, float cameraDepth)
{
scale = cameraDepth / (z - camZ);
X = scale * (x - camX) * screenWidth2;
Y = scale * (y - camY) * screenHeight2;
W = scale * w * screenWidth2;
}
}
아. 참고로 나의 경우 장애물을 그려주기 위해 위에서 처럼 코드에 bool변수인 isObstacle을 추가해 주었다.
주요 속성으로 spriteX, frequency, segments, sprite 정도는 알아둬야 된다.
- spriteX는 스프라이트의 x축 위치를 말한다. 이 값으로 화면에 보여주고 싶은 오브젝트의 위치를 정한다. - frequency는 스프라이트가 반복되는 간격을 말한다. 같은 간격으로 왼쪽, 오른쪽에 물체를 추가해주고 싶다면 같은 값을 입력하면 되고, 만약 다르게 나타내고 싶다면 다른 값을 입력해 주면 어긋나게 오브젝트들이 나온다. - segments의 경우 시작과 끝지점이다.(x, y값) 이걸로 물체를 어디서부터 어디까지 나오게 하고 싶은지 정하면 된다. - sprite는 해당 라인에 표시될 스프라이트로 그려주고 싶은 오브젝트의 스프라이트를 넣어주면 된다.
이제 유니티에 가서 Tracks폴더에 있는 New Track을 켜주고 추가로 그려주고 싶은 애들을 Modifier에 추가해 주자.
아까도 말했듯이 나는 장애물을 추가해주고 싶었기 때문에 왼쪽 장애물과 오른쪽 장애물을 추가해 주었다.
2. TrackObject.cs를 봐보자.
여기는 도로 트랙의 전체적인 구성과 속성을 관리하는 ScriptableObject다.
여기서는 트랙을 생성해 주는 Construct() 함수를 봐야 된다. 방금 유니티의 Inspector에서 설정된 TrackModifier들(leftObstacle, RightObstacle)의 속성들이 Construct() 함수를 통해 실제 트랙에 적용되어 화면에 표시되는 것이다.
참고로, 만약 트랙의 길이를 더 길게 하고 싶다면 TrackObject.cs에서 Length 값을 증가시켜 주면 된다.
using HQ;
using UnityEngine;
[CreateAssetMenu( fileName = "New Track", menuName = "HQ/Track")]
public class TrackObject: ScriptableObject
{
public int Length;
public Line[] lines { get; protected set; }
[HideInInspector]
public TrackModifier[] Modifier;
public float roadWidth;
public int segmentLength;
public float trackHeight;
private void OnEnable()
{
Construct();
}
protected virtual void Construct()
{
Length = 1600;
lines = new Line[Length];
for (int i = 0; i < Length; i++)
{
ref Line line = ref lines[i];
line.z = i * segmentLength;
line.w = roadWidth;
foreach (var m in Modifier)
{
if (!m.disabled && m.Segments.InRange(i) && i % m.frequency == 0)
{
line.curve += m.curve;
line.spriteX = m.spriteX;
line.y += Mathf.Sin(i * m.h) * trackHeight;
line.sprite = m.sprite ?? line.sprite;
line.flipX = m.flipX;
//장애물이면 체크
line.isObstacle = m.label.Contains("Obstacle");
}
}
}
}
}
나는 장애물과 플레이어의 충돌처리를 위해 여기서 장애물일 때 1. 에서 추가로 넣어줬던 bool값을 변경해 줬다.
3. 자, 이제 마지막 HqRenderer.cs를 봐보자.
여기서 추가해줘야 할 변수는 1개이다. spriteRenderer로 이전에 만들어 뒀던 스프라이트들을 그려줄 변수를 만들어 주자. 나의 경우 원래 오브젝트들을 그려주고 있던 scale과 장애물 scale의 비율이 달랐기에 장애물 스케일을 관리해 줄 ObstacleScale 변수도 추가해 줬다. scale 문제가 없다면 굳이 추가해 줄 필요가 없다.
변수를 만들어 줬다면 Awake() 함수로 가서 기존 코드와 같은 방식으로 새 스프라이트를 생성해 주자.
아, 이렇게 해주기 전에 유니티에서 ProjectedScene에 빈 오브젝트를 하나 추가해 주고 Sprite Renderer를 컴포넌트로 추가해 주자. sprite는 비워두고 다시 코드로 와서 이렇게 코드를 써주면 유니티로 넘어갔을 때 runtimeObstacle이라는 스프라이트가 연결된 걸 확인할 수 있다. 물론 이 스프라이트에는 아직 아무것도 그려져있지 않는 빈 스프라이트다.
이제 다시 코드로 와서 수정해 주자. drawSprite() 함수에서 매개변수로 Line을 받아오는 걸 보면 여기서 아까 추가해 준 scale을 변경해 주자. 장애물일 경우엔 내가 넣어준 ObstacleScale을 사용해 주고 장애물이 아닐 경우 원래 있던 SpriteScale을 사용해 주자.
그리고 새롭게 DrawObstacle() 함수를 하나 만들어주자. 여기서 장애물을 그려줄 것이다. DrawObjects() 함수와 거의 똑같이 만들어 주면 된다.
처음엔 DrawObjects() 함수에 같이 그려줬다가 충돌처리 오브젝트들을 따로 관리해 주기 위해 변경해 줬다.
만약 또 다른 오브젝트들을 그려줄 때, 충돌처리가 필요 없다면 DrawObject() 함수에서 처리할 것이고, 충돌처리가 필요한 오브젝트라면 DrawObstacle() 함수에서 처리해 줄 것이다.
하나만 기억하면 된다. Track에 추가 해준 Modifier에 label 이름을 충돌 처리 하고 싶은 오브젝트들에만 + "Obstacle" 해주자. 절대 충돌처리가 필요 없는 애들의 이름에는 Obstacle을 써주면 안 된다. 그 이유는 내가 작업한 코드를 보면 이름에 "Obstacle" 이 있는지 없는지만을 확인해서 나눠주기 때문이다.
이제 거의 다 왔다. CheckObstacleCollision() 함수를 만들어 주자. 여기서 충돌 처리를 확인할 것이다. 처음엔 위치값으로 만도 체크를 해봤는데 섬세하게 안 돼서 AABB충돌체크로 충돌 감지를 해봤다.
만약에 충돌이 된다면 OnCollision() 함수를 실행시켜 주고 이 OnCollision() 함수에서 충돌했을 때 해주고 싶은 작업을 해주면 된다.
마지막으로 Update() 함수에서 DrawObstacle() 함수와 CheckObstacleCollision() 함수를 추가해 주면 모든 게 끝이 난다.
바로 다음날 작업한 걸 정리하려던 나는 내일 할까 미뤘다가 파일정리를 하면서 Shift + Delete로 프로젝트를 삭제시키는 끔찍한 실수를 저질렀다. 잠시 잠깐 멘붕에 빠져있다가 PuranUtilitiesSetup이라는 복원프로그램을 사용해 봤다.(이 프로그램은 공짜로 풀린 복원 프로그램인 것 같은데 안타깝게도 cs파일은 찾지 못하는 것 같았다. 텍스트 파일이나 사진파일 같은 건 바로 찾을 수 있는 것 같았음.)
나의 경우는 파일을 찾지도 못했을 뿐더러 중간에 갑자기 노트북이 업데이트되는 해프닝이 있었다. 업데이트가 되길래 약간은 불안했다만 "그저 기우겠지."란 생각으로 더 이상 생각하지 않기로 했다. 그렇게 노트북이 힘겨워하기에 그만두고 다시 구현하는 쪽을 선택해서 지금 다시 올린다. 그래도 한번 만들어 봤다고 시간이 엄청 투자되지는 않아서 다행이었다.
using UnityEngine;
using System.Collections.Generic;
public class ObstacleManager : MonoBehaviour
{
// 도로의 전체 너비와 카메라 높이 설정
[Header("Road Settings")]
public int roadWidth = 1;
public float cameraHeight = 1000;
public float speed = 0.8f;
[Header("Obstacle Settings")]
public GameObject[] obstaclePrefabs;
[SerializeField] private float obstacleSpeed = 0.8f;
[SerializeField] private float spawnY = 3.16f;
[SerializeField] private float endYPosition = -6f;
[SerializeField] private int poolSize = 5;
[SerializeField] private int maxObstacles = 3; // 동시에 존재할수 있는 최대 장애물 수
[SerializeField] private float minObstacleSpacing = 0.5f; // 장애물 X간 최소 간격
[SerializeField] private float heightCheck = 1f; // 장애물 Y간 최소 간격
[SerializeField] private int consecutiveNum = 3; //연속 장애물 허용 범위
[SerializeField] private float spawnRate = 0.02f;
private Queue<GameObject>[] obstaclePools;
private List<GameObject> activeObstacles = new List<GameObject>();
private int consecutiveSpawnCount = 0; // 연속 생성 횟수
private bool? lastSpawnSide = null; // 마지막 생성 방향 (null: 초기상태)
private float spawnTimer;
private readonly Vector3 scaleVector =Vector3.one;
private void Awake()
{
activeObstacles = new List<GameObject>(maxObstacles);
InitializeObstaclePools();
}
private void InitializeObstaclePools()
{
obstaclePools = new Queue<GameObject>[obstaclePrefabs.Length];
for (int i = 0; i < obstaclePrefabs.Length; i++)
{
obstaclePools[i] = new Queue<GameObject>();
for (int j = 0; j < poolSize; j++)
{
CreateObstacles(i);
}
}
}
private void CreateObstacles(int typeIndex)
{
GameObject obj = Instantiate(obstaclePrefabs[typeIndex], transform);
obj.SetActive(false);
obj.AddComponent<ObstacleData>();
obstaclePools[typeIndex].Enqueue(obj);
}
private void Update()
{
spawnTimer += Time.deltaTime;
if (spawnTimer >= spawnRate)
{
spawnTimer = 0f;
if (Random.value < spawnRate)
{
SpawnObstacle();
}
}
UpdateObstacles();
}
private void UpdateObstacles()
{
Vector3 position;
float progress, scale, xOffset, newX, newY;
for (int i = activeObstacles.Count - 1; i >= 0; i--)
{
GameObject obstacle = activeObstacles[i];
ObstacleData data = obstacle.GetComponent<ObstacleData>();
position = obstacle.transform.position;
// 장애물의 진행도 계산 (0~1 사이 값)
progress = (spawnY - position.y) / (spawnY - (transform.position.y + endYPosition));
// 진행도에 따른 크기 보간
scale = Mathf.Lerp(0.1f, 3f, progress);
// 바깥쪽으로 이동하는 힘을 더 강하게 수정
xOffset = data.isLeftSide ? -progress * 4f : progress * 4f;
newX = data.initialX + (xOffset * roadWidth);
// 스케일이 커질수록 더 빠르게 이동 //scale 0.1_기본속도 1, scale 1.5_기본속도 4.5, scale 최대_기본속도 8.25
newY = position.y - (obstacleSpeed * (1f + (scale - 0.1f) * 2.5f) * Time.deltaTime);
position.x = newX;
position.y = newY;
obstacle.transform.position = position;
obstacle.transform.localScale = scaleVector * scale;
if (newY < transform.position.y + endYPosition)
{
ReturnToPool(obstacle);
activeObstacles.RemoveAt(i);
}
}
}
public class ObstacleData : MonoBehaviour // 장애물의 초기 x위치와 방향을 저장하는 컴포넌트
{
public float initialX;
public bool isLeftSide;
}
private bool IsValidSpawnPosition(Vector3 newPosition)
{
foreach (GameObject obstacle in activeObstacles)
{
// y축 간격이 heightCheck의 간격보다 작은지 체크
if (Mathf.Abs(obstacle.transform.position.y - newPosition.y) < heightCheck)
{
// x축 간격이 최소 간격보다 작으면 생성 불가
if (Mathf.Abs(obstacle.transform.position.x - newPosition.x) < minObstacleSpacing)
{
return false;
}
}
}
return true;
}
private void SpawnObstacle()
{
if (activeObstacles.Count >= maxObstacles)
{
return;
}
//50% 확률로 장애물 왼쪽, 오른쪽으로 스폰
bool spawnOnLeft = Random.value > 0.5f;
HandleConsecutiveSpawns(ref spawnOnLeft);
int typeIndex = Random.Range(0, obstaclePrefabs.Length);
GameObject obstacle = GetObstacleFromPool(typeIndex);
if (obstacle == null)
{
return;
}
// 초기 생성 위치를 도로 중앙 근처로 수정 //0.1 수치 증가시키면 장애물이 도로의 바깥쪽 생성,감소 >>중앙에 가깝게 생성.
float xPos = spawnOnLeft ? -roadWidth * 0.1f : roadWidth * 0.1f;
Vector3 newPosition = new Vector3(xPos, spawnY, 0);
// 새로운 위치가 적절한 간격을 유지하는지 확인
if (!IsValidSpawnPosition(newPosition))
{
ReturnToPool(obstacle);
return;
}
SetupObstacle(obstacle, xPos, spawnOnLeft);
}
private void HandleConsecutiveSpawns(ref bool spawnOnLeft) //연속으로 같은 방향에 장애물이 생성되는 것을 제어
{
// 이전과 같은 방향으로 생성하려는 경우
if (lastSpawnSide.HasValue && spawnOnLeft == lastSpawnSide.Value)
{
consecutiveSpawnCount++;
// consecutiveNum번 연속 생성되면 반대편에 생성
if (consecutiveSpawnCount >= consecutiveNum)
{
spawnOnLeft = !lastSpawnSide.Value;
consecutiveSpawnCount = 0;
}
}
else
{
consecutiveSpawnCount = 0;
}
lastSpawnSide =spawnOnLeft;
}
private void SetupObstacle(GameObject obstacle, float xPos, bool isLeft) // 생성된 장애물의 초기 설정을 담당
{
obstacle.transform.position = new Vector3(xPos, spawnY, 0);
obstacle.transform.localScale = scaleVector * 0.1f;
var data = obstacle.GetComponent<ObstacleData>();
data.initialX = xPos;
data.isLeftSide = isLeft;
obstacle.SetActive(true);
activeObstacles.Add(obstacle);
}
private void ReturnToPool(GameObject obstacle)
{
obstacle.SetActive(false);
for (int i = 0; i < obstaclePrefabs.Length; i++)
{
if (obstacle.name.Contains(obstaclePrefabs[i].name))
{
obstaclePools[i].Enqueue(obstacle);
break;
}
}
}
private GameObject GetObstacleFromPool(int typeIndex)
{
return obstaclePools[typeIndex].Count > 0 ? obstaclePools[typeIndex].Dequeue() : null;
}
}
장애물은 왼쪽과 오른쪽에 생성되는 장애물에 따라 도로 벽 쪽으로 붙어서 떨어지게 작업했다.
만들다 보니까 겹쳐서 생성될 때도 있길래 최소 x, y값의 간격을 정해서 생성되게 작업했다. 그렇게 만들었더니 간격은 마음에 드는데 뜸하게 만들어지는 것 같기도 하고 이건 수치 조절하면 될 것 같은데 그 적정한 선을 찾기가 쉽지가 않구만.
장애물이 연속해서 나오는 것도 별로길래 몇 개 같은 쪽으로 나오면 다시 만들어질 때는 다른 쪽으로 만들어지게 만들어 줬다.
주석처리를 많이 해두어서 코드를 보는 데는 문제가 없을 듯하다.
하지만 솔직하게 이 코드는 pseudo 3d game라 할 수 없다. 그래서 시간이 될 때 다시 도전해 보는 걸로 마무리 지었다.
일단 내가 만든 방법은 2d 배경 1장에 도로의 가운데 라인과, 장애물이 플레이어의 반대 방향(위에서 아래 방향)으로 이동하게 해서 플레이어는 가만히 있지만 이동하는 느낌을 만들어 봤다. 장애물 만드는 방법과 도로 라인 만드는 방법을 다르게 해 봤다. 사실 두 가지 다 각자 테스트 해보다가 각자 만들어졌지만 원리는 비슷할 것이다.
생각보다 많은 다양한 게임들의 스프라이트들을 다운로드할 수 있다. 만약 배경이 있다면 aseprite 프로그램을 사용해서 제거해 준다. 물론 코드로 뒷배경을 삭제시켜 주는 방법도 있다. 하지만 나는 이 프로그램을 돈 주고 샀기 때문에 뽕을 뽑아야 한다는 마음으로 작은 작업이라도 사용하는 편이다.
ai를 사용해서 나만의 배경을 만들어 볼까도 했지만 2시간 동안 하늘의 사이즈를 줄여주지 않는 ai에게 굴복하고 그만뒀다.(회원가입도 했는데... 하늘을 줄여주는 게 그렇게 싫었던 걸까. 그렇게 소량으로만 줄여주고 싶었던 것일까. 한글도 영어도 전부 먹히지 않았던 너란 ai. 배경 오브젝트들이 점점 한자들이 나오길래 중국인가 싶었지만 난 중국어는 못하는 걸.)
언덕도, 산도, 나무도, 하늘도 더이상 나의 계획대로 변경되지 않았던 나름 예뻤던 내가 요청한 배경. 도로 안에 가운데 라인도 빼달라고 빼달라고 그렇게 말했는데 완강했던 너의 모습. 잊지 못할 거야.
2. 유니티에서 몇 가지 작업을 해준다.
일단 우선적으로 2d project를 만들어 준다. 그리고 Obstacle Manager(장애물 관리 매니저) 오브젝트와, RoadLineManager(도로 라인 관리 매니저)를 만들어 준다. 프리팹들은 장애물 프리팹과, 라인 프리팹만 있으면 된다. UI Canvas를 만들어 주고 필요하다면 플레이어가 이동할 수 있는 버튼을 추가로 만들어 주면 된다. 나의 경우 버튼을 눌러 좌, 우로 이동할 수 있게 작업했다. 속도를 늘리는 버튼과 속도를 줄이는 버튼 또한 만들어 두었다.
3. 이제 스크립트를 짜보자.
우선 도로 라인이 내려오게 만들어 주는 RoadLineManager를 만들어 보자.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.Linq;
public class RoadLineManager : MonoBehaviour
{
[Header("Line Spawn Settings")]
public GameObject roadLinePrefab;
public int poolSize = 10;
public float spawnInterval = 1f;
[Header("Line Position Settings")]
public float startYPosition = 5.7f;
public float startXPosition = 0.15f;
public float endYPosition = -1f;
public float fixedLineSpacing = 1.8f;
[Header("Line Scale Settings")]
public float startScaleX = 0.05f;
public float startScaleY = 0.02f;
public float endScaleX = 0.4f;
public float endScaleY = 0.5f;
[Header("Movement Settings")]
public float initialMoveSpeed = 5f;
public float currentMoveSpeed;
public float maxMoveSpeed = 15f;
public float accelerationRate = 2f;
[Header("Line Settings")]
public int maxVisibleLines = 6;
private List<GameObject> linePool;
private float[] linePositions;
//private float[] spacings = new float[] { 1.0f, 1.2f, 1.5f, 1.8f, 2.2f, 2.7f };
private float[] spacings = new float[] { 0.8f, 1.2f, 1.8f, 2.5f, 3.3f, 4.2f };
private void Start()
{
linePool = new List<GameObject>();
linePositions = new float[maxVisibleLines];
InitializePool();
currentMoveSpeed = initialMoveSpeed;
UpdateLinePositions();
SpawnInitialLines();
}
private void InitializePool()
{
for (int i = 0; i < poolSize; i++)
{
GameObject line = Instantiate(roadLinePrefab);
line.SetActive(false);
linePool.Add(line);
}
}
private void UpdateLinePositions()
{
linePositions[0] = startYPosition;
for (int i = 1; i < maxVisibleLines; i++)
{
float spacing = fixedLineSpacing * spacings[i-1];
linePositions[i] = linePositions[i-1] - spacing;
}
}
private void SpawnInitialLines()
{
for (int i = 0; i < maxVisibleLines; i++)
{
SpawnLineAtPosition(linePositions[i]);
}
}
private void SpawnLineAtPosition(float yPosition)
{
GameObject line = GetInactiveLine();
if (line != null)
{
float t = (startYPosition - yPosition) / (startYPosition - endYPosition);
t = Mathf.Clamp01(t);
float scaleX = Mathf.Lerp(startScaleX, endScaleX, t);
float scaleY = Mathf.Lerp(startScaleY, endScaleY, t);
line.transform.position = new Vector3(startXPosition, yPosition, 0);
line.transform.localScale = new Vector3(scaleX, scaleY, 1);
line.SetActive(true);
}
}
private void Update()
{
UpdateLines();
}
private void UpdateLines()
{
int visibleCount = 0;
float highestY = float.MinValue;
foreach (GameObject line in linePool)
{
if (line.activeInHierarchy)
{
Vector3 pos = line.transform.position;
float t = (startYPosition - pos.y) / (startYPosition - endYPosition);
t = Mathf.Clamp01(t);
float scaleX = Mathf.Lerp(startScaleX, endScaleX, t);
float scaleY = Mathf.Lerp(startScaleY, endScaleY, t);
// 스케일이 클수록 더 빠르게 이동하도록 수정
float speedMultiplier = scaleY / startScaleY;
pos.y -= (currentMoveSpeed * speedMultiplier) * Time.deltaTime;
line.transform.localScale = new Vector3(scaleX, scaleY, 1);
line.transform.position = pos;
if (pos.y < endYPosition)
{
line.SetActive(false);
}
else
{
highestY = Mathf.Max(highestY, pos.y);
visibleCount++;
}
}
}
if (visibleCount < maxVisibleLines)
{
if (highestY == float.MinValue)
{
SpawnLineAtPosition(startYPosition);
}
else
{
int currentIndex = visibleCount - 1;
float spacing = fixedLineSpacing * spacings[currentIndex];
float newYPos = highestY + spacing;
if (newYPos <= startYPosition)
{
SpawnLineAtPosition(newYPos);
}
}
}
}
private GameObject GetInactiveLine()
{
return linePool.Find(line => !line.activeInHierarchy);
}
public void IncreaseSpeed()
{
currentMoveSpeed = Mathf.Min(currentMoveSpeed + accelerationRate, maxMoveSpeed);
}
public void DecreaseSpeed()
{
currentMoveSpeed = Mathf.Max(currentMoveSpeed - accelerationRate, initialMoveSpeed);
}
public void ResetSpeed()
{
currentMoveSpeed = initialMoveSpeed;
}
}
이 코드를 짤 때 몇 가지 골머리 써야 됐었던 부분이 있었다.
그중 가장 큰 게 라인이 프리팹으로 활성화가 될 때 아래로 내려오는 느낌으로 원근감 있게 내려오려면 처음 적용해 줬던 간격을 계속 유지하면 안 되는 문제가 있었다. 이 부분을 수정하기 위해 나에겐 여러 번의 시도가 필요했다. 그래서 UpdateLines() 함수는 정말 쉴 새 없이 수정됐었다. 그리고 마침내 해결된 방법이 바로 위에 적어둔 스케일의 사이즈에 따라 빠르게 이동시키는 것. 이 부분이 사실 그렇게 어려운 부분은 아니었다. 처음부터 수치를 디버깅해서 확인해 봤으면 문제가 되지 않았을 것이다. 변경시켜 주던 수치값이 제대로 늘어나지 않는 오류를 모르고 도대체 왜 내가 원했던 데로 나오지 않는 거지 하다가 시간을 두배로 썼다. 잊지 말자 디버깅.
버튼에 적용시켰던 speed up 버튼과 speed down 버튼에 사용할 함수도 여기 있는 IncreaseSpeed() 함수와 DecreaseSpeed() 함수를 연결해 주면 된다. 필요시 나머지 함수들도 연결시켜줘도 기능은 할 것이다.
나의 경우는 변수들을 많이 만들어 최대한 유니티에서 배경을 바꿨을 때도 사용할 수 있게 작업했다. 지금 보이는 것과 같이 저 배경은 free이기 때문에 가져온 스프라이트지 그렇게 내 마음에 들지 않기 때문일지도 모른다. 도로의 시작과 끝을 잡아줄 위치 변수들과, 라인의 스케일을 관리해 줄 변수들, 라인과 라인 사이의 간격을 정할 변수로 되어있다. 그리고 화면에 나올 라인의 개수 변수가 있는데 이건 간격과 도로의 총길이에 따라 나오지 않을 수도 있다. 프리팹은 오브젝트 풀링을 사용해서 최적화해 두었다.
장애물 매니저는 내일 다시 정리해 두겠다. 여기서는 역시나 오브젝트풀로 장애물을 관리하고, 도로의 좌, 우에 따라붙어서 원근감 있게 스케일이 커지도록 작업했다.
++ 나도 모르게 필요없는 파일인줄 알고 프로젝트 삭제시켜서 장애물 매니저는 그렇게 날라갔다...다시 작업해서 올리도록 하겠다.