SkyOffice의 클라이언트 구조를 뜯어보자.
Phaser, Network class, Redux 등.. 고려할 요소가 많다.
↓ 서비스에 접속할 때 실행되는 phaser scene 들
import Phaser from ‘phaser’
import Game from ‘./scenes/Game’
import Background from ‘./scenes/Background’
import Bootstrap from ‘./scenes/Bootstrap’
import GameBootstrap from ‘./scenes/GameBootstrap’
import GameRoom from ‘./scenes/GameRoom’
import BrickGame from ‘./scenes/BrickGame’
const config: Phaser.Types.Core.GameConfig = {
type: Phaser.AUTO,
parent: ‘phaser-container’,
backgroundColor: ‘#93cbee’,
pixelArt: true, // Prevent pixel art from becoming blurred when scaled.
scale: {
mode: Phaser.Scale.ScaleModes.RESIZE,
width: window.innerWidth,
height: window.innerHeight,
},
physics: {
default: ‘arcade’,
arcade: {
gravity: { y: 0 },
debug: false,
},
},
autoFocus: true,
scene: [Bootstrap, Background, Game, GameBootstrap, GameRoom],
}
const phaserGame = new Phaser.Game(config)
;(window as any).game = phaserGame
export default phaserGame
해당 파일에서는 새로운 Phaser 인스턴스를 생성한다. 그리고 이 안에서 정의하고 있는 각각의 scene 들 (Bootstrap, Background, Game, ...) 의 인스턴스 역시 하나씩 생성된다.
↓ Bootstrap Scene
import Phaser from ‘phaser’
import Network from ‘../services/Network’
import { BackgroundMode } from ‘../../../types/BackgroundMode’
import store from ‘../stores’
import { setRoomJoined } from ‘../stores/RoomStore’
export default class Bootstrap extends Phaser.Scene {
private preloadComplete = false
network!: Network
constructor() {
super(‘bootstrap’)
}
preload() {
/* Preload assets */
}
init() {
this.network = new Network()
}
private launchBackground(backgroundMode: BackgroundMode) {
this.scene.launch(‘background’, { backgroundMode })
}
launchGame() {
if (!this.preloadComplete) return
this.network.webRTC?.checkPreviousPermission()
this.scene.launch(‘game’, {
network: this.network,
})
// update Redux state
store.dispatch(setRoomJoined(true))
}
changeBackgroundMode(backgroundMode: BackgroundMode) {
this.scene.stop(‘background’)
this.launchBackground(backgroundMode)
}
}
Bootstrap scene은 Network 인스턴스를 생성하고 Game scene을 launch해주는 중간단계 역할을 한다.
↓ Network는 인스턴스가 생성되자마자 lobbyroom을 join 하도록 되어있다.
export default class Network {
private client: Client
private room?: Room<IOfficeState>
private lobby!: Room
private gamelobby!: Room
webRTC?: WebRTC
mySessionId!: string
constructor(type) {
const protocol = window.location.protocol.replace(‘http’, ‘ws’)
const endpoint =
process.env.NODE_ENV === ‘production’
? import.meta.env.VITE_SERVER_URL
: `${protocol}//${window.location.hostname}:2567`
this.client = new Client(endpoint)
this.joinLobbyRoom(type).then(() => {
store.dispatch(setLobbyJoined(true))
})
phaserEvents.on(Event.MY_PLAYER_NAME_CHANGE, this.updatePlayerName, this)
phaserEvents.on(Event.MY_PLAYER_TEXTURE_CHANGE, this.updatePlayer, this)
phaserEvents.on(Event.PLAYER_DISCONNECTED, this.playerStreamDisconnect, this)
}
👉🏻 각각의 게임이 퀄리티가 높았으면 해서 Phaser Scene으로 각각의 게임을 만들고 싶었다. 이를 위해 각 game의 scene을 preload 할 수 있도록 게임을 위한 bootstrap 인스턴스를 하나 더 정의하고자 하였다.
game 을 위한 bootstrap 을 따로 정의해줘야 하는 이유
gamebootstrap를 새롭게 정의해서 gamebootstrap 인스턴스가 생성될 때 새로운 network 인스턴스를 생성하도록 하면 bootstrap에서 사용하는 network 인스턴스와는 또 다른 network 인스턴스가 생성되는 것으로, network의 constructor에 room_type을 넘겨줌으로서 게임마다 각각에게 맞는 lobby에 입장할 수 있도록 할 수 있다!
Preload와 관련해서 고민해봐야하는 사항
내가 플레이해봤던 게임들의 로딩방식을 생각해봤을 때 다음과 같은 선택지가 있을 것 같다.
한편으로 Network 인스턴스를 계속해서 생성했다가 제거하는 것도 자원이 많이 낭비될 수 있기 때문에 한번 생성해서 재사용하는 것이 바람직한 것 같다. 따라서 network가 bootstrap에 종속되어있는 현재 구조에서는 여러가지 자원 낭비를 고려해서 로그인할 때 한번에 게임에 관련된 asset도 load 하는 방향으로 진행하는 것이 좋을 것 같다. (미니게임의 가지수가 엄청 많은 것도 아니고, 엄청난 양의 asset이 존재하는 것도 아니기 때문에 entry 화면에서 login 화면으로 넘어가는 과정에서 로딩이 충분히 끝날 수 있을 정도의 양이라고 생각한다.)
결론은
1. 게임 시작 시 (Phaser 인스턴스 생성 시) GameBootstrap 인스턴스 및 Network 인스턴스를 생성한다.
2. 유저가 게임을 시작하기 위해 방 선택화면에 들어갈 때마다 lobbyroom을 세팅할 수 있도록 GameBootstrap 과 Network에서 lobby 참여 함수를 분리해준다.
👉🏻 하지만 결국 메인 맵과의 연결은 유지하면서 각각의 유저에게 화면만 게임을 위한 phaser scene으로 전환하는 것은 실패했다.. 그 정도까지 해보려면 Phaser에 대한 공부가 더 필요할 것 같다 ㅜㅜ
👉🏻 우선 시각적으로 게임이 되도록 하는게 우선이니, 메인 맵 위에 React Component를 overlay 해서 게임 화면을 구성하는 방향으로 진행해야할 것 같다.
게임을 위한 network를 별도로 정의하여 게임 서버와 통신할 수 있도록 구현하였다. Network를 분리해야하는 이유는 메인 맵에 연결된 Network에서는 Phaser scene을 위해서 서버와 player add, remove 등의 상황에 대한 통신을 한다. 따라서 메인 맵에서 다른 유저들이 들어오고 나가는 것 움직이는 것 등에 영향을 주지 않고 영향을 받지 않으면서 게임에 들어갔다 나오기 위해서는 network를 분리하는 것이 맞을 것이다. 또한, 메인 맵과 게임은 통신하는 내용이 완전히 다르니 아예 게임을 위한 새로운 class를 정의하는 것이 좋을 것 같다. (또다시 분리를 시도한다...)
'Game' Scene에서는 두개의 서로 다른 network class를 사용하도록 설계하였다.
이 두개의 network는 클라이언트에서 로그인 시 각각의 인스턴스가 바로 생성되며, 게임용 네트워크는 lobby room을 바로 생성하지 않는다. 게임룸을 퇴장한 후에도 해당 네트워크 인스턴스는(당연히) 살아있으며, 다른 게임에 접속하고자 할 때 또 다시 사용된다.
게임의 결과에 따라 사용자에게 적용되어야 하는 정보는 게임이 결과에 따라 바로 API로 게임 결과 정보를 DB에 저장한다. 다른 플레이어에게 실시가능로 업데이트되어야 하는 경우는 없을까? 만약 다른 유저가 랭킹보드를 확인하고 있다거나, 내 프로필을 조회하고 있는 상태라면, 나의 수정된 정보 (레벨 등)가 실시간으로 다른 유저의 화면에 업데이트 되도록 하는 것이 맞을까?
GameNetwork에 연결되어있는 room에 접근해서 player 정보를 얻어올 수 있지 않을까 하는 생각을 했으나 잘 되지 않았다..
따라서 server에서 플레이어 관련 변동사항이 있을 때마다 broadcast 해주는 방향으로 결정하였다.
클라이언트에서 변경사항을 받을 때마다 플레이어 리스트를 업데이트 해주는 방법도 있겠지만, 클라이언트에서 불필요한 연산량이 늘어날 뿐더러 게임에 참여한 유저간의 데이터 일관성을 보장하기 어렵다. 서버와 클라이언트가 주고받는 데이터의 양이 증가하긴 하겠지만, 데이터의 일관성을 위해 서버에서 데이터를 일괄적으로 전송하는 방식이 더 바람직하다고 판단하였다.
일반적으로 Redux 스토어의 상태가 변경될 때 액션 디스패치와 리듀서에서 상태 업데이트가 처리되며, 컴포넌트에 변경사항을 전달하기 위해서는 useEffect
를 사용하여 상태 변화를 감지해야 한다고 한다.
Redux 스토어에서 gamePlayers
값을 업데이트할 때, 해당 액션을 디스패치하는 시점에 useEffect
를 실행할 수 있습니다. 이를 위해 액션 디스패치 직후에 useEffect
를 호출하는 방식을 사용할 수 있다.
useEffect 함수의 조건배열에 해당 변수를 추가한다면, 해당하는 변수에 변경사항이 발생하였을 때 useEffect가 발동하게 되는 것이다.
👉🏻 GPT가 이 방법을 주로 추천해줘서.. 처음 구현을 시작했을 때는 useEffect를 사용하는 코드가 많았는데, 구현하면 할수록 useEffect의 불안정성으로 인한 단점들이 너무 많아서 (두번 호출된다던지, 굳이 useEffect를 안써도 되는 부분이라던지) 점점 빼게 되었다.
👉🏻 GPT는 useEffect를 사용해서 실시간으로 변경사항을 반영할 수 있다고 했는데, Redux Store에 저장된 변수를 useSelector로 가지고 온 경우 component에서 변경사항들이 바로 반영되는 것을 볼 수 있다. 예를들어 network 인스턴스가 서버로부터 받은 정보를 dispatch를 통해 store에 저장하면 store에서 수정된 사항이 바로 useSelector를 사용중인 react component에도 반영되는 구조인 것이다.
👉🏻 화면이 처음 로드될 때 한번만 실행되었으면 하는 코드들이 몇가지 있었는데, '화면이 로드될 때 한번만' 실행되기 위해서는 useEffect를 사용하는 것이 맞다.. 하지만 앞뒤 상황을 살펴봤을 때, 어떠한 버튼을 눌러서 해당 화면으로 넘어오게 되는 것이라면, 그 버튼을 누를 때 실행하도록 바꾸면 되는 것이 아닐까.. ⇒ 이렇게 바꿈으로서 서비스가 훨씬 더 안정적으로 돌아갈 수 있도록 할 수 있었다.