SVGで作る！アニメーションゲームの実装手順

## はじめに

SVGを使用してインタラクティブなゲームを作成する方法を紹介します。この記事では、SVGでキャラクターを描画し、JavaScriptで動きを制御する実装方法を step by step で解説していきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2648069/3d2ff307-3b4f-7723-ea9b-606604af744f.png)

## この記事で学べること

- SVGを使ったキャラクターデザイン
- JavaScriptによるSVGの動的な制御
- アニメーションとインタラクションの実装
- 簡単なゲームシステムの構築

## 環境

- HTML5
- JavaScript（ES6+）
- SVG

## 完成イメージ

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2648069/079b6077-7d81-bd16-4d49-87c24a95d009.png)


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2648069/2161fae4-e05f-5130-93bb-e4477127d73c.png)

## 実装手順

### 1. キャラクターのデザイン

まずはSVGでキャラクターを作成します。SVGは複数のパスを組み合わせることで、複雑な図形を描画できます。

```html
<svg id="character" viewBox="-100 -100 200 200">
  <g>
    <!-- 本体 -->
    <ellipse cx="0" cy="0" rx="50" ry="45" fill="#4CAF50" />
    <!-- 顔 -->
    <ellipse cx="0" cy="0" rx="30" ry="25" fill="#FDF1D2" />
    <!-- 表情の切り替え用グループ -->
    <g id="face">
      <!-- 通常の目 -->
      <g id="eyes-normal">
        <circle cx="-10" cy="-5" r="2.5" fill="#000" />
        <circle cx="10" cy="-5" r="2.5" fill="#000" />
      </g>
      <!-- ジャンプ時の目 -->
      <g id="eyes-jumping" style="display: none">
        <line x1="-12" y1="-6" x2="-8" y2="-4" stroke="#000" stroke-width="2" />
        <line x1="-12" y1="-4" x2="-8" y2="-6" stroke="#000" stroke-width="2" />
        <line x1="8" y1="-6" x2="12" y2="-4" stroke="#000" stroke-width="2" />
        <line x1="8" y1="-4" x2="12" y2="-6" stroke="#000" stroke-width="2" />
      </g>
    </g>
    <!-- その他のパーツ -->
  </g>
</svg>
```

### 2. ゲームの基本システム

ゲームの核となるクラスを作成します。

```javascript
class Game {
    constructor() {
        // 必要な要素の参照を取得
        this.character = document.getElementById('character');
        this.container = document.getElementById('game-container');
        this.scoreElement = document.getElementById('score');
        
        // 状態管理
        this.x = 400;
        this.y = 300;
        this.vx = 0;
        this.vy = 0;
        this.isJumping = false;
        this.score = 0;
        
        this.init();
    }

    init() {
        this.setupControls();
        this.startGameLoop();
    }
}
```

### 3. アニメーションの実装

キャラクターの動きは、CSSのtransformとJavaScriptを組み合わせて実現します。

```javascript
class Game {
    // ... 前述のコード ...

    update() {
        // 重力の適用
        if (this.isJumping) {
            this.vy += 0.8;
        }

        // 位置の更新
        this.x += this.vx;
        this.y += this.vy;

        // 地面との衝突判定
        if (this.y > 300) {
            this.y = 300;
            this.vy = 0;
            this.isJumping = false;
            this.setJumpingFace(false);
        }

        // キャラクターの位置更新
        this.character.style.transform = `translate(${this.x}px, ${this.y}px)`;
    }

    setJumpingFace(isJumping) {
        document.getElementById('eyes-normal').style.display = 
            isJumping ? 'none' : 'block';
        document.getElementById('eyes-jumping').style.display = 
            isJumping ? 'block' : 'none';
    }
}
```

### 4. エフェクトの追加

視覚的なフィードバックを追加して、ゲーム体験を向上させます。

```javascript
createLandingEffect() {
    const container = document.createElement('div');
    container.className = 'land-effect';
    
    // 星型のエフェクトを作成
    for (let i = 0; i < 8; i++) {
        const sparkle = document.createElementNS("http://www.w3.org/2000/svg", "svg");
        const star = document.createElementNS("http://www.w3.org/2000/svg", "path");
        
        // 星型のパスを設定
        star.setAttribute("d", "M10 0L13 7L20 8L15 13L16 20L10 17L4 20L5 13L0 8L7 7Z");
        star.setAttribute("fill", "#FFD700");
        
        sparkle.appendChild(star);
        container.appendChild(sparkle);
    }
    
    this.container.appendChild(container);
    setTimeout(() => container.remove(), 500);
}
```

### 5. ゲーム要素の実装

コイン収集システムを実装して、ゲーム性を追加します。

```javascript
class Game {
    // ... 前述のコード ...

    spawnCoin() {
        const coin = document.createElementNS("http://www.w3.org/2000/svg", "svg");
        coin.setAttribute("class", "coin");
        coin.innerHTML = `
            <circle cx="50" cy="50" r="40" fill="#FFD700"/>
            <text x="50" y="65" text-anchor="middle" fill="#B8860B" font-size="40">¥</text>
        `;
        
        // ランダムな位置に配置
        const x = Math.random() * (this.container.clientWidth - 50);
        const y = Math.random() * (this.container.clientHeight - 150);
        
        coin.style.left = `${x}px`;
        coin.style.top = `${y}px`;
        
        this.container.appendChild(coin);
        this.coins.push({ element: coin, x, y });
    }

    collectCoin(coin) {
        coin.element.remove();
        this.score += 100;
        this.scoreElement.textContent = `Score: ${this.score}`;
        this.showScorePopup();
    }
}
```

## SVGを選んだ理由

1. **高品質なグラフィック**: SVGはベクター形式なので、任意のサイズで鮮明に表示できます。
2. **軽量**: パスデータで図形を定義するため、画像ファイルと比べて非常に軽量です。
3. **動的な制御**: JavaScriptで簡単に要素の操作や変更が可能です。
4. **アニメーション**: CSS transitions/animationsとの相性が良く、滑らかなアニメーションを実現できます。

## パフォーマンスの考慮点

1. `requestAnimationFrame`を使用して、効率的なアニメーションループを実現
2. DOM操作を最小限に抑え、transformプロパティを活用
3. 不要なエフェクト要素は適切なタイミングで削除

## 発展的な実装アイデア

- 複数のステージ
- 障害物の追加
- パワーアップアイテム
- モバイル対応（タッチ操作）
- サウンドエフェクト

## まとめ

SVGとJavaScriptを組み合わせることで、軽量で高品質なブラウザゲームを作ることができます。SVGの特徴を活かした表現は、特にキャラクターやエフェクトのアニメーションに有効です。

## 参考文献

- [SVG: Scalable Vector Graphics | MDN](https://developer.mozilla.org/ja/docs/Web/SVG)
- [Using SVG with CSS3 and HTML5](https://shop.oreilly.com/product/0636920037972.do)
- [Game Programming Patterns](http://gameprogrammingpatterns.com/)

## ソースコード

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2648069/6e37fae8-9ea6-9b0f-bb26-8bea13aaa89c.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2648069/8529a9d6-80e3-c95e-f844-23796727fdc0.png)

```html
<!DOCTYPE html>
<html>
<head>
    <title>Simple Character Game</title>
    <style>
        #game-container {
            width: 800px;
            height: 400px;
            border: 2px solid #333;
            background: linear-gradient(to bottom, #87CEEB 0%, #87CEEB 60%, #90EE90 60%, #90EE90 100%);
            position: relative;
            overflow: hidden;
            margin: 20px auto;
        }
        #score {
            position: absolute;
            top: 20px;
            left: 20px;
            font-size: 24px;
            color: #333;
            background: rgba(255, 255, 255, 0.8);
            padding: 10px;
            border-radius: 5px;
            font-family: Arial, sans-serif;
            z-index: 100;
        }
        .coin {
            position: absolute;
            width: 30px;
            height: 30px;
            transition: transform 0.3s ease;
        }
        .coin:hover {
            transform: scale(1.1);
        }
        .score-popup {
            position: absolute;
            color: #FFD700;
            font-size: 20px;
            font-weight: bold;
            font-family: Arial, sans-serif;
            pointer-events: none;
            animation: floatUp 0.8s ease-out forwards;
        }
        @keyframes floatUp {
            0% { opacity: 1; transform: translateY(0); }
            100% { opacity: 0; transform: translateY(-30px); }
        }
        #character {
            position: absolute;
            width: 80px;
            height: 80px;
        }
        .effect {
            position: absolute;
            pointer-events: none;
        }
        .sparkle {
            animation: sparkle 0.5s ease-out forwards;
        }
        @keyframes sparkle {
            0% { opacity: 1; transform: scale(1); }
            100% { opacity: 0; transform: scale(2) translateY(-20px); }
        }
        #controls {
            text-align: center;
            padding: 10px;
        }
    </style>
</head>
<body>
    <div id="game-container">
        <div id="score">Score: 0</div>
        <svg id="character" viewBox="-100 -100 200 200" xmlns="http://www.w3.org/2000/svg">
            <g>
                <!-- 本体(楕円) -->
                <ellipse cx="0" cy="0" rx="50" ry="45" fill="#4CAF50" />
                <!-- 顔部分(クリーム色の楕円) -->
                <ellipse cx="0" cy="0" rx="30" ry="25" fill="#FDF1D2" />
                <!-- 左耳 -->
                <path d="M -20 -32 C -30 -50, -10 -60, -15 -40 C -18 -35, -18 -35, -20 -32 Z" fill="#4CAF50" />
                <path d="M -20 -34 C -27 -47, -13 -52, -16 -40 C -18 -37, -18 -37, -20 -34 Z" fill="#FFD83D" />
                <!-- 右耳 -->
                <path d="M 20 -32 C 30 -50, 10 -60, 15 -40 C 18 -35, 18 -35, 20 -32 Z" fill="#4CAF50" />
                <path d="M 20 -34 C 27 -47, 13 -52, 16 -40 C 18 -37, 18 -37, 20 -34 Z" fill="#FFD83D" />
                <!-- 目と表情 -->
                <g id="face">
                    <!-- 通常の目 -->
                    <g id="eyes-normal">
                        <circle cx="-10" cy="-5" r="2.5" fill="#000" />
                        <circle cx="10" cy="-5" r="2.5" fill="#000" />
                    </g>
                    <!-- ジャンプ時の目 -->
                    <g id="eyes-jumping" style="display: none">
                        <line x1="-12" y1="-6" x2="-8" y2="-4" stroke="#000" stroke-width="2" />
                        <line x1="-12" y1="-4" x2="-8" y2="-6" stroke="#000" stroke-width="2" />
                        <line x1="8" y1="-6" x2="12" y2="-4" stroke="#000" stroke-width="2" />
                        <line x1="8" y1="-4" x2="12" y2="-6" stroke="#000" stroke-width="2" />
                    </g>
                </g>
                <!-- 鼻穴 -->
                <circle cx="-2" cy="0" r="1" fill="#000" />
                <circle cx="2" cy="0" r="1" fill="#000" />
                <!-- 口 -->
                <path d="M -5 5 Q 0 10 5 5" stroke="#000" stroke-width="1" fill="none" />
                <!-- ヒゲ -->
                <line x1="-20" y1="-5" x2="-35" y2="-5" stroke="#000" stroke-width="1" />
                <line x1="-20" y1="0" x2="-35" y2="0" stroke="#000" stroke-width="1" />
                <line x1="-20" y1="5" x2="-35" y2="5" stroke="#000" stroke-width="1" />
                <line x1="20" y1="-5" x2="35" y2="-5" stroke="#000" stroke-width="1" />
                <line x1="20" y1="0" x2="35" y2="0" stroke="#000" stroke-width="1" />
                <line x1="20" y1="5" x2="35" y2="5" stroke="#000" stroke-width="1" />
                <!-- しっぽ -->
                <path d="M 35 30 C 45 40, 60 35, 50 20 C 45 10, 40 20, 35 30 Z" fill="#4CAF50" />
            </g>
        </svg>
    </div>
    <div id="controls">
        操作方法: ← 左移動 | → 右移動 | スペース ジャンプ | Shift+スペース スーパージャンプ
    </div>

    <script>
        class Game {
            constructor() {
                this.character = document.getElementById('character');
                this.container = document.getElementById('game-container');
                this.scoreElement = document.getElementById('score');
                this.eyesNormal = document.getElementById('eyes-normal');
                this.eyesJumping = document.getElementById('eyes-jumping');
                
                this.x = 400;
                this.y = 300;
                this.vx = 0;
                this.vy = 0;
                this.isJumping = false;
                this.isShiftPressed = false;
                this.wasInAir = false;
                this.score = 0;
                this.coins = [];
                
                this.init();
            }

            init() {
                this.setupControls();
                this.startGameLoop();
                this.character.style.transform = `translate(${this.x}px, ${this.y}px)`;
                this.spawnCoin();
                setInterval(() => this.spawnCoin(), 2000);
            }

            setupControls() {
                document.addEventListener('keydown', (e) => {
                    switch(e.code) {
                        case 'ArrowLeft':
                            this.vx = -5;
                            this.character.style.transform += ' scaleX(-1)';
                            break;
                        case 'ArrowRight':
                            this.vx = 5;
                            this.character.style.transform = this.character.style.transform.replace(' scaleX(-1)', '');
                            break;
                        case 'Space':
                            if (!this.isJumping) {
                                this.vy = this.isShiftPressed ? -25 : -15;
                                this.isJumping = true;
                                this.setJumpingFace(true);
                                this.createJumpEffect();
                            }
                            break;
                        case 'ShiftLeft':
                        case 'ShiftRight':
                            this.isShiftPressed = true;
                            break;
                    }
                });

                document.addEventListener('keyup', (e) => {
                    if (e.code === 'ArrowLeft' && this.vx < 0) this.vx = 0;
                    if (e.code === 'ArrowRight' && this.vx > 0) this.vx = 0;
                    if (e.code === 'ShiftLeft' || e.code === 'ShiftRight') {
                        this.isShiftPressed = false;
                    }
                });
            }

            setJumpingFace(isJumping) {
                this.eyesNormal.style.display = isJumping ? 'none' : 'block';
                this.eyesJumping.style.display = isJumping ? 'block' : 'none';
            }

            createJumpEffect() {
                const effect = document.createElementNS("http://www.w3.org/2000/svg", "svg");
                effect.setAttribute("class", "effect");
                effect.setAttribute("width", "100");
                effect.setAttribute("height", "100");
                effect.style.left = `${this.x - 10}px`;
                effect.style.top = `${this.y + 40}px`;
                
                const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
                circle.setAttribute("cx", "50");
                circle.setAttribute("cy", "50");
                circle.setAttribute("r", "20");
                circle.setAttribute("fill", this.isShiftPressed ? "#FF4444" : "#4CAF50");
                circle.classList.add('sparkle');
                
                effect.appendChild(circle);
                this.container.appendChild(effect);
                setTimeout(() => effect.remove(), 500);
            }

            createLandingEffect() {
                for (let i = 0; i < 8; i++) {
                    const sparkle = document.createElementNS("http://www.w3.org/2000/svg", "svg");
                    sparkle.setAttribute("class", "effect");
                    sparkle.setAttribute("width", "20");
                    sparkle.setAttribute("height", "20");
                    
                    const angle = (i / 8) * Math.PI * 2;
                    const offsetX = Math.cos(angle) * 30;
                    const offsetY = Math.sin(angle) * 30;
                    
                    sparkle.style.left = `${this.x + 30 + offsetX}px`;
                    sparkle.style.top = `${this.y + 60 + offsetY}px`;
                    
                    const star = document.createElementNS("http://www.w3.org/2000/svg", "path");
                    star.setAttribute("d", "M10 0L13 7L20 8L15 13L16 20L10 17L4 20L5 13L0 8L7 7Z");
                    star.setAttribute("fill", "#FFD700");
                    star.classList.add('sparkle');
                    
                    sparkle.appendChild(star);
                    this.container.appendChild(sparkle);
                    setTimeout(() => sparkle.remove(), 500);
                }
            }

            spawnCoin() {
                if (this.coins.length >= 5) return;

                const coin = document.createElementNS("http://www.w3.org/2000/svg", "svg");
                coin.setAttribute("class", "coin");
                coin.setAttribute("viewBox", "0 0 100 100");
                coin.innerHTML = `
                    <circle cx="50" cy="50" r="40" fill="#FFD700"/>
                    <text x="50" y="65" text-anchor="middle" fill="#B8860B" font-size="40">¥</text>
                `;
                
                const x = Math.random() * (this.container.clientWidth - 50);
                const y = Math.random() * (this.container.clientHeight - 150);
                
                coin.style.left = `${x}px`;
                coin.style.top = `${y}px`;
                
                this.container.appendChild(coin);
                this.coins.push({ element: coin, x, y });
            }

            checkCoinCollisions() {
                const characterRect = {
                    left: this.x,
                    right: this.x + 80,
                    top: this.y,
                    bottom: this.y + 80
                };

                for (let i = this.coins.length - 1; i >= 0; i--) {
                    const coin = this.coins[i];
                    const coinRect = {
                        left: parseFloat(coin.element.style.left),
                        right: parseFloat(coin.element.style.left) + 30,
                        top: parseFloat(coin.element.style.top),
                        bottom: parseFloat(coin.element.style.top) + 30
                    };

                    if (this.isColliding(characterRect, coinRect)) {
                        this.collectCoin(coin, i);
                    }
                }
            }

            isColliding(rect1, rect2) {
                return rect1.left < rect2.right &&
                       rect1.right > rect2.left &&
                       rect1.top < rect2.bottom &&
                       rect1.bottom > rect2.top;
            }

            collectCoin(coin, index) {
                coin.element.remove();
                this.coins.splice(index, 1);
                this.score += 100;
                this.scoreElement.textContent = `Score: ${this.score}`;
                this.showScorePopup(coin.x, coin.y);
            }

            showScorePopup(x, y) {
                const popup = document.createElement('div');
                popup.className = 'score-popup';
                popup.textContent = '+100';
                popup.style.left = `${x}px`;
                popup.style.top = `${y}px`;
                this.container.appendChild(popup);
                setTimeout(() => popup.remove(), 800);
            }

            update() {
                // 重力の適用
                if (this.isJumping) {
                    this.vy += 0.8;
                }

                // 位置の更新
                this.x += this.vx;
                this.y += this.vy;

                // 画面端の制限
                if (this.x < 0) this.x = 0;
                if (this.x > this.container.clientWidth - 80) {
                    this.x = this.container.clientWidth - 80;
                }

                // 地面との衝突判定
                if (this.y > 300) {
                    if (this.wasInAir) {
                        this.createLandingEffect();
                        this.wasInAir = false;
                    }
                    this.y = 300;
                    this.vy = 0;
                    this.isJumping = false;
                    this.setJumpingFace(false);
                } else if (this.y < 300) {
                    this.wasInAir = true;
                }

                // キャラクターの位置を更新
                this.character.style.transform = `translate(${this.x}px, ${this.y}px)`;
                
                // コインとの衝突チェック
                this.checkCoinCollisions();
            }

            startGameLoop() {
                const gameLoop = () => {
                    this.update();
                    requestAnimationFrame(gameLoop);
                };
                gameLoop();
            }
        }

        // ゲーム開始
        window.onload = () => {
            new Game();
        };
    </script>
</body>
</html>
```