TIL

Node.js 서버 user, dungeon 관리 작성 - 최종프로젝트 2

추운날_너를_기다리며 2024. 12. 17. 12:45

1. 최종프로젝트 1에서 일부러 class와 session 관리를 어떻게 하고 있는지 보여주지 않았습니다.

위의 이유에 대해서는 할말이 정말 많지만 이미 망가져버린 코드 구조의 흐름을 보여주고 싶지도 않았지만 일단, 누군가 진지하게 읽을 수 있으니 간단하게 설명하며 보여드리겠습니다.

2. user.class

유저 클래스에서는 로그인한 유저들을 관리하기 위해서 필요한 정보를 객체화 해서 관리하기 위해 만들었습니다.

const { TOWN_SPAWN_TRANSFORMS } = configs;

class User {
  #pingQueue = [];
  #intervalId = null;
  constructor(socket) {
    this.socket = socket;
    this.id = socket.id;
    this.transform = TOWN_SPAWN_TRANSFORMS;
    this.latency = 0;
    this.myClass = 0;
    this.nickname = '';
    this.dungeonId = '';

    this.#intervalId = setInterval(this.ping.bind(this), 1000);
  }
}

3. user.Session

유저 세션에서는 위의 user.class에 있는 정보들을 저장하여 해당 유저에 대한 정보를 쉽게 관리 하기 
위해서 작성하였습니다.

import { userSessions } from './sessions.js';

const allUsersUUID = [];

const addUserSession = (socket) => {
  if (userSessions.has(socket.id)) {
    logger.error('이미 존재하는 유저 세션입니다.');
    return userSessions.get(socket.id);
  }
  const user = new User(socket);
  userSessions.set(socket.id, user);
  allUsersUUID.push(socket.UUID);
  return user;
};

const removeUserSession = (socket) => {
  const user = userSessions.get(socket.id);
  if (user) {
    user.dispose();
    userSessions.delete(socket.id);
    const index = allUsersUUID.indexOf(socket.UUID);
    if (index !== -1) {
      allUsersUUID.splice(index, 1);
    }
  }
};

const getAllUserUUID = () => {
  return allUsersUUID;
};

const getUserById = (userId) => {
  return userSessions.get(userId);
};

export {
  addUserSession,
  removeUserSession,
  getUserById,
  getUserTransformById,
  updateUserTransformById,
  getAllUserUUID,
};

4. dungeon.class

저희의 프로젝트의 가장 중요한 Dungeon클래스인데 던전에서 관리하고 있는 것들은 게임 시작을 하면
던전 클래스에서 접근이 가능합니다.
하지만 너무 큰 의존성을 줄이기 위해서 이너클래스를 사용해서 관리하기 보다는 따로 작성하여
코드들 끼리의 의존성을 줄여 모듈화를 하려고 노력했습니다.

여기서 아쉬운 점은 addDungeonUser 부분인데 던전에 들어간 유저는 유저세션에 있는 정보와
스탯 정보 등 여러 정보가 추가 되기 때문에 원래 dungeonUser.class를 만들어 작성하려 하였지만
단 2주라는 시간이 주어졌고 이미 만들어진 코드에서 사용하는 방법 말고는 해당 기간에 마감할
방법이 없었습니다.

따라서, this.users = new Map();에 넣는 던전 유저는 addDungeonUser 메서드를 사용해 새로운 던전유저
객체를 만들어 const dungeonUser = { user, .... };추가하는 방식으로 사용하였습니다.

class Dungeon {
  constructor(dungeonInfo) {
    this.dungeonId = dungeonInfo.dungeonId;
    this.name = dungeonInfo.name;
    this.users = new Map();
    this.usersUUID = [];
    this.monsterLogic = new MonsterLogic(this);

    this.nexusCurrentHp = 100;
    this.nexusMaxHp = 100;

    this.nexus = null;
    this.respawnTimers = new Map();
    this.droppedItems = {};
    this.spawnTransforms = [
      [2.5, 0.5, 112, 180],
      [2.5, 0.5, -5.5, 0],
      [42, 0.5, 52.5, 270],
      [-38, 0.5, 52.5, 90],
    ];

    this.startTime = Date.now(); // 던전 시작 시간 초기화
  }

  async addDungeonUser(user, statInfo) {
    const userId = user.socket.id;
    user.dungeonId = this.dungeonId;
    await setSessionId(userId, this.dungeonId);
    if (this.users.has(userId)) {
      logger.info('이미 던전에 참여 중인 유저입니다.');
      return null;
    }

    this.usersUUID.push(user.socket.UUID);

    const dungeonUser = {
      user,
      _currentHp: statInfo.stats.maxHp,
      get currentHp() {
        return this._currentHp;
      },
      set currentHp(value) {
        this._currentHp = Math.max(0, Math.min(value, statInfo.stats.maxHp));
      },
      userKillCount: 0,
      monsterKillCount: 0,
      statInfo,
      skillList: {}, //getSkill가보면     dungeonUser.skillList[skillId] = { slot: slotIndex, ...skillData, lastUseTime: 0 }; 이렇게 등록함
    };

    this.users.set(userId, dungeonUser);

    return user;
  }

  attackedNexus(damage, playerId) {
    if (this.nexus) {
      const isGameOver = this.nexus.hitNexus(damage, playerId, this.usersUUID);
      if (isGameOver) {
        logger.info(`Nexus destroyed in dungeon ${this.dungeonId}.`);
        return true; // 게임 종료
      }
    }
    return false;
  }

  handleGameEnd() {
    const playerId = this.nexus.lastAttackerId;
    logger.info(`Game ended in dungeonId ${this.dungeonId}. Winner is player: ${playerId}`);

    createNotificationPacket(PACKET_ID.S_GameEnd, { playerId }, this.usersUUID);
  }

  spawnNexusNotification() {
    this.nexus = new Nexus();

    if (this.nexus) {
      logger.info(
        `Nexus spawned in dungeon: ${this.dungeonId}, Position: ${JSON.stringify(this.nexus.position)}`,
      );

      this.nexus.spawnNexusNotification(this.usersUUID);

      this.nexus.updateNexusHpNotification(this.usersUUID);

      return true;
    }
    return false;
  }

  /**
   *  몬스터를 통해 드랍된 아이템 정보를 보관합니다.
   */
  createDroppedObject(playerId, Id, itemInstanceId) {
    if (!this.users.has(playerId)) {
      return;
    }

    this.droppedItems[itemInstanceId] = { playerId, Id };
  }

  /**
   * 몬스터를 통해 드랍되었던 아이템 정보를 가져오며 데이터를 제거합니다.
   * @param {Number} playerId
   * @param {Number} Id  ItemID or SkillID
   * @param {Number} itemInstanceId
   * @returns
   */
  getDroppedObject(playerId, Id, itemInstanceId) {
    const droppedItem = this.droppedItems[itemInstanceId];
    if (!droppedItem || droppedItem.playerId != playerId || droppedItem.Id != Id) {
      logger.error(`getItemOrSkill. not matched droppedItemInfo playerID: ${playerId} or Id ${Id}`);
      return;
    }
    delete this.droppedItems[itemInstanceId];
    return droppedItem;
  }

  increaseMonsterKillCount(userId) {
    const user = this.users.get(userId);
    if (!user) {
      logger.error(`해당 userId (${userId})를 가진 사용자가 던전에 없습니다.`);
      return;
    }
    user.monsterKillCount += 1;
    logger.info(
      `플레이어 ${userId}의 몬스터 킬 수가 증가했습니다. 현재 몬스터 킬 수: ${user.monsterKillCount}`,
    );

    createNotificationPacket(
      PACKET_ID.S_MonsterKillCount,
      { playerId: userId, monsterKillCount: user.monsterKillCount },
      this.getDungeonUsersUUID(),
    );
  }

  increaseUserKillCount(userId) {
    const user = this.users.get(userId);
    if (!user) {
      logger.error(`해당 userId (${userId})를 가진 사용자가 던전에 없습니다.`);
      return;
    }
    user.userKillCount += 1;
    logger.info(
      `플레이어 ${userId}의 유저 킬 수가 증가했습니다. 현재 유저 킬 수: ${user.userKillCount}`,
    );

    createNotificationPacket(
      PACKET_ID.S_PlayerKillCount,
      { playerId: userId, playerKillCount: user.userKillCount },
      this.getDungeonUsersUUID(),
    );
  }

  async removeDungeonUser(userId) {
    const dungeonUser = this.users.get(userId);
    if (dungeonUser) {
      const user = dungeonUser.user;
      const userUUID = user.socket.UUID;
      user.dungeonId = '';
      const result = this.users.delete(userId);
      await setSessionId(userId, '');
      const index = this.usersUUID.indexOf((uuid) => uuid === userUUID);
      if (index !== -1) {
        this.usersUUID.splice(index, 1);
      }

      if (this.users.size == 0) {
        this.Dispose();
      }

      return result;
    }
  }

  getMaxLatency() {
    let maxLatency = 0;
    this.users.forEach((user) => {
      const userLatency = user.userInfo.getLatency();
      maxLatency = Math.max(maxLatency, userLatency);
    });

    return maxLatency;
  }

  callOnClose() {
    if (this.users.size === 0) {
      this.monsterLogic.pathServer.onClose();
    }
  }

  removeDungeonSession() {
    removeDungeonSession(this.dungeonId);
  }

  getDungeonUser(userId) {
    return this.users.get(userId);
  }

  getDungeonUsersUUID() {
    return this.usersUUID;
  }

  getSpawnPosition() {
    return [...this.spawnTransforms];
  }

  getUserStats(userId) {
    const user = this.getDungeonUser(userId);
    return user.statInfo;
  }

  updateUserStats(userId, stats) {
    const {
      atk = 0,
      def = 0,
      maxHp = 0,
      moveSpeed = 0,
      criticalProbability = 0,
      criticalDamageRate = 0,
    } = stats;
    const statInfo = this.getUserStats(userId);

    statInfo.stats = {
      atk: statInfo.stats.atk + atk,
      def: statInfo.stats.def + def,
      maxHp: statInfo.stats.maxHp + maxHp,
      moveSpeed: statInfo.stats.moveSpeed + moveSpeed,
      criticalProbability: statInfo.stats.criticalProbability + criticalProbability,
      criticalDamageRate: statInfo.stats.criticalDamageRate + criticalDamageRate,
    };

    return statInfo.stats;
  }

  levelUpUserStats(user, nextLevel, maxExp) {
    const { stats: currentStats, exp: currentExp, maxExp: currentMaxExp } = user.statInfo;

    const newExp = currentExp - currentMaxExp;

    const levelperStats = getGameAssets().levelperStats;

    const userClassId = user.user.myClass;
    const classLevelStats = levelperStats[userClassId]?.stats || {};

    user.statInfo = {
      level: nextLevel,
      stats: {
        maxHp: currentStats.maxHp + (classLevelStats.maxHp || 0),
        atk: currentStats.atk + (classLevelStats.atk || 0),
        def: currentStats.def + (classLevelStats.def || 0),
        moveSpeed: currentStats.moveSpeed + (classLevelStats.speed || 0),
        criticalProbability:
          currentStats.criticalProbability + (classLevelStats.criticalProbability || 0),
        criticalDamageRate:
          currentStats.criticalDamageRate + (classLevelStats.criticalDamageRate || 0),
      },
      exp: newExp,
      maxExp,
    };

    return user.statInfo;
  }

  addExp(userId, getExp) {
    const user = this.getDungeonUser(userId);
    // 레벨당 필요 경험치 불러오기
    let maxExp = user.statInfo.maxExp;
    const currentLevel = user.statInfo.level;
    const nextLevel = currentLevel + 1;
    const expAssets = getGameAssets().expInfo;

    if (!maxExp) {
      maxExp = expAssets[currentLevel].maxExp; // ID로 직접 접근
    }

    //에셋 정보가 없으면 테이블 문제 or 최대 레벨 도달
    if (!expAssets[nextLevel]) {
      return;
    }

    user.statInfo.exp += getExp;

    const expResponse = createResponse(PACKET_ID.S_GetExp, {
      playerId: userId,
      expAmount: user.statInfo.exp,
    });

    enqueueSend(user.user.socket.UUID, expResponse);

    if (user.statInfo.exp >= maxExp) {
      const statInfo = this.levelUpUserStats(user, nextLevel, expAssets[nextLevel].maxExp);
      this.levelUpNotification(userId, statInfo);
    }

    return user.statInfo.exp;
  }

  levelUpNotification(userId, statInfo) {
    createNotificationPacket(
      PACKET_ID.S_LevelUp,

      { playerId: userId, statInfo },
      this.getDungeonUsersUUID(),
    );
  }

  damagedUser(userId, damage) {
    const user = this.users.get(userId);
    const resultDamage = Math.max(1, Math.floor(damage * (100 - user.statInfo.stats.def) * 0.01)); // 방어력이 공격력보다 커도 최소뎀

    createNotificationPacket(
      PACKET_ID.S_HitPlayer,
      { playerId: userId, damage: resultDamage },
      this.getDungeonUsersUUID(),
    );

    return resultDamage;
  }

  getAmountHpByKillUser(userId) {
    const user = this.users.get(userId);
    const userMaxHp = user.statInfo.stats.maxHp;

    const healAmount = Math.floor(userMaxHp * 0.5);

    user.currentHp = Math.min(user.currentHp + healAmount, userMaxHp);
    createNotificationPacket(
      PACKET_ID.S_UpdatePlayerHp,
      { playerId: userId, hp: user.currentHp },
      this.getDungeonUsersUUID(),
    );
  }

  updatePlayerHp(userId, amount) {
    const user = this.users.get(userId);

    // 스탯 불러오기 수정
    const maxHp = user.statInfo.stats.maxHp;
    const currentHp = user.currentHp;
    const newHp = currentHp + amount;
    user.currentHp = Math.max(0, Math.min(newHp, maxHp));

    if (user.currentHp != currentHp) {
      createNotificationPacket(
        PACKET_ID.S_UpdatePlayerHp,
        { playerId: userId, hp: user.currentHp },
        this.getDungeonUsersUUID(),
      );
    }

    return user.currentHp;
  }

  increasePlayerAtk(userId, amount) {
    const user = this.users.get(userId);

    user.statInfo.stats.atk = Math.min(amount + user.statInfo.stats.atk, user.statInfo.stats.atk);

    return user.statInfo.stats.atk;
  }

  increasePlayerDef(userId, amount) {
    const user = this.users.get(userId);

    user.statInfo.stats.def = Math.min(amount + user.statInfo.stats.def, user.statInfo.stats.def);

    return user.statInfo.stats.def;
  }

  increasePlayerMaxHp(userId, amount) {
    const user = this.users.get(userId);

    user.statInfo.stats.maxHp = Math.min(
      amount + user.statInfo.stats.maxHp,
      user.statInfo.stats.maxHp,
    );

    return user.statInfo.stats.maxHp;
  }

  increasePlayerMoveSpeed(userId, amount) {
    const user = this.users.get(userId);

    user.statInfo.stats.moveSpeed = Math.min(
      amount + user.statInfo.stats.moveSpeed,
      user.statInfo.stats.moveSpeed,
    );

    return user.statInfo.stats.moveSpeed;
  }

  increasePlayerCriticalProbability(userId, amount) {
    const user = this.users.get(userId);

    user.statInfo.stats.criticalProbability = Math.min(
      amount + user.statInfo.stats.criticalProbability,
      user.statInfo.stats.criticalProbability,
    );

    return user.criticalProbability;
  }

  increasePlayerCriticalDamageRate(userId, amount) {
    const user = this.users.get(userId);

    user.statInfo.stats.criticalDamageRate = Math.min(
      amount + user.statInfo.stats.criticalDamageRate,
      user.statInfo.stats.criticalDamageRate,
    );

    return user.statInfo.stats.criticalDamageRate;
  }

  nexusDamaged(damage) {
    this.nexusCurrentHp -= damage;
    return this.nexusCurrentHp;
  }

  // int32 playerId = 1;
  // TransformInfo transform = 2;
  // StatInfo statInfo = 3;

  onRespawn = (userId) => {
    const user = this.users.get(userId);

    const getSpawnPos =
      this.spawnTransforms[Math.floor(Math.random() * this.spawnTransforms.length)];

    const reviveResponse = {
      playerId: userId,
      transform: {
        posX: getSpawnPos[0],
        posY: getSpawnPos[1],
        posZ: getSpawnPos[2],
        rot: 0,
      },
      statInfo: user.statInfo,
    };

    createNotificationPacket(PACKET_ID.S_RevivePlayer, reviveResponse, this.getDungeonUsersUUID());

    user.currentHp = user.statInfo.stats.maxHp;

    logger.info(`userId: ${userId} 리스폰!`);
  };

  startRespawnTimer(userId, respawnTime) {
    if (this.respawnTimers.has(userId)) {
      logger.info(`respawnTimers에 userId: ${userId} 가 이미 존재합니다`);
      return;
    }

    let remainingTime = respawnTime * 1000; // 초기 리스폰 시간 설정
    const defaultIntervalTime = 1000; //기본값 1초
    let intervalDuration = defaultIntervalTime;

    let lastTime = Date.now();
    const interval = setInterval(() => {
      const currentTime = Date.now();
      const timeDiff = currentTime - lastTime;
      lastTime = currentTime;
      remainingTime -= timeDiff; // 흐른 시간 만큼 감소
      logger.info(`userId : ${userId} 리스폰 시간 ${remainingTime}ms`);
      if (remainingTime <= 0) {
        clearInterval(interval); // 타이머 종료
        this.respawnTimers.delete(userId); // 관리 목록에서 제거
        this.onRespawn(userId); // 내부 리스폰 처리
      }

      if (remainingTime < defaultIntervalTime) {
        intervalDuration = remainingTime;
      }
    }, intervalDuration); // 1초 간격으로 실행

    this.respawnTimers.set(userId, interval); // 타이머 등록
  }

  clearAllTimers() {
    this.respawnTimers.forEach((interval) => clearInterval(interval));
    this.respawnTimers.clear();
    logger.info('모든 리스폰 타이머 클리어!');
  }

  Dispose() {
    this.monsterLogic.Dispose();
    this.monsterLogic = null;
    this.clearAllTimers();
    removeDungeonSession(this.dungeonId);
  }
}

export default Dungeon;

5. dungeon.session

던전 세션은 유저세션과 비슷한데 던전에 들어간 유저들을 관리하는 던전 클래스로 만들어진 객체를
담아서 던전 자체를 관리하는 것 입니다.
저희는 서버가 1개 이기 때문에 여러 유저가 들어온다면 수많은 던전이 생성되기 때문에 그 던전을
서버 1개로 관리하기 위해서는 던전세션을 만들어 관리하는게 용이하기 때문에 만들었습니다.

import { dungeonSessions } from './sessions.js';

const addDungeonSession = (sessionId, dungeonLevel) => {
  if (dungeonSessions.has(sessionId)) {
    logger.error('세션 중복');
    return null;
  }

  const dungeonCode = 1;

  const dungeonAssets = getGameAssets().dungeonInfo; // 맵핑된 던전 데이터 가져오기
  const dungeonInfo = dungeonAssets[dungeonCode]; // ID로 바로 접근

  if (!dungeonInfo) {
    logger.error(`던전 정보를 찾을 수 없습니다. dungeonCode: ${dungeonCode}`);
    return null;
  }
  dungeonInfo.dungeonId = sessionId;
  const dungeon = new Dungeon(dungeonInfo, dungeonLevel);
  dungeonSessions.set(sessionId, dungeon);

  return dungeon;
};

export const findDungeonByUserId = (userId) => {
  let result = null;
  for (const [_, dungeon] of dungeonSessions.entries()) {
    if (dungeon.users.has(userId)) {
      result = dungeon;
      break;
    }
  }
  return result;
};

const getDungeonSession = (dungeonId) => {
  return dungeonSessions.get(dungeonId);
};

const getDungeonUsersUUID = (dungeonId) => {
  const dungeon = getDungeonSession(dungeonId);
  return dungeon.usersUUID;
};

const removeDungeonSession = (dungeonId) => {
  const dungeon = dungeonSessions.get(dungeonId);
  if (!dungeon) {
    logger.error(`던전 세션이 존재하지 않습니다.`);
    return;
  }
  return dungeonSessions.delete(dungeonId);
};

const getStatsByUserClass = (userClass) => {
  const classAssets = getGameAssets().classInfo;
  const classInfos = classAssets[userClass];

  if (!classInfos) {
    logger.error(`Class 정보를 찾을 수 없습니다. classId: ${userClass}`);
    return null;
  }

  const expAssets = getGameAssets().expInfo;
  const expInfos = expAssets[1];

  if (!expInfos) {
    logger.error('레벨 1의 경험치 정보를 찾을 수 없습니다.');
    return null;
  }
  
  return {
    level: 1,
    stats: classInfos.stats,
    exp: 0,
    maxExp: expInfos.maxExp,
  };
};

export {
  addDungeonSession,
  getDungeonSession,
  removeDungeonSession,
  getDungeonSessions,
  getDungeonUsersUUID,
  getStatsByUserClass,
};

6. 느낀점

작성하면서 여러 불편한 부분들이 많고 확장성은 전혀 신경쓰지 않는 부분을 고치지 않은 저의 잘못이 느껴지지만 그 부분을 고치려면 너무 많은 부분을 고쳐야해서 기일 시간을 맞추기 위해 일단 최대한 만들어진 코드의 스켈레톤 구조에서 추가하는 방식으로 하였습니다.

핸들러는 직접 다시 다 작성하고 있습니다.

가능한 SRP를 지키기 위해 노력중입니다.