第4日目:予期せぬ出来事 - ゲーム世界の異変
こんにちは、蒼井 蓮です。「ゼロから始めるnew.world - AAA級ゲーム開発への道」の第4日目の記録をお届けします。今日は敵キャラクターのAI実装と音響効果の追加に取り組みました。そして、予想外の出来事も起こりました…
朝の日常
今朝は珍しく雨。窓を叩く雨音で目が覚めました。いつもより30分ほど早い6時半です。雨の日は何故か早起きしてしまう癖があります。
ストレッチをして体を目覚めさせた後、キッチンでコーヒーを淹れました。豆から挽くタイプの本格派です。この香りがないと一日が始まらない気がします。朝食はシンプルにトーストとヨーグルト。健康を意識して、ヨーグルトにはブルーベリーとナッツを混ぜています。
食後にバルコニーに出て、雨に濡れる街並みを眺めました。静かな朝の時間は、これから取り組むコードの構造を頭の中で整理するのに最適です。雨の音を聞きながら、今日実装する敵AIのアルゴリズムについて考えていました。
今日の目標
敵キャラクターの基本AIの実装
音響効果の追加
アニメーション機能の基礎実装
プレイヤーの体力システム
敵キャラクターAIの実装
まずは敵キャラクターとその基本的なAIを実装しました。シンプルな追跡アルゴリズムから始め、徐々に複雑にしていく予定です。
// enemy.js - 敵キャラクタークラス
class Enemy extends Entity {
constructor(x, y, width, height, color, difficulty = 'normal') {
super(x, y, width, height, color || 'crimson');
this.speed = 2;
this.detectionRadius = 200; // プレイヤー検出半径
this.target = null; // 追跡対象
this.state = 'idle'; // idle, chase, attack, flee
this.attackCooldown = 0;
this.maxAttackCooldown = 60; // フレーム単位
this.damage = 10;
this.health = 100;
this.difficulty = difficulty;
// 難易度に応じたステータス調整
this.adjustStatsByDifficulty();
}
adjustStatsByDifficulty() {
switch(this.difficulty) {
case 'easy':
this.speed *= 0.7;
this.health *= 0.8;
this.damage *= 0.7;
break;
case 'hard':
this.speed *= 1.3;
this.health *= 1.5;
this.damage *= 1.3;
this.detectionRadius *= 1.2;
break;
case 'nightmare':
this.speed *= 1.8;
this.health *= 2.5;
this.damage *= 2;
this.detectionRadius *= 1.5;
this.maxAttackCooldown = Math.floor(this.maxAttackCooldown * 0.7);
break;
}
}
setTarget(entity) {
this.target = entity;
}
update(deltaTime) {
super.update(deltaTime);
// クールダウンの更新
if (this.attackCooldown > 0) {
this.attackCooldown -= deltaTime * 60;
}
// AIの状態更新
this.updateAI(deltaTime);
}
updateAI(deltaTime) {
if (!this.target) return;
// ターゲットとの距離を計算
const dx = this.target.x - this.x;
const dy = this.target.y - this.y;
const distance = Math.sqrt(dx * dx + dy * dy);
// 状態の決定
if (distance > this.detectionRadius) {
this.state = 'idle';
} else if (distance > this.width + 10) {
this.state = 'chase';
} else {
this.state = 'attack';
}
// 状態に応じた行動
switch(this.state) {
case 'idle':
this.velocityX = 0;
// たまにランダムに動く
if (Math.random() < 0.01) {
this.velocityX = (Math.random() - 0.5) * this.speed;
}
break;
case 'chase':
// ターゲットへ向かって移動
if (dx > 0) {
this.velocityX = this.speed;
} else {
this.velocityX = -this.speed;
}
// ある程度近づいたらジャンプを試みる
if (Math.abs(dx) < 100 && this.isGrounded && Math.random() < 0.05) {
this.velocityY = -8; // ジャンプ力
}
break;
case 'attack':
// 攻撃
if (this.attackCooldown <= 0) {
this.attack();
this.attackCooldown = this.maxAttackCooldown;
}
break;
}
}
attack() {
if (!this.target) return;
// 攻撃のロジック
console.log(`敵が攻撃: ダメージ ${this.damage}`);
// 後でプレイヤーにダメージを与える処理を追加
// 攻撃アニメーションやエフェクトはこの後に追加
}
takeDamage(amount) {
this.health -= amount;
// 体力が0以下になったら消滅
if (this.health <= 0) {
this.die();
} else {
// ダメージ時の一時的な状態変化
this.color = 'white'; // ダメージフラッシュ
setTimeout(() => {
this.color = 'crimson';
}, 100);
}
}
die() {
console.log('敵が倒された');
// 後で敵の撃破処理を追加
// ゲームから敵を削除する処理も必要
}
render(ctx) {
super.render(ctx);
// 体力ゲージの描画
const healthPercent = this.health / 100;
const barWidth = this.width;
const barHeight = 5;
ctx.fillStyle = 'red';
ctx.fillRect(
this.x,
this.y - barHeight - 2,
barWidth,
barHeight
);
ctx.fillStyle = 'green';
ctx.fillRect(
this.x,
this.y - barHeight - 2,
barWidth * healthPercent,
barHeight
);
}
}
敵AIの実装中、昼食のためにキッチンへ向かう途中、ふと窓の外を見ると雨がすっかり止んでいました。思いがけず空が晴れて、日差しが部屋に差し込んできます。気分転換にと思い、近所のカフェまで散歩に出かけました。
席に座りながらも、頭の中では敵AIのアルゴリズムが回り続けています。少し複雑になりすぎたかもしれないと感じ、メモ帳に簡略化したバージョンをスケッチしました。ラテを飲み終えて帰宅すると、すぐに作業に戻りました。
音響効果の実装
午後からは、ゲームに臨場感を出すために音響効果の実装に取り組みました。
// audio.js - オーディオマネージャクラス
class AudioManager {
constructor() {
this.sounds = {};
this.musicTracks = {};
this.currentMusic = null;
this.volume = 0.5;
this.muted = false;
}
loadSound(key, src) {
const sound = new Audio(src);
sound.volume = this.volume;
this.sounds[key] = sound;
return sound;
}
loadMusic(key, src) {
const music = new Audio(src);
music.volume = this.volume * 0.7; // 音楽は効果音より少し小さめ
music.loop = true;
this.musicTracks[key] = music;
return music;
}
playSound(key) {
if (this.muted) return;
if (this.sounds[key]) {
// サウンドをクローンして再生(重複再生のため)
const soundClone = this.sounds[key].cloneNode();
soundClone.volume = this.volume;
soundClone.play();
}
}
playMusic(key) {
if (this.muted) return;
// 現在の音楽を停止
if (this.currentMusic) {
this.currentMusic.pause();
this.currentMusic.currentTime = 0;
}
// 新しい音楽を再生
if (this.musicTracks[key]) {
this.currentMusic = this.musicTracks[key];
this.currentMusic.volume = this.volume * 0.7;
this.currentMusic.play();
}
}
stopMusic() {
if (this.currentMusic) {
this.currentMusic.pause();
this.currentMusic.currentTime = 0;
this.currentMusic = null;
}
}
setVolume(value) {
this.volume = Math.max(0, Math.min(1, value));
// すべてのサウンドとミュージックのボリュームを更新
for (const key in this.sounds) {
this.sounds[key].volume = this.volume;
}
for (const key in this.musicTracks) {
this.musicTracks[key].volume = this.volume * 0.7;
}
}
toggleMute() {
this.muted = !this.muted;
if (this.muted) {
if (this.currentMusic) {
this.currentMusic.pause();
}
} else {
if (this.currentMusic) {
this.currentMusic.play();
}
}
return this.muted;
}
}
次に、ゲームに必要な効果音をいくつか用意しました。無料のサウンドリソースを活用しています。
// 効果音の読み込みと初期化
const audioManager = new AudioManager();
// 効果音の読み込み
audioManager.loadSound('jump', 'sounds/jump.wav');
audioManager.loadSound('land', 'sounds/land.wav');
audioManager.loadSound('hit', 'sounds/hit.wav');
audioManager.loadSound('damage', 'sounds/damage.wav');
audioManager.loadSound('collect', 'sounds/collect.wav');
// BGMの読み込み
audioManager.loadMusic('main', 'music/main_theme.mp3');
audioManager.loadMusic('battle', 'music/battle.mp3');
これにより、プレイヤーのジャンプや着地、攻撃や被ダメージなどの瞬間に効果音を再生できるようになりました。
アニメーション機能の実装
単調な動きにアニメーションを追加することで、ゲームの見た目が大幅に向上します。シンプルなスプライトアニメーションシステムを実装しました。
// animation.js - アニメーションシステム
class Animation {
constructor(frames, frameRate) {
this.frames = frames; // フレーム(画像)の配列
this.frameRate = frameRate || 10; // 1秒あたりのフレーム数
this.frameInterval = 1 / this.frameRate; // フレーム間の時間
this.currentFrame = 0;
this.elapsed = 0;
this.loop = true;
this.finished = false;
}
update(deltaTime) {
if (this.finished) return;
this.elapsed += deltaTime;
if (this.elapsed >= this.frameInterval) {
this.currentFrame = (this.currentFrame + 1) % this.frames.length;
this.elapsed = 0;
// ループしない場合の処理
if (!this.loop && this.currentFrame === 0) {
this.finished = true;
this.currentFrame = this.frames.length - 1;
}
}
}
reset() {
this.currentFrame = 0;
this.elapsed = 0;
this.finished = false;
}
getCurrentFrame() {
return this.frames[this.currentFrame];
}
}
// sprite.js - スプライトクラス
class Sprite {
constructor(x, y, width, height) {
this.x = x;
this.y = y;
this.width = width;
this.height = height;
this.animations = {};
this.currentAnimation = null;
this.flipX = false;
}
addAnimation(name, animation) {
this.animations[name] = animation;
// 最初のアニメーションを現在のアニメーションに設定
if (!this.currentAnimation) {
this.currentAnimation = name;
}
}
play(name, reset = false) {
if (this.animations[name] && this.currentAnimation !== name) {
this.currentAnimation = name;
if (reset) {
this.animations[name].reset();
}
}
}
update(deltaTime) {
if (this.currentAnimation && this.animations[this.currentAnimation]) {
this.animations[this.currentAnimation].update(deltaTime);
}
}
render(ctx) {
if (this.currentAnimation && this.animations[this.currentAnimation]) {
const frame = this.animations[this.currentAnimation].getCurrentFrame();
ctx.save();
if (this.flipX) {
ctx.translate(this.x + this.width, this.y);
ctx.scale(-1, 1);
ctx.drawImage(frame, 0, 0, this.width, this.height);
} else {
ctx.drawImage(frame, this.x, this.y, this.width, this.height);
}
ctx.restore();
}
}
}
午後3時頃、コーディングの合間に部屋の掃除をしました。机の上が散らかっていると集中できないタイプなので、定期的に整理整頓するようにしています。コーヒーカップを洗い、書類を整理し、窓を開けて部屋を換気しました。新鮮な空気を吸い込むと、何故か新しいアイディアが浮かんでくるような気がします。
さて、次にエンティティクラスを拡張して、アニメーションをサポートするようにしました。
// entity.js - アニメーション対応版
class Entity {
constructor(x, y, width, height, color) {
this.x = x;
this.y = y;
this.width = width;
this.height = height;
this.color = color;
this.velocityX = 0;
this.velocityY = 0;
this.isGrounded = false;
// スプライトとアニメーション関連
this.sprite = null;
this.useSprite = false;
}
setSprite(sprite) {
this.sprite = sprite;
this.useSprite = true;
}
update(deltaTime) {
// 物理更新
this.x += this.velocityX * deltaTime * 60;
this.y += this.velocityY * deltaTime * 60;
// スプライトの更新
if (this.useSprite && this.sprite) {
this.sprite.x = this.x;
this.sprite.y = this.y;
// 左右の向きを設定
if (this.velocityX > 0) {
this.sprite.flipX = false;
} else if (this.velocityX < 0) {
this.sprite.flipX = true;
}
this.sprite.update(deltaTime);
}
}
render(ctx) {
if (this.useSprite && this.sprite) {
this.sprite.render(ctx);
} else {
// スプライトがない場合は従来通り四角形で描画
ctx.fillStyle = this.color;
ctx.fillRect(this.x, this.y, this.width, this.height);
}
}
}
// player.js - アニメーション対応版
class Player extends Entity {
constructor(x, y) {
super(x, y, 40, 40, 'blue');
this.speed = 5;
this.jumpForce = 10;
this.isJumping = false;
this.health = 100;
this.maxHealth = 100;
// プレイヤー用のスプライトを設定
const sprite = new Sprite(x, y, this.width, this.height);
this.setSprite(sprite);
// アニメーションの追加
this.setupAnimations();
}
setupAnimations() {
// 実際のゲームではここでアニメーションフレームを読み込む
// 今回はダミーの色付き四角形でシミュレート
const idleFrames = this.createDummyFrames('blue', 4);
const runFrames = this.createDummyFrames('lightblue', 6);
const jumpFrames = this.createDummyFrames('darkblue', 1);
this.sprite.addAnimation('idle', new Animation(idleFrames, 5));
this.sprite.addAnimation('run', new Animation(runFrames, 10));
this.sprite.addAnimation('jump', new Animation(jumpFrames, 1));
// デフォルトアニメーションを設定
this.sprite.play('idle');
}
createDummyFrames(color, count) {
// テスト用のダミーフレームを生成
const frames = [];
for (let i = 0; i < count; i++) {
const canvas = document.createElement('canvas');
canvas.width = this.width;
canvas.height = this.height;
const ctx = canvas.getContext('2d');
// 基本の四角形
ctx.fillStyle = color;
ctx.fillRect(0, 0, this.width, this.height);
// フレームごとに少し違う模様を描く
ctx.fillStyle = 'rgba(255, 255, 255, 0.5)';
ctx.fillRect(
5 + i * 3,
5,
this.width - 10 - i * 6,
this.height - 10
);
frames.push(canvas);
}
return frames;
}
update(deltaTime) {
super.update(deltaTime);
// アニメーション状態の更新
if (!this.isGrounded) {
this.sprite.play('jump');
} else if (Math.abs(this.velocityX) > 0.1) {
this.sprite.play('run');
} else {
this.sprite.play('idle');
}
}
}
プレイヤーの体力システム
最後に、プレイヤーの体力システムを実装しました。これにより、敵からの攻撃でダメージを受けたり、回復アイテムで体力を回復したりできるようになります。
// player.js - 体力システム追加
class Player extends Entity {
// ... 既存のコード ...
takeDamage(amount) {
this.health = Math.max(0, this.health - amount);
// ダメージ効果音
audioManager.playSound('damage');
// ダメージエフェクト
this.color = 'red';
setTimeout(() => {
this.color = 'blue';
}, 200);
// 体力がゼロになったら
if (this.health <= 0) {
this.die();
}
}
heal(amount) {
this.health = Math.min(this.maxHealth, this.health + amount);
// 回復効果音
audioManager.playSound('collect');
}
die() {
console.log('プレイヤーがやられた');
// ゲームオーバー処理
}
render(ctx) {
super.render(ctx);
// 体力ゲージの描画
const healthPercent = this.health / this.maxHealth;
const barWidth = this.width;
const barHeight = 5;
ctx.fillStyle = 'red';
ctx.fillRect(
this.x,
this.y - barHeight - 2,
barWidth,
barHeight
);
ctx.fillStyle = 'lime';
ctx.fillRect(
this.x,
this.y - barHeight - 2,
barWidth * healthPercent,
barHeight
);
}
}
奇妙な出来事
夕食後、新しく実装した敵AIをテストしていると、本当に奇妙なことが起こりました。敵キャラクターがプログラムした通りに動かないのです。
具体的には、敵キャラクターがプレイヤーを追いかけるコードを書いたのですが、時々明らかに違う方向に移動したり、プログラムしていないタイミングでジャンプしたりするのです。バグを探すために、デバッグコードを追加しました。
// デバッグ情報
console.log(`敵の状態: ${this.state}, 距離: ${distance.toFixed(2)}, 速度X: ${this.velocityX.toFixed(2)}, Y: ${this.velocityY.toFixed(2)}`);
しかし、ログを見ても特におかしな点は見つかりません。AIのロジックは正しく動作しているようですが、画面上の敵の動きはそれに従っていないのです。
これは本当に奇妙で、以前にも似たような現象があったことを思い出しました。ゲームキャラクターが時折、プログラムとは無関係に動くような…。
さらに気になったのが、デバッグコンソールに時々表示される謎のメッセージです。
[システム] 対象を認識...
[システム] 接続を試みています...
これは私が書いたコードではありません。最初はブラウザの拡張機能か、new.worldのデバッグメッセージかと思いました。しかし、拡張機能をすべて無効にし、別のブラウザでテストしても同じメッセージが表示されます。
念のため、PCを再起動してみましたが、状況は変わりませんでした。
この謎の現象に頭を悩ませながらも、とりあえず今日実装した機能は正常に動作していると判断し、作業を終えることにしました。
日常の締めくくり
夜10時頃、少し息抜きに近所のコンビニまで散歩に出かけました。帰り道、夜空を見上げると満点の星が輝いていました。都会では珍しく、星がこんなにはっきり見える夜です。
部屋に戻り、温かいお茶を淹れながら今日の進捗を振り返りました。敵AIの謎の動作については、明日もう少し詳しく調査してみようと思います。
寝る前に、明日の予定を手帳に書き込みます。
敵AIの謎の動作を調査
レベルデザインの実装
スコアシステムの追加
UIの改善
ベッドに横になりながら、ふと天井を見つめていると、今日の不思議な出来事が頭から離れません。コードとゲームの世界の境界が、何だか曖昧に感じられる瞬間がありました。
プログラマーの直感でしょうか、それとも単なる疲れからくる思い込みでしょうか…。しかし、確かに何かが起きています。明日はその謎に迫ってみようと思います。
おやすみなさい。
蒼井 蓮
作品に感心したり、次の展開を待ち望んでいる方は、ブックマークや評価をしていただけると幸いです。
作品に魅力を感じなかった方も、お手数ですが評価でご感想をお聞かせください。




