본문 바로가기
프로젝트

24/10/14 - [팀] 타워 디펜스 게임(2): 골드 검증과 CSV 파일(리팩토링도 진행)

by Jini_Lamp 2024. 10. 14.

이번에 만든 건 골드 검증과 CSV 파일의 데이터를 DB에 적재하는 기능이다.

 

골드 검증 부분은 이전에 만들었던 "점핑 액션 게임"의 점수 검증과 동일하다.(해당 부분은 아직 게시물이 작성되지 않았다... 하긴 해야하는데...ㅋㅋ)

 

 

 

골드 검증

먼저 골드 검증에 대한 기능을 설명하자면, 플레이어는 다음 스테이지로 넘어가는 상황에서 검증을 진행한다.

그렇다면 골드는 언제 사용되고, 얻을 수 있는가? 해당 부분을 정리하면 다음과 같다.

골드 소모
1. 타워를 구매할 때마다
2. 기지를 업그레이드 할 때마다

골드 획득

1.타워를 팔 때마다
2. 몬스터를 죽일 때마다

 

타워를 구매하면, 구매된 타워의 ID가 tower.model.js 파일의 towers 배열에 추가된다.

그러면 해당 배열을 가져와 저장된 ID를 확인하고, DB에 저장된 값가 비교하여 타워의 가격을 알아낸다. 그렇게 알아낸 값의 총합을 플레이어의 초기 골드 값(5000 골드)에서 뺀다.

 

기지 업그레이드도 마찬가지다.

기지가 업그레이드 된 만큼 가격을 추적하고, 해당 값들을 초기 골드 값에서 뺀다. 그렇게 되면 플레이어의 총 골드 사용 값을 알 수 있다.

 

아래 함수가 골드를 검증하는 코드이다.

다음 스테이지로 넘어갈 때, 스테이지 검증하는 부분에서 사용되며, 해당 부분에서 몬스터 사망 시 얻을 수 있는 총 점수를 검증하고 있다. 거기서 코드만 조금 수정하면 몬스터 사망 시 얻을 수 있는 총 값도 구할 수 있기 때문에 아래 함수에서는 매개 변수로 받아오기만 했다.

let numOfInitialTowers = 3; // 초기 타워 개수

export const goldCalculate = (currentTower, monsterGold, baseUpgradeArr) => {
  let totalGold = 0;
  // 구매한 타워 총 가격
  let totalTowerGold = 0;

  for (let i = numOfInitialTowers; i < currentTower.length; i++) {
    const towerInfo = getData().towers.find((a) => a.towerId === currentTower[i]);
    totalTowerGold += towerInfo.towerCost;
  }

  // 기지 업그레이드 가격
  let totalBaseGole = 0;
  for (let i = 1; i < baseUpgradeArr.length; i++) {
    const baseInfo = getData().bases.find((a) => a.baselevel === baseUpgradeArr[i]);
    totalBaseGole += baseInfo.baseUpgradeCost;
  }

  totalGold = moneyBase + monsterGold - totalTowerGold + baseTowerCost - totalBaseGole;

  return totalGold;
};

 

골드 획득도 골드 소모에서 사용한 방법과 비슷한데, 타워 판매에서는 조금 다른 방법을 사용하고 있다.

 

해당 프로젝트에서는 게임을 시작할 때, 기본적으로 타워를 3개 제공한다. 이 타워는 플레이어가 구매한 것이 아니므로 타워 구매에 포함해선 안된다. 따라서 구매한 타워의 총 값을 구할 땐 towers 배열에서 앞의 3칸은 건너뛰고 계산한다.

 

하지만 타워를 판매할 땐, 기본적으로 제공되었던 타워 또한 판매가 가능하다.

따라서 아래와 같은 코드가 추가되었다.

let baseTowerCost = 0;

export const baseTowerDelete = async (isDelete) => {
  if (isDelete && numOfInitialTowers !== 0) {
    numOfInitialTowers--;
    baseTowerCost += 1000;
  }
};

 

해당 코드에서는 판매된 타워가 기본적으로 생성되는 타워가 맞는지 확인하고, 그게 맞다면 기본적으로 제공하는 타워의 개수를 줄이고 그만큼 비용을 추가한다. 그렇게 추가된 값은 totalGold 변수에 더해진다.

 

전체 코드는 다음과 같다.

import { getData } from '../init/data.js';
import { moneyBase } from '../models/stage.model.js';

let numOfInitialTowers = 3; // 초기 타워 개수
let baseTowerCost = 0;

export const baseTowerDelete = async (isDelete) => {
  if (isDelete && numOfInitialTowers !== 0) {
    numOfInitialTowers--;
    baseTowerCost += 1000;
  }
};

export const goldCalculate = (currentTower, monsterGold, baseUpgradeArr) => {
  let totalGold = 0;
  // 구매한 타워 총 가격
  let totalTowerGold = 0;

  for (let i = numOfInitialTowers; i < currentTower.length; i++) {
    const towerInfo = getData().towers.find((a) => a.towerId === currentTower[i]);
    totalTowerGold += towerInfo.towerCost;
  }

  // 기지 업그레이드 가격
  let totalBaseGole = 0;
  for (let i = 1; i < baseUpgradeArr.length; i++) {
    const baseInfo = getData().bases.find((a) => a.baselevel === baseUpgradeArr[i]);
    totalBaseGole += baseInfo.baseUpgradeCost;
  }

  totalGold = moneyBase + monsterGold - totalTowerGold + baseTowerCost - totalBaseGole;

  return totalGold;
};

 

 

 

CSV 파일

이전 팀프로젝트에서 엑셀(xlsx) 파일을 DB에 연동시키는 작업을 진행한 적 있다.(링크)

 

하지만 당시에 엑셀 파일보다 csv 파일로 하는 게 더 좋다는 피드백을 들었다.

이유를 들어보니 엑셀 파일은 추가적인 메타데이터나 포멧팅 정보가 포함되어 있어 파일을 읽고 쓰는 데 더 많은 자원이 소모되며, 여러 기능을 포함하고 있기 때문에 파일 크기가 더 크고 복잡한 구조를 가진다고 했다.

 

반면에 csv 파일은 단순한 텍스트 형식이라 데이터를 읽고 쓰는 속도가 빨라 대용량 데이터를 다룰 때, 처리 시간이 엑셀보다 짧다고 한다. 또한 저장 공간도 절약된다고.

무엇보다 대부분의 DBMS에서 csv 파일을 직접적으로 사용하여 데이터를 입력하거나 내보낼 수 있는 기능을 제공하며, DB에 있는 테이블 구조와 유사하기 때문에 DB와 호환성이 좋다.

 

요약하자면 csv 파일은 단순하고 효율적이며, 처리 속도와 호환성이 뛰어나기 때문에 데이터를 다룰 때 훨씬 더 유연하다.

특히 단순한 데이터 저장 및 교환, 대용량 데이터 처리, DB 연동 등에서는 csv 파일이 더 좋은 선택이다.

 

더보기

해서, 작성된 코드는 다음과 같다.

import fs from 'fs';
import csv from 'csv-parser';
import { prisma } from '../src/utils/prisma/prisma.client.js';

// CSV 파일을 읽어서 JSON 형식으로 변환하는 함수
async function readCSV(filePath) {
  return new Promise((resolve, reject) => {
    const results = [];
    fs.createReadStream(filePath)
      .pipe(csv())
      .on('data', (data) => results.push(data))
      .on('end', () => resolve(results))
      .on('error', (error) => reject(error));
  });
}

// CSV 파일 읽기 (stage와 tower 데이터)
const stageDataJson = await readCSV('./data_DB/csv_file/stage_data.csv');
const towerDataJson = await readCSV('./data_DB/csv_file/tower_data.csv');
const monsterDataJson = await readCSV('./data_DB/csv_file/monster_data.csv');

// Stage 데이터 처리
const excelStageId = stageDataJson.map((item) => parseInt(item['stageId'], 10));
const dbStageData = await prisma.stage.findMany({
  select: { stageId: true, stageStartScore: true },
});
const dbStageId = dbStageData.map((item) => item.stageId);

// CSV에 없는 스테이지가 DB에 있는지 확인하고 삭제 처리
const deleteStage = dbStageId.filter((stageId) => !excelStageId.includes(stageId));
if (0 < deleteStage.length) {
  console.log('삭제할 스테이지 ID 목록: ' + deleteStage.join(', '));
  await prisma.stage.deleteMany({
    where: {
      stageId: { in: deleteStage },
    },
  });
} else {
  console.log('삭제할 스테이지가 없습니다.');
}

const promiseAllStages = [];
for (let i = 0; i < stageDataJson.length; i++) {
  const currentStage = stageDataJson[i];
  const stageIdInt = parseInt(currentStage['stageId'], 10);
  const stageStartScoreInt = parseInt(currentStage['stageStartScore'], 10);
  const isStageInDb = dbStageData.find((stage) => stage.stageId === stageIdInt);

  if (isStageInDb) {
    if (isStageInDb.stageStartScore !== stageStartScoreInt) {
      console.log(`스테이지 ${stageIdInt} 업데이트!!!!`);
      const promise = prisma.stage.update({
        data: { stageStartScore: stageStartScoreInt },
        where: { stageId: stageIdInt },
      });
      promiseAllStages.push(promise);
    }
  } else {
    console.log(`${stageIdInt} 삽입`);
    const promise = prisma.stage.create({
      data: {
        stageId: stageIdInt,
        stageStartScore: stageStartScoreInt,
      },
    });
    promiseAllStages.push(promise);
  }
}

// Tower 데이터 처리
const excelTowerId = towerDataJson.map((item) => parseInt(item['towerId'], 10));
const dbTowerData = await prisma.tower.findMany({
  select: {
    towerId: true,
    towerCost: true,
    towerAttack: true,
    towerSpeed: true,
    towerRange: true,
    img: true,
  },
});
const dbTowerId = dbTowerData.map((item) => item.towerId);

// CSV에 없는 타워가 DB에 있는지 확인하고 삭제 처리
const deleteTower = dbTowerId.filter((towerId) => !excelTowerId.includes(towerId));
if (0 < deleteTower.length) {
  console.log('삭제할 타워 ID 목록: ' + deleteTower.join(', '));
  await prisma.tower.deleteMany({
    where: {
      towerId: { in: deleteTower },
    },
  });
} else {
  console.log('삭제할 타워가 없습니다.');
}

const promiseAllTowers = [];
for (let i = 0; i < towerDataJson.length; i++) {
  const currentTower = towerDataJson[i];
  const towerIdInt = parseInt(currentTower['towerId'], 10);
  const towerCostInt = parseInt(currentTower['towerCost'], 10);
  const towerAttackInt = parseInt(currentTower['towerAttack'], 10);
  const towerSpeedInt = parseInt(currentTower['towerSpeed'], 10);
  const towerRangeInt = parseInt(currentTower['towerRange'], 10);

  const isTowerInDb = dbTowerData.find((tower) => tower.towerId === towerIdInt);

  if (isTowerInDb) {
    if (
      isTowerInDb.towerCost !== towerCostInt ||
      isTowerInDb.towerAttack !== towerAttackInt ||
      isTowerInDb.towerSpeed !== towerSpeedInt ||
      isTowerInDb.towerRange !== towerRangeInt ||
      isTowerInDb.img !== currentTower['img']
    ) {
      console.log(`타워 ${towerIdInt} 업데이트!!!!`);
      const promise = prisma.tower.update({
        data: {
          towerCost: towerCostInt,
          towerAttack: towerAttackInt,
          towerSpeed: towerSpeedInt,
          towerRange: towerRangeInt,
          img: currentTower['img'],
        },
        where: { towerId: towerIdInt },
      });
      promiseAllTowers.push(promise);
    }
  } else {
    console.log(`${towerIdInt} 삽입`);
    const promise = prisma.tower.create({
      data: {
        towerId: towerIdInt,
        towerCost: towerCostInt,
        towerAttack: towerAttackInt,
        towerSpeed: towerSpeedInt,
        towerRange: towerRangeInt,
        img: currentTower['img'],
      },
    });
    promiseAllTowers.push(promise);
  }
}

// Monster 데이터 처리
const excelMonsterId = monsterDataJson.map((item) => parseInt(item['monsterId'], 10));
const dbMonsterData = await prisma.monster.findMany({
  select: {
    monsterId: true,
    level: true,
    monsterHp: true,
    monsterAttack: true,
    spawnTime: true,
    img: true,
    monsterGold: true,
    monsterScore: true,
    monsterMoveSpeed: true,
  },
});
const dbMonsterId = dbMonsterData.map((item) => item.monsterId);

// CSV에 없는 몬스터가 DB에 있는지 확인하고 삭제 처리
const deleteMonster = dbMonsterId.filter((monsterId) => !excelMonsterId.includes(monsterId));
if (0 < deleteMonster.length) {
  console.log('삭제할 몬스터 ID 목록: ' + deleteMonster.join(', '));
  await prisma.monster.deleteMany({
    where: {
      monsterId: { in: deleteMonster },
    },
  });
} else {
  console.log('삭제할 몬스터가 없습니다.');
}

const promiseAllMonster = [];
for (let i = 0; i < monsterDataJson.length; i++) {
  const currentMonster = monsterDataJson[i];
  const monsterIdInt = parseInt(currentMonster['monsterId'], 10);
  const monsterLevelInt = parseInt(currentMonster['level'], 10);
  const monsterHpInt = parseInt(currentMonster['monsterHp'], 10);
  const monsterAttackInt = parseInt(currentMonster['monsterAttack'], 10);
  const monsterSpawnTimeInt = parseInt(currentMonster['spawnTime'], 10);
  const monsterGoldInt = parseInt(currentMonster['monsterGold'], 10);
  const monsterScoreInt = parseInt(currentMonster['monsterScore'], 10);
  const monsterMoveSpeednt = parseInt(currentMonster['monsterMoveSpeed'], 10);

  const isMonsterInDb = dbMonsterData.find((monster) => monster.monsterId === monsterIdInt);
  if (isMonsterInDb) {
    if (
      isMonsterInDb.level !== monsterLevelInt ||
      isMonsterInDb.monsterHp !== monsterHpInt ||
      isMonsterInDb.monsterAttack !== monsterAttackInt ||
      isMonsterInDb.spawnTime !== monsterSpawnTimeInt ||
      isMonsterInDb.monsterGold !== monsterGoldInt ||
      isMonsterInDb.monsterScore !== monsterScoreInt ||
      isMonsterInDb.monsterMoveSpeed !== monsterMoveSpeednt ||
      isMonsterInDb.img !== currentMonster['img']
    ) {
      console.log(`몬스터 ${monsterIdInt} 업데이트!!!`);
      const promise = prisma.monster.update({
        data: {
          level: monsterLevelInt,
          monsterHp: monsterHpInt,
          monsterAttack: monsterAttackInt,
          spawnTime: monsterSpawnTimeInt,
          monsterGold: monsterGoldInt,
          monsterScore: monsterScoreInt,
          monsterMoveSpeed: monsterMoveSpeednt,
          img: currentMonster['img'],
        },
        where: { monsterId: monsterIdInt },
      });
      promiseAllMonster.push(promise);
    }
  } else {
    console.log(`${monsterIdInt} 삽입`);
    const promise = prisma.monster.create({
      data: {
        monsterId: monsterIdInt,
        level: monsterLevelInt,
        monsterHp: monsterHpInt,
        monsterAttack: monsterAttackInt,
        spawnTime: monsterSpawnTimeInt,
        monsterGold: monsterGoldInt,
        monsterScore: monsterScoreInt,
        monsterMoveSpeed: monsterMoveSpeednt,
        img: currentMonster['img'],
      },
    });
    promiseAllMonster.push(promise);
  }
}

// 모든 Promise 완료 처리
const results = await Promise.allSettled([
  ...promiseAllStages,
  ...promiseAllTowers,
  ...promiseAllMonster,
]);

results.forEach((result, index) => {
  if (result.status === 'rejected') {
    console.log(`작업 ${index}: 실패`, result.reason);
  }
});

 

그런데 해당 코드는 공통적인 부분이 계속 반복된다.

이렇게 한 이유는 각각의 데이터 테이블마다 보유하고 있는 속성이 달라서인데, 아무리 생각해도 코드가 길어지고 별로 효율적이지 못한 코딩이라는 생각밖에 안 들었다.

 

해서, 수정된 코드는 다음과 같다.

더보기
import fs from 'fs';
import csv from 'csv-parser';
import { prisma } from '../src/utils/prisma/prisma.client.js';

// CSV 파일을 읽어서 JSON 형식으로 변환하는 함수
async function readCSV(filePath) {
  return new Promise((resolve, reject) => {
    const results = [];
    fs.createReadStream(filePath)
      .pipe(csv())
      .on('data', (data) => results.push(data))
      .on('end', () => resolve(results))
      .on('error', (error) => reject(error));
  });
}

// 데이터 삽입/업데이트/삭제를 처리하는 일반 함수
// dbData: DB에서 읽어온 데이터
// csvData: CSV 파일에서 읽은 데이터
// idField: 각 모델의 고유 식별자 속성
// modelName: Prisma에서 사용할 모델 이름
// fieldsToUpdate: 업데이트할 속성 목록
async function upsertData({ dbData, csvData, idField, modelName, fieldsToUpdate }) {
  // CSV에서 가져온 데이터의 고유 ID 저장
  const csvIds = csvData.map((item) => parseInt(item[idField], 10));
  // DB에서 가져온 데이터의 고유 ID 저장
  const dbIds = dbData.map((item) => item[idField]);

  // CSV에 없는 DB 데이터를 삭제
  const deleteIds = dbIds.filter((id) => !csvIds.includes(id));
  if (0 < deleteIds.length) {
    console.log(`삭제할 ${modelName} ID 목록: ${deleteIds.join(', ')}`);
    await prisma[modelName].deleteMany({
      where: {
        [idField]: { in: deleteIds },
      },
    });
  } else {
    console.log(`삭제할 ${modelName} 데이터가 없습니다.`);
  }

  // CSV 데이터를 처리
  const promiseAll = [];
  for (let i = 0; i < csvData.length; i++) {
    const currentData = csvData[i];
    const currentId = parseInt(currentData[idField], 10); // 현재 데이터의 ID
    const dbRecord = dbData.find((item) => item[idField] === currentId); // DB에서 같은 ID 찾기

    const updateData = {};
    fieldsToUpdate.forEach((field) => {
      if (typeof currentData[field] === 'string' && field !== 'img') {
        updateData[field] = parseInt(currentData[field], 10); // String을 Int로 변환
      } else {
        updateData[field] = currentData[field]; // 문자열 데이터는 그대로 유지
      }
    });

    if (dbRecord) {
      // 업데이트할 필요가 있는지 확인
      let needsUpdate = false;
      for (const field of fieldsToUpdate) {
        if (dbRecord[field] !== updateData[field]) {
          needsUpdate = true;
          break;
        }
      }

      if (needsUpdate) {
        console.log(`${modelName} ${currentId} 업데이트!!!!`);
        const promise = prisma[modelName].update({
          data: updateData,
          where: { [idField]: currentId },
        });
        promiseAll.push(promise);
      }
    } else {
      // DB에 없으면 새로 삽입
      console.log(`${currentId} 삽입`);
      const promise = prisma[modelName].create({
        data: {
          [idField]: currentId,
          ...updateData,
        },
      });
      promiseAll.push(promise);
    }
  }

  // 모든 DB 작업 실행
  await Promise.all(promiseAll);
}

// CSV 파일 읽기
const stageDataJson = await readCSV('./data_DB/csv_file/stage_data.csv');
const towerDataJson = await readCSV('./data_DB/csv_file/tower_data.csv');
const monsterDataJson = await readCSV('./data_DB/csv_file/monster_data.csv');

// Stage 데이터 처리
const dbStageData = await prisma.stage.findMany({
  select: { stageId: true, stageStartScore: true },
});
await upsertData({
  dbData: dbStageData,
  csvData: stageDataJson,
  idField: 'stageId',
  modelName: 'stage',
  fieldsToUpdate: ['stageStartScore'],
});

// Tower 데이터 처리
const dbTowerData = await prisma.tower.findMany({
  select: {
    towerId: true,
    towerCost: true,
    towerAttack: true,
    towerSpeed: true,
    towerRange: true,
    img: true,
  },
});
await upsertData({
  dbData: dbTowerData,
  csvData: towerDataJson,
  idField: 'towerId',
  modelName: 'tower',
  fieldsToUpdate: ['towerCost', 'towerAttack', 'towerSpeed', 'towerRange', 'img'],
});

// Monster 데이터 처리
const dbMonsterData = await prisma.monster.findMany({
  select: {
    monsterId: true,
    level: true,
    monsterHp: true,
    monsterAttack: true,
    spawnTime: true,
    img: true,
    monsterGold: true,
    monsterScore: true,
    monsterMoveSpeed: true,
  },
});
await upsertData({
  dbData: dbMonsterData,
  csvData: monsterDataJson,
  idField: 'monsterId',
  modelName: 'monster',
  fieldsToUpdate: [
    'level',
    'monsterHp',
    'monsterAttack',
    'spawnTime',
    'monsterGold',
    'monsterScore',
    'monsterMoveSpeed',
    'img',
  ],
});

// 모든 Promise 완료 처리
console.log('모든 데이터 처리가 완료되었습니다.');

 

이전과 비교하면 코드 길이가 많이 짧아졌다.

245줄에서 162줄이 되었으니 거의 80줄 가까이 사라진 셈이다. 주석까지 포함하면 좀 더 사라질 것 같긴한데... 속성도 세로가 아닌 가로로 늘이면 더 줄어들 것 같긴한데............. 이 부분은 prettier를 사용중이니 그냥 넘어가도록 하자....