Canvas API란 Javascript와 HTML의 canvas 엘리먼트를 통해 웹에 그래픽을 그리기 위한 수단이며, 애니메이션, 게임, 그래픽 및 비디오를 처리하기위해 사용됩니다.
이번 프로젝트는 2D 웹 게임을 만드는 프로젝트 이기에 웹에서 그래픽을 구현하고 Javascript를 이용해 그래픽을 조작할 수 있어야 하기 때문에 Canvas API는 중요 기술이였다.
우선 React 기반에서 Typescript를 사용해 컴포넌트 및 기능 개발을 진행할 것이다.
1. useRef() 리액트 훅을 이용해 <canvas> 요소에 접근
React의 함수형 컴포넌트에서 <canvas> 요소에 접근하기 위해서는 리액트의 useRef() 훅을 이용해 접근해야 한다.
const GamePlaying = ({ canvasWidth, canvasHeight }: GamePlayingPropsInterface) => {
const canvasRef: RefObject<HTMLCanvasElement> = useRef<HTMLCanvasElement>(null);
return (
<GameContentArea>
<GameCanvas ref={canvasRef} />
</GameContentArea>
)
}
const GameCanvas = styled.canvas`
...
`
위의 코드에서 useRef() 훅에 제네릭 타입을 선언해 호출한 것을 볼 수 있는데, 이는 <canvas> 요소에 접근하기 위해 useRef() 훅을 호출하면서 타입을 지정한 것이다.
제네릭 타입을 사용하면 타입을 동적으로 결정하며 타입을 보장할 수 있어 재사용성이 높은 함수 또는 클래스를 선언할 수 있다.
useRef() 훅은 3개의 동명의 함수가 선언되어 있으며, 매개변수 타입에 따라 호출되는 함수를 결정하는 오버로드 함수(Overloaded Functions)이다.
동일한 이름에 매개변수에 따라 여러 버전의 함수를 만드는 것을 함수 오버로딩이라 하며, Typescript에서는 함수의 다형성을 지원하는 함수 오버로딩을 지원한다.
function useRef<T>(initialValue: T): MutableRefObject<T>;
function useRef<T>(initialVlue: T | null): RefObject<T>;
function useRef<T>(undefined): MutableRefObject<T | undefined>;
1번 함수와 2번 함수는 자주 사용되는 useRef()이다. 두 함수가 반환하는 객체의 인터페이스 이름에서 알 수 있듯이 1번은 수정할 수 있는(Mutable) current 속성을 갖는 Ref 객체(RefObject)를 반환한다. 2번째 함수는 반환하는 객체 인터페이스 이름에서 명시하지는 않지만, 수정할 수 없고 읽기 전용(readonly)인 current 속성을 갖는 객체를 반환한다.
interface MutableRefObject<T> {
current: T;
}
interface RefObject<T> {
readonly current: T | null;
}
이때 착각하기 쉬운 점은 readonly는 객체를 완전히 수정하지 못하는 것이 아니라 객체 내부의 속성을 수정해도 에러는 발생하지 않지만, 객체 자체를 바꿀 수 없다는 의미이다.
interface Home {
readonly resident: { name: string; age: number };
}
function evict(home: Home) {
// We can't write to the 'resident' property itself on a 'Home'.
home.resident = {
name: "Victor the Evictor",
age: 42,
};
// TypeScript Error: Cannot assign to 'resident' because it is a read-only property.
}
function visitForBirthday(home: Home) {
// We can read and update properties from 'home.resident'.
console.log(`Happy birthday ${home.resident.name}!`);
home.resident.age++;
}
이번 프로젝트에서 사용할 <canvas>에 접근하기 위해서는 2번째 useRef()를 사용하려한다. 이유는 한번 <canvas>요소에 접근하면 더이상 접근할 요소를 변경할 이유가 없고, 사실상 웹의 렌더링 특성상 최초에는 아직 DOM에 <canvas> 요소가 렌더링 되어있지 않기에 useRef()에는 null이 할당된다. 이러한 이유로 2번째 useRef() 훅을 이용하기로 했다.
const GamePlaying = ({ canvasWidth, canvasHeight }: GamePlayingPropsInterface) => {
const canvasRef: RefObject<HTMLCanvasElement> = useRef<HTMLCanvasElement>(null);
return (
<GameContentArea>
<GameCanvas ref={canvasRef} />
</GameContentArea>
)
}
const GameCanvas = styled.canvas`
...
`
2. 커스텀 훅을 만들어 Canvas API의 2D Context 가져오기
다른 코드 혹은 컴포넌트에서도 사용할 수 있는 커스텀 훅을 만들어 쉽게 Canvas API를 이용할 수 있게 해보자.
우선 React의 useEffect() 훅을 이용해 부수 효과를 컴포넌트 생명 주기(Life Cycle)에 따라 처리하도록 해주자.
굳이 useEffect()를 사용하는 이유는 컴포넌트 렌더링 결과가 실제 돔에 반영된 뒤(componentDidMount)와 컴포넌트가 업데이트된 뒤(componentDidUpdate), 컴포넌트가 사리지기 직전(componentWillUnmount)에 호출되며 컴포넌트 생명주기에 맞게 동작을 처리할 수 있기 때문이다.
canvas의 너비와 높이에 적용할 값을 매개변수로 받아오고, <canva> 요소와 연결할 canvasRef 객체를 반환하는 함수이다.
이때 2D 컨텍스트를 불러오기 위해 getContext('2d') 를 호출했다.
import React, { RefObject, useEffect, useRef } from 'react';
/**
* 게임을 display해주는 canvas의 설정을 해주는 Hook
* @param canvasWidth : number
* @param canvasHeight : number
* @returns canvasRef: RefObject<HTMLCanvasElement>
*/
export const useCanvas = ( canvasWidth: number, canvasHeight: number) => {
const canvasRef: RefObject<HTMLCanvasElement> = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
const ctx = canvas?.getContext('2d');
const setCanvas = () => {
if (canvas && ctx) {
canvas.width = canvasWidth;
canvas.height = canvasHeight;
}
};
setCanvas();
}, [canvasWidth, canvasHeight]);
return canvasRef;
};
const GamePlaying = ({ canvasWidth, canvasHeight }: GamePlayingPropsInterface) => {
const canvasRef: RefObject<HTMLCanvasElement> = useCanvas(canvasWidth, canvasHeight);
return (
<GameContentArea>
<GameCanvas ref={canvasRef} />
</GameContentArea>
)
}
const GameContentArea = styled.div`
width: 100%;
height: 80vh;
background-color: aliceblue;
display: flex;
justify-content: center;
align-items: center;
`;
3. 반응형UI 적용하기
위의 useCanvas 커스텀 훅의 코드 중 2d 컨텍스트를 불러오기 위해 작성한 코드를 보자.
옵셔널 체이닝(?)이 존재하는 모습을 볼 수 있는데, 이렇게 하면 최초 렌더링 시 null로 지정되어 있는 초기값 때문에 에러가 발생할 수 있다.
하지만 이러한 걱정은 필자가 반응형 UI를 적용하기 위해 만든 또다른 커스텀 Hook으로 인해 해결할 수 있다.
React 컴포넌트는 상태나 상속받는 props가 변화하면 리렌더링이 발생한다. 이러한 특징과 브라우저의 리사이징을 통한 너비 및 크기 변화를 감지해 해결할 수 있다.
사용자에게 <canvas> 요소를 보여주는 GameDisplayArea 영역의 크기가 리사이지될 때마다 새로운 사이즈를 측정하고, 이는 아래의 useClientWidthHeight 컴포넌트에서 useEffect에 매개변수로 전달되는 ref를 의존성을 전달했다.
import GamePlaying from 'components/Game/GamePlaying';
import Header from 'components/modules/Header/Header';
import { useClientWidthHeight } from 'hooks/useClientWidthHeight';
import React, { RefObject, useEffect, useRef } from 'react';
import styled from 'styled-components';
const GamePlayingPage = () => {
const mainRef: RefObject<HTMLDivElement> = useRef<HTMLDivElement>(null);
const clientRect = useClientWidthHeight(mainRef);
const [canvasWidth, canvasHeight] = [clientRect.width, clientRect.height];
return (
<PlayingPage>
<Header />
<GameDisplayArea ref={mainRef}>
<GamePlaying canvasWidth={canvasWidth} canvasHeight={canvasHeight} />
</GameDisplayArea>
</PlayingPage>
)
}
const PlayingPage = styled.div`
width: 100%;
`
const GameDisplayArea = styled.div`
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
`
export default GamePlayingPage;
import React, { RefObject, useEffect, useState } from 'react';
/**
* 사용자의 Display 사이즈의 변화에 따라 ref로 지정한 요소의 크기를 측정해
* canvas 요소에서 사용할 너비와 높이 전달
* @param ref RefObject<HTMLElement>
* @returns clientRects {width: number, height: number}
*/
export const useClientWidthHeight = (ref: RefObject<HTMLElement>) => {
const [width, setWidth] = useState<number>(0);
const [height, setHeight] = useState<number>(0);
useEffect(() => {
const setClientWidthHeight = () => {
if (ref.current) {
setWidth(ref.current.clientWidth);
setHeight(ref.current.clientHeight / 2.1);
}
};
setClientWidthHeight();
window.addEventListener('resize', setClientWidthHeight);
return () => {
window.removeEventListener('resize', setClientWidthHeight);
};
}, [ref]);
const clientRects = { width, height };
return clientRects;
};
이렇게 반응형으로 너비와 높이를 측정한 값을 자식 컴포넌트인 GamePlaying 컴포넌트의 props로 전달하며 원활하게 canvas의 사용이 가능하게 개발할 수 있다.
'프로젝트 > 북극팽귄 프로젝트' 카테고리의 다른 글
페이지 이동에 따른 게임 실행 에러 해결.V2 (5) | 2024.09.11 |
---|---|
페이지 이동에 따른 게임 실행 에러 해결 (0) | 2024.09.09 |
웹 게임 만들기를 도와주는 Kaplay 라이브러리 사용 (4) | 2024.09.03 |
Storybook을 이용한 컴포넌트 UI 테스트 및 문서화 (0) | 2024.08.16 |
(tsconfig.json, webpack.config.js 설정 오류)React에서 절대경로 사용시 발생한 에러 해결 (0) | 2024.04.26 |