Serverless로 GatherTown 스타일의 "Small Village" 구현 - Part 2

지난 글에서는 Small Village 프로젝트의 첫 번째 단계로 캐릭터 선택 기능을 구현했습니다. 이번 글에서는 선택한 캐릭터를 방향키로 이동하게 하고, Supabase를 사용하여 캐릭터의 위치를 다른 유저들과 실시간으로 동기화하는 기능을 추가하겠습니다.
아래 링크를 클릭해 "Small Village"를 바로 방문해 보실 수 있습니다.
👉 smallvillage.netlify.app
캐릭터 이미지 변경
우선 캐릭터 이미지 리소스를 더 다양하게 활용하기 위해, 이전에 사용한 3개의 캐릭터 대신, 구글링을 통해 40개의 캐릭터를 포함한 새로운 리소스(Jephed, Game Between The Lines, https://gamebetweenthelines.com)를 가져왔습니다. 이 이미지들은 캐릭터 단위로 개별적인 파일로 나뉘어 있으며, 각 캐릭터의 애니메이션을 구현하기 용이한 형태로 되어 있습니다.

이미지 프레임 구성은 다음과 같습니다.
0, 1, 2 프레임: 아래 방향 걷기
3, 4, 5 프레임: 왼쪽 방향 걷기
6, 7, 8 프레임: 오른쪽 방향 걷기
9, 10, 11 프레임: 위 방향 걷기
이를 바탕으로 Phaser에서 캐릭터 애니메이션을 생성하는 코드를 작성해 보겠습니다. 각 캐릭터마다 상하좌우로 걷는 애니메이션을 구현하여 실제 게임 환경에서 다양한 캐릭터의 움직임을 표현할 수 있습니다.
const NUM_CHARACTERS = 40;
preload() {
for (let i = 0; i < NUM_CHARACTERS; i++) {
const index = i.toString().padStart(3, "0");
this.load.spritesheet(`character_${i}`, `/assets/${index}.png`, {
frameWidth: 20,
frameHeight: 32,
});
}
}
createAnimations() {
for (let i = 0; i < NUM_CHARACTERS; i++) {
this.createWalkAnimation(i, `walk_down_${i}`, 0, 3); // 0,1,2 프레임
this.createWalkAnimation(i, `walk_left_${i}`, 3, 3); // 3,4,5 프레임
this.createWalkAnimation(i, `walk_right_${i}`, 6, 3); // 6,7,8 프레임
this.createWalkAnimation(i, `walk_up_${i}`, 9, 3); // 9,10,11 프레임
}
}
createWalkAnimation(
characterIndex: number,
key: string,
startFrame: number,
frameCount: number
): void {
this.anims.create({
key,
frames: this.anims.generateFrameNumbers(`character_${characterIndex}`, {
start: startFrame,
end: startFrame + frameCount - 1,
}),
frameRate: 3,
repeat: -1,
});
}
위 코드에서 preload()
는 40개의 캐릭터 이미지를 한 번에 로드하며, createAnimations()
에서는 각 캐릭터의 네 방향 걷기 애니메이션을 설정합니다.
방향키로 캐릭터 이동 구현하기
방향키 입력 구현에 들어가기 전에, phaser의 주요 메서드
preload()
,create()
,update()
를 간단히 살펴보겠습니다.
preload
: 게임이 시작되기 전에 한 번 호출되며, 필요한 리소스를 미리 로드합니다.
create
: 게임 오브젝트를 생성하고 초기 설정을 수행합니다.
update
: 초당 여러 번 호출되며, 사용자 입력을 받아 캐릭터 이동, 충돌 체크 등 게임의 상태를 지속적으로 업데이트합니다
Phaser의 입력 처리
이제 Phaser에서 방향키 입력을 처리하여 캐릭터가 상하좌우로 자연스럽게 움직일 수 있도록 구현해 보겠습니다. Phaser의 this.input.keyboard.createCursorKeys()
를 사용하여 방향키 입력을 처리할 수 있으며, isDown
상태를 통해 키가 눌린 방향으로 캐릭터를 이동시킬 수 있습니다.
async create() {
// 방향키 입력 설정
this.cursors = this.input.keyboard?.createCursorKeys() || null;
// 애니메이션 및 캐릭터 생성 코드
// 생략 ...
}
async update() {
const speed = 2;
if (this.cursors.left?.isDown) {
this.sprite.play(`walk_left_${this.characterIndex}`, true);
this.sprite.x -= speed;
} else if (this.cursors.right?.isDown) {
this.sprite.play(`walk_right_${this.characterIndex}`, true);
this.sprite.x += speed;
} else if (this.cursors.up?.isDown) {
this.sprite.play(`walk_up_${this.characterIndex}`, true);
this.sprite.y -= speed;
} else if (this.cursors.down?.isDown) {
this.sprite.play(`walk_down_${this.characterIndex}`, true);
this.sprite.y += speed;
} else {
this.sprite.anims.stop();
}
}
위 코드에서 create()
메서드에서는 방향키 입력을 감지할 수 있도록 설정하고, update()
메서드에서는 각 방향키 입력에 따라 캐릭터의 위치를 변경해 줍니다. 이 update()
메서드는 매 프레임마다 호출되기 때문에 자연스러운 캐릭터 이동이 가능합니다. this.characterIndex
은 내가 선택한 캐릭터의 index 입니다. 방향키가 입력되면 위에서 생성한 애니매이션 중에 선택된 캐릭터의 해당 방향의 애니메이션이 play 되는 거죠.

캐릭터와 이름 출력
이제 본인의 캐릭터와 해당 캐릭터 이름을 화면에 표시해 보겠습니다. Phaser.Text
객체를 활용하여 캐릭터 이름을 캐릭터 위에 출력할 수 있습니다.
async create() {
// 생략 ...
// 캐릭터 생성
this.sprite = this.physics.add
.sprite(width / 2, height / 2, `character_${this.characterIndex}`, 0)
.setScale(2)
.setCollideWorldBounds(true)
.setOrigin(0.5, 0.5);
// 유저 이름 text 생성
this.nameText = this.add
.text(
this.sprite.x,
this.sprite.y - 50,
this.characterName,
{
fontSize: "16px",
color: "#fff",
align: "center",
}
)
.setOrigin(0.5, 0.5);
// 생략 ...
}
자 이렇게 하면 본인 캐릭터는 준비가 끝났습니다. 이제 다른 유저와 실시간으로 위치 정보를 동기화 해 봅시다.

Supabase를 활용한 위치 동기화
다른 사용자들과 캐릭터 위치를 실시간으로 공유하기 위해 Supabase의 실시간 데이터베이스 기능을 활용해 보겠습니다. Supabase는 실시간 데이터베이스 기능을 제공하여, 캐릭터 위치 정보를 쉽게 동기화할 수 있습니다. 각 유저의 위치와 상태는 users 테이블에 저장되며, 모든 유저가 변경 사항을 구독함으로써 서로의 최신 위치 정보를 즉시 받아볼 수 있습니다.
Supabase 프로젝트 생성 및 설정
1. Supabase 프로젝트 생성
Supabase 대시보드에서 Create new project 버튼을 클릭하여 새로운 프로젝트를 생성합니다.

프로젝트가 생성되면 프로젝트의 URL과 API Key가 제공됩니다. 이 정보는 후속 설정에 필요하니 기록해 둡니다.

2. Supabase 패키지 설치
프로젝트에서 Supabase 클라이언트를 사용하기 위해, 다음 명령어로 @supabase/supabase-js
패키지를 설치합니다.
npm install @supabase/supabase-js --save
3. Supabase 클라이언트 설정
Supabase 클라이언트를 설정하기 위해 supabaseClient.ts
파일을 생성하고 다음 코드를 추가합니다. 환경 변수에 REACT_APP_SUPABASE_URL
과 REACT_APP_SUPABASE_KEY
값을 저장하여 보안을 유지합니다.
import { createClient } from "@supabase/supabase-js";
const supabaseUrl = process.env.REACT_APP_SUPABASE_URL as string;
const supabaseKey = process.env.REACT_APP_SUPABASE_KEY as string;
export const supabase = createClient(supabaseUrl, supabaseKey);
Users Table 생성
사용자 정보와 위치를 저장할 users 테이블을 생성합니다.
create table
public.users (
user_name text not null default ''::text,
character_index integer not null,
created_at timestamp with time zone not null default now(),
x integer not null,
y integer not null,
user_id text not null,
constraint users_pkey primary key (user_id)
) tablespace pg_default;
내 캐릭터의 상태 저장 및 업데이트
캐릭터가 생성된 후, 현재 상태를 users 테이블에 삽입하여 위치 정보를 기록합니다. 캐릭터가 움직일 때마다 위치가 업데이트되도록 구현하여 실시간 위치 동기화가 이루어질 수 있도록 합니다.
await supabase.from("users").insert({
user_id: this.userId,
user_name: this.characterName,
character_index: this.characterIndex,
x: Math.floor(this.sprite.x),
y: Math.floor(this.sprite.y),
});
그리고 캐릭터가 이동될때 마다 위치를 업데이트 합니다. phaser의 update()
는 매 프레임마다 호출되므로, 캐릭터의 위치가 실제로 변경되었을 때만 업데이트가 이루어지도록 조건을 추가합니다. 이렇게 하면 불필요한 업데이트 호출을 줄이고 성능을 최적화할 수 있습니다.
if (isMoving) {
await supabase.from("users").upsert({
user_id: this.userId,
user_name: this.characterName,
character_index: this.characterIndex,
x: Math.floor(this.sprite.x),
y: Math.floor(this.sprite.y),
});
}
다른 유저의 캐릭터 상태 동기화
Supabase 실시간 데이터베이스를 통해 모든 사용자가 다른 유저들의 최신 위치 정보를 실시간으로 반영할 수 있도록 구독 기능을 설정합니다. 실시간 데이터 수신 및 반영 과정은 다음과 같은 세 가지 단계로 이루어집니다.
1. 초기 데이터 로딩
컴포넌트가 처음 렌더링될 때, users 테이블에서 현재 접속한 유저 데이터를 불러옵니다. 각 유저의 user_id
, character_index
, user_name
, x
, y
정보를 가져와 SmallVillageScene 추가합니다. 이를 통해 초기 화면에 접속한 유저들의 상태를 표시할 수 있습니다.
useEffect(() => {
const fetchData = async () => {
const { data, error } = await supabase.from("users").select("*");
if (error) {
console.error("Error loading initial data:", error);
return;
}
gameInstanceRef.current?.scene.getScene("SmallVillageScene")?.updateUsers(data);
};
fetchData();
}, []);
2. 다른 유저의 캐릭터 상태 동기화
Supabase의 실시간 기능을 통해 users 테이블의 변경 사항을 실시간으로 구독하여, 다른 유저가 접속하거나 위치를 업데이트하거나, 게임에서 나갈 때마다 해당 변경 사항을 화면에 반영합니다.
INSERT 이벤트: 새로운 유저가 접속할 때 발생하며, 해당 유저의 캐릭터가 화면에 추가됩니다.
UPDATE 이벤트: 다른 유저의 위치가 변경되면, 캐릭터의 위치와 애니메이션이 업데이트됩니다.
DELETE 이벤트: 유저가 게임에서 나가거나 비활성 상태가 되면 발생하며, 해당 유저의 캐릭터가 화면에서 제거됩니다.
useEffect(() => {
const usersChannel = supabase
.channel("realtime:public:users")
.on("postgres_changes", { event: "INSERT", schema: "public", table: "users" }, (payload) => {
if (payload.new.user_id !== userId) {
const scene = gameInstanceRef.current?.scene.getScene("SmallVillageScene");
if (scene) {
scene.addUser(payload.new); // 새로운 유저를 추가
}
}
})
.on("postgres_changes", { event: "UPDATE", schema: "public", table: "users" }, (payload) => {
if (payload.new.user_id !== userId) {
const scene = gameInstanceRef.current?.scene.getScene("SmallVillageScene");
if (scene) {
scene.updateUser(payload.new); // 유저 위치 업데이트
}
}
})
.on("postgres_changes", { event: "DELETE", schema: "public", table: "users" }, (payload) => {
const scene = gameInstanceRef.current?.scene.getScene("SmallVillageScene");
if (scene) {
scene.removeUser(payload.old.user_id); // 유저 삭제
}
})
.subscribe();
return () => {
usersChannel.unsubscribe();
};
}, [userId]);
3. 캐릭터 상태를 SmallVillageScene에 반영
users 테이블의 실시간 정보를 기반으로 각 유저의 최신 위치 정보에 맞추어 캐릭터의 위치와 애니메이션이 변경되도록 합니다.
아래 코드는 업데이트 된 유저들 정보를 바탕으로 화면에 표시하는 함수입니다. 이 함수는 매 프레임마다 호출되는 phaser의 update()
에서 호출되어야 합니다.
실시간이기는 하지만 네트워크를 통해서 데이터를 받다 보니 움직임이 부자연스러울 수 있습니다. 트윈 애니메이션을 사용해서 위치가 큰 차이가 있을 때에도 부드러운 이동을 유지할 수 있습니다.
private updateOtherUsers() {
const MIN_DISTANCE = 2;
Object.entries(this.userSprites).forEach(([userId, userSprite]) => {
let isMoving = false;
const userData = this.users.find((u) => u.user_id === userId);
if (userData) {
const sprite = userSprite.sprite;
const distanceX = Math.abs(userData.x - sprite.x);
const distanceY = Math.abs(userData.y - sprite.y);
const characterIndex = userData.character_index;
// X축 이동 처리
if (distanceX > MIN_DISTANCE) {
if (userData.x < sprite.x) {
isMoving = true;
sprite.play(`walk_left_${characterIndex}`, true);
} else {
isMoving = true;
sprite.play(`walk_right_${characterIndex}`, true);
}
}
// Y축 이동 처리
if (distanceY > MIN_DISTANCE) {
if (userData.y < sprite.y) {
isMoving = true;
sprite.play(`walk_up_${characterIndex}`, true);
} else {
isMoving = true;
sprite.play(`walk_down_${characterIndex}`, true);
}
}
// 이동이 없는 경우 애니메이션 멈춤
if (!isMoving) {
sprite.anims.stop();
}
// 부드러운 이동을 위한 트윈 설정
this.tweens.add({
targets: sprite,
x: userData.x,
y: userData.y,
duration: 100,
ease: "Linear",
onUpdate: () => {
userSprite.nameText?.setPosition(sprite.x, sprite.y - 50);
},
});
}
});
}
자 이제 다 끝났습니다.

아직은 배경도 없고 채팅, 음성/영상 대화도 할 수 없지만 조금은 gather town 스럽지 않나요? ^^;
마치며
이번 글에서는 “Small Village” 프로젝트에서 캐릭터 이동과 위치 동기화를 구현하는 과정을 살펴보았습니다. Supabase의 실시간 데이터베이스 기능을 활용하여 서버리스 환경에서도 사용자 간의 위치 데이터를 실시간으로 공유하고, Phaser를 통해 캐릭터 애니메이션을 자연스럽게 구현하는 방법을 소개했습니다.
더 나은 구현 방법이나 개선점이 있다면 댓글로 알려주세요.
다음 글에서는 supabase의 Realtime 기능을 활용해서 간단한 채팅 기능을 넣어 보겠습니다. 프로젝트 전체 코드는 GitHub 저장소에서 확인하실 수 있습니다. https://github.com/hissinger/small-village