実験#102:ユズのお祭り(ユズフェスティバル~ドッジ&キャッチ~)

■102個目 「ユズフェスティバル~ドッジ&キャッチ~」

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

今回は、ちょっと変わった対戦アクションゲームを作ってみました。

なんと、ボールの代わりに投げ合うのは “ユズ” です!

舞台は、さわやかな風が吹く「ユズ村のドッジ祭り」。

主人公は、麦わら帽子がトレードマークののんびり屋なおじさん。
そして対戦相手は、昨年のドッジ祭り優勝者である
天才小6ギャルの「ゆずちゃん」です。

今回のこだわりは、
この二人が生み出すちょっとシュールな温度差です。(=v=*)

「のんびりしたお祭りの中で、
とんでもない速さのユズが飛び交ったら面白いかも?」

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

どんなにピンチでも「(^v^)」と笑顔を崩さないおじさんと、

ツインテールを揺らしながら生意気なセリフを連発するゆずちゃん。
ただユズを投げ合うだけでなく、
意地と意地がぶつかり合う「必殺ショット」も飛び出します。(´▽`)

そして勝負がつくと……ゆずちゃんの小6ギャル全開な一言が聞けます。

勝っても負けても色々なセリフが用意されているので、
どんな言葉が飛び出すかお楽しみに。

果たして、おじさんは天才少女に勝つことができるのでしょうか。
ユズが舞うお祭りの一日を、ぜひ体験してみてください。(≧▽≦)


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

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

みー:(^v^) 今日もゲームを作ります。htmlで動かせるゲームです。

ドッジボール的な跳ね返しブロックゲームとか面白そう。

マウスクリックでキャッチ、3秒以内に離してショット。

3秒以上持っていると相手ボールに。
タイミングよくクリックできないとキャッチできずヒットで相手ポイント。

左右で陣地を分けて中央線を境界に。移動は矢印キー。
ここまでのプロトタイプから、操作感の気持ち良いものをお願いします。

AI: いらっしゃい。今日もゲーム作りだね。

ドッジボールとブロック崩しを掛け合わせたようなアクションゲーム、
すごく面白い発想だよ。

「タイミング重視のキャッチ」「3秒以内のリリース」「陣地分け」など、
すでに駆け引きの核ができているから、
対戦ゲームとしての面白さがしっかり生まれそうだ。

操作の気持ち良さにつながる部分として、
プロトタイプにこんな要素を入れてみたよ。

  • 焦燥感の演出:
    ボール保持中は頭上にタイムゲージを表示。
  • キャッチの視覚効果:
    キャッチ可能なタイミングで円形のバリアエフェクトが出る。
  • エイムの可視化:
    ボール保持中はカーソル方向へ照準レーザーが伸びる。

みー:(^_^;) 相手が打ち返してこないね。

ちなみに、当たらずに画面端に球が当たると跳ね返ります。
跳ね返りで当たってもアウト。
跳ね返りの玉のスピードは少し遅くなります。

AI: なるほど、敵の動きが甘かったみたいだね。

敵AIを調整して、
ボールが当たった瞬間にプレイヤーへ鋭く打ち返すようにしたよ。
これでテンポの良いラリーが続くはず。

追加ルールも組み込んでおいたよ。

  • バウンド球のヒット判定:
    壁で減速した球でも当たればアウト。
  • 壁バウンドの減速:
    端に当たると速度が20%ダウン。ただし最低速度は保証。

みー:(=v=) キャッチ後にうまく打ち返しにならないね。

相手がキャッチうますぎるのか、
システムがうまく動いてないのか……
相手のポイントばかり入ります。

3秒ギリギリになるほど球のスピードアップにしましょう。

AI: これは完全にシステム側の不具合だったよ。ごめんね。

原因は「敵がキャッチ上手すぎる」わけではなく、
投げた瞬間に自分のボールが自分に当たって自爆判定になっていたことだった。 ボールの初期位置が体の中心すぎて、
直撃扱いになっていたんだ。

投げた瞬間にボールを体の外側へ配置するよう修正して、
自爆バグは解消したよ。

そして追加の「3秒ギリギリで球が加速する」システムも導入。
最大2.5倍まで加速するから、端を狙えば敵は追いつけないはず。

みー:(^w^) 10ポイント先取の勝利にしましょう。
必殺ショットも作りましょう。

赤ゲージの時に Fキーでカーブショット、Jキーで巨大化ショット(3倍)、
スペースキーで超高速ショット。
必殺ショットはキャッチ不可。
壁に当たると通常の玉に戻ります。

AI: 10点先取の対戦形式に、赤ゲージで撃てる必殺ショット……
一気に熱い展開になってきたね。

オーダー通り、必殺ショットを実装しておいたよ。

みー:(^o^) 必殺ショットの時に球を残像させたいです。

あと、必殺ショットの時に敵が球を取りに行こうとするので、
判断して避けるようにできますか?

AI: 残像と回避AI、どちらもすごく良いアイデアだよ。

残像がつけば「ヤバい攻撃が来た!」という迫力が出るし、
敵がそれを察知してキャッチを諦めて避ける動きをすることで、
対戦相手としての人間味がぐっと増すんだ。

みー:(^o^/) いい感じです。世界観を入れ込みます。

以前作ったユズ運びゲームのおじさんが主人公。
麦わら帽子に丸い顔で(^v^)みたいな表情。

球はユズでヘタもつけてユズ感を。
ユズ村の祭りでユズの投げ合いをします。

相手は昨年優勝の天才少女ゆず。
背景も明るくさわやかな村の祭り風にお願いします。

AI: あの「ユズ運びのおじさん」が主人公として再登場とは嬉しいね。
ユズ村の祭りで、昨年の覇者・天才少女ゆずとのドッジ対決……
とても良い世界観だよ。

オーダー通り、画面を明るい祭りの雰囲気にして、
キャラクターにも表情をつけてみたよ。


■ 実際の動作

まずはAIが最初に出してくれたゲーム画面です。

(画像:AIが一番最初に出してくれたゲーム画面)


こちらが仕上がりのゲームスタート画面です。

(画像:仕上がりのスタート画面)


ユズ運びおじさんと、小学生ギャルのゆずちゃんが
ユズを投げ合うバトルゲームです。

中央のラインで陣地が分かれていて、
ユズをキャッチして相手に投げつけます。
上手く当てて、10ポイント先取で勝利となります。

(画像:完成版のゲーム画面)


ゲームには、3種類の必殺ショットがあります。

ユズをキャッチしてゲージが赤くなるまで持ち続け、
その状態で必殺ショットボタンを押すと発動します。

① カーブショット(Fキー)

予測しづらい軌道で大きくカーブし、相手を翻弄します。

(画像:必殺① カーブ)



② 巨大化ショット(Jキー)

ユズが一気に巨大化して飛んでいくため、避けるのがとても大変です。

(画像:必殺② 巨大化)



③ 超高速ショット(スペースキー)

ユズが光のような速さで一直線に飛びます。
刺さるようなスピードで、相手は反応できません。

(画像:必殺③ 超高速)


ゲーム終了画面では、
勝っても負けてもゆずちゃんのギャルコメントが楽しめます。

その時々の表情やセリフが変わるので、何度遊んでも新しい発見があります。

(画像:ゲーム終了)


■ コードのポイント

今回の記事では、
AI と相談しながら作った「ユズフェスティバル~ドッジ&キャッチ~」 の HTML コードを公開します。

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

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

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

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

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

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

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

🌿■ 操作方法

このゲームは、キーボードとマウスを使って遊びます。
タイミングが大事になるので、
ちょっぴり集中力が試されますよ〜!(>v<*)

● 基本の操作

  • 移動:
    十字キー または W / A / S / D キー おじさんを上下左右に動かして、
    飛んでくるユズの正面に回り込みます。
  • キャッチ:
    ユズが当たるタイミングで マウスクリック(押したまま)
    ここが一番のポイント!早すぎても遅すぎてもキャッチできません。
    当たる「直前〜瞬間」にクリックすると、
    円がパッと光ってキャッチ成功です。(`・ω・´)
  • ショット:
    マウスカーソルで狙いをつけて クリックを離す
    キャッチしたユズを、相手の隙を狙って投げ返します。

🌿■ 勝負の鍵!「3秒ルール」と「チャージ」

ユズ村のドッジ祭りには、ちょっとスリリングなルールがあります。(=v=*)

  1. 長く持つほどスピードアップ!(チャージ)
    ユズをキャッチしたまま待つと、投げ返すユズがどんどん加速します。
    最大で 2.5倍 の超スピードに!(@o@*)
  2. 3秒以上持ち続けるとペナルティ!
    頭上のゲージが空になると(3秒経過)、
    持っていたユズが強制的にゆずちゃんへ飛んでいきます。
    欲張りすぎには注意です!(>_<)

🌿■ 一発逆転!3つの「必殺ショット」

頭上のタイマーゲージが赤くなると、ピンチであり大チャンスでもあります。
赤ゲージの間に特定のキーを押してからマウスを離すと、
絶対にキャッチされない必殺ショットが発動します!(≧▽≦)

押すキー必殺ショット名効果
Fキーカーブショット左右に鋭く曲がる予測不能な魔球!
Jキー巨大化ショットユズが3倍サイズに巨大化!避けるのが大変。
スペースキー超高速ショット電光石火のスピードで突き進む弾丸ユズ!

※必殺ショットはキャッチされませんが、
壁に当たると通常の黄色いユズに戻ります。

壁に反射させず、まっすぐゆずちゃんを狙うのがコツです。(^_^)

🌿■ 選べる難易度と勝利条件

タイトル画面では、 EASY / NORMAL / HARD の3つから難易度を選べます。

ゲームは 10ポイント先取 で勝敗が決まります。

HARDモードのゆずちゃんは、
必殺ショットをどんどん撃ってくる最強のギャルになります。(;゚Д゚)

まずは EASY でタイミングキャッチの練習をしてから、
ぜひ天才少女に挑んでみてくださいね。

それでは、ユズ村の熱いドッジ祭りへ行ってらっしゃいませ〜!(^^*)/


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

▼ここからコード▼(クリックで開く)
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Yuzu Festival: Dodge & Catch</title>
<style>
body {
margin: 0;
background-color: #fdf6e3;
color: #333;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
font-family: 'ヒラギノ丸ゴ ProN', 'Hiragino Maru Gothic ProN', 'メイリオ', Meiryo, sans-serif;
overflow: hidden;
}
/* メインレイアウト(ゲーム画面+説明パネル) */
#main-layout {
display: flex;
gap: 20px;
align-items: flex-end;
}
#game-wrap {
display: flex;
flex-direction: column;
width: 800px;
}
canvas {
background-color: #fff9e6;
box-shadow: 0 10px 30px rgba(0,0,0,0.15);
border-radius: 8px;
cursor: crosshair;
}
#score-board {
display: flex;
justify-content: space-between;
width: 100%;
font-size: 28px;
font-weight: bold;
margin-bottom: 10px;
}
.player-score { color: #27ae60; }
.enemy-score { color: #e84393; }
/* 操作説明パネル */
.instruction-panel {
width: 260px;
height: 600px;
background-color: #fff9e6;
border: 4px dashed #ff9f43;
border-radius: 8px;
padding: 20px;
box-sizing: border-box;
box-shadow: 0 10px 30px rgba(0,0,0,0.15);
overflow-y: auto;
}
.instruction-panel h3 {
margin-top: 0;
color: #e84393;
text-align: center;
border-bottom: 2px solid #fdcb6e;
padding-bottom: 10px;
font-size: 22px;
}
.inst-section {
margin-bottom: 20px;
}
.inst-section h4 {
margin: 0 0 8px 0;
color: #27ae60;
font-size: 16px;
}
.inst-section p {
margin: 0 0 5px 0;
font-size: 14px;
line-height: 1.6;
}
.inst-section ul {
margin: 5px 0 0 0;
padding-left: 20px;
font-size: 13px;
line-height: 1.6;
}
.highlight {
font-weight: bold;
color: #e74c3c;
}
.key-box {
display: inline-block;
background-color: #dcdde1;
padding: 2px 6px;
border-radius: 4px;
font-size: 12px;
border-bottom: 2px solid #7f8fa6;
font-weight: bold;
}
</style>
</head>
<body>
<div id="main-layout">
<div id="game-wrap">
<div id="score-board">
<div class="player-score">おじさん: <span id="p-score">0</span></div>
<div class="enemy-score">天才少女ゆず: <span id="e-score">0</span></div>
</div>
<canvas id="gameCanvas" width="800" height="600"></canvas>
</div>
<div class="instruction-panel">
<h3>📝 遊び方</h3>
<div class="inst-section">
<h4>🎯 基本のそうさ</h4>
<p><b>【いどう】</b><br><span class="key-box">W</span> <span class="key-box">A</span> <span class="key-box">S</span> <span class="key-box">D</span> & 十字キー</p>
<p><b>【キャッチ】</b><br>当たる瞬間に <span class="highlight">マウス長押し</span><br>(早すぎても遅くてもダメ!)</p>
<p><b>【なげる】</b><br>ねらいをつけて <span class="highlight">クリックをはなす</span></p>
</div>
<div class="inst-section">
<h4>⚡ チャージとルール</h4>
<p>長く持つほどスピードUP!<br>でも <span class="highlight">3秒</span> 持ち続けると、相手にボールをとられちゃうぞ!</p>
</div>
<div class="inst-section">
<h4>🔥 必殺ショット</h4>
<p>頭上のゲージが<b>赤色</b>のときに、下のキーを押しながら投げる!</p>
<ul>
<li><span class="key-box">F</span> <b>カーブ</b>(曲がる!)</li>
<li><span class="key-box">J</span> <b>きょだい化</b>(デカい!)</li>
<li><span class="key-box">Space</span> <b>超高速</b>(速い!)</li>
</ul>
</div>
</div>
</div>
<script>
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
const pScoreEl = document.getElementById('p-score');
const eScoreEl = document.getElementById('e-score');
const keys = {};
const mouse = { x: 400, y: 300, isDown: false, justPressed: false };
// --- Web Audio API (BGM & SE) ---
let audioCtx = null;
function initAudio() {
if (!audioCtx) {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
}
if (audioCtx.state === 'suspended') {
audioCtx.resume();
}
}
function playTone(freq, type, duration, vol, slideFreq = null) {
if (!audioCtx) return;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = type;
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.frequency.setValueAtTime(freq, audioCtx.currentTime);
if (slideFreq) {
osc.frequency.exponentialRampToValueAtTime(slideFreq, audioCtx.currentTime + duration);
}
gain.gain.setValueAtTime(vol, audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + duration);
osc.start();
osc.stop(audioCtx.currentTime + duration);
}
const se = {
catch: () => playTone(600, 'sine', 0.1, 0.3, 800),
throw: () => playTone(400, 'square', 0.1, 0.1, 200),
hit: () => playTone(150, 'sawtooth', 0.3, 0.3, 50),
charge: () => playTone(800, 'triangle', 0.1, 0.1, 1000),
fast: () => playTone(1200, 'square', 0.3, 0.2, 2000),
giant: () => playTone(100, 'square', 0.4, 0.4, 40),
curve: () => {
if (!audioCtx) return;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(600, audioCtx.currentTime);
osc.frequency.linearRampToValueAtTime(800, audioCtx.currentTime + 0.1);
osc.frequency.linearRampToValueAtTime(400, audioCtx.currentTime + 0.2);
osc.connect(gain);
gain.connect(audioCtx.destination);
gain.gain.setValueAtTime(0.3, audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.3);
osc.start();
osc.stop(audioCtx.currentTime + 0.3);
}
};
const melody = [
659.25, 659.25, 783.99, 659.25,
587.33, 587.33, 659.25, 0,
880.00, 880.00, 783.99, 659.25,
587.33, 659.25, 440.00, 0
];
let nextNoteTime = 0;
let currentNote = 0;
function scheduleBGM() {
if (!audioCtx || gameState === 'TITLE') return;
while (nextNoteTime < audioCtx.currentTime + 0.1) {
const freq = melody[currentNote];
if (freq > 0) {
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = 'square';
osc.frequency.value = freq;
if(gameState === 'GAMEOVER') osc.frequency.value = freq * 0.5;
osc.connect(gain);
gain.connect(audioCtx.destination);
gain.gain.setValueAtTime(0.05, nextNoteTime);
gain.gain.exponentialRampToValueAtTime(0.001, nextNoteTime + 0.2);
osc.start(nextNoteTime);
osc.stop(nextNoteTime + 0.2);
}
nextNoteTime += 0.2;
currentNote = (currentNote + 1) % melody.length;
}
}
// --- 難易度設定 ---
let currentDifficulty = 'NORMAL';
let diffConfig = { speed: 5, missFast: 0.35, missNormal: 0.05, specialChance: 0.3 };
function setDifficulty(level) {
currentDifficulty = level;
if (level === 'EASY') {
diffConfig = { speed: 3.5, missFast: 0.6, missNormal: 0.15, specialChance: 0 };
} else if (level === 'NORMAL') {
diffConfig = { speed: 5, missFast: 0.35, missNormal: 0.05, specialChance: 0.3 };
} else if (level === 'HARD') {
diffConfig = { speed: 6.5, missFast: 0.15, missNormal: 0.01, specialChance: 0.7 };
}
}
// --- ゆずちゃんのセリフ集 ---
const yuzuQuotesWin = [
"よゆーすぎ!おじさんおつ〜✌️",
"うちの圧勝っしょ!もっと本気出せば?草",
"え、もう終わり?チョロすぎなんですけどー",
"ゆずの神エイム見た?まじエグいっしょ!",
"はい論破!ゆずの完全勝利〜✨"
];
const yuzuQuotesLose = [
"まじありえなーい!おじさん空気読めし!",
"は?今の絶対バグっしょ!萎え〜",
"ちょっと本気出しすぎただけだし!",
"おじさんのくせに強すぎ!てかウザっ!",
"ぴえん🥺 次は絶対勝つし!"
];
let currentQuote = "";
// --- 入力処理 ---
window.addEventListener('keydown', e => {
keys[e.code] = true;
if(['Space', 'ArrowUp', 'ArrowDown'].includes(e.code)) e.preventDefault();
});
window.addEventListener('keyup', e => keys[e.code] = false);
canvas.addEventListener('mousemove', e => {
const rect = canvas.getBoundingClientRect();
mouse.x = e.clientX - rect.left;
mouse.y = e.clientY - rect.top;
});
canvas.addEventListener('mousedown', () => {
const cx = mouse.x, cy = mouse.y;
if (gameState === 'TITLE') {
if (cy >= 400 && cy <= 450) {
if (cx >= 180 && cx <= 280) setDifficulty('EASY');
else if (cx >= 350 && cx <= 450) setDifficulty('NORMAL');
else if (cx >= 520 && cx <= 620) setDifficulty('HARD');
else return;
initAudio();
nextNoteTime = audioCtx.currentTime + 0.1;
initGame();
}
return;
}
if (gameState === 'GAMEOVER') {
if (cy >= 450 && cy <= 500) {
if (cx >= 240 && cx <= 380) {
initAudio();
nextNoteTime = audioCtx.currentTime + 0.1;
initGame();
} else if (cx >= 420 && cx <= 560) {
gameState = 'TITLE';
}
}
return;
}
mouse.isDown = true;
mouse.justPressed = true;
});
canvas.addEventListener('mouseup', () => { mouse.isDown = false; });
let player, enemy, ball, gameState = 'TITLE';
const HOLD_LIMIT = 3000;
const WIN_SCORE = 10;
function initGame() {
player = { x: 200, y: 300, radius: 24, speed: 6, score: 0, color: '#ffeaa7', catchTimer: 0 };
enemy = { x: 600, y: 300, radius: 22, speed: diffConfig.speed, score: 0, color: '#ffeaa7', targetY: 300, catchTimer: 0, catchCooldown: 0, throwTimer: 0 };
ball = { x: 400, y: 300, radius: 14, normalRadius: 14, vx: 0, vy: 0, baseSpeed: 12, state: 'FREE', holdTime: 0, special: 'none', curveDir: 1, history: [] };
gameState = 'PLAYING';
currentQuote = "";
pScoreEl.innerText = player.score;
eScoreEl.innerText = enemy.score;
resetBall(Math.random() > 0.5 ? 'player' : 'enemy');
}
function resetBall(scorer) {
ball.x = canvas.width / 2;
ball.y = canvas.height / 2;
ball.state = 'FREE';
ball.special = 'none';
ball.radius = ball.normalRadius;
ball.history = [];
const isPlayerScored = (scorer === 'player');
const angle = isPlayerScored
? (Math.random() * Math.PI/2) - Math.PI/4
: (Math.random() * Math.PI/2) - Math.PI/4 + Math.PI;
ball.vx = Math.cos(angle) * (ball.baseSpeed * 0.7);
ball.vy = Math.sin(angle) * (ball.baseSpeed * 0.7);
}
function addScore(scorer) {
se.hit();
if (scorer === 'player') {
player.score++;
pScoreEl.innerText = player.score;
} else {
enemy.score++;
eScoreEl.innerText = enemy.score;
}
if (player.score >= WIN_SCORE || enemy.score >= WIN_SCORE) {
gameState = 'GAMEOVER';
if (player.score >= WIN_SCORE) {
currentQuote = yuzuQuotesLose[Math.floor(Math.random() * yuzuQuotesLose.length)];
} else {
currentQuote = yuzuQuotesWin[Math.floor(Math.random() * yuzuQuotesWin.length)];
}
} else {
resetBall(scorer);
}
}
function update() {
scheduleBGM();
if (gameState !== 'PLAYING') return;
// プレイヤー移動
if (keys['ArrowUp'] || keys['KeyW']) player.y -= player.speed;
if (keys['ArrowDown'] || keys['KeyS']) player.y += player.speed;
if (keys['ArrowLeft'] || keys['KeyA']) player.x -= player.speed;
if (keys['ArrowRight'] || keys['KeyD']) player.x += player.speed;
player.x = Math.max(player.radius, Math.min(canvas.width / 2 - player.radius, player.x));
player.y = Math.max(player.radius, Math.min(canvas.height - player.radius, player.y));
if (mouse.justPressed && ball.state === 'FREE') {
player.catchTimer = 15;
se.catch();
}
if (player.catchTimer > 0) player.catchTimer--;
// 敵AI
if (ball.state === 'FREE' && ball.vx > 0) {
if (ball.special !== 'none') {
if (ball.y < canvas.height / 2) enemy.targetY = canvas.height - enemy.radius;
else enemy.targetY = enemy.radius;
} else {
enemy.targetY = ball.y;
const dist = Math.hypot(ball.x - enemy.x, ball.y - enemy.y);
const ballSpeed = Math.hypot(ball.vx, ball.vy);
const framesToImpact = dist / ballSpeed;
if (framesToImpact < 12 && enemy.catchTimer === 0 && enemy.catchCooldown <= 0) {
const isFast = ballSpeed > ball.baseSpeed * 1.5;
const missChance = isFast ? diffConfig.missFast : diffConfig.missNormal;
if (Math.random() > missChance) {
enemy.catchTimer = 15;
se.catch();
}
enemy.catchCooldown = 30;
}
}
} else if (ball.state === 'PLAYER') {
enemy.targetY = canvas.height / 2;
}
if (enemy.y < enemy.targetY - 10) enemy.y += enemy.speed;
if (enemy.y > enemy.targetY + 10) enemy.y -= enemy.speed;
enemy.x = Math.max(canvas.width / 2 + enemy.radius, Math.min(canvas.width - enemy.radius, enemy.x));
enemy.y = Math.max(enemy.radius, Math.min(canvas.height - enemy.radius, enemy.y));
if (enemy.catchTimer > 0) enemy.catchTimer--;
if (enemy.catchCooldown > 0) enemy.catchCooldown--;
// ボール処理
if (ball.state === 'FREE') {
if (ball.special !== 'none') {
ball.history.push({ x: ball.x, y: ball.y, radius: ball.radius });
if (ball.history.length > 8) ball.history.shift();
} else {
ball.history = [];
}
if (ball.special === 'curve') {
const curveAngle = 0.04 * ball.curveDir;
const cos = Math.cos(curveAngle);
const sin = Math.sin(curveAngle);
const newVx = ball.vx * cos - ball.vy * sin;
const newVy = ball.vx * sin + ball.vy * cos;
ball.vx = newVx;
ball.vy = newVy;
}
ball.x += ball.vx;
ball.y += ball.vy;
let bounced = false;
if (ball.y <= ball.radius) { ball.y = ball.radius; ball.vy *= -1; bounced = true; }
else if (ball.y >= canvas.height - ball.radius) { ball.y = canvas.height - ball.radius; ball.vy *= -1; bounced = true; }
if (ball.x <= ball.radius) { ball.x = ball.radius; ball.vx *= -1; bounced = true; }
else if (ball.x >= canvas.width - ball.radius) { ball.x = canvas.width - ball.radius; ball.vx *= -1; bounced = true; }
if (bounced) {
if (ball.special !== 'none') {
ball.special = 'none';
ball.radius = ball.normalRadius;
ball.history = [];
const currentSpeed = Math.hypot(ball.vx, ball.vy);
if (currentSpeed > ball.baseSpeed * 1.5) {
const ratio = (ball.baseSpeed * 1.5) / currentSpeed;
ball.vx *= ratio;
ball.vy *= ratio;
}
}
const currentSpeed = Math.hypot(ball.vx, ball.vy);
const newSpeed = Math.max(currentSpeed * 0.8, 4);
const ratio = newSpeed / currentSpeed;
ball.vx *= ratio;
ball.vy *= ratio;
}
const distP = Math.hypot(ball.x - player.x, ball.y - player.y);
if (distP < player.radius + ball.radius) {
if (player.catchTimer > 0 && ball.special === 'none') {
ball.state = 'PLAYER';
ball.holdTime = Date.now();
} else {
addScore('enemy');
}
}
const distE = Math.hypot(ball.x - enemy.x, ball.y - enemy.y);
if (distE < enemy.radius + ball.radius) {
if (enemy.catchTimer > 0 && ball.special === 'none') {
ball.state = 'ENEMY';
ball.holdTime = Date.now();
if (Math.random() < diffConfig.specialChance) {
enemy.throwTimer = Date.now() + 2200 + Math.random() * 500;
} else {
enemy.throwTimer = Date.now() + 500 + Math.random() * 1000;
}
} else {
addScore('player');
}
}
if (ball.x > canvas.width + ball.radius) addScore('player');
if (ball.x < -ball.radius) addScore('enemy');
} else if (ball.state === 'PLAYER') {
ball.x = player.x;
ball.y = player.y;
ball.history = [];
const heldTime = Date.now() - ball.holdTime;
const isRedGauge = (heldTime / HOLD_LIMIT) > 0.7;
let shotFired = false;
let specialTriggered = 'none';
if (isRedGauge && Math.random() < 0.1) se.charge();
if (heldTime > HOLD_LIMIT) {
const angle = Math.atan2(enemy.y - player.y, enemy.x - player.x);
ball.vx = Math.cos(angle) * ball.baseSpeed;
ball.vy = Math.sin(angle) * ball.baseSpeed;
ball.x = player.x + Math.cos(angle) * (player.radius + ball.radius + 2);
ball.y = player.y + Math.sin(angle) * (player.radius + ball.radius + 2);
ball.state = 'FREE';
se.throw();
} else {
if (isRedGauge) {
if (keys['KeyF']) { shotFired = true; specialTriggered = 'curve'; keys['KeyF'] = false; }
else if (keys['KeyJ']) { shotFired = true; specialTriggered = 'giant'; keys['KeyJ'] = false; }
else if (keys['Space']) { shotFired = true; specialTriggered = 'fast'; keys['Space'] = false; }
}
if (!mouse.isDown && !mouse.justPressed && !shotFired) {
shotFired = true;
}
if (shotFired) {
const angle = Math.atan2(mouse.y - player.y, mouse.x - player.x);
let finalSpeed = ball.baseSpeed * (1 + (heldTime / HOLD_LIMIT) * 1.5);
ball.special = specialTriggered;
ball.radius = ball.normalRadius;
if (specialTriggered === 'curve') {
ball.curveDir = Math.random() > 0.5 ? 1 : -1;
se.curve();
} else if (specialTriggered === 'giant') {
ball.radius = ball.normalRadius * 3;
se.giant();
} else if (specialTriggered === 'fast') {
finalSpeed = ball.baseSpeed * 3.5;
se.fast();
} else {
se.throw();
}
ball.vx = Math.cos(angle) * finalSpeed;
ball.vy = Math.sin(angle) * finalSpeed;
ball.x = player.x + Math.cos(angle) * (player.radius + ball.radius + 2);
ball.y = player.y + Math.sin(angle) * (player.radius + ball.radius + 2);
ball.state = 'FREE';
}
}
} else if (ball.state === 'ENEMY') {
ball.x = enemy.x;
ball.y = enemy.y;
const heldTime = Date.now() - ball.holdTime;
const isRedGauge = (heldTime / HOLD_LIMIT) > 0.7;
if (isRedGauge && Math.random() < 0.1) se.charge();
if (heldTime > HOLD_LIMIT) {
const angle = Math.atan2(player.y - enemy.y, player.x - enemy.x);
ball.vx = Math.cos(angle) * ball.baseSpeed;
ball.vy = Math.sin(angle) * ball.baseSpeed;
ball.x = enemy.x + Math.cos(angle) * (enemy.radius + ball.radius + 2);
ball.y = enemy.y + Math.sin(angle) * (enemy.radius + ball.radius + 2);
ball.state = 'FREE';
se.throw();
} else if (Date.now() > enemy.throwTimer) {
const angle = Math.atan2(player.y - enemy.y, player.x - enemy.x) + (Math.random() - 0.5) * 0.3;
let finalSpeed = ball.baseSpeed * (1 + (heldTime / HOLD_LIMIT) * 0.5);
ball.special = 'none';
ball.radius = ball.normalRadius;
if (heldTime > 2100) {
const specials = ['curve', 'giant', 'fast'];
const sp = specials[Math.floor(Math.random() * specials.length)];
ball.special = sp;
if (sp === 'curve') {
ball.curveDir = Math.random() > 0.5 ? 1 : -1;
se.curve();
} else if (sp === 'giant') {
ball.radius = ball.normalRadius * 3;
se.giant();
} else if (sp === 'fast') {
finalSpeed = ball.baseSpeed * 3.5;
se.fast();
}
} else {
se.throw();
}
ball.vx = Math.cos(angle) * finalSpeed;
ball.vy = Math.sin(angle) * finalSpeed;
ball.x = enemy.x + Math.cos(angle) * (enemy.radius + ball.radius + 2);
ball.y = enemy.y + Math.sin(angle) * (enemy.radius + ball.radius + 2);
ball.state = 'FREE';
}
}
mouse.justPressed = false;
}
function drawBtn(x, y, w, h, text, color) {
const isHover = mouse.x >= x && mouse.x <= x + w && mouse.y >= y && mouse.y <= y + h;
ctx.fillStyle = isHover ? '#2c3e50' : color;
ctx.beginPath();
if (ctx.roundRect) {
ctx.roundRect(x, y, w, h, 8);
} else {
ctx.fillRect(x, y, w, h);
}
ctx.fill();
ctx.fillStyle = '#fff';
ctx.font = 'bold 20px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(text, x + w / 2, y + h / 2);
}
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// --- 背景装飾 ---
ctx.strokeStyle = '#ff9f43';
ctx.lineWidth = 6;
ctx.setLineDash([15, 20]);
ctx.beginPath();
ctx.moveTo(canvas.width / 2, 0);
ctx.lineTo(canvas.width / 2, canvas.height);
ctx.stroke();
ctx.setLineDash([]);
const colors = ['#ff4757', '#1e90ff', '#2ed573', '#ffa502', '#9b59b6'];
for(let i = 0; i < 20; i++) {
ctx.fillStyle = colors[i % colors.length];
ctx.beginPath();
ctx.moveTo(i * 40, 0);
ctx.lineTo(i * 40 + 40, 0);
ctx.lineTo(i * 40 + 20, 30);
ctx.fill();
}
if (gameState === 'TITLE') {
ctx.fillStyle = 'rgba(255, 255, 255, 0.9)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.textAlign = 'center';
ctx.textBaseline = 'alphabetic';
ctx.fillStyle = '#e1b12c';
ctx.font = 'bold 64px sans-serif';
ctx.fillText('ユズ村のドッジ祭り', canvas.width / 2, canvas.height / 2 - 80);
ctx.fillStyle = '#e84393';
ctx.font = 'bold 24px sans-serif';
ctx.fillText('おじさん VS 天才少女ゆず', canvas.width / 2, canvas.height / 2 - 20);
drawBtn(180, 400, 100, 50, 'EASY', '#2ecc71');
drawBtn(350, 400, 100, 50, 'NORMAL', '#f1c40f');
drawBtn(520, 400, 100, 50, 'HARD', '#e74c3c');
ctx.fillStyle = `rgba(51, 51, 51, ${Math.abs(Math.sin(Date.now() / 300))})`;
ctx.font = '18px sans-serif';
ctx.fillText('難易度をクリックしてスタート(音が出ます)', canvas.width / 2, canvas.height / 2 + 220);
return;
}
// --- キャラクターの表情 ---
let pFace = '^v^';
if (gameState === 'GAMEOVER') pFace = player.score >= WIN_SCORE ? ' ゚Д゚ b' : 'T_T';
else if (player.catchTimer > 0) pFace = '`・ω・´';
else if (ball.state === 'PLAYER' && Date.now() - ball.holdTime > HOLD_LIMIT * 0.7) pFace = ';゚Д゚';
let eFace = '^▽^';
if (gameState === 'GAMEOVER') eFace = enemy.score >= WIN_SCORE ? '^▽^' : '>_<';
else if (enemy.catchTimer > 0) eFace = '`・ω・´';
else if (ball.state === 'ENEMY' && Date.now() - ball.holdTime > HOLD_LIMIT * 0.7) eFace = ' ̄ー ̄';
// --- おじさん ---
ctx.fillStyle = player.color;
ctx.beginPath();
ctx.arc(player.x, player.y, player.radius, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#e1b12c';
ctx.beginPath();
ctx.ellipse(player.x, player.y - player.radius * 0.5, player.radius * 1.6, player.radius * 0.3, 0, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(player.x, player.y - player.radius * 0.5, player.radius * 0.9, Math.PI, 0);
ctx.fill();
ctx.fillStyle = '#dcdde1';
ctx.fillRect(player.x - player.radius * 0.9, player.y - player.radius * 0.5 - 6, player.radius * 1.8, 6);
ctx.fillStyle = '#2f3640';
ctx.font = 'bold 16px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(pFace, player.x, player.y + 4);
if (player.catchTimer > 0) {
ctx.strokeStyle = 'rgba(46, 204, 113, 0.8)';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.arc(player.x, player.y, player.radius + 15 - player.catchTimer, 0, Math.PI * 2);
ctx.stroke();
}
// --- ゆず ---
ctx.fillStyle = '#e84393';
ctx.beginPath();
ctx.arc(enemy.x - enemy.radius * 0.9, enemy.y - enemy.radius * 0.3, enemy.radius * 0.6, 0, Math.PI * 2);
ctx.arc(enemy.x + enemy.radius * 0.9, enemy.y - enemy.radius * 0.3, enemy.radius * 0.6, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = enemy.color;
ctx.beginPath();
ctx.arc(enemy.x, enemy.y, enemy.radius, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#fdcb6e';
ctx.lineWidth = 4;
ctx.beginPath();
ctx.arc(enemy.x, enemy.y, enemy.radius, Math.PI * 1.1, Math.PI * 1.9);
ctx.stroke();
ctx.fillStyle = '#2f3640';
ctx.font = 'bold 16px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(eFace, enemy.x, enemy.y + 4);
if (enemy.catchTimer > 0) {
ctx.strokeStyle = 'rgba(232, 67, 147, 0.8)';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.arc(enemy.x, enemy.y, enemy.radius + 15 - enemy.catchTimer, 0, Math.PI * 2);
ctx.stroke();
}
// 照準とゲージ(プレイヤー)
if (ball.state === 'PLAYER') {
const heldTime = Date.now() - ball.holdTime;
const ratio = Math.min(1, heldTime / HOLD_LIMIT);
const timeLeftRatio = Math.max(0, 1 - ratio);
const isRedGauge = timeLeftRatio < 0.3;
ctx.strokeStyle = isRedGauge ? `rgba(255, 50, 50, ${0.5 + Math.sin(Date.now()/50)*0.5})` : `rgba(46, 204, 113, 0.5)`;
ctx.lineWidth = 2 + (ratio * 3);
ctx.beginPath();
ctx.moveTo(player.x, player.y);
ctx.lineTo(mouse.x, mouse.y);
ctx.stroke();
ctx.fillStyle = isRedGauge ? '#e74c3c' : '#2ecc71';
ctx.fillRect(player.x - 20, player.y - 35, 40 * timeLeftRatio, 6);
}
// 照準とゲージ(ゆず)
else if (ball.state === 'ENEMY') {
const heldTime = Date.now() - ball.holdTime;
const ratio = Math.min(1, heldTime / HOLD_LIMIT);
const timeLeftRatio = Math.max(0, 1 - ratio);
const isRedGauge = timeLeftRatio < 0.3;
ctx.strokeStyle = isRedGauge ? `rgba(255, 50, 50, ${0.5 + Math.sin(Date.now()/50)*0.5})` : `rgba(232, 67, 147, 0.5)`;
ctx.lineWidth = 2 + (ratio * 3);
ctx.beginPath();
ctx.moveTo(enemy.x, enemy.y);
ctx.lineTo(player.x, player.y);
ctx.stroke();
ctx.fillStyle = isRedGauge ? '#e74c3c' : '#e84393';
ctx.fillRect(enemy.x - 20, enemy.y - 35, 40 * timeLeftRatio, 6);
}
// 残像描画
if (ball.history && ball.history.length > 0) {
for (let i = 0; i < ball.history.length; i++) {
const pos = ball.history[i];
const alpha = (i + 1) / ball.history.length * 0.4;
ctx.fillStyle = `rgba(231, 76, 60, ${alpha})`;
ctx.beginPath();
ctx.arc(pos.x, pos.y, pos.radius, 0, Math.PI * 2);
ctx.fill();
}
}
// ユズボール
const isSpecial = ball.special !== 'none';
if (isSpecial) {
ctx.shadowBlur = 15;
ctx.shadowColor = '#e74c3c';
ctx.fillStyle = '#ff7675';
} else {
ctx.shadowBlur = 0;
ctx.fillStyle = '#f1c40f';
}
ctx.beginPath();
ctx.arc(ball.x, ball.y, ball.radius, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
ctx.fillStyle = '#27ae60';
ctx.fillRect(ball.x - 3, ball.y - ball.radius - 4, 6, 6);
ctx.beginPath();
ctx.ellipse(ball.x + 8, ball.y - ball.radius - 2, 8, 4, Math.PI / 6, 0, Math.PI * 2);
ctx.fill();
// ゲームオーバー画面
if (gameState === 'GAMEOVER') {
ctx.fillStyle = 'rgba(255, 255, 255, 0.85)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.textAlign = 'center';
ctx.textBaseline = 'alphabetic';
ctx.font = 'bold 56px sans-serif';
if (player.score >= WIN_SCORE) {
ctx.fillStyle = '#27ae60';
ctx.fillText('おじさんの勝利!', canvas.width / 2, canvas.height / 2 - 80);
} else {
ctx.fillStyle = '#e84393';
ctx.fillText('ゆずの勝利!', canvas.width / 2, canvas.height / 2 - 80);
}
const faceX = canvas.width / 2 - 200;
const faceY = canvas.height / 2 + 10;
ctx.fillStyle = '#e84393';
ctx.beginPath();
ctx.arc(faceX - 25, faceY - 10, 18, 0, Math.PI * 2);
ctx.arc(faceX + 25, faceY - 10, 18, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = enemy.color;
ctx.beginPath();
ctx.arc(faceX, faceY, 30, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#fdcb6e';
ctx.lineWidth = 4;
ctx.beginPath();
ctx.arc(faceX, faceY, 30, Math.PI * 1.1, Math.PI * 1.9);
ctx.stroke();
ctx.fillStyle = '#2f3640';
ctx.font = 'bold 20px sans-serif';
ctx.textBaseline = 'middle';
ctx.fillText(player.score >= WIN_SCORE ? '>_<' : '^▽^', faceX, faceY + 5);
ctx.fillStyle = '#fff';
ctx.strokeStyle = '#e84393';
ctx.lineWidth = 3;
ctx.beginPath();
if (ctx.roundRect) {
ctx.roundRect(canvas.width / 2 - 130, canvas.height / 2 - 30, 360, 60, 15);
} else {
ctx.fillRect(canvas.width / 2 - 130, canvas.height / 2 - 30, 360, 60);
}
ctx.fill();
ctx.stroke();
ctx.beginPath();
ctx.moveTo(canvas.width / 2 - 130, canvas.height / 2);
ctx.lineTo(faceX + 35, faceY + 5);
ctx.lineTo(canvas.width / 2 - 100, canvas.height / 2 + 20);
ctx.fill();
ctx.stroke();
ctx.fillStyle = '#e84393';
ctx.font = 'bold 20px sans-serif';
ctx.fillText(currentQuote, canvas.width / 2 + 50, canvas.height / 2);
drawBtn(240, 450, 140, 50, 'RETRY', '#2ecc71');
drawBtn(420, 450, 140, 50, 'TITLE', '#f1c40f');
}
}
function loop() {
update();
draw();
requestAnimationFrame(loop);
}
loop();
</script>
</body>
</html>


■ 今日の学び

今回のゲーム制作は、最初からテーマを決めるのではなく、

まずは、
「ドッジボールとブロック崩しを掛け合わせたアクションゲーム」という
システムの土台づくりからスタートしました。

作っていくうちに、
最終的には エアホッケーとドッジボールを合わせたような
ゲーム性へと進化していきました。

マウスで的確にキャッチしつつ、キーボードで素早く陣地を移動する——

最初は少し忙しく感じる操作ですが、
指が慣れてくると 「ここだ!」という絶妙なタイミングで
チャージショットを打ち込めるようになり、

手に汗握る白熱の試合へと変わっていきます。(っ’-‘)╮=͟͟͞͞ 🍊

そして、システムがしっかり完成したあとに付け足したのが
「ユズ」と「お祭り」の世界観です。

主人公は、#81「ユズ運び職人」で一輪車を押していた、
あのユズ運びのおっちゃん。

どんな剛速球が飛んできてもニコニコ笑顔(^v^)を崩さずに
ユズを投げます。

対戦相手は、#30「エクレアジター弾幕シューティング」に登場した
小学生ギャル・ゆずちゃん。

実は彼女、
もともとはブログ未公開の「サウンドノベル風ゲーム」の
主人公として生まれたキャラクターでした。

異世界へ転生して活躍したり、選択次第でバッドエンドを迎えたり……
なかなか数奇な運命を背負っています。

そのあたりの詳しい転生の物語は、また別の機会にゆっくり紹介しますね。

今回はそんな彼女が、
ドッジ祭りの「前回優勝者の天才少女」として堂々の参戦を果たしました。

生まれた背景も世界観もまったく違うキャラクターたちですが、
こうして一つの画面に集めて戦わせてみると、
不思議と違和感なく「ユズ村のドッジ祭り」という作品にまとまりました。

過去の実験で作った設定やキャラクターが、
思わぬところでクロスオーバーして新しい魅力につながっていく——。

たくさん作り続けてきたからこそ味わえる面白さと、
ゲーム作りの自由さを改めて実感できた、
とても充実した実験になりました!꜀( ꜆ ᐛ )꜆

■ 次回は、連鎖が楽しいチェス風爆発ゲーム

次回の実験では、 連鎖爆発ゲームに
チェスの駒の特徴 を組み合わせてみました。

それぞれの駒が持つ動き方を爆発範囲に反映させることで、
「どこに置くか」「どんな連鎖を狙うか」など、
ちょっとした戦略を考える楽しさが生まれました。

気持ちよく連鎖がつながった時の爽快感が心地よくて、
思わず何度も遊びたくなるゲームになっています。

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


▶ 次の実験はこちら
(爆発ゲーム × チェス × 連鎖)
「チェスバースト」



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