[웹퍼즐] 퍼즐 플레이 페이지: 캔버스, 퍼즐 생성, 이벤트

🐈 JAELEE 🐈·2021년 11월 28일
0

웹퍼즐 프로젝트

목록 보기
4/7

퍼즐을 만들기 위해 필요한 것

  • canvas에서 퍼즐 타일 개체에 click Event를 등록해야 한다.
  • paper js는 캔버스상에서 Path, Raster, Group 등의 객체와 mouseEvent를 사용할 수 있도록 하는 프레임워크이다.
  • 때문에 paper js를 사용하여 퍼즐을 구현했다.

퍼즐 개요

pages/play-puzzle/
|
└──index.tsx

copmonents/play-puzzle
|
└──index.tsx
└──/puzzle-canvas
     └──index.tsx
     └──create-puzzle.tsx
     └──move-puzzle.tsx
     └──audio-effect.tsx
     └──complete-animation.tsx
     └──config-init.tsx
     └──find-change.tsx
     
  1. 페이지의 사진이 다 로드됐고, 이 방의 첫 번째 유저인지 boolean값이 명확해졌다면, PuzzleCanvas 컴포넌트를 화면에 출력한다.
  2. 전역변수로 puzzleConfig를 관리한다. 첫 번째 유저라면 퍼즐조각의 random한 shape을 생성해서 다른 기본 값들을 config에 넣어야 한다. -> setConfig, createTiles
  3. 첫 번째 유저가 아니라면 이 페이지의 퍼즐 조각과 index 정보를 먼저 받아온 후 config 세팅을 한다. (3.1에서 만든 퍼즐 타일 정보를 모두 받아온다) -> socket.emit("groupIndex"), socket.emit("getPuzzleConfig"), Puzzle.setting()
  4. 업데이트된 puzzleConfig를 바탕으로 퍼즐 조각에 move Event를 건다. -> Puzzle.move()
    3.1 move를 호출한 user가 첫 번째 유저라면 랜덤으로 생성한 shape을 기반으로 퍼즐 타일을 만들어야 한다. ->initConfig

이 방의 첫 번째 유저라면

import { createTiles } from "@components/play-puzzle/puzzle-canvas/puzzle/create-puzzle";

if (isFirstClient) {
  setConfig(puzzleImg, level, Paper);
  createTiles();
  config = Puzzle.exportConfig();
  Puzzle.move(isFirstClient, socket, roomID);
  socket.emit("setPuzzleConfig", { roomID: roomID, config: config });
}

이 방의 첫 번째 유저가 아니라면

else {
  socket.emit("groupIndex", { roomID: roomID, groupTileIndex: 200 });
  socket.on("groupIndex", ({ groupIndex }: { groupIndex: number }) => {
    if (!isNaN(groupIndex)) {
      Puzzle.groupFirstUpdate(groupIndex);
    }
  });
  socket.on("getPuzzleConfig", (res: Config) => {
    Puzzle.setting(getConfig(res, Paper));
    Puzzle.move(isFirstClient, socket, roomID);
  });
  socket.emit("getPuzzleConfig", { roomID: roomID });
}

Puzzle Config

return {
  originHeight : img.current.height; //실제 사진의 높이
  originWidth: img.current.width; //실제 사진의 너비
  imgWidth: originHeight >= originWidth
      ? Math.round((levelSize[level] * originWidth) / originHeight / 100) * 100
      : levelSize[level]; // canas에 나타날 이미지의 너비
  imgHeight: originHeight >= originWidth
      ? levelSize[level]
      : Math.round((levelSize[level] * originHeight) / originWidth / 100) * 100; // canvas에 아나탈 이미지의 높이
  tilesPerRow: Math.floor(imgWidth / tileWidth); // 한 줄에 타일 개수
  tilesPerColumn: Math.floor(imgHeight / tileWidth); // 한 열에 타일 개수
  tileWidth: 100; // 한 타일의 길이(타일은 정사각형)
  tileMarginWidth: number; // 타일이 잘 맞기 위한 사이값
  level: number; // 난이도
  imgName: String; // img 태그의 id값
  groupTiles: any[]; // 그룹화된 타일들 배열 ([타일, 해당 타일의 그룹(없을 땐 undefined)])
  shapes: any[]; // 랜덤으로 생성된 각 타일들의 shape 정보
  tiles: any[]; // 타일들 배열
  complete: boolean; // 퍼즐 성공 여부
  groupTileIndex: number | null; // 그룹화된 타일들 배열의 인덱스
  project: any; // html의 캔버스를 감싼 paper 변수. paper 객체를 조작할 떄 필요
  puzzleImage: any; // paper.Raster 객체
  tileIndexes: any[]; // 타일들 배열의 인덱스
};

퍼즐 생성

Html5 Jigsaw Puzzle

타일 모양값 랜덤으로 생성하기

  • 타일의 오른쪽, 아래쪽 면은 랜덤하게 튀어나오거나(1), 들어간 값(-1)을 가진다.
  • 타일의 왼쪽, 위쪽 면은, 왼쪽 타일과 위쪽 타일의 오른쪽 면, 아래쪽 면을 따라 값을 가진다.

이미지까지 씌운 퍼즐 타일 생성하기

  • getMask에서 위에서 랜덤으로 생성한 모양값 따라 퍼즐 곡선을 만든 paper.path()를 반환받는다.
for (let y = 0; y < config.tilesPerColumn; y++) {
  for (let x = 0; x < config.tilesPerRow; x++) {
    const shape = config.shapes[y * config.tilesPerRow + x];
    const mask = getMask(...);
    //mask.opacity, mask.strokeColor 퍼즐 투명도와 퍼즐색 설정
    //퍼즐의 모양을 복사하여 테두리용 path 생성
    const img = getTileRaster(
        config.puzzleImage.clone(),
        new Size(config.tileWidth, config.tileWidth),
        new Point(config.tileWidth * x, config.tileWidth * y),
        Math.max(
          config.imgWidth / config.originWidth,
          config.imgHeight / config.originHeight
        )
      );

    const border = mask.clone();
    border.strokeColor = new config.project.Color("#ddd");
    // 퍼즐 모양, 퍼즐 배경 이미지, 퍼즐 테두리를 group으로 묶어주면 하나의 타일이 됨
    const tile = new config.project.Group([mask, img, border]);
    config.tile.push(tile);
    config.groupTiles.push([tile, undefined]);
    config.groupArr.push(undefined);
    config.tileIndexes.push(config.tileIndexes.length);
  }
}
  • 퍼즐 생성 작업이 끝났다면 Puzzle.setting({...config,});을 호출해 puzzleConfig를 업데이트한다.

퍼즐 동작 (event)

Paper.js─Path

mouseDown

  • 클릭한 퍼즐이 타인에 의해 선점된 퍼즐인지 확인한다.
  • 마우스 클릭을 시작할 때, 클릭한 타일의 그룹이 어딘지 확인한다.
//_parent와 index는 paperjs의 path의 Project Hierarchy이다.
select_idx = gtile[0].index //해당타일 index;
gtile[0]._parent.addChild(gtile[0]); //해당타일

mouseDrag

  • 마우스로 드래그를 할 때 클릭한 타일(단일 또는 그룹)의 위치를 업데이트한다.
  • event.delta.x, event.delta.y 는 이동한 위치의 증감값.
  • newPosition += originalPosition + event.delta;
  • 그룹화된 타일의 이동이라도 캔버스 상에서 그룹화를 한 것이 아닌 수치로만 그룹화를 한 것이다.
if (gtile[1] === undefined) {
  //그룹화안된 타일을 움직이고 있다면 바뀐 위치를 바로 넣어준다.
  gtile[0].position = new Point(newPosition.x, newPosition.y);
} else {
  //그룹화된 타일을 움직이고 있다면 같은 그룹인 타일의 위치도 바꿔준다.
  config.groupTiles.forEach((gtile_now) => {
    if (gtile[1] === gtile_now[1]) {
      gtile_now[0].position = new Point(
        gtile_now[0].position._x + newPosition.x - originalPosition.x,
        gtile_now[0].position._y + newPosition.y - originalPosition.y
      );
    }
  });
}

mouseUp

마우스 클릭을 손에서 떼었을 때, 해당 타일의 그룹화 여부 확인 후 그룹화를 하고 위치보정을 한다. 변화된 위치를 socket.emit("tilePosition")으로 서버의 퍼즐 정보에 저장한다. 선점을 해제한다.

  1. mouseUp한 타일의 parent 배열을 업데이트 한다.
  2. mouseUp한 타일과 mouseUp한 타일의 상하좌우 타일들에 대해 fitTiles() 함수를 호출한다.
  • fitTiles는 mouseUp한 타일과 근처 타일(상하좌우 타일 중 1)이 합쳐져야 하는지 판단하고, preTile과 nowTile의 위치를 보정해준다.(근처에 대충 맞춰도 연결된 타일들로 딱 맞춰준다) 때문에 위치보정만을 위해서도 호출된다.(groupFit) 위치보정을 위한 호출이 아닐 때, 두 타일이 합쳐져야 한다면 uniteFlag의 값을 true로 바꾼다. (위치보정을 위해서 FindChange 컴포넌트의 함수들을 사용한다.)
    위치보정을 위한 호출이 아니고 uniteFlag값이 true라면, uniteTiles(nowTile, preTile)함수를 호출한다.
  • uniteTiles(nowTile, preTile)는 nowTile의 그룹의 유무, preTiles의 그룹의 유무를 고려하며 두 타일을 합친다. 두 타일 모두 그룹이 있다면 preTile의 그룹이 우선순위가 높다.(nowTile의 타일 또는 그룹의 타입들은 preTile의 그룹에 들어간다.)
    • groupTiles의 원소들은 [타일, 타일의그룹] 로 구성되어 있는데, 이 타일의그룹에 preTile이나 nowTile의 그룹인덱스를 넣으면 그룹화가 되었다고 간주한다.
    • uniteTiles에서 nowTile과 preTile 모두가 그룹화되어 있지 않다면 (※groupIndex 질문하기)
    • uniteTiles에서 dismantling을 하고 그룹과 그룹간의 퍼즐을 맞게 하기 위해 groupFit을 호출한다. (※dismantling 리팩토링할까?) (dismantling은 groupTiles에서 nowTile의 타일의그룹 정보를 undefined하는 작업을 말함)
  • groupFit은 퍼즐 그룹과 퍼즐 그룹을 합칠 때 위치 보정을 위한 함수이다.

    예를 들어 (3번 퍼즐, 2번퍼즐), (1번퍼즐, 그 옆의 퍼즐)이 각각 한 그룹으로 묶였다 하자. 2번퍼즐을 drag-mouseUp한 순간, 2번퍼즐의 위치만 보정하면 2번퍼즐의 그룹원인 3번 퍼즐의 위치는 그대로라 퍼즐을 맞출 때 퍼즐이 딱 맞아 보이지 않는다는 문제가 생긴다. groupFit은 이 문제를 해결하는 함수이다.
    • 현재 그룹의 모든 타일들의 상하좌우 타일에 대해 fitTiles를 호출한다.
  1. 업데이트된 그룹과 변화된 위치를 socket.emit("tilePosition")으로 서버의 퍼즐 정보에 저장한다.
config.groupTiles.forEach((gtile, idx) => {
  if (gtile[0] === tile) {
    //이번에 이동한 타일이 그룹화가 되지 않았을 때
    if (gtile[1] === undefined) {
      socket.emit("tilePosition", {
        roomID: roomID,
        tileIndex: idx,
        tilePosition: gtile[0].position,
        tileGroup: gtile[1],
        changedData: gtile[0],
      });
    } else {
      //이번 이동한 타일이 그룹화가 되었다면
      //groupTiles의 모든 원소들의 0번째엔 해당 타일이 없다.
      config.groupTiles.forEach((tileNow, index) => {
        if (gtile[1] === tileNow[1]) {
          //이번 이동한 타일의 그룹과 같은 그룹에 있는 타일들
          socket.emit("tilePosition", {
            roomID: roomID,
            tileIndex: index,
            tilePosition: tileNow[0].position,
            tileGroup: tileNow[1],
            changedData: tileNow[0],
          });
        }
      });
    }
  }
});

mouseUp-fitTiles-findChange

fitTiles는 mouseUp한 타일과 근처 타일(상하좌우 타일 중 1)이 합쳐져야 하는지 판단하고, preTile과 nowTile의 위치를 보정해준다.(근처에 대충 맞춰도 연결된 타일들로 딱 맞춰준다) 때문에 두 타일을 합칠 필요가 없어도 위치보정만을 위해서도 groupFit에서 호출된다.
위치 보정이란 무엇인가

위의 경우 상-하 퍼즐을 맞출 때, y축 이동과 x축 이동을 고려해야 퍼즐이 육안으로 보기에 정확히 맞춰진다. 위의 퍼즐을 기준으로 보았을 때 아래 퍼즐의 y가 오른쪽으로 이동해야 한다. 상하 퍼즐끼리 맞출때 y축, x축 이동을 구현한 함수를 yUp, xUp이라 했다.
반대로 좌-우 퍼즐끼리 맞출 때 y축, x축 이동을 구현한 함수를 yChnage, xChange라고 했다.

checkComplete

groupTiles의 모든 타일들의 그룹이 undefined이며 같을 때 퍼즐이 완성되었다 간주한다.

문제 및 해결방법

  • 문제
    여러명이 플레이할 때 상대방 퍼즐의 이동이 버벅여 보임
  • 원인 찾기 및 해결과정
    • 네트워크 문제일까?: 서버측에 클라이언트 A,B,C,D의 소켓통신 내역을 출력했을 때 정상으로 확인
    • 이벤트의 과다 등록/이벤트 해지 미스 때문일까?: 소켓 이벤트가 렌더링시 재등록되는 것을 발견 이후 해결. 소켓 이벤트는 첫번째 마운트 때만 등록하자.
      참고하기: https://velog.io/@mong-head/React-Socket-이벤트-중복-호출-방지
    • 그리고 console.log를 찍어보니 mouseDrag 이벤트가 너무 많이 발생한 것 같다. 모티브가 된 퍼즐 사이트가 그런 것처럼 이동시에는 퍼즐 조각의 화면 공유를 하지 말자.
    • UX적으로는 throttle을 적용하여 퍼즐의 이동이 모두에게 보이는게 낫지 않을까?
      -> 그렇지만 퍼즐조각을 이동하는 과정이 throttle로 보이도록 해보자. (리팩토링 예정)

0개의 댓글