TypeScript로 canvas에 원 움직이기

TypeScript로 canvas에 원 움직이기

·

7 min read

TypeScript를 이용해 canvas에 원을 움직이는 예제를 만들어 보겠습니다.

본격적으로 개발하기 위해 Visual Studio Code에 TypeScript 개발 환경을 만들 것입니다.

개발 환경 구성

TypeScript 컴파일러(tsc) 설치

tsc를 가장 쉽게 설치하는 방법은 npm을 이용하는 것입니다.

$ npm install -g typescript

정상적으로 설치되었는지 버젼을 통해 확인 합니다.

$ tsc --version

Hello World 예제 부터 시작해 봅시다. HelloWorld 디렉토리를 만든 후 VS Code를 실행합니다.

$ mkdir HelloWorld
$ cd HelloWorld
$ code .

helloworld.ts를 만든 후 아래의 코드를 추가합니다.

let message: string = "Hello World";
console.log(message);

통합 터미널 (Ctrl + `)을 열고 tsc helloworld.ts로 컴파일을 하면 helloworld.js를 얻을 수 있습니다. Node.js를 통해 실행해 볼 수 있습니다.

$ node .\helloworld.js
Hello World

파일 구성

TypeScript를 이용해 canvas에 원을 움직이는 코드를 작성하기 위해서는 다음의 세 개의 파일이 필요합니다.

  • tsconfig.json : TypeScript 컴파일러를 구성
  • index.html : canvas와 원을 추가하는 button이 있는 단순한 HTML 페이지
  • main.ts : 우리가 작성하고 컴파일 할 TypeScript 파일

tsconfig.json

파일은 다음과 같이 구성합니다.

{
    "compilerOptions": {
        "target": "es5",
        "sourceMap": true,
        "lib": [
            "es5",
            "dom"
        ],
        "noUnusedLocals": true,
        "module": "commonjs"
    }
}

위 설정에 대한 자세한 사항은 TypeScript 문서의 What is a tsconfig.json를 통해 확인할 수 있습니다.

index.html

파일은 다음과 같이 구성합니다. 우리가 TypeScript로 canvas에 원을 움직이게 하기 위한 간단한 HTML 구성을 할 것입니다.

<html>

<head>
    <title>Canvas Sample</title>
</head>

<body>
    <canvas id="canvas" width="800" height="800"
            style="border: 1px solid black;">
    </canvas>
    <p></p>
    <button>add 10 circles</button>
    <script src="main.js"></script>
</body>

</html>

main.ts

마지막으로 구현해야 할 TypeScript 파일입니다. 우리는 클래스를 이용할 것이므로 다음의 간단한 구성으로 시작합니다.

class DrawingApp {

}

new DrawingApp();

디버깅 환경 구성

웹페이지로 띄울 것이기 때문에 Live Server를 이용해서 이를 설정합니다.

Live Server를 VS Code 확장에서 설치 합니다.

image.png

설치되면 오른쪽 하단에 Go Live 버튼이 생성됩니다. 이 버튼을 누르면 현 디렉토리로 웹 서버를 띄우게 됩니다.

image.png

TypeScript 빌드

터미널에서 tsc를 실행합니다.

$ tsc

tsc를 실행하면 tsconfig.json 구성에 의해 main.jsmain.js.map 파일이 생성됩니다.

만약 TypeScript 소스코드를 변경할 때마다 자동으로 빌드되게 하려면 tsconfig.jsoncompilerOptions"watch": true를 추가합니다.

크롬에서 디버깅

크롬 브라우저에서 디버깅을 하려면 다음의 launch.json.vscode 디렉토리에 추가 합니다.

{
    "version": "0.2.0",
    "configurations": [
        {
            "type": "pwa-chrome",
            "request": "launch",
            "name": "Launch Chrome against localhost",
            "url": "http://localhost:5500",
            "webRoot": "${workspaceFolder}"
        }
    ]
}

이때 url은 Live Server의 실행 포트를 입력하도록 합니다.

이제 적절한 위치에 중단점 (F9)을 지정하고 디버깅 시작 (F5)를 통해 디버깅을 시작할 수 있습니다.

image.png

image.png

샘플 코딩

디버깅 환경이 구성되었으므로 코드를 조금 바꿔 봅시다.

| main.ts

class DrawingApp {
    private canvas: HTMLCanvasElement;
    private context: CanvasRenderingContext2D;

    constructor(canvas: HTMLCanvasElement) {
        this.canvas = canvas;
        this.context = canvas.getContext("2d");

        this.context.fillStyle = "blue";
        this.context.beginPath();
        this.context.arc(100, 100, 16, 0, 2 * Math.PI);
        this.context.fill();
    }
}

canvascontext를 획득해서 청색 원을 canvas에 그려보았습니다. constructorcanvas 객체를 넘겨줘야 하므로 index.html도 다음처럼 변경 합니다.

| index.html

<html>

<head>
    <title>Canvas Sample</title>
</head>

<body>
    <canvas id="canvas" width="800" height="800"
            style="border: 1px solid black;">
    </canvas>
    <p></p>
    <button>add 10 circles</button>
    <script src="main.js"></script>
    <script type="text/javascript">
        const app = new DrawingApp(document.getElementById('canvas'));
    </script>
</body>

</html>

실행하면 다음과 같이 canvas에 청색 원이 그려지는 것을 확인할 수 있습니다.

image.png

Live Server는 파일을 감시해서 변경되면 웹페이지를 갱신하는 기능이 있어서 index.html을 변경하면 웹페이지에 바로 반영을 해줍니다.

또한 tsconfig.jsoncompilerOptions"watch": true 설정에 의해 main.ts가 변경될 때 tscmain.js로 컴파일 해주고 Live Server가 이를 감지해 웹페이지에 반영해 줍니다.

| main.jsthis.context.fillStyle = "blue";this.context.fillStyle = "red";로 변경한 후 파일 저장시

image.png

canvas 다시 그리기

원을 이동하게 하려면 주기적으로 canvas를 다시 그려야 하는데 웹브라우저에서 다시 그릴 수 있는 시점을 알아야 합니다. 이 때 requestAnimationFrame()를 이용할 수 있습니다.

class DrawingApp {
    private canvas: HTMLCanvasElement;
    private context: CanvasRenderingContext2D;

    constructor(canvas: HTMLCanvasElement) {
        // ...

        requestAnimationFrame(this.redraw);
    }

    private redraw() {
        console.log("!!!");

        requestAnimationFrame(this.redraw);
    }
}

그런데 이 코드는 한번 redraw()가 호출된 후 다시 requestAnimationFrame(this.redraw);가 실행될 때 정상 동작하지 않습니다. 왜냐하면 콜백으로 호출된 메서드에서 thisDrawingApp 객체가 아닌 requestAnimationFrame() 메서드가 있는 Window 객체가 되기 때문입니다.

이를 개선하기 위해 redraw()를 다음처럼 변경해봅시다.

    private redraw = () => {
        console.log("!!!");

        requestAnimationFrame(this.redraw);
    }

이제 잘 redraw()가 계속해서 호출이 됩니다. this의 작동 방식 및 특징 및 수정 방법은 this in TypeScript를 참고하세요.

원 구조

원을 표시하려면 원의 좌표 정보 및 반지름, 색 등의 정보를 따로 정의하는 것이 좋습니다.

type Circle = {
    x: number,
    y: number,
    readonly radius: number,
    readonly color: string,
    xInc: number,
    yInc: number,
};

이제 원 목록을 담는 클래스 필드 및 색 관련 필드를 추가합니다.

    private readonly circles: Circle[] = [];
    private readonly colors: readonly string[] = ["red", "green", "blue"];
    private colorsCount: number = 0;

원 관련 기능

원을 추가할 때마다 다른 색을 적용하기 위해 getColor() 메서드를 만들고

    private getColor(): string {
        let result = this.colors[this.colorsCount];
        this.colorsCount = (this.colorsCount + 1) % this.colors.length;
        return result;
    }

원을 추가하는 메서드를 만들고,

    public addCircles = (numbers: number) => {
        var color = this.getColor();
        for (var i = 0; i < numbers; i++) {
            this.circles.push({
                x: this.canvas.width * Math.random(),
                y: this.canvas.height * Math.random(),
                radius: 32 * Math.random() + 8,
                xInc: 5 * Math.random() + 1,
                yInc: 5 * Math.random() + 1,
                color: color
            });
        }
    }

다음은 원을 그리고 이동하는 메서드를 만듭니다.

    private drawCircle(circle: Circle) {
        this.context.fillStyle = circle.color;
        this.context.beginPath();
        this.context.arc(circle.x, circle.y, circle.radius, 0, 2 * Math.PI);
        this.context.fill();
    }

    private moveCircle(circle: Circle) {
        circle.x += circle.xInc;
        circle.y += circle.yInc;

        if (circle.x < 0 || circle.x > this.canvas.width) {
            circle.xInc *= -1;
        }
        if (circle.y < 0 || circle.y > this.canvas.height) {
            circle.yInc *= -1;
        }
    }

전체 소스코드

| main.ts

class DrawingApp {
    private canvas: HTMLCanvasElement;
    private context: CanvasRenderingContext2D;

    private readonly circles: Circle[] = [];
    private readonly colors: readonly string[] = ["red", "green", "blue"];
    private colorsCount: number = 0;


    constructor(canvas: HTMLCanvasElement) {
        this.canvas = canvas;
        this.context = canvas.getContext("2d");

        this.addCircles(10);

        requestAnimationFrame(this.redraw);
    }

    private redraw = () => {
        this.context.fillStyle = "white";
        this.context.fillRect(0, 0, this.canvas.width, this.canvas.height);

        for (const circle of this.circles) {
            this.drawCircle(circle);
            this.moveCircle(circle);
        }

        requestAnimationFrame(this.redraw);
    }

    private drawCircle(circle: Circle) {
        this.context.fillStyle = circle.color;
        this.context.beginPath();
        this.context.arc(circle.x, circle.y, circle.radius, 0, 2 * Math.PI);
        this.context.fill();
    }

    private getColor(): string {
        let result = this.colors[this.colorsCount];
        this.colorsCount = (this.colorsCount + 1) % this.colors.length;
        return result;
    }

    private moveCircle(circle: Circle) {
        circle.x += circle.xInc;
        circle.y += circle.yInc;

        if (circle.x < 0 || circle.x > this.canvas.width) {
            circle.xInc *= -1;
        }
        if (circle.y < 0 || circle.y > this.canvas.height) {
            circle.yInc *= -1;
        }
    }

    public addCircles = (numbers: number) => {
        var color = this.getColor();
        for (var i = 0; i < numbers; i++) {
            this.circles.push({
                x: this.canvas.width * Math.random(),
                y: this.canvas.height * Math.random(),
                radius: 32 * Math.random() + 8,
                xInc: 5 * Math.random() + 1,
                yInc: 5 * Math.random() + 1,
                color: color
            });
        }
    }
}

type Circle = {
    x: number,
    y: number,
    readonly radius: number,
    readonly color: string,
    xInc: number,
    yInc: number,
};

다음으로 버튼을 눌렀을 때 원을 추가할 수 있도록 index.html을 다음과 같이 수정합니다.

| index.html

<html>

<head>
    <title>Canvas Sample</title>
</head>

<body>
    <canvas id="canvas" width="800" height="800"
            style="border: 1px solid black;">
    </canvas>
    <p></p>
    <button id="add">add 10 circles</button>
    <script src="main.js"></script>
    <script type="text/javascript">
        const app = new DrawingApp(document.getElementById('canvas'));

        const button = document.getElementById('add');
        button.onclick = () => app.addCircles(10);
    </script>
</body>

</html>

실행 결과

image.png

tsc에 의해 변환된 JavaScript 코드

컴파일된 JavaScript 코드를 살펴보는 것은 흥미롭니다. 다음은 컴파일된 JavaScript 코드입니다.

var DrawingApp = /** @class */ (function () {
    function DrawingApp(canvas) {
        var _this = this;
        this.circles = [];
        this.colors = ["red", "green", "blue"];
        this.colorsCount = 0;
        this.redraw = function () {
            _this.context.fillStyle = "white";
            _this.context.fillRect(0, 0, _this.canvas.width, _this.canvas.height);
            for (var _i = 0, _a = _this.circles; _i < _a.length; _i++) {
                var circle = _a[_i];
                _this.drawCircle(circle);
                _this.moveCircle(circle);
            }
            requestAnimationFrame(_this.redraw);
        };
        this.addCircles = function (numbers) {
            var color = _this.getColor();
            for (var i = 0; i < numbers; i++) {
                _this.circles.push({
                    x: _this.canvas.width * Math.random(),
                    y: _this.canvas.height * Math.random(),
                    radius: 32 * Math.random() + 8,
                    xInc: 5 * Math.random() + 1,
                    yInc: 5 * Math.random() + 1,
                    color: color
                });
            }
        };
        this.canvas = canvas;
        this.context = canvas.getContext("2d");
        this.addCircles(10);
        requestAnimationFrame(this.redraw);
    }
    DrawingApp.prototype.drawCircle = function (circle) {
        this.context.fillStyle = circle.color;
        this.context.beginPath();
        this.context.arc(circle.x, circle.y, circle.radius, 0, 2 * Math.PI);
        this.context.fill();
    };
    DrawingApp.prototype.getColor = function () {
        var result = this.colors[this.colorsCount];
        this.colorsCount = (this.colorsCount + 1) % this.colors.length;
        return result;
    };
    DrawingApp.prototype.moveCircle = function (circle) {
        circle.x += circle.xInc;
        circle.y += circle.yInc;
        if (circle.x < 0 || circle.x > this.canvas.width) {
            circle.xInc *= -1;
        }
        if (circle.y < 0 || circle.y > this.canvas.height) {
            circle.yInc *= -1;
        }
    };
    return DrawingApp;
}());
//# sourceMappingURL=main.js.map

정리

VS Code에 TypeScript 개발환경을 구성해서 코딩 결과를 웹브라우저를 통해 확인할 수 있도록 예제를 통해 확인해 보면서 TypeScript의 강력한 타입 검사 기능과 VS Code를 이용한 편리한 디버깅 환경을 살펴볼 수 있었습니다.

샘플 데모

maum.in/samples/moving_circles/index.html