Posts

Phaser로 웹 게임 만들기

웹 게임 개발을 위한 프레임워크인 Phaser를 사용하여 간단한 웹 게임 개발하기 (티스토리로 이동 중)

Skill

Framework : Phaser 3.80.1
Language : JavaScript / HTML / CSS
IDEA : Visual Studio Code

WEBPACK

오픈 소스 자바스크립트 정적 모듈 번들러로, 호환 플러그인 포함 시 HTML, CSS, 이미지 등의 프론트엔드 자산을 변환할 수 있다.
즉, Webpack은 각각의 모듈 (HTML, CSS 등 웹 구성 자원)을 조합해 여러 파일을 하나의 결과물로 만드는 자바스크립트 모듈 번들러이다.

사용하는 이유

성능 최적화와 자동화

  • 여러 파일을 각각 컴파일 하는 것과 여러 파일을 번들링 하여 하나의 파일로 만들어 컴파일 하는 것의 속도 차이는 크다.
  • 코드 축소와 사용하지 않는 코드를 제거하는 등의 최적화 기능을 가지고 있고, 이를 통해 HTTP 요청 수를 감소해 웹 사이트 성능을 향상한다.

번들러 제공 편의성

  • CSS의 단점을 보완한 SCSS나 웹 확장 프로그램인 Stylus, JS에 타입을 부여한 TypeScript를 사용하는 프로젝트라면 번들러가 컴파일 과정에서 필요한 플러그인을 추가하고 실행한다.

Code Splitting

  • Webpack의 매력적인 기능 중 하나인 코드 스플리팅 Code Splitting을 통해 코드를 다양한 번들로 분할하거나, 필요나 요청에 따라 로드하거나, 병렬로 로드할 수 있다.
  • 더 작은 번들을 만들고 리소스 우선순위를 올바르게 제어하는 등 Code Splitting를 사용한다면 로드 시간에 큰 영향을 줄 수 있다.

Module Federation을 통한 종속성 문제 해결

  • Module Federation은 JS의 아키텍처로, 가장 최신 버전인 webpack 5 부터 제공되는 기능이다.
  • 이 기능은 여러 개의 개별 빌드가 단일 애플리케이션을 형성할 수 있도록 한다. 이는 개별 빌드를 컨테이너처럼 작동하고, 빌드 간에 코드를 노출하고 소비하여 단일 통합 애플리케이션을 생성할 수 있게 한다.

webpack.config.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const WorkboxWebpackPlugin = require('workbox-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin'); // 추가

const config = {
    entry: './src/index.js',
    output: {
        path: path.resolve(__dirname, 'dist'),
    },
    devServer: {
        open: true,
        host: 'localhost',
    },
    plugins: [
        new HtmlWebpackPlugin({
            title : "web game",
            filename : "index.html",
            template: 'index.html',
        }),
        new CopyWebpackPlugin({
            patterns: [
                { from: './asset', to: './asset'}
            ]
        })  
    ],
    module: {
        rules: [
            {
                test: /\.(eot|svg|ttf|woff|woff2|png|jpg|gif)$/i,
                type: 'asset',
            },
        ],
    },
};

module.exports = () => {
    config.mode = 'development';
    return config;
};


WEBPACK 참고

webpack 홈페이지
Webpack이란 무엇인가? 정의와 필요성, 그리고 장단점



Phaser

Phaser는 Photon Storm에서 개발한 무료 SW로, HTML5용 게임을 만드는데 사용되는 2D 게임 프레임워크다.
Phaser는 내부적으로 Canvas1와 WebGL 렌더러2를 모두 사용한다. 이를 통해 데스크톱과 모바일에서 빠른 렌더링이 가능하다. 렌더링에는 Pixi.js 라이브러리3를 사용한다.

사용 언어

웹 기술에 의존하는 프레임워크이기 때문에 JavaScript로 개발되었다. 때문에 Phaser를 사용하여 게임을 코딩하려면 JavaScript나 TypeScript를 사용해야 한다.

디렉터리 구조

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Web_Game
ㄴ asset
    ㄴ audio
        ㄴ 음악은 유튜브에서 제공하는 무료 사운드를 사용했다.
    ㄴ image
        ㄴ mouse1.png
        ㄴ mouse2.png
    ㄴ lib
        ㄴ phaser.min.js //phaser 홈페이지에서 다운로드 한 파일 / cdn load 해도 무관
ㄴ dist // 빌드한 파일이 저장되는 곳
ㄴ node_modules
ㄴ src
    ㄴ index.js
    ㄴ intro.js
    ㄴ main.js
    ㄴ end.js
ㄴ index.html
ㄴ package-lock.json
ㄴ package.json
ㄴ webpack.config.js

Code

  • index.html
    • 게임 컨테이너
    • 게임 실행을 위한 웹 페이지의 기본 구조
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<!doctype html>
<html>
    <head>
    <meta charset="utf-8" />
    <title>Webpack App</title>
    </head>
    <body> <!-- 실제로 표시되는 콘텐츠를 포함하는 부분 -->
        <script src="src/index.js"></script> <!-- 진입점 설정 -> index.js -->
        <script>
            // serviceWorker 등록
            if ("serviceWorker" in navigator) {
            window.addEventListener("load", () => {
                navigator.serviceWorker
                .register("service-worker.js") // 브라우저가 service-worker를 지원하면 해당 파일을 등록한다.
                .then((registration) => { // 파일 등록을 성공하면 아래 내용을 콘솔에 출력한다.
                    console.log("Service Worker registered: ", registration);
                })
                .catch((registrationError) => { // 파일 등록 실패 시 아래 내용을 콘솔에 출력한다.
                    console.error(
                    "Service Worker registration failed: ",
                    registrationError
                    );
                });
            });
            }
        </script>
    </body>
</html>
  • index.js
    • 게임 설정
      • Phaser 게임 인스턴스 생성
      • type : 렌더러 설정. Auto로 설정할 경우, 가능한 한 WebGL을 렌더러로 설정하고 그렇지 않으면 canvas 렌더러로 설정한다.
      • parent : 컨테이너 안에 렌더링할 이름 (index.html에서 src/index.js 대신에 이 이름을 사용할 수 있다.)
      • scale : 게임의 크기와 크기 조정 모드 등 설정
        • mode : 화면 크기 조정 방식 설정
        • width 및 height : 게임의 기본 해상도
        • autoCenter : 화면을 중앙에 배치하는 설정
      • scene : 게임에 포함될 씬을 배열 형태로 정의.
        • Scene이란, 게임의 논리와 흐름을 관리하는 기본 단위를 말한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// src/index.js
import Phaser from 'phaser'
import IntroScene from './intro.js';
import MainScene from './main.js'
import EndScene from './End.js';

var game = new Phaser.Game({
    type: Phaser.AUTO,
    parent: 'index',
    scale: {
        // mode : Phaser.Scale.FIT, //종횡비를 유지하며 캔버스를 창 크기에 맞춤
        // mode: Phaser.Scale.RESIZE, //종횡비를 무시하고 캔버스를 창 크기에 맞춤
        // mode: Phaser.Scale.HEIGHT_CONTROLS_WIDTH, //높이를 꽉 채우고 비율에 맞게 가로를 조정
        // mode : Phaser.Scale.WIDTH_CONTROLS_HEIGHT도 가능

        // 게임은 아래의 해상도로 렌더링 됨
        width: 800,
        height: 600,
        // autoCenter: Phaser.Scale.Center.CENTER_HORIZONTALLY,
        autoCenter: Phaser.Scale.Center.CENTER_BOTH // 가로 및 세로 방향으로 중앙에 배치
    },
    scene: [
        IntroScene,
        MainScene,
        EndScene
    ]
})
  • intro.js
    • 인트로 화면 구성
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import Phaser from 'phaser';

export default class IntroScene extends Phaser.Scene {
    constructor() {
        super({ key: 'Intro' })
    }

    create() {
        this.add.text(this.game.scale.width / 2, 200, 'catching bugs', {
            fontFamily: 'Arial',
            fontSize: 80,
            color: '#87CEFA'
        }).setOrigin(0.5, 0.5)

        this.textTap = this.add.text(this.game.scale.width/2, 500, 'Tap to continue', {
            fontFamily: 'Arial',
            fontSize:50,
            color: 'white'
        }).setAlpha(1).setOrigin(0.5, 0.5)

        this.input.on('pointerdown', (point) => {
            this.scene.start('Main') //Main 씬으로 이동
        })

        this.tweens.add({    // 애니메이션 설정 -> 텍스트가 0.5초간 투명 -> 불투명을 반복
            targets: this.textTap,  // 애니메이션을 적용할 대상
            alpha: 0,        // alpha 값을 0으로 변경하여 투명하게 만듦
            duration: 500,   // 애니메이션의 지속 시간 (밀리초)
            ease: 'Linear',  // 애니메이션의 보간 방법 (선형 보간)
            yoyo: true,      // 애니메이션이 끝나면 반대 방향으로 재생
            repeat: -1       // 애니메이션을 무한히 반복
        })
    }
}
  • main.js
    • 메인 화면 구성
    • 마우스 커서를 지정한 이미지로 표현하고 사용자의 마우스 커서는 숨긴다.
    • 랜덤한 값으로 벌레의 위치와 크기가 결정되며 지정한 시간만큼 생성과 삭제를 반복한다.
    • 이 과정에서 벌레를 클릭하면 벌레의 이미지가 유령 이미지로 바뀌면서 점수가 +1 된다.
    • 지정한 시간이 끝나면 EndScene을 호출한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
import Phaser from 'phaser';

export default class MainScene extends Phaser.Scene {
    constructor() {
        super({ key: 'Main' });
    }

    preload() {
        this.load.image('mouse', '../asset/image/mouse1.png');
        this.load.image('bug', '../asset/image/bug.png');
        this.load.image('ghosts', '../asset/image/ghosts.png'); // 새로운 이미지 로드
    }

    create() {
        this.score = 0; // 점수 초기화
        this.timeLeft = 30; // 남은 시간 초기화

        // 30초 타이머 설정
        this.time.addEvent({
            delay: 1000, // 1초마다 실행
            callback: this.updateTimer,
            callbackScope: this,
            loop: true
        });

        // 1초마다 새로운 버그 이미지를 추가하는 타이머 설정
        this.time.addEvent({
            delay: 1000, // 1초마다 실행
            callback: this.addRandomBug,
            callbackScope: this,
            loop: true
        });

        this.input.setDefaultCursor('none'); // 마우스 포인터 숨기기
        this.mouseImage = this.add.image(this.scale.width / 2, this.scale.height / 2, 'mouse');
        this.mouseImage.setDepth(1); // 마우스 이미지의 깊이를 1로 설정해 다른 이미지보다 앞에 있도록 함

        // 점수 텍스트를 화면에 추가
        this.scoreText = this.add.text(10, 10, `Score: ${this.score}`, {
            fontSize: '32px',
            fill: '#fff'
        });

        // 남은 시간 텍스트를 화면에 추가
        this.timerText = this.add.text(10, 50, `Time Left: ${this.timeLeft}`, {
            fontSize: '32px',
            fill: '#fff'
        });
    }

    update() {
        // 이미지의 위치를 마우스 포인터 위치로 할당
        this.mouseImage.x = this.input.activePointer.x;
        this.mouseImage.y = this.input.activePointer.y;
    }

    updateTimer() {
        this.timeLeft -= 1; // 남은 시간 감소
        this.timerText.setText(`Time Left: ${this.timeLeft}`); // 남은 시간 텍스트 업데이트

        if (this.timeLeft <= 0) {
            this.gameOver(); // 남은 시간이 0이 되면 게임 종료
        }
    }

    addRandomBug() {
        const { width, height } = this.scale;

        // 랜덤 위치 생성
        const x = Phaser.Math.Between(0, width);
        const y = Phaser.Math.Between(0, height);

        // 랜덤 크기 생성
        const scale = Phaser.Math.FloatBetween(0.05, 0.4);

        // 새로운 버그 이미지 추가
        const bug = this.add.image(x, y, 'bug');
        bug.setScale(scale);
        bug.setDepth(0);
        bug.setInteractive(); // 버그 이미지에 대해 인터랙티브 설정

        // 클릭 이벤트 추가
        bug.on('pointerdown', () => {
            console.log('Bug clicked!');
            bug.setTexture('ghosts'); // 클릭 시 이미지 변경
            this.incrementScore(); // 점수 증가

            this.time.addEvent({
                delay: 200, // 0.2초 후에 버그 이미지 삭제
                callback: () => {
                    bug.destroy();
                },
                callbackScope: this
            });
        });

        this.time.addEvent({
            delay: 1000, // 1초 후에 실행
            callback: () => {
                bug.destroy();
            },
            callbackScope: this
        });
    }

    incrementScore() {
        this.score += 1; // 점수 증가
        this.scoreText.setText(`Score: ${this.score}`); // 점수 텍스트 업데이트
        this.registry.set('score', this.score); // 점수 저장
    }

    gameOver() {
        this.scene.start('End'); // EndScene으로 전환
    }
}
  • end.js
    • 엔딩 화면 구성
    • 사용자가 잡은 벌레의 수를 반환한다.
    • 벌레 수에 따라 엔딩 문구를 바꾼다.
    • 화면을 클릭하면 게임을 재시작한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
import Phaser from 'phaser';

export default class EndScene extends Phaser.Scene {
    constructor() {
        super({ key: 'End' });
    }

    preload() {
        this.load.image('mouse', '../asset/image/mouse1.png');
    }

    create() {
        this.mouseImage = this.add.image(this.scale.width / 2, this.scale.height / 2, 'mouse');

        // 점수를 레지스트리에서 가져오고 undefined일 경우 0으로 설정
        const score = this.registry.get('score') || 0;

        // Game Over 텍스트
        this.add.text(this.scale.width / 2, this.scale.height / 2 - 150, 'Game Over', {
            fontSize: '64px',
            fill: '#fff'
        }).setOrigin(0.5, 0.5);

        // 점수에 따라 다른 엔딩 문구 설정
        let endingText = '';
        if (score >= 100) {
            endingText = '벌레 잡기의 신입니다!';
        } else if (score >= 50) {
            endingText = '그냥저냥 하네요.';
        } else {
            endingText = '벌레 잡기는 다른 사람에게 맞깁시다.';
        }

        // 엔딩 문구를 화면에 추가
        this.add.text(this.scale.width / 2, this.scale.height / 2 - 50, endingText, {
            fontSize: '32px',
            fill: '#fff',
            fontFamily: 'Arial',
            align: 'center'
        }).setOrigin(0.5);

        // 점수 표시
        this.add.text(this.scale.width / 2, this.scale.height / 2 + 50, `Your Score: ${score}`, {
            fontSize: '32px',
            fill: '#fff'
        }).setOrigin(0.5, 0.5);

        // 게임 재시작 텍스트
        this.add.text(this.scale.width / 2, this.scale.height / 2 + 150, 'Click to Restart', {
            fontSize: '24px',
            fill: '#fff'
        }).setOrigin(0.5, 0.5);

        this.input.on('pointerdown', () => {
            this.scene.start('Main'); // MainScene으로 다시 시작
        });
    }

    update() {
        // 이미지의 위치를 마우스 포인터 위치로 할당
        this.mouseImage.x = this.input.activePointer.x;
        this.mouseImage.y = this.input.activePointer.y;
    }
}

트러블슈팅

1. 환경 세팅

가장 처음 phaser 세팅 후 간단한 게임 화면을 만든 코드는 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
<!DOCTYPE html>
<html lang="ko">
<head>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/phaser/3.80.1/phaser.min.js"></script>
</head>
<body>
    <div>제목</div>
    <div id="game"></div>

    <script type="text/javascript">
        class MainScene extends Phaser.Scene {
            constructor() {
                super({ key: 'MainScene' });
                this.player;
            }

            preload() {
                // 플레이어 그래픽을 생성한다. 0x00ff00 색상으로 가로20, 세로20 만큼의 원으로 만든다.
                let p = this.add.graphics().fillStyle(0x00ff00).fillCircle(10, 10, 10, 10);
                // 플레이어 그래픽을 'player'라는 이름의 Texture로 생성한다.
                p.generateTexture("player", 20, 20);
                // 플레이어 그래픽을 파괴한다.
                p.destroy();
            }

            create() {
                // 플레이어 오브젝트를 250, 250 위치에 생성한다.
                this.player = this.physics.add.image(250, 250, "player");
            }
        }

        const config = {
            type: Phaser.AUTO, // 게임 타입. 렌더링 방식을 결정하는 프로퍼티. -> canvas를 자동으로 설정함을 의미
            width: 500, // 화면의 가로 크기
            height: 500, // 화면의 세로 크기
            parent: "game", // 게임을 그릴 영역 id
            backgroundColor: "#000000", // 배경 색 RGB
            // transparent: true, //배경을 투명하게 설정 (default : 검정)
            // 물리 설정. 충돌 처리 등에 사용
            physics: {
                default: "arcade", // 아케이드 게임 (phaser에서 만든 자체 물리엔진) vs matter (상용화되어 있는 물리엔진)
                arcade: {
                    debug: true, // 디버그 모드 설정
                },
            },
            scale: {
                parent: "game", //div id 값
                // 높이를 꽉 채우고, 비율에 맞게 가로를 조정한다.
                // css로 치면 height 100% width auto 의 기능을 한다.
                // WIDTH_CONTROLS_HEIGHT 도 가능하다.
                mode: Phaser.Scale.HEIGHT_CONTROLS_WIDTH,
                // 게임이 아래의 해상도로 렌더링된다.
                // 모든 좌표, 크기 설정은 이 크기를 기본으로 계산된다.
                width: 600,
                height: 300,
            },
            //씬 관리
            scene: {MainScene}, // 게임 Scene 배열로 전달
        };

        let game = new Phaser.Game(config);
    </script>
</body>
</html>

그리고 npm run serve로 실행을 하는데 계속 phaser을 실행할 수 없다는 에러가 발생했다.
phaser.min.js를 cdn으로 받는데도 왜 그러지? 하고 다른 블로그를 확인했는데 다른 곳에서는 문제 없이 실행되는 것 같았다.
그러던 중, phaser 홈페이지를 들어가보니 phaser.min.js를 다운 받을 수 있도록 제공하고 있어서 assets/lib에 넣고 해당 경로로 지정했더니 문제 없이 동작했다.

1
2
3
4
...
    <script src="./assets/lib/phaser.min.js"></script>
...

어디서는 cdn 부분을 body에 넣어야 한다고 하고 어디는 head에 넣어야 한다고 하고.. 외부 사이트에 접근해야 하는 부분이라 이것저것 신경쓸게 많아서 앞으로는 어지간하면 라이브러리를 직접 받아 사용하려고 한다.

softserve 님 - Javascript CDN을 피해야 하는 이유


참고

아날로그코더 님 - Phaser 로 자바스크립트 게임 만들기 (지속 업데이트)


  1. canvas API는 별도의 프로그램 설치 없이 HTML의 <canvas> 태그와 JavaScript를 통해 그래픽을 그리기 위한 수단을 제공하며 데이터 시각화, 애니메이션, 웹 게임 등 다양한 활용이 가능하다. ↩︎

  2. WebGL은 웹 기반의 그래픽 API로, 저수준의 3D 그래픽을 사용할 수 있도록 제공한다. HTML5의 Canvas 요소를 통해 JavaScript로 접근할 수 있게 설계되어 있다. ↩︎

  3. Pixi는 HTML5 기반의 오픈소스 그래픽 라이브러리이다. 빠르고 유연한 2D WebGL 렌더링 시스템으로, 그래픽 집약적 프로젝트에 매우 빠른 성능을 제공한다. ↩︎

이 포스트는 저작권자의 CC BY 4.0 라이센스를 따릅니다.