처음부터 실시간 다중 사용자 게임을 만드는 방법
게시 됨: 2022-03-10팬데믹이 지속되면서 내가 함께 일하는 갑자기 멀리 떨어진 팀은 점점 더 축구공이 부족해졌습니다. 멀리 떨어진 곳에서 어떻게 축구를 할까 고민했지만, 단순히 화면에서 축구의 규칙을 재구성하는 것만으로는 그다지 재미가 없을 것이라는 점은 분명했습니다.
재미있는 것은 장난감 자동차를 사용하여 공을 차는 것입니다. 두 살짜리 아이와 놀면서 깨달았습니다. 같은 날 밤 나는 Autowuzzler 가 될 게임의 첫 번째 프로토타입을 만들기 시작했습니다.
아이디어는 간단합니다 . 플레이어는 축구 테이블과 유사한 하향식 경기장에서 가상 장난감 자동차를 조종합니다. 먼저 10골을 넣는 팀이 승리합니다.
물론 자동차를 사용하여 축구를 한다는 아이디어가 독특한 것은 아니지만 두 가지 주요 아이디어가 Autowuzzler 를 구분해야 합니다. 실제 축구 테이블에서 플레이하는 모양과 느낌의 일부를 재구성하고 싶었고 그것이 맞는지 확인하고 싶었습니다. 친구나 팀원을 빠른 캐주얼 게임에 초대하기가 최대한 쉽습니다.
이 기사에서는 내가 선택한 도구와 프레임워크 인 Autowuzzler 생성 이면의 프로세스를 설명하고 내가 배운 몇 가지 구현 세부 정보와 교훈을 공유합니다.
첫 번째 작동(끔찍) 프로토타입
첫 번째 프로토타입은 오픈 소스 게임 엔진 Phaser.js를 사용하여 제작되었습니다. 대부분 포함된 물리 엔진용이고 제가 이미 약간의 경험이 있었기 때문입니다. 게임 단계는 Next.js 응용 프로그램에 포함되었습니다. 다시 한 번 저는 Next.js에 대해 확실히 이해하고 있었고 주로 게임에 집중하고 싶었기 때문입니다.
게임 은 실시간으로 여러 플레이어를 지원 해야 하므로 Express를 WebSockets 중개인으로 활용했습니다. 여기에서 까다로워집니다.
Phaser 게임의 클라이언트에서 물리 계산이 수행되었기 때문에 단순하지만 분명히 결함이 있는 논리를 선택했습니다. 첫 번째 연결된 클라이언트는 모든 게임 개체에 대한 물리 계산을 수행하고 결과를 Express 서버로 보내는 의심스러운 권한을 가지고 있었습니다. 업데이트된 위치, 각도 및 힘을 다른 플레이어의 클라이언트에 다시 브로드캐스트했습니다. 그러면 다른 클라이언트가 게임 개체에 변경 사항을 적용합니다.
이로 인해 첫 번째 플레이어는 실시간으로 일어나는 물리 현상을 보게 되었고 (결국 브라우저에서 로컬로 발생), 다른 모든 플레이어는 최소 30밀리초(내가 선택한 브로드캐스트 속도 ) 또는 — 첫 번째 플레이어의 네트워크 연결이 느린 경우 — 상당히 나쁩니다.
이것이 당신에게 좋지 않은 아키텍처처럼 들린다면 당신의 말이 절대적으로 옳습니다. 그러나 나는 게임이 실제로 플레이하기에 재미 있는지 알아내기 위해 플레이 가능한 무언가를 빨리 얻는 것에 찬성하여 이 사실을 받아들였습니다.
아이디어 검증, 프로토타입 폐기
구현에 결함이 있었지만 첫 번째 테스트 드라이브에 친구를 초대할 정도로 충분히 플레이할 수 있었습니다. 피드백은 매우 긍정적이었고 , 주요 관심사는 당연하게도 실시간 성능이었습니다. 다른 고유한 문제에는 첫 번째 플레이어( 모든 것을 담당하는 사람을 기억하십시오)가 게임을 떠났을 때의 상황이 포함되었습니다. 누가 그 자리를 차지해야 할까요? 이 시점에서 게임룸은 하나뿐이었으므로 누구나 같은 게임에 참여할 수 있었습니다. 또한 Phaser.js 라이브러리가 도입된 번들 크기에 대해 약간 우려했습니다.
프로토타입을 버리고 새로운 설정과 명확한 목표로 시작할 때였습니다.
프로젝트 설정
분명히 "첫 번째 클라이언트가 모든 것을 지배" 접근 방식 은 게임 상태가 서버에 있는 솔루션으로 대체되어야 했습니다. 내 연구에서 나는 그 일에 완벽한 도구처럼 들렸던 Colyseus를 발견했습니다.
내가 선택한 게임의 다른 주요 빌딩 블록:
- Matter.js는 Phaser.js 대신 물리 엔진으로 사용됩니다. Node에서 실행되고 Autowuzzler는 전체 게임 프레임워크가 필요하지 않기 때문입니다.
- SvelteKit은 그 당시에 공개 베타에 들어갔기 때문에 Next.js 대신 애플리케이션 프레임워크로 사용되었습니다. (게다가 저는 Svelte와 함께 일하는 것을 좋아합니다.)
- 사용자가 만든 게임 PIN을 저장하기 위한 Supabase.io.
이러한 빌딩 블록을 더 자세히 살펴보겠습니다.
Colyseus와 동기화된 중앙 집중식 게임 상태
Colyseus는 Node.js 및 Express를 기반으로 하는 멀티플레이어 게임 프레임워크입니다. 핵심적으로 다음을 제공합니다.
- 신뢰할 수 있는 방식으로 클라이언트 간에 상태 동기화
- 변경된 데이터만 전송하여 WebSocket을 사용한 효율적인 실시간 통신
- 멀티룸 설정;
- JavaScript, Unity, Defold Engine, Haxe, Cocos Creator, Construct3용 클라이언트 라이브러리;
- 수명 주기 후크(예: 방 생성, 사용자 참여, 사용자 퇴장 등).
- 방의 모든 사용자 또는 단일 사용자에게 브로드캐스트 메시지로 메시지 보내기
- 내장된 모니터링 패널 및 부하 테스트 도구.
참고 : Colyseus 문서는 npm init
스크립트와 예제 리포지토리를 제공하여 베어본 Colyseus 서버를 쉽게 시작할 수 있도록 합니다.
스키마 생성
Colyseus 앱의 주요 엔터티는 단일 룸 인스턴스와 모든 게임 개체에 대한 상태를 보유하는 게임 룸입니다. Autowuzzler 의 경우 다음이 포함된 게임 세션입니다.
- 두 팀,
- 제한된 수의 플레이어,
- 한 공.
클라이언트 간에 동기화되어야 하는 게임 개체의 모든 속성에 대해 스키마를 정의해야 합니다. 예를 들어 공이 동기화되기를 원하므로 공에 대한 스키마를 생성해야 합니다.
class Ball extends Schema { constructor() { super(); this.x = 0; this.y = 0; this.angle = 0; this.velocityX = 0; this.velocityY = 0; } } defineTypes(Ball, { x: "number", y: "number", angle: "number", velocityX: "number", velocityY: "number" });
위의 예에서는 Colyseus에서 제공하는 스키마 클래스를 확장하는 새 클래스가 생성됩니다. 생성자에서 모든 속성은 초기 값을 받습니다. 공의 위치와 움직임은 x
, y
, angle
, velocityX,
velocityY
의 5가지 속성을 사용하여 설명됩니다. 또한 각 속성의 유형을 지정 해야 합니다. 이 예제에서는 JavaScript 구문을 사용하지만 약간 더 간결한 TypeScript 구문을 사용할 수도 있습니다.
속성 유형은 기본 유형일 수 있습니다.
-
string
-
boolean
-
number
(보다 효율적인 정수 및 부동 소수점 유형)
또는 복합 유형:
-
ArraySchema
(자바스크립트의 Array와 유사) -
MapSchema
(JavaScript의 Map과 유사) -
SetSchema
(JavaScript의 Set과 유사) -
CollectionSchema
(ArraySchema와 유사하지만 인덱스를 제어할 수 없음)
위의 Ball
클래스에는 좌표( x
, y
), 현재 angle
및 속도 벡터( velocityX
, velocityY
)인 number
유형의 5가지 속성이 있습니다.
플레이어에 대한 스키마는 유사하지만 플레이어 인스턴스를 생성할 때 제공해야 하는 플레이어의 이름과 팀 번호를 저장하기 위한 몇 가지 속성이 더 포함되어 있습니다.
class Player extends Schema { constructor(teamNumber) { super(); this.name = ""; this.x = 0; this.y = 0; this.angle = 0; this.velocityX = 0; this.velocityY = 0; this.teamNumber = teamNumber; } } defineTypes(Player, { name: "string", x: "number", y: "number", angle: "number", velocityX: "number", velocityY: "number", angularVelocity: "number", teamNumber: "number", });
마지막으로 Autowuzzler Room
의 스키마는 이전에 정의된 클래스를 연결합니다. 하나의 룸 인스턴스에는 여러 팀이 있습니다(ArraySchema에 저장됨). 또한 단일 공을 포함하므로 RoomSchema의 생성자에서 새 Ball 인스턴스를 생성합니다. 플레이어는 ID를 사용하여 빠른 검색을 위해 MapSchema에 저장됩니다.
멀티룸 설정("매칭")
유효한 게임 PIN이 있으면 누구나 Autowuzzler 게임에 참여할 수 있습니다. Colyseus 서버는 첫 번째 플레이어가 참여하는 즉시 모든 게임 세션에 대해 새로운 Room 인스턴스를 생성하고 마지막 플레이어가 나갈 때 방을 버립니다.
플레이어를 원하는 게임방에 배정하는 과정을 "매칭"이라고 합니다. Colyseus는 새 방을 정의할 때 filterBy
메소드를 사용하여 매우 쉽게 설정할 수 있습니다.
gameServer.define("autowuzzler", AutowuzzlerRoom).filterBy(['gamePIN']);
이제 동일한 gamePIN
으로 게임에 참가하는 모든 플레이어(나중에 "참가" 방법 참조)는 같은 게임방에 있게 됩니다! 모든 상태 업데이트 및 기타 브로드캐스트 메시지는 같은 방에 있는 플레이어로 제한됩니다.
콜리세우스 앱의 물리학
Colyseus는 신뢰할 수 있는 게임 서버를 신속하게 시작하고 실행할 수 있도록 즉시 사용할 수 있는 많은 기능을 제공하지만 물리학을 포함한 실제 게임 역학을 만드는 것은 개발자에게 달려 있습니다. 프로토타입에서 사용한 Phaser.js는 브라우저가 아닌 환경에서는 실행할 수 없지만 Phaser.js의 통합 물리 엔진 Matter.js는 Node.js에서 실행할 수 있습니다.
Matter.js를 사용하면 크기 및 중력과 같은 특정 물리적 속성으로 물리 세계를 정의할 수 있습니다. 질량, 충돌, 마찰이 있는 이동 등을 포함하여 (시뮬레이션된) 물리학 법칙을 준수하여 서로 상호 작용하는 원시 물리학 개체를 만드는 여러 방법을 제공합니다. 실제 세계에서와 마찬가지로 힘을 가하여 물체를 움직일 수 있습니다.
Matter.js "세계"는 Autowuzzler 게임의 핵심입니다. 그것은 자동차가 얼마나 빨리 움직이는지, 공이 얼마나 탄력 있어야 하는지, 골이 어디에 있는지, 누군가가 골을 쏘면 어떻게 되는지를 정의합니다.
let ball = Bodies.circle( ballInitialXPosition, ballInitialYPosition, radius, { render: { sprite: { texture: '/assets/ball.png', } }, friction: 0.002, restitution: 0.8 } ); World.add(this.engine.world, [ball]);
Matter.js의 스테이지에 "공" 게임 개체를 추가하기 위한 단순화된 코드입니다.
규칙이 정의되면 Matter.js는 실제로 화면에 무언가를 렌더링하거나 렌더링 하지 않고 실행할 수 있습니다. Autowuzzler 의 경우 이 기능을 활용하여 서버 와 클라이언트 모두에 대해 물리 세계 코드를 재사용하고 있습니다. 몇 가지 주요 차이점이 있습니다.
서버의 물리학 세계:
- Colyseus를 통해 사용자 입력(자동차를 조종하기 위한 키보드 이벤트)을 수신하고 게임 개체(사용자의 자동차)에 적절한 힘을 가합니다.
- 충돌 감지를 포함하여 모든 물체(플레이어 및 공)에 대한 모든 물리 계산을 수행합니다.
- 각 게임 개체에 대한 업데이트된 상태를 콜리세우스에 다시 전달하고 콜리세우스는 이를 클라이언트에 브로드캐스트합니다.
- Colyseus 서버에 의해 트리거되는 16.6밀리초(=초당 60프레임)마다 업데이트됩니다.
클라이언트의 물리학 세계:
- 게임 개체를 직접 조작하지 않습니다.
- Colyseus로부터 각 게임 개체에 대한 업데이트된 상태를 수신합니다.
- 업데이트된 상태를 수신한 후 위치, 속도 및 각도의 변경 사항을 적용합니다.
- 사용자 입력(자동차를 조종하기 위한 키보드 이벤트)을 Colyseus에 보냅니다.
- 게임 스프라이트를 로드하고 렌더러를 사용하여 캔버스 요소에 물리 세계를 그립니다.
- 충돌 감지를 건너뜁니다(객체에
isSensor
옵션 사용). - 이상적으로는 60fps에서 requestAnimationFrame을 사용하여 업데이트합니다.
이제 서버에서 일어나는 모든 마법과 함께 클라이언트는 입력만 처리하고 서버에서 받은 상태를 화면으로 그립니다. 한 가지 예외:
클라이언트에서 보간
우리는 클라이언트에서 동일한 Matter.js 물리 세계를 재사용하고 있기 때문에 간단한 트릭으로 숙련된 성능을 향상시킬 수 있습니다. 게임 개체의 위치만 업데이트하는 대신 개체 의 속도도 동기화합니다 . 이렇게 하면 서버에서 다음 업데이트가 평소보다 오래 걸리더라도 개체가 계속해서 궤적을 따라 이동합니다. 따라서 A 위치에서 B 위치로 개별적인 단계로 물체를 이동하는 대신 위치를 변경하고 특정 방향으로 움직이게 합니다.
수명 주기
Autowuzzler Room
클래스는 Colyseus 방의 여러 단계와 관련된 논리가 처리되는 곳입니다. Colyseus는 다음과 같은 몇 가지 수명 주기 방법을 제공합니다.
-
onCreate
: 새 방이 생성될 때(보통 첫 번째 클라이언트가 연결될 때); -
onAuth
: 방에 입장을 허용하거나 거부하는 권한 부여 후크. -
onJoin
: 클라이언트가 방에 연결할 때; -
onLeave
: 클라이언트가 방에서 연결을 끊을 때; -
onDispose
: 방이 버려질 때.
Autowuzzler 룸은 생성되자마자( onCreate
) 물리 세계의 새 인스턴스를 생성하고("Colyseus 앱의 물리" 섹션 참조) 클라이언트가 연결될 때( onJoin
) 세계에 플레이어를 추가합니다. 그런 다음 setSimulationInterval
메서드(주 게임 루프)를 사용하여 1초에 60번(16.6밀리초마다) 물리 세계를 업데이트합니다.
// deltaTime is roughly 16.6 milliseconds this.setSimulationInterval((deltaTime) => this.world.updateWorld(deltaTime));
물리 개체는 Colyseus 개체와 독립적이며 동일한 게임 개체(예: 공)의 두 가지 순열을 남깁니다. 즉, 물리 세계의 개체와 동기화할 수 있는 Colyseus 개체입니다.
물리적 개체가 변경되자마자 업데이트된 속성을 Colyseus 개체에 다시 적용해야 합니다. Matter.js의 afterUpdate
이벤트를 수신하고 거기에서 값을 설정하여 이를 달성할 수 있습니다.
Events.on(this.engine, "afterUpdate", () => { // apply the x position of the physics ball object back to the colyseus ball object this.state.ball.x = this.physicsWorld.ball.position.x; // ... all other ball properties // loop over all physics players and apply their properties back to colyseus players objects })
우리가 처리해야 하는 개체의 복사본이 하나 더 있습니다. 사용자 대면 게임의 게임 개체입니다 .
클라이언트 측 애플리케이션
이제 여러 방에 대한 게임 상태 동기화와 물리 계산을 처리하는 애플리케이션이 서버에 있으므로 웹사이트 및 실제 게임 인터페이스 구축에 집중하겠습니다. Autowuzzler 프론트엔드에는 다음과 같은 책임이 있습니다.
- 사용자가 게임 PIN을 만들고 공유하여 개별 방에 액세스할 수 있습니다.
- 지속성을 위해 생성된 게임 PIN을 Supabase 데이터베이스로 보냅니다.
- 플레이어가 게임 PIN을 입력할 수 있는 선택적 "게임 참여" 페이지를 제공합니다.
- 플레이어가 게임에 참여할 때 게임 PIN을 확인합니다.
- 공유 가능한(즉, 고유한) URL에서 실제 게임을 호스팅하고 렌더링합니다.
- Colyseus 서버에 연결하고 상태 업데이트를 처리합니다.
- 랜딩(“마케팅”) 페이지를 제공합니다.
이러한 작업을 구현하기 위해 다음과 같은 이유로 Next.js보다 SvelteKit을 선택했습니다.
왜 SvelteKit인가?
저는 neolightsout을 만든 이후로 Svelte를 사용하여 다른 앱을 개발하고 싶었습니다. SvelteKit(Svelte의 공식 응용 프로그램 프레임워크)가 공개 베타에 들어갔을 때 저는 Autowuzzler 를 빌드하고 새로운 베타를 사용하는 데 따르는 골칫거리를 받아들이기로 결정했습니다. Svelte를 사용하는 기쁨은 분명히 그것을 보상합니다.
이러한 주요 기능 덕분에 게임 프론트엔드의 실제 구현을 위해 Next.js보다 SvelteKit을 선택하게 되었습니다.
- Svelte는 UI 프레임워크 이자 컴파일러이므로 클라이언트 런타임 없이 최소한의 코드를 제공합니다.
- Svelte는 표현적인 템플릿 언어와 구성 요소 시스템(개인 취향)을 가지고 있습니다.
- Svelte에는 즉시 사용 가능한 글로벌 스토어, 전환 및 애니메이션이 포함되어 있습니다.
- Svelte는 단일 파일 구성 요소에서 범위가 지정된 CSS를 지원합니다.
- SvelteKit은 API 구축을 위한 SSR, 단순하지만 유연한 파일 기반 라우팅 및 서버 측 경로를 지원합니다.
- SvelteKit을 사용하면 각 페이지가 서버에서 코드를 실행할 수 있습니다. 예를 들어 페이지를 렌더링하는 데 사용되는 데이터를 가져옵니다.
- 경로 간에 공유되는 레이아웃
- SvelteKit은 서버리스 환경에서 실행할 수 있습니다.
게임 PIN 생성 및 저장
사용자가 게임을 시작하려면 먼저 게임 PIN을 만들어야 합니다. 다른 사람들과 PIN을 공유하면 모두 같은 게임룸에 액세스할 수 있습니다.
이것은 Sveltes onMount 기능과 함께 SvelteKits 서버 측 끝점에 대한 훌륭한 사용 사례입니다. 끝점 /api/createcode
는 게임 PIN을 생성하고 이를 Supabase.io 데이터베이스에 저장 하고 게임 PIN을 응답으로 출력합니다 . 다음은 "만들기" 페이지의 페이지 구성 요소가 마운트되는 즉시 가져오는 응답입니다.
Supabase.io로 게임 PIN 저장
Supabase.io는 Firebase에 대한 오픈 소스 대안입니다. Supabase를 사용하면 PostgreSQL 데이터베이스를 매우 쉽게 생성하고 클라이언트 라이브러리 중 하나 또는 REST를 통해 액세스할 수 있습니다.
JavaScript 클라이언트의 경우 createClient
함수를 가져오고 데이터베이스를 생성할 때 받은 매개변수 supabase_url
및 supabase_key
를 사용하여 실행합니다. createcode
엔드포인트에 대한 각 호출에서 생성 되는 게임 PIN을 저장 하려면 다음과 같은 간단한 insert
쿼리를 실행하기만 하면 됩니다.
import { createClient } from '@supabase/supabase-js' const database = createClient( import.meta.env.VITE_SUPABASE_URL, import.meta.env.VITE_SUPABASE_KEY ); const { data, error } = await database .from("games") .insert([{ code: 123456 }]);
참고 : supabase_url
및 supabase_key
는 .env 파일에 저장됩니다. SvelteKit의 핵심에 있는 빌드 도구인 Vite로 인해 SvelteKit에서 액세스할 수 있도록 환경 변수에 VITE_ 접두사를 붙여야 합니다.
게임 액세스
링크를 따라가는 것처럼 쉽게 Autowuzzler 게임에 참여하고 싶었습니다. 따라서 모든 게임 룸 은 이전에 생성된 게임 PIN을 기반으로 하는 고유한 URL 이 필요했습니다(예: https://autowuzzler.com/play/12345).
SvelteKit에서 동적 경로 매개변수가 있는 페이지는 경로의 동적 부분을 페이지 파일의 이름을 지정할 때 대괄호 안에 넣어 생성됩니다: client/src/routes/play/[gamePIN].svelte
. 그러면 gamePIN
매개변수의 값을 페이지 구성요소에서 사용할 수 있게 됩니다(자세한 내용은 SvelteKit 문서 참조). play
경로에서 우리는 Colyseus 서버에 연결하고, 화면에 렌더링할 물리 세계를 인스턴스화하고, 게임 개체에 대한 업데이트를 처리하고, 키보드 입력을 듣고, 점수와 같은 다른 UI를 표시하는 등의 작업을 수행해야 합니다.
콜리세우스에 연결하고 상태 업데이트
Colyseus 클라이언트 라이브러리를 사용하면 클라이언트를 Colyseus 서버에 연결할 수 있습니다. 먼저 Colyseus 서버(개발 중인 ws://localhost:2567
)를 가리키도록 하여 새로운 Colyseus.Client
를 생성해 보겠습니다. 그런 다음 이전에 선택한 이름( autowuzzler
)과 route 매개변수의 gamePIN
으로 방에 참여합니다. gamePIN
매개변수는 사용자가 올바른 방 인스턴스에 참여하는지 확인합니다(위의 "매칭" 참조).
let client = new Colyseus.Client("ws://localhost:2567"); this.room = await client.joinOrCreate("autowuzzler", { gamePIN });
SvelteKit은 처음에 서버에서 페이지를 렌더링하므로 이 코드는 페이지 로드가 완료된 후에만 클라이언트에서 실행 되도록 해야 합니다. 다시 말하지만 해당 사용 사례에 대해 onMount
수명 주기 기능을 사용합니다. (React에 익숙하다면 onMount
는 빈 의존성 배열이 있는 useEffect
후크와 유사합니다.)
onMount(async () => { let client = new Colyseus.Client("ws://localhost:2567"); this.room = await client.joinOrCreate("autowuzzler", { gamePIN }); })
이제 Colyseus 게임 서버에 연결되었으므로 게임 개체에 대한 변경 사항을 수신할 수 있습니다.
다음 은 방에 참가하는 플레이어 ( onAdd
)를 듣고 이 플레이어에 대한 연속적인 상태 업데이트를 수신하는 방법의 예입니다.
this.room.state.players.onAdd = (player, key) => { console.log(`Player has been added with sessionId: ${key}`); // add player entity to the game world this.world.createPlayer(key, player.teamNumber); // listen for changes to this player player.onChange = (changes) => { changes.forEach(({ field, value }) => { this.world.updatePlayer(key, field, value); // see below }); }; };
물리 세계의 updatePlayer
메서드에서 Colyseus의 onChange
가 변경된 모든 속성 집합을 전달하기 때문에 속성을 하나씩 업데이트합니다.
참고 : 이 기능은 게임 개체가 Colyseus 서버를 통해 간접적으로만 조작되기 때문에 물리 세계의 클라이언트 버전에서만 실행됩니다.
updatePlayer(sessionId, field, value) { // get the player physics object by its sessionId let player = this.world.players.get(sessionId); // exit if not found if (!player) return; // apply changes to the properties switch (field) { case "angle": Body.setAngle(player, value); break; case "x": Body.setPosition(player, { x: value, y: player.position.y }); break; case "y": Body.setPosition(player, { x: player.position.x, y: value }); break; // set velocityX, velocityY, angularVelocity ... } }
다른 게임 개체(공 및 팀)에도 동일한 절차가 적용됩니다. 변경 사항을 듣고 변경된 값을 클라이언트의 물리적 세계에 적용합니다.
아직까지는 키보드 입력을 듣고 서버로 보내야 하기 때문에 객체가 움직이지 않습니다. 모든 keydown
이벤트에 대해 직접 이벤트를 보내는 대신 현재 눌려진 키의 맵을 유지하고 50ms 루프에서 Colyseus 서버로 이벤트를 보냅니다. 이런 식으로 여러 키를 동시에 누르는 것을 지원하고 키가 계속 눌러져 있을 때 첫 번째 및 연속적인 keydown
이벤트 후에 발생하는 일시 중지를 완화할 수 있습니다.
let keys = {}; const keyDown = e => { keys[e.key] = true; }; const keyUp = e => { keys[e.key] = false; }; document.addEventListener('keydown', keyDown); document.addEventListener('keyup', keyUp); let loop = () => { if (keys["ArrowLeft"]) { this.room.send("move", { direction: "left" }); } else if (keys["ArrowRight"]) { this.room.send("move", { direction: "right" }); } if (keys["ArrowUp"]) { this.room.send("move", { direction: "up" }); } else if (keys["ArrowDown"]) { this.room.send("move", { direction: "down" }); } // next iteration requestAnimationFrame(() => { setTimeout(loop, 50); }); } // start loop setTimeout(loop, 50);
이제 주기가 완료되었습니다. 키 입력을 듣고 해당 명령을 Colyseus 서버에 보내 서버의 물리 세계를 조작합니다. 그런 다음 Colyseus 서버는 새로운 물리적 속성을 모든 게임 개체에 적용하고 데이터를 클라이언트로 다시 전파하여 게임의 사용자 대면 인스턴스를 업데이트합니다.
사소한 불편
돌이켜보면 아무도 말하지 않았지만 누군가는 그랬어야 하는 범주의 두 가지가 떠올랐습니다.
- 물리 엔진이 어떻게 작동하는지 잘 이해하면 도움이 됩니다. 물리 속성과 제약 조건을 미세 조정하는 데 상당한 시간을 할애했습니다. 이전에 Phaser.js와 Matter.js로 작은 게임을 만들긴 했지만, 내가 상상한 대로 개체를 이동시키려면 많은 시행착오를 겪었습니다.
- 실시간은 어렵습니다 . 특히 물리학 기반 게임에서는 더욱 그렇습니다. 약간의 지연은 경험을 상당히 악화시키고 클라이언트 간에 상태를 Colyseus와 동기화하는 것은 훌륭하게 작동하지만 계산 및 전송 지연을 제거할 수 없습니다.
SvelteKit의 주의 사항 및 주의 사항
베타 오븐에서 갓 나온 SvelteKit을 사용했기 때문에 몇 가지 문제점과 주의 사항을 지적하고 싶습니다.
- SvelteKit에서 환경 변수를 사용하려면 환경 변수에 VITE_ 접두사를 붙여야 한다는 것을 이해하는 데 시간이 걸렸습니다. 이것은 이제 FAQ에 제대로 문서화되어 있습니다.
- Supabase를 사용하려면 package.json의
dependencies
과devDependencies
목록 모두 에 Supabase를 추가해야 했습니다. 나는 이것이 더 이상 사실이 아니라고 믿습니다. - SvelteKits
load
기능은 서버 와 클라이언트 모두에서 실행됩니다! - 전체 핫 모듈 교체(상태 유지 포함)를 활성화하려면 페이지 구성 요소에 주석 줄
<!-- @hmr:keep-all -->
을 수동으로 추가해야 합니다. 자세한 내용은 FAQ를 참조하세요.
다른 많은 프레임워크도 잘 맞았겠지만 이 프로젝트에 SvelteKit을 선택한 것에 대해 후회는 없습니다. Svelte 자체가 표현력이 뛰어나고 상용구 코드를 많이 건너뛰기 때문이기도 하지만 Svelte에는 애니메이션, 전환, 범위가 지정된 CSS 및 전역 저장소가 포함되어 있기 때문에 매우 효율적인 방식으로 클라이언트 애플리케이션에서 작업할 수 있었습니다. SvelteKit 은 내가 필요한 모든 구성 요소 (SSR, 라우팅, 서버 경로)를 제공했으며 아직 베타 버전이지만 매우 안정적이고 빠르게 느껴졌습니다.
배포 및 호스팅
처음에는 Heroku 인스턴스에서 Colyseus(노드) 서버를 호스팅하고 WebSocket과 CORS가 작동하도록 하는 데 많은 시간을 낭비했습니다. 결과적으로 작은(무료) Heroku dyno의 성능은 실시간 사용 사례에 충분하지 않습니다. 나중에 Colyseus 앱을 Linode의 작은 서버로 마이그레이션했습니다. 클라이언트 측 응용 프로그램은 SvelteKits 어댑터-netlify를 통해 Netlify에서 배포되고 호스팅됩니다. 여기에 놀라움이 없습니다. Netlify는 훌륭하게 작동했습니다!
결론
아이디어를 검증하기 위해 정말 간단한 프로토타입으로 시작하는 것은 프로젝트가 따를 가치가 있는지와 게임의 기술적인 문제가 어디에 있는지 파악하는 데 많은 도움이 되었습니다. 최종 구현에서 Colyseus는 여러 방에 분산된 여러 클라이언트에서 실시간으로 동기화 상태의 모든 무거운 작업을 처리했습니다. 스키마를 적절하게 설명하는 방법을 알게 되면 Colyseus 를 사용하여 실시간 다중 사용자 애플리케이션을 얼마나 빨리 구축할 수 있는지는 인상적입니다. Colyseus의 내장 모니터링 패널은 동기화 문제를 해결하는 데 도움이 됩니다.
이 설정을 복잡하게 만든 것은 게임의 물리 계층이었습니다. 유지 관리해야 하는 각 물리 관련 게임 개체의 추가 복사본을 도입했기 때문입니다. SvelteKit 앱에서 Supabase.io에 게임 PIN을 저장하는 것은 매우 간단했습니다. 돌이켜보면 SQLite 데이터베이스를 사용하여 게임 PIN을 저장할 수도 있었지만 새로운 것을 시도하는 것은 사이드 프로젝트를 구축할 때 재미의 절반입니다.
마지막으로, SvelteKit을 사용하여 게임의 프론트엔드를 구축한 덕분에 빠르게 움직일 수 있었고 가끔 기쁨의 미소를 지었습니다.
이제 Autowuzzler 라운드에 친구를 초대하세요!
Smashing Magazine에 대한 추가 정보
- "두더지 잡기 게임을 만들어 React 시작하기", Jhey Tompkins
- "실시간 멀티플레이어 가상 현실 게임을 만드는 방법", Alvin Wan
- "Node.js에서 멀티플레이어 텍스트 어드벤처 엔진 작성", Fernando Doglio
- "모바일 웹 디자인의 미래: 비디오 게임 디자인과 스토리텔링", Suzanne Scacca
- "가상 현실에서 끝없는 러너 게임을 만드는 방법" Alvin Wan