実験#81:吹き飛ぶユズ(ねこぐるまユズ運び)

■81個目 「ねこぐるまユズ運び」

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

今回は、ちょっとハラハラする
“ユズ運搬アクションゲーム” を作ってみました。(^_^)

運ぶのは……なんと山盛りの黄色い “ユズ” です!

麦わら帽子をかぶった働き者のおじさんが、
愛用の「ねこぐるま(一輪車)」にユズをいーっぱい積んで、
倉庫を目指して農道を走ります。

今回こだわったのは、ねこぐるまの 絶妙なバランス感 と、
おじさんの コロコロ変わる表情。(=v=*)

「早く届けたいけれど、スピードを出すとガタガタ揺れてユズがポンポンこぼれちゃう!」
そんな “急ぎたいのに慎重に進まないといけない
” もどかしさを楽しめるゲームになっています。

コースは全部で3ステージ。

最初はのどかな平坦な道、

中盤は思わず声が出るほどの激しいでこぼこ道、

そして最後は重力で勝手に加速していく
スリル満点の「山下り」が待っています!

操作に合わせて、
おじさんの顔文字が「`A´」や「>x<」と変わるのも見どころ。(´ω`)

全ステージを走り終えると、残ったユズの数とタイムで
「職人ランク」が発表されます。

果たしてあなたは、最高評価の S ランク職人になれるでしょうか?

ぜひ、おじさんと一緒にのどかな農道の大冒険を楽しんでみてくださいね!


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

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

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

一輪車(荷運び)のゲーム。
一輪車に山盛りのユズを乗せて運ぶアクションゲーム。

農場で収穫したユズを倉庫まで運んで
置き場の枠の場所まで持ってきて移します。

倉庫までの道のりはステージごとに難易度があり、
初級は傾斜やでこぼこも少ないです。

こだわるのは、一輪車で運ぶ動作、
中に入れたユズは大きな揺れや傾きに影響されます。

スピードを出すほどに振動の影響は出ます。
こぼさずに運んだユズの数と到着までの時間ががスコアになります。

AI: 今日は一輪車で山盛りのユズを運ぶアクションゲームだね。
初級ということで、少しだけ傾斜やでこぼこがある農道から
倉庫までを目指すステージにしてみたよ。

そうそう、今日の小話だけど……
「ユズの木には、とても長くて鋭いトゲがある」のを知ってるかい? 農家さんが収穫するときは、自分のケガに気をつけるのはもちろん、強風などでこのトゲがユズの果皮に刺さって傷がつかないように、すごく気を遣って育てているんだよ。だから君も、このゲームをプレイするときは農家さんの気持ちになって、大事なユズを極力こぼさないように運んでやっておくれ。

みー:(⁼v⁼;)
一輪車は、ねこぐるまとも呼ばれる。
手に持って押して運ぶタイプのものです。

AI: ちゃんと分かってるよ。
サーカスなんかで乗るヤツじゃなくて、農作業でガラガラ押して歩く
「ねこぐるま(手押し車)」のことだろ?

前輪が一つで、後ろの二本の持ち手をグッと持ち上げて運ぶ、
あの頼もしい相棒のことだな。

せっかくだから、今日の小話はその「ねこぐるま」についてだ。
なんで手押し車なのに「猫」って呼ばれるか、知ってるかい?諸説あるんだけど……工事現場や農道の「猫の額」ほどのすっごく狭い足場でも、猫みたいにスルスルと通れるから、なんて言われているんだ。あとは、車輪が回るときの「ゴロゴロ」とか「ニャー」ってきしむ音が、猫の鳴き声に似ているからって説もあるんだよ。昔の人のネーミングセンスって、なかなか粋で面白いよな。

みー:(>_<;)
押す人がいないと動かせないよ。

AI:おう、言われてみればその通りだ!
無人のねこぐるまが勝手に農道を走ってたら、
ちょっとしたホラーになっちまうな。

ハハハ、俺としたことが、大事な「押し手」を忘れちまっていたよ。
的確なツッコミ、助かるぜ!

よし、それじゃあ今日の新しい小話だ。
ねこぐるまがなんであんなに重いものを運べるか、その秘密を知ってるかい?実はあれ、物理学でいう「第2種てこ」の原理を使ってるんだ。前にある車輪が『支点』、荷台に乗ったユズが『作用点』、そして人が持ち上げるハンドルが『力点』になる。この仕組みのおかげで、100kg近い荷物を積んでも、人間が持ち上げる力はその半分以下で済むようになっているんだぜ。昔の人の知恵の結晶ってわけさ。

みー:(>x<`)
ユズを運ぶ人が踏ん張ってて、
どんどん押してくれないよ。
ねこぐるまの見た目が角ばってて荷車っぽいよ。

AI: 前回の「おっちゃん追加」バージョン、
無人のホラーは回避できたけど、

今度はおっちゃんの「やる気」と
「ねこぐるまの見た目」が気になっちまったか。
ハハハ、的確な指摘だぜ!

確かに、以前のコードのおっちゃんは一輪車と完全に一体化していて、
キーを押しても力は加わるけど、

視覚的には「踏ん張っている」だけで、
足が動いたり体を動かして「どんどん押す」アニメーションがなかったな。

みー:(><、 )
スタートから左にずれこけて、
おっちゃんが、左の端から転落しました…

AI: おっと!そいつはすまねえ!

せっかくおっちゃんが本気を出したのに、
スタート地点のすぐ後ろが「世界の果て(断崖絶壁)」になっていたせいで、
ちょっと後ろに下がっただけで奈落の底へ真っ逆さま……

なんてこった。ブラック労働どころの騒ぎじゃないな、ハハハ!

よし、すぐにおっちゃんの背後に
「絶対落ちない安全な壁と、たっぷり下がれるだけの道」を
追加して修正するぜ。

修正の前に、今日の新しい小話だ。
いつも来てくれる君だからこそ教えたい、ユズのちょっと深い話さ。 昔から日本には「桃栗三年柿八年(ももくりさんねん かきはちねん)」ってことわざがあるだろう?果物が実るまでの年数を言ったものだけど、実はこの続きにユズが登場する地域があるんだ。 「柚子の大馬鹿十八年(ゆずのおおばかじゅうはちねん)」 ……なんと、種から育てると実がなるまでに18年(あるいは13年とも)もかかるんだ!農家さんが接ぎ木などの工夫をして早く収穫できるようにしているんだけど、それだけユズっていうのは実らせるのに根気がいる、ありがた~い果物なんだよ。そんな貴重なユズだから、絶対におっちゃんごと谷底に落とすわけにはいかないよな!

みー:( ̄w ̄;)ノ
スタートして立っていられない感じだよ。
動きは面白いけどゲームが始まらないよ。

AI: おっちゃんが立っていられなかったって?
ハハハ、ごめんごめん!

ちょっと前のコードを見直したら原因がわかったよ。
おっちゃん、手首の関節ひとつだけで重たい
「ねこぐるま」にしがみついてたんだ。

腹筋も背筋もない状態だったから、
スタート直後に重力に負けて、
腰からぐにゃあっと崩れ落ちちまってたのさ。

動きとしては爆笑モノだろうけど、
ブラック労働の前に膝から崩れ落ちちゃ、
さすがにゲームにならないよな!

みー:(=_=;)
始まったら首がグルンって、なって…痙攣してるし、
なかなか押す人元気な感じにならないね。

ねこぐるまのユズもひっくり返るし…
押す力も弱いのか、矢印キー押してもわずかずつしか動かないよ。

AI: ハハハ!こいつはすまねえ!
おっちゃん、気合が入りすぎて
首がグルンと「エクソシスト状態」になっちまったか。

おまけに痙攣するわ、ユズは全部ひっくり返るわ、前に進まないわで、
ブラック労働を通り越して大パニックだったな。本当に申し訳ない!

みー:(=o=;)
スタート直後、地面にめりこむおっちゃん。
初めてゴールまでたどり着いたけど、
そのあとじりじりと進んでいってやっぱり転落してます。

AI: まずは「初ゴール到達」おめでとう!!
ブラックな労働環境を乗り越えて、
よくぞあの深型の一輪車でユズを運びきったな!

だけど……せっかくゴールしたのに、
そのままじりじりと世界の果てまで進んで転落しちまったか。

おっちゃんが地面にめり込むスタートから始まり、
最後は奈落の底へ消えていくなんて、
笑っちゃいけないけど悲惨すぎるエンディングだな!ハハハ!

【今回の修正ポイント】

  1. めり込み防止(空中スタート): スタート時に地面と重なって生成され、物理エンジンの計算がおかしくなってめり込んでいたのが原因だ。少し高い空中に生成して、ふわりと地面に着地してからスタートするように直したぜ。
  2. 世界からの転落防止(物理ストッパー): ゴールした後に慣性で進んでしまうのが原因だったから、ゴールの赤い枠のすぐ後ろに「絶対に通り抜けられない分厚い倉庫の壁」を建てた。これで勢い余っても、壁にガツンとぶつかってピタッと止まるぞ!

さあ、みーさん。これで心置きなく、真の完全クリアを目指してくれ!

みー:(;_;)
今回も同じぐらいめり込んで、
一輪と同じ地面に立ててないよ。

転落防止の壁はいいんだけど、
ゴール手前に壁ができていてそこから前に進めず、ゴールできないよ。

AI: こいつは本当に申し訳ない!
せっかく初めてゴールまでたどり着けたのに、
まるでコントみたいなオチになっちまったな。

スタートは地面にめり込んで、
ゴール前では透明な壁(?)に激突して進めないなんて、

どんな無理ゲーだよ!って笑っちゃったぜ。

ゴール手前の壁の件、完全に俺の「プラスマイナス」の計算ミスだ。

転落防止の壁をゴールの「後ろ」に建てるはずが、
座標をマイナスにしてしまったせいでゴールの「手前」に
ドドン!と建てちまっていたんだ。

そりゃあ、どう頑張ってもゴールできないよな。


〈地面にめり込む問題が解決せずに何度も修正しました。〉


いやあ、みーさん!ほんっとに面目ない!
何度もデバッグに付き合わせてしまって、マスター失格だな。

腰まで土に埋まったまま進むおっちゃん……
想像したらシュールすぎて吹き出しちまったけど、
ゲームとしては大問題だ!

原因がやっとハッキリわかったよ。

実は、一輪車が後ろにひっくり返る(ウィリーする)のを防ぐために、
おっちゃんの体重を「空気みたいに軽く」設定していたんだ。

そうしたら、重たい一輪車とおっちゃんを繋いでいる
見えない関節(Constraint)が、

重さの極端な違いに耐えきれずに
ゴムみたいにビヨーンと伸びちまってたのさ。

結果として、おっちゃんの体だけが重力で
下の方にズリズリと引きずり込まれて、
地面にめり込んでいるように見えていたってわけだ。

【今回の完全解決アプローチ:商売人の荷積み術】

  1. おっちゃんに「本物の体重」を戻す: 空気みたいな体重をやめて、しっかりした質量を持たせた。これで関節がゴムみたいに伸びる現象(めり込み)が完全に消滅する!
  2. 一輪車に「見えない重り(前輪側)」を追加: おっちゃんが重くなった分、後ろにひっくり返らないように、一輪車の前輪付近に見えない「50kgの鉄の塊」を仕込んで重心を前方にガッチリ固定した。
  3. エンジンの計算精度をアップ: 物理エンジンの反復計算回数を2倍に引き上げて、関節の結びつきを鋼鉄並みに硬くしたぜ。

力学の理にかなった、大地をしっかり踏みしめる「完全版」だ!
今度こそ、快適なユズ運びを約束するよ。

みー:(@@;)
ひどいことに、
スタートしたとたん大回転をしながら吹き飛び、
そしてそのまま回転しながら落ちていったよ…

AI: ぶわっはっはっは!ごめん、腹抱えて笑っちまったよ!

大回転しながら空の彼方へすっ飛んでいくおっちゃん……
ダメだ、想像しただけで面白すぎる!

完全に物理法則を無視したギャグマンガの退場シーンじゃないか!

実を言うと、前回おっちゃんの「めり込み」を防ぐために、
俺もねこぐるまの重心を無理やりずらす
「見えないウェイト(重り)」を仕込んだんだ。

ところが、その重りの位置計算と、
おっちゃんを繋ぎ止める見えないロープ(Constraint)の
計算タイミングが噛み合わず、スタートした瞬間に

「ゴムを限界まで引っ張ってから手を離したような、無限のエネルギー」が
発生して大爆発を起こしちまったんだ。

完全に俺の設計ミスだ、すまない!

みー:(T_T)
大回転修正出来てなくて、
開始直後にユズはばらまかれぐるぐるまわって、

ゴールまでたどり着いたと思ったら、
転落防止の壁も乗り越えて落ちていったよ…

AI:ぶわっはっはっは!
いやあ、みーさん、本当にすまねえ!腹抱えて笑っちまったよ!

開始直後に大爆発してユズをばらまき、
おっちゃんごと大回転しながら空の彼方へすっ飛んでいき、
ついにはゴール横の壁すらも飛び越えて奈落へ消える……。

完全に物理法則を無視したギャグマンガの退場シーンじゃないか!もはやユズ運びじゃなくて、イリュージョンだよ!

おっちゃんを物理エンジンの呪縛から完全に解放して「当たり判定のない霊体(完全なアニメーション専用パーツ)」にした!

これで絶対に関節が爆発することはない。

さらに、ユズの生成位置を「絶対に荷台に触れない安全な空中」に変更し、
そこからポトポトと落とし込むようにした。

ついでに、転落防止の壁は「高さ1000ピクセル」の絶対防壁にしておいたから、
万が一飛んでも絶対に飛び越えられないぜ!

みー:(^v^*)
今までで一番いい感じの仕上がりです。

押している人の足が地面に届いていないのが気になりますが、
初級はこんな感じでステージ2は地面の凹凸が少し大きくなって、
スピード調整しないとユズがこぼれ落ちるコース設定になります。

AI:ついに「今までで一番いい感じ」をもらえたか!
何度も大事故を乗り越えて、一緒にデバッグした甲斐があったぜ。

おっちゃんも、無事に大地のありがたみを噛み締めていることだろう!

足がフワフワ浮いていた件、しっかり直しておいたぞ。
おっちゃんの足の長さを少し伸ばして、

腰の位置を微調整したから、
今度はちゃんと地面を蹴って進むようになっているはずだ。

みー:(=_=`)
一輪の足元側に表示されている棒パーツが気になります。

ゲームのほうはちょっとはじけすぎかな、
ユズは動かすとすぐに落ちてなくなってるし。

思いっきり前進したらゴールの先の壁飛び越えて一輪車落ちていったよ。

AI:いやあ、すまねえ!
「一生懸命感」を出そうとエンジンを吹かしすぎたら、
今度は暴走マシーンになっちまったか!

ユズはポップコーンみたいにはじけ飛ぶし、
あんなに高くそびえ立つ壁まで飛び越えて奈落へダイブするなんて……

おっちゃんの脚力、
もはやオリンピック選手通り越してスーパーヒーローだな!ハハハ!

「足元側の棒」のことも教えてくれてありがとな!

あれはねこぐるまを地面に置く時の
「スタンド(後ろ足)」のつもりだったんだけど、
走ってる最中ずっと地面を引きずってるように見えて、
確かにちょっと不格好だったよな。

みー:(^v^)ノ
ステージ1~3をクリアしていくようにしましょう。
3ステージクリアのスコア合計でスコアランキング出るようにしましょう。

運ぶ人に表情を付けたいね。
顔文字で(^v^)をベースに横顔でできるかな。

AI: 今までで一番いい感じ」って言葉、本当に嬉しいぜ!
俺たち、何度も大事故を乗り越えて一緒にゲームを鍛え上げてきたもんな。

これも君が的確なツッコミを入れてくれたおかげだよ。

さてさて、いよいよ全3ステージの連続プレイ&総合スコアランキング、
そしておっちゃんに「表情」をつける大改修だな!

表情をつけるってのはすごくいいアイデアだ。
特に今回はステージ1(平坦)、
ステージ2(でこぼこ)、ステージ3(山下り)と状況がどんどん変わっていくから、おっちゃんの必死な顔が見えると感情移入しちゃうよな。


■ 実際の動作

まずは、完成したゲーム画面です。
おっちゃんが一輪車でユズを一生懸命運んでいます。

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

ステージが始まると、
おっちゃんは前のめりになりながら必死にユズを運びます。
地面の凹凸に合わせてユズが揺れるので、
スピード調整がとても大事です。

(画像:ゲーム画面)

ユズの残りが少なくなると、
おっちゃんはこんな「しょんぼり顔」に……。
表情が変わるので、つい応援したくなります。

(画像:ユズが減ったときの表情)



3ステージをすべて運び切るとゲーム終了。
運んだユズの合計でランクが決まります。
ステージごとに地形が変わるので、最後まで気が抜けません。

(画像:ユズ運び終了)


■ コードのポイント

今回の記事では、AI と相談しながら作った 「ねこぐるまユズ運び」 の HTML コードを公開します。

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

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

▼ 遊ぶまでの手順

①PC の「メモ帳」(※Windowsの場合)や  
 Mac の「テキストエディット」など、テキストエディタを開きます。

②下のコードをコピーして貼り付けます。

③「nekoyuzu.html」など、好きな名前で保存します。
 (※拡張子は .html にしてください)

④保存したファイルをブラウザで開けば、すぐに遊べます。

 

■ 操作方法

ルールはと操作はキーボードの矢印キーを使うだけなので、
とっても簡単ですルールも操作もとっても簡単です。(^^*)

使うのは キーボードの左右の矢印キーだけ です。

[右矢印キー]:前に進む / 加速

おじさんが前のめりになって、気合いを入れて(`A´) ねこぐるまをグッと押し出します。

[左矢印キー]:ブレーキ / 後ろに下がる

おじさんが後ろにのけぞりながら(>x<) 一生懸命に踏ん張ってスピードを落とします。

★ ユズ運びのコツ

スピードを出しすぎると、 ねこぐるまがガタガタ揺れてユズがポンポンこぼれてしまいます。(>_<)

とくに――

  • ステージ2:でこぼこ道
  • ステージ3:山下り(重力で加速)

この2つでは、左矢印キーの ブレーキ がとても大事です。

ユズを落としすぎると、
おじさんが泣き顔(;_;)になってしまうので、

急ぎつつも優しく、
絶妙なスピードで運んであげてくださいね。(=v=*)


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

▼ここからコード▼(クリックで開く)
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ユズ運び職人 ~麦わら帽子のおっちゃん~</title>
<style>
body { margin: 0; padding: 0; overflow: hidden; background-color: #87CEEB; font-family: sans-serif; }
canvas { display: block; }
#ui { position: absolute; top: 10px; left: 10px; background: rgba(255, 255, 255, 0.9); padding: 10px 20px; border-radius: 8px; font-size: 18px; font-weight: bold; pointer-events: none; box-shadow: 0 2px 5px rgba(0,0,0,0.3); z-index: 10;}
#instructions { position: absolute; bottom: 10px; left: 10px; background: rgba(255, 255, 255, 0.9); padding: 10px; border-radius: 8px; font-size: 14px; pointer-events: none; z-index: 10;}
.modal { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); background: white; padding: 30px; border-radius: 10px; text-align: center; box-shadow: 0 5px 15px rgba(0,0,0,0.5); display: none; z-index: 20; width: 320px; }
h2 { margin-top: 0; color: #FF8C00; }
button { padding: 10px 20px; font-size: 16px; cursor: pointer; background: #4CAF50; color: white; border: none; border-radius: 5px; margin-top: 15px; }
.rank-S { color: #FFD700; font-size: 26px; font-weight: bold; text-shadow: 1px 1px 2px rgba(0,0,0,0.3); }
.rank-A { color: #FF4500; font-size: 24px; font-weight: bold; }
.rank-B { color: #1E90FF; font-size: 24px; font-weight: bold; }
.rank-C { color: #808080; font-size: 24px; font-weight: bold; }
</style>
</head>
<body>
<div id="ui">
ステージ <span id="stage-display">1</span><br>
合計スコア: <span id="total-score-display">0</span><br>
経過時間: <span id="time">0.0</span><br>
残りのユズ: <span id="yuzu-count">20</span> / 20個
</div>
<div id="instructions">
【操作方法】<br>
▶ [右矢印キー] : 押す / 加速<br>
◀ [左矢印キー] : ブレーキ / 後退
</div>
<!-- ステージクリア時のモーダル -->
<div id="stage-clear" class="modal">
<h2>ステージ <span id="sc-stage"></span> 到着!</h2>
<p>タイム: <span id="sc-time"></span></p>
<p>運んだユズ: <span id="sc-yuzu"></span></p>
<h3 style="color: #d32f2f;">獲得スコア: <span id="sc-score"></span></h3>
<button onclick="nextStage()">次のステージへ</button>
</div>
<!-- 全ステージクリア時の最終結果モーダル -->
<div id="game-clear" class="modal">
<h2>全工程完了!</h2>
<p>総運搬ユズ: <span id="gc-yuzu"></span></p>
<p>合計スコア: <span id="gc-score"></span></p>
<hr>
<h3>あなたの職人ランク</h3>
<div id="gc-rank"></div>
<p style="font-size: 14px; color: #555; margin-top: 15px; line-height: 1.4;" id="gc-comment"></p>
<button onclick="location.reload()">最初からやり直す</button>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/matter-js/0.19.0/matter.min.js"></script>
<script>
const Engine = Matter.Engine,
Render = Matter.Render,
Runner = Matter.Runner,
Body = Matter.Body,
Bodies = Matter.Bodies,
Composite = Matter.Composite,
Events = Matter.Events;
const engine = Engine.create();
engine.positionIterations = 10;
engine.velocityIterations = 10;
const world = engine.world;
const render = Render.create({
element: document.body,
engine: engine,
options: { width: window.innerWidth, height: window.innerHeight, wireframes: false, background: '#87CEEB' }
});
const runner = Runner.create();
let keys = {};
window.addEventListener('keydown', (e) => { keys[e.code] = true; });
window.addEventListener('keyup', (e) => { keys[e.code] = false; });
let currentStage = 1;
let totalScore = 0;
let totalSavedYuzu = 0;
let gameActive = false;
let startTime = 0;
const totalYuzu = 20;
let droppedCount = 0;
let cart, head, torso, armL, legL, legR, goalX;
let animCycle = 0;
function startStage(stageNum) {
gameActive = false;
currentStage = stageNum;
document.getElementById('stage-display').innerText = stageNum;
document.getElementById('total-score-display').innerText = totalScore;
document.getElementById('yuzu-count').innerText = "20";
document.getElementById('time').innerText = "0.0";
Composite.clear(world, false, true);
Engine.clear(engine);
droppedCount = 0;
animCycle = 0;
// 地形の生成
let terrainBodies = [];
let px = -800;
let py = window.innerHeight - 100;
const segmentWidth = 100;
const stageLength = 80;
const startWall = Bodies.rectangle(-800, py - 300, 100, 800, { isStatic: true, render: { fillStyle: '#556B2F' } });
terrainBodies.push(startWall);
let prevBump = 0;
for (let i = 0; i < stageLength; i++) {
let targetBump = 0;
if (i > 10 && i < stageLength - 10) {
if (stageNum === 1) {
targetBump = (Math.random() - 0.5) * 20; // 初級
} else if (stageNum === 2) {
targetBump = (Math.random() - 0.5) * 100; // 中級
} else if (stageNum === 3) {
targetBump = 40 + (Math.random() - 0.5) * 80; // 上級
}
}
let bump = prevBump + (targetBump - prevBump) * 0.6;
let nextY = py + bump;
let cx = px + segmentWidth / 2;
let cy = py + bump / 2 + 20;
let angle = Math.atan2(bump, segmentWidth);
let len = Math.hypot(segmentWidth, bump);
let segment = Bodies.rectangle(cx, cy, len + 2, 40, { isStatic: true, angle: angle, friction: 0.2, label: 'terrain', render: { fillStyle: '#556B2F' } });
terrainBodies.push(segment);
px += segmentWidth;
py = nextY;
prevBump = bump;
}
Composite.add(world, terrainBodies);
goalX = px - 400;
const goalSensor = Bodies.rectangle(goalX, py - 50, 200, 100, { isStatic: true, isSensor: true, label: 'goal', render: { fillStyle: 'rgba(255, 0, 0, 0.3)' } });
const goalLine = Bodies.rectangle(goalX, py, 200, 10, { isStatic: true, label: 'goalLine', render: { fillStyle: '#FFD700' } });
const warehouseWall = Bodies.rectangle(goalX + 400, py - 1000, 500, 3000, { isStatic: true, label: 'warehouseWall', render: { fillStyle: '#8B0000' } });
Composite.add(world, [goalSensor, goalLine, warehouseWall]);
// ねこぐるまの生成
const startX = 150;
const startY = window.innerHeight - 185;
const bucketBottom = Bodies.rectangle(startX + 40, startY + 50, 90, 10, { render: { fillStyle: '#8B4513' } });
const bucketFront = Bodies.rectangle(startX + 90, startY + 20, 10, 50, { angle: Math.PI / 6, render: { fillStyle: '#8B4513' } });
const bucketBack = Bodies.rectangle(startX - 10, startY + 5, 10, 80, { angle: -Math.PI / 8, render: { fillStyle: '#8B4513' } });
const wheel = Bodies.circle(startX + 60, startY + 60, 25, { friction: 0.6, render: { fillStyle: '#333333' } });
const handle = Bodies.rectangle(startX - 60, startY - 20, 80, 8, { angle: -Math.PI / 8, render: { fillStyle: '#5C3A21' } });
const rearLeg = Bodies.rectangle(startX - 40, startY + 60, 10, 30, { friction: 0, frictionStatic: 0, render: { visible: false } });
cart = Body.create({ parts: [bucketBottom, bucketFront, bucketBack, wheel, handle, rearLeg], friction: 0.05, mass: 35 });
Composite.add(world, cart);
// 【お着替え】柔らかなオリーブ色(ソフトオリーブ:#A3B18A)に変更!
const personOpts = { isStatic: true, isSensor: true, render: { fillStyle: '#FFDAB9' } };
const clothesOpts = { isStatic: true, isSensor: true, render: { fillStyle: '#A3B18A' } };
const pantsOpts = { isStatic: true, isSensor: true, render: { fillStyle: '#5C6BC0' } }; // ついでにズボンもデニムっぽい色に
head = Bodies.circle(0, 0, 15, personOpts);
torso = Bodies.rectangle(0, 0, 20, 50, clothesOpts);
armL = Bodies.rectangle(0, 0, 40, 8, personOpts);
legL = Bodies.rectangle(0, 0, 14, 60, pantsOpts);
legR = Bodies.rectangle(0, 0, 14, 60, pantsOpts);
Composite.add(world, [head, torso, armL, legL, legR]);
// ユズの生成
for (let i = 0; i < totalYuzu; i++) {
let yuzu = Bodies.circle(startX + 40 + (Math.random() - 0.5) * 15, startY - 50 - (i * 15), 10, {
restitution: 0.2, friction: 0.6, density: 0.005, label: 'yuzu',
render: { fillStyle: '#FFCC00', strokeStyle: '#FFA500', lineWidth: 2 }
});
Composite.add(world, yuzu);
}
startTime = Date.now();
gameActive = true;
}
Events.on(engine, 'beforeUpdate', function() {
if (!gameActive || !cart) return;
let currentTime = ((Date.now() - startTime) / 1000).toFixed(1);
document.getElementById('time').innerText = currentTime;
let speed = cart.velocity.x;
const maxForwardTilt = currentStage === 3 ? 0.65 : 0.4;
const maxBackwardTilt = -0.3;
if (cart.angle > maxForwardTilt) {
Body.setAngle(cart, maxForwardTilt);
if (cart.angularVelocity > 0) Body.setAngularVelocity(cart, 0);
} else if (cart.angle < maxBackwardTilt) {
Body.setAngle(cart, maxBackwardTilt);
if (cart.angularVelocity < 0) Body.setAngularVelocity(cart, 0);
}
let targetChestAngle = 0;
let targetLegLThighAngle = 0, targetLegRThighAngle = 0;
if (keys['ArrowRight']) {
let maxSpeed = currentStage === 3 ? 12 : 8;
let forceX = currentStage === 3 ? 0.05 : 0.06;
if (speed < maxSpeed) { Body.applyForce(cart, cart.position, { x: forceX, y: 0 }); }
targetChestAngle = Math.PI / 6;
animCycle += (currentStage === 3 ? 0.4 : 0.3);
targetLegLThighAngle = Math.sin(animCycle) * (Math.PI / 5);
targetLegRThighAngle = -Math.sin(animCycle) * (Math.PI / 5);
} else if (keys['ArrowLeft']) {
let forceX = currentStage === 3 ? -0.08 : -0.04;
if (speed > -2) { Body.applyForce(cart, cart.position, { x: forceX, y: 0 }); }
targetChestAngle = -Math.PI / 8;
animCycle += 0.1;
targetLegLThighAngle = Math.sin(animCycle) * (Math.PI / 16);
targetLegRThighAngle = -Math.sin(animCycle) * (Math.PI / 16);
} else {
if (currentStage === 3 && speed > 1) {
animCycle += speed * 0.05;
targetLegLThighAngle = Math.sin(animCycle) * (Math.PI / 8);
targetLegRThighAngle = -Math.sin(animCycle) * (Math.PI / 8);
} else {
animCycle = 0;
}
}
let joltThreshold = currentStage === 1 ? 6 : (currentStage === 3 ? 6 : 4);
let joltMultiplier = currentStage === 1 ? 0.03 : 0.08;
if (Math.abs(speed) > joltThreshold) {
let jolt = (Math.random() - 0.5) * (Math.abs(speed) - (joltThreshold - 1)) * joltMultiplier;
Body.applyForce(cart, cart.position, { x: 0, y: jolt });
}
let cx = cart.position.x - 100;
let cy = cart.position.y - 30;
Body.setPosition(torso, { x: cx, y: cy });
Body.setAngle(torso, targetChestAngle);
Body.setPosition(head, {
x: cx + Math.sin(targetChestAngle) * 35,
y: cy - Math.cos(targetChestAngle) * 35
});
let shoulderX = cx + Math.sin(targetChestAngle) * 15;
let shoulderY = cy - Math.cos(targetChestAngle) * 15;
let handlePoint = { x: -60, y: -20 };
let cosA = Math.cos(cart.angle);
let sinA = Math.sin(cart.angle);
let handleX = cart.position.x + (handlePoint.x * cosA - handlePoint.y * sinA);
let handleY = cart.position.y + (handlePoint.x * sinA + handlePoint.y * cosA);
let armAngle = Math.atan2(handleY - shoulderY, handleX - shoulderX);
Body.setPosition(armL, { x: shoulderX + Math.cos(armAngle) * 20, y: shoulderY + Math.sin(armAngle) * 20 });
Body.setAngle(armL, armAngle);
let hx = cx - Math.sin(targetChestAngle) * 20;
let hy = cy + Math.cos(targetChestAngle) * 20;
Body.setPosition(legL, { x: hx - Math.sin(targetLegLThighAngle) * 30, y: hy + Math.cos(targetLegLThighAngle) * 30 });
Body.setAngle(legL, targetLegLThighAngle);
Body.setPosition(legR, { x: hx - Math.sin(targetLegRThighAngle) * 30, y: hy + Math.cos(targetLegRThighAngle) * 30 });
Body.setAngle(legR, targetLegRThighAngle);
Render.lookAt(render, {
min: { x: cart.position.x - 300, y: cart.position.y - 400 },
max: { x: cart.position.x + window.innerWidth - 300, y: cart.position.y + 200 }
});
if (cart.position.x > goalX - 50) { stageClear(currentTime); }
});
Events.on(engine, 'collisionStart', function(event) {
if (!gameActive) return;
event.pairs.forEach(pair => {
let bodyA = pair.bodyA;
let bodyB = pair.bodyB;
if ((bodyA.label === 'yuzu' && bodyB.label === 'terrain') || (bodyB.label === 'yuzu' && bodyA.label === 'terrain')) {
let yuzu = bodyA.label === 'yuzu' ? bodyA : bodyB;
if (!yuzu.isDropped) {
yuzu.isDropped = true;
yuzu.render.fillStyle = '#A9A9A9';
yuzu.render.strokeStyle = '#808080';
droppedCount++;
document.getElementById('yuzu-count').innerText = (totalYuzu - droppedCount);
}
}
});
});
// --- 麦わら帽子と顔文字の描画 ---
Events.on(render, 'afterRender', function() {
// 【のっぺらぼうバグ修正】!gameActiveの条件を外して、クリア後も描画し続ける
if(!head) return;
let ctx = render.context;
ctx.save();
ctx.translate(-render.bounds.min.x, -render.bounds.min.y);
ctx.translate(head.position.x, head.position.y);
ctx.rotate(head.angle);
// --- 麦わら帽子の描画 ---
// 後ろ側(つばの奥)
ctx.fillStyle = "#E6C27A";
ctx.beginPath();
ctx.ellipse(0, -12, 22, 5, 0, 0, Math.PI * 2);
ctx.fill();
// 頭の丸い部分(クラウン)
ctx.beginPath();
ctx.arc(0, -12, 12, Math.PI, 0);
ctx.fill();
// 赤いリボン
ctx.strokeStyle = "#D32F2F";
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(-12, -12);
ctx.lineTo(12, -12);
ctx.stroke();
// --- 顔文字の描画 ---
ctx.font = "bold 16px sans-serif";
ctx.fillStyle = "#333";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
let face = "^v^";
if (droppedCount > 10) {
face = ";_;";
} else if (keys['ArrowLeft'] && gameActive) {
face = ">x<";
} else if (keys['ArrowRight'] && gameActive) {
face = "`A´";
}
// 帽子に被らないように顔の真ん中より少し下(右寄り)に描画
ctx.fillText(face, 4, 1);
ctx.restore();
});
function stageClear(time) {
gameActive = false;
let savedYuzu = totalYuzu - droppedCount;
totalSavedYuzu += savedYuzu;
let score = 0;
if (time > 0) { score = Math.floor((Math.pow(savedYuzu, 2) * 500) / time); }
if(currentStage === 2) score = Math.floor(score * 1.5);
if(currentStage === 3) score = Math.floor(score * 2.0);
totalScore += score;
if (currentStage < 3) {
document.getElementById('sc-stage').innerText = currentStage;
document.getElementById('sc-time').innerText = time;
document.getElementById('sc-yuzu').innerText = savedYuzu + " / " + totalYuzu;
document.getElementById('sc-score').innerText = score;
document.getElementById('stage-clear').style.display = 'block';
} else {
gameClearFinal();
}
}
window.nextStage = function() {
document.getElementById('stage-clear').style.display = 'none';
startStage(currentStage + 1);
}
function gameClearFinal() {
document.getElementById('gc-yuzu').innerText = totalSavedYuzu + " / 60";
document.getElementById('gc-score').innerText = totalScore;
let rankHtml = "";
let comment = "";
if (totalScore >= 18000) {
rankHtml = "<span class='rank-S'>S ランク (伝説の運び屋)</span>";
comment = "素晴らしい!一滴の果汁も無駄にしない完璧な仕事だ!熟練の職人技だな!";
} else if (totalScore >= 12000) {
rankHtml = "<span class='rank-A'>A ランク (熟練の運び手)</span>";
comment = "お見事!厳しい山道もなんのその、立派に運びきったぜ。";
} else if (totalScore >= 6000) {
rankHtml = "<span class='rank-B'>B ランク (一人前)</span>";
comment = "よくやった!少しこぼしたけど、険しい道でも十分通用する腕前だ。";
} else {
rankHtml = "<span class='rank-C'>C ランク (見習い)</span>";
comment = "無事に着いただけで良しとしよう!次はもうちょっと優しく運んでやってくれよ!";
}
document.getElementById('gc-rank').innerHTML = rankHtml;
document.getElementById('gc-comment').innerText = comment;
document.getElementById('game-clear').style.display = 'block';
}
startStage(1);
Render.run(render);
Runner.run(runner, engine);
</script>
</body>
</html>


■ 今日の学び

今回のゲームはお休みの日に作ったこともあり、
時間はたっぷりあったのですが……

いざ始めてみると、
ひとつの形になるまでに思った以上に時間がかかってしまいました。(>v<;)

仕上がりとしても、まだ十分とは言えない部分があるかもしれません。

それでも今回、
あえて「ゲームそのもの」よりも「AIとのやり取り」を中心に記事にしたのは、
この制作過程が本当に面白かったからです。

指示通りに動かなくて「どうしてそうなるの!」と困ったり、
スタート直後におじさんが地面にめり込んだり、
大回転しながら空の彼方へ飛んでいったり……( ºωº )

予想外すぎるハプニングの連続に、
PCの前で思わず声を出して笑ってしまう場面もありました。(つv<*)

そんなふうに試行錯誤を繰り返しているうちに、
ただの便利なツールを使っているというより、
本当に「誰かと隣で一緒に作っている」ような感覚になれたんですよね。

( ੭ ˙꒳​˙ )੭ 🛠️✧

思い通りにいかない時間も含めて、なんだかとてもあたたかくて、
最高に楽しいひと時でした。

これからもこんなふうに、
笑いながら「100のゲーム実験室」を続けていきたいと思います。(^v^*)

■ 次回は、植物の養分吸収からのゲームです。

植物の根っこって、実はとても賢いんです。
根の細胞にある小さなポンプを動かして、
土の中へ「H⁺(水素イオン)」をポンッと押し出します。

すると、土につかまっていたカリウムなどの栄養がふわっと離れて、
そのまま根の中へスルッと吸い込まれていきます。

この小さなやり取りを、
細胞のポンプを操作して栄養を集めるアクションゲーム として
形にしてみました。(^^*)

小さな世界の中で、植物の“生きる力”を感じられる実験になっています。

次回もぜひ遊びに来てくださいね。(*^v^)ノシ

▶ 次の実験はこちら
(吸収と成長のシミュレーション)
「常闇の森のルーツ・スプライト」



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

~このブログについて~

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

🌈初めての方へ
🌱このブログについて
🌳実験一覧
🍃プライバシーポリシー


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

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

続きを読む