■106個目 「無重力 ウォーターガン・スナイパー」
こんにちは、AIと作る100のゲーム実験室の みー です。(*^_^*)/
今回は、宇宙空間を舞台にした
ちょっと不思議なシューティングゲームを作ってみました。
タイトルは「無重力ウォーターガン・スナイパー」です。
ゲームの舞台は、
国際宇宙ステーション(ISS)のような明るい居住モジュール。
黄色い手すりがついた船内の奥には大きな丸い窓があり、
青い地球や静かに浮かぶ月、
そして瞬かない星々がゆっくりと流れていきます。
今回みなさんに水鉄砲で狙っていただく的は、
ぽっかりと浮かぶ「青い炎」です。(=v=)
地球では炎は上へ伸びますが、
無重力空間では空気が対流しないため、
まん丸い球体になって青白く光るんだそうです。
とても神秘的ですよね。(´▽`)
船内には、木箱やレンチ、
ツールボックスなどの生活雑貨も障害物としてプカプカ漂っています。
水鉄砲のお水がそれらに当たると、
パシャッと小さな飛沫に分かれて弾けたり、
衝撃で重たい木箱がゆっくりクルクル回り始めたり……。
無重力ならではの「水の動き」と
「モノがぶつかったときのポヨンとしたリアリティ」に
こだわって作りました。(´艸`)
美しい窓の外の絶景に見とれすぎないように気をつけながら、
漂う雑貨をかわして青い炎を鎮火させてくださいね。
どんな無重力体験になるか、お楽しみに。
■ AIとのやりとりで面白かった部分
● ゲーム仕様の相談からスタート
みー:(^v^) 今日もゲームを作ります。
HTMLで動く水鉄砲の狙撃ゲームです。
世界観は無重力空間。
的は「ろうそくの炎」。
無重力では炎が球状になるので、
そのリアリティをしっかり再現したいです。
水も発射の勢いで形が変わりつつ、
最終的には表面張力で丸く戻るようにしたいです。
AI: 無重力空間での水鉄砲狙撃ゲーム、ロマンがあって最高だな。
HTML5 Canvas と JavaScript で、
微小重力の物理法則をできる限り再現してみるぜ。
■ 実装のポイント(リアリティ向上)
水滴の変形と表面張力
射出された水は推進力で細長く伸び、
速度が落ちると表面張力で球体に戻ろうとします。
Canvas の ctx.ellipse を動的にスケーリング・回転させて、
この挙動を表現しました。
無重力の炎(青い球体)
無重力では対流が起きないため、
炎は青白く光る完全な球体になります。
CSS と Canvas の放射状グラデーションで、
神秘的な球状の炎を描きました。
みー:(^_^) 宇宙船の中のイメージにしましょう。
障害物として雑貨が漂っている感じにしたいです。
水が当たったときのリアリティも高めたいです。
AI: 了解だ。宇宙船の居住モジュールに舞台を移して、
漂う生活雑貨を追加するぜ。
水がぶつかったときの挙動と、
障害物の無重力下での動きを強化しておいた。
■ 障害物と水滴の物理表現
水滴の飛散(スプラット)
水滴が障害物に当たると、
複数の小さな飛沫に分裂して四方に弾け飛びます。
飛沫は親水滴の速度を少し引き継ぎながら、
短時間で蒸発して消えます。
障害物(宇宙の雑貨) 箱、レンチ、ツールボックスなど、質量の異なる雑貨をゆっくり漂わせています。
物理衝突(運動量保存則)
水滴が当たると、その運動エネルギーの一部が障害物に伝わり、
軽い物体は大きく弾かれ、重い物体はゆっくり押し出されます。
回転速度も変化し、無重力らしい挙動になります。
みー:(=v=*)いいね。全部消したらクリア。
リトライボタンもつけましょう。
時間が短いほど高得点で、スコアランキングも付けたいです。
AI: アーケードゲームらしくなってきたな!
タイムで得点が変わるスコアシステムと、
LocalStorage を使ったランキング機能、
そしてリトライボタンを追加したぜ。
みー:(つ_<) 動作が重くなって止まったりします。
障害物同士がぶつかったときの動きも表現できますか?
AI: すまねえ!原因は「飛沫のネズミ算式増殖」だった。
障害物の内部で飛沫が発生して、
次のフレームでも「衝突判定→分裂」を繰り返し、
一瞬で数万個に増えてブラウザが悲鳴を上げてたんだ。
飛沫が再衝突しないように修正しておいたぜ。
障害物同士の衝突も実装した。
軽い物体が大きく弾かれたり、衝撃で回転が変わったりするぞ。
みー:(^o^) 背景が暗いので、ISSみたいに明るい船内にしたいです。
AI: 任せろ。壁を明るいアイボリーにして、黄色いハンドレールを付けた。
中央には青い地球が見える大きな窓も配置したぜ。
照準器も赤色にして見やすくしてある。
ちなみに ISS の中は、換気ファンが常に回っていて意外と騒がしいらしいぞ。
みー:(=v=) 窓の外には地球や月や星が見えたらいいね。
AI: もちろんだ。
青い地球、静かに浮かぶ月、瞬かない星々を描き込んでおいた。
大気がない宇宙では星は瞬かず、鋭い光の点として見えるんだ。
その星々が、船の周回軌道に合わせてゆっくり流れていくようにしてある。
景色に見とれすぎて撃ち漏らすなよ!
■ 実際の動作
まずは、AIが最初に出してくれた試作版のゲーム画面です。
まだ背景が暗く、青白い炎を水鉄砲で消すところまでのシンプルな状態でした。

(画像:AIが最初に出してくれた試作画面)
こちらが、最終的に仕上がったゲーム画面です。
居住モジュールの明るい壁や黄色い手すり、
大きな丸窓の景色が加わり、世界観が一気に広がりました。

(画像:仕上がりのゲーム画面)
炎を全部消すとクリアです。
クリア後にはスコアランキングも表示されます。

(画像:ゲーム終了画面)
■ コードのポイント
今回の記事では、
AI と相談しながら作った「無重力 ウォーターガン・スナイパー」 の HTML コードを公開します。
このコードをそのまま保存するだけで、
あなたの PC でもすぐに遊べます。
ブラウザだけで動く、とてもシンプルなゲームなので、
「コードは初めて…」という方でも気軽に試してみてくださいね。
▼ 遊ぶまでの手順(むずかしさ:やさしい)
① テキストエディタを開く
Windows の場合は「メモ帳」、
Mac の場合は「テキストエディット」を使います。
(※検索バーで「メモ帳」と入力するとすぐ出てきます)
② 下のコードをコピーして貼り付けます。
③ 「mujyuuryoku.html」など、好きな名前で保存します。
(※拡張子は .html にしてください)
④ 保存したファイルをダブルクリックすると、
ブラウザでゲームが起動します。
■ 遊び方・操作方法
それでは、ゲームの操作方法をご説明しますね!(^_^)
パソコンのブラウザから、マウスを使って遊べます。
【操作方法】
・狙いを定める
マウスを動かすと、画面の赤い照準(カーソル)がスッと動きます。
・水鉄砲を撃つ
マウスの左クリックで発射できます。
長押しすると、お水を連続でピュンピュン撃てますよ!(^^*)
【ルールとクリア条件】
ぷかぷか浮かんでいる 「青い炎」 にお水を当てて、
すべての炎を消火できたらミッションクリア です!
途中、宇宙船の中に漂っている木箱や工具にお水が当たると、
パシャッと飛沫になって弾けてしまいます。(=v=)
(でも、お水が当たった障害物がクルクル回って飛んでいくのが楽しくて、 ついつい無駄撃ちしたくなっちゃうかも……?(´艸`*))
【スコアランキング】
炎をすべて消すまでの タイムが短いほど高得点 になります。
上位5位までのスコアが記録されるので、
ハイスコアを目指して、ぜひ何度でも「RETRY」してみてくださいね。(´▽`*)
無重力空間のちょっと不思議なスナイパー体験、
ぜひ楽しんでみてください!(^_^)
“コードは長いので折りたたんでいます。必要な方は開いてご覧ください。
▼ここからコード▼(クリックで開く)
<html lang="ja"><head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Zero-G Module Sniper - Surprise Update</title> <style> body { margin: 0; overflow: hidden; background-color: #eef0f4; color: white; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; cursor: none; user-select: none; } canvas { display: block; } #ui { position: absolute; top: 20px; left: 20px; pointer-events: none; background: rgba(10, 15, 30, 0.7); padding: 15px; border-radius: 8px; border: 1px solid rgba(0, 200, 255, 0.3); box-shadow: 0 4px 10px rgba(0,0,0,0.3); } h1 { margin: 0; font-size: 20px; color: #00ddff; letter-spacing: 2px; } .stats { margin-top: 8px; font-size: 16px; font-family: monospace; color: #eef0f4; } .stats div { margin: 3px 0; } #score-display { font-size: 20px; color: #ffdf00; font-weight: bold; margin-top: 5px; } #screen-overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: rgba(200, 205, 215, 0.7); display: flex; flex-direction: column; justify-content: center; align-items: center; opacity: 0; pointer-events: none; transition: opacity 0.5s ease; backdrop-filter: blur(4px); } #screen-overlay.active { opacity: 1; pointer-events: auto; cursor: default; } .result-box { background: rgba(15, 22, 42, 0.95); border: 2px solid #00ddff; box-shadow: 0 0 20px rgba(0, 200, 255, 0.3); padding: 30px; border-radius: 12px; text-align: center; max-width: 400px; width: 80%; } .result-box h2 { margin: 0 0 10px 0; color: #00ddff; font-size: 28px; letter-spacing: 2px; } .final-score { font-size: 32px; font-weight: bold; color: #fff; margin: 15px 0; font-family: monospace; } .ranking-list { margin: 20px 0; padding: 0; list-style: none; text-align: left; font-family: monospace; font-size: 16px; } .ranking-list li { padding: 6px 10px; border-bottom: 1px solid rgba(255,255,255,0.1); display: flex; justify-content: space-between; color: #eef0f4;} .ranking-list li.current-run { background: rgba(0, 221, 255, 0.2); color: #00ddff; font-weight: bold; } .btn { background: #00ddff; color: #080a14; border: none; padding: 12px 30px; font-size: 18px; font-weight: bold; border-radius: 6px; cursor: pointer; transition: all 0.2s ease; box-shadow: 0 4px 10px rgba(0, 200, 255, 0.3); } .btn:hover { background: #fff; box-shadow: 0 4px 15px rgba(255, 255, 255, 0.5); transform: translateY(-2px); } .btn:active { transform: translateY(0); } </style></head><body> <div id="ui"> <h1>ZERO-G MODULE SNIPER</h1> <div class="stats"> <div>TIME: <span id="ui-time">0.00</span>s</div> <div>FLAMES: <span id="ui-flames">0/0</span></div> <div id="score-display">SCORE: 10000</div> </div> </div> <div id="screen-overlay"> <div class="result-box"> <h2>MISSION CLEARED</h2> <div id="result-time" style="font-size: 14px; color: #aaa;"></div> <div class="final-score" id="result-score">SCORE: 0</div> <h3 style="margin-top: 20px; font-size: 16px; letter-spacing: 1px; color: #00ddff;">- TOP 5 RANKING -</h3> <ul class="ranking-list" id="ranking-board"></ul> <button class="btn" id="retry-btn">RETRY MISSION</button> </div> </div> <canvas id="gameCanvas"></canvas> <script> const canvas = document.getElementById('gameCanvas'); const ctx = canvas.getContext('2d'); canvas.width = window.innerWidth; canvas.height = window.innerHeight; const uiTime = document.getElementById('ui-time'); const uiFlames = document.getElementById('ui-flames'); const uiScore = document.getElementById('score-display'); const screenOverlay = document.getElementById('screen-overlay'); const resultTime = document.getElementById('result-time'); const resultScore = document.getElementById('result-score'); const rankingBoard = document.getElementById('ranking-board'); const retryBtn = document.getElementById('retry-btn'); let gameState = 'playing'; let startTime = 0; let elapsedFrames = 0; let currentScore = 10000; let globalTime = 0; let mouse = { x: canvas.width / 2, y: canvas.height / 2, isDown: false }; window.addEventListener('mousemove', e => { mouse.x = e.clientX; mouse.y = e.clientY; }); window.addEventListener('mousedown', () => { if(gameState === 'playing') mouse.isDown = true; }); window.addEventListener('mouseup', () => mouse.isDown = false); const spaceStars = Array.from({ length: 200 }).map(() => ({ x: Math.random() * canvas.width * 2, y: Math.random() * canvas.height, size: 0.5 + Math.random() * 1.5, brightness: 0.5 + Math.random() * 0.5 })); function drawBackground(time) { ctx.fillStyle = '#eef0f4'; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.strokeStyle = '#d0d5df'; ctx.lineWidth = 2; for (let x = 0; x < canvas.width; x += 150) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, canvas.height); ctx.stroke(); } for (let y = 0; y < canvas.height; y += 150) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(canvas.width, y); ctx.stroke(); } ctx.fillStyle = '#f39c12'; ctx.shadowColor = 'rgba(0,0,0,0.15)'; ctx.shadowBlur = 8; ctx.fillRect(canvas.width * 0.1, canvas.height * 0.2, 18, 250); ctx.fillRect(canvas.width * 0.85, canvas.height * 0.4, 18, 250); ctx.fillRect(canvas.width * 0.3, canvas.height * 0.08, 300, 18); ctx.shadowBlur = 0; const windowRadius = 190; const cx = canvas.width / 2; const cy = canvas.height / 2; ctx.save(); ctx.beginPath(); ctx.arc(cx, cy, windowRadius, 0, Math.PI * 2); ctx.clip(); ctx.fillStyle = '#020308'; ctx.fillRect(cx - windowRadius, cy - windowRadius, windowRadius * 2, windowRadius * 2); const starOffset = (time * 0.15) % (canvas.width); spaceStars.forEach(star => { let drawX = star.x - starOffset; if (drawX < 0) drawX += canvas.width * 2; ctx.fillStyle = `rgba(255, 255, 255, ${star.brightness})`; ctx.beginPath(); ctx.arc(drawX, star.y, star.size, 0, Math.PI * 2); ctx.fill(); }); const moonOffset = (time * 0.05) % (canvas.width * 1.5); const moonX = (cx + 100) - moonOffset; const moonY = cy - 80; const moonGrad = ctx.createRadialGradient(moonX - 8, moonY - 8, 2, moonX, moonY, 25); moonGrad.addColorStop(0, '#fdfdfd'); moonGrad.addColorStop(0.7, '#a9aeb5'); moonGrad.addColorStop(1, '#050505'); ctx.beginPath(); ctx.arc(moonX, moonY, 25, 0, Math.PI * 2); ctx.fillStyle = moonGrad; ctx.fill(); ctx.fillStyle = 'rgba(0,0,0,0.15)'; ctx.beginPath(); ctx.arc(moonX - 5, moonY + 5, 4, 0, Math.PI*2); ctx.fill(); ctx.beginPath(); ctx.arc(moonX + 8, moonY - 2, 6, 0, Math.PI*2); ctx.fill(); const earthGrad = ctx.createRadialGradient(cx, cy + 280, 0, cx, cy + 280, 300); earthGrad.addColorStop(0, '#2c7fb8'); earthGrad.addColorStop(0.5, '#154b73'); earthGrad.addColorStop(1, '#000000'); ctx.beginPath(); ctx.arc(cx, cy + 280, 300, 0, Math.PI * 2); ctx.fillStyle = earthGrad; ctx.fill(); ctx.beginPath(); ctx.arc(cx, cy + 280, 305, 0, Math.PI * 2); ctx.strokeStyle = 'rgba(100, 220, 255, 0.25)'; ctx.lineWidth = 10; ctx.stroke(); ctx.restore(); ctx.beginPath(); ctx.arc(cx, cy, windowRadius, 0, Math.PI * 2); ctx.strokeStyle = '#9aa1ad'; ctx.lineWidth = 20; ctx.stroke(); ctx.beginPath(); ctx.arc(cx, cy, windowRadius - 8, 0, Math.PI * 2); ctx.strokeStyle = '#111'; ctx.lineWidth = 4; ctx.stroke(); ctx.fillStyle = '#555'; for(let i = 0; i < 12; i++) { let angle = (i / 12) * Math.PI * 2; let bx = cx + Math.cos(angle) * windowRadius; let by = cy + Math.sin(angle) * windowRadius; ctx.beginPath(); ctx.arc(bx, by, 4, 0, Math.PI*2); ctx.fill(); } } class Vector { constructor(x, y) { this.x = x; this.y = y; } add(v) { this.x += v.x; this.y += v.y; return this; } mult(n) { this.x *= n; this.y *= n; return this; } mag() { return Math.hypot(this.x, this.y); } } // 空間を漂うポップアップテキスト class FloatingText { constructor(x, y, text, color) { this.x = x; this.y = y; this.text = text; this.color = color; this.life = 60; this.vy = -1; } draw() { if (this.life <= 0) return; ctx.save(); ctx.globalAlpha = this.life / 60; ctx.fillStyle = this.color; ctx.font = 'bold 24px monospace'; ctx.shadowColor = 'black'; ctx.shadowBlur = 4; ctx.fillText(this.text, this.x, this.y); ctx.restore(); this.y += this.vy; this.life--; } } class Flame { constructor() { this.x = Math.random() * (canvas.width * 0.8) + canvas.width * 0.1; this.y = Math.random() * (canvas.height * 0.5) + canvas.height * 0.1; this.baseRadius = 25 + Math.random() * 15; this.health = 100; this.active = true; } draw(time) { if (!this.active) return; const radius = this.baseRadius * (this.health / 100) + Math.sin(time * 0.1) * 1.5; const grad = ctx.createRadialGradient(this.x, this.y, 0, this.x, this.y, radius); grad.addColorStop(0, 'rgba(200, 240, 255, 1)'); grad.addColorStop(0.3, 'rgba(0, 150, 255, 0.85)'); grad.addColorStop(0.8, 'rgba(0, 50, 200, 0.4)'); grad.addColorStop(1, 'rgba(0, 0, 0, 0)'); ctx.beginPath(); ctx.arc(this.x, this.y, radius, 0, Math.PI * 2); ctx.fillStyle = grad; ctx.fill(); } } // 隠しキャラ:宇宙シマエナガ class SpaceBird { constructor() { this.pos = new Vector(-50, Math.random() * canvas.height * 0.7); this.vel = new Vector(1.5 + Math.random(), (Math.random() - 0.5) * 0.5); this.angle = 0; this.av = 0; this.active = true; this.radius = 25; } update() { this.pos.add(this.vel); this.angle += this.av; if (this.pos.x > canvas.width + 100) this.active = false; } draw() { if (!this.active) return; ctx.save(); ctx.translate(this.pos.x, this.pos.y); ctx.rotate(this.angle); // ヘルメット(宇宙服) ctx.fillStyle = 'rgba(150, 220, 255, 0.2)'; ctx.strokeStyle = 'rgba(150, 220, 255, 0.6)'; ctx.lineWidth = 3; ctx.beginPath(); ctx.arc(0, 0, 32, 0, Math.PI*2); ctx.fill(); ctx.stroke(); // シマエナガ本体 ctx.fillStyle = 'white'; ctx.beginPath(); ctx.arc(0, 0, 22, 0, Math.PI*2); ctx.fill(); ctx.fillStyle = 'black'; // 目 ctx.beginPath(); ctx.arc(-8, -4, 2.5, 0, Math.PI*2); ctx.fill(); ctx.beginPath(); ctx.arc(8, -4, 2.5, 0, Math.PI*2); ctx.fill(); ctx.fillStyle = '#f1c40f'; // くちばし ctx.beginPath(); ctx.moveTo(-4, 1); ctx.lineTo(4, 1); ctx.lineTo(0, 6); ctx.fill(); ctx.restore(); } } class Obstacle { constructor() { this.pos = new Vector(Math.random() * canvas.width, Math.random() * canvas.height * 0.6 + canvas.height * 0.1); this.vel = new Vector((Math.random() - 0.5) * 0.6, (Math.random() - 0.5) * 0.6); this.angle = Math.random() * Math.PI * 2; this.av = (Math.random() - 0.5) * 0.01; const types = [ { type: 'crate', w: 65, h: 65, mass: 200, color: '#e67e22' }, { type: 'wrench', w: 85, h: 22, mass: 60, color: '#7f8c8d' }, { type: 'watermelon', w: 70, h: 70, mass: 180, color: '#1e8449' } // スイカ追加! ]; Object.assign(this, types[Math.floor(Math.random() * types.length)]); this.radius = Math.max(this.w, this.h) * 0.55; } update() { this.pos.add(this.vel); this.angle += this.av; if (this.pos.x - this.radius < 0) { this.pos.x = this.radius; this.vel.x *= -1; } if (this.pos.x + this.radius > canvas.width) { this.pos.x = canvas.width - this.radius; this.vel.x *= -1; } if (this.pos.y - this.radius < 0) { this.pos.y = this.radius; this.vel.y *= -1; } if (this.pos.y + this.radius > canvas.height) { this.pos.y = canvas.height - this.radius; this.vel.y *= -1; } } draw() { ctx.save(); ctx.translate(this.pos.x, this.pos.y); ctx.rotate(this.angle); ctx.shadowColor = 'rgba(0,0,0,0.3)'; ctx.shadowBlur = 10; ctx.shadowOffsetX = 5; ctx.shadowOffsetY = 5; if (this.type === 'watermelon') { // スイカの描画 ctx.beginPath(); ctx.arc(0, 0, this.w/2, 0, Math.PI*2); ctx.fillStyle = this.color; ctx.fill(); ctx.strokeStyle = '#2ecc71'; ctx.lineWidth = 4; for (let s = 0; s < 4; s++) { // スイカの縞模様 ctx.beginPath(); ctx.ellipse(0, 0, this.w/2 * 0.7, this.w/2, s * (Math.PI/4), 0, Math.PI*2); ctx.stroke(); } } else if (this.type === 'crate') { ctx.fillStyle = this.color; ctx.strokeStyle = 'rgba(255, 255, 255, 0.4)'; ctx.lineWidth = 2; ctx.fillRect(-this.w/2, -this.h/2, this.w, this.h); ctx.strokeRect(-this.w/2, -this.h/2, this.w, this.h); ctx.fillStyle = 'rgba(0,0,0,0.15)'; ctx.fillRect(-this.w/2+6, -this.h/2+6, this.w-12, this.h-12); } else if (this.type === 'wrench') { ctx.fillStyle = this.color; ctx.strokeStyle = 'rgba(255, 255, 255, 0.4)'; ctx.lineWidth = 2; ctx.fillRect(-this.w/2, -this.h/2, this.w, this.h); ctx.beginPath(); ctx.arc(-this.w/2, 0, this.h/2 * 1.4, 0, Math.PI*2); ctx.fill(); ctx.beginPath(); ctx.arc(this.w/2, 0, this.h/2 * 1.4, 0, Math.PI*2); ctx.fill(); } ctx.restore(); } } class WaterBlob { constructor(x, y, vel, mass, life) { this.pos = new Vector(x, y); this.vel = vel; this.mass = mass; this.life = life; this.isSplatter = false; } update() { this.pos.add(this.vel); if (!this.isSplatter) this.vel.mult(0.992); this.life--; } draw() { if (this.life <= 0) return; const speed = this.vel.mag(); const angle = Math.atan2(this.vel.y, this.vel.x); let stretch = this.isSplatter ? 1 : 1 + (speed / 6); let thickness = this.isSplatter ? this.mass : this.mass / Math.sqrt(stretch); ctx.save(); ctx.translate(this.pos.x, this.pos.y); ctx.rotate(angle); ctx.beginPath(); ctx.ellipse(0, 0, this.mass * stretch, thickness, 0, 0, Math.PI * 2); const wGrad = ctx.createRadialGradient(0, 0, 0, 0, 0, this.mass * stretch); wGrad.addColorStop(0, 'rgba(255, 255, 255, 0.9)'); wGrad.addColorStop(0.3, 'rgba(80, 170, 255, 0.8)'); wGrad.addColorStop(1, 'rgba(0, 80, 220, 0)'); ctx.fillStyle = wGrad; ctx.fill(); ctx.restore(); } } let flames = []; let obstacles = []; let waterBlobs = []; let texts = []; let birds = []; const playerPos = new Vector(canvas.width / 2, canvas.height); let totalFlamesCount = 6; function initGame() { gameState = 'playing'; elapsedFrames = 0; startTime = performance.now(); currentScore = 10000; waterBlobs = []; texts = []; birds = []; mouse.isDown = false; screenOverlay.classList.remove('active'); flames = Array.from({ length: totalFlamesCount }).map(() => new Flame()); obstacles = Array.from({ length: 8 }).map(() => new Obstacle()); } function handleGameClear(timeInSeconds) { gameState = 'cleared'; mouse.isDown = false; let ranking = JSON.parse(localStorage.getItem('zerog_sniper_ranking')) || []; const currentRunId = Date.now(); ranking.push({ id: currentRunId, score: currentScore, time: timeInSeconds.toFixed(2) }); ranking.sort((a, b) => b.score - a.score); ranking = ranking.slice(0, 5); localStorage.setItem('zerog_sniper_ranking', JSON.stringify(ranking)); resultTime.textContent = `CLEAR TIME: ${timeInSeconds.toFixed(2)} seconds`; resultScore.textContent = `SCORE: ${currentScore.toLocaleString()}`; rankingBoard.innerHTML = ''; ranking.forEach((record, index) => { const li = document.createElement('li'); if (record.id === currentRunId) li.classList.add('current-run'); li.innerHTML = `<span>#${index + 1} ${record.score.toLocaleString()} pts</span><span>(${record.time}s)</span>`; rankingBoard.appendChild(li); }); screenOverlay.classList.add('active'); } function animate() { requestAnimationFrame(animate); globalTime++; drawBackground(globalTime); const activeFlames = flames.filter(f => f.active); if (gameState === 'playing') { elapsedFrames++; const currentTime = ((performance.now() - startTime) / 1000); // 時間経過でスコア減少 if (elapsedFrames % 60 === 0) currentScore = Math.max(100, currentScore - 150); uiTime.textContent = currentTime.toFixed(2); uiFlames.textContent = `${totalFlamesCount - activeFlames.length} / ${totalFlamesCount}`; uiScore.textContent = `SCORE: ${currentScore}`; if (activeFlames.length === 0) handleGameClear(currentTime); if (mouse.isDown && elapsedFrames % 4 === 0) { const target = new Vector(mouse.x, mouse.y); const angle = Math.atan2(target.y - playerPos.y, target.x - playerPos.x); const speed = 17 + Math.random() * 3; const vel = new Vector(Math.cos(angle) * speed, Math.sin(angle) * speed); waterBlobs.push(new WaterBlob(playerPos.x, playerPos.y, vel, 3.5 + Math.random() * 2.5, 140)); } // シマエナガのランダム出現 if (Math.random() < 0.002 && birds.length === 0) { birds.push(new SpaceBird()); } } birds.forEach(b => { b.update(); b.draw(); }); // 障害物同士の衝突判定は省略(軽快さ優先) flames.forEach(f => f.draw(elapsedFrames)); obstacles.forEach(o => { o.update(); o.draw(); }); for (let i = waterBlobs.length - 1; i >= 0; i--) { let w = waterBlobs[i]; w.update(); w.draw(); let collided = false; if (!w.isSplatter) { // 障害物衝突 for (let o of obstacles) { const dx = w.pos.x - o.pos.x; const dy = w.pos.y - o.pos.y; if (Math.hypot(dx, dy) < o.radius + w.mass) { o.vel.add(new Vector(w.vel.x, w.vel.y).mult(w.mass * 0.65 / o.mass)); o.av += (Math.random() - 0.5) * (w.mass * w.vel.mag() / o.mass) * 0.06; for (let k = 0; k < 3; k++) { const splatterAngle = Math.atan2(dy, dx) + (Math.random() - 0.5) * Math.PI; const sp = new WaterBlob(w.pos.x, w.pos.y, new Vector(Math.cos(splatterAngle) * 5, Math.sin(splatterAngle) * 5), w.mass * 0.4, 15); sp.isSplatter = true; waterBlobs.push(sp); } collided = true; break; } } // シマエナガ衝突判定 if (!collided) { for (let b of birds) { if (!b.active) continue; if (Math.hypot(w.pos.x - b.pos.x, w.pos.y - b.pos.y) < b.radius + w.mass) { currentScore += 2000; texts.push(new FloatingText(b.pos.x, b.pos.y - 20, "+2000 CUTE!", "#ff77aa")); b.vel.x += 1; b.av += 0.2; // くるくる回って飛んでいく collided = true; break; } } } } if (!collided && !w.isSplatter) { for (let f of flames) { if (!f.active) continue; if (Math.hypot(w.pos.x - f.x, w.pos.y - f.y) < f.baseRadius + w.mass) { f.health -= 4; if (f.health <= 0) { f.active = false; texts.push(new FloatingText(f.x, f.y, "CLEAR!", "#00ffaa")); } collided = true; break; } } } if (collided || w.life <= 0 || w.pos.x < -50 || w.pos.x > canvas.width+50 || w.pos.y < -50 || w.pos.y > canvas.height+50) { waterBlobs.splice(i, 1); } } // テキストの描画 for (let i = texts.length - 1; i >= 0; i--) { texts[i].draw(); if (texts[i].life <= 0) texts.splice(i, 1); } if (gameState === 'playing') { ctx.strokeStyle = 'rgba(255, 50, 80, 0.9)'; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(mouse.x, mouse.y, 10, 0, Math.PI * 2); ctx.stroke(); ctx.beginPath(); ctx.moveTo(mouse.x - 16, mouse.y); ctx.lineTo(mouse.x + 16, mouse.y); ctx.moveTo(mouse.x, mouse.y - 16); ctx.lineTo(mouse.x, mouse.y + 16); ctx.stroke(); } } retryBtn.addEventListener('click', initGame); window.addEventListener('resize', () => { canvas.width = window.innerWidth; canvas.height = window.innerHeight; playerPos.x = canvas.width / 2; playerPos.y = canvas.height; }); initGame(); animate(); </script></body></html>
■ 今日の学び
今回のゲームは、
「無重力環境での物理的なリアリティを、ゲームの中でどう表現できるだろう?」
という好奇心から作り始めました。
開発の途中、
AIが「無重力では空気が対流しないから炎が丸くなる」など、
宇宙にまつわる雑学をいろいろ教えてくれました。
そのおかげで私自身の理解も深まり、
とても満足のいくゲーム作りになりました。( ੭ ˙࿁˙ )੭ ᐝ
そして最近、私の中でちょっとしたブームになっているのが、
ゲームがひと通り完成したあとに
「ブラッシュアップお願い」と一言だけ AI に頼んでみることです。
すると AI が、ゲームがより面白くなるような
アイデアを最後にそっと提案してくれるのですが……
なんと今回は、これまでのやり取りの中で AI が
「私の好きなもの」として認識していたらしい シマエナガ と スイカ を、
隠し要素として追加してくれたんです!
無機質な宇宙船内にふわふわ漂うスイカと、
ボーナスポイントを持ったレアな宇宙シマエナガ。
まさか私の好みを汲み取ってくれるなんて思っていなかったので、
AI の遊び心に心がぽかぽかしました。( ˶´⚰︎`˵ )
こんなふうに AI との対話そのものを楽しみながら、
これからもいろいろなゲーム作りの実験を続けていきたいと思います。
■ 次回は、水泳レースゲームです。
次回は、平泳ぎのタイミングゲームを作ります。(^v^)
競う相手は
・一瞬前の自分
・前回の自分の記録(ゴースト)
・CPU の3つです。
つまり、自分 × 3 + CPU のにぎやかなレースになります。
タイミングよく平泳ぎの動きを合わせて、
新記録更新を目指してチャレンジしてみてくださいね。
次回もぜひ楽しみにしていてくださいね。(*^v^)ノシ
✨今日も最後まで読んでくださって、ありがとうございます。
みーの実験室は、みなさんの応援で元気に動いています。(*^v^*)






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