왜 데이터를 코드 밖으로 빼야 할까
게임이 짧을 때는 모든 값을 코드에 직접 써도 큰 문제가 없다.
this.playerSpeed = 220;
this.enemyHp = 3;
this.bgmVolume = 0.5;
this.currentLevel = 1;하지만 레벨이 늘고, 적 종류가 많아지고, 옵션 화면이 생기면 이야기가 달라진다. 점프 높이를 바꾸려고 플레이어 코드를 열고, 적 체력을 바꾸려고 Enemy 클래스를 수정하고, 레벨 순서를 바꾸려고 Scene 코드를 뒤지는 상황이 생긴다.
이 장의 핵심은 간단하다.
게임 규칙을 실행하는 코드는 코드에 두고, 자주 바뀌는 값은 데이터로 분리한다.
저장 데이터, 옵션, 레벨 정보, 아이템 능력치, 적 능력치, 밸런스 값은 코드보다 데이터에 가깝다. 데이터로 분리하면 수정이 쉬워지고, 테스트 속도가 빨라지고, 나중에 스테이지나 언어를 추가하기도 편해진다.
이번 장에서는 세 가지 데이터를 나누어 다룬다.
브라우저에 저장할 데이터: 최고 점수, 진행 상황, 옵션
JSON 파일로 관리할 데이터: 레벨, 적, 아이템, 밸런스 값
개발 중에만 쓸 데이터: 치트 키, 디버그 메뉴
AI 생성 이미지
localStorage로 간단한 저장 만들기
브라우저 게임에서 가장 쉽게 사용할 수 있는 저장 공간은 localStorage다. 별도 서버 없이 사용자의 브라우저에 문자열 데이터를 저장할 수 있다.
localStorage는 값으로 문자열만 저장한다. 그래서 객체를 저장할 때는 JSON.stringify()로 문자열로 바꾸고, 다시 읽을 때는 JSON.parse()로 객체로 되돌린다.
먼저 저장 데이터의 기본 형태를 정하자.
const SAVE_KEY = 'my-phaser-game-save';
const DEFAULT_SAVE = {
version: 1,
highScore: 0,
unlockedLevel: 1,
options: {
bgmVolume: 0.5,
sfxVolume: 0.8,
controlType: 'keyboard',
language: 'ko'
}
};저장 데이터에는 반드시 기본값이 있어야 한다. 처음 실행한 사용자, 저장 파일이 손상된 사용자, 예전 버전에서 넘어온 사용자 모두를 처리해야 하기 때문이다.
기본값을 그대로 반환하면 나중에 실수로 DEFAULT_SAVE 자체를 바꿀 수 있다. 그래서 새 저장 데이터를 만들 때는 복사본을 만든다. 요즘 브라우저에서는 structuredClone()을 사용할 수 있지만, 여기서는 단순한 데이터만 다루므로 JSON 방식으로 복사해도 충분하다.
function createDefaultSave() {
return JSON.parse(JSON.stringify(DEFAULT_SAVE));
}이제 저장과 불러오기를 담당하는 작은 관리 객체를 만든다.
function normalizeSave(data) {
const safeData = data && typeof data === 'object' ? data : {};
const safeOptions = safeData.options && typeof safeData.options === 'object'
? safeData.options
: {};
return {
...createDefaultSave(),
...safeData,
options: {
...DEFAULT_SAVE.options,
...safeOptions
}
};
}
const SaveManager = {
load() {
try {
const text = localStorage.getItem(SAVE_KEY);
if (!text) {
return createDefaultSave();
}
const data = JSON.parse(text);
return normalizeSave(data);
} catch (error) {
console.warn('저장 데이터를 읽을 수 없습니다. 기본값을 사용합니다.', error);
return createDefaultSave();
}
},
save(data) {
try {
const normalizedData = normalizeSave(data);
localStorage.setItem(SAVE_KEY, JSON.stringify(normalizedData));
return true;
} catch (error) {
console.warn('저장 데이터를 저장할 수 없습니다.', error);
return false;
}
},
clear() {
try {
localStorage.removeItem(SAVE_KEY);
} catch (error) {
console.warn('저장 데이터를 삭제할 수 없습니다.', error);
}
}
};여기서 중요한 부분은 try...catch다. 저장 데이터는 항상 정상이라고 가정하면 안 된다. 사용자가 개발자 도구에서 값을 바꿀 수도 있고, 이전 버전 데이터가 현재 구조와 다를 수도 있다. 브라우저 설정에 따라 저장소 접근이 실패할 수도 있다. 읽기에 실패하면 게임이 멈추는 대신 기본값으로 복구하는 편이 안전하다.
또 하나 기억할 점이 있다. localStorage는 편리하지만 보안 저장소는 아니다. 사용자가 직접 내용을 볼 수도 있고 바꿀 수도 있다. 최고 점수, 옵션, 싱글 플레이 진행 상황처럼 조금 바뀌어도 큰 문제가 없는 데이터에 적합하다. 비밀번호, 결제 정보, 서버에서 검증해야 하는 중요한 값은 여기에 저장하면 안 된다.
최고 점수와 진행 상황 저장하기
슈팅 게임에서는 최고 점수를 저장하고, 플랫폼 게임에서는 클리어한 레벨을 저장할 수 있다.
게임이 시작될 때 저장 데이터를 읽어 Scene에 보관한다.
class PlayScene extends Phaser.Scene {
create() {
this.saveData = SaveManager.load();
this.score = 0;
this.currentLevel = this.saveData.unlockedLevel;
}
}게임 오버가 되었을 때 현재 점수가 최고 점수보다 높다면 저장한다. Scene 안의 메서드로 작성하면 this.score, this.saveData, this.scene을 자연스럽게 사용할 수 있다.
class PlayScene extends Phaser.Scene {
finishGame() {
if (this.score > this.saveData.highScore) {
this.saveData.highScore = this.score;
SaveManager.save(this.saveData);
}
this.scene.start('GameOverScene', {
score: this.score,
highScore: this.saveData.highScore
});
}
}레벨 클리어도 같은 방식이다.
class PlayScene extends Phaser.Scene {
clearLevel(levelNumber) {
const nextLevel = levelNumber + 1;
if (nextLevel > this.saveData.unlockedLevel) {
this.saveData.unlockedLevel = nextLevel;
SaveManager.save(this.saveData);
}
this.scene.restart({ level: nextLevel });
}
}저장은 너무 자주 하지 않는 것이 좋다. 점수가 1점 오를 때마다 저장하기보다, 게임 오버, 레벨 클리어, 옵션 변경처럼 의미 있는 순간에 저장한다. localStorage는 간단하지만, 게임 루프 안에서 매 프레임 호출할 기능은 아니다.
옵션 저장 구조 만들기
옵션은 플레이어가 직접 바꾸는 데이터다. 대표적으로 사운드 볼륨, 조작 방식, 언어 설정이 있다.
const DEFAULT_OPTIONS = {
bgmVolume: 0.5,
sfxVolume: 0.8,
controlType: 'keyboard', // keyboard, touch, gamepad
language: 'ko'
};옵션은 저장 데이터 안에 넣어도 되고, 별도 키로 저장해도 된다. 작은 게임이라면 하나의 저장 데이터 안에 함께 넣는 편이 단순하다.
옵션 화면에서 볼륨을 바꾸면 즉시 게임에도 반영하고 저장한다. Phaser의 사운드 볼륨 값은 보통 0에서 1 사이로 다룬다.
setBgmVolume(value) {
this.saveData.options.bgmVolume = value;
this.sound.volume = value;
SaveManager.save(this.saveData);
}효과음 볼륨은 사운드를 재생할 때 적용할 수 있다.
function playSfx(scene, key) {
const saveData = SaveManager.load();
scene.sound.play(key, {
volume: saveData.options.sfxVolume
});
}다만 위 코드처럼 효과음을 재생할 때마다 저장 데이터를 다시 읽으면 낭비가 생긴다. 실제 프로젝트에서는 게임 시작 시 한 번 읽고, Registry나 별도 GameSettings 객체에 올려두는 방식이 좋다.
// BootScene 또는 MenuScene
const saveData = SaveManager.load();
this.registry.set('saveData', saveData);
this.registry.set('options', saveData.options);
// 다른 Scene
const options = this.registry.get('options');
this.sound.play('coin', { volume: options.sfxVolume });옵션 화면에서 값을 바꿀 때도 같은 객체를 수정하면 다른 Scene에서 읽는 값과 저장되는 값이 맞아떨어진다.
setSfxVolume(value) {
const saveData = this.registry.get('saveData');
saveData.options.sfxVolume = value;
this.registry.set('options', saveData.options);
SaveManager.save(saveData);
}조작 방식도 옵션으로 저장할 수 있다.
function readMoveInput() {
const options = this.registry.get('options');
if (options.controlType === 'touch') {
return this.readTouchInput();
}
return this.readKeyboardInput();
}이렇게 만들면 입력 코드를 완전히 바꾸지 않고도 옵션에 따라 다른 조작 방식을 선택할 수 있다.
언어 설정도 마찬가지다. 텍스트를 코드에 직접 쓰지 않고 데이터로 빼면 나중에 번역을 추가하기 쉽다.
{
"ko": {
"start": "시작",
"option": "설정",
"gameOver": "게임 오버"
},
"en": {
"start": "Start",
"option": "Options",
"gameOver": "Game Over"
}
}JSON 파일로 레벨 데이터 관리하기
저장 데이터가 사용자마다 달라지는 데이터라면, JSON 파일은 개발자가 미리 준비하는 게임 데이터다.
예를 들어 레벨 정보를 코드에 직접 쓰면 이런 형태가 된다.
this.enemyCount = 5;
this.timeLimit = 60;
this.nextLevel = 'level-2';레벨이 하나일 때는 괜찮지만, 20개가 되면 Scene 코드가 레벨 데이터로 가득 찬다. 이때 JSON 파일로 분리한다.
{
"id": "level-1",
"name": "첫 번째 숲",
"tilemapKey": "map-level-1",
"timeLimit": 90,
"playerStart": { "x": 120, "y": 360 },
"enemies": [
{ "type": "slime", "x": 520, "y": 340 },
{ "type": "bat", "x": 900, "y": 180 }
],
"items": [
{ "type": "coin", "x": 300, "y": 260 },
{ "type": "potion", "x": 760, "y": 300 }
]
}Phaser에서는 JSON 파일도 에셋처럼 불러올 수 있다.
preload() {
this.load.json('level-1-data', 'assets/data/levels/level-1.json');
}
create() {
const levelData = this.cache.json.get('level-1-data');
this.player.setPosition(
levelData.playerStart.x,
levelData.playerStart.y
);
levelData.enemies.forEach(enemyData => {
this.spawnEnemy(enemyData);
});
levelData.items.forEach(itemData => {
this.spawnItem(itemData);
});
}이제 레벨을 수정할 때 Scene 코드를 열 필요가 줄어든다. 적 위치, 아이템 위치, 제한 시간은 JSON에서 바꾸면 된다.
중요한 점은 JSON이 모든 것을 해결해 주지는 않는다는 것이다. JSON은 데이터일 뿐이다. 실제로 적을 만들고, 아이템을 배치하고, 충돌을 연결하는 코드는 여전히 필요하다. 다만 코드의 역할은 "값을 보관하는 곳"이 아니라 "데이터를 읽어 게임 오브젝트로 바꾸는 곳"으로 바뀐다.
적, 아이템, 밸런스 값을 데이터로 분리하기
레벨 데이터에는 "어디에 무엇이 있는가"를 넣고, 적이나 아이템의 공통 능력치는 별도 데이터로 관리하는 편이 좋다.
{
"slime": {
"hp": 2,
"speed": 60,
"damage": 1,
"score": 100
},
"bat": {
"hp": 1,
"speed": 120,
"damage": 1,
"score": 150
}
}아이템도 같은 방식으로 관리할 수 있다.
{
"coin": {
"score": 10,
"sound": "coin"
},
"potion": {
"heal": 1,
"sound": "heal"
}
}이 구조의 장점은 같은 적을 여러 레벨에서 재사용할 수 있다는 점이다. 레벨 JSON에는 type: "slime"만 적고, 실제 체력과 속도는 적 데이터에서 가져온다.
spawnEnemy(enemyData) {
const enemyStats = this.cache.json.get('enemy-stats');
const stats = enemyStats[enemyData.type];
if (!stats) {
console.warn(`알 수 없는 적 타입입니다: ${enemyData.type}`);
return;
}
const enemy = new Enemy(
this,
enemyData.x,
enemyData.y,
enemyData.type,
stats
);
this.enemies.add(enemy);
}타입 이름을 잘못 적었을 때 바로 경고가 나오게 하면 디버깅이 훨씬 쉬워진다. JSON 데이터가 많아질수록 이런 작은 확인 코드가 시간을 아껴 준다.
프로젝트 구조화와 TypeScript 적용에서 프로젝트를 entities, systems, data로 나누는 흐름을 살펴보았다. 이 장의 데이터 파일은 자연스럽게 assets/data나 src/data에 배치하면 된다.
예를 들면 이런 구조가 된다.
assets/
data/
levels/
level-1.json
level-2.json
enemies.json
items.json
balance.json
src/
scenes/
entities/
systems/
data/
SaveManager.js
constants.jsassets/data는 게임 실행 중 불러올 외부 JSON에 적합하다. src/data는 빌드 시 코드에 포함할 상수나 저장 관리 코드에 적합하다.
세이브 데이터 버전 관리하기
게임을 업데이트하면 저장 데이터 구조도 바뀐다.
처음에는 최고 점수만 저장했다고 해 보자.
{
"version": 1,
"highScore": 3000
}나중에 옵션과 해금 레벨이 추가되면 구조가 바뀐다.
{
"version": 2,
"highScore": 3000,
"unlockedLevel": 3,
"options": {
"bgmVolume": 0.5,
"sfxVolume": 0.8,
"controlType": "keyboard",
"language": "ko"
}
}이때 예전 저장 데이터를 무조건 버리면 플레이어의 진행 상황이 사라진다. 반대로 예전 데이터를 그대로 쓰면 options가 없어서 오류가 날 수 있다. 그래서 저장 데이터에는 version을 넣고, 필요할 때 변환한다.
const CURRENT_SAVE_VERSION = 2;
function migrateSave(data) {
let migratedData = data && typeof data === 'object' ? { ...data } : {};
if (!migratedData.version) {
migratedData.version = 1;
}
if (migratedData.version === 1) {
migratedData = {
...migratedData,
version: 2,
unlockedLevel: migratedData.unlockedLevel ?? 1,
options: {
...DEFAULT_SAVE.options,
...migratedData.options
}
};
}
migratedData.version = CURRENT_SAVE_VERSION;
return migratedData;
}SaveManager.load()에서 파싱 후 마이그레이션을 적용한다.
load() {
try {
const text = localStorage.getItem(SAVE_KEY);
if (!text) {
return createDefaultSave();
}
let data = JSON.parse(text);
data = migrateSave(data);
return normalizeSave(data);
} catch (error) {
console.warn('저장 데이터를 읽을 수 없습니다. 기본값을 사용합니다.', error);
return createDefaultSave();
}
}마이그레이션은 "예전 저장 데이터를 현재 구조로 바꾸는 작업"이다. 큰 프로젝트에서는 버전 1에서 2로, 2에서 3으로 순서대로 변환한다.
if (data.version === 1) {
data = migrateV1ToV2(data);
}
if (data.version === 2) {
data = migrateV2ToV3(data);
}저장 데이터는 한 번 만들어지면 사용자의 브라우저에 오래 남는다. 그래서 구조를 바꿀 때는 "이미 배포된 저장 데이터가 있다"는 사실을 항상 기억해야 한다.
테스트 속도를 높이는 치트 키
개발 중에는 매번 첫 레벨부터 플레이하면 시간이 많이 든다. 보스 패턴을 확인하려고 5분씩 진행하거나, 게임 오버 화면을 보려고 일부러 적에게 맞는 것은 비효율적이다.
이때 임시 치트 키를 만들면 테스트 속도가 빨라진다.
const params = new URLSearchParams(location.search);
const DEBUG = params.get('debug') === '1';
create() {
if (DEBUG) {
this.input.keyboard.on('keydown-L', () => {
this.clearLevel(this.currentLevel);
});
this.input.keyboard.on('keydown-H', () => {
this.player.heal(999);
});
this.input.keyboard.on('keydown-K', () => {
this.player.takeDamage(999);
});
}
}브라우저 주소에 ?debug=1을 붙였을 때만 동작하게 하면 실수로 일반 플레이에 노출될 가능성을 줄일 수 있다.
http://localhost:5173/?debug=1치트 키는 다음 상황에 유용하다.
다음 레벨로 바로 이동
플레이어 체력 회복 또는 즉시 사망
코인, 점수, 아이템 지급
보스 등장
현재 저장 데이터 삭제
충돌 박스 표시 켜기/끄기
예를 들어 저장 데이터를 지우는 테스트 키도 만들 수 있다.
this.input.keyboard.on('keydown-R', () => {
if (!DEBUG) return;
SaveManager.clear();
this.scene.restart();
});단, 치트 코드는 배포 전에 반드시 확인해야 한다. 완전히 제거하거나, 개발 모드에서만 켜지도록 막아야 한다. Vite 같은 개발 서버를 쓴다면 import.meta.env.DEV 같은 값을 함께 확인하는 방식도 사용할 수 있다.
const DEBUG = import.meta.env.DEV
&& new URLSearchParams(location.search).get('debug') === '1';간단한 디버그 메뉴 만들기
키 하나씩 외우기 불편하다면 디버그 메뉴를 만들 수 있다. 처음부터 멋진 UI가 필요하지는 않다. 화면 구석에 텍스트 버튼 몇 개만 있어도 충분하다.
createDebugMenu() {
if (!DEBUG) return;
const panel = this.add.rectangle(10, 10, 180, 140, 0x000000, 0.7)
.setOrigin(0)
.setScrollFactor(0)
.setDepth(9999);
const nextLevelText = this.add.text(20, 20, 'Next Level', {
fontSize: '14px',
color: '#ffffff'
})
.setScrollFactor(0)
.setDepth(10000)
.setInteractive();
nextLevelText.on('pointerdown', () => {
this.clearLevel(this.currentLevel);
});
const resetSaveText = this.add.text(20, 50, 'Reset Save', {
fontSize: '14px',
color: '#ffaaaa'
})
.setScrollFactor(0)
.setDepth(10000)
.setInteractive();
resetSaveText.on('pointerdown', () => {
SaveManager.clear();
this.scene.restart();
});
}디버그 메뉴도 UI다. 카메라가 움직이는 게임에서는 setScrollFactor(0)으로 화면에 고정하고, 다른 오브젝트에 가려지지 않도록 높은 depth를 준다.
디버그 메뉴의 목적은 플레이어에게 보여 주는 것이 아니라 개발자의 반복 작업을 줄이는 것이다. 그래서 예쁘게 만드는 것보다 빠르게 접근하고, 실수 없이 끄는 것이 더 중요하다.
데이터 관리의 기준
게임 데이터가 늘어나면 무엇을 코드에 두고 무엇을 JSON으로 뺄지 헷갈릴 수 있다. 다음 기준으로 판단하면 쉽다.
자주 바뀌는 숫자는 데이터로 뺀다.
playerSpeed, jumpPower, enemyHp, spawnInterval, itemScore레벨마다 달라지는 배치는 JSON으로 뺀다.
playerStart, enemies, items, timeLimit, nextLevel사용자마다 달라지는 값은 저장 데이터로 둔다.
highScore, unlockedLevel, options, clearedStages반대로 실제 행동 로직은 코드에 둔다.
move(), jump(), attack(), takeDamage(), spawnEnemy()데이터와 코드를 분리한다는 말은 모든 것을 JSON으로 만들라는 뜻이 아니다. 데이터는 "무엇인가"를 설명하고, 코드는 "어떻게 동작하는가"를 담당한다.
마무리
저장과 데이터 관리는 게임의 재미를 직접 만드는 기능처럼 보이지 않을 수 있다. 하지만 게임이 길어질수록 이 부분이 개발 속도를 크게 좌우한다.
localStorage로 최고 점수, 진행 상황, 옵션을 저장하면 플레이어의 선택과 성과가 유지된다. JSON 파일로 레벨과 능력치를 관리하면 코드를 고치지 않고도 게임 내용을 조정할 수 있다. 저장 데이터에 버전을 넣으면 업데이트 후에도 기존 플레이어의 데이터를 더 안전하게 다룰 수 있다. 치트 키와 디버그 메뉴는 반복 테스트 시간을 줄여 준다.
이제 게임은 한 번 플레이하고 끝나는 예제가 아니라, 진행 상황을 기억하고 설정을 유지하며 여러 데이터를 바탕으로 확장되는 프로젝트가 되었다. 다음 단계에서는 이렇게 커진 게임이 다양한 기기에서 안정적으로 동작하도록 성능과 모바일 대응을 살펴본다.
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.