본문 바로가기
Unity/경주안전체험관

[경주안전체험관] 버스 안전 사고 발생 개발일지

by 으얏 2023. 1. 6.

먼저 외부용 기획안입니다.

클라이언트분의 요청에 의해 개발과정에서 스크립트 수정이 많았지만 그 당시엔 기록하지 못해서 완성된 스크립트를 부분 리뷰하는 형식으로 하겠습니다.

어려웠던 점과 해결방법:

1. 24개의 VR Oculus 기기를 서버에서 신호를 주고 동시적으로 진행해야 합니다.

 처음에는 UDP 통신으로 스크립트를 작성하였지만, UDP를 이용하면 가끔씩 몇몇 기기에는 신호가 가지 않아서 TCP/IP로 변경하였습니다.

내부망을 이용하기 때문에 TCP/IP 통신 중 가장 쉽게 접할 수 있는 포톤은 이용할 수 없어서, 직접 스크립트를 작성하여 TCP/IP 통신을 하였습니다.

 

2. 유치원생부터 고등학생까지 다양한 연령대의 학생들이 체험을 하게 되는데 VR 기기 특성문제가 있었습니다.

 기기 앞 인식부분이 인식이 되지않으면 검은화면(대기모드)가 되어버리는데 어린 학생들은 착용의 답답함이나 두려움이 있어 착용을 반복하여 진행이 다르게 되는 경우가 있었습니다.

VR 기기의 연령제한이 있는 이유 중 하나라고 생각했고 대기화면 자체를 없애는 방법으로 해결은 했지만 배터리 소모가 크다는 단점이 있었습니다. 


서버 스크립트 입니다.

스크립트 요점 :

1. 소켓통신을 위해서 using System.Net과 Sockets를 이용했습니다.

2. 체험관은 많은 사람들이 오가기 때문에 매번 파일을 실행시키는 것보단 '다시하기' 와 같은 방법이 더 유용하기 때문에 GameManager를 통해 씬을 관리 하였습니다.

3. 자신의 iptime 혹은 연결된 인터넷의 관리자 홈페이지에서 통신가능한 port를 열어주었습니다.

4. ipconfig를 이용해 서버가 될 컴퓨터의 ip를 입력해주었습니다.

- 이용하는 사람이 VR이 아닌 컴퓨터나 모바일이면 ip나 port를 강제 할당하지않고 Input Field에 입력하는 방식으로 하여도 충분합니다. 입력하는 방식이 혹여 서버의 ip가 바뀌어도 쉽게 접근이 가능하기에 VR이 아닌 방법으로 작업을 하신다면 추천하는 방법입니다.

5. string 값인 "4" 를 BroadCast하면 클라이언트가 시작 할 수 있게 스크립트를 작성하였습니다.  

using System.Collections;
using System.Collections.Generic;
using System.Net; // 소켓통신을 위함 입니다.
using System.Net.Sockets; // 소켓통신을 위함 입니다.
using UnityEngine;
using UnityEngine.UI;
using System;
using System.IO;
using TMPro;
using UnityEngine.SceneManagement;

public class Server : MonoBehaviour
{
    public static Server Instance;

    private void Awake()
    {
        Instance = this;
        DontDestroyOnLoad(gameObject);
    }

    public TMP_InputField PortInput;

    List<ServerClient> clients;
    List<ServerClient> disconnectList;

    TcpListener server;
    bool serverStarted;

    public static List<bool> isStartList = new List<bool>();

	public void ServerCreate()
	{
        clients = new List<ServerClient>();
        disconnectList = new List<ServerClient>();
        
        try
        {
            // 자기 IPv4 주소를 server에 입력   
            // Client ConnectToServer 쪽에서도 자기 ip로 입력해서 들어오게 하기       
            
            int port = PortInput.text == "" ? 7777 : int.Parse(PortInput.text);

            // 집에서 할 시
            //server = new TcpListener(IPAddress.Any, port);

            // 빌드시
            IPAddress ip = IPAddress.Parse("192.168.0.13");
            server = new TcpListener(ip, port);
            server.Start();
            
            StartListening();
            serverStarted = true;
            Chat.instance.ShowMessage($"서버가 {port}에서 시작되었습니다.");
        }
        catch (Exception e) 
        {
            Chat.instance.ShowMessage($"Socket error: {e.Message}");
        }
	}

	void Update()
	{
        if (!serverStarted) return;

        //clients.Count
        foreach (ServerClient c in clients) 
        {
            // 클라이언트가 여전히 연결되있나?
            if (!IsConnected(c.tcp))
            {
                c.tcp.Close();
                disconnectList.Add(c);
                continue;
            }
            // 클라이언트로부터 체크 메시지를 받는다
            else 
            {
                NetworkStream s = c.tcp.GetStream();
                if (s.DataAvailable) 
                {
                    string data = new StreamReader(s, true).ReadLine();
                    if (data != null)
                        OnIncomingData(c, data);
                }
            }
        }

		for (int i = 0; i < disconnectList.Count - 1; i++)
		{
            Broadcast($"{disconnectList[i].clientName} 연결이 끊어졌습니다", clients);

            clients.Remove(disconnectList[i]);
            disconnectList.RemoveAt(i);
		}
	}	

	bool IsConnected(TcpClient c)
	{
        try
        {
            if (c != null && c.Client != null && c.Client.Connected)
            {
                if (c.Client.Poll(0, SelectMode.SelectRead))
                    return !(c.Client.Receive(new byte[1], SocketFlags.Peek) == 0);

                return true;
            }
            else
                return false;
        }
        catch 
        {
            return false;
        }
	}

	void StartListening()
	{
        server.BeginAcceptTcpClient(AcceptTcpClient, server);
	}

    void AcceptTcpClient(IAsyncResult ar) 
    {
        TcpListener listener = (TcpListener)ar.AsyncState;
        clients.Add(new ServerClient(listener.EndAcceptTcpClient(ar)));
        StartListening();

        // 메시지를 연결된 모두에게 보냄
        Broadcast("%NAME", new List<ServerClient>() { clients[clients.Count - 1] });
    }


    void OnIncomingData(ServerClient c, string data)
    {
        if (data.Contains("&NAME")) 
        {
            c.clientName = data.Split('|')[1];
            Broadcast($"{c.clientName}이 연결되었습니다", clients);

            isStartList.Add(false);

            return;
        }

        Broadcast($"{c.clientName} : {data}", clients);
    }

    void Broadcast(string data, List<ServerClient> cl) 
    {
        foreach (var c in cl) 
        {
            try 
            {
                StreamWriter writer = new StreamWriter(c.tcp.GetStream());
                writer.WriteLine(data);
                writer.Flush();
            }
            catch (Exception e) 
            {
                Chat.instance.ShowMessage($"쓰기 에러 : {e.Message}를 클라이언트에게 {c.clientName}");
            }
        }
    }
    public void OnStartButton()
    {
        Debug.Log("Must Excuted");
        Broadcast("4",clients);
    }

    public void OnApplicationQuit()
    {
        Debug.Log("ServerClose");
        server.Stop();
    }
    
    public void BusReStart()
    {        
        SceneManager.LoadScene(SceneManager.GetActiveScene().name);
    }
}

public class ServerClient
{
    public TcpClient tcp;
    public string clientName;

    public ServerClient(TcpClient clientSocket) 
    {
        clientName = "체험자";
        tcp = clientSocket;
    }
}

 

클라이언트 스크립트 입니다.

스크립트 요점:

1. Server와 같은 이유로 GameManager를 통해 씬 관리를 하였습니다.

2. Server에서 시작버튼을 누르면 Client가 연결이 되어있어야 시작이 되기 때문에 Start 부분에서 코루틴으로 1초마다  자동연결을 받도록 설정했습니다. 

- 너무 낮은 시간단위로 자동연결을 받게 된다면 인게임상에서 랙이 걸리기 때문에 1초 정도로 수정하였습니다.

- 기존에는 VR 컨트롤러를 이용해서 직접 Canvas에 있는 준비하기 버튼을 누르면서 체험하게 하였으나, 다양한 연령층의 이유로 자동연결이 되게 바뀌었습니다.

3. OnComingData에서 Chat 부분의 데이터를 받아와서 사람들이 준비가 됐는지의 여부를 체크하였습니다.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using System.Net.Sockets;
using System.IO;
using System;
using TMPro;
using UnityEngine.SceneManagement;

public class Client : MonoBehaviour
{
	public static Client Instance;

    private void Awake()
    {
        Instance = this;
        DontDestroyOnLoad(gameObject);
    }

    public TMP_InputField IPInput, PortInput, NickInput;
	string clientName;

    bool socketReady;
    TcpClient socket;
    NetworkStream stream;
	StreamWriter writer;
    StreamReader reader;

    private void Start()
    {        
        if (autoConnect == null)
        {
            autoConnect = StartCoroutine(AutoConnect());
        }
    }

    private Coroutine autoConnect;
	private IEnumerator AutoConnect()
	{
		WaitForSeconds waitForSeconds = new WaitForSeconds(1f);
		while (true)
		{
			if (socketReady)
			{
				yield return waitForSeconds;
				Debug.Log("ScoketReady");
				continue;
			}
			else
			{
				//string ip = IPInput.text == "" ? "127.0.0.1" : IPInput.text;

				// 빌드용
				string ip = "192.168.0.13";				

				//int port = PortInput.text == "" ? 7777 : int.Parse(PortInput.text);
				int port = 7777;

				// 소켓 생성입니다.
				try
				{
					socket = new TcpClient(ip, port);
					stream = socket.GetStream();
					writer = new StreamWriter(stream);
					reader = new StreamReader(stream);
					socketReady = true;
				}
				catch (Exception e)
				{
					Chat.instance.ShowMessage($"소켓에러 : {e.Message}");
				}
			}
			yield return waitForSeconds;
		}
	}

	void Update()
	{
		if (socketReady && stream.DataAvailable) 
		{
			string data = reader.ReadLine();
			if (data != null)
				OnIncomingData(data);
		}
	}

	void OnIncomingData(string data)
	{
		if (data == "%NAME") 
		{			
			clientName = "체험자" + UnityEngine.Random.Range(1, 25);
			Send($"&NAME|{clientName}");
			return;
		}

		Chat.instance.ShowMessage(data);
	}

	void Send(string data)
	{
		if (!socketReady) return;

		writer.WriteLine(data);
		writer.Flush();
	}

	public void OnSendButton(InputField SendInput) 
	{
#if (UNITY_EDITOR || UNITY_STANDALONE)
		if (!Input.GetButtonDown("Submit")) return;
		SendInput.ActivateInputField();
#endif
		if (SendInput.text.Trim() == "") return;

		string message = SendInput.text;
		SendInput.text = "";
		Send(message);
	}

	public void OnApplicationQuit()
	{
		Debug.Log("ClientClose");
		CloseSocket();
	}

	public void ReBusClient()
	{		
		SceneManager.LoadScene(SceneManager.GetActiveScene().name);
	}

	public void CloseSocket()
	{
		if (!socketReady) return;

		writer.Close();
		reader.Close();
		socket.Close();
		socketReady = false;

		Debug.Log("closesocket");
	}
}

 

Chat 스크립트 입니다.  ( 부분 공개 )

스크립트 요점:

1. 채팅창에 특정 데이터를 받으면 실행이 되게 하였습니다. (string 4)

2. 시작 버튼을 한번 누를시 화면 재조정, 한번 더 누를시 시작이 될 수 있게 하였습니다.

- 체험관들의 체험통솔자분들이 각기 다른 학교 선생님분들이기 때문에 어느 상황에서든 동일하게 시작 할 수 있게 버튼 한번 클릭시 리셋 및 화면 재조정, 다시 한번 클릭시 프로그램이 시작 될 수 있게 하였습니다. 

3. 위 스크립트와 동일하게 재시작시 파괴되지 않고 관리될 수 있게 하였습니다.

public void ShowMessage(string data)
	{
		ChatText.text += ChatText.text == "" ? data : "\n" + data;
		
		Fit(ChatText.GetComponent<RectTransform>());
		Fit(ChatContent);

        temp = data;

		int kkk;
        
		if (int.TryParse(data, out kkk))
		{
			if (kkk == 1)
            {
				Server.isStartList[count] = true;

				count++;
            }
        }

        if (int.TryParse(data, out kkk))
        {
            if (kkk == 4)
            {
                //GameManager.Instance.startBtn = true;
                Debug.Log("kkk");
                if (GameManager.Instance.gameState == GameState.Awake)
                {
                    GameManager.Instance.gameState = GameState.Ready;
                    PlayerController playerController = FindObjectOfType<PlayerController>();
                    playerController.ResetPosition();
                }
                else if (GameManager.Instance.gameState == GameState.Ready)
                {
                    GameManager.Instance.startBtn = true;
                    GameManager.Instance.gameState = GameState.Start;
                }
                else
                {
                    SceneManager.LoadScene("[01]Scene_Road");
                    GameManager.Instance.gameState = GameState.Ready;
                }
            }
        }
    }

 

GameManager 스크립트 입니다. ( 부분 공개 )

스크립트 요점 :

1. enum으로 게임상태를 Awake, Ready, Start로 두었습니다.

- Awake에서는 화면 조정을 Ready는 준비를 Reday 상태가 되면 Start가 되게 하였습니다.

2. 인스턴스가 없는 경우에 접근하려면 인스턴스를 할당할 수 있게 해주었습니다.

3. 혹시 여러 인스턴스가 있는경우에는 새로 생기는 인스턴스를 삭제하게 하였습니다.

4. 오브젝트, 사운드 등이 파괴되지않게 설정하였습니다.

5. 필요한 사운드나 오브젝트 등을 코루틴으로 관리하였습니다.

6. 씬로드 관리 함수를 만들어서 재시작시 사운드와 화면 재조정을 하였습니다.

public enum GameState { Awake, Ready, Start }

public class GameManager : MonoBehaviour
{
	private static GameManager startInstance;
    public bool startBtn;
    public GameState gameState = GameState.Awake;
    
    public static GameManager Instance
    {
        get
        {
            // 인스턴스가 없는 경우에 접근하려 하면 인스턴스를 할당해준다.
            if (!startInstance)
            {
                startInstance = FindObjectOfType(typeof(GameManager)) as GameManager;

                if (startInstance == null)
                    Debug.Log("single");
            }
            return startInstance;
        }
    }

    // 추가
    private void Awake()
    {
        if (startInstance == null)
        {
            startInstance = this;
        }
        // 인스턴스가 존재하는 경우 새로생기는 인스턴스를 삭제한다.
        else if (startInstance != this)
        {
            Destroy(gameObject);
        }
        // 아래의 함수를 사용하여 씬이 전환되더라도 선언되었던 인스턴스가 파괴되지 않는다.
        DontDestroyOnLoad(gameObject);
    }

    private void Start()
    {
        SceneManager.sceneLoaded += OnSceneLoaded;    
    }
    
    // 생략하였습니다.
    
    private void OnSceneLoaded(Scene scene, LoadSceneMode mode)
    {
        allSound = FindObjectsOfType(typeof(AudioSource)) as AudioSource[];
        foreach (AudioSource source in allSound)
        {
            source.Stop();
        }

        can1.gameObject.SetActive(true);
        CanvasAlive();
        StopCoroutine("Info1Audio");
        StopCoroutine("CubeDestroy");

        PlayerController playerController = FindObjectOfType<PlayerController>();
        playerController.ResetPosition();
    }
}

 

PlayerController 스크립트 입니다.

스크립트 요점 :

1. 게임 재시작시 화면 재조정을 위함입니다.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class PlayerController : MonoBehaviour
{
    [SerializeField] private Transform resetTransform;
    [SerializeField] private GameObject player;
    [SerializeField] private Camera playerHead;

    public void ResetPosition()
    {
        var rotationAngleY = resetTransform.transform.rotation.eulerAngles.y - playerHead.transform.rotation.eulerAngles.y;
        player.transform.Rotate(0, rotationAngleY, 0);

        var distanceDiff = resetTransform.position - playerHead.transform.position;

        player.transform.position += distanceDiff;
    }
}

 

각종 안내음성은 구글 번역기 TTS 부분 사운드를 추출해서 적용시켰습니다.

걸어다니는 시민, 자동차는 에셋스토어 유료 Traffic System을 활용하였습니다.

다른 부분의 스크립트는 공개가 허락된다면 리뷰하도록 하겠습니다.

댓글