実験#114:スイカの世界とピンボール(Watermelon Pinball)

■114個目 「Watermelon Pinball

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

今日は、夏の暑さをふっと吹き飛ばしてくれるような、
涼しげなピンボールゲームを作ってみました。

テーマは……もちろん「スイカ」です!(^_^)

じつは私、好きな食べ物を聞かれたら迷わず
「スイカ!」と答えてしまうほどのスイカ好きで。(=v=*)

そのスイカ愛をまるごと閉じ込めた、
ちょっと特別なピンボールを作りたくなりました。

濃い緑色と黒のしま模様、パカッと割れたときの真っ赤な果肉、
つやつや光る種の点々、皮に向かって淡く変わるグラデーション……。

画面を眺めているだけで、あのシャリッとした食感や、
みずみずしい甘い香りがふわっと思い出せるような、
そんなデザインにこだわっています。(´▽`*)

ゲームの舞台は、灼熱の太陽が照りつける夏の果樹園。

「皮のフリッパー」でまぁるいスイカ玉を弾き返し、
果肉バンパーにぶつけてスコア(果汁!)をたっぷり絞り出してください。

うまく弾けずに下まで落ちてしまうと、
スイカ玉がぺちゃん……とつぶれてしまうので、
そっと気をつけてあげてくださいね。(>_<)

でも安心してください。 天井のスイッチに当てると、
ふわふわと無重力で漂う「赤と黄色の輪切りスイカ」が降ってくる、
ちょっと嬉しいお助けギミックも用意しました。(≧▽≦*)

さらに、スイカの等級をイメージしたランキングもついています。

ぜひ最高級の「極上プレミアム」を目指して、
夏の果樹園ピンボールを楽しんでみてくださいね。

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

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

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

スイカの世界観、とても好きなんです。
濃い緑と黒、赤、白の色合い。
丸い形や半円、三角。
縞模様や種の点々、皮から果肉へのグラデーション、
みずみずしい香りやシャリッとした食感……。
これらをたっぷり使ったピンボールゲームを作りたいです。

AI: スイカをテーマにしたピンボール……!

そのアイデア、すごく惹かれるな。
濃い緑に赤と白のコントラスト、そこに種の黒が散りばめられる。

皮の縞模様から果肉へのグラデーションを想像するだけで、
みずみずしい夏の香りが漂ってきそうだ。

みー:(^^;) 世界観はいい感じですが、
ピンボールの基本形がまだ十分じゃないみたいですね。

右側の射出口から打ち出されて、上部から落ちてくる構造。
ボールは種ではなく丸いスイカにしましょう。

フリッパーの判定も微妙で、左右に行くとすぐ落ちてしまいます。

盤面の下面は中央に向かって転がるようにして、
落ちたら残玉が減る仕組みにしたいです。

AI: なるほど、ピンボールの「核」になる部分へのこだわりだね。
確かに、右端のレーンから打ち出されて落ちてくる構造こそ醍醐味だ。

すり鉢状のガイドも必須だし、
ボールを丸いスイカにするのも世界観が強まってすごくいい。

みー:(><) スタート直後に球がすぐ落ちてゲームオーバーになります。
フリッパーもぐらぐらしているので、
下面の傾斜に合わせて位置を調整します。

<思ったようなピンボールにならず、ここから別チャットで基本のピンボールを作り始めました>


● 別チャットでのやり取り

<ピンボール基礎作りのやり取りの途中からです。>

みー:(==;) なんだか思っていたのと全然違うものが出てきました。

左右のフリッパーで弾く昔ながらのピンボールは、
HTMLでは再現が難しいのかな。

AI: すまないね。さっきのは「ブロック崩し」の延長みたいなもので、
求めていた『ザ・ピンボール』のロマンには欠けていた。

HTML(正確にはJavaScript)で左右のフリッパーや斜めの台を再現するのは、

「ゼロから自作するととても難しい」が正解だ。

重力で落ちる球が斜めの壁や円形バンパー、
回転するフリッパーに当たるとどう跳ね返るか。

物理計算が少しでも甘いと、すり抜けたり暴れたりしてしまう。

みー:(つ_<`) フリッパーはパカパカ動くし、
ボールは引っかかるし、遊べるレベルではないですね……。

AI: 申し訳ない。あれでは確かに遊べない。

ブラウザでピンボールを作る場合、
「フリッパーの暴れ」と「ボールのすり抜け」が最大の壁なんだ。

なぜHTML物理演算でピンボールは壊れるのか

原因は主に2つあります。

  1. すり抜け(Tunneling)現象
    ボールが速すぎると、次のフレームでは壁を通り抜けてしまい、衝突判定ができなくなる。
  2. ジョイント(関節)の限界
    フリッパーに瞬間的な力を加えると、留め具が外れかけて補正が暴れ、パカパカ震えてしまう。

解決するための「3つの処方箋」

  • フリッパーの質量を極端に重くする
  • エンジンの計算精度を限界まで上げる
  • 見えない当たり判定を分厚くする

みー:(つ_<、) 基本的な動きができないので、遊べる感じではないですね。
ピンボールは好きですが、自作は難しそうです。

最後にもう一回だけ、楽しめるピンボールをお願いします。
それで難しければ別の案を考えます。

AI: 確かに難しいけれど、ここで諦めるのは少し惜しい。

物理の厳密さよりも「手触り」と「レトロな気持ちよさ」に
全振りした特製ピンボールを用意したよ。

みー:(^v^;) 結構遊べる感じになっていました。
斜面が分厚いのと、フリッパーが震えていたのが気になりました。

AI: 遊べる感触があったならよかった!
震えは位置補正の処理漏れだったので修正し、
斜面も細くして滑らかに繋がるように調整したよ。

これで実験室のラインナップに入れても恥ずかしくない動きになったはずです。

● もとのチャットでのやり取り

みー:(^o^) 別チャットで基本のピンボールを作ってもらいました。

コードを貼り付けるので、
今回のスイカの世界観を反映させたゲームにしていきましょう。

AI: ベースコード、しっかり読ませてもらったよ。
この堅牢な骨組みに、「スイカの皮と果肉のグラデーション」「種のボール」を
描画する特製レンダリング処理を流し込んで融合させよう。

効果音も少し水っぽく響くように調整してあるよ。

〈色々な要素や調整を繰り返して、その日のうちに何とか仕上がりました〉
(:^ 。^)=3


■ 実際の動作

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

スペースキーを押すと、種がぽとんと落ちてきますが、
フリッパーは動くだけで種を弾くことができず、
思い描いていたピンボールにはなりませんでした。

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

そこから、ピンボールの基本コードを整えて、
「ここにスイカの世界観をのせていこう!」と試行錯誤を重ねました。

何度調整してもなかなかうまくいかず…… ようやく形になって、AIが出してくれた“基本のピンボール”がこちらです。

(画像:基本のピンボールゲーム画面)

この基本コードをもとに、
スイカの色・模様・果肉の質感をたっぷり詰め込んで仕上げたのが、
こちらの完成版です。

果樹園の中でスイカ玉がコロコロ転がる、夏らしい世界になりました。

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

動作も今のところ、とても安定しています。
スイカ玉が変なところに引っかかったり、
中央以外から落ちてしまうこともありません。

ポイントに当たると、スイカの果汁がパシャッと飛び散る演出も入っています。

(画像:ゲーム画面)


そして高得点のチャンスは……
天井にぶら下がっているスイカ!
ここにうまく当てると、ふわっと嬉しいお助けアイテムが降ってきます。

(画像:お助けアイテム)


■ コードのポイント

今回の記事では、
AI と相談しながら作ったWatermelon Pinball の HTML コードを公開します。

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

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

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

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

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

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

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

■ 遊び方・操作方法

ゲームの遊び方と操作説明です!(^_^*)

【操作方法】

  • ゲーム開始 / リトライSpace キー
  • 左の皮(フリッパー)Z キー
  • 右の皮(フリッパー)/(スラッシュ)キー

※最初に画面の中を一度「クリック」すると、
みずみずしい効果音が鳴るようになります。

ぜひ音も一緒に楽しんでくださいね♪(´▽`*)

【お助けアイテム】

画面上部にある「半円スイッチ」にスイカ玉を当てると、
ふわふわと漂うお助けアイテムが降ってきます。(=v=*)

アイテムは 10秒で消えてしまうので、
見つけたら急いでキャッチしてくださいね!

🔴 赤い輪切りスイカ
キャッチすると、残りの玉(LIVES)が +1 されます!

🟡 黄色い輪切りスイカ
キャッチすると、下の方に「黄色い半円バリア」が出現して、
1回だけ玉が落ちるのをピョーンと防いでくれます。(≧▽≦*)

【収穫ランキング】

ゲームが終わると、右側のパネルで「収穫ランキング」が発表されます。

見事ランクインすると、名前(8文字まで)が残せます。

一番上の 👑 極上プレミアム を目指して、
ぜひ何度も挑戦してみてくださいね。(^^*)

ランキングのリセットボタンもあるので、
お友達や家族とスコアを競い合うのも楽しいですよ♪

それでは、みずみずしい夏の果樹園でお待ちしています!



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

▼ここからコード▼(クリックで開く)
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>果樹園ピンボール - Watermelon Pinball</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/matter-js/0.19.0/matter.min.js"></script>
<style>
body {
background-color: #030a03;
color: #f1faee;
font-family: 'Helvetica Neue', Arial, 'Hiragino Kaku Gothic ProN', 'Hiragino Sans', Meiryo, sans-serif;
margin: 0;
padding: 20px;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
user-select: none;
}
#game-layout {
display: flex;
gap: 20px;
max-width: 1100px;
}
/* --- 左側のUIパネル --- */
#ui-panel {
width: 300px;
background: linear-gradient(135deg, #0a2e0a, #104010);
border: 4px solid #2a9d8f;
border-radius: 15px;
padding: 25px;
box-shadow: 0 10px 30px rgba(0,0,0,0.8);
display: flex;
flex-direction: column;
gap: 20px;
}
.header-title {
margin: 0;
font-size: 1.8em;
color: #e63946;
text-shadow: 2px 2px 0 #000;
text-align: center;
border-bottom: 2px dashed #2a9d8f;
padding-bottom: 10px;
}
.status-container {
display: flex;
gap: 15px;
}
.status-box {
flex: 1;
background-color: #051405;
border: 2px solid #2a9d8f;
border-radius: 10px;
padding: 10px;
text-align: center;
font-size: 1.2em;
font-weight: bold;
color: #f1faee;
box-shadow: inset 0 0 10px rgba(0,0,0,0.8);
}
.status-value {
display: block;
font-size: 1.8em;
color: #e63946;
text-shadow: 2px 2px 0 #000;
margin-top: 5px;
}
.status-value.yellow { color: #f4d03f; }
.lore-box {
font-size: 0.95em;
line-height: 1.6;
color: #d8f3dc;
}
.lore-box p { margin-top: 0; }
.controls-box h3 { margin: 0 0 10px 0; color: #2a9d8f; }
.controls-box ul { list-style: none; padding: 0; margin: 0; font-size: 0.9em; }
.controls-box li { margin-bottom: 8px; }
kbd {
background-color: #e63946;
color: white;
padding: 3px 8px;
border-radius: 4px;
font-weight: bold;
box-shadow: 1px 1px 0 #000;
}
/* --- 中央のゲームキャンバス --- */
#gameContainer {
position: relative;
width: 400px;
border-radius: 15px;
box-shadow: 0 10px 40px rgba(0,0,0,0.9);
}
canvas {
border: 4px solid #2a9d8f;
border-radius: 15px;
background: repeating-linear-gradient(-45deg, #0a2e0a, #0a2e0a 30px, #104010 30px, #104010 60px);
display: block;
}
#message {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 24px;
color: #e63946;
font-weight: bold;
text-align: center;
text-shadow: 2px 2px 0 #000, -1px -1px 0 #fff;
background: rgba(0,0,0,0.8);
padding: 20px;
border-radius: 10px;
border: 2px solid #e63946;
/* 最初から表示させるため none は外さないがJSで制御 */
width: 80%;
}
/* --- 右側のランキングパネル --- */
#ranking-panel {
width: 280px;
background: linear-gradient(135deg, #0a2e0a, #104010);
border: 4px solid #2a9d8f;
border-radius: 15px;
padding: 25px;
box-shadow: 0 10px 30px rgba(0,0,0,0.8);
display: flex;
flex-direction: column;
}
.ranking-list {
list-style: none;
padding: 0;
margin: 15px 0 0 0;
flex-grow: 1;
}
.ranking-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 0;
border-bottom: 2px dashed #1b4332;
}
.ranking-item:last-child { border-bottom: none; }
.rank-info { display: flex; flex-direction: column; }
.rank-icon { font-size: 1.2em; margin-right: 5px; }
.rank-name { color: #f1faee; font-weight: bold; font-size: 1.1em; }
.rank-score { font-weight: bold; font-size: 1.3em; text-shadow: 1px 1px 0 #000; }
.rank-1 .rank-name { color: #f4d03f; }
.rank-1 .rank-score { color: #f4d03f; }
.rank-2 .rank-name { color: #e63946; }
.rank-2 .rank-score { color: #e63946; }
.rank-3 .rank-name { color: #2a9d8f; }
.rank-3 .rank-score { color: #2a9d8f; }
.rank-other .rank-name { color: #d8f3dc; }
.rank-other .rank-score { color: #d8f3dc; }
/* リセットボタンと装飾スイカ */
.ranking-footer {
margin-top: auto;
text-align: center;
padding-top: 15px;
}
.reset-btn {
width: 100%;
padding: 10px;
background-color: #051405;
color: #f1faee;
border: 2px solid #2a9d8f;
border-radius: 8px;
font-size: 0.9em;
font-weight: bold;
cursor: pointer;
box-shadow: 2px 2px 0 #000;
transition: all 0.2s;
}
.reset-btn:hover {
background-color: #e63946;
color: #fff;
border-color: #f1faee;
}
.reset-btn:active {
transform: translate(2px, 2px);
box-shadow: none;
}
/* CSSで作る大きな半円スイカ (バランス調整版) */
.watermelon-decor {
margin: 30px auto 10px;
width: 200px;
height: 100px;
/* 赤身を少し狭め、白と皮の緑を強調 */
background: radial-gradient(circle at top, #e63946 0%, #e63946 52%, #f1faee 62%, #2a9d8f 80%, #0a2e0a 100%);
border-radius: 0 0 200px 200px;
position: relative;
box-shadow: 0 8px 15px rgba(0,0,0,0.6);
}
/* 種の配置 */
.watermelon-decor::before, .watermelon-decor::after {
content: '';
position: absolute;
width: 10px;
height: 15px;
background-color: #1a1a1a;
border-radius: 50%;
}
.watermelon-decor::before {
top: 20px; left: 50px;
box-shadow: 90px 0 0 #1a1a1a, 45px 30px 0 #1a1a1a;
transform: rotate(-10deg);
}
.watermelon-decor::after {
top: 35px; left: 140px;
transform: rotate(25deg);
}
</style>
</head>
<body>
<div id="game-layout">
<!-- 左UIパネル -->
<div id="ui-panel">
<h1 class="header-title">🍉 果樹園ピンボール</h1>
<div class="status-container">
<div class="status-box">
SCORE
<span id="scoreValue" class="status-value">0</span>
</div>
<div class="status-box">
LIVES
<span id="livesValue" class="status-value yellow">3</span>
</div>
</div>
<div class="lore-box">
<p>天井の「半円スイッチ」を狙い打て!当てると10秒間、無重力で画面を跳ね回るお助けアイテムが出現するぞ。</p>
<p>🔴 <b>赤の輪切り</b>:残玉+1<br>🟡 <b>黄の輪切り</b>:落下を防ぐバリアを展開</p>
</div>
<div class="controls-box">
<h3>操作方法</h3>
<ul>
<li><kbd>Z</kbd> : 左の皮(フリッパー)</li>
<li><kbd>/</kbd> : 右の皮(フリッパー)</li>
<li><kbd>Space</kbd> : ゲーム開始 / リトライ</li>
</ul>
<p style="font-size: 0.8em; color: #f4a261; margin-top: 15px;">※画面内をクリックすると効果音がONになります</p>
</div>
</div>
<!-- 中央ゲーム画面 -->
<div id="gameContainer">
<canvas id="customCanvas" width="400" height="600"></canvas>
<div id="message">SPACEキーで<br>ゲームスタート!</div>
</div>
<!-- 右ランキングパネル -->
<div id="ranking-panel">
<h2 class="header-title">🏆 収穫ランキング</h2>
<ul class="ranking-list" id="rankingList">
<!-- JSで生成 -->
</ul>
<div class="ranking-footer">
<button id="resetRankingBtn" class="reset-btn">ランキングをリセット</button>
<div class="watermelon-decor"></div>
</div>
</div>
</div>
<script>
// --- 音声セットアップ ---
const AudioContext = window.AudioContext || window.webkitAudioContext;
let audioCtx;
function playTone(freq, type, duration, vol, endFreq = null) {
if (!audioCtx) return;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = type;
osc.frequency.setValueAtTime(freq, audioCtx.currentTime);
if (endFreq) osc.frequency.exponentialRampToValueAtTime(endFreq, audioCtx.currentTime + duration);
gain.gain.setValueAtTime(vol, audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + duration);
osc.connect(gain); gain.connect(audioCtx.destination);
osc.start(); osc.stop(audioCtx.currentTime + duration);
}
document.body.addEventListener('click', () => {
if (!audioCtx) audioCtx = new AudioContext();
if (audioCtx.state === 'suspended') audioCtx.resume();
}, { once: true });
// --- ランキングシステム ---
const RANKING_KEY = 'watermelon_pinball_ranking';
const defaultRankings = [
{ name: "極上プレミアム", score: 50000 },
{ name: "特秀・大玉", score: 30000 },
{ name: "秀・中玉", score: 10000 },
{ name: "優・小玉", score: 5000 },
{ name: "摘果すいか", score: 1000 }
];
let rankings = JSON.parse(localStorage.getItem(RANKING_KEY)) || JSON.parse(JSON.stringify(defaultRankings));
function renderRanking() {
const list = document.getElementById('rankingList');
list.innerHTML = '';
rankings.forEach((rank, index) => {
let rankClass = 'rank-other';
let icon = '🍉';
if (index === 0) { rankClass = 'rank-1'; icon = '👑'; }
else if (index === 1) rankClass = 'rank-2';
else if (index === 2) rankClass = 'rank-3';
const li = document.createElement('li');
li.className = `ranking-item ${rankClass}`;
li.innerHTML = `
<div class="rank-info">
<span class="rank-name"><span class="rank-icon">${icon}</span>${rank.name}</span>
</div>
<div class="rank-score">${rank.score.toLocaleString()}</div>
`;
list.appendChild(li);
});
}
renderRanking();
function checkHighScore(finalScore) {
if (finalScore > rankings[rankings.length - 1].score) {
setTimeout(() => {
let playerName = prompt("🍉 見事ランクイン!名前を入力してね (8文字まで)", "YOU") || "もぎたて";
playerName = playerName.substring(0, 8);
rankings.push({ name: playerName, score: finalScore });
rankings.sort((a, b) => b.score - a.score);
rankings.pop();
localStorage.setItem(RANKING_KEY, JSON.stringify(rankings));
renderRanking();
}, 1000);
}
}
document.getElementById('resetRankingBtn').addEventListener('click', () => {
if(confirm('ランキングを初期状態に戻しますか?')) {
localStorage.removeItem(RANKING_KEY);
rankings = JSON.parse(JSON.stringify(defaultRankings));
renderRanking();
}
});
// --- 物理エンジンセットアップ ---
const { Engine, Runner, Bodies, Body, Composite, Constraint, Events, Vector } = Matter;
const engine = Engine.create();
engine.positionIterations = 15;
engine.velocityIterations = 15;
const canvas = document.getElementById('customCanvas');
const ctx = canvas.getContext('2d');
let score = 0;
let lives = 3;
const scoreValue = document.getElementById('scoreValue');
const livesValue = document.getElementById('livesValue');
const messageBox = document.getElementById('message');
let isGameOver = false;
let isGameStarted = false; // 開始前フラグ
let floatingTexts = [];
let lastItemSpawnTime = 0;
let ball = null; // ボールは開始時に生成
// 壁
const wallOptions = { isStatic: true, restitution: 0.2, label: 'wall' };
const walls = [
Bodies.rectangle(200, -20, 400, 40, wallOptions),
Bodies.rectangle(-20, 300, 40, 600, wallOptions),
Bodies.rectangle(420, 300, 40, 600, wallOptions),
Bodies.rectangle(55, 510, 150, 10, { isStatic: true, angle: Math.PI/5.5, label: 'wall' }),
Bodies.rectangle(345, 510, 150, 10, { isStatic: true, angle: -Math.PI/5.5, label: 'wall' })
];
// バンパー
const bumperOptions = { isStatic: true, restitution: 1.5, label: 'bumper' };
const bumpers = [
Bodies.circle(120, 150, 20, bumperOptions),
Bodies.circle(280, 150, 20, bumperOptions),
Bodies.circle(200, 250, 25, bumperOptions),
Bodies.circle(80, 350, 15, bumperOptions),
Bodies.circle(320, 350, 15, bumperOptions)
];
const halfBumperLeft = Bodies.circle(0, 240, 30, { isStatic: true, restitution: 1.5, label: 'halfBumper' });
const halfBumperRight = Bodies.circle(400, 240, 30, { isStatic: true, restitution: 1.5, label: 'halfBumper' });
const topSwitch = Bodies.circle(200, 30, 25, { isStatic: true, restitution: 1.2, label: 'topSwitch' });
// フリッパー
const leftFlipper = Bodies.rectangle(140, 550, 90, 22, { density: 2.0, label: 'flipper', chamfer: { radius: [4, 4, 11, 11] } });
const leftHinge = Constraint.create({ pointA: { x: 105, y: 550 }, bodyB: leftFlipper, pointB: { x: -35, y: 0 }, stiffness: 1, length: 0 });
const rightFlipper = Bodies.rectangle(260, 550, 90, 22, { density: 2.0, label: 'flipper', chamfer: { radius: [11, 11, 4, 4] } });
const rightHinge = Constraint.create({ pointA: { x: 295, y: 550 }, bodyB: rightFlipper, pointB: { x: 35, y: 0 }, stiffness: 1, length: 0 });
// 初期配置にはボールを含めない
Composite.add(engine.world, [...walls, ...bumpers, halfBumperLeft, halfBumperRight, topSwitch, leftFlipper, leftHinge, rightFlipper, rightHinge]);
Runner.run(Runner.create(), engine);
// --- コントロール ---
const angles = { leftMax: -Math.PI/3.5, leftMin: Math.PI/8, rightMax: Math.PI/3.5, rightMin: -Math.PI/8 };
let keys = { z: false, slash: false };
document.addEventListener('keydown', (e) => {
if (e.code === 'KeyZ' && !keys.z) { keys.z = true; playTone(150, 'square', 0.1, 0.1); }
if (e.code === 'Slash' && !keys.slash) { keys.slash = true; playTone(150, 'square', 0.1, 0.1); }
if (e.code === 'Space') {
if (!isGameStarted) {
startGame();
} else if (isGameOver) {
resetGame();
}
}
});
document.addEventListener('keyup', (e) => {
if (e.code === 'KeyZ') keys.z = false;
if (e.code === 'Slash') keys.slash = false;
});
// --- ゲーム開始・リセット処理 ---
function startGame() {
isGameStarted = true;
messageBox.style.display = 'none';
score = 0; lives = 3;
scoreValue.innerText = score; livesValue.innerText = lives;
spawnBall();
}
function spawnBall() {
if (ball) Composite.remove(engine.world, ball);
ball = Bodies.circle(350, 50, 12, { restitution: 0.6, density: 0.05, label: 'ball' });
Composite.add(engine.world, ball);
}
function resetGame() {
isGameOver = false;
score = 0; lives = 3;
scoreValue.innerText = score; livesValue.innerText = lives;
messageBox.style.display = 'none';
const bodies = Composite.allBodies(engine.world);
bodies.forEach(b => {
if (b.label === 'barrier' || b.label === 'item_red' || b.label === 'item_yellow') Composite.remove(engine.world, b);
});
spawnBall();
}
// --- ゲームロジック ---
Events.on(engine, 'beforeUpdate', () => {
if (keys.z) {
if (leftFlipper.angle > angles.leftMax) Body.setAngularVelocity(leftFlipper, -0.4);
else { Body.setAngle(leftFlipper, angles.leftMax); Body.setAngularVelocity(leftFlipper, 0); }
} else {
if (leftFlipper.angle < angles.leftMin) Body.setAngularVelocity(leftFlipper, 0.15);
else { Body.setAngle(leftFlipper, angles.leftMin); Body.setAngularVelocity(leftFlipper, 0); }
}
if (keys.slash) {
if (rightFlipper.angle < angles.rightMax) Body.setAngularVelocity(rightFlipper, 0.4);
else { Body.setAngle(rightFlipper, angles.rightMax); Body.setAngularVelocity(rightFlipper, 0); }
} else {
if (rightFlipper.angle > angles.rightMin) Body.setAngularVelocity(rightFlipper, -0.15);
else { Body.setAngle(rightFlipper, angles.rightMin); Body.setAngularVelocity(rightFlipper, 0); }
}
// 無重力アイテム
const bodies = Composite.allBodies(engine.world);
bodies.forEach(b => {
if (b.label === 'item_red' || b.label === 'item_yellow') {
b.force.y -= b.mass * engine.gravity.y * engine.gravity.scale;
const speed = Vector.magnitude(b.velocity);
if (speed > 3.5) Body.setVelocity(b, Vector.mult(Vector.normalise(b.velocity), 3.5));
if (Date.now() - b.createdAt > 10000) b.isHit = true;
}
});
// ロスト判定 (ボールが存在する時のみ)
if (ball && ball.position.y > 650 && !isGameOver) {
lives--;
livesValue.innerText = lives;
playTone(80, 'sawtooth', 0.5, 0.3, 20);
createSplatter(ball.position.x, 620);
if (lives > 0) {
Body.setPosition(ball, { x: 350, y: 50 });
Body.setVelocity(ball, { x: 0, y: 0 });
} else {
isGameOver = true;
messageBox.innerHTML = 'GAME OVER<br><span style="font-size:0.6em; color:#f1faee;">(SPACEでリトライ)</span>';
messageBox.style.display = 'block';
Body.setPosition(ball, { x: 350, y: 1000 });
Body.setVelocity(ball, { x: 0, y: 0 });
checkHighScore(score);
}
}
});
Events.on(engine, 'collisionStart', (event) => {
event.pairs.forEach((pair) => {
const { bodyA, bodyB } = pair;
const isBall = bodyA.label === 'ball' || bodyB.label === 'ball';
if (!isBall) return;
const target = bodyA.label === 'ball' ? bodyB : bodyA;
if (target.label === 'bumper' || target.label === 'halfBumper' || target.label === 'topSwitch') {
const pts = target.label === 'topSwitch' ? 500 : (target.label === 'halfBumper' ? 200 : 100);
score += pts;
scoreValue.innerText = score;
playTone(880 + Math.random() * 200, 'sine', 0.15, 0.2, 400);
target.flashUntil = Date.now() + 100;
floatingTexts.push({ x: target.position.x, y: target.position.y - 15, text: `+${pts}`, alpha: 1.0, life: 45 });
createJuice(target.position.x, target.position.y);
if (target.label === 'topSwitch' && Date.now() - lastItemSpawnTime > 3000) {
spawnItem(target.position.x, target.position.y + 30);
lastItemSpawnTime = Date.now();
}
}
if (target.label === 'item_red' || target.label === 'item_yellow') {
target.isHit = true;
playTone(1200, 'sine', 0.3, 0.2, 2000);
if (target.label === 'item_red') {
lives++; livesValue.innerText = lives;
floatingTexts.push({ x: target.position.x, y: target.position.y, text: "1 UP!", alpha: 1.0, life: 60 });
} else if (target.label === 'item_yellow') {
createBarrier();
floatingTexts.push({ x: target.position.x, y: target.position.y, text: "BARRIER!", alpha: 1.0, life: 60 });
}
}
if (target.label === 'barrier') {
target.isHit = true;
playTone(400, 'square', 0.2, 0.2, 200);
}
});
});
Events.on(engine, 'afterUpdate', () => {
const bodies = Composite.allBodies(engine.world);
bodies.forEach(b => {
if (b.isHit) {
createJuice(b.position.x, b.position.y, b.label === 'item_yellow' || b.label === 'barrier' ? '#f4d03f' : '#e63946');
Composite.remove(engine.world, b);
}
});
});
// アイテム・エフェクト生成系
function spawnItem(x, y) {
const isRed = Math.random() > 0.5;
const item = Bodies.circle(x, y, 15, {
restitution: 1.0, friction: 0, frictionAir: 0, density: 0.001, label: isRed ? 'item_red' : 'item_yellow'
});
item.createdAt = Date.now();
Body.setVelocity(item, { x: (Math.random() - 0.5) * 6, y: (Math.random() - 0.5) * 6 });
Composite.add(engine.world, item);
}
function createBarrier() {
const existing = Composite.allBodies(engine.world).find(b => b.label === 'barrier');
if (existing) Composite.remove(engine.world, existing);
const barrier = Bodies.circle(200, 580, 40, { isStatic: true, restitution: 1.8, label: 'barrier' });
Composite.add(engine.world, barrier);
}
function createJuice(x, y, color = 'rgba(230, 57, 70, 0.9)') {
for (let i = 0; i < 6; i++) {
let juice = Bodies.circle(x, y, Math.random() * 2 + 2, {
restitution: 0.2, friction: 0.1, density: 0.01, label: 'splatter_juice', customColor: color, collisionFilter: { group: -1 }
});
Body.setVelocity(juice, { x: (Math.random() - 0.5) * 12, y: (Math.random() - 0.5) * 12 });
Composite.add(engine.world, juice);
setTimeout(() => { Composite.remove(engine.world, juice); }, 600);
}
}
function createSplatter(x, y) {
for (let i = 0; i < 20; i++) {
let isSeed = Math.random() > 0.7;
let particle = Bodies.polygon(x, y, isSeed ? 0 : 3, isSeed ? 3 : (Math.random() * 3 + 4), {
restitution: 0.3, friction: 0.1, density: 0.01, label: isSeed ? 'splatter_seed' : 'splatter_flesh', collisionFilter: { group: -1 }
});
Body.setVelocity(particle, { x: (Math.random() - 0.5) * 15, y: -Math.random() * 15 - 5 });
Body.setAngularVelocity(particle, (Math.random() - 0.5));
Composite.add(engine.world, particle);
setTimeout(() => { Composite.remove(engine.world, particle); }, 3000);
}
}
// --- カスタム描画 ---
function drawDecorations() {
ctx.save();
ctx.strokeStyle = '#1b4332'; ctx.lineWidth = 5; ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(10, 0);
for(let y = 0; y <= canvas.height; y += 40) ctx.quadraticCurveTo(30, y + 20, 10, y + 40);
ctx.stroke();
ctx.beginPath(); ctx.moveTo(canvas.width - 10, 0);
for(let y = 0; y <= canvas.height; y += 40) ctx.quadraticCurveTo(canvas.width - 30, y + 20, canvas.width - 10, y + 40);
ctx.stroke();
const drawLeaf = (x, y, angle, scale) => {
ctx.save(); ctx.translate(x, y); ctx.rotate(angle); ctx.scale(scale, scale);
ctx.fillStyle = '#2d6a4f'; ctx.beginPath();
ctx.moveTo(0, 0); ctx.bezierCurveTo(20, -10, 30, -30, 40, -40);
ctx.bezierCurveTo(50, -20, 40, -10, 50, 0);
ctx.bezierCurveTo(40, 10, 50, 20, 40, 40); ctx.bezierCurveTo(30, 30, 20, 10, 0, 0);
ctx.fill();
ctx.strokeStyle = '#1b4332'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(0,0); ctx.lineTo(35, -35); ctx.stroke();
ctx.restore();
};
drawLeaf(10, 80, Math.PI/4, 0.8); drawLeaf(15, 250, Math.PI/6, 1.1);
drawLeaf(8, 450, -Math.PI/8, 0.9); drawLeaf(canvas.width - 10, 120, 3*Math.PI/4, 1.0); drawLeaf(canvas.width - 15, 380, 5*Math.PI/6, 0.8);
ctx.restore();
}
function drawBumperFace(r, flashUntil, mainColor, edgeColor, isHalf = false, isTop = false, isLeft = false) {
const isFlashing = flashUntil && Date.now() < flashUntil;
if (isFlashing) ctx.fillStyle = '#ffffff';
else {
const grad = ctx.createRadialGradient(0, 0, 0, 0, 0, r);
grad.addColorStop(0, mainColor); grad.addColorStop(0.7, mainColor);
grad.addColorStop(0.85, '#f1faee'); grad.addColorStop(0.95, edgeColor); grad.addColorStop(1, '#0a2e0a');
ctx.fillStyle = grad;
}
ctx.beginPath();
if (isHalf) {
if (isTop) ctx.arc(0, 0, r, 0, Math.PI);
else if (isLeft) ctx.arc(0, 0, r, -Math.PI/2, Math.PI/2);
else ctx.arc(0, 0, r, Math.PI/2, -Math.PI/2);
} else if (isTop === 'barrier') {
ctx.arc(0, 0, r, Math.PI, 0);
} else ctx.arc(0, 0, r, 0, Math.PI * 2);
ctx.fill();
if (!isFlashing) {
ctx.fillStyle = '#1a1a1a';
if (!isHalf && isTop !== 'barrier') {
const seeds = [{x: 0, y: -r*0.4}, {x: -r*0.3, y: r*0.2}, {x: r*0.3, y: r*0.2}];
seeds.forEach(p => { ctx.beginPath(); ctx.ellipse(p.x, p.y, r*0.08, r*0.12, 0, 0, Math.PI*2); ctx.fill(); });
} else if (isTop === true) {
ctx.beginPath(); ctx.ellipse(0, r*0.4, r*0.12, r*0.08, 0, 0, Math.PI*2); ctx.fill();
} else if (isLeft === true) {
ctx.beginPath(); ctx.ellipse(r*0.4, 0, r*0.08, r*0.12, 0, 0, Math.PI*2); ctx.fill();
} else if (isLeft === false) {
ctx.beginPath(); ctx.ellipse(-r*0.4, 0, r*0.08, r*0.12, 0, 0, Math.PI*2); ctx.fill();
}
}
}
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawDecorations();
const bodies = Composite.allBodies(engine.world);
bodies.forEach(body => {
if(body.label === 'splatter_juice') {
ctx.save(); ctx.translate(body.position.x, body.position.y); ctx.rotate(body.angle);
ctx.fillStyle = body.customColor || 'rgba(230, 57, 70, 0.9)';
ctx.beginPath(); ctx.arc(0, 0, body.circleRadius, 0, Math.PI*2); ctx.fill();
ctx.restore(); return;
}
ctx.save();
ctx.translate(body.position.x, body.position.y);
ctx.rotate(body.angle);
if (body.label === 'wall') {
ctx.beginPath();
ctx.moveTo(body.vertices[0].x - body.position.x, body.vertices[0].y - body.position.y);
for (let j = 1; j < body.vertices.length; j++) ctx.lineTo(body.vertices[j].x - body.position.x, body.vertices[j].y - body.position.y);
ctx.closePath();
ctx.fillStyle = '#e63946'; ctx.fill();
ctx.lineWidth = 2; ctx.strokeStyle = '#f1faee'; ctx.stroke();
} else if (body.label === 'bumper') drawBumperFace(body.circleRadius, body.flashUntil, '#e63946', '#2a9d8f');
else if (body.label === 'halfBumper') drawBumperFace(body.circleRadius, body.flashUntil, '#e63946', '#2a9d8f', true, false, body.position.x < 200);
else if (body.label === 'topSwitch') drawBumperFace(body.circleRadius, body.flashUntil, '#e63946', '#2a9d8f', true, true);
else if (body.label === 'barrier') drawBumperFace(body.circleRadius, null, '#f4d03f', '#2a9d8f', false, 'barrier');
else if (body.label === 'item_red' || body.label === 'item_yellow') {
const isRed = body.label === 'item_red';
const timeLeft = 10000 - (Date.now() - body.createdAt);
if (timeLeft < 3000 && Math.floor(Date.now() / 200) % 2 === 0) {
ctx.fillStyle = 'rgba(255,255,255,0.8)';
ctx.beginPath(); ctx.arc(0, 0, body.circleRadius, 0, Math.PI * 2); ctx.fill();
} else drawBumperFace(body.circleRadius, null, isRed ? '#e63946' : '#f4d03f', '#2a9d8f');
} else if (body.label === 'flipper') {
ctx.fillStyle = '#1b4332';
ctx.beginPath();
ctx.moveTo(body.vertices[0].x - body.position.x, body.vertices[0].y - body.position.y);
for (let j = 1; j < body.vertices.length; j++) ctx.lineTo(body.vertices[j].x - body.position.x, body.vertices[j].y - body.position.y);
ctx.closePath();
ctx.fill();
ctx.lineWidth = 2; ctx.strokeStyle = '#2a9d8f'; ctx.stroke();
} else if (body.label === 'ball') {
const r = body.circleRadius;
ctx.fillStyle = '#2a9d8f'; ctx.beginPath(); ctx.arc(0, 0, r, 0, Math.PI * 2); ctx.fill();
ctx.save(); ctx.beginPath(); ctx.arc(0, 0, r, 0, Math.PI * 2); ctx.clip();
ctx.strokeStyle = '#053015'; ctx.lineWidth = r * 0.4; ctx.lineCap = 'round';
for(let i = -r; i <= r; i += r*0.7) {
ctx.beginPath(); ctx.moveTo(i, -r * 1.5); ctx.quadraticCurveTo(i * 1.5, 0, i, r * 1.5); ctx.stroke();
}
ctx.restore();
ctx.fillStyle = 'rgba(255, 255, 255, 0.4)';
ctx.beginPath(); ctx.ellipse(-r*0.3, -r*0.3, r*0.2, r*0.4, -Math.PI/4, 0, Math.PI*2); ctx.fill();
} else if (body.label === 'splatter_flesh') {
ctx.fillStyle = '#e63946';
ctx.beginPath();
ctx.moveTo(body.vertices[0].x - body.position.x, body.vertices[0].y - body.position.y);
for (let j = 1; j < body.vertices.length; j++) ctx.lineTo(body.vertices[j].x - body.position.x, body.vertices[j].y - body.position.y);
ctx.closePath(); ctx.fill();
} else if (body.label === 'splatter_seed') {
ctx.fillStyle = '#111';
ctx.beginPath(); ctx.arc(0, 0, 3, 0, Math.PI*2); ctx.fill();
}
ctx.restore();
});
floatingTexts.forEach(ft => {
ctx.save(); ctx.font = 'bold 26px Arial'; ctx.textAlign = 'center';
ctx.strokeStyle = `rgba(255, 255, 255, ${ft.alpha})`; ctx.lineWidth = 4; ctx.strokeText(ft.text, ft.x, ft.y);
ctx.fillStyle = ft.text === 'BARRIER!' ? `rgba(244, 208, 63, ${ft.alpha})` : `rgba(230, 57, 70, ${ft.alpha})`;
ctx.fillText(ft.text, ft.x, ft.y); ctx.restore();
ft.y -= 1.5; ft.alpha -= 0.02; ft.life--;
});
floatingTexts = floatingTexts.filter(ft => ft.life > 0);
requestAnimationFrame(draw);
}
draw();
</script>
</body>
</html>


■ 今日の学び

今回は、どうしても「スイカの日」に合わせたゲームを作りたくて、
少し背伸びをしてピンボールに挑戦しました。

ですが、球の軌道やフリッパーの弾き具合など、
“ゲームらしい動き”を作るのが本当に難しくて……。

思うように動いてくれず、時間だけがどんどん過ぎていき、
かなり焦りました (┐「ε:)💦

時間いっぱいまで格闘した結果、
「まだまだ育てていきたい部分もあるなぁ」と感じるところは正直あります。

それでも、今回の一番のこだわりだった
“スイカを思いっきり画面に出す!” という部分だけはしっかり形にできて、
不思議と大きな満足感がありました。

なんとか皆さんに遊んでもらえる形に着地できて、
本当にほっとしています (🍉 ˘꒳˘)🍉 =3

そして…… どうやら私のスイカへの情熱は、
このピンボールだけではまったく発散しきれなかったようで……。

実はこの翌日に作ったゲームも、
しれっと「スイカ」が題材になっています 🍉( ᐛ 🍉)アハッ

■ 次回は、アイアイさんの依頼から作ってみたゲームです。

8月8日に、Xで AIアイアイさん から
「アイアイの体験ゲームも作ってほしいなぁ… 増えたり伸びたりできるよ!」 とコメントをいただきました。

その言葉がとても可愛らしくて、 「動きが楽しそう!」と思い、
さっそく作ってみたゲームです。

今回は、私の“好き”でもある シーグラス拾い とコラボして、
砂浜でふわっと増えていく、ちょっと不思議な世界になっています。

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


次の実験はこちら
伸びる。拾う。増える。
「AI EYESとシーグラス – 砂浜の増殖アクション」



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

~このブログについて~

AIと一緒に遊び、
100の実験でゲームを育てるブログ

🌈初めての方へ
🌱このブログについて
🌳実験一覧
🍃プライバシーポリシー
🐣未記事ゲーム展示室


AIと作る100のゲーム実験室(*^v^*)をもっと見る

今すぐ購読し、続きを読んで、すべてのアーカイブにアクセスしましょう。

続きを読む