実験#125:風を操る爽快アクション(大魔導士シマエナガ – 吹雪の防衛戦)

■125個目 「大魔導士シマエナガ – 吹雪の防衛戦」

こんにちは、AIと作る100のゲーム実験室の みー です。(*^_^*)/

今回は、魔法で敵を吹き飛ばしながら
陣地を守る防衛アクションゲームを作ってみました。

主人公は、なんと冬の妖精として親しまれている小鳥「シマエナガ」です!

舞台は雪深く静かな冬の林。
大魔導士シマエナガとなって、
迫りくる敵たちから大切な陣地を守り抜きます。

今回こだわったのは、主人公の見た目と強さのギャップです。(=v=*)

「小さくてふわふわで可愛い小鳥が、とてつもなく強力な風魔法を操る大魔導士だったら面白いのでは?」

そんな遊び心から、このゲームが生まれました。

穏やかな冬の朝を乱すように押し寄せてくるのは、
黒くてモフモフした正体不明の「何か怖いの」達。

大魔導士シマエナガは、それに立ち向かうために様々な風魔法を駆使します。

そよ風、鎌鼬(かまいたち)、竜巻、暴風雨など……。
使える魔法は全部で10種類!

まん丸でやさしい見た目からは想像できないほどの強力な魔法で、
敵の群れを次々と吹き飛ばしていきます。

敵をまとめて吹き飛ばした時の爽快感も見どころのひとつです。(^v^)

はたして、大魔導士シマエナガは
雪の林の平和を守り切ることができるのでしょうか?

可愛さと大迫力の風魔法が織りなす防衛戦を、
ぜひお楽しみください!(*^v^)ノシ


■ AIとのやりとりで面白かった部分

● ゲーム仕様の相談からスタート

みー:(^v^*)今日もゲームを作ります。HTMLで動くゲームです。

舞台は魔法ファンタジーな世界。
斜め視点で、主人公は大魔導士シマエナガ。

太い線で風を表現しながら、
迫ってくる敵の「何か怖いの」達を
吹き飛ばしたり倒したりするゲームを作りたいです。

魔法は10種類くらい用意して、
それぞれ発動の特徴やクールダウンを変えます。
操作はテンキーの0~9に対応させたいです。


AI:

大魔導士シマエナガが主役の、爽快な風魔法アクションゲームだね。

斜め視点の空間で「何か怖いの」達を
吹き飛ばしながら戦うゲームとして組み上げてみたよ。

テンキー0~9でそれぞれ異なる風魔法を使うことができ、
クールダウンや吹き飛ばし性能も魔法ごとに変えてある。

状況に応じて使い分けながら戦うのがポイントだ。

(中略)

敵は徐々に強くなりながら押し寄せてくる。

シマエナガの圧倒的な風魔法で迎え撃とう。


みー:(^_^;)

いい感じなんだけど、
敵に向かって魔法が出せないとゲームが成り立たないかな。

発動方向はマウスで指定できるようにしたいです。

背景は目が痛くならない程度の雪山の林。

あと風魔法らしい表現をもっと強くしてほしいです。


AI:

そこは確かに重要だったね。

マウスカーソルへ正確に魔法を放てるように照準処理を修正した。

さらに足元へ照準マーカーも表示して、狙いやすく改善している。

背景は淡い雪山の林へ変更。

風魔法も直線だけではなく、
ゆらぐ軌跡や曲線を加えて風の流れを感じられる表現へ調整した。


みー:(つv<)ノ

風魔法の威力をもう少し上げたいかな。

敵がもっと勢いよく吹き飛ぶと楽しそう。

あと各魔法は2回までストックできるようにします。


AI:

威力とノックバックを強化しておいたよ。

敵が風で豪快に吹き飛ぶようになったので爽快感もアップしている。

さらに全ての魔法を最大2回までストックできるチャージ制へ変更した。

ピンチの時には強力な魔法を連続で使うこともできる。

この調整によって、戦況を一気にひっくり返せる場面が増えた。


みー:(=v=*)

ゲーム画面の上にはタイトルを表示しましょう。

左側には世界観や操作説明。

右側には連続撃破数ランキングを置きたいです。


AI:

レイアウトを調整して、ゲーム画面の外側に情報パネルを追加した。

左側には世界観と操作説明。

右側には連続撃破ランキングを配置している。

敵をテンポよく倒すとコンボがつながり、
一定時間撃破できないと記録される仕組みも追加した。

よりハイスコアを狙いたくなるゲームになったと思うよ。


■ 実際の動作

まずは、AIが最初に作ってくれた試作版のゲーム画面をご紹介します。

シンプルな図形の魔法を飛ばして「何か怖いの」を吹き飛ばすという、
ゲームの基本部分はこの時点ですでに完成していました。

(画像:AIが最初に出してくれた試作画面)


こちらが完成したゲーム画面です。

かわいいシマエナガ魔導士が中央に立ち、
雪山の林を舞台に迫り来る「何か怖いの」を迎え撃ちます。

(画像:仕上がりのゲーム画面)


0~9のキーを押すことで、それぞれ対応した風魔法を発動できます。

マウスカーソルの方向へ向かって魔法が飛んでいくので、
敵を狙いながら戦います。

(画像:魔法の発動)



「何か怖いの」達は次々と近寄ってきます。

今回は生き残ることを目的としたゲームではなく、
どれだけ連続して敵を撃破できるかを競うスコアアタック形式のゲームです。

魔法ごとにチャージ時間や性能が異なるため、
その場の状況に合わせて使い分けることが重要になります。

強力な風魔法で敵をまとめて吹き飛ばせた時は、なかなか爽快です。(^v^*)

(画像:すべてを吹き飛ばすシマエナガ魔導士)





■ コードのポイント

今回の記事では、
AI と相談しながら作った「大魔導士シマエナガ – 吹雪の防衛戦」 の HTML コードを公開します。

このコードをそのまま保存するだけで、
あなたの PC でもすぐに遊べます。

ブラウザだけで動く、とてもシンプルなゲームなので、
「コードは初めて…」という方でも気軽に試してみてくださいね。

▼ 遊ぶまでの手順(むずかしさ:やさしい)
① テキストエディタを開く

Windows の場合は「メモ帳」、
Mac の場合は「テキストエディット」を使います。
(※検索バーで「メモ帳」と入力するとすぐ出てきます)

② 下のコードをコピーして貼り付けます。
③ 「simaenagamadou.html」など、好きな名前で保存します。

(※拡張子は.html にしてください)

④ 保存したファイルをダブルクリックすると、
ブラウザでゲームが起動します。

■ 遊び方・操作方法

  • マウス
     画面内でマウスを動かすと、
    シマエナガの足元に点線の照準が表示されます。(・v・)

     魔法は、この照準の方向へ向かって発動します。
  • 数字キー(0~9)
     キーボードの数字キーを押すと、
    それぞれの数字に対応した風魔法が発動します。

今回のバトルのポイントは「ストック制」です。(^_^)

画面下には10個の魔法アイコンが並んでいます。

各魔法アイコンには小さなランプが2つ表示されていて、
これが現在使える回数を表しています。

魔法は時間が経つと自動でチャージされ、
最大2回分までためておくことができます。(=v=*)

威力の高い大魔法(7番や9番など)は、
そのぶんチャージ時間も長めです。

普段は使いやすい魔法で戦いながら、
ここぞという場面で大魔法を2連発!

そんな戦い方もできます。


敵の「何か怖いの」達をテンポよく吹き飛ばしていくと、
コンボが繋がります。(≧v≦)

コンボを途切れさせずに撃破を続けると、
画面右側のランキングへ最高記録が保存されます。

10種類の風魔法を上手に使い分けながら、
ハイスコアを目指してみてください。

強力な風魔法で敵の群れを
まとめて吹き飛ばせた時の爽快感も見どころです!(*^v^)ノシ





“コードは長いので折りたたんでいます。必要な方は開いてご覧ください。

▼ここからコード▼(クリックで開く)
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>大魔導士シマエナガ - 吹雪の防衛戦</title>
<style>
body {
margin: 0;
padding: 20px;
background-color: #8da3b8;
background-image: radial-gradient(circle at top left, #a2b4c7, #8da3b8);
color: #1e293b;
font-family: 'Hiragino Rounded Gothic Pro', 'Arial Rounded MT Bold', 'M PLUS Rounded 1c', sans-serif;
display: grid;
grid-template-columns: 280px auto 280px;
gap: 20px;
justify-content: center;
align-items: start;
min-height: 100vh;
box-sizing: border-box;
}
/* --- サイドパネル共通 --- */
.side-panel {
background: rgba(255, 255, 255, 0.85);
border: 4px solid #fff;
border-radius: 15px;
padding: 20px;
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15);
}
.side-panel h2 {
margin-top: 0;
color: #0284c7;
font-size: 1.2em;
border-bottom: 2px dashed #bae6fd;
padding-bottom: 8px;
margin-bottom: 15px;
}
.side-panel p, .side-panel li {
font-size: 0.95em;
line-height: 1.6;
color: #334155;
}
/* --- 左パネル(説明) --- */
.story-box {
margin-bottom: 20px;
}
.story-box p {
margin: 0 0 10px 0;
}
.how-to-list {
list-style: none;
padding: 0;
margin: 0;
}
.how-to-list li {
margin-bottom: 10px;
padding-left: 1.5em;
text-indent: -1.5em;
}
.highlight {
font-weight: bold;
color: #ea580c;
}
/* --- センターパネル(ゲーム画面) --- */
#center-panel {
display: flex;
flex-direction: column;
align-items: center;
}
.game-title {
margin: 0 0 15px 0;
font-size: 36px;
color: #fff;
text-align: center;
line-height: 1.2;
text-shadow:
3px 3px 0px #38bdf8,
-1px -1px 0px #0284c7,
0px 6px 10px rgba(0,0,0,0.3);
letter-spacing: 2px;
}
.game-title span {
font-size: 20px;
color: #e0f2fe;
text-shadow: 2px 2px 0px #0284c7;
letter-spacing: 4px;
}
#gameContainer {
position: relative;
width: 800px;
height: 600px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
border-radius: 12px;
overflow: hidden;
border: 4px solid #fff;
}
canvas {
display: block;
background: radial-gradient(circle at center, #dce4ed 0%, #a2b4c7 100%);
}
/* --- ゲーム内UI --- */
#hud {
position: absolute;
bottom: 10px;
left: 10px;
right: 10px;
display: flex;
justify-content: space-between;
pointer-events: none;
}
.magic-slot {
background: rgba(255, 255, 255, 0.85);
border: 2px solid #94a3b8;
border-radius: 8px;
padding: 5px 2px;
width: 9%;
text-align: center;
font-size: 11px;
position: relative;
overflow: hidden;
box-shadow: 0 2px 4px rgba(0,0,0,0.15);
box-sizing: border-box;
}
.magic-slot .key {
font-size: 16px;
font-weight: 900;
color: #0f172a;
position: relative;
z-index: 2;
}
.magic-slot .name {
color: #475569;
margin-top: 2px;
white-space: nowrap;
font-weight: bold;
position: relative;
z-index: 2;
}
.stocks-container {
display: flex;
justify-content: center;
gap: 4px;
margin-top: 4px;
position: relative;
z-index: 2;
}
.stock-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(148, 163, 184, 0.4);
border: 1px solid #94a3b8;
}
.stock-dot.on {
background: #38bdf8;
border-color: #fff;
box-shadow: 0 0 5px #38bdf8;
}
.magic-slot .cooldown-overlay {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 0%;
background: rgba(148, 163, 184, 0.6);
z-index: 1;
}
.ready { border-color: #0ea5e9 !important; background: rgba(255,255,255,0.95); }
.ready .name { color: #0284c7 !important; }
#scoreBoard {
position: absolute;
top: 15px;
left: 15px;
font-size: 22px;
font-weight: bold;
color: #1e293b;
text-shadow: 2px 2px 0px #fff;
pointer-events: none;
}
#comboBoard {
position: absolute;
top: 45px;
left: 15px;
font-size: 18px;
font-weight: bold;
color: #ea580c;
text-shadow: 1px 1px 0px #fff;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s;
}
/* --- 右パネル(ランキング) --- */
#ranking-list {
list-style: none;
padding: 0;
margin: 0;
}
#ranking-list li {
background: #f1f5f9;
margin-bottom: 8px;
padding: 10px 15px;
border-radius: 8px;
display: flex;
justify-content: space-between;
align-items: center;
font-weight: bold;
color: #334155;
border-left: 4px solid #94a3b8;
}
#ranking-list li:nth-child(1) { border-left-color: #fbbf24; background: #fef3c7; }
#ranking-list li:nth-child(2) { border-left-color: #94a3b8; background: #e2e8f0; }
#ranking-list li:nth-child(3) { border-left-color: #d97706; background: #ffedd5; }
.rank-num { font-size: 1.1em; }
.rank-score { color: #0284c7; font-size: 1.2em; }
.combo-text { font-size: 0.8em; color: #64748b; }
</style>
</head>
<body>
<div id="left-panel" class="side-panel">
<h2>📖 世界観</h2>
<div class="story-box">
<p>雪山の奥深く。静かな林に突如として現れた「何か怖いの」たち。</p>
<p>主人公は、凄腕の魔法使い<strong>『大魔導士シマエナガ』</strong>。</p>
<p>彼の得意な<span class="highlight">10種類の風の魔法</span>を操り、迫りくる敵を豪快に吹き飛ばして、林の平和を守り抜こう!</p>
</div>
<h2>🎮 操作説明</h2>
<ul class="how-to-list">
<li>🖱️ <strong>マウス移動</strong><br>魔法を放つ方向を定めます。</li>
<li>⌨️ <strong>0〜9キー (テンキー)</strong><br>対応する風魔法を発動。</li>
<li>🔄 <strong>魔法のストック</strong><br>各魔法は<span class="highlight">最大2回分</span>までチャージ可能。</li>
</ul>
</div>
<div id="center-panel">
<h1 class="game-title">
大魔導士シマエナガ<br>
<span>〜 吹雪の防衛戦 〜</span>
</h1>
<div id="gameContainer">
<canvas id="gameCanvas" width="800" height="600"></canvas>
<div id="scoreBoard">総合撃破数: <span id="scoreVal">0</span></div>
<div id="comboBoard"><span id="comboVal">0</span> 連続撃破中!</div>
<div id="hud"></div>
</div>
</div>
<div id="right-panel" class="side-panel">
<h2>🏆 連続撃破ランキング</h2>
<p style="font-size: 0.85em; color: #64748b; margin-top: -10px; margin-bottom: 15px;">
途切れることなく敵を吹き飛ばした「最大コンボ数」の記録です。
</p>
<ul id="ranking-list"></ul>
</div>
<script>
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
const hud = document.getElementById('hud');
const scoreVal = document.getElementById('scoreVal');
const comboBoard = document.getElementById('comboBoard');
const comboVal = document.getElementById('comboVal');
const rankingList = document.getElementById('ranking-list');
// --- 魔法の定義 (ノックバックと足止め効果) ---
const magics = [
{ id: 0, name: "そよ風", cd: 500, width: 8, color: "#e0f2fe", speed: 12, life: 40, dmg: 25, kb: 30, type: "line", count: 1 },
{ id: 1, name: "突風", cd: 1200, width: 18, color: "#bae6fd", speed: 18, life: 30, dmg: 60, kb: 80, type: "line", count: 1 },
{ id: 2, name: "鎌鼬", cd: 1500, width: 6, color: "#7dd3fc", speed: 16, life: 35, dmg: 35, kb: 40, type: "spread", count: 4 },
{ id: 3, name: "衝撃波", cd: 2500, width: 25, color: "#ffffff", speed: 10, life: 40, dmg: 100, kb: 150, type: "circle", count: 1 },
{ id: 4, name: "竜巻", cd: 4000, width: 25, color: "#38bdf8", speed: 4, life: 120, dmg: 20, kb: 30, type: "tornado", count: 1 },
{ id: 5, name: "五月雨", cd: 3000, width: 8, color: "#7dd3fc", speed: 14, life: 40, dmg: 50, kb: 50, type: "spread", count: 6 },
{ id: 6, name: "防風壁", cd: 5000, width: 35, color: "#e0f2fe", speed: 3, life: 90, dmg: 40, kb: 250, type: "wall", count: 1 },
{ id: 7, name: "烈風弾", cd: 4500, width: 40, color: "#fef08a", speed: 10, life: 60, dmg: 200, kb: 300, type: "line", count: 1 },
{ id: 8, name: "大旋風", cd: 7000, width: 12, color: "#38bdf8", speed: 8, life: 60, dmg: 80, kb: 120, type: "spread", count: 16 },
{ id: 9, name: "暴風雨", cd: 12000, width: 60, color: "#818cf8", speed: 25, life: 80, dmg: 400, kb: 500, type: "screen", count: 1 }
];
let magicStates = magics.map(m => ({ stocks: 2, lastReplenish: Date.now() }));
let mouseLogic = { x: 10, y: 0 };
let score = 0;
let currentCombo = 0;
let comboTimer = 0;
const COMBO_TIMEOUT_FRAMES = 120;
let rankings = JSON.parse(localStorage.getItem('shimaenagaRankings')) || [0, 0, 0, 0, 0];
function updateRankingDisplay() {
rankingList.innerHTML = '';
rankings.forEach((record, index) => {
const li = document.createElement('li');
let crown = ["🥇", "🥈", "🥉", " 4.", " 5."][index] || "";
li.innerHTML = `<span class="rank-num">${crown}</span> <span class="rank-score">${record} <span class="combo-text">連撃</span></span>`;
rankingList.appendChild(li);
});
}
updateRankingDisplay();
function registerCombo(combo) {
if (combo <= 0) return;
let newRankings = [...rankings, combo].sort((a, b) => b - a);
newRankings = Array.from(new Set(newRankings));
while(newRankings.length < 5) newRankings.push(0);
rankings = newRankings.slice(0, 5);
localStorage.setItem('shimaenagaRankings', JSON.stringify(rankings));
updateRankingDisplay();
}
function addCombo() {
currentCombo++;
comboTimer = COMBO_TIMEOUT_FRAMES;
comboVal.innerText = currentCombo;
comboBoard.style.opacity = 1;
comboBoard.style.transform = "scale(1.2)";
setTimeout(() => { comboBoard.style.transform = "scale(1)"; }, 100);
}
function resetCombo() {
if (currentCombo > 0) registerCombo(currentCombo);
currentCombo = 0;
comboBoard.style.opacity = 0;
}
const angle = Math.PI / 6;
const cosA = Math.cos(angle);
const sinA = Math.sin(angle);
function toScreen(x, y) {
return { x: canvas.width / 2 + (x - y) * cosA, y: canvas.height / 2 + (x + y) * sinA };
}
function toLogic(sx, sy) {
const cx = sx - canvas.width / 2;
const cy = sy - canvas.height / 2;
return { x: (cx / cosA + cy / sinA) / 2, y: (cy / sinA - cx / cosA) / 2 };
}
canvas.addEventListener('mousemove', (e) => {
const rect = canvas.getBoundingClientRect();
const sx = e.clientX - rect.left;
const sy = e.clientY - rect.top;
mouseLogic = toLogic(sx, sy);
});
window.addEventListener('keydown', (e) => {
let key = e.key;
let num = -1;
if (key >= '0' && key <= '9') num = parseInt(key);
if (e.code && e.code.startsWith('Numpad')) num = parseInt(e.code.replace('Numpad', ''));
if (num >= 0 && num <= 9) castMagic(num);
});
magics.forEach((m) => {
const div = document.createElement('div');
div.className = 'magic-slot ready';
div.id = `magic-slot-${m.id}`;
div.innerHTML = `
<div class="key">${m.id}</div>
<div class="name">${m.name}</div>
<div class="stocks-container">
<div class="stock-dot on" id="stock-${m.id}-0"></div>
<div class="stock-dot on" id="stock-${m.id}-1"></div>
</div>
<div class="cooldown-overlay" id="cd-${m.id}"></div>
`;
hud.appendChild(div);
});
function updateStockUI(id, stocks) {
document.getElementById(`stock-${id}-0`).className = stocks > 0 ? "stock-dot on" : "stock-dot";
document.getElementById(`stock-${id}-1`).className = stocks > 1 ? "stock-dot on" : "stock-dot";
const slot = document.getElementById(`magic-slot-${id}`);
if(stocks > 0) slot.classList.add('ready');
else slot.classList.remove('ready');
}
function updateCooldowns() {
const now = Date.now();
magics.forEach((m, i) => {
let state = magicStates[i];
const overlay = document.getElementById(`cd-${i}`);
if (state.stocks < 2) {
let elapsed = now - state.lastReplenish;
if (elapsed >= m.cd) {
state.stocks++;
state.lastReplenish = now - (elapsed - m.cd);
if (state.stocks === 2) state.lastReplenish = now;
updateStockUI(i, state.stocks);
}
if (state.stocks < 2) {
let nextElapsed = now - state.lastReplenish;
let percent = 100 - (nextElapsed / m.cd * 100);
overlay.style.height = `${percent}%`;
} else overlay.style.height = '0%';
} else {
state.lastReplenish = now;
overlay.style.height = '0%';
}
});
}
let particles = [];
let effects = [];
let enemies = [];
let trees = [];
let gameTime = 0;
for(let i=0; i<30; i++) {
let tx = (Math.random() - 0.5) * 1200;
let ty = (Math.random() - 0.5) * 1200;
if(Math.abs(tx) < 100 && Math.abs(ty) < 100) { tx += 200; ty += 200; }
trees.push({ x: tx, y: ty, size: 8 + Math.random() * 8 });
}
function castMagic(id) {
let state = magicStates[id];
if (state.stocks <= 0) return;
if (state.stocks === 2) state.lastReplenish = Date.now();
state.stocks--;
updateStockUI(id, state.stocks);
const magic = magics[id];
const dist = Math.sqrt(mouseLogic.x**2 + mouseLogic.y**2) || 1;
const dirX = mouseLogic.x / dist;
const dirY = mouseLogic.y / dist;
const createP = (vx, vy, typeParam = null) => {
particles.push({
x: 0, y: 0, vx: vx, vy: vy,
life: magic.life, maxLife: magic.life,
magic: magic, history: [],
param: typeParam || Math.random() * Math.PI * 2
});
};
if (magic.type === "line" || magic.type === "wall") {
createP(dirX * magic.speed, dirY * magic.speed);
} else if (magic.type === "spread") {
const spreadAngle = (magic.id === 8) ? 0.4 : 0.15;
const startOffset = -((magic.count - 1) / 2);
for (let i = 0; i < magic.count; i++) {
const currentAngle = (startOffset + i) * spreadAngle;
const rx = dirX * Math.cos(currentAngle) - dirY * Math.sin(currentAngle);
const ry = dirX * Math.sin(currentAngle) + dirY * Math.cos(currentAngle);
createP(rx * magic.speed, ry * magic.speed);
}
} else if (magic.type === "circle" || magic.type === "tornado") {
createP(dirX * magic.speed, dirY * magic.speed);
} else if (magic.type === "screen") {
for (let i = 0; i < 25; i++) {
const rx = (Math.random() - 0.5) * 2;
const ry = (Math.random() - 0.5) * 2;
particles.push({
x: (Math.random() - 0.5) * 1000, y: (Math.random() - 0.5) * 1000,
vx: dirX * magic.speed + rx * 2, vy: dirY * magic.speed + ry * 2,
life: magic.life, maxLife: magic.life,
magic: magic, history: [], param: Math.random()
});
}
}
}
function spawnEnemy() {
const angle = Math.random() * Math.PI * 2;
const dist = 600;
enemies.push({
x: Math.cos(angle) * dist, y: Math.sin(angle) * dist,
hp: 100 + score * 8, maxHp: 100 + score * 8,
speed: 1.2 + Math.random() * 0.8 + (score * 0.02),
vx: 0, vy: 0, wobble: Math.random() * Math.PI * 2,
stun: 0
});
}
function update() {
gameTime++;
updateCooldowns();
if (currentCombo > 0) {
comboTimer--;
if (comboTimer <= 0) resetCombo();
}
if (gameTime % Math.max(20, 80 - Math.floor(score/2)) === 0) spawnEnemy();
for (let i = effects.length - 1; i >= 0; i--) {
let ef = effects[i];
ef.x += ef.vx; ef.y += ef.vy;
ef.life--;
if (ef.life <= 0) effects.splice(i, 1);
}
for (let i = particles.length - 1; i >= 0; i--) {
let p = particles[i];
p.history.push({x: p.x, y: p.y});
if(p.history.length > 8) p.history.shift();
p.x += p.vx; p.y += p.vy;
p.life--;
if (p.magic.type === "circle") p.radius = (1 - p.life / p.maxLife) * 350;
if (p.life <= 0) particles.splice(i, 1);
}
for (let i = enemies.length - 1; i >= 0; i--) {
let e = enemies[i];
const dist = Math.sqrt(e.x*e.x + e.y*e.y) || 1;
e.vx *= 0.85;
e.vy *= 0.85;
if (e.stun > 0) {
e.stun--;
} else {
e.x += -(e.x / dist) * e.speed;
e.y += -(e.y / dist) * e.speed;
}
e.x += e.vx;
e.y += e.vy;
e.wobble += 0.1;
particles.forEach(p => {
let hit = false;
if (p.magic.type === "circle") {
let pdist = Math.sqrt((e.x - p.x)**2 + (e.y - p.y)**2);
if (Math.abs(pdist - p.radius) < 35) hit = true;
} else if (p.magic.type === "wall") {
let pdist = Math.sqrt((e.x - p.x)**2 + (e.y - p.y)**2);
if (pdist < 60) hit = true;
} else {
let pdist = Math.sqrt((e.x - p.x)**2 + (e.y - p.y)**2);
if (pdist < (p.magic.width + 15)) hit = true;
}
if (hit && p.life % 4 === 0) {
e.hp -= p.magic.dmg;
const kx = e.x - p.x; const ky = e.y - p.y;
const kdist = Math.sqrt(kx*kx + ky*ky) || 1;
e.vx += (kx / kdist) * p.magic.kb;
e.vy += (ky / kdist) * p.magic.kb;
e.stun = 15;
for(let k=0; k<3; k++) {
effects.push({
x: e.x + (Math.random()-0.5)*30, y: e.y + (Math.random()-0.5)*30,
vx: (Math.random()-0.5)*15, vy: (Math.random()-0.5)*15,
life: 15, maxLife: 15, color: p.magic.color, type: "spark"
});
}
}
});
if (e.hp <= 0) {
for(let k=0; k<8; k++) {
effects.push({
x: e.x + (Math.random()-0.5)*40, y: e.y + (Math.random()-0.5)*40,
vx: (Math.random()-0.5)*5, vy: (Math.random()-0.5)*5,
life: 25, maxLife: 25, color: "#1e293b", type: "smoke"
});
}
score++;
scoreVal.innerText = score;
addCombo();
enemies.splice(i, 1);
} else if (dist < 20) {
enemies.splice(i, 1);
score = Math.max(0, score - 2);
scoreVal.innerText = score;
resetCombo();
}
}
}
function drawTree(t) {
const pos = toScreen(t.x, t.y);
ctx.fillStyle = "#5c4a3d";
ctx.fillRect(pos.x - 2, pos.y - 5, 4, 10);
ctx.fillStyle = "#cbd5e1";
ctx.strokeStyle = "#94a3b8";
ctx.lineWidth = 1;
for(let i=0; i<3; i++) {
ctx.beginPath();
let w = t.size * (3-i) * 0.8;
let h = t.size * 1.8;
let base = pos.y - i*t.size*1.2;
ctx.moveTo(pos.x, base - h);
ctx.lineTo(pos.x - w, base);
ctx.lineTo(pos.x + w, base);
ctx.fill(); ctx.stroke();
}
}
function drawEnemy(e) {
const pos = toScreen(e.x, e.y);
ctx.fillStyle = "rgba(0,0,0,0.2)";
ctx.beginPath();
ctx.ellipse(pos.x, pos.y + 12, 16, 6, 0, 0, Math.PI*2);
ctx.fill();
let wobbleOffset = e.stun > 0 ? (Math.random() - 0.5) * 4 : 0;
ctx.fillStyle = "#1e293b";
ctx.beginPath();
for (let j = 0; j < Math.PI * 2; j += Math.PI / 6) {
const r = 16 + Math.sin(e.wobble*4 + j * 3) * 6;
const px = pos.x + Math.cos(j) * r + wobbleOffset;
const py = pos.y - 6 + Math.sin(j) * r + wobbleOffset;
if (j === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
}
ctx.fill();
const dirX = -e.x; const dirY = -e.y;
const dist = Math.sqrt(dirX*dirX + dirY*dirY) || 1;
const nx = (dirX/dist) * 3;
const ny = (dirY/dist) * 3;
ctx.shadowColor = "#ff0000";
ctx.shadowBlur = 10;
ctx.fillStyle = "#ff4444";
ctx.beginPath();
ctx.arc(pos.x - 6 + nx + wobbleOffset, pos.y - 8 + ny + wobbleOffset, 2.5, 0, Math.PI*2);
ctx.arc(pos.x + 6 + nx + wobbleOffset, pos.y - 8 + ny + wobbleOffset, 2.5, 0, Math.PI*2);
ctx.fill();
ctx.shadowBlur = 0;
ctx.fillStyle = "rgba(0,0,0,0.6)";
ctx.fillRect(pos.x - 12 + wobbleOffset, pos.y - 25 + wobbleOffset, 24, 3);
ctx.fillStyle = "#ef4444";
ctx.fillRect(pos.x - 12 + wobbleOffset, pos.y - 25 + wobbleOffset, 24 * (e.hp / e.maxHp), 3);
}
function drawPlayer() {
const center = toScreen(0, 0);
const distToMouse = Math.sqrt(mouseLogic.x**2 + mouseLogic.y**2) || 1;
const nx = mouseLogic.x / distToMouse;
const ny = mouseLogic.y / distToMouse;
const guidePos = toScreen(nx * 40, ny * 40);
ctx.strokeStyle = "rgba(255, 255, 255, 0.5)";
ctx.lineWidth = 2;
ctx.setLineDash([3, 3]);
ctx.beginPath();
ctx.moveTo(center.x, center.y);
ctx.lineTo(guidePos.x, guidePos.y);
ctx.stroke();
ctx.setLineDash([]);
ctx.beginPath();
ctx.arc(guidePos.x, guidePos.y, 3, 0, Math.PI*2);
ctx.fillStyle = "rgba(255, 255, 255, 0.8)";
ctx.fill();
ctx.fillStyle = "rgba(100, 116, 139, 0.4)";
ctx.beginPath();
ctx.ellipse(center.x, center.y + 12, 18, 7, 0, 0, Math.PI*2);
ctx.fill();
const bounce = Math.sin(gameTime * 0.15) * 2;
const bodyY = center.y - 12 + bounce;
ctx.fillStyle = "#f8fafc";
ctx.beginPath();
ctx.arc(center.x, bodyY, 16, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "#0f172a";
ctx.beginPath();
ctx.arc(center.x - 6, bodyY - 3, 2, 0, Math.PI * 2);
ctx.arc(center.x + 6, bodyY - 3, 2, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "#fbbf24";
ctx.beginPath();
ctx.moveTo(center.x - 3, bodyY + 3);
ctx.lineTo(center.x + 3, bodyY + 3);
ctx.lineTo(center.x, bodyY + 7);
ctx.fill();
// 魔導士の帽子
ctx.fillStyle = "#1e293b";
ctx.beginPath();
ctx.ellipse(center.x, bodyY - 13, 21, 5, 0, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.moveTo(center.x - 13, bodyY - 14);
ctx.lineTo(center.x + 13, bodyY - 14);
ctx.lineTo(center.x + 4, bodyY - 38);
ctx.fill();
ctx.strokeStyle = "#fbbf24";
ctx.lineWidth = 3.5;
ctx.beginPath();
ctx.moveTo(center.x - 11, bodyY - 17);
ctx.quadraticCurveTo(center.x, bodyY - 13, center.x + 12, bodyY - 16);
ctx.stroke();
}
function drawWind(p) {
const m = p.magic;
const pos = toScreen(p.x, p.y);
const alpha = p.life / p.maxLife;
ctx.lineCap = "round";
if (m.type === "circle") {
ctx.shadowColor = m.color;
ctx.shadowBlur = 20;
for(let i=0; i<3; i++) {
let r = p.radius - i*20;
if(r < 0) continue;
ctx.strokeStyle = (i===0) ? "#ffffff" : m.color;
ctx.lineWidth = (m.width - i*5) * alpha;
ctx.globalAlpha = alpha * (1 - i*0.2);
ctx.beginPath();
ctx.ellipse(pos.x, pos.y, r * cosA, r * sinA, 0, 0, Math.PI * 2);
ctx.stroke();
}
ctx.globalAlpha = 1.0;
ctx.shadowBlur = 0;
} else if (m.type === "tornado") {
ctx.shadowColor = m.color;
ctx.shadowBlur = 15;
for(let i=0; i<8; i++) {
let pAlpha = alpha * (1 - i/8);
ctx.strokeStyle = m.color;
ctx.lineWidth = 3 + i;
ctx.globalAlpha = pAlpha;
let offset = Math.sin(gameTime*0.5 + i*1.5) * (10 + i*5);
let height = -i * 12;
let radius = 10 + i*6;
ctx.beginPath();
ctx.ellipse(pos.x + offset, pos.y + height, radius * cosA, radius * sinA, 0, 0, Math.PI * 2);
ctx.stroke();
}
ctx.globalAlpha = 1.0;
ctx.shadowBlur = 0;
} else {
if(p.history.length < 2) return;
const start = toScreen(p.history[0].x, p.history[0].y);
ctx.lineWidth = m.width * alpha * 1.5;
ctx.strokeStyle = m.color;
ctx.globalAlpha = alpha * 0.5;
ctx.shadowColor = m.color;
ctx.shadowBlur = 15;
ctx.beginPath();
ctx.moveTo(start.x, start.y);
for(let i=1; i<p.history.length; i++) {
let hPos = toScreen(p.history[i].x, p.history[i].y);
let wobble = Math.sin(gameTime*0.5 + p.param + i) * (m.width*0.4);
ctx.lineTo(hPos.x, hPos.y + wobble);
}
ctx.stroke();
ctx.lineWidth = m.width * 0.4 * alpha;
ctx.strokeStyle = "#ffffff";
ctx.globalAlpha = alpha;
ctx.beginPath();
ctx.moveTo(start.x, start.y);
for(let i=1; i<p.history.length; i++) {
let hPos = toScreen(p.history[i].x, p.history[i].y);
ctx.lineTo(hPos.x, hPos.y);
}
ctx.stroke();
ctx.globalAlpha = 1.0;
ctx.shadowBlur = 0;
}
}
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
let renderList = [];
renderList.push({ type: 'player', ySort: 0 });
trees.forEach(t => renderList.push({ type: 'tree', entity: t, ySort: t.x + t.y }));
enemies.forEach(e => renderList.push({ type: 'enemy', entity: e, ySort: e.x + e.y }));
renderList.sort((a, b) => a.ySort - b.ySort);
renderList.forEach(item => {
if (item.type === 'player') drawPlayer();
else if (item.type === 'tree') drawTree(item.entity);
else if (item.type === 'enemy') drawEnemy(item.entity);
});
ctx.globalCompositeOperation = "screen";
particles.forEach(p => drawWind(p));
effects.forEach(ef => {
const pos = toScreen(ef.x, ef.y);
ctx.globalAlpha = ef.life / ef.maxLife;
ctx.fillStyle = ef.color;
if (ef.type === "spark") {
ctx.beginPath();
ctx.arc(pos.x, pos.y, 4 * (ef.life/ef.maxLife), 0, Math.PI*2);
ctx.fill();
} else {
ctx.globalCompositeOperation = "source-over";
ctx.beginPath();
ctx.arc(pos.x, pos.y, 8 + (ef.maxLife - ef.life)*0.5, 0, Math.PI*2);
ctx.fill();
ctx.globalCompositeOperation = "screen";
}
});
ctx.globalAlpha = 1.0;
ctx.globalCompositeOperation = "source-over";
}
function loop() {
update();
draw();
requestAnimationFrame(loop);
}
loop();
</script>
</body>
</html>


■ 今日の学び

「とにかく大好きなシマエナガを大活躍させたい!」

そんな思いから作り始めたゲームでした。(^v^*)

今回は、かわいいシマエナガが風の大魔導士となり、
悪夢のような「何か怖いの」達を豪快に吹き飛ばしていくという、
かわいさと強さのギャップをテーマにしてみました。

実際に作ってみると、このギャップが思った以上に面白く、
ゲームの個性にもなった気がします。

ちなみにこのゲームは、気軽に爽快感を楽しめるように、
ダメージを受ける要素は入れていません。

マウスで狙いを定めながらキーボードで魔法を撃つという同時操作は、
作っている時には少し難しいかなと思っていたのですが、
遊んでみると意外と夢中になります。

気が付くと、

( ੭•̀⊝•́)੭ 🌪️ ブオォォン!!

と無心で風魔法を撃ち続けていました。(笑)


そして少し、ブログ運営のお話です。

このところAIとのゲーム作りがますます楽しくなってしまい、
ブログ記事を書くペースが追いつかなくなってきました。

ゲーム自体は毎日「X」で動画を投稿しているのですが、
未記事ゲーム展示室に並んでいるゲームは、
ついに150作品を超えています。(^_^;)

先日はあまりにも数が増えてきたため、
展示室を2ページに分ける作業も行いました。

現状では、記事として公開したゲームだけコードを掲載しているため、
展示室に並んでいるたくさんの未記事ゲームは、
まだ皆さんに遊んでいただけない状態です。

せっかく完成したゲーム達ですので、
もっと気軽に遊べる形で公開できないかな……と、
現在いろいろ試行錯誤しています。

ゲーム作りもブログ作りも、まだまだ実験の途中です。

これからも少しずつ環境を整えながら、
たくさんの作品を皆さんにお届けできるよう頑張りますので、
温かく見守っていただけると嬉しいです。(^v^)


■ 次回は、本格的な操作感にこだわった3Dフォークリフト実験ゲームです。

これまでのゲームから一歩踏み込んで、

今回は 「乗り物を自分の手で思い通りに操る楽しさ」 をテーマにした、

フォークリフトの操作感を追求した実験ゲームを作ってみました。

フォークリフト特有の「お尻が振れる後輪操舵」の感覚や、

運転席から補助モニターを見ながらツメをスッと差し込む緊張感。

重たいパレットを慎重に持ち上げ、トラックの荷台へピタッと収める。

そんな職人技のような心地よい達成感を味わえるゲームになっています。

次回もぜひ楽しみにしていてくださいね。(*^v^)ノシ


▶ 次の実験はこちら
重い荷物を運ぶ爽快感とリアルな操作感
「フォークリフト職人への道」



 ✨今日も最後まで読んでくださって、ありがとうございます。
みーの実験室は、みなさんの応援で元気に動いています。(*^v^*)