はじめに
SVGを使用してインタラクティブなゲームを作成する方法を紹介します。この記事では、SVGでキャラクターを描画し、JavaScriptで動きを制御する実装方法を step by step で解説していきます。
この記事で学べること
- SVGを使ったキャラクターデザイン
- JavaScriptによるSVGの動的な制御
- アニメーションとインタラクションの実装
- 簡単なゲームシステムの構築
環境
- HTML5
- JavaScript(ES6+)
- SVG
完成イメージ
実装手順
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を選んだ理由
- 高品質なグラフィック: SVGはベクター形式なので、任意のサイズで鮮明に表示できます。
- 軽量: パスデータで図形を定義するため、画像ファイルと比べて非常に軽量です。
- 動的な制御: JavaScriptで簡単に要素の操作や変更が可能です。
- アニメーション: CSS transitions/animationsとの相性が良く、滑らかなアニメーションを実現できます。
パフォーマンスの考慮点
-
requestAnimationFrame
を使用して、効率的なアニメーションループを実現 - DOM操作を最小限に抑え、transformプロパティを活用
- 不要なエフェクト要素は適切なタイミングで削除
発展的な実装アイデア
- 複数のステージ
- 障害物の追加
- パワーアップアイテム
- モバイル対応(タッチ操作)
- サウンドエフェクト
まとめ
SVGとJavaScriptを組み合わせることで、軽量で高品質なブラウザゲームを作ることができます。SVGの特徴を活かした表現は、特にキャラクターやエフェクトのアニメーションに有効です。
参考文献
ソースコード
<!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>