본문 바로가기
Project

[게임 개발] 코드 리팩토링 + 데이터 저장 / 로드

by 배애앰이 좋아 2024. 12. 15.
반응형

 

안녕하세요!

 

오늘은 저번 글에 이어서 살짝 언급드린 코드 리팩토링한 부분에 대해 작성해볼까 합니다.

추가적으로 전 글을 읽지 않으면 어떤 식으로 바꿨는지 알기 어려워 전 글을 읽어주시길 바랍니다.

 

1. 사각 타일 맵 기반 게임 기초 만들기 Link : https://88-it.tistory.com/438

2. 자동 타일 맵 만들기 Link : https://88-it.tistory.com/439

 

리팩토링을 왜 갑자기 하게 되었나면 예전에 SOLID 객체지향적인 코드 작성에 대한 질문을 받은 적이 있습니다. 그때, 어떤 부분에 대해 어떻게 적용 시켰는데 경험을 들어 설명해달라고 했는데 제대로 설명하지 못했던 기억이 있습니다. 실제로 코드를 짤 때도 최대한 아는 선에서 분리하고 상속 등 활용하지만 인지하면서 구현하는 게 부족한 부분들이 많았던 것 같습니다.

 

그런 부분을 보완하기 위해 그리고 디자인 패턴, 알고리즘 등 사용해서 더 효율적으로 바꾸기 위해 시도해보게 되었습니다. 개인적으로 이론도 많이 찾아보았지만 무엇보다 챗지피티가 있어서 더 잘 수월하게 할 수 있지 않았나 싶습니다.

 

먼저, Enermy 코드에 대해 리팩토링한 결과입니다.

 

class MoveBehavior {
    move(enemy, blocks) {
        throw new Error("move() must be implemented.");
    }
}

class DistanceCalculator {
    calculate(currentBlock, target) {
        throw new Error("calculate() must be implemented.");
    }
}

// 장애물 고려한 움직임
class SimpleMove extends MoveBehavior {
    move(enemy, blocks) {
        let closeBlock = null;
        let closeBlockNum = -1;
        
        if (enemy.blockMap[enemy.currentPos].forwardB) {
            closeBlockNum = enemy.calculateDistance(closeBlockNum, closeBlock, enemy.blockMap[enemy.currentPos].forwardB);
            closeBlock = blocks[closeBlockNum];
        }
        if (enemy.blockMap[enemy.currentPos].backB) {
            closeBlockNum = enemy.calculateDistance(closeBlockNum, closeBlock, enemy.blockMap[enemy.currentPos].backB);
            closeBlock = blocks[closeBlockNum];
        }
        if (enemy.blockMap[enemy.currentPos].leftB) {
            closeBlockNum = enemy.calculateDistance(closeBlockNum, closeBlock, enemy.blockMap[enemy.currentPos].leftB);
            closeBlock = blocks[closeBlockNum];
        }
        if (enemy.blockMap[enemy.currentPos].rightB) {
            closeBlockNum = enemy.calculateDistance(closeBlockNum, closeBlock, enemy.blockMap[enemy.currentPos].rightB);
            closeBlock = blocks[closeBlockNum];
        }

        enemy.moveBlock(blocks[enemy.currentPos], closeBlock, closeBlockNum);
    }
}

// 장애물 고려하지 않은 움직임
class FollowPlayerMove extends MoveBehavior {
    move(enemy, blocks) {
        let closestBlock = null;
        let minDistance = Infinity;

        for (let i = 0; i < blocks.length; i++) {
            let distance = blocks[i].position.distanceTo(enemy.player.position);
            if (distance < minDistance) {
                minDistance = distance;
                closestBlock = blocks[i];
            }
        }

        enemy.moveBlock(blocks[enemy.currentPos], closestBlock, closestBlock);
    }
}

// 맨하탄 거리 계산법
class ManhattanDistanceCalculator extends DistanceCalculator {
    calculate(currentBlock, target) {
        return Math.abs(currentBlock.position.x - target.position.x) + Math.abs(currentBlock.position.z - target.position.z);
    }
}

// 유클리드 거리 계산법
class EuclideanDistanceCalculator extends DistanceCalculator {
    calculate(currentBlock, target) {
        let dx = currentBlock.position.x - target.position.x;
        let dz = currentBlock.position.z - target.position.z;
        return Math.sqrt(dx * dx + dz * dz); 
    }
}

class EnemyAnimation {
    static moveBlock(enemy, startB, endB, nextPos) {
        enemy.time = 0;
        const tween = new TWEEN.Tween({ x: startB.position.x, y: startB.position.y + 2, z: startB.position.z })
            .to({ x: endB.position.x, y: endB.position.y + 2, z: endB.position.z }, 1500 / enemy.moveSpeed)
            .onUpdate(({ x, y, z }) => {
                const currentHeight = Math.sin(enemy.time * 2 * enemy.moveSpeed) * 5;
                enemy.object.position.set(x, y + currentHeight, z);
            })
            .onComplete(() => {
                enemy.currentPos = nextPos;
                GLOBAL.GameSound.playEnermyFootSFX();

                let distanceToPlayer = enemy.object.position.distanceTo(enemy.player.position);
                if (distanceToPlayer < 8) {
                    REDBRICK.Signal.send("SHAKE_CAMERA", {
                        shakeDuration: 200, // 흔들리는 시간 500
                        shakeMagnitude: 0.4, // 흔들림 크기
                        shakeSize: 0.5
                    });
                }
            })
            .start();
    }
}

class Enermy {
    constructor(id, obj, currentPos, movingTerm, moveSpeed, blocks, blockMap, player) {
        this.id = id;
        this.object = obj;
        this.movingTerm = movingTerm;
        this.moveSpeed = moveSpeed;
        this.blocks = blocks;
        this.blockMap = blockMap;
        this.currentPos = currentPos;
        this.player = player;

        this.time = 0;
        this.currentTime = 0;
        this.isGameStart = false;
        this.isCatchPlayer = false;
        
        this.currentlevelUpTime = 0;
        this.levelUpTime = 30;
        this.level = 0;

        this.moveBehavior = new SimpleMove(); // 기본 이동 방식
        this.distanceCalculator = new EuclideanDistanceCalculator(); // 기본 거리 계산 방식
        this.object.position.set(this.blocks[this.currentPos].position.x , this.blocks[this.currentPos].position.y+2, this.blocks[this.currentPos].position.z);
        this.init();
    }

    init() {
        this.time = 0;
        this.currentTime = 0;
        this.isGameStart = false;
        this.isCatchPlayer = false;
    }

    setMoveBehavior(moveBehavior) {
        this.moveBehavior = moveBehavior;
    }

    setDistanceCalculator(distanceCalculator) {
        this.distanceCalculator = distanceCalculator;
    }

    calculateDistance(currentCloseBlockNum, currentCloseBlock, otherBlockNum) {
        if (currentCloseBlockNum !== -1) {
            let obj = this.blocks[otherBlockNum - 1];
            let diff1 = this.distanceCalculator.calculate(currentCloseBlock, this.player);
            let diff2 = this.distanceCalculator.calculate(obj, this.player);
            if (diff1 > diff2) {
                return otherBlockNum - 1;
            } else {
                return currentCloseBlockNum;
            }
        } else {
            return otherBlockNum - 1;
        }
    }

    move() {
        this.moveBehavior.move(this, this.blocks);
    }

    update(dt) {
        if (this.isGameStart) {
            this.time += dt;
            this.currentTime += dt;
            this.currentlevelUpTime += dt;
            
            if (this.currentTime > this.movingTerm) {
                this.move();
                this.currentTime = 0;
            }

            let distanceToPlayer = this.object.position.distanceTo(this.player.position);
            if (distanceToPlayer < 3 && this.isCatchPlayer === false) {
                this.isCatchPlayer = true;
                GLOBAL.GameSound.catchPlayerSFX();
                REDBRICK.Signal.send("GAME_OVER", {
                    enemyID: this.id
                });
            }
            
            if (this.currentlevelUpTime > this.levelUpTime) {
                this.level += 1;
                this.currentlevelUpTime = 0;
                if (this.movingTerm > 1.5) this.movingTerm -= 0.05;
                if (this.moveSpeed > 1.5) this.moveSpeed -= 0.1;
            }
        }
    }

    moveBlock(StartB, endB, nextPos) {
        EnemyAnimation.moveBlock(this, StartB, endB, nextPos);
    }
}

GLOBAL.Enermy = Enermy;

 

리팩토링이기 때문에 기능 적이 면에서는 똑같으나 구조가 바꿨습니다.

몬스터가 플레이어를 추적할 때, 추적 방식을 여러 개 테스트해보고 싶지만 테스트할 때마다 수정하지 않기 위해 전략 패턴을 사용하게 되었습니다. 전략 패턴을 사용하기 위해 움직임을 담당하는 class MoveBehavior 라는 추상 클래스를 만들고 이를 상속 받은 SimpleMove 을 통해 현재 움직임을 나타나게 하였습니다.

 

또한, 장애물을 고려하지 않은 움직임을 만들고 싶어 마찬가지로 MoveBehavior 상속받은 다른 클래스 FollowPlayerMove 를 만들어 setMoveBehavior() 함수를 통해 동적으로 바꿀 수 있게 하였습니다. 이렇게 만들어 놓으면 게임 중간에도 언제든 바꿀 수 있게 됩니다.

비슷한 원리로 거리 계산 방식도 기본 class DistanceCalculator 아래의 ManhattanDistanceCalculator, EuclideanDistanceCalculator 만들어 상위 객체의 수정없이 바꾸기 편하도록 만들었습니다.

 

SOLID 원칙을 적용시킨 부분

  1. 단일 책임 원칙 : MoveBehavior, DistanceCalculator, Enermy는 각각 하나의 책임을 가지고 있으며, 각 클래스가 해야 할 일이 명확하게 구분시켰습니다.
  2. 개방-폐쇄 원칙 : MoveBehavior나 DistanceCalculator는 새로운 이동 방식이나 거리 계산 방식을 추가할 수 있도록 확장 가능한 구조로 바꾸어 기존 코드를 수정하지 않고 새로운 클래스를 추가하는 것으로 기능을 확장할 수 있게 하였습니다.
  3. 인터페이스 분리 원칙 : MoveBehavior , DistanceCalculator 를 Enermy 으로부터 인터페이스를 분리하고 각 하나의 책임 만을 가지고 있습니다. 이를 통해 Enermy 가 동적으로 변경하기 유연하게 설계했습니다.
  4. 의존 역전 원칙 : Enermy 클래스는 구체적인 이동 방식이나 거리 계산 방식을 직접적으로 의존하지 않고, 추상화된 인터페이스인 MoveBehavior와 DistanceCalculator에 의존하게 하였습니다.

이쉽지만 3. 리스코프 치환 원칙 위 코드에서 두드러지게 나타나는 부분이 없는 것 같습니다.

 

그 다음은, ItemManager 코드에 대해 적어볼까 합니다.

이 부분은 리팩토링 하지 않았으나 코드 상 의미가 있는 부분과 어떻게 바꾸면 좋을지 작성해보겠습니다.

 

class ItemManager{
    constructor(itemList, itemListPercent, itemIDList, blocks, player, spawnTime){
        this.itemList = itemList;
        this.itemListPercent = itemListPercent;
        this.itemIDList = itemIDList;
        this.blocks = blocks;
        this.player = player;
        this.spawnTime = spawnTime;
        
        this.currentTime = 0;
        this.isGameStart = false;
        this.itemInfoList = [];
        this.getItemNum = 0;
        this.finialItemNum = itemList.length - 1;
        
        this.GameGUI = new GLOBAL.GameGUI();
        
        itemList.forEach((obj, index) => {
            const itemInfo = new Item(obj, player, itemIDList[index]);
            this.itemInfoList.push(itemInfo);
        })
        
        this.init();
    }
    init(){
        this.currentTime = 0;
        this.isGameStart = false;
        this.itemInfoList.forEach((item) => {
            item.isGet = false;
        });
    }
    Update(dt){
        if(this.isGameStart){
            this.currentTime += dt;
            if(this.currentTime > this.spawnTime){
                this.SpawnReward();
                this.currentTime = 0;
            }   
            this.itemInfoList.forEach((item) => {
                 item.Update(dt);
            })
        }
    }
    SpawnReward(){
        let randomObjectNum = this.getRandomObject(this.itemList, this.itemListPercent);
        while(randomObjectNum !== -1 && this.itemInfoList[randomObjectNum].isGet === true){
            randomObjectNum = this.getRandomObject(this.itemList, this.itemListPercent);
        }
        this.itemInfoList.forEach((itemInfo) => {
            if(itemInfo.onGame === true){
                itemInfo.Delete();
            }
        })
        if(randomObjectNum !== -1){
            let randomIndex = Math.floor(Math.random() * this.blocks.length);
            while(this.blocks[randomIndex].hasWall == true){
                randomIndex = Math.floor(Math.random() * this.blocks.length);
            }
            this.itemList[randomObjectNum].position.set(this.blocks[randomIndex].position.x, this.blocks[randomIndex].position.y + 2, this.blocks[randomIndex].position.z);
            this.itemInfoList[randomObjectNum].Create();
            this.itemInfoList[randomObjectNum].onGame = true;
            GLOBAL.GameSound.playSpwanItemSFX();
        }
    }
    getRandomObject(itemList, itemListPercent) {
        const randomValue = Math.random() * 100;
        let cumulativeProbability = 0;
        for (let i = 0; i < itemList.length; i++) {
            cumulativeProbability += itemListPercent[i];
            if (randomValue < cumulativeProbability) {
                if(i !== itemList.length-1) return i;
                else return -1;
            }
        }
    }
    Reset(){
        this.itemInfoList.forEach((itemInfo) => {
            if(itemInfo.onGame === true){
                itemInfo.Delete();
            }
        });
    }
    getItem(mapNumber, num){
        GLOBAL.GameSound.playGetItemSFX();
        this.getItemNum += 1;
        console.log("mapNumber : " + mapNumber + " this.getItemNum : "+ this.getItemNum);
        if(this.getItemNum === this.finialItemNum){
            REDBRICK.Signal.send("GAME_OVER", {
                enemyID: -1
            }); 
        }
        
        switch(mapNumber) {
            case 1:
                if(num == 0) GLOBAL.DataManager.addMap1Data(1, 0, 0);
                if(num == 1) GLOBAL.DataManager.addMap1Data(2, 0, 0);
                if(num == 2) GLOBAL.DataManager.addMap1Data(3, 0, 0);
                if(num == 3) GLOBAL.DataManager.addMap1Data(0, 1, 0);
                if(num == 4) GLOBAL.DataManager.addMap1Data(0, 2, 0);
                if(num == 5) GLOBAL.DataManager.addMap1Data(0, 3, 0);
                if(num == 6) GLOBAL.DataManager.addMap1Data(0, 0, 5);
                break;
            case 2:
                if(num == 0) GLOBAL.DataManager.addMap6Data(1, 0);
                if(num == 1) GLOBAL.DataManager.addMap6Data(2, 0);
                if(num == 2) GLOBAL.DataManager.addMap6Data(3, 0);
                if(num == 3) GLOBAL.DataManager.addMap6Data(0, 1);
                if(num == 4) GLOBAL.DataManager.addMap6Data(0, 2);
                if(num == 5) GLOBAL.DataManager.addMap6Data(0, 3);
                break;
            case 3:
                if(num == 0) GLOBAL.DataManager.addMap3Data(1);
                if(num == 1) GLOBAL.DataManager.addMap3Data(2);
                break;
            case 4:
                if(num == 0) GLOBAL.DataManager.addMap4Data(1, 0, 0);
                if(num == 1) GLOBAL.DataManager.addMap4Data(2, 0, 0);
                if(num == 2) GLOBAL.DataManager.addMap4Data(3, 0, 0);
                if(num == 3) GLOBAL.DataManager.addMap4Data(0, 1, 0);
                if(num == 4) GLOBAL.DataManager.addMap4Data(0, 2, 0);
                if(num == 5) GLOBAL.DataManager.addMap4Data(0, 3, 0);
                if(num == 6) GLOBAL.DataManager.addMap4Data(0, 0, 5);
                break;
            case 5:
                if(num == 0) GLOBAL.DataManager.addMap5Data(1, 0, 0);
                if(num == 1) GLOBAL.DataManager.addMap5Data(2, 0, 0);
                if(num == 2) GLOBAL.DataManager.addMap5Data(3, 0, 0);
                if(num == 3) GLOBAL.DataManager.addMap5Data(0, 1, 0);
                if(num == 4) GLOBAL.DataManager.addMap5Data(0, 2, 0);
                if(num == 5) GLOBAL.DataManager.addMap5Data(0, 3, 0);
                if(num == 6) GLOBAL.DataManager.addMap5Data(0, 0, 5);
                break;
            case 6:
                if(num == 0) GLOBAL.DataManager.addMap2Data(1, 0, 0);
                if(num == 1) GLOBAL.DataManager.addMap2Data(0, 1, 0);
                if(num == 2) GLOBAL.DataManager.addMap2Data(0, 0, 1);
                break;
            default:
                break;
        }
        
        setTimeout(() => {
            this.GameGUI.ShowItemBoard(mapNumber);
        }, 2000)
    }
}

class Item{
    constructor(object, player, itemID){
        this.object = object;
        this.player = player;
        this.itemID = itemID;
        this.isGet = false;
        this.speed = 3;
        this.onGame = false;
    }
    Create(){
        this.onGame = false;
        this.object.revive();
    }
    Update(dt){
        if(this.onGame){
            this.object.rotation.y += dt * this.speed; 
            let distanceToPlater = this.object.position.distanceTo(this.player.position);
            if(distanceToPlater < 3 && this.isGet === false){
                this.isGet = true;
                this.object.kill();
                this.onGame = false;
                REDBRICK.Signal.send("GET_ITEM", {
                    itemID: this.itemID
                });  
            }   
        }
    }
    Delete(){
        this.onGame = false;
        this.object.kill();
    }
}

GLOBAL.ItemManager = ItemManager;

 

  1. 모듈 화 : ItemManager, Item 클래스를 구분하고 각 기능별로 모듈화하여 관리하기 쉽게 만들었습니다. ItemManager 에서는 아이템의 전체적인 관리, 생성, 삭제를 담당하고 Item 에서는 개별의 아이템의 상태와 행동을 관리하게 만들었는데 이를 통해서 코드의 재사용성을 높이고 유지보수하기 쉬워졌습니다.
  2. 팩토리 패턴 : ItemManager 내에서 ItemList, ItemIDList 배열을 기반으로 Item 객체를 생성하는데 생성 로직을 캡슈화하여 코드의 결합도를 낮추웠습니다.
  3. 옵저버 패턴 : 특정 상태 변화에서의 신호 전달을 통해 다른 오브젝트가 알림을 받고 동적으로 변화하는 디자인 패턴을 사용했습니다.
  4. 싱글턴 패턴 : 이를 통해 여러 클래스에서 공통으로 접근하는 객체를 쉽게 사용할 수 있게 하였습니다.

위와 같이 장점들이 있지만 반대로 SOLID 원칙에서는 아쉬운 점들이 있습니다.

먼저, ItemManager 클래스 안에 여러가지 역할들을 들어있어  ItemSpawner: 아이템을 생성하고 랜덤 위치에 배치하는 책임을 담당

ItemAcquisitionManager: 아이템 획득 처리 및 해당 이벤트를 관리 등 각 역할에 따른 별도의 클래스를 나누는 것이 단일 책임 원칙에 개선할 수 있으며 GLOBAL.DataManager.addMapXData 와 같은 구체적인 클래스에 의존하고 있어 이를 인터페이스를 통해 주입받는 방식으로 바꿔줘야 의존성 역전 원칙을 준수할 수 있다고 chatGPT가 말해주세요!

 

그 다음으로는 DataManager 로 데이터 저장 및 로드 관리하는 클래스입니다.

처음에는 노가다 방식으로 각 변수 별로 저장 했었는데 다른 분이 조언해주신 덕분에 객체 채로 JSON 을 이용해서 더 간편하게 하는 방식으로 바꾸었습니다. (전에도 JSON 사용했었는데 왜 잊어 버렸을까요…..ㅎㅎㅎ)

 

// 데이터 저장 
function saveData(data) {
    const jsonData = JSON.stringify(data); // 데이터를 JSON 문자열로 변환
    // 로컬 스토리지에 저장
    localStorage.setItem('gameData_puzzle', jsonData);
    console.log("데이터가 저장되었습니다.");
}

// 데이터 로드 
function loadData() {
    const jsonData = localStorage.getItem('gameData_puzzle'); // 로컬 스토리지에서 데이터 가져오기
    if (jsonData) {
        const data = JSON.parse(jsonData); // JSON 문자열을 객체로 변환
        console.log("데이터가 로드되었습니다:", data);
        return data;
    } else {
        console.log("저장된 데이터가 없습니다.");
        return null;
    }
}

class DataManager {
    constructor(storage = new LocalStorageDataStorage()) {
        this.storage = storage;
        this.mapData = loadData() || {}; // 로드된 데이터가 있으면 그 값을 사용, 없으면 빈 객체

        this.initData();
    }

    initData() {
        this.pistolPiece = this.mapData.pistolPiece || 0;
        this.knifePiece = this.mapData.knifePiece || 0;
        this.pistolBullet = this.mapData.pistolBullet || 0;

        this.foodNum = this.mapData.foodNum || 0;
        this.waterNum = this.mapData.waterNum || 0;

        this.medicineNum = this.mapData.medicineNum || 0;

        this.key1 = this.mapData.key1 || 0;
        this.key2 = this.mapData.key2 || 0;
        this.key3 = this.mapData.key3 || 0;

        this.riflePiece = this.mapData.riflePiece || 0;
        this.pipePiece = this.mapData.pipePiece || 0;
        this.rifleBullet = this.mapData.rifleBullet || 0;

        this.axePiece = this.mapData.axePiece || 0;
        this.sniperPiece = this.mapData.sniperPiece || 0;
        this.sniperBullet = this.mapData.sniperBullet || 0;
    }

    addMap1Data(pistolPiece, knifePiece, pistolBullet) {
        this.pistolPiece += pistolPiece;
        this.knifePiece += knifePiece;
        this.pistolBullet += pistolBullet;

        this.saveGameData();
    }

    addMap2Data(foodNum, waterNum) {
        this.foodNum += foodNum;
        this.waterNum += waterNum;

        this.saveGameData();
    }

    addMap3Data(medicineNum) {
        this.medicineNum += medicineNum;

        this.saveGameData();
    }

    addMap4Data(key1, key2, key3) {
        this.key1 += key1;
        this.key2 += key2;
        this.key3 += key3;

        this.saveGameData();
    }

    addMap5Data(riflePiece, pipePiece, rifleBullet) {
        this.riflePiece += riflePiece;
        this.pipePiece += pipePiece;
        this.rifleBullet += rifleBullet;

        this.saveGameData();
    }

    addMap6Data(axePiece, sniperPiece, sniperBullet) {
        this.axePiece += axePiece;
        this.sniperPiece += sniperPiece;
        this.sniperBullet += sniperBullet;

        this.saveGameData();
    }

    saveGameData() {
        const gameData = {
            pistolPiece: this.pistolPiece,
            knifePiece: this.knifePiece,
            pistolBullet: this.pistolBullet,

            foodNum: this.foodNum,
            waterNum: this.waterNum,

            medicineNum: this.medicineNum,

            key1: this.key1,
            key2: this.key2,
            key3: this.key3,

            riflePiece: this.riflePiece,
            pipePiece: this.pipePiece,
            rifleBullet: this.rifleBullet,

            axePiece: this.axePiece,
            sniperPiece: this.sniperPiece,
            sniperBullet: this.sniperBullet
        };

        saveData(gameData); // 데이터 저장
    }
}

class LocalStorageDataStorage {
    getItem(key) {
        return localStorage.getItem(key);
    }

    setItem(key, value) {
        localStorage.setItem(key, value);
    }
}

GLOBAL.DataManager = new DataManager();

 

이렇게 바꾸니 전에 데이터 저장소에 반영된 값을 바로 읽어드릴 수 없던 오류도 해결했습니다.

 

코드 짜는 데 여러가지를 고려해야해서 어려운 것 같습니다ㅠㅠ 또 익숙하지 않으니 처음부터 적용해서 구현하기도 어려운 것 같습니다. 계속 공부하고 인식하고 바꾸다 보면 언젠가 능숙하게 짜는 날이 올거라 믿습니다. (빨리 고급 개발자가 되고 싶네요)

 

여기까지 긴 글 읽어주셔서 감사합니다.

 

반응형

댓글