개발새발

좀비고등학교 성격유형 테스트 이스터에그! 본문

회고록

좀비고등학교 성격유형 테스트 이스터에그!

비숑주인 2025. 4. 27. 22:53

사실 성격 유형 테스트만 만들까 했는데 너무 허전해서 작은 이스터 에그를 하나 만들었다.

 
학생 유형 테스트 옆에 있는 좀비 이모티콘을 누르면 이스터에그 페이지로 이동한다! 
 

 
해당 페이지에서는 리더보드 순위 표시에 사용 될 닉네임이랑 순위권 경품 수령에 필요한 학번을 입력 받는다. 귀찮은 사람들을 위해서 점수 기록은 불가능하지만 게임 플레이는 할 수 있게 입력 없이 시작할 수 있는 버전도 준비했다. 
 
DB는 MongoDB Atlas를 사용했다. MongoDB는 로컬이나 서버에 직접 깔고 돌려야 하고, 자동 갱신은 안 해주기 때문에 데이터 업데이트는 내 프로그램이 해야 했다. 찾아보니 Atlas는 클라우드에 이미 MongoDB 깔려있고, 인터넷으로 바로 연결해서 쓸 수 있어서 Atlas로 간단하게 DB를 구축했다.
 
MongoDB Atlas를 만들면 아래 형식과 같은 url이 나올텐데 꼭 저장해둬야 한다!! 

mongodb+srv://DB접속아이디:DB접속비번@test1.jea.mongodb.net/?retryWrites=true&w=majority

 
그리고 Add My Current Ip Address로 IP를 등록해주면 되는데 나는 귀찮아서 그냥 0.0.0./0 을 넣어서 모든 Ip 를 허용해줬다...

 
Atlas도 MongoDB 꺼라 당연하다면 당연하겠지만 MongoDB Compass 사용이 가능하다! 내 닉네임이랑 학번이 잘 저장 된 걸 확인할 수 있다. 



프론트를 React로 개발하고 있었기 때문에 백엔드는 Express.js를 사용했다. Mongoose를 사용해서 Node.js로 MongoDB를 연결했다. 

 
server 폴더에 .env 파일을 만들고 MONGODB_URI를 넣어주면 된다
.gitignore에 넣는 걸 잊지 말자!

 
DB에 저장해 둔 데이터로 리더보드를 구현했다.  

 
이게 PC 버전이나 모바일 세로 버전에서는 상관이 없는데 모바일 가로 버전에서는 리더보드 버튼이 게임 플레이 화면이랑 겹쳐져서 불편했다. 그래서 어짜피 세로 버전으로 보이면 모바일 가로 버전에선 리더보드 버튼이 필요 없을 거 같아서 빼버렸다. 가로 버전에서 빼 버리니까 게임 플레이에 집중하기에 더 좋은 거 같다. 
 
여러 기종을 고려해서 넉넉하게 1024px를 기준으로 했다. 

  @media (max-width: 1024px) and (orientation: landscape) {
    display: none !important;
  }

 
js로 게임을 만들어 본 건 처음이고 난 처음에 구현에 의의를 둬서 이 상태로 클랜원들에게 플레이 시켰는데 수많은 피드백들과 오류들을 제보 받고 (...) 미니 게임 로직을 고치는 중이다...대동제 전까지만 고치면 되지 않을까...(먼산)


 
지금까지는 구현 한 건 우선 점프 할 수 있게 장애물 사이에 최소 거리 보장이다. 

const speedFactor = Math.min(2, this.gameSpeed / this.initialSpeed);
const minInterval = GAME_CONSTANTS.MIN_TREE_INTERVAL / speedFactor;
const maxInterval = GAME_CONSTANTS.MAX_TREE_INTERVAL / speedFactor;

const interval = minInterval + (maxInterval - minInterval) * Math.random();

 
GAME_CONSTANTS.MIN_TREE_INTERVAL은 40프레임으로 설정했다. 이 값은 speedFactor로 나뉘어 실제 최소 간격이 결정하게 했다. 

const speedFactor = Math.min(2, this.gameSpeed / this.initialSpeed);

 
여기에서 아무리 빨라져도 speedFactor가 최소 2 밑으로는 내려가지 않게 해서 게임 플레이가 불가능한 상황을 막았다. 
 
아래 코드는 장애물 생성 로직이다. 

createTree() {
  const speedFactor = Math.min(2, this.gameSpeed / this.initialSpeed);
  const minInterval = GAME_CONSTANTS.MIN_TREE_INTERVAL / speedFactor;
  const maxInterval = GAME_CONSTANTS.MAX_TREE_INTERVAL / speedFactor;
  
  const interval = minInterval + (maxInterval - minInterval) * Math.random();
  
  const newTree = {
    x: Math.round(GAME_CONSTANTS.TREE_START * this.scale),
    y: Math.round(GAME_CONSTANTS.Y_BASE * this.scale + GAME_CONSTANTS.DINO_HEIGHT * this.scale - GAME_CONSTANTS.TREE_HEIGHT * this.scale),
    width: GAME_CONSTANTS.TREE_WIDTH * this.scale,
    height: GAME_CONSTANTS.TREE_HEIGHT * this.scale,
    type: Math.floor(Math.random() * 4)
  };
  
  this.trees.push(newTree);

  if (Math.random() < GAME_CONSTANTS.DOUBLE_TREE_CHANCE) {
    const secondTree = {
      x: Math.round((GAME_CONSTANTS.TREE_START + GAME_CONSTANTS.TREE_WIDTH) * this.scale),
      y: Math.round(GAME_CONSTANTS.Y_BASE * this.scale + GAME_CONSTANTS.DINO_HEIGHT * this.scale - GAME_CONSTANTS.TREE_HEIGHT * this.scale),
      width: GAME_CONSTANTS.TREE_WIDTH * this.scale,
      height: GAME_CONSTANTS.TREE_HEIGHT * this.scale,
      type: Math.floor(Math.random() * 4)
    };
    this.trees.push(secondTree);
  }
  
  this.nextTreeTime = this.frameCount + Math.floor(interval);
}

 
 
speedFactor는 현재 게임 속도를 초기 속도로 나눈 값 (최대 2배까지)이다.  minInterval과 maxInterval를 게임 속도에 따라 조정해서 속도가 빨라질수록 간격이 줄어들게 게임 속도에 따라 장애물 생성 간격을 조절했다. 
 
장애물 생성 간격은 MIN_TREE_INTERVAL(40프레임)과 MAX_TREE_INTERVAL(150프레임) 사이에서 랜덤하게 결정헸다. 이 간격이 nextTreeTime에 저장되어 다음 장애물 생성 시점을 결정한다. 30퍼 확률로 장애물이 연달아 2개 등장하게 해서 좀 더 장애물 종류가 많아 보이게 했다. 
 
 
속도는 처음엔 그냥 로그 함수적으로 증가하게 했다. 나는 300점대에서 계속 죽어서 그 이후에 대해서는 생각을 안해봤는데 99렙 고인물 클랜원들이 2000점대 이후로부터는 속도 차이가 거의 없어서 재미 없다는 평(...)을 줘서 고인물들의 도전 욕구를 위해 800점 이상부터는 선형적으로 속도가 점점 증가하게 수정했다. 

updateGameSpeed(newScore) {
  if (newScore < 800) {
    // 800점 미만일 때는 기존 로그 함수 사용
    this.gameSpeed = this.initialSpeed + Math.min(
      GAME_CONSTANTS.MAX_SPEED_INCREASE,
      Math.log2(newScore + 1) * GAME_CONSTANTS.SPEED_INCREASE_FACTOR
    );
  } else {
    // 800점 이상일 때는 선형적으로 증가
    const baseSpeed = this.initialSpeed + Math.log2(800 + 1) * GAME_CONSTANTS.SPEED_INCREASE_FACTOR;
    const linearIncrease = (newScore - 800) * 0.01; // 100점당 1씩 증가
    this.gameSpeed = baseSpeed + linearIncrease;
  }
}

 
피격 판정 같은 경우에는 처음에는 그냥 이미지 크게에 맞춰서 했는데 아무래도 캐릭터는 정사각형 사이즈인데 투명한 배경 부분 때문에 완전히 접속하지 않아도 죽는 거 같아 보이는 현상이 생겼다. 이 역시 클랜원들의 피드백으로 캐릭터의 피격 판정을 살짝 작게 해서 억울함을 덜었다(...)
 

GAME_CONSTANTS = {
  DINO_HITBOX_SHRINK: 8,      // 히트박스 축소량
  BASE_HITBOX_WIDTH: 40,      // 기본 히트박스 너비
  BASE_HITBOX_HEIGHT: 40,     // 기본 히트박스 높이
  TREE_WIDTH: 30,             // 나무 너비
  TREE_HEIGHT: 50,            // 나무 높이
}

 
이렇게 캐릭터의 축소된 히트박스와 나무의 히트박스가 겹치는지 확인하고, 모든 방향(x, y)에서 충돌을 체크 화면 크기에 따라 자동으로 스케일 조정하게 했다. 

const checkCollision = (dino, tree) => {
  const game = gameRef.current;
  const hitboxScale = game.hitboxScale;
  
  // 고정된 픽셀 값에 스케일 적용
  const dinoHitboxShrink = GAME_CONSTANTS.DINO_HITBOX_SHRINK * hitboxScale;
  const dinoWidth = GAME_CONSTANTS.BASE_HITBOX_WIDTH * hitboxScale;
  const dinoHeight = GAME_CONSTANTS.BASE_HITBOX_HEIGHT * hitboxScale;
  const treeWidth = GAME_CONSTANTS.TREE_WIDTH * hitboxScale;
  const treeHeight = GAME_CONSTANTS.TREE_HEIGHT * hitboxScale;

  // 실제 위치에서 히트박스 크기를 고려한 충돌 체크
  return (
    dino.x + dinoHitboxShrink < tree.x + treeWidth &&
    dino.x + dinoWidth - dinoHitboxShrink > tree.x &&
    dino.y + dinoHitboxShrink < tree.y + treeHeight &&
    dino.y + dinoHeight - dinoHitboxShrink > tree.y
  );
};

 

지오메트리대쉬 같은 게임은 꾹 누르면 연속 점프가 되는 기능이 있는데, 비슷한 게임으로서 이런 기능이 있으면 좋을 것 같아서 추가해봤다. 

const handleTouchStart = (e) => {
  if (showAuthModal || e.target.closest('button')) return;
  e.preventDefault();
  e.stopPropagation();
  setIsTouching(true);
  handleJump();
};

const handleTouchMove = (e) => {
  if (showAuthModal || e.target.closest('button')) return;
  e.preventDefault();
  e.stopPropagation();
};

const handleTouchEnd = (e) => {
  if (showAuthModal || e.target.closest('button')) return;
  e.preventDefault();
  e.stopPropagation();
  setIsTouching(false);
};


e.target.closest('button') 이 버튼 영역을 터치했는지 확인한다. showAuthModal이 로그인 모달이 표시되어 있는지 확인하고, 버튼이나 모달 영역이면 이벤트 처리를 중단한다. 또한 터치 시작/종료 시 isTouching 상태를 변경하게 코드를 추가했다. 

 

useEffect(() => {
  const animateJump = () => {
    if (isTouching && !showAuthModal) {
      handleJump();
    }
    animationRef.current = requestAnimationFrame(animateJump);
  };

  if (isTouching && !showAuthModal) {
    animationRef.current = requestAnimationFrame(animateJump);
  }

  return () => {
    if (animationRef.current) {
      cancelAnimationFrame(animationRef.current);
    }
  };
}, [isTouching, showAuthModal]);

 

isTouching이 true일 때 requestAnimationFrame으로 연속 점프한다. 모달이 표시되면 점프 중지하고, 컴포넌트 언마운트 시 애니메이션을 종료한다. 위 코드에서도 그렇고 모달이 표시되면 중지하는 것은, 로그인 모달이나 게임 오버 모달 창이 떴을 때도 꾹 눌리는 함수가 적용되서 버튼 클릭이 안되는 문제가 있었다. 그래서 아예 모달이 뜨면 해당 애니메이션 효과가 막히게 따로 예외 설정을 해주었다. 

 

 

이전 학생 유형 테스트는 프론트 배포는 그냥 vercel로 간단하게 했는데 아무래도 백엔드 파트가 생겨서 다시 배포했다. 
 
Express.js은 vercel에서 바로 배포가 안 되서 render에서 먼저 백엔드만 배포했다. render 사용법은 아래 블로그에 잘 정리 되어 있어서 첨부한다. 
 
https://velog.io/@jx7789/%EC%BF%A0%ED%8C%A1%EC%9D%B4%EC%B8%A0-Render%EB%A1%9C-%EB%B0%B1%EC%97%94%EB%93%9C-%EB%B0%B0%ED%8F%AC%ED%95%98%EA%B8%B0
 
render를 사용한 이유는 특별한 건 없고 무료라서(....) 일정 기간 요청이 없으면 슬립 모드에 들어가긴 하는데 무료인게 어디인가. 3000원짜리 학생증 굿즈의 이스터에그 사이트에 쓸 돈은 없다. 

 
프론트엔드 폴더인 client에 .env 파일을 만들고 render url이랑 vercel 배포 링크를 넣어줬다. 
 

 
 
다시 vercel로 넘어와서 나는 vite를 사용했기 때문에 framework preset를 vite로 설정해 줬다. 
 

 
그 다음에 프론트 부분인 client 폴더를 루트 폴더로 지정한 다음

 
Build and Output Settings은 framework preset를 vite로 지정해둬서 자동으로 잘 설정 되어 있다. 
 

 
 MongoDB Url를 넣어주고 배포하면 vercel에서 할 일은 끝이다. 
 

 
 
 
https://zombie-smoky.vercel.app/

 

이화좀비대학교

 

zombie-smoky.vercel.app

 
그럼 이제 배포된 완성본 url을 받을 수 있다!  Vercel에 프로젝트를 배포할 때, 프로젝트 구조(예: api/ 폴더, server/ 폴더, package.json 등)를 분석해서 자동으로 vercel.json 파일을 생성하기 때문에 자동으로 백엔드 코드까지 연동 시켜준 것으로 보인다.


학생증 굿즈의 떡상을 기대하며...저 하트로 가려진 부분이 내 사이트로 연결 되는 QR코드이다!

 

좀비고 성격 유형 테스트 이전 게시글

https://his0si.tistory.com/181

 

좀비고등학교 성격유형 테스트 제작

대동제에서 판매하는 이화좀비대학교 학생증 굿즈의 뒷면 QR 코드에 들어갈 좀비고 성격 유형 테스트를 만들었다. 사실 굿즈는 학생증이 메인이고 qr은 이스터에그(?) 같은 느낌으로 제작해서 최

his0si.tistory.com