Node.js에서 멀티플레이어 텍스트 어드벤처 엔진 작성: 터미널 클라이언트 생성(3부)
게시 됨: 2022-03-10저는 먼저 이와 같은 프로젝트를 정의하는 방법을 보여주었고 아키텍처의 기초와 게임 엔진 이면의 역학에 대해 설명했습니다. 그런 다음 JSON 정의 세계를 탐색할 수 있는 기본 REST API인 엔진의 기본 구현을 보여 주었습니다.
오늘 저는 Node.js 외에는 아무 것도 사용하지 않고 API용 구식 텍스트 클라이언트를 만드는 방법을 보여 드리겠습니다.
이 시리즈의 다른 부분
- 1부: 소개
- 2부: 게임 엔진 서버 설계
- 4부: 게임에 채팅 추가하기
원래 디자인 검토
UI에 대한 기본 와이어프레임을 처음 제안했을 때 화면에 4개의 섹션을 제안했습니다.
이론상 맞는 것처럼 보이지만 게임 명령과 문자 메시지 전송 사이를 전환하는 것이 고통스럽다는 사실을 놓쳤으므로 플레이어가 수동으로 전환하는 대신 명령 파서가 게임이나 친구들과 소통하려고 합니다.
따라서 화면에 4개의 섹션이 있는 대신 이제 3개의 섹션이 있습니다.
최종 게임 클라이언트의 실제 스크린샷입니다. 왼쪽에는 게임 화면이, 오른쪽에는 채팅이 표시되며 하단에 하나의 공통 입력 상자가 있습니다. 우리가 사용하는 모듈을 사용하면 색상과 몇 가지 기본 효과를 사용자 정의할 수 있습니다. Github에서 이 코드를 복제하고 모양과 느낌으로 원하는 작업을 수행할 수 있습니다.
한 가지 주의 사항: 위의 스크린샷은 채팅이 애플리케이션의 일부로 작동하는 것을 보여주지만 이 기사에서는 프로젝트를 설정하고 동적 텍스트 UI 기반 애플리케이션을 만들 수 있는 프레임워크를 정의하는 데 중점을 둘 것입니다. 이 시리즈의 다음 장과 마지막 장에서 채팅 지원을 추가하는 데 중점을 둘 것입니다.
필요한 도구
Node.js로 CLI 도구를 생성할 수 있는 많은 라이브러리가 있지만 텍스트 기반 UI를 추가하는 것은 완전히 다른 일입니다. 특히, 나는 내가 원하는 것을 정확히 할 수 있게 해주는 단 하나의 (매우 완전한, 당신을 염두에 두는) 라이브러리를 찾을 수 있었습니다: Blessed.
이 라이브러리는 매우 강력하며 이 프로젝트에서 사용하지 않을 많은 기능(예: 그림자 만들기, 끌어서 놓기 등)을 제공합니다. 기본적으로 Node.js 바인딩이 없는 전체 ncurses 라이브러리(개발자가 텍스트 기반 UI를 만들 수 있는 C 라이브러리)를 다시 구현하며 JavaScript에서 직접 수행합니다. 따라서 필요하다면 내부 코드를 아주 잘 확인할 수 있습니다(꼭 해야 하는 경우가 아니면 권장하지 않는 것).
Blessed에 대한 문서는 상당히 광범위하지만 주로 제공된 각 방법에 대한 개별 세부 사항으로 구성되어 있으며(실제로 이러한 방법을 함께 사용하는 방법을 설명하는 자습서가 있는 것과 반대) 모든 곳에서 예제가 부족하므로 파고 들기 어려울 수 있습니다. 특정 방법이 어떻게 작동하는지 이해해야 하는 경우. 즉, 일단 이해하면 모든 것이 동일한 방식으로 작동합니다. 이는 모든 라이브러리나 언어(PHP를 보고 있습니다)가 일관된 구문을 갖고 있지 않기 때문에 큰 장점입니다.
그러나 문서는 제쳐두고; 이 라이브러리의 가장 큰 장점은 JSON 옵션을 기반으로 작동한다는 것입니다. 예를 들어 화면의 오른쪽 상단 모서리에 상자를 그리려면 다음과 같이 하면 됩니다.
var box = blessed.box({ top: '0', right: '0', width: '50%', height: '50%', content: 'Hello {bold}world{/bold}!', tags: true, border: { type: 'line' }, style: { fg: 'white', bg: 'magenta', border: { fg: '#f0f0f0' }, hover: { bg: 'green' } } });
상상할 수 있듯이 상자의 다른 측면(크기 등)도 정의되어 있으며, 이는 터미널의 크기, 테두리 유형 및 색상을 기반으로 완벽하게 동적일 수 있습니다. 심지어 호버 이벤트의 경우에도 마찬가지입니다. 어느 시점에서 프론트 엔드 개발을 수행했다면 둘 사이에 많은 겹침을 발견하게 될 것입니다.
여기서 말하려는 요점은 상자의 표현과 관련된 모든 것이 box
메서드에 전달된 JSON 개체를 통해 구성된다는 것입니다. 그 내용을 구성 파일로 쉽게 추출하고 이를 읽고 화면에 그릴 요소를 결정할 수 있는 비즈니스 로직을 생성할 수 있기 때문에 저에게는 완벽합니다. 가장 중요한 것은 일단 그려지면 어떻게 보일지 엿볼 수 있다는 것입니다.
이것은 이 모듈의 전체 UI 측면의 기반이 될 것입니다( 자세한 내용은 잠시 후에! ).
모듈의 아키텍처
이 모듈의 주요 아키텍처는 우리가 보여줄 UI 위젯에 전적으로 의존합니다. 이러한 위젯 그룹은 화면으로 간주되며 이러한 모든 화면은 단일 JSON 파일( /config
폴더 내에서 찾을 수 있음)에 정의됩니다.
이 파일에는 250줄 이상이 있으므로 여기에 표시하는 것은 의미가 없습니다. 온라인에서 전체 파일을 볼 수 있지만 그 중 일부는 다음과 같습니다.
"screens": { "main-options": { "file": "./main-options.js", "elements": { "username-request": { "type": "input-prompt", "params": { "position": { "top": "0%", "left": "0%", "width": "100%", "height": "25%" }, "content": "Input your username: ", "inputOnFocus": true, "border": { "type": "line" }, "style": { "fg": "white", "bg": "blue", "border": { "fg": "#f0f0f0" }, "hover": { "bg": "green" } } } }, "options": { "type": "window", "params": { "position": { "top": "25%", "left": "0%", "width": "100%", "height": "50%" }, "content": "Please select an option: \n1. Join an existing game.\n2. Create a new game", "border": { "type": "line" }, "style": { //... } } }, "input": { "type": "input", "handlerPath": "../lib/main-options-handler", //... } } }
"screens" 요소에는 애플리케이션 내부의 화면 목록이 포함됩니다. 각 화면에는 위젯 목록이 포함되어 있으며(이에 대해서는 잠시 후에 다루겠습니다) 모든 위젯에는 축복 관련 정의 및 관련 핸들러 파일(해당되는 경우)이 있습니다.
모든 "params" 요소(특정 위젯 내부)가 앞에서 본 메서드에서 예상한 실제 매개변수 집합을 나타내는 방법을 볼 수 있습니다. 여기에 정의된 나머지 키는 렌더링할 위젯 유형과 해당 동작에 대한 컨텍스트를 제공하는 데 도움이 됩니다.
몇 가지 관심 사항:
화면 핸들러
모든 화면 요소에는 해당 화면과 관련된 코드를 참조하는 파일 속성이 있습니다. 이 코드는 init
메소드가 있어야 하는 객체에 불과합니다(특정 화면의 초기화 로직은 내부에서 발생합니다). 특히, 메인 UI 엔진은 모든 화면의 init
메소드를 호출할 것이며, 이는 차례로 필요한 로직(즉, 입력 상자 이벤트 설정) 초기화를 담당해야 합니다.
다음은 애플리케이션이 플레이어에게 새로운 게임을 시작하거나 기존 게임에 참여하는 옵션을 선택하도록 요청하는 기본 화면의 코드입니다.
const logger = require("../utils/logger") module.exports = { init: function(elements, UI) { this.elements = elements this.UI = UI this. this.setInput() }, moveToIDRequest: function(handler) { return this.UI.loadScreen('id-requests', (err, ) => { }) }, createNewGame: function(handler) { handler.createNewGame(this.UI.gamestate.APIKEY, (err, gameData) => { this.UI.gamestate.gameID = gameData._id handler.joinGame(this.UI.gamestate, (err) => { return this.UI.loadScreen('main-ui', { flashmessage: "You've joined game " + this.UI.gamestate.gameID + " successfully" }, (err, ) => { }) }) }) }, setInput: function() { let handler = require(this.elements["input"].meta.handlerPath) let input = this.elements["input"].obj let usernameRequest = this.elements['username-request'].obj let usernameRequestMeta = this.elements['username-request'].meta let question = usernameRequestMeta.params.content.trim() usernameRequest.setValue(question) this.UI.renderScreen() let validOptions = { 1: this.moveToIDRequest.bind(this), 2: this.createNewGame.bind(this) } usernameRequest.on('submit', (username) => { logger.info("Username:" +username) logger.info("Playername: " + username.replace(question, '')) this.UI.gamestate.playername = username.replace(question, '') input.focus() input.on('submit', (data) => { let command = input.getValue() if(!validOptions[+command]) { this.UI.setUpAlert("Invalid option: " + command) return this.UI.renderScreen() } return validOptions[+command](handler) }) }) return input } }
보시다시피, init
메소드는 기본적으로 사용자 입력을 처리하기 위해 올바른 콜백을 구성하는 setupInput
메소드를 호출합니다. 이 콜백은 사용자의 입력(1 또는 2)에 따라 무엇을 할지 결정하는 논리를 담고 있습니다.
위젯 핸들러
일부 위젯(일반적으로 입력 위젯)에는 특정 구성 요소 뒤에 있는 논리가 포함된 파일을 참조하는 handlerPath
속성이 있습니다. 이것은 이전 화면 핸들러와 동일하지 않습니다. 이들은 UI 구성 요소에 그다지 신경 쓰지 않습니다. 대신 UI와 외부 서비스(예: 게임 엔진의 API)와 상호 작용하는 데 사용하는 라이브러리 간의 글루 논리를 처리합니다.
위젯 유형
위젯의 JSON 정의에 또 다른 사소한 추가 사항은 해당 유형입니다. 그들을 위해 정의된 Blessed라는 이름을 사용하는 대신, 나는 그들의 행동에 관해 더 많은 흔들림의 여지를 주기 위해 새로운 이름을 만들고 있습니다. 결국 창 위젯이 항상 "정보만 표시"하는 것은 아니거나 입력 상자가 항상 같은 방식으로 작동하지 않을 수도 있습니다.
이것은 미래에 필요할 때 해당 기능을 사용할 수 있도록 하기 위한 대부분의 선제적 조치였습니다.
다중 화면
메인 화면은 위의 스크린샷에서 보여드린 화면이지만, 게임에서 플레이어 이름이나 새로운 게임 세션을 생성하는지 또는 기존 세션에 참여하는지 여부와 같은 정보를 요청하려면 몇 개의 다른 화면이 필요합니다. 내가 처리한 방법은 이 모든 화면을 동일한 JSON 파일에 정의하는 것이었습니다. 그리고 한 화면에서 다음 화면으로 이동하기 위해 화면 핸들러 파일 내부의 논리를 사용합니다.
다음 코드 줄을 사용하여 간단히 이 작업을 수행할 수 있습니다.
this.UI.loadScreen('main-ui', (err ) => { if(err) this.UI.setUpAlert(err) })
잠시 후에 UI 속성에 대한 자세한 내용을 보여 드리겠습니다. 하지만 이 loadScreen
메서드를 사용하여 화면을 다시 렌더링하고 매개변수로 전달된 문자열을 사용하여 JSON 파일에서 올바른 구성 요소를 선택합니다. 매우 간단합니다.
코드 샘플
이제 이 기사의 고기와 감자인 코드 샘플을 확인할 시간입니다. 나는 단지 그 안에 있는 작은 보석이라고 생각하는 것을 강조할 것이지만, 언제든지 저장소에서 직접 전체 소스 코드를 볼 수 있습니다.
구성 파일을 사용하여 UI 자동 생성
이미 이 부분을 다루었지만 이 생성기의 이면을 자세히 살펴볼 가치가 있다고 생각합니다. 그 이면의 요점( /ui
폴더 안의 index.js 파일)은 이것이 Blessed 객체를 감싸는 래퍼라는 것입니다. 그리고 그 안에서 가장 흥미로운 메소드는 loadScreen
메소드입니다.
이 메서드는 특정 화면의 구성(구성 모듈을 통해)을 가져오고 해당 콘텐츠를 살펴보고 각 요소의 유형에 따라 올바른 위젯을 생성하려고 시도합니다.
loadScreen: function(sname, extras, done) { if(typeof extras == "function") { done = extras } let screen = config.get('screens.' + sname) let screenElems = {} if(this.screenElements.length > 0) { //remove previous screen this.screenElements.map( e => e.detach()) this.screen.realloc() } Object.keys(screen.elements).forEach( eName => { let elemObj = null let element = screen.elements[eName] if(element.type == 'window') { elemObj = this.setUpWindow(element) } if(element.type == 'input') { elemObj = this.setUpInputBox(element) } if(element.type == 'input-prompt') { elemObj = this.setUpInputBox(element) } screenElems[eName] = { meta: element, obj: elemObj } }) if(typeof extras === 'object' && extras.flashmessage) { this.setUpAlert(extras.flashmessage) } this.renderScreen() let logicPath = require(screen.file) logicPath.init(screenElems, this) done() },
보시다시피 코드는 약간 길지만 그 뒤에 숨겨진 논리는 간단합니다.
- 현재 특정 화면에 대한 구성을 로드합니다.
- 이전에 존재하는 모든 위젯을 정리합니다.
- 모든 위젯을 살펴보고 인스턴스화합니다.
- 추가 경고가 플래시 메시지로 전달된 경우(기본적으로 다음 새로 고침까지 화면에 표시되도록 메시지를 설정하는 Web Dev에서 훔친 개념입니다);
- 실제 화면을 렌더링합니다.
- 마지막으로 화면 핸들러가 필요하고 "초기화" 메소드를 실행합니다.
그게 다야! 나머지 메서드를 확인할 수 있습니다. 대부분 개별 위젯 및 렌더링 방법과 관련이 있습니다.
UI와 비즈니스 로직 간의 통신
대규모로 UI, 백엔드 및 채팅 서버는 모두 어느 정도 계층화된 통신을 가지고 있습니다. 프론트 엔드 자체에는 순수 UI 요소가 이 특정 프로젝트 내부의 핵심 로직을 나타내는 기능 세트와 상호 작용하는 적어도 2계층 내부 아키텍처가 필요합니다.
다음 다이어그램은 우리가 만들고 있는 텍스트 클라이언트의 내부 아키텍처를 보여줍니다.
조금 더 설명하겠습니다. 위에서 언급했듯이 loadScreenMethod
는 위젯의 UI 프레젠테이션을 생성합니다(이것은 축복받은 객체입니다). 그러나 그것들은 기본 이벤트(예: 입력 상자에 대한 onSubmit
)를 설정하는 화면 논리 개체의 일부로 포함됩니다.
실제적인 예를 들어보겠습니다. 다음은 UI 클라이언트를 시작할 때 표시되는 첫 번째 화면입니다.
이 화면에는 세 개의 섹션이 있습니다.
- 사용자 이름 요청,
- 메뉴 옵션/정보,
- 메뉴 옵션에 대한 입력 화면입니다.
기본적으로 우리가 원하는 것은 사용자 이름을 요청한 다음 두 가지 옵션(새로운 게임을 시작하거나 기존 게임에 참여) 중 하나를 선택하도록 요청하는 것입니다.
이를 처리하는 코드는 다음과 같습니다.
module.exports = { init: function(elements, UI) { this.elements = elements this.UI = UI this. this.setInput() }, moveToIDRequest: function(handler) { return this.UI.loadScreen('id-requests', (err, ) => { }) }, createNewGame: function(handler) { handler.createNewGame(this.UI.gamestate.APIKEY, (err, gameData) => { this.UI.gamestate.gameID = gameData._id handler.joinGame(this.UI.gamestate, (err) => { return this.UI.loadScreen('main-ui', { flashmessage: "You've joined game " + this.UI.gamestate.gameID + " successfully" }, (err, ) => { }) }) }) }, setInput: function() { let handler = require(this.elements["input"].meta.handlerPath) let input = this.elements["input"].obj let usernameRequest = this.elements['username-request'].obj let usernameRequestMeta = this.elements['username-request'].meta let question = usernameRequestMeta.params.content.trim() usernameRequest.setValue(question) this.UI.renderScreen() let validOptions = { 1: this.moveToIDRequest.bind(this), 2: this.createNewGame.bind(this) } usernameRequest.on('submit', (username) => { logger.info("Username:" +username) logger.info("Playername: " + username.replace(question, '')) this.UI.gamestate.playername = username.replace(question, '') input.focus() input.on('submit', (data) => { let command = input.getValue() if(!validOptions[+command]) { this.UI.setUpAlert("Invalid option: " + command) return this.UI.renderScreen() } return validOptions[+command](handler) }) }) return input } }
코드가 많다는 것을 알고 있지만 init
메소드에만 집중하세요. 마지막으로 하는 일은 올바른 입력 상자에 올바른 이벤트를 추가하는 작업을 처리하는 setInput
메서드를 호출하는 것입니다.
따라서 다음 행으로:
let handler = require(this.elements["input"].meta.handlerPath) let input = this.elements["input"].obj let usernameRequest = this.elements['username-request'].obj let usernameRequestMeta = this.elements['username-request'].meta let question = usernameRequestMeta.params.content.trim()
나중에 submit
이벤트를 설정할 수 있도록 Blessed 개체에 액세스하고 참조를 가져옵니다. 따라서 사용자 이름을 제출한 후 포커스를 두 번째 입력 상자(말 그대로 input.focus()
사용)로 전환합니다.
메뉴에서 선택한 옵션에 따라 다음 방법 중 하나를 호출합니다.
-
createNewGame
: 연결된 핸들러와 상호 작용하여 새 게임을 만듭니다. -
moveToIDRequest
: 게임 ID 참여를 요청하는 다음 화면을 렌더링합니다.
게임 엔진과의 통신
마지막으로 중요한 것은(위의 예에 따라) 2를 누르면 createNewGame
메서드가 핸들러의 createNewGame
메서드를 사용한 다음 joinGame
(게임을 만든 직후 게임에 참여)을 사용하는 것을 알 수 있습니다.
이 두 가지 방법 모두 게임 엔진의 API와의 상호 작용을 단순화하기 위한 것입니다. 다음은 이 화면의 핸들러에 대한 코드입니다.
const request = require("request"), config = require("config"), apiClient = require("./apiClient") let API = config.get("api") module.exports = { joinGame: function(apikey, gameId, cb) { apiClient.joinGame(apikey, gameId, cb) }, createNewGame: function(apikey, cb) { request.post(API.url + API.endpoints.games + "?apikey=" + apikey, { //creating game body: { cartridgeid: config.get("app.game.cartdrigename") }, json: true }, (err, resp, body) => { cb(null, body) }) } }
이 동작을 처리하는 두 가지 다른 방법이 있습니다. 첫 번째 방법은 실제로 apiClient
클래스를 사용하며, 이 클래스는 GameEngine과의 상호 작용을 또 다른 추상화 계층으로 래핑합니다.
두 번째 방법은 올바른 페이로드가 포함된 올바른 URL에 POST 요청을 전송하여 직접 작업을 수행합니다. 이후에는 멋진 작업이 수행되지 않습니다. 우리는 응답의 본문을 UI 로직으로 다시 보내고 있습니다.
참고 : 이 클라이언트에 대한 전체 버전의 소스 코드에 관심이 있는 경우 여기에서 확인할 수 있습니다.
마지막 단어
이것은 우리의 텍스트 모험을 위한 텍스트 기반 클라이언트를 위한 것입니다. 나는 덮었다:
- 클라이언트 응용 프로그램을 구성하는 방법
- 프레젠테이션 레이어를 만들기 위한 핵심 기술로 Blessed를 사용한 방법
- 복잡한 클라이언트에서 백엔드 서비스와의 상호 작용을 구조화하는 방법
- 그리고 전체 리포지토리를 사용할 수 있기를 바랍니다.
UI가 원래 버전과 정확히 같지 않을 수 있지만 목적은 수행합니다. 바라건대, 이 기사가 그러한 노력을 설계하는 방법에 대한 아이디어를 제공했으며 미래에 직접 시도해 볼 의향이 있기를 바랍니다. Blessed는 확실히 매우 강력한 도구이지만 사용 방법과 문서 탐색 방법을 배우는 동안 인내심을 가져야 합니다.
다음 부분과 마지막 부분에서는 이 텍스트 클라이언트와 백엔드 모두에 채팅 서버를 추가하는 방법을 다룰 것입니다.
다음 편에서 만나요!
이 시리즈의 다른 부분
- 1부: 소개
- 2부: 게임 엔진 서버 설계
- 4부: 게임에 채팅 추가하기