実験#57:ボウリングと悪夢(THE VOID STRIKE)

■57個目「THE VOID STRIKE

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

今回は、ちょっと不思議で……
少しだけ怖いボウリングゲームを作ってみました。(^_^)
タイトルは 「THE VOID STRIKE」 です!

舞台は、窓もない古びた地下室。
レーンの奥に並んでいるのは、普通の白いピンではありません。
なんと、こっちをじっと見つめてくる
「何か怖いの(ヤツら)」 なんです!(><;)

重いボールを転がして、ギョロギョロ動く目玉や、
蠢く黒い影たちをなぎ倒していきます。

今回こだわったのは、ステージが進むごとに変化していく
「崩壊する世界観」 です。(=v=*)

「もしも、倒しても倒しても終わらない不条理な儀式に迷い込んでしまったら……?」

そんなホラー映画のようなドキドキ感を詰め込んでみました。

全部で5つのステージ(階層)があるんですが、
下の階へ進むにつれて、
ただのカビ臭い木の床だったレーンが赤く染まったり、波打ったり……
どんどん現実から遠ざかっていきます。(@_@;)

ボールをぶつけると「SPLAT!」と派手に弾け飛ぶので、
ちょっと怖いけど、なんだかクセになる爽快感がありますよ。

そして、逃げ場のない第5ステージの最後までたどり着くと……。
どんな結末が待っているか、ぜひご自身の目で確かめてみてくださいね!

果たしてあなたは、無事にこの地下室から帰れるでしょうか。(・_・;)

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

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

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

ボウリングゲームと、以前の実験で登場した「何か怖いの」を掛け合わせたゲームを作りたいと考えています。

AI: いらっしゃい。今日も創作活動、精が出ますね。
ボウリングと、あの不気味な「何か怖いの」の融合……。
日常の娯楽が急に非日常へ変貌する、ゾクッとするアイデアです。

このゲームでは、ピンの代わりに「何か怖いの」がこちらを凝視しています。
ストライクを取れなければ、彼らは少しずつ近づいてくるかもしれません。

みー:(*^_^*) ホラーはファンタジーだと思ってます。
予想外の要素は良いスパイスになるので、どんどん取り入れてみましょう。

ボウリング要素が少し弱いので、ピン同士が弾けた時の干渉や重さを、
もっとリアルに近づけられるかな。
ファンタジーにはリアリティが大切だと思ってます。

AI: その考え、すごく共感します。
「非日常(ファンタジー)」を際立たせるのは、
皮肉にも徹底した「日常の物理(リアリティ)」です。

ピンが重さを持ち、互いにぶつかり合って弾け飛ぶ。
その物理的な感触が「何か怖いの」に生々しさを与えます。

ホラーは、この「物理的に正しいのに、見た目が異常」というギャップで深まりますね。

みー:(*^v^)リアリティ大事ですね。
リトライもできるようにしたいです。
不条理な世界観だけど先を見てみたい。
ホラー映画を見るときのような、中毒性のあるゲームに進化させていきたいね。

AI: 不条理な世界、そして先が見たくなる中毒性……。
100の別世界を創り出すあなたなら、
この狂気も完璧にコードへ落とし込めるはずです。

みー:(つw<) 2投目まで投げられるようにしよう。
背景は古びた木の床で、木目を感じられるボウリングレーンに。
大きな廃屋の一室、窓もなく外界から隔離された仄暗い地下室の世界観。

AI: 素晴らしいですね。 「2投目」というゲーム性の拡張と、
「仄暗い地下室」という世界観の融合。

カビ臭い空気、軋む木の床、逃げ場のない空間。
1投目で倒しきれなかった時、ヤツらの視線はより冷たく、
不気味に感じられるでしょう。

Three.js の3D表現と Matter.js の物理演算を組み合わせ、
その執念と恐怖をブラウザ上に構築しました。

みー:(^_^) 難易度もちょうどいい感じになってきたよ。
ステージが進むごとにカオスが増していくようにしたい。
後戻りできなくなるような錯覚を生む、未知の恐怖への進行にしよう。
演出変化をステージごとにお願い。5ステージまでにします。

AI: 「5つの階層」で完結する、後戻りできない未知への進行……。
ホラーで最も恐ろしいのは、突然の脅威ではなく、
“日常が少しずつ歪み、気づいた時にはもう戻れない” という侵食です。

■ 実際の動作

こちらが、AIが最初に作ってくれたゲーム画面です。
(画像:最初にAIが作ってくれたゲームの画像)

仕上がりのゲーム画面です。
ホラー的な雰囲気と「怖い何か」を、AIがしっかり表現してくれています。

(画像:完成版の画面)


5ショットのうちに「怖い何か」を全部やっつけると、
次のステージへ進めます。

(画像:プレイ中の画面)


ボウリングの玉を当てるとダメージを与えられます。
「SPLAT!」と派手に弾けるので、怖いけれどクセになる爽快感があります。

(画像:ボウリングの玉で退治した時の画面)

ステージが進むと、背景や「怖い何か」の種類が変わっていきます。
階層が深くなるほど、世界はどんどん現実から離れていきます。

(画像:ステージ2の画面)

■ コードのポイント

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

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

ブラウザだけで動く、とてもシンプルなゲームなので、気軽に試してみてくださいね。

▼ 遊ぶまでの手順

1. PC の「メモ帳」(※Windowsの場合)や、  
 Mac の「テキストエディット」など、
 テキストエディタを開きます。
2. 下のコードをコピーして貼り付けます。
3. 「kowainanika.html」など、好きな名前で保存します。
 (※拡張子は .html にしてください)
4. 保存したファイルをブラウザで開けば、すぐに遊べます。

■ 遊び方・操作方法

操作はとってもシンプルです! パソコンのマウスで遊べますよ〜。

① グーッと引っ張って狙う!

画面をタッチ(クリック)したまま、手前(下方向)に向かってグーッと引っ張ります。(´▽`)
引っ張ると赤い「ガイドライン」が出るので、ヤツらがいる方向へ狙いを定めてみてください。

② 指を離してストライク!

狙いが決まったら、指をパッと離します。
引っ張る距離が長いほど、
ボールの勢い(破壊力!)が強くなりますよ(`・ω・´)✨
おもいっきり引っ張って、ド派手に弾き飛ばしちゃいましょう!

③ 5回のチャンスで全滅を目指せ!

1つのステージでボールを投げられる回数は 5回
5回のうちにレーンのヤツらを全部やっつけることができると……

床が崩れ落ちて、次の階層(ステージ)へ進めます。(@_@;)

もし1匹でも生き残ってしまったら、
そのステージはやり直しになっちゃいます。

★ みーのワンポイントアドバイス(=v=*)

ヤツらの中には、赤黒くて重い「お肉みたいなヤツ」や、
軽くてピンボールみたいに跳ねる「黒い影」が混ざっています。

ヤツら同士を激しくぶつけると連鎖してダメージが入るので、
ドミノみたいに巻き込んで倒すのがコツ ですよ〜!

それでは、深淵の底(ステージ5)でお待ちしています……
なんて。(´艸`) ぜひぜひ、遊んでみてくださいね!

▼ここからコード▼(クリックで開く)
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>THE VOID STRIKE - 5 Rituals</title>
<style>
body { margin: 0; background: #000; color: #fff; overflow: hidden; font-family: 'Hiragino Mincho ProN', serif; user-select: none; }
/* 画面の揺れやバグ表現をCSSでも補助 */
#gameContainer { position: relative; width: 450px; height: 700px; margin: 0 auto; overflow: hidden; box-shadow: inset 0 0 100px #000; }
canvas { display: block; background: #0a0502; cursor: crosshair; }
#ui { position: absolute; top: 20px; left: 20px; pointer-events: none; z-index: 5; text-shadow: 2px 2px 4px #000; transition: 0.1s; }
.info { font-size: 18px; color: #ccc; margin-bottom: 5px; font-weight: bold; letter-spacing: 1px; }
.score-val { font-size: 24px; color: #a00; text-shadow: 0 0 10px #f00; }
#overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: flex; flex-direction: column; justify-content: center; align-items: center; background: rgba(0,0,0,0.85); z-index: 10; display: none; }
.msg { font-size: 40px; margin-bottom: 20px; letter-spacing: 5px; text-align: center; }
.sub-msg { font-size: 18px; color: #888; margin-bottom: 30px; text-align: center; }
button { background: #050000; color: #a00; border: 1px solid #a00; padding: 12px 36px; font-size: 18px; cursor: pointer; transition: 0.3s; font-family: serif; letter-spacing: 2px; }
button:hover { background: #300; border-color: #f00; box-shadow: 0 0 20px #f00; color: #fff; }
/* グリッチエフェクト用クラス */
.glitch { animation: glitch-anim 0.2s infinite; }
@keyframes glitch-anim {
0% { transform: translate(0) }
20% { transform: translate(-2px, 1px) }
40% { transform: translate(2px, -1px) }
60% { transform: translate(-1px, 2px) }
80% { transform: translate(1px, -2px) }
100% { transform: translate(0) }
}
</style>
</head>
<body>
<div id="gameContainer">
<div id="ui">
<div class="info" id="stageLabel">STAGE: <span id="stageDisplay" class="score-val">1</span></div>
<div class="info" id="shotLabel">SHOT: <span id="shotDisplay" class="score-val">1 / 5</span></div>
<div style="font-size:12px; color:#555; margin-top:10px;" id="hintText">DRAG DOWN & RELEASE</div>
</div>
<div id="overlay">
<div id="resultMsg" class="msg"></div>
<div id="subMsg" class="sub-msg"></div>
<button type="button" id="actionBtn" onclick="handleOverlayAction()"></button>
</div>
<canvas id="gameCanvas"></canvas>
</div>
<script>
const CONFIG = {
maxStages: 5, // 全5ステージ構成
maxShots: 5,
ballMass: 15,
powerMultiplier: 0.3,
maxSpeed: 50,
basePinHp: 12, // 少しだけ柔らかく調整
restitution: 2.2
};
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
const gameContainer = document.getElementById('gameContainer');
const stageEl = document.getElementById('stageDisplay');
const shotEl = document.getElementById('shotDisplay');
const uiEl = document.getElementById('ui');
const overlay = document.getElementById('overlay');
const resultMsg = document.getElementById('resultMsg');
const subMsg = document.getElementById('subMsg');
const actionBtn = document.getElementById('actionBtn');
canvas.width = 450;
canvas.height = 700;
let stage = 1;
let currentShot = 1;
let gameState = 'PLAYING';
let overlayAction = null;
let globalTime = 0; // 演出用の時間軸
let ball = { x: 225, y: 620, r: 16, vx: 0, vy: 0, active: false, mass: CONFIG.ballMass, trail: [] };
let pins = [];
let particles = [];
let floatingTexts = [];
let splatters = [];
let dragging = false;
let mouseX = 0, mouseY = 0;
let cameraShake = 0;
// === エフェクト類 ===
class Splatter {
constructor(x, y, color, size) {
this.x = x + (Math.random() - 0.5) * 40;
this.y = y + (Math.random() - 0.5) * 40;
this.r = Math.random() * size + 3;
this.color = color;
this.scaleY = 0.5 + Math.random() * 0.5;
}
draw() {
ctx.save();
ctx.fillStyle = this.color;
ctx.globalAlpha = stage >= 4 ? 0.3 : 0.7; // 終盤はシミが薄れ、別の恐怖へ
ctx.translate(this.x, this.y);
ctx.scale(1, this.scaleY);
ctx.beginPath(); ctx.arc(0, 0, this.r, 0, Math.PI * 2); ctx.fill();
ctx.restore();
}
}
class Particle {
constructor(x, y, color, speedMultiplier) {
this.x = x; this.y = y;
this.vx = (Math.random() - 0.5) * 20 * speedMultiplier;
this.vy = (Math.random() - 0.5) * 20 * speedMultiplier;
this.life = 1.0;
this.color = color;
}
update() {
this.x += this.vx; this.y += this.vy;
this.life -= 0.03;
}
draw() {
ctx.globalAlpha = Math.max(0, this.life);
ctx.fillStyle = this.color;
ctx.fillRect(this.x, this.y, stage >= 4 ? 2 : 4, stage >= 4 ? 10 : 4); // 終盤はノイズのように縦長に
ctx.globalAlpha = 1.0;
}
}
class FloatingText {
constructor(x, y, text, color, size = 16) {
this.x = x; this.y = y;
this.text = text; this.color = color;
this.life = 1.0;
this.size = size;
if(stage === 5) this.text = "ERROR"; // 最終層は文字化け
}
update() { this.y -= 1.0; this.life -= 0.015; }
draw() {
ctx.globalAlpha = Math.max(0, this.life);
ctx.fillStyle = this.color;
ctx.font = `bold ${this.size}px serif`;
ctx.fillText(this.text, this.x - this.size, this.y + (stage >= 4 ? (Math.random()-0.5)*5 : 0)); // 終盤は文字が震える
ctx.globalAlpha = 1.0;
}
}
// === ヤツら(ピン) ===
class Pin {
constructor(x, y) {
this.x = x; this.y = y;
this.vx = 0; this.vy = 0;
this.alive = true;
// ステージが進むごとに異形の割合が増える
const rand = Math.random();
let type0Prob = 0.6 - (stage * 0.1);
let type1Prob = 0.85 - (stage * 0.05);
if (stage === 5) {
this.type = 2; // 最終層はすべて「黒い蠢き」
} else if (rand < type0Prob) {
this.type = 0;
} else if (rand < type1Prob) {
this.type = 1;
} else {
this.type = 2;
}
if(this.type === 0) { this.r = 14; this.mass = 2; this.hp = CONFIG.basePinHp; this.blood = "#800"; }
if(this.type === 1) { this.r = 18; this.mass = 4; this.hp = CONFIG.basePinHp * 1.5; this.blood = "#400"; }
if(this.type === 2) { this.r = 10; this.mass = 0.5; this.hp = CONFIG.basePinHp * 0.8; this.blood = "#000"; }
this.hp += stage * 4;
}
takeDamage(amount, impactSpeed) {
this.hp -= amount;
cameraShake = Math.min(20, cameraShake + amount * 0.2); // ダメージで画面が揺れる
if (amount > 15) {
for(let i=0; i<3; i++) splatters.push(new Splatter(this.x, this.y, this.blood, amount/5));
if (splatters.length > 150) splatters.splice(0, splatters.length - 150);
}
if (this.hp <= 0 && this.alive) {
this.alive = false;
let pCount = Math.min(40, amount);
for(let i=0; i<pCount; i++) particles.push(new Particle(this.x, this.y, this.blood, impactSpeed/8));
floatingTexts.push(new FloatingText(this.x, this.y, "SPLAT!", "#f00", 24));
}
}
update() {
if (!this.alive) return;
this.vx *= 0.94; this.vy *= 0.94;
this.x += this.vx; this.y += this.vy;
if (this.x < this.r || this.x > canvas.width - this.r) {
this.x = Math.max(this.r, Math.min(canvas.width - this.r, this.x));
this.vx *= -0.6;
this.takeDamage(Math.abs(this.vx), Math.abs(this.vx));
}
if (this.y < -50 || this.y > canvas.height + 50) {
this.hp = 0;
this.takeDamage(999, 15);
}
}
draw() {
if (!this.alive) return;
ctx.save();
ctx.translate(this.x, this.y);
ctx.shadowColor = "rgba(0, 0, 0, 0.9)";
ctx.shadowBlur = 15;
ctx.shadowOffsetY = 10;
// 階層が深いほど平常時でも震える
const baseShake = stage >= 3 ? (Math.random() - 0.5) * stage : 0;
const damageShake = (Math.random() - 0.5) * (40 / Math.max(1, this.hp));
const shake = baseShake + damageShake;
if (this.type === 0) {
ctx.fillStyle = stage >= 4 ? "#555" : "#e0e0e0";
ctx.beginPath(); ctx.ellipse(shake, shake, this.r, this.r * 0.8, 0, 0, Math.PI * 2); ctx.fill();
ctx.shadowColor = "transparent";
ctx.fillStyle = "#000"; ctx.beginPath(); ctx.arc(shake, shake, 5, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = stage >= 3 ? "#f00" : "#a00"; ctx.beginPath(); ctx.arc(shake, shake, 2, 0, Math.PI * 2); ctx.fill();
} else if (this.type === 1) {
ctx.fillStyle = stage >= 3 ? "#500" : "#300";
ctx.beginPath(); ctx.arc(shake, shake, this.r, 0, Math.PI * 2); ctx.fill();
ctx.shadowColor = "transparent";
ctx.strokeStyle = stage >= 4 ? "#f00" : "#aaa"; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(-10+shake, shake); ctx.lineTo(-5+shake, 5+shake); ctx.lineTo(0+shake, -2+shake); ctx.lineTo(5+shake, 5+shake); ctx.lineTo(10+shake, shake); ctx.stroke();
} else {
ctx.fillStyle = "#050505";
ctx.beginPath(); ctx.arc(shake, shake, this.r, 0, Math.PI * 2); ctx.fill();
ctx.shadowColor = stage >= 4 ? "rgba(255,0,0,0.5)" : "transparent";
ctx.fillStyle = stage >= 4 ? "#fff" : "#500"; ctx.beginPath(); ctx.arc(shake + (Math.random()-0.5)*5, shake + (Math.random()-0.5)*5, 2, 0, Math.PI * 2); ctx.fill();
}
ctx.restore();
}
}
function initPins() {
pins = [];
const rows = 4;
const spacing = 45;
for (let i = 0; i < rows; i++) {
for (let j = 0; j <= i; j++) {
let px = 225 - (i * spacing / 2) + (j * spacing);
// ステージが進むと配置が少し乱れる(狂気の表れ)
let chaos = stage >= 3 ? (Math.random()-0.5)*10 : 0;
let py = 250 - (i * 45) + chaos;
pins.push(new Pin(px, py));
}
}
}
function resolveCollision(p1, p2) {
const dx = p1.x - p2.x; const dy = p1.y - p2.y;
const distance = Math.sqrt(dx * dx + dy * dy);
const minDistance = p1.r + p2.r;
if (distance < minDistance) {
const overlap = minDistance - distance;
const nx = dx / distance; const ny = dy / distance;
p1.x += nx * overlap / 2; p1.y += ny * overlap / 2;
p2.x -= nx * overlap / 2; p2.y -= ny * overlap / 2;
const vdx = p1.vx - p2.vx; const vdy = p1.vy - p2.vy;
const dot = vdx * nx + vdy * ny;
if (dot < 0) {
const impulse = ((1 + CONFIG.restitution) * dot) / (p1.mass + p2.mass);
p1.vx -= impulse * p2.mass * nx; p1.vy -= impulse * p2.mass * ny;
p2.vx += impulse * p1.mass * nx; p2.vy += impulse * p1.mass * ny;
const impactSpeed = Math.abs(impulse);
const impactForce = impactSpeed * p1.mass;
if (p1.takeDamage) p1.takeDamage(impactForce * 3, impactSpeed);
if (p2.takeDamage) p2.takeDamage(impactForce * 3, impactSpeed);
}
}
}
// === 背景(現実崩壊)の描画 ===
function drawEnvironment() {
globalTime++;
if (stage === 1) {
// 【第1階層:木目の地下室】
ctx.fillStyle = "#1a0f0a"; ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.lineWidth = 2;
for(let i=0; i<canvas.width; i+=8) {
ctx.beginPath(); ctx.moveTo(i, 0);
ctx.bezierCurveTo(i + 5, canvas.height/3, i - 5, canvas.height*0.66, i, canvas.height);
ctx.strokeStyle = `rgba(0, 0, 0, 0.2)`; ctx.stroke();
}
const grad = ctx.createRadialGradient(225, 200, 50, 225, 300, 500);
grad.addColorStop(0, "rgba(255, 200, 150, 0.1)"); grad.addColorStop(1, "rgba(0, 0, 0, 0.95)");
ctx.fillStyle = grad; ctx.fillRect(0, 0, canvas.width, canvas.height);
} else if (stage === 2) {
// 【第2階層:血と錆(赤みが増す)】
ctx.fillStyle = "#110000"; ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.lineWidth = 2;
for(let i=0; i<canvas.width; i+=8) {
ctx.beginPath(); ctx.moveTo(i, 0);
ctx.bezierCurveTo(i + 10, canvas.height/3, i - 10, canvas.height*0.66, i, canvas.height);
ctx.strokeStyle = `rgba(50, 0, 0, 0.3)`; ctx.stroke();
}
const grad = ctx.createRadialGradient(225, 200, 50, 225, 300, 500);
grad.addColorStop(0, "rgba(200, 0, 0, 0.15)"); grad.addColorStop(1, "rgba(0, 0, 0, 0.95)");
ctx.fillStyle = grad; ctx.fillRect(0, 0, canvas.width, canvas.height);
} else if (stage === 3) {
// 【第3階層:肉の蠢き(床が波打つ)】
ctx.fillStyle = "#220000"; ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.lineWidth = 3;
let wave = Math.sin(globalTime * 0.05) * 15;
for(let i=0; i<canvas.width; i+=10) {
ctx.beginPath(); ctx.moveTo(i, 0);
ctx.bezierCurveTo(i + wave, canvas.height/3, i - wave, canvas.height*0.66, i, canvas.height);
ctx.strokeStyle = `rgba(100, 0, 0, 0.3)`; ctx.stroke();
}
const grad = ctx.createRadialGradient(225, 200, 50, 225, 300, 500);
grad.addColorStop(0, "rgba(255, 0, 0, 0.2)"); grad.addColorStop(1, "rgba(0, 0, 0, 0.98)");
ctx.fillStyle = grad; ctx.fillRect(0, 0, canvas.width, canvas.height);
uiEl.classList.add('glitch'); // UIが狂い始める
} else if (stage === 4) {
// 【第4階層:認識阻害(ノイズと暗闇)】
ctx.fillStyle = "#000"; ctx.fillRect(0, 0, canvas.width, canvas.height);
// 砂嵐エフェクト
for(let i=0; i<100; i++) {
ctx.fillStyle = Math.random() > 0.8 ? "rgba(255,255,255,0.5)" : "rgba(255,0,0,0.3)";
ctx.fillRect(Math.random()*canvas.width, Math.random()*canvas.height, Math.random()*4, 2);
}
const grad = ctx.createRadialGradient(225, 200, 50, 225, 300, 500);
grad.addColorStop(0, "rgba(100, 0, 0, 0.3)"); grad.addColorStop(1, "rgba(0, 0, 0, 1)");
ctx.fillStyle = grad; ctx.fillRect(0, 0, canvas.width, canvas.height);
document.getElementById('hintText').innerText = "D R A G M E T O H E L L";
} else if (stage === 5) {
// 【最終階層:深淵(The Void)】
ctx.fillStyle = "#000"; ctx.fillRect(0, 0, canvas.width, canvas.height);
// 背景に浮かぶ巨大な「目」
ctx.save();
ctx.translate(225, 200);
let eyeShake = (Math.random()-0.5)*5;
ctx.fillStyle = "rgba(50, 0, 0, 0.4)";
ctx.beginPath(); ctx.ellipse(eyeShake, eyeShake, 180, 100 + Math.sin(globalTime*0.02)*10, 0, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = "rgba(0,0,0,0.8)";
ctx.beginPath(); ctx.arc(eyeShake, eyeShake, 60, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = "rgba(255,0,0,0.8)";
ctx.beginPath(); ctx.arc(eyeShake + Math.sin(globalTime*0.05)*10, eyeShake, 15, 0, Math.PI*2); ctx.fill();
ctx.restore();
// 常に画面が僅かに揺れる
cameraShake = Math.max(cameraShake, 2);
document.getElementById('hintText').innerText = "N O T H I N G L E F T";
document.getElementById('stageDisplay').innerText = "∞";
}
}
function checkResult() {
gameState = 'RESULT';
overlay.style.display = 'flex';
const remaining = pins.filter(p => p.alive).length;
if (remaining === 0) {
if (stage >= CONFIG.maxStages) {
// 【真のエンディング】
resultMsg.innerText = "RITUAL COMPLETE.";
resultMsg.style.color = "#800";
resultMsg.style.textShadow = "0 0 30px #f00";
subMsg.innerText = "YOU CAN NEVER LEAVE. YOU ARE ONE OF THEM NOW.";
actionBtn.style.display = "none"; // 進行不可
uiEl.style.display = "none";
} else {
// 次の階層へ
let msgTitle = currentShot === 1 ? "STRIKE!" : "CLEARED.";
if(stage === 4) msgTitle = "DEEPER..."; // 4クリア時は不穏に
resultMsg.innerText = msgTitle;
resultMsg.style.color = "#f00";
resultMsg.style.textShadow = "0 0 20px #f00";
subMsg.innerText = "THE FLOOR COLLAPSES.";
actionBtn.innerText = "FALL (Next Stage)";
actionBtn.style.display = "inline-block";
overlayAction = nextStage;
}
} else if (currentShot < CONFIG.maxShots) {
resultMsg.innerText = `SHOT ${currentShot + 1}`;
resultMsg.style.color = "#a00";
resultMsg.style.textShadow = "none";
subMsg.innerText = `${remaining} LEFT.`;
actionBtn.innerText = "THROW AGAIN";
actionBtn.style.display = "inline-block";
overlayAction = nextShot;
} else {
resultMsg.innerText = "THEY SURVIVED.";
resultMsg.style.color = "#444";
resultMsg.style.textShadow = "none";
subMsg.innerText = "RESTART THE RITUAL.";
actionBtn.innerText = "RETRY STAGE";
actionBtn.style.display = "inline-block";
overlayAction = retryStage;
}
}
function handleOverlayAction() { if (overlayAction) overlayAction(); }
function nextShot() {
overlay.style.display = 'none';
currentShot++;
shotEl.innerText = `${currentShot} / ${CONFIG.maxShots}`;
resetBall();
pins = pins.filter(p => p.alive);
gameState = 'PLAYING';
}
function nextStage() {
stage++;
stageEl.innerText = stage;
splatters = [];
resetBoard();
}
function retryStage() {
splatters = [];
resetBoard();
}
function resetBall() {
ball.active = false;
ball.x = 225; ball.y = 620; ball.vx = 0; ball.vy = 0; ball.trail = [];
}
function resetBoard() {
overlay.style.display = 'none';
currentShot = 1;
shotEl.innerText = `${currentShot} / ${CONFIG.maxShots}`;
resetBall();
particles = []; floatingTexts = [];
cameraShake = 0;
gameState = 'PLAYING';
initPins();
}
function update() {
if (gameState !== 'PLAYING' && gameState !== 'RESOLVING') return;
if (ball.active) {
ball.x += ball.vx; ball.y += ball.vy;
ball.vx *= 0.98; ball.vy *= 0.98;
if(stage >= 4 && globalTime % 3 === 0) {
// 終盤はボールの軌跡が残る
ball.trail.push({x: ball.x, y: ball.y});
if(ball.trail.length > 20) ball.trail.shift();
}
if (ball.x < ball.r || ball.x > canvas.width - ball.r) {
ball.x = Math.max(ball.r, Math.min(canvas.width - ball.r, ball.x));
ball.vx *= -0.8;
cameraShake = Math.min(10, cameraShake + 3);
}
if (ball.y < -100 || ball.y > canvas.height + 100 || (Math.abs(ball.vx) < 0.1 && Math.abs(ball.vy) < 0.1)) {
ball.active = false;
gameState = 'RESOLVING';
setTimeout(checkResult, 1500);
}
}
pins.forEach(p => {
if (!p.alive) return;
if (ball.active) resolveCollision(ball, p);
pins.forEach(p2 => { if (p !== p2 && p2.alive) resolveCollision(p, p2); });
p.update();
});
particles.forEach(p => p.update()); particles = particles.filter(p => p.life > 0);
floatingTexts.forEach(t => t.update()); floatingTexts = floatingTexts.filter(t => t.life > 0);
// カメラの揺れを減衰
if (cameraShake > 0) cameraShake *= 0.9;
if (cameraShake < 0.5) cameraShake = 0;
}
canvas.addEventListener('mousedown', e => {
if (ball.active || gameState !== 'PLAYING') return;
dragging = true;
const rect = canvas.getBoundingClientRect();
mouseX = e.clientX - rect.left; mouseY = e.clientY - rect.top;
});
canvas.addEventListener('mousemove', e => {
if (!dragging) return;
const rect = canvas.getBoundingClientRect();
mouseX = e.clientX - rect.left; mouseY = e.clientY - rect.top;
});
window.addEventListener('mouseup', e => {
if (!dragging) return;
dragging = false;
const dx = ball.x - mouseX; const dy = ball.y - mouseY;
ball.vx = dx * CONFIG.powerMultiplier; ball.vy = dy * CONFIG.powerMultiplier;
// 最終層は威力が暴走する
let speedCap = stage === 5 ? CONFIG.maxSpeed * 1.5 : CONFIG.maxSpeed;
const speed = Math.sqrt(ball.vx*ball.vx + ball.vy*ball.vy);
if(speed > speedCap) {
ball.vx = (ball.vx/speed) * speedCap; ball.vy = (ball.vy/speed) * speedCap;
}
ball.active = true;
});
function draw() {
// 画面揺れ(シェイク)の適用
let shakeX = (Math.random() - 0.5) * cameraShake;
let shakeY = (Math.random() - 0.5) * cameraShake;
canvas.style.transform = `translate(${shakeX}px, ${shakeY}px)`;
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawEnvironment();
splatters.forEach(s => s.draw());
if (dragging) {
ctx.strokeStyle = stage >= 4 ? "rgba(255, 0, 0, 0.8)" : "rgba(255, 0, 0, 0.4)";
ctx.lineWidth = stage >= 4 ? 3 : 1.5;
ctx.beginPath();
ctx.moveTo(ball.x, ball.y);
ctx.lineTo(ball.x + (ball.x - mouseX)*1.5, ball.y + (ball.y - mouseY)*1.5);
ctx.stroke();
}
// 終盤のボール軌跡
if (stage >= 4 && ball.trail.length > 0) {
ctx.beginPath();
ctx.moveTo(ball.trail[0].x, ball.trail[0].y);
for(let i=1; i<ball.trail.length; i++) ctx.lineTo(ball.trail[i].x, ball.trail[i].y);
ctx.strokeStyle = stage === 5 ? "rgba(255,0,0,0.5)" : "rgba(255,255,255,0.2)";
ctx.lineWidth = ball.r;
ctx.stroke();
}
// ボールの描画(深層へ行くほど異質に)
ctx.shadowColor = stage >= 4 ? "#f00" : "rgba(0,0,0,0.8)";
ctx.shadowBlur = stage >= 4 ? 20 : 10;
ctx.fillStyle = stage === 5 ? "#000" : (stage === 4 ? "#fff" : "#888");
ctx.beginPath(); ctx.arc(ball.x, ball.y, ball.r, 0, Math.PI * 2); ctx.fill();
ctx.shadowColor = "transparent";
if (stage < 5) {
ctx.fillStyle = stage === 4 ? "rgba(255,0,0,0.5)" : "rgba(255,255,255,0.2)";
ctx.beginPath(); ctx.arc(ball.x - 5, ball.y - 5, ball.r/3, 0, Math.PI * 2); ctx.fill();
}
pins.sort((a, b) => a.y - b.y).forEach(p => p.draw());
particles.forEach(p => p.draw());
floatingTexts.forEach(t => t.draw());
update();
requestAnimationFrame(draw);
}
initPins();
draw();
</script>
</body>
</html>


■ 今日の学び

今回のボウリングゲームは、
以前作った『#6 筋肉もりもり森盛り盛り』の時に
「本当はこういうボウリング風のゲームを作りたかったなぁ」と
思っていたことへの、 ちょっとしたリベンジでもありました!(`・ω・´)✨

重いボールを投げて、ピンをド派手にパーン!と吹き飛ばすあの爽快感。
そして、全部のピンを倒しきった時のスッキリ感を、
なんとかブラウザ上で再現できないかなと試行錯誤しました。

今回は、ただのボウリングにするのではなく、
『#13 A〜Zタイピング(THE BACK)』で登場した あの不気味な
「何か怖いもの」を組み合わせてみることにしました。

薄暗い地下室から始まって、どんどん現実が崩壊していくホラーな世界観は、
実は最初から決めていたわけではなくて。

作りながら 「ここをこうしたらもっと怖いかも!」
「こんなエフェクトを入れたらどうだろう?」 と、
思いつきでどんどん設定を足していきました。(@_@;)

最初は「ボウリング」と「ホラー」って合うのかな?と少しドキドキしていましたが、

結果的に、全部なぎ倒す爽快感と、
後戻りできない未知への恐怖がいい感じに組み合わさって、
とっても満足のいく仕上がりになりました!(^▽^)/

過去のゲームで作ったアイデアやキャラクターを、
全く違うジャンルで再登場させたり、組み合わせたりすることで、

予想もしていなかった新しい面白さが生まれるんだなぁと、
今回もすごく良い学びになりました。(=v=*)

これからも、過去の実験室の要素がひょっこり顔を出すかもしれません。

■ 次回は、お絵描きゲームの6個目です。

今回、少し怖いゲームになっていましたので……

次回は、癒しのアート体験ゲームをご紹介します。 フラクタルな集合体が生み出す、やさしい“自然のゆらぎ”を感じてもらえたら嬉しいです。

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

▶ 次の実験はこちら(自然の集合体と癒し) 「さわさわ自然アート」



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

~このブログについて~

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

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


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

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

続きを読む