Node.js에서 멀티플레이어 텍스트 어드벤처 엔진 작성: 게임 엔진 서버 디자인(2부)
게시 됨: 2022-03-10신중하게 고려하고 모듈을 실제로 구현한 후 설계 단계에서 정의한 일부를 변경해야 했습니다. 이것은 이상적인 제품에 대한 꿈을 꾸지만 개발팀의 구속을 받아야 하는 열성적인 고객과 함께 일한 적이 있는 사람이라면 누구에게나 친숙한 장면일 것입니다.
기능이 구현되고 테스트되면 팀은 일부 특성이 원래 계획과 다를 수 있음을 알아차리기 시작할 것입니다. 알리고, 조정하고, 계속하십시오. 따라서 더 이상 고민하지 않고 먼저 원래 계획에서 변경된 사항을 설명하겠습니다.
이 시리즈의 다른 부분
- 1부: 소개
- 3부: 터미널 클라이언트 만들기
- 4부: 게임에 채팅 추가하기
전투 역학
이것은 아마도 원래 계획에서 가장 큰 변화일 것입니다. 관련된 각 PC와 NPC가 이니셔티브 가치를 얻고 그 후에 턴 기반 전투를 실행하는 D&D 방식 구현으로 갈 것이라고 말한 것을 압니다. 좋은 생각이었지만 REST 기반 서비스에서 구현하는 것은 서버 측에서 통신을 시작할 수 없고 호출 간에 상태를 유지할 수 없기 때문에 약간 복잡합니다.
따라서 대신 REST의 단순화된 메커니즘을 활용하여 전투 메커니즘을 단순화할 것입니다. 구현된 버전은 파티 기반이 아닌 플레이어 기반이며 플레이어가 NPC(Non-Player Characters)를 공격할 수 있습니다. 공격이 성공하면 NPC가 죽거나 플레이어에게 피해를 주거나 죽여서 반격합니다.
공격의 성공 여부는 사용하는 무기의 종류와 NPC가 가질 수 있는 약점에 따라 결정됩니다. 따라서 기본적으로 죽이려는 몬스터가 무기에 약하면 죽습니다. 그렇지 않으면 영향을 받지 않고 아마도 매우 화를 낼 것입니다.
트리거
내 이전 기사에서 JSON 게임 정의에 세심한 주의를 기울였다면 장면 항목에 있는 트리거 정의를 발견했을 것입니다. 게임 상태( statusUpdate
) 업데이트와 관련된 특정 문제가 있습니다. 구현하는 동안 토글로 작동하는 것이 제한된 자유를 제공한다는 것을 깨달았습니다. (관용적 관점에서) 구현된 방식에서 상태를 설정할 수 있었지만 설정 해제는 옵션이 아니었습니다. 그래서 대신 이 트리거 효과를 addStatus
및 removeStatus
라는 두 가지 새로운 효과로 대체했습니다. 이를 통해 이러한 효과가 발생할 수 있는 시기를 정확히 정의할 수 있습니다. 나는 이것이 훨씬 이해하기 쉽고 추론하기 쉽다고 생각합니다.
이는 이제 트리거가 다음과 같이 표시됨을 의미합니다.
"triggers": [ { "action": "pickup", "effect":{ "addStatus": "has light", "target": "game" } }, { "action": "drop", "effect": { "removeStatus": "has light", "target": "game" } } ]
아이템을 주울 때는 상태를 설정하고, 떨어뜨릴 때는 제거합니다. 이런 식으로 여러 게임 수준 상태 표시기를 갖는 것이 완전히 가능하고 관리하기 쉽습니다.
구현
이러한 업데이트가 끝나면 실제 구현을 다룰 수 있습니다. 아키텍처 관점에서 보면 아무것도 바뀌지 않았습니다. 우리는 여전히 메인 게임 엔진의 로직을 포함할 REST API를 구축 중입니다.
기술 스택
이 특정 프로젝트에서 사용할 모듈은 다음과 같습니다.
기준 치수 | 설명 |
---|---|
익스프레스.js | 분명히 나는 Express를 전체 엔진의 기반으로 사용할 것입니다. |
윈스턴 | 로깅과 관련된 모든 것은 Winston에서 처리합니다. |
구성 | 모든 상수 및 환경 종속 변수는 config.js 모듈에서 처리되므로 액세스 작업이 크게 간소화됩니다. |
몽구스 | 이것은 우리의 ORM이 될 것입니다. 저는 몽구스 모델을 사용하여 모든 리소스를 모델링하고 이를 사용하여 데이터베이스와 직접 상호 작용할 것입니다. |
uuid | 고유 ID를 생성해야 합니다. 이 모듈이 해당 작업에 도움이 될 것입니다. |
Node.js 외에 사용되는 다른 기술로는 MongoDB 와 Redis 가 있습니다. 필요한 스키마가 없기 때문에 Mongo를 사용하고 싶습니다. 이 간단한 사실 덕분에 테이블 구조 업데이트, 스키마 마이그레이션 또는 충돌하는 데이터 유형에 대해 걱정할 필요 없이 내 코드와 데이터 형식에 대해 생각할 수 있습니다.
Redis는 프로젝트에서 최대한 지원 시스템으로 사용하는 편인데 이 경우도 다르지 않습니다. 저는 파티 구성원 번호, 명령 요청 및 영구 저장을 할 가치가 없을 만큼 충분히 작고 휘발성이 있는 기타 유형의 데이터와 같이 휘발성 정보로 간주될 수 있는 모든 것에 Redis를 사용할 것입니다.
또한 Redis의 키 만료 기능을 사용하여 흐름의 일부 측면을 자동 관리할 예정입니다(자세한 내용은 곧 설명).
API 정의
클라이언트-서버 상호 작용 및 데이터 흐름 정의로 이동하기 전에 이 API에 대해 정의된 끝점을 살펴보고 싶습니다. 그렇게 많지는 않습니다. 대부분 1부에서 설명한 주요 기능을 준수해야 합니다.
특징 | 설명 |
---|---|
게임에 참여 | 플레이어는 게임의 ID를 지정하여 게임에 참여할 수 있습니다. |
새 게임 만들기 | 플레이어는 새 게임 인스턴스를 만들 수도 있습니다. 엔진은 ID를 반환해야 다른 사람들이 참가할 수 있습니다. |
복귀 장면 | 이 기능은 파티가 있는 현재 장면을 반환해야 합니다. 기본적으로 모든 관련 정보(가능한 작업, 개체 등)와 함께 설명을 반환합니다. |
장면과 상호 작용 | 이것은 가장 복잡한 것 중 하나가 될 것입니다. 클라이언트로부터 명령을 받아 해당 작업을 수행하기 때문입니다. 몇 가지 예를 들면 이동, 밀기, 가져오기, 보기, 읽기 등입니다. |
재고 확인 | 이것은 게임과 상호 작용하는 방법이지만 장면과 직접 관련이 없습니다. 따라서 각 플레이어의 인벤토리를 확인하는 것은 다른 작업으로 간주됩니다. |
클라이언트 애플리케이션 등록 | 위의 작업을 실행하려면 유효한 클라이언트가 필요합니다. 이 끝점은 클라이언트 응용 프로그램을 확인하고 후속 요청에서 인증 목적으로 사용할 클라이언트 ID를 반환합니다. |
위 목록은 다음 끝점 목록으로 변환됩니다.
동사 | 끝점 | 설명 |
---|---|---|
게시하다 | /clients | 클라이언트 애플리케이션은 이 끝점을 사용하여 클라이언트 ID 키를 가져와야 합니다. |
게시하다 | /games | 클라이언트 애플리케이션은 이 끝점을 사용하여 새 게임 인스턴스를 만듭니다. |
게시하다 | /games/:id | 게임이 생성되면 이 끝점을 통해 파티 구성원이 게임에 참여하고 게임을 시작할 수 있습니다. |
가져 오기 | /games/:id/:playername | 이 끝점은 특정 플레이어의 현재 게임 상태를 반환합니다. |
게시하다 | /games/:id/:playername/commands | 마지막으로 이 끝점을 사용하여 클라이언트 응용 프로그램은 명령을 제출할 수 있습니다(즉, 이 끝점이 재생에 사용됨). |
이전 목록에서 설명한 몇 가지 개념에 대해 좀 더 자세히 설명하겠습니다.
클라이언트 앱
클라이언트 응용 프로그램을 사용하려면 시스템에 등록해야 합니다. 모든 끝점(목록의 첫 번째 끝점 제외)은 보호되며 요청과 함께 보내려면 유효한 응용 프로그램 키가 필요합니다. 해당 키를 얻으려면 클라이언트 앱에서 키를 요청하기만 하면 됩니다. 한 번 제공되면 사용하는 동안 지속되거나 사용하지 않은 달이 지나면 만료됩니다. 이 동작은 키를 Redis에 저장하고 1개월 길이의 TTL을 설정하여 제어합니다.
게임 인스턴스
새 게임을 만든다는 것은 기본적으로 특정 게임의 새 인스턴스를 만드는 것을 의미합니다. 이 새 인스턴스에는 모든 장면과 해당 콘텐츠의 복사본이 포함됩니다. 게임에 대한 모든 수정은 파티에만 영향을 미칩니다. 이런 식으로 많은 그룹이 각자의 방식으로 같은 게임을 할 수 있습니다.
플레이어의 게임 상태
이것은 이전 것과 유사하지만 각 플레이어마다 고유합니다. 게임 인스턴스가 전체 파티에 대한 게임 상태를 유지하는 동안 플레이어의 게임 상태는 특정 플레이어의 현재 상태를 유지합니다. 주로 인벤토리, 위치, 현재 장면 및 HP(헬스 포인트)를 보유합니다.
플레이어 명령
모든 것이 설정되고 클라이언트 애플리케이션이 게임에 등록 및 참여하면 명령 전송을 시작할 수 있습니다. 이 버전의 엔진에서 구현된 명령은 다음과 같습니다: move
, look
, pickup
및 attack
.
-
move
명령을 사용하면 지도를 횡단할 수 있습니다. 이동하려는 방향을 지정할 수 있으며 엔진이 결과를 알려줍니다. Part 1을 간략히 살펴보면 제가 지도를 다루기 위해 취한 접근 방식을 알 수 있습니다. (요컨대, 맵은 그래프로 표현되며, 각 노드는 방이나 장면을 나타내며 인접한 방을 나타내는 다른 노드에만 연결됩니다.)
노드 사이의 거리도 표현에 나타나며 플레이어의 표준 속도와 결합됩니다. 방에서 방으로 이동하는 것은 명령을 말하는 것만큼 간단하지 않을 수 있지만 거리를 횡단해야 합니다. 실제로 이것은 한 방에서 다른 방으로 이동하는 데 여러 이동 명령이 필요할 수 있음을 의미합니다. 이 명령의 다른 흥미로운 측면은 이 엔진이 멀티플레이어 파티를 지원하기 위한 것이며 파티를 분할할 수 없다는 사실에서 비롯됩니다(적어도 현재로서는 불가능).
따라서 이에 대한 솔루션은 투표 시스템과 유사합니다. 모든 파티원은 원할 때마다 이동 명령 요청을 보냅니다. 절반 이상이 그렇게 하면 가장 많이 요청된 방향이 사용됩니다. -
look
은 움직임과 사뭇 다르다. 플레이어가 검사하려는 방향, 항목 또는 NPC를 지정할 수 있습니다. 이 명령의 핵심 논리는 상태 종속 설명에 대해 생각할 때 고려됩니다.
예를 들어, 새 방에 들어갔지만 완전히 어두워서(아무것도 보이지 않음) 무시하면서 앞으로 이동한다고 가정해 보겠습니다. 몇 방 후, 당신은 벽에서 불이 붙은 횃불을 줍습니다. 이제 돌아가서 그 어두운 방을 다시 검사할 수 있습니다. 횃불을 집어 들었으므로 이제 내부를 볼 수 있으며 거기에서 찾은 모든 항목 및 NPC와 상호 작용할 수 있습니다.
이는 게임 전체 및 플레이어별 상태 속성 집합을 유지 관리하고 게임 제작자가 JSON 파일의 상태 종속 요소에 대한 몇 가지 설명을 지정할 수 있도록 함으로써 달성됩니다. 모든 설명에는 현재 상태에 따라 기본 텍스트와 조건부 텍스트 세트가 포함됩니다. 후자는 선택 사항입니다. 필수 항목은 기본값뿐입니다.
또한 이 명령에는look at room: look around
의 축약형 버전이 있습니다. 플레이어가 방을 매우 자주 검사하려고 하기 때문에 입력하기 쉬운 속기(또는 별칭) 명령을 제공하는 것이 좋습니다. -
pickup
명령은 게임 플레이에서 매우 중요한 역할을 합니다. 이 명령은 플레이어 인벤토리나 손에 항목을 추가하는 작업을 처리합니다(무료인 경우). 각 항목이 저장되는 위치를 이해하기 위해 해당 항목의 정의에는 인벤토리 또는 플레이어의 손을 위한 것인지 지정하는 "destination" 속성이 있습니다. 장면에서 성공적으로 픽업된 모든 항목은 장면에서 제거되어 게임 인스턴스의 게임 버전이 업데이트됩니다. -
use
명령을 사용하면 인벤토리에 있는 항목을 사용하여 환경에 영향을 줄 수 있습니다. 예를 들어, 방에서 열쇠를 집으면 다른 방의 잠긴 문을 여는 데 사용할 수 있습니다. - 게임 플레이와 관련이 없는 특수 명령이 있지만 대신 현재 게임 ID 또는 플레이어 이름과 같은 특정 정보를 얻기 위한 도우미 명령이 있습니다. 이 명령을 get 이라고 하며 플레이어는 이를 사용하여 게임 엔진을 쿼리할 수 있습니다. 예: gameid 가져오기 .
- 마지막으로 이 엔진 버전에 대해 구현된 마지막 명령은
attack
명령입니다. 나는 이미 이것을 다루었습니다. 기본적으로 목표물과 공격할 무기를 지정해야 합니다. 그렇게 하면 시스템이 대상의 약점을 확인하고 공격 결과를 결정할 수 있습니다.
클라이언트-엔진 상호작용
위에 나열된 엔드포인트를 사용하는 방법을 이해하기 위해 모든 잠재 고객이 새로운 API와 상호 작용할 수 있는 방법을 보여드리겠습니다.

단계 | 설명 |
---|---|
클라이언트 등록 | 먼저 클라이언트 애플리케이션은 다른 모든 엔드포인트에 액세스할 수 있도록 API 키를 요청해야 합니다. 해당 키를 얻으려면 플랫폼에 등록해야 합니다. 제공할 유일한 매개변수는 앱의 이름뿐입니다. |
게임 만들기 | API 키를 얻은 후 가장 먼저 할 일은(이것이 완전히 새로운 상호 작용이라고 가정) 완전히 새로운 게임 인스턴스를 만드는 것입니다. 다음과 같이 생각해 보십시오. 지난 게시물에서 생성한 JSON 파일에는 게임의 정의가 포함되어 있지만 우리는 당신과 당신의 파티를 위한 인스턴스를 생성해야 합니다(클래스와 객체, 같은 거래). 해당 인스턴스로 원하는 대로 수행할 수 있으며 다른 당사자에게 영향을 미치지 않습니다. |
게임에 참여 | 게임을 만든 후 엔진에서 게임 ID를 다시 받습니다. 그런 다음 해당 게임 ID를 사용하여 고유한 사용자 이름으로 인스턴스에 참여할 수 있습니다. 게임에 참여하지 않으면 게임을 할 수 없습니다. 게임에 참여하면 게임 상태 인스턴스도 혼자 생성되기 때문입니다. 여기에 귀하의 인벤토리, 위치 및 기본 통계가 귀하가 플레이하는 게임과 관련하여 저장됩니다. 잠재적으로 동시에 여러 게임을 할 수 있으며 각 게임에는 독립적인 상태가 있습니다. |
명령 보내기 | 즉, 게임을 하십시오. 마지막 단계는 명령 보내기를 시작하는 것입니다. 사용 가능한 명령의 양은 이미 다뤘으며 쉽게 확장할 수 있습니다(이에 대해서는 잠시 후에 자세히 설명). 명령을 보낼 때마다 게임은 클라이언트가 그에 따라 보기를 업데이트할 수 있도록 새 게임 상태를 반환합니다. |
손을 더럽히자
그 정보가 다음 부분을 이해하는 데 도움이 되기를 바라는 마음으로 최대한 많은 디자인을 검토했으므로 게임 엔진의 기본 사항에 대해 알아보겠습니다.
참고 : 이 기사에서는 전체 코드를 보여주지 않을 것입니다. 이 기사는 매우 크고 모든 내용이 흥미롭지 않기 때문입니다. 대신, 더 자세한 내용을 원할 경우를 대비하여 더 관련성이 높은 부분과 전체 저장소에 대한 링크를 보여 드리겠습니다.
메인 파일
가장 먼저 할 일: 이것은 Express 프로젝트이고 Express의 자체 생성기를 사용하여 기반 상용구 코드를 생성 했으므로 app.js 파일이 익숙할 것입니다. 내 작업을 단순화하기 위해 해당 코드에서 수행하고 싶은 두 가지 조정을 살펴보고 싶습니다.
먼저 다음 스니펫을 추가하여 새 경로 파일 포함을 자동화합니다.
const requireDir = require("require-dir") const routes = requireDir("./routes") //... Object.keys(routes).forEach( (file) => { let cnt = routes[file] app.use('/' + file, cnt) })
정말 간단하지만 나중에 만들 각 경로 파일을 수동으로 요구할 필요가 없습니다. 그건 그렇고, require-dir
은 폴더 안의 모든 파일을 자동으로 요구하는 간단한 모듈입니다. 그게 다야
내가 하고 싶은 다른 변경 사항은 오류 처리기를 약간 조정하는 것입니다. 더 강력한 것을 사용하기 시작해야 하지만 당면한 필요에 따라 다음과 같이 작업이 완료되는 것 같습니다.
// error handler app.use(function(err, req, res, next) { // render the error page if(typeof err === "string") { err = { status: 500, message: err } } res.status(err.status || 500); let errorObj = { error: true, msg: err.message, errCode: err.status || 500 } if(err.trace) { errorObj.trace = err.trace } res.json(errorObj); });
위의 코드는 우리가 처리해야 하는 다양한 유형의 오류 메시지를 처리합니다. 전체 개체, Javascript에 의해 발생한 실제 오류 개체 또는 다른 컨텍스트가 없는 단순한 오류 메시지입니다. 이 코드는 모든 것을 가져와 표준 형식으로 지정합니다.
명령 처리
이것은 확장하기 쉬워야 했던 엔진의 또 다른 측면 중 하나입니다. 이와 같은 프로젝트에서는 미래에 새로운 명령이 나타날 것이라고 가정하는 것이 완전히 합리적입니다. 피하고 싶은 것이 있다면 3~4개월 후에 새로운 것을 추가하려고 할 때 기본 코드를 변경하지 않는 것이 좋습니다.
코드 주석이 아무리 많아도 몇 달 동안 건드리지 않은(또는 생각조차 하지 않은) 코드를 수정하는 작업을 쉽게 만들 수 없으므로 가능한 한 많은 변경을 피하는 것이 우선 순위입니다. 운 좋게도 이 문제를 해결하기 위해 구현할 수 있는 몇 가지 패턴이 있습니다. 특히 Command와 Factory 패턴을 혼합하여 사용했습니다.
기본적으로 모든 명령에 대한 일반 코드를 포함하는 BaseCommand
클래스에서 상속되는 단일 클래스 내에서 각 명령의 동작을 캡슐화했습니다. 동시에 클라이언트가 보낸 문자열을 가져와 실행할 실제 명령을 반환하는 CommandParser
모듈을 추가했습니다.
구현된 모든 명령에 이제 첫 번째 단어(예: "북쪽으로 이동", "칼 집어 올리기" 등)에 대한 실제 명령이 있으므로 구문 분석기는 매우 간단합니다. 문자열을 분할하고 첫 번째 부분을 가져오기만 하면 됩니다.
const requireDir = require("require-dir") const validCommands = requireDir('./commands') class CommandParser { constructor(command) { this.command = command } normalizeAction(strAct) { strAct = strAct.toLowerCase().split(" ")[0] return strAct } verifyCommand() { if(!this.command) return false if(!this.command.action) return false if(!this.command.context) return false let action = this.normalizeAction(this.command.action) if(validCommands[action]) { return validCommands[action] } return false } parse() { let validCommand = this.verifyCommand() if(validCommand) { let cmdObj = new validCommand(this.command) return cmdObj } else { return false } } }
참고 : 기존 및 새 명령 클래스의 포함을 단순화하기 위해 다시 한 번 require-dir
모듈을 사용하고 있습니다. 폴더에 추가하기만 하면 전체 시스템에서 선택하여 사용할 수 있습니다.
즉, 이를 개선할 수 있는 여러 가지 방법이 있습니다. 예를 들어, 명령에 동의어 지원을 추가할 수 있다는 것은 훌륭한 기능이 될 것입니다(따라서 "북쪽으로 이동", "북쪽으로 이동" 또는 "북쪽으로 이동"이라고 말하는 것도 동일함을 의미합니다). 이것은 우리가 이 클래스에서 중앙 집중화하고 모든 명령에 동시에 영향을 줄 수 있는 것입니다.
여기서도 너무 많은 코드를 보여주기 때문에 명령에 대한 세부 정보는 다루지 않겠습니다. 하지만 다음 경로 코드에서 기존(및 향후) 명령의 처리를 일반화하는 방법을 볼 수 있습니다.
/** Interaction with a particular scene */ router.post('/:id/:playername/:scene', function(req, res, next) { let command = req.body command.context = { gameId: req.params.id, playername: req.params.playername, } let parser = new CommandParser(command) let commandObj = parser.parse() //return the command instance if(!commandObj) return next({ //error handling status: 400, errorCode: config.get("errorCodes.invalidCommand"), message: "Unknown command" }) commandObj.run((err, result) => { //execute the command if(err) return next(err) res.json(result) }) })
모든 명령에는 run
방법만 필요합니다. 다른 모든 명령은 추가적이며 내부 사용을 의미합니다.
나는 당신이 가서 전체 소스 코드를 검토할 것을 권장합니다. 이 시리즈의 다음 부분에서는 이 API의 실제 클라이언트 구현 및 상호 작용을 보여 드리겠습니다.
마무리 생각
여기에서 내 코드를 많이 다루지는 않았지만 초기 설계 단계 이후에도 이 기사가 프로젝트를 처리하는 방법을 보여주는 데 도움이 되었기를 바랍니다. 많은 사람들이 새로운 아이디어에 대한 첫 번째 반응으로 코딩을 시작하려고 하고 최종 제품을 준비하는 것 외에는 실제 계획이나 달성할 목표가 없기 때문에 때때로 개발자가 낙담할 수 있다고 생각합니다. 그리고 그것은 첫날부터 다루기에는 너무 큰 이정표입니다). 다시 말하지만, 이 기사를 통해 큰 프로젝트에서 혼자(또는 소규모 그룹의 일부로) 작업하는 다른 방법을 공유하기를 바랍니다.
재미있게 읽으셨기를 바랍니다! 어떤 유형의 제안이나 권장 사항과 함께 아래에 의견을 자유롭게 남겨주세요. 귀하의 생각과 자신의 클라이언트 측 코드로 API 테스트를 시작하려는 열망을 읽고 싶습니다.
다음 편에서 만나요!
이 시리즈의 다른 부분
- 1부: 소개
- 3부: 터미널 클라이언트 만들기
- 4부: 게임에 채팅 추가하기