Pac-Man 만들기 도전, 추억을 떠올리다

소개

게임바이브코딩 청강 중입니다. 영상보고 vercel 배포를 처음 도전해 봤습니다.

Repositories가 총 6개가 있었는데 public이 아니라도 배포가 되는 것 같아서 2개를 만들고 둘 다 private로 바꿨는데 하나가 블럭이 됐습니다.

1개만 private가 되는 것 같아요.

진행 방법

어떤 도구를 사용했고, 어떻게 활용하셨나요?

codex를 이용해서 Pac-Man을 만들어 보았습니다. 예전에 했던 기억이 나서 만들어 봤는데 플레이해보니 너무 어렵습니다. 예전에는 어떻게 게임을 했었는지 기억조차 나지 않네요.

Tip: 사용한 프롬프트 전문을 꼭 포함하고, 내용을 짧게 소개해 주세요.

팩맨을 만들어줘. html 단일 웹으로 개발해줘

html 파일 여러개 만들까봐 단일 웹으로 개발해 달라고 했습니다.

Tip: 활용 이미지나 캡처 화면을 꼭 남겨주세요.

네온 미로- 스크린샷 미리보기 이미지

기존의 게임과는 좀 다른 뭔가를 추가하고 싶다는 욕구가 생깁니다.

Hunted Mansion 재미있었습니다.

Tip: 코드 전문은 코드블록에 감싸서 작성해주세요. ( / 을 눌러 '코드 블록'을 선택)


<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
  <title>NEON MAZE</title>
  <style>
    :root{color-scheme:dark;--yellow:#ffe600;--blue:#2448ff;--ink:#070710;--cyan:#55f7ff}
    *{box-sizing:border-box}
    body{margin:0;min-height:100svh;display:grid;place-items:center;overflow:hidden;background:
      radial-gradient(circle at 50% 20%,#18183c 0,#090914 40%,#030307 100%);font-family:ui-monospace,Consolas,monospace;color:#fff}
    .cabinet{width:min(96vw,520px);padding:18px 18px 14px;border:2px solid #29295a;border-radius:24px;background:linear-gradient(145deg,#17172c,#090912);
      box-shadow:0 0 50px #193cff2c,inset 0 0 0 5px #05050b}
    header{display:flex;align-items:end;justify-content:space-between;margin:0 5px 12px}
    h1{margin:0;color:var(--yellow);font:900 clamp(22px,6vw,36px)/1 system-ui;letter-spacing:.08em;text-shadow:3px 3px 0 #ff3d7f}
    .stats{display:flex;gap:15px;font-weight:800;font-size:13px;text-align:right}.stats span{display:block;color:var(--cyan);font-size:18px}
    .screen{position:relative;padding:8px;border-radius:12px;background:#020208;box-shadow:inset 0 0 22px #000,0 0 0 2px #343468}
    canvas{display:block;width:100%;height:auto;aspect-ratio:19/22;background:#000;border-radius:6px;image-rendering:pixelated}
    .overlay{position:absolute;inset:8px;display:grid;place-items:center;background:#000b;border-radius:6px;text-align:center;backdrop-filter:blur(2px)}
    .overlay[hidden]{display:none}.card h2{margin:0 0 8px;color:var(--yellow);font-size:32px}.card p{margin:5px 0 18px;color:#d8d8ef}
    button{border:0;border-radius:999px;padding:12px 24px;background:var(--yellow);color:#111;font:900 15px inherit;cursor:pointer;box-shadow:0 5px 0 #b79f00}
    button:active{transform:translateY(4px);box-shadow:0 1px 0 #b79f00}
    .help{text-align:center;color:#8f90ab;font-size:12px;margin:12px 0 4px}
    .touch{display:none;margin:12px auto 0;width:154px;grid-template:repeat(3,44px)/repeat(3,44px);gap:5px}
    .touch button{padding:0;border-radius:12px;font-size:20px;box-shadow:none;background:#282848;color:#fff;touch-action:manipulation}
    .touch .up{grid-column:2}.touch .left{grid-area:2/1}.touch .down{grid-area:2/2}.touch .right{grid-area:2/3}
    @media(pointer:coarse),(max-width:600px){body{overflow:auto}.cabinet{margin:8px auto}.touch{display:grid}.help{display:none}}
  </style>
</head>
<body>
<main class="cabinet">
  <header>
    <h1>NEON MAZE</h1>
    <div class="stats"><div>SCORE<span id="score">00000</span></div><div>LIVES<span id="lives">● ● ●</span></div></div>
  </header>
  <section class="screen">
    <canvas id="game" width="456" height="528" aria-label="팩맨 미로 게임"></canvas>
    <div class="overlay" id="overlay">
      <div class="card"><h2 id="title">READY!</h2><p id="message">모든 점을 먹고 유령을 피하세요.</p><button id="start">게임 시작</button></div>
    </div>
  </section>
  <div class="touch" aria-label="터치 방향키">
    <button class="up" data-dir="up"></button><button class="left" data-dir="left"></button>
    <button class="down" data-dir="down"></button><button class="right" data-dir="right"></button>
  </div>
  <p class="help">방향키 / WASD 이동 · P 일시정지 · ENTER 시작</p>
</main>
<script>
(()=>{
  "use strict";
  const MAP=[
    "###################",
    "#........#........#",
    "#.###.##.#.##.###.#",
    "#o###.##.#.##.###o#",
    "#.................#",
    "#.###.#.#####.#.###",
    "#.....#...#...#...#",
    "#####.### # ###.###",
    "    #.#       #.#  ",
    "#####.# ##=## #.###",
    "     .  #   #  .   ",
    "#####.# ##### #.###",
    "    #.#       #.#  ",
    "#####.# ##### #.###",
    "#........#........#",
    "#.###.##.#.##.###.#",
    "#o..#....P....#..o#",
    "###.#.#.#####.#.#.#",
    "#.....#...#...#...#",
    "#.#######.#.#######",
    "#.................#",
    "###################"
  ];
  const C=24, canvas=document.querySelector("#game"),ctx=canvas.getContext("2d");
  const scoreEl=document.querySelector("#score"), livesEl=document.querySelector("#lives");
  const overlay=document.querySelector("#overlay"),title=document.querySelector("#title"),message=document.querySelector("#message"),start=document.querySelector("#start");
  const dirs={left:{x:-1,y:0,a:Math.PI},right:{x:1,y:0,a:0},up:{x:0,y:-1,a:-Math.PI/2},down:{x:0,y:1,a:Math.PI/2}};
  let grid,player,ghosts,score,lives,dots,state="ready",last=0,power=0,chain=0;
  function reset(full=true){
    grid=MAP.map(r=>[...r]);
    if(full){score=0;lives=3}
    player={x:9,y:16,dir:"left",next:"left",progress:0,speed:7};
    grid[16][9]=" ";
    const data=[["#ff365e",9,10,"blinky"],["#ff8bd7",8,10,"pinky"],["#56efff",10,10,"inky"],["#ff9f40",9,11,"clyde"]];
    ghosts=data.map((g,i)=>({color:g[0],x:g[1],y:g[2],dir:i%2?"right":"left",progress:0,speed:5.3,homeX:g[1],homeY:g[2],name:g[3],dead:false}));
    power=0;chain=0;updateHud();draw();
  }
  const tile=(x,y)=>grid[(y+grid.length)%grid.length]?.[(x+19)%19]??"#";
  const open=(x,y,ghost=false)=>{const t=tile(x,y);return t!=="#"&&(ghost||t!=="=")};
  function pickDir(g){
    const options=Object.keys(dirs).filter(d=>open(g.x+dirs[d].x,g.y+dirs[d].y,true)&&!(dirs[d].x===-dirs[g.dir].x&&dirs[d].y===-dirs[g.dir].y));
    if(!options.length)return Object.keys(dirs).find(d=>open(g.x+dirs[d].x,g.y+dirs[d].y,true))||g.dir;
    if(power>0&&!g.dead)return options[Math.floor(Math.random()*options.length)];
    let tx=player.x,ty=player.y;
    if(g.dead){tx=g.homeX;ty=g.homeY}
    else if(g.name==="pinky"){tx+=dirs[player.dir].x*4;ty+=dirs[player.dir].y*4}
    else if(g.name==="clyde"&&Math.hypot(g.x-player.x,g.y-player.y)<6){tx=1;ty=20}
    return options.sort((a,b)=>dist(g,a,tx,ty)-dist(g,b,tx,ty))[0];
  }
  const dist=(g,d,x,y)=>Math.hypot(g.x+dirs[d].x-x,g.y+dirs[d].y-y)+Math.random()*.15;
  function move(e,dt,isGhost=false){
    if(e.progress<=0){
      if(!isGhost&&open(e.x+dirs[e.next].x,e.y+dirs[e.next].y))e.dir=e.next;
      if(isGhost)e.dir=pickDir(e);
      if(!open(e.x+dirs[e.dir].x,e.y+dirs[e.dir].y,isGhost))return;
    }
    e.progress+=e.speed*dt*((isGhost&&power>0&&!e.dead)?.72:1);
    if(e.progress>=1){
      e.x=(e.x+dirs[e.dir].x+19)%19;e.y=(e.y+dirs[e.dir].y+22)%22;e.progress=0;
      if(!isGhost)eat();
      else if(e.dead&&e.x===e.homeX&&e.y===e.homeY)e.dead=false;
    }
  }
  function eat(){
    const t=tile(player.x,player.y);
    if(t==="."||t==="o"){
      grid[player.y][player.x]=" ";score+=t==="o"?50:10;
      if(t==="o"){power=8;chain=0}
      updateHud();
      if(!grid.some(r=>r.some(c=>c==="."||c==="o")))finish(true);
    }
  }
  function collide(){
    for(const g of ghosts){
      const px=player.x+dirs[player.dir].x*player.progress,py=player.y+dirs[player.dir].y*player.progress;
      const gx=g.x+dirs[g.dir].x*g.progress,gy=g.y+dirs[g.dir].y*g.progress;
      if(!g.dead&&Math.hypot(px-gx,py-gy)<.7){
        if(power>0){g.dead=true;g.progress=0;score+=200*(2**chain++);updateHud()}
        else loseLife();
      }
    }
  }
  function loseLife(){
    state="pause";lives--;updateHud();
    if(lives<=0)return finish(false);
    setTimeout(()=>{const keepScore=score,keepLives=lives;reset(false);score=keepScore;lives=keepLives;updateHud();state="play";last=performance.now();requestAnimationFrame(loop)},900);
  }
  function finish(win){
    state="over";title.textContent=win?"YOU WIN!":"GAME OVER";message.textContent=`최종 점수 ${score.toLocaleString()}점`;start.textContent="다시 플레이";overlay.hidden=false;
  }
  function updateHud(){scoreEl.textContent=String(score).padStart(5,"0");livesEl.textContent="● ".repeat(lives).trim()||"—"}
  function startGame(){reset(true);overlay.hidden=true;state="play";last=performance.now();requestAnimationFrame(loop)}
  function loop(now){
    if(state!=="play")return;
    const dt=Math.min((now-last)/1000,.05);last=now;
    if(power>0)power=Math.max(0,power-dt);
    move(player,dt);ghosts.forEach(g=>move(g,dt,true));collide();draw();
    if(state==="play")requestAnimationFrame(loop);
  }
  function draw(){
    ctx.clearRect(0,0,canvas.width,canvas.height);
    ctx.fillStyle="#000";ctx.fillRect(0,0,canvas.width,canvas.height);
    ctx.lineWidth=2;
    for(let y=0;y<grid.length;y++)for(let x=0;x<19;x++){
      const t=grid[y][x],cx=x*C+C/2,cy=y*C+C/2;
      if(t==="#"){ctx.fillStyle="#1023a8";ctx.fillRect(x*C+2,y*C+2,C-4,C-4);ctx.strokeStyle="#4469ff";ctx.strokeRect(x*C+5,y*C+5,C-10,C-10)}
      else if(t==="."){ctx.fillStyle="#ffd9b5";ctx.beginPath();ctx.arc(cx,cy,2.3,0,7);ctx.fill()}
      else if(t==="o"){ctx.fillStyle="#fff";ctx.beginPath();ctx.arc(cx,cy,6+Math.sin(performance.now()/120),0,7);ctx.fill()}
      else if(t==="="){ctx.fillStyle="#ff91d8";ctx.fillRect(x*C,y*C+10,C,4)}
    }
    drawPac(player);ghosts.forEach(drawGhost);
  }
  function pos(e){return{x:(e.x+dirs[e.dir].x*e.progress)*C+C/2,y:(e.y+dirs[e.dir].y*e.progress)*C+C/2}}
  function drawPac(p){
    const q=pos(p),mouth=.16+.13*Math.abs(Math.sin(performance.now()/70)),a=dirs[p.dir].a;
    ctx.fillStyle="#ffe600";ctx.beginPath();ctx.moveTo(q.x,q.y);ctx.arc(q.x,q.y,10.5,a+mouth*Math.PI,a+(2-mouth)*Math.PI);ctx.closePath();ctx.fill();
  }
  function drawGhost(g){
    const q=pos(g),flash=power<2&&Math.floor(power*6)%2===0;
    if(g.dead){eyes(q.x,q.y,g.dir);return}
    ctx.fillStyle=power>0?(flash?"#fff":"#263cff"):g.color;
    ctx.beginPath();ctx.arc(q.x,q.y-2,10,Math.PI,0);ctx.lineTo(q.x+10,q.y+9);
    for(let i=0;i<4;i++)ctx.lineTo(q.x+10-i*6.66,q.y+5+(i%2?4:0));
    ctx.lineTo(q.x-10,q.y-2);ctx.fill();eyes(q.x,q.y,g.dir);
  }
  function eyes(x,y,d){for(const s of[-4,4]){ctx.fillStyle="#fff";ctx.beginPath();ctx.ellipse(x+s,y-3,3.4,4.5,0,0,7);ctx.fill();ctx.fillStyle="#183bd4";ctx.beginPath();ctx.arc(x+s+dirs[d].x*1.4,y-3+dirs[d].y*1.4,1.7,0,7);ctx.fill()}}
  function setDir(d){if(dirs[d])player.next=d}
  addEventListener("keydown",e=>{
    const keys={ArrowLeft:"left",a:"left",A:"left",ArrowRight:"right",d:"right",D:"right",ArrowUp:"up",w:"up",W:"up",ArrowDown:"down",s:"down",S:"down"};
    if(keys[e.key]){e.preventDefault();setDir(keys[e.key])}
    if(e.key==="Enter"&&(state==="ready"||state==="over"))startGame();
    if((e.key==="p"||e.key==="P")&&state!=="ready"&&state!=="over"){state=state==="play"?"paused":"play";overlay.hidden=state==="play";title.textContent="PAUSED";message.textContent="P를 눌러 계속";start.textContent="계속";if(state==="play"){last=performance.now();requestAnimationFrame(loop)}}
  });
  document.querySelectorAll("[data-dir]").forEach(b=>{b.addEventListener("pointerdown",e=>{e.preventDefault();setDir(b.dataset.dir)})});
  start.addEventListener("click",()=>state==="paused"?(state="play",overlay.hidden=true,last=performance.now(),requestAnimationFrame(loop)):startGame());
  reset(true);
})();
</script>
</body>
</html>
<script async data-explicit-opt-in="true" data-cookie-opt-in="true" data-deployment-id="dpl_3LT1GweeacJN1aV3Z5ReQkdnVho5" src="https://vercel.live/_next-live/feedback/feedback.js"></script>

결과와 배운 점

vercel 배포를 처음 해봤는데 github랑 쉽게 연동이 되는데다가 github 아이디가 보이는 것이 굉장히 불편했는데 너무 좋은 꿀팁을 배웠습니다.

2주차에 복잡하고 규모있는 게임을 만드는 방법을 공유해주신다고 해서 매우 기대가 됩니다.

1개의 답글

뉴스레터 무료 구독