■48個目「魔法の投網 – ちょびっと魔法と果樹園」
こんにちは、AIと作る100のゲーム実験室の みー です。(*^_^)/
今回は、ちょっぴり不思議で泥臭いファンタジーゲームを作ってみました。 その名も 「魔法の投網と逃げる果実」 です!
舞台は、魔法が存在するけれど、その力がとーっても弱い世界。(=v=*)
この世界の果樹園では、なんと果物たちに「足」が生えていて、画面の中をちょこまかと逃げ回ります。
プレイヤーの目的は、逃げる果物たちを 魔法の投網 でうまく捕まえること。
魔法が弱いので、投網に頼るしかないのです。
狙いを定めて「えいっ!」と網を投げ込むと、
青白い魔法の光がぶわぁっと広がります。(´▽`*)
🍑 逃げる果物たちは個性豊か
- 高得点だけどすばしっこい、ぷっくりお尻の 桃 🍑
- しなやかに体をくねらせて走る バナナ 🍌
- 頭のへたを揺らして必死に逃げる イチゴ 🍓
- 網の気配を察知するセンサー(種)を持つ キウイ 🥝
などなど、全部で8種類の果物たちがわちゃわちゃと走り回っています。
✨ このゲームのキモは「ちょびっと魔法」
魔法が弱いからこそ生まれる、ギリギリの攻防が最大の魅力です。
網がパッと広がったあと、ほんの少しだけ魔法の力を振り絞って 網を「ずずっ」と広げられる “ちょびっと魔法” の時間が訪れます。
「あとちょっとで桃が入るのに……!」という、
指先に力が入るようなせめぎ合いが熱い、ワンチャンス×2回のスコアアタックになっています。(^-^)
■ AIとのやりとりで面白かった部分
■ゲーム仕様の相談からスタート
みー:(*^^) 今日もゲームを作ります。
今回は「投網ゲーム」。 魔法はあるけど中途半端に弱い世界で、
足の生えた果物たちが逃げ回ります。 魔法の力が弱いので、
収穫には 魔法の投網 を使います。
AI: いらっしゃい!今日も来てくれたね。
泥臭さとファンタジーが混ざった世界観、店主は大好きだよ。
果物たちはポイントが高いほどレアで、
低いほどたくさん湧くようにしてある。
クリックした場所に魔法陣のように網が
「ぶわっ」と展開するようにしておいたよ。
● スコアバランスと果物の見た目調整
みー:( ̄_ ̄;) リンゴとミカンの配点が高すぎるかも。
イチゴにはへたを付けたいね。
AI: 了解だ!配点をマイルドに調整しておいたよ。 イチゴのへたも追加して、より可愛くしておいたぜ。
● 果物の種類追加と世界観の強化
みー:(^v^) 背景や果物の表現をもっと良くしたいね。
桃、キウイ、バナナも追加できるかな。
AI: もちろんだとも! キウイの種は微弱な魔力を感知するセンサーになっていて、 網の気配を察知して逃げ足が速くなる……
なんて設定も加えておいたよ。
● “ちょびっと魔法”の実装
みー:(*^w^) 網を投げた後、
1秒間だけクリック連打で範囲を広げられる 「ちょびっと魔法」 を入れたい。
1回のゲームで2回投げて、その合計得点をスコアにしよう。
AI: いいねぇ! 魔法が弱い世界だからこそ、
必死に足掻いて網を広げる「ちょびっと魔法」が光るんだよ。
● 操作性の改善
みー:(つv<;) 1投目の連打の後に、誤って2投目が暴発しちゃったよ。
「投網ボタン → 投げる位置クリック」にしよう。
AI: あーっ、それは完全に店主の配慮不足だった!
連打の勢いで2投目が暴発しないように、操作方法を修正しておいたよ。
■ 実際の動作
最初に AI と相談して作ってもらった初期バージョンです。
ほぼイメージ通りで、逃げ回る果物たちがとてもかわいらしい動きをしてくれています。

(画像:初期のゲーム画面)
種類を増やしていくと、果樹園はさらににぎやかに。
画面の中をちょこまかと走り回る姿が本当に楽しいです。

(画像:にぎやかな果樹園)
「網を構える」ボタンを押すと、果物たちがふっと集まる瞬間があります。
その一瞬を狙って、投網を投げ込みます。

(画像:構えの様子)
魔法の力は弱いけれど、投げた瞬間に網がふわっと広がります。
思ったよりも大きく広がるので、狙いが決まると気持ちいいです。

(画像:投網の広がり)
■ コードのポイント
今回の記事では、AI と相談しながら作った
「魔法の投網 – ちょびっと魔法と果樹園」 の HTML コードを公開します。
ブラウザだけで動く、とてもシンプルなゲームです。
難しい部分はほとんどないので、気軽に試してみてくださいね。
▼ 遊ぶまでの手順
1. PC の「メモ帳」(※Windowsの場合)や、
Mac の「テキストエディット」など、
テキストエディタを開きます。
2. 下のコードをコピーして貼り付けます。
3. 「toami .html」など、好きな名前で保存します。
(※拡張子は .html にしてください)
4. 保存したファイルをブラウザで開けば、すぐに遊べます。
■ 遊び方・操作方法
操作はとっても簡単!
でも、指先にちょっとだけ体力(?)を使いますよ。(=v=*)
① 「網を構える」ボタンをポチッ!
画面下の青いボタンを押すと、マウスカーソルが「+」の形
(照準)に変わります。 これで 網を投げる準備完了 です!
② 狙いを定めて……えいっ!
逃げ回る果物たちがギュッと集まっている場所を狙ってクリック!
すると、青白い魔法の網が ぶわぁっ と広がります。(´▽`)
③ ここからが本番!「ちょびっと魔法」で連打!!
網が最大まで広がると、画面に黄色い文字で
「連打で範囲を広げろ!」
と表示されます。
この「1秒間」だけが、最後の魔法の力を振り絞れる時間です!
カチカチカチッ!!(`・_・´)
連打すればするほど、網が ずずずっ…… と広がって、
ギリギリ逃げそうだった果物も巻き込めるようになります。
④ 2回の合計スコアで勝負!
網を投げられるのは 1ゲームにつき2回まで。
1回目で失敗しても大丈夫。
2回目で高得点の桃(🍑500pt)やリンゴ(🍎300pt)を一網打尽にできれば、 大逆転も余裕です!(^_^)
2回投げ終わった合計スコアがランキングに記録されます。 ぜひ何度も挑戦して、果樹園の凄腕ハンター(?)を目指して、
渾身の「ちょびっと魔法」を炸裂させてみてくださいね♪(^^*)
▼ここからコード▼(クリックで開く)
<html lang="ja"><head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>魔法の投網 - ちょびっと魔法と輪廻果樹園 -</title> <style> body { font-family: 'Helvetica Neue', Arial, 'Hiragino Kaku Gothic ProN', 'Hiragino Sans', Meiryo, sans-serif; background-color: #1a252f; color: #ecf0f1; text-align: center; margin: 0; padding: 20px; display: flex; flex-direction: column; align-items: center; user-select: none; } h1 { font-size: 28px; margin: 0; color: #f1c40f; text-shadow: 2px 2px 4px #000; } .status-bar { display: flex; gap: 20px; margin: 10px 0; font-weight: bold; } #game-container { display: flex; gap: 20px; flex-wrap: wrap; justify-content: center; } canvas { background: radial-gradient(circle, #9dc183 0%, #7a9e5b 100%); border: 6px solid #4a3728; border-radius: 12px; box-shadow: 0 10px 20px rgba(0,0,0,0.6); cursor: default; } #ui-panel { background-color: #2c3e50; padding: 20px; border-radius: 12px; border: 2px solid #34495e; width: 280px; text-align: left; box-shadow: 0 4px 10px rgba(0,0,0,0.3); } .ranking-list { list-style: none; padding: 0; margin: 0 0 15px 0; } .ranking-list li { background: #34495e; margin-bottom: 6px; padding: 10px; border-radius: 6px; display: flex; justify-content: space-between; font-size: 16px; border-left: 4px solid #f1c40f; } .btn { width: 100%; padding: 12px; font-size: 16px; font-weight: bold; color: #fff; border: none; border-radius: 6px; cursor: pointer; transition: all 0.2s; margin-bottom: 10px; } #prepare-btn { background-color: #3498db; } #prepare-btn:hover { background-color: #2980b9; transform: scale(1.02); } #retry-btn { display: none; background-color: #e67e22; } #retry-btn:hover { background-color: #d35400; transform: scale(1.02); } #reset-btn { background-color: #95a5a6; font-size: 11px; padding: 5px; } .magic-hint { color: #f1c40f; font-weight: bold; text-align: center; display: none; animation: flash 0.5s infinite; } .prepare-hint { color: #3498db; font-weight: bold; text-align: center; display: none; margin-bottom: 10px; } @keyframes flash { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } } .target-list { font-size: 11px; color: #bdc3c7; margin-bottom: 10px; display: grid; grid-template-columns: 1fr 1fr; gap: 2px; } </style></head><body> <h1>魔法の投網と逃げる果実</h1> <div class="status-bar"> <div id="attempt-display">残り回数: 2</div> <div id="score-display">合計スコア: 0</div> </div> <div id="game-container"> <canvas id="gameCanvas" width="650" height="450"></canvas> <div id="ui-panel"> <div id="magic-message" class="magic-hint">連打で範囲を広げろ!</div> <div id="prepare-message" class="prepare-hint">狙いを定めてクリック!</div> <button id="prepare-btn" class="btn">網を構える</button> <div class="target-list"> <div>🍑 500pt(荒)</div><div>🍎 300pt(急)</div> <div>🍌 200pt</div><div>🍊 150pt</div> <div>🥝 100pt</div><div>🍓 80pt</div> <div>🟢 40pt</div><div>🔵 10pt</div> </div> <h3>🏆 ランキング (2投合計)</h3> <ul class="ranking-list" id="ranking"></ul> <button id="retry-btn" class="btn">再挑戦</button> <button id="reset-btn" class="btn">ランキングリセット</button> </div> </div> <script> const canvas = document.getElementById('gameCanvas'); const ctx = canvas.getContext('2d'); const prepareBtn = document.getElementById('prepare-btn'); const retryBtn = document.getElementById('retry-btn'); const resetBtn = document.getElementById('reset-btn'); const scoreDisplay = document.getElementById('score-display'); const attemptDisplay = document.getElementById('attempt-display'); const rankingList = document.getElementById('ranking'); const magicMsg = document.getElementById('magic-message'); const prepareMsg = document.getElementById('prepare-message'); const FRUIT_TYPES = [ // erraticChance (変則移動確率), erraticAmount (変則移動量) { name: 'peach', color: '#ffafbd', radius: 22, points: 500, speed: 2.8, errChance: 0.10, errAmt: 1.0 }, // 高得点・超荒ぶる { name: 'apple', color: '#ff4d4d', radius: 18, points: 300, speed: 2.2, errChance: 0.08, errAmt: 0.8 }, // 高得点・急加速 { name: 'banana', color: '#f1c40f', radius: 17, points: 200, speed: 1.6, errChance: 0.05, errAmt: 0.4 }, { name: 'orange', color: '#ffa502', radius: 15, points: 150, speed: 1.3, errChance: 0.03, errAmt: 0.3 }, { name: 'kiwi', color: '#8d6e63', radius: 14, points: 100, speed: 1.5, errChance: 0.04, errAmt: 0.5 }, // 小さいけどセンサー持ち { name: 'strawberry', color: '#ff3838', radius: 12, points: 80, speed: 1.1, errChance: 0.02, errAmt: 0.2 }, { name: 'muscat', color: '#b8e994', radius: 9, points: 40, speed: 1.8, errChance: 0.02, errAmt: 0.2 }, // 意外と速い { name: 'blueberry', color: '#4b4bff', radius: 6, points: 10, speed: 0.8, errChance: 0.01, errAmt: 0.1 } // のんびり ]; let fruits = []; let gameState = 'playing'; // playing, magic_time, result let isNetReady = false; // 網を構えているかどうかのフラグ let net = { x: 0, y: 0, currentRadius: 0, baseMaxRadius: 150, // 基本範囲を割り増し bonusRadius: 0, active: false }; let totalScore = 0; let attemptsLeft = 2; let animationId; let magicTimer = 0; function loadRanking() { const saved = localStorage.getItem('netGameRanking_v4'); return saved ? JSON.parse(saved) : [0, 0, 0, 0, 0]; } function saveAndDisplayRanking(newScore) { let ranks = loadRanking(); if (newScore > 0) { ranks.push(newScore); ranks.sort((a, b) => b - a); ranks = ranks.slice(0, 5); localStorage.setItem('netGameRanking_v4', JSON.stringify(ranks)); } rankingList.innerHTML = ''; ranks.forEach((score, index) => { const li = document.createElement('li'); li.innerHTML = `<span class="rank">${index + 1}位</span> <span>${score} pt</span>`; rankingList.appendChild(li); }); } class Fruit { constructor(isInitial = false) { const rand = Math.random(); let type; if (rand < 0.04) type = FRUIT_TYPES[0]; else if (rand < 0.10) type = FRUIT_TYPES[1]; else if (rand < 0.18) type = FRUIT_TYPES[2]; else if (rand < 0.28) type = FRUIT_TYPES[3]; else if (rand < 0.40) type = FRUIT_TYPES[4]; else if (rand < 0.55) type = FRUIT_TYPES[5]; else if (rand < 0.75) type = FRUIT_TYPES[6]; else type = FRUIT_TYPES[7]; this.type = type; this.caught = false; this.bounceCount = 0; // 跳ね返り回数 this.legPhase = Math.random() * Math.PI; const padding = this.type.radius + 10; if (isInitial) { // 初期配置:画面内にランダム this.x = Math.random() * (canvas.width - padding * 2) + padding; this.y = Math.random() * (canvas.height - padding * 2) + padding; const angle = Math.random() * Math.PI * 2; this.vx = Math.cos(angle) * type.speed; this.vy = Math.sin(angle) * type.speed; } else { // 入れ替わり配置:画面中央へ召喚 ★変更 // 中央付近の一定範囲内(例: 中央 ± radius*3)に配置 const spawnAreaRadius = type.radius * 3; this.x = canvas.width / 2 + (Math.random() - 0.5) * spawnAreaRadius * 2; this.y = canvas.height / 2 + (Math.random() - 0.5) * spawnAreaRadius * 2; // 速度ベクトルは完全にランダム const angle = Math.random() * Math.PI * 2; this.vx = Math.cos(angle) * this.type.speed; this.vy = Math.sin(angle) * this.type.speed; } } update() { if (this.caught || gameState === 'magic_time') return; // 変則的な動き if (Math.random() < this.type.errChance) { const angle = Math.atan2(this.vy, this.vx) + (Math.random() - 0.5) * this.type.errAmt; this.vx = Math.cos(angle) * this.type.speed; this.vy = Math.sin(angle) * this.type.speed; // 急加速/急減速の効果 const speedMod = 0.8 + Math.random() * 0.4; // 0.8倍〜1.2倍 this.vx *= speedMod; this.vy *= speedMod; // 最高速度を制限 const currentSpeed = Math.hypot(this.vx, this.vy); if (currentSpeed > this.type.speed * 1.5) { this.vx = (this.vx / currentSpeed) * this.type.speed * 1.5; this.vy = (this.vy / currentSpeed) * this.type.speed * 1.5; } } this.x += this.vx; this.y += this.vy; // 跳ね返りロジックの修正 // 左右の端 if (this.x < this.type.radius || this.x > canvas.width - this.type.radius) { if (this.bounceCount < 1) { this.vx *= -1; this.bounceCount++; // 画面内に押し戻す this.x = Math.max(this.type.radius, Math.min(canvas.width - this.type.radius, this.x)); } // 2回目以降はそのまま画面外へ } // 上下の端 if (this.y < this.type.radius || this.y > canvas.height - this.type.radius) { if (this.bounceCount < 1) { this.vy *= -1; this.bounceCount++; // 画面内に押し戻す this.y = Math.max(this.type.radius, Math.min(canvas.height - this.type.radius, this.y)); } // 2回目以降はそのまま画面外へ } this.legPhase += 0.2 * this.type.speed; } draw() { ctx.save(); ctx.translate(this.x, this.y); ctx.strokeStyle = '#34495e'; ctx.lineWidth = 2; ctx.beginPath(); let lo1 = (this.caught || gameState === 'magic_time') ? 0 : Math.sin(this.legPhase) * 5; let lo2 = (this.caught || gameState === 'magic_time') ? 0 : Math.cos(this.legPhase) * 5; ctx.moveTo(-5, this.type.radius * 0.7); ctx.lineTo(-5 + lo1, this.type.radius * 1.4); ctx.moveTo(5, this.type.radius * 0.7); ctx.lineTo(5 + lo2, this.type.radius * 1.4); ctx.stroke(); if(this.type.name === 'banana') { ctx.rotate(Math.atan2(this.vy, this.vx)); ctx.beginPath(); ctx.arc(0, 0, this.type.radius, 0.2, Math.PI - 0.2); ctx.lineWidth = 9; ctx.strokeStyle = this.type.color; ctx.stroke(); ctx.fillStyle = '#443311'; ctx.fillRect(this.type.radius-2, 5, 4, 4); } else { ctx.beginPath(); ctx.arc(0, 0, this.type.radius, 0, Math.PI * 2); ctx.fillStyle = this.type.color; ctx.fill(); if(this.type.name === 'peach') { ctx.strokeStyle = 'rgba(0,0,0,0.1)'; ctx.beginPath(); ctx.moveTo(0, -this.type.radius); ctx.quadraticCurveTo(6, 0, 0, this.type.radius); ctx.stroke(); } if(this.type.name === 'kiwi') { ctx.fillStyle = '#c5e1a5'; ctx.beginPath(); ctx.arc(0,0, this.type.radius * 0.7, 0, Math.PI*2); ctx.fill(); ctx.fillStyle = '#333'; for(let i=0; i<8; i++) ctx.fillRect(Math.cos(i)*5, Math.sin(i)*5, 2, 2); } if(this.type.name === 'strawberry') { ctx.fillStyle = '#27ae60'; ctx.beginPath(); ctx.moveTo(0, -this.type.radius+2); ctx.lineTo(-7, -this.type.radius-3); ctx.lineTo(7, -this.type.radius-3); ctx.fill(); } } ctx.restore(); if (this.caught) { ctx.fillStyle = '#fff'; ctx.font = 'bold 14px Arial'; ctx.textAlign = 'center'; ctx.fillText('+' + this.type.points, this.x, this.y - this.type.radius - 10); } } } function initGame() { // 初期配置 fruits = Array.from({length: 45}, () => new Fruit(true)); totalScore = 0; attemptsLeft = 2; gameState = 'playing'; isNetReady = false; net.active = false; updateUI(); retryBtn.style.display = 'none'; prepareBtn.style.display = 'block'; canvas.style.cursor = 'default'; prepareMsg.style.display = 'none'; saveAndDisplayRanking(0); if (animationId) cancelAnimationFrame(animationId); gameLoop(); } function updateUI() { scoreDisplay.textContent = `合計スコア: ${totalScore}`; attemptDisplay.textContent = `残り回数: ${attemptsLeft}`; } // 網を構えるボタンの処理 prepareBtn.addEventListener('click', () => { if (gameState === 'playing' && !isNetReady) { isNetReady = true; prepareBtn.style.display = 'none'; prepareMsg.style.display = 'block'; canvas.style.cursor = 'crosshair'; // 狙いを定めるカーソルに変更 } }); canvas.addEventListener('mousedown', (e) => { if (gameState === 'playing' && isNetReady) { // 網を投げる const rect = canvas.getBoundingClientRect(); net.x = e.clientX - rect.left; net.y = e.clientY - rect.top; net.currentRadius = 0; net.bonusRadius = 0; net.active = true; // 状態をリセット isNetReady = false; canvas.style.cursor = 'default'; prepareMsg.style.display = 'none'; gameState = 'animating_net'; } else if (gameState === 'magic_time') { // ちょびっと魔法:連打でボーナス範囲拡大を強化 (1.5 -> 3.0) net.bonusRadius += 3.0; } }); function endAttempt() { const currentTotalRadius = net.baseMaxRadius + net.bonusRadius; let catchScore = 0; fruits.forEach(f => { if (!f.caught) { const dist = Math.hypot(f.x - net.x, f.y - net.y); if (dist <= currentTotalRadius) { f.caught = true; catchScore += f.type.points; } } }); totalScore += catchScore; attemptsLeft--; updateUI(); magicMsg.style.display = 'none'; if (attemptsLeft > 0) { gameState = 'playing'; net.active = false; prepareBtn.style.display = 'block'; // 次の投網の準備ボタンを再表示 } else { gameState = 'result'; saveAndDisplayRanking(totalScore); retryBtn.style.display = 'block'; } } function gameLoop() { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = 'rgba(255,255,255,0.03)'; for(let i=0; i<15; i++) { ctx.beginPath(); ctx.arc((i*150 + Date.now()*0.05)%canvas.width, (i*90)%canvas.height, 30, 0, Math.PI*2); ctx.fill(); } // 捕まっていない果物の更新(逆順ループで削除・追加に対応) for (let i = fruits.length - 1; i >= 0; i--) { const f = fruits[i]; if (f.caught) continue; // 捕まった果物は動かさない f.update(); // 画面外に出た判定(入れ替わり)★変更不要、new Fruit() が中央召還に対応 const padding = f.type.radius + 15; if (f.x < -padding || f.x > canvas.width + padding || f.y < -padding || f.y > canvas.height + padding) { // 削除して新しい果物を追加(new Fruit() 内の else ブロックが走る) fruits[i] = new Fruit(); } } // 描画 let drawList = fruits.slice().sort((a, b) => a.caught - b.caught); drawList.forEach(f => f.draw()); if (net.active) { if (gameState === 'animating_net') { // ★網が広がる時間を長くするために、増加量を小さく (12 -> 5) net.currentRadius += 5; if (net.currentRadius >= net.baseMaxRadius) { net.currentRadius = net.baseMaxRadius; gameState = 'magic_time'; magicTimer = Date.now(); magicMsg.style.display = 'block'; } } else if (gameState === 'magic_time') { // 魔法の時間(連打時間)を少し長く (1000 -> 2000) if (Date.now() - magicTimer > 2000) { endAttempt(); } } const drawRadius = net.currentRadius + net.bonusRadius; ctx.beginPath(); ctx.arc(net.x, net.y, drawRadius, 0, Math.PI * 2); // 広がる効果を分かりやすくするための描画強化 if (gameState === 'magic_time') { // 魔法の時間中は、より黄金色に輝き、太線で表示 ctx.fillStyle = 'rgba(241, 196, 15, 0.3)'; // 少し透明度を上げ、黄金色を強く ctx.lineWidth = 6; // 線を太く ctx.strokeStyle = '#f1c40f'; // 黄金色 } else { // 通常の拡張中は、青白く、細線で表示 ctx.fillStyle = 'rgba(100, 200, 255, 0.3)'; ctx.lineWidth = 3; ctx.strokeStyle = '#fff'; } ctx.fill(); ctx.stroke(); ctx.beginPath(); ctx.setLineDash([5, 5]); for(let r=30; r<drawRadius; r+=30) ctx.arc(net.x, net.y, r, 0, Math.PI*2); ctx.stroke(); ctx.setLineDash([]); } animationId = requestAnimationFrame(gameLoop); } resetBtn.addEventListener('click', () => { if(confirm('ランキングをリセットしますか?')) { localStorage.removeItem('netGameRanking_v4'); saveAndDisplayRanking(0); } }); retryBtn.addEventListener('click', initGame); initGame(); </script></body></html>
■ 今日の学び
今回は、世界観からゲームを作り始めました。 この「中途半端な魔法の世界」、やっぱり好きです。
不自由さと、道具の便利さのバランス。 それは現実世界にも通じるところがあって、 制限のある世界でどんな動きが生まれるのか想像するのが、とても楽しいです。
ゲームとして形になって満足していますが、 まだまだ改良の余地がある「実験の種」のようにも感じています。
次にこのゲームが登場するときは、 もしかしたら全く違う姿になっているかもしれません。
お邪魔キャラやアイテムなど、 実験アイデアをこれからもたくさん集めていくので、お楽しみに!
■ 次回は、久しぶりの落ちげーです。
大好きなハムスターを今回も登場させました。
走り去っていく姿が何とも言えません。
次回もお楽しみに。(*^v^)ノシ
今日も最後まで読んでくださって、ありがとうございます。
みーの実験室は、みなさんの応援で元気に動いています。(*^v^*)

コメントを残す