가상 현실에서 끝없는 주자 게임을 만드는 방법(3부)
게시 됨: 2022-03-10그렇게 우리의 여정은 계속됩니다. 끝없는 러너 VR 게임을 만드는 방법에 대한 이 시리즈의 마지막 부분에서는 두 장치 간에 게임 상태를 동기화하여 멀티플레이어 게임을 만드는 데 한 걸음 더 다가갈 수 있는 방법을 보여 드리겠습니다. 클라이언트에서 클라이언트로의 통신에서 중개 서버를 처리하는 MirrorVR을 구체적으로 소개하겠습니다.
참고 : 이 게임은 VR 헤드셋을 사용하거나 사용하지 않고 재생할 수 있습니다. ergo-3.glitch.me에서 최종 제품의 데모를 볼 수 있습니다.
시작하려면 다음이 필요합니다.
- 인터넷 액세스(특히 glitch.com)
- 이 튜토리얼의 2부에서 완성된 글리치 프로젝트. https://glitch.com/edit/#!/ergo-2로 이동하여 "Remix to edit"를 클릭하여 2부 완성된 제품에서 시작할 수 있습니다.
- 가상 현실 헤드셋(옵션, 권장). (저는 개당 15달러에 제공되는 Google Cardboard를 사용합니다.)
1단계: 점수 표시
게임은 플레이어에게 장애물을 피하는 도전이 주어지는 최소한으로 기능합니다. 그러나 개체 충돌 외에 게임은 게임 진행 상황에 대해 플레이어에게 피드백을 제공하지 않습니다. 이 문제를 해결하기 위해 이 단계에서 점수 표시를 구현합니다. 점수는 사용자의 시야에 고정된 인터페이스와 달리 가상 현실 세계에 배치된 큰 텍스트 개체입니다.
가상 현실에서 일반적으로 사용자 인터페이스는 사용자의 머리에 고정되기보다 세계에 통합되는 것이 가장 좋습니다.
index.html 에 객체를 추가하는 것으로 시작하십시오. 다른 텍스트 요소에 재사용할 text
믹스인을 추가합니다.
<a-assets> ... <a-mixin text=" font:exo2bold; anchor:center; align:center;"></a-mixin> ... </a-assets>
다음으로 플레이어 바로 앞에 플랫폼에 text
요소를 추가합니다.
<!-- Score --> <a-text value="" mixin="text" height="40" width="40" position="0 1.2 -3" opacity="0.75"></a-text> <!-- Player --> ...
그러면 가상 현실 장면에 텍스트 엔터티가 추가됩니다. 해당 값이 비어 있도록 설정되어 있기 때문에 텍스트는 현재 표시되지 않습니다. 그러나 이제 JavaScript를 사용하여 텍스트 엔터티를 동적으로 채웁니다. asset/ergo.js 로 이동합니다. collisions
섹션 뒤에 score
섹션을 추가하고 여러 전역 변수를 정의합니다.
-
score
: 현재 게임 점수. -
countedTrees
: 점수에 포함된 모든 나무의 ID입니다. (충돌 테스트가 동일한 트리에 대해 여러 번 트리거될 수 있기 때문입니다.) -
scoreDisplay
: 가상 현실 세계의 텍스트 개체에 해당하는 DOM 개체에 대한 참조입니다.
/********* * SCORE * *********/ var score; var countedTrees; var scoreDisplay;
다음으로 전역 변수를 초기화하는 설정 함수를 정의합니다. 같은 맥락에서 teardown
기능을 정의합니다.
... var scoreDisplay; function setupScore() { score = 0; countedTrees = new Set(); scoreDisplay = document.getElementById('score'); } function teardownScore() { scoreDisplay.setAttribute('value', ''); }
Game
섹션에서 점수 설정 및 분해를 포함하도록 gameOver
, startGame
및 window.onload
를 업데이트합니다.
/******** * GAME * ********/ function gameOver() { ... teardownScore(); } function startGame() { ... setupScore(); addTreesRandomlyLoop(); } window.onload = function() { setupScore(); ... }
특정 나무에 대한 점수를 증가시키는 함수를 정의하십시오. 이 함수는 countedTrees
를 확인하여 트리가 이중으로 계산되지 않았는지 확인합니다.
function addScoreForTree(tree_id) { if (countedTrees.has(tree_id)) return; score += 1; countedTrees.add(tree_id); }
또한 전역 변수를 사용하여 점수 표시를 업데이트하는 유틸리티를 추가합니다.
function updateScoreDisplay() { scoreDisplay.setAttribute('value', score); }
장애물이 플레이어를 지나갈 때마다 이 점수 증가 기능을 호출하려면 충돌 테스트를 적절하게 업데이트하십시오. 여전히 assets/ergo.js
에서 collisions
섹션으로 이동합니다. 다음 확인을 추가하고 업데이트하십시오.
AFRAME.registerComponent('player', { tick: function() { document.querySelectorAll('.tree').forEach(function(tree) { ... if (position.z > POSITION_Z_LINE_END) { addScoreForTree(tree_id); updateScoreDisplay(); } }) } })
마지막으로, 게임이 시작되자마자 점수 표시를 업데이트하십시오. Game
섹션으로 이동하여 updateScoreDisplay();
를 추가합니다. 게임을 startGame
:
function startGame() { ... setupScore(); updateScoreDisplay(); ... }
asset/ergo.js 및 index.html 이 해당 소스 코드 파일과 일치하는지 확인합니다. 그런 다음 미리보기로 이동합니다. 다음이 표시되어야 합니다.
이것으로 점수 표시를 마칩니다. 다음으로 플레이어가 원하는 대로 게임을 재생할 수 있도록 적절한 시작 및 게임 오버 메뉴를 추가합니다.
2단계: 시작 메뉴 추가
이제 사용자가 진행 상황을 추적할 수 있으므로 마무리 작업을 추가하여 게임 경험을 완료합니다. 이 단계에서는 사용자가 게임을 시작하고 다시 시작할 수 있도록 시작 메뉴와 게임 종료 메뉴를 추가합니다.
플레이어가 게임을 시작하기 위해 "시작" 버튼을 클릭 하는 시작 메뉴부터 시작하겠습니다. 이 단계의 후반부에는 "다시 시작" 버튼이 있는 게임 오버 메뉴를 추가합니다.
편집기에서 index.html 로 이동합니다. 그런 다음 Mixins
섹션을 찾으십시오. 여기에 특히 큰 텍스트의 스타일을 정의하는 title
mixin을 추가합니다. 기존과 동일한 글꼴을 사용하고, 텍스트를 가운데에 정렬하고, 텍스트 유형에 맞는 크기를 정의합니다. (아래 anchor
는 텍스트 개체가 해당 위치에 고정되는 위치입니다.)
<a-assets> ... <a-mixin text=" font:exo2bold; height:40; width:40; opacity:0.75; anchor:center; align:center;"></a-mixin> </a-assets>
다음으로, 보조 제목에 대한 두 번째 믹스인을 추가합니다. 이 텍스트는 약간 작지만 그 외에는 제목과 동일합니다.
<a-assets> ... <a-mixin text=" font:exo2bold; height:10; width:10; opacity:0.75; anchor:center; align:center;"></a-mixin> </a-assets>
세 번째이자 마지막 믹스인의 경우 보조 제목보다 훨씬 작은 설명 텍스트의 속성을 정의합니다.
<a-assets> ... <a-mixin text=" font:exo2bold; height:5; width:5; opacity:0.75; anchor:center; align:center;"></a-mixin> </a-assets>
모든 텍스트 스타일이 정의되면 이제 세계 내 텍스트 개체를 정의합니다. 시작 메뉴에 대한 빈 컨테이너와 함께 Score
섹션 아래에 새 Menus
섹션을 추가합니다.
<!-- Score --> ... <!-- Menus --> <a-entity> <a-entity position="0 1.1 -3"> </a-entity> </a-entity>
시작 메뉴 컨테이너 내에서 제목과 제목이 아닌 모든 텍스트에 대한 컨테이너를 정의합니다.
... <a-entity ...> <a-entity position="0 1 0"> </a-entity> <a-text value="ERGO" mixin="title"></a-text> </a-entity> </a-entity>
제목이 아닌 텍스트에 대한 컨테이너 내부에 게임 플레이 지침을 추가합니다.
<a-entity...> <a-text value="Turn left and right to move your player, and avoid the trees!" mixin="copy"></a-text> </a-entity>
시작 메뉴를 완료하려면 "시작"이라는 버튼을 추가하세요.
<a-entity...> ... <a-text value="Start" position="0 0.75 0" mixin="heading"></a-text> <a-box position="0 0.65 -0.05" width="1.5" height="0.6" depth="0.1"></a-box> </a-entity>
시작 메뉴 HTML 코드가 다음과 일치하는지 다시 확인하십시오.
<!-- Menus --> <a-entity> <a-entity position="0 1.1 -3"> <a-entity position="0 1 0"> <a-text value="Turn left and right to move your player, and avoid the trees!" mixin="copy"></a-text> <a-text value="Start" position="0 0.75 0" mixin="heading"></a-text> <a-box position="0 0.65 -0.05" width="1.5" height="0.6" depth="0.1"></a-box> </a-entity> <a-text value="ERGO" mixin="title"></a-text> </a-entity> </a-entity>
미리 보기로 이동하면 다음 시작 메뉴가 표시됩니다.
Menus
섹션( start
메뉴 바로 아래)에서 동일한 믹스인을 사용하여 game-over
메뉴를 추가합니다.
<!-- Menus --> <a-entity> ... <a-entity position="0 1.1 -3"> <a-text value="?" mixin="heading" position="0 1.7 0"></a-text> <a-text value="Score" mixin="copy" position="0 1.2 0"></a-text> <a-entity> <a-text value="Restart" mixin="heading" position="0 0.7 0"></a-text> <a-box position="0 0.6 -0.05" width="2" height="0.6" depth="0.1"></a-box> </a-entity> <a-text value="Game Over" mixin="title"></a-text> </a-entity> </a-entity>
JavaScript 파일 asset/ergo.js 로 이동합니다. Game
섹션 전에 새 Menus
섹션을 만듭니다. 또한 세 가지 빈 함수인 setupAllMenus
, hideAllMenus
및 showGameOverMenu
를 정의합니다.
/******** * MENU * ********/ function setupAllMenus() { } function hideAllMenus() { } function showGameOverMenu() { } /******** * GAME * ********/
다음으로 세 곳에서 Game
섹션을 업데이트합니다. gameOver
에서 Game Over 메뉴를 표시합니다.
function gameOver() { ... showGameOverMenu(); } ``` In `startGame`, hide all menus: ``` function startGame() { ... hideAllMenus(); }
다음으로, window.onload
에서 startGame 에 대한 직접 호출을 제거하고 대신 startGame
를 호출 setupAllMenus
. 다음과 일치하도록 리스너를 업데이트합니다.
window.onload = function() { setupAllMenus(); setupScore(); setupTrees(); }
Menu
섹션으로 다시 이동합니다. 다양한 DOM 객체에 대한 참조 저장:
/******** * MENU * ********/ var menuStart; var menuGameOver; var menuContainer; var isGameRunning = false; var startButton; var restartButton; function setupAllMenus() { menuStart = document.getElementById('start-menu'); menuGameOver = document.getElementById('game-over'); menuContainer = document.getElementById('menu-container'); startButton = document.getElementById('start-button'); restartButton = document.getElementById('restart-button'); }
다음으로 "시작" 및 "다시 시작" 버튼을 startGame
에 바인딩합니다.
function setupAllMenus() { ... startButton.addEventListener('click', startGame); restartButton.addEventListener('click', startGame); }
setupAllMenus
를 정의하고 showStartMenu
에서 호출합니다.
function setupAllMenus() { ... showStartMenu(); } function hideAllMenus() { } function showGameOverMenu() { } function showStartMenu() { }
세 개의 빈 함수를 채우려면 몇 가지 도우미 함수가 필요합니다. A-Frame VR 엔터티를 나타내는 DOM 요소를 허용하고 이를 표시하거나 숨기는 다음 두 함수를 정의합니다. showAllMenus
위에 두 함수를 모두 정의합니다.
... var restartButton; function hideEntity(el) { el.setAttribute('visible', false); } function showEntity(el) { el.setAttribute('visible', true); } function showAllMenus() { ...
먼저 hideAllMenus
를 채웁니다. 시야에서 개체를 제거한 다음 두 메뉴에 대한 클릭 리스너를 제거합니다.
function hideAllMenus() { hideEntity(menuContainer); startButton.classList.remove('clickable'); restartButton.classList.remove('clickable'); }
둘째, showGameOverMenu
를 채웁니다. 여기에서 두 메뉴에 대한 컨테이너와 게임 오버 메뉴 및 '다시 시작' 버튼의 클릭 리스너를 복원합니다. 그러나 '시작' 버튼의 클릭 리스너를 제거하고 '시작' 메뉴를 숨깁니다.
function showGameOverMenu() { showEntity(menuContainer); hideEntity(menuStart); showEntity(menuGameOver); startButton.classList.remove('clickable'); restartButton.classList.add('clickable'); }
셋째, showStartMenu
를 채웁니다. 여기에서 showGameOverMenu
가 적용한 모든 변경 사항을 되돌립니다.
function showStartMenu() { showEntity(menuContainer); hideEntity(menuGameOver); showEntity(menuStart); startButton.classList.add('clickable'); restartButton.classList.remove('clickable'); }
코드가 해당 소스 파일과 일치하는지 다시 확인하십시오. 그런 다음 미리 보기로 이동하면 다음 동작을 관찰할 수 있습니다.
이것으로 시작 및 게임 오버 메뉴를 마칩니다.
축하합니다! 이제 적절한 시작과 적절한 종료가 있는 완전히 작동하는 게임이 있습니다. 그러나 이 튜토리얼에서 한 단계 더 남았습니다. 다른 플레이어 장치 간에 게임 상태를 동기화해야 합니다. 이를 통해 우리는 멀티플레이어 게임에 한 걸음 더 다가갈 것입니다.
3단계: 게임 상태를 MirrorVR과 동기화
이전 자습서에서 서버와 클라이언트 간의 단방향 통신을 용이하게 하기 위해 소켓을 통해 실시간 정보를 보내는 방법을 배웠습니다. 이 단계에서는 클라이언트 대 클라이언트 통신에서 중재 서버를 처리하는 해당 자습서의 완전한 제품인 MirrorVR을 기반으로 빌드합니다.
참고 : 여기에서 MirrorVR에 대해 자세히 알아볼 수 있습니다.
index.html 로 이동합니다. 여기에서 MirrorVR을 로드하고 카메라에 구성 요소를 추가하여 해당되는 경우 모바일 장치의 뷰를 미러링해야 함을 나타냅니다. socket.io 종속성과 MirrorVR 0.2.3을 가져옵니다.
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/2.1.1/socket.io.js"></script> <script src="https://cdn.jsdelivr.net/gh/alvinwan/[email protected]/dist/mirrorvr.min.js"></script>
다음으로 카메라에 구성 요소인 camera-listener
를 추가합니다.
<a-camera camera-listener ...>
asset/ergo.js 로 이동합니다. 이 단계에서 모바일 장치는 명령을 보내고 데스크톱 장치는 모바일 장치만 미러링합니다.
이를 용이하게 하려면 데스크탑과 모바일 장치를 구별하는 유틸리티가 필요합니다. 파일 끝에 shuffle
다음에 mobileCheck
함수를 추가합니다.
/** * Checks for mobile and tablet platforms. */ function mobileCheck() { var check = false; (function(a){if(/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino|android|ipad|playbook|silk/i.test(a)||/1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[aw])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(a.substr(0,4))) check = true;})(navigator.userAgent||navigator.vendor||window.opera); return check; };
먼저 게임 시작을 동기화합니다. 게임 섹션의 mirrorVR
startGame
을 추가합니다.
function startGame() { ... if (mobileCheck()) { mirrorVR.notify('startGame', {}) } }
모바일 클라이언트는 이제 게임 시작에 대한 알림을 보냅니다. 이제 데스크탑의 응답을 구현합니다.
창 로드 리스너에서 setupMirrorVR
함수를 호출합니다.
window.onload = function() { ... setupMirrorVR(); }
MirrorVR 설정을 위해 Game
섹션 위에 새 섹션을 정의합니다.
/************ * MirrorVR * ************/ function setupMirrorVR() { mirrorVR.init(); }
다음으로 mirrorVR의 초기화 함수에 키워드 인수를 추가합니다. 특히 게임 시작 알림에 대한 핸들러를 정의합니다. 방 ID를 추가로 지정합니다. 이렇게 하면 애플리케이션을 로드하는 모든 사람이 즉시 동기화됩니다.
function setupMirrorVR() { mirrorVR.init({ roomId: 'ergo', state: { startGame: { onNotify: function(data) { hideAllMenus(); setupScore(); updateScoreDisplay(); } }, } }); }
Game Over 에 대해 동일한 동기화 프로세스를 반복합니다. Game
섹션의 gameOver
에서 모바일 장치에 대한 확인을 추가하고 그에 따라 알림을 보냅니다.
function gameOver() { ... if (mobileCheck()) { mirrorVR.notify('gameOver', {}); } }
MirrorVR
섹션으로 이동하여 gameOver
리스너로 키워드 인수를 업데이트하십시오.
function setupMirrorVR() { mirrorVR.init({ state: { startGame: {... }, gameOver: { onNotify: function(data) { gameOver(); } }, } }) }
다음으로 트리 추가에 대해 동일한 동기화 프로세스를 반복합니다. Trees
섹션에서 addTreesRandomly
로 이동합니다. 어떤 레인이 새 나무를 받는지 추적하십시오. 그런 다음 return
지시문 바로 앞에 다음과 같이 알림을 보냅니다.
function addTreesRandomly(...) { ... var numberOfTreesAdded ... var position_indices = []; trees.forEach(function (tree) { if (...) { ... position_indices.push(tree.position_index); } }); if (mobileCheck()) { mirrorVR.notify('addTrees', position_indices); } return ... }
MirrorVR
섹션으로 이동하고 나무에 대한 새 리스너로 mirrorVR.init
에 대한 키워드 인수를 업데이트합니다.
function setupMirrorVR() { mirrorVR.init({ state: { ... gameOver: {... }, addTrees: { onNotify: function(position_indices) { position_indices.forEach(addTreeTo) } }, } }) }
마지막으로 게임 점수를 동기화합니다. Score
섹션의 updateScoreDisplay
에서 해당되는 경우 알림을 보냅니다.
function updateScoreDisplay() { ... if (mobileCheck()) { mirrorVR.notify('score', score); } }
점수 변경에 대한 리스너를 사용하여 마지막으로 mirrorVR
초기화를 업데이트합니다.
function setupMirrorVR() { mirrorVR.init({ state: { addTrees: { }, score: { onNotify: function(data) { score = data; updateScoreDisplay(); } } } }); }
코드가 이 단계에 적합한 소스 코드 파일과 일치하는지 다시 확인하십시오. 그런 다음 데스크탑 미리보기로 이동합니다. 또한 모바일 장치에서 동일한 URL을 엽니다. 모바일 장치에서 웹 페이지를 로드하는 즉시 데스크탑에서 모바일 장치의 게임 미러링을 시작해야 합니다.
여기 데모가 있습니다. 데스크탑 커서가 움직이지 않는다는 점에 유의하십시오. 이는 모바일 장치가 데스크탑 미리보기를 제어하고 있음을 나타냅니다.
이것으로 mirrorVR로 증강 프로젝트를 마칩니다.
이 세 번째 단계에서는 몇 가지 기본 게임 상태 동기화 단계를 도입했습니다. 이것을 더 강력하게 만들기 위해 더 많은 온전성 검사와 더 많은 동기화 지점을 추가할 수 있습니다.
결론
이 자습서에서는 끝없는 러너 게임에 마무리 작업을 추가하고 데스크톱 클라이언트와 모바일 클라이언트의 실시간 동기화를 구현하여 모바일 장치의 화면을 데스크톱에서 효과적으로 미러링했습니다. 이것으로 가상 현실에서 끝없는 러너 게임을 만드는 방법에 대한 시리즈를 마칩니다. A-Frame VR 기술과 함께 3D 모델링, 클라이언트 간 통신 및 기타 널리 적용 가능한 개념을 배웠습니다.
다음 단계에는 다음이 포함될 수 있습니다.
- 고급 모델링
이는 잠재적으로 타사 소프트웨어에서 생성되어 가져온 보다 사실적인 3D 모델을 의미합니다. 예를 들어 (MagicaVoxel)은 복셀 아트를 간단하게 만들고 (Blender)는 완전한 3D 모델링 솔루션입니다. - 더 많은 복잡성
실시간 전략 게임과 같은 더 복잡한 게임은 효율성을 높이기 위해 타사 엔진을 활용할 수 있습니다. 이는 컴파일된(Unity3d) 게임을 게시하는 대신 A-Frame 및 webVR을 완전히 건너뛰는 것을 의미할 수 있습니다.
다른 방법으로는 멀티플레이어 지원과 풍부한 그래픽이 있습니다. 이 자습서 시리즈를 마치면 이제 추가로 탐색할 프레임워크가 생겼습니다.