0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

はじめに

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

image.png

この記事で学べること

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

環境

  • HTML5
  • JavaScript(ES6+)
  • SVG

完成イメージ

image.png

image.png

実装手順

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

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

<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. ゲームの基本システム

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

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を組み合わせて実現します。

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. エフェクトの追加

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

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. ゲーム要素の実装

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

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の特徴を活かした表現は、特にキャラクターやエフェクトのアニメーションに有効です。

参考文献

ソースコード

image.png

image.png

<!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>
0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?