Skip to content

Instantly share code, notes, and snippets.

@ryogrid
Created February 9, 2025 03:45
Show Gist options
  • Save ryogrid/ec20d633657277b90161b37361b23100 to your computer and use it in GitHub Desktop.
Save ryogrid/ec20d633657277b90161b37361b23100 to your computer and use it in GitHub Desktop.
フルーツ合成パズルゲーム
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>フルーツ合成パズルゲーム</title>
<!-- Matter.js を CDN 経由で読み込み -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/matter-js/0.19.0/matter.min.js"></script>
<style>
body { margin: 0; font-family: sans-serif; }
/* 上部の情報&操作エリア */
#top-bar {
background: #eee;
padding: 10px;
text-align: center;
position: relative;
z-index: 2;
}
#top-bar h1 {
margin: 0;
font-size: 24px;
}
#score, #current-fruit {
display: block;
margin: 5px 0;
}
#instructions {
font-size: 14px;
margin: 5px 0 10px;
line-height: 1.4;
}
/* 操作ボタン群 */
#controls {
text-align: center;
margin-top: 20px;
}
#controls button {
font-size: 60px;
margin: 5px;
width: 150px;
height: 150px;
}
/* ニューゲームボタン(初期は非表示) */
#newGame {
font-size: 20px;
margin: 5px;
padding: 10px 20px;
display: none;
}
/* ゲームエリア(キャンバス) */
#gameCanvas {
display: block;
background: #fafafa;
margin: 0 auto;
border: 1px solid #ccc;
}
/* ゲームオーバー時のオーバーレイ */
#gameOver {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(0,0,0,0.7);
color: #fff;
font-size: 48px;
padding: 20px;
display: none;
pointer-events: none;
z-index: 3;
}
</style>
</head>
<body>
<!-- 上部:タイトル、スコア、操作方法・ルール説明、操作ボタン群 および ニューゲームボタン -->
<div id="top-bar">
<h1>フルーツ合成パズルゲーム</h1>
<span id="score">Score: 0</span>
<!-- "次のフルーツ" 表示はグローバル変数 nextFruitData により更新 -->
<span id="current-fruit">次のフルーツ: さくらんぼ</span>
<p id="instructions">
【操作方法】<br>
左右ボタンで落下位置を調整し、上ボタンでフルーツを落下開始します。<br>
落とすフルーツはサクランボかお邪魔フルーツのどちらかです。<br>
【ゲームルール】<br>
同じ段階の通常フルーツ(お邪魔フルーツ以外)が3つ、くっつくと次の段階のフルーツに進化します。<br>
進化が起きるとゲームエリア内に存在する全てのお邪魔フルーツの中から、進化の段階に応じて決まった数が消えます<br>
フルーツが画面上部の制限ラインを超えるとゲームオーバーです。<br>
</p>
<button id="newGame">ニューゲーム</button>
</div>
<!-- ゲームエリア -->
<canvas id="gameCanvas" width="600" height="800"></canvas>
<!-- 操作ボタン群(左、上、右)をキャンバスの下に配置 -->
<div id="controls">
<button id="left">←</button>
<button id="up">↑</button>
<button id="right">→</button>
</div>
<!-- ゲームオーバー表示 -->
<div id="gameOver">Game Over</div>
<script>
// Matter.js の各モジュールを取り出す
const { Engine, World, Bodies, Body, Composite, Constraint, Events } = Matter;
// エンジン・ワールドの生成
let engine = Engine.create();
let world = engine.world;
// キャンバス関連
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
const width = canvas.width;
const height = canvas.height;
// ※フルーツの基本サイズ(段階1のサイズの目安)
const FRUIT_RADIUS = 50;
// ゲームエリア上部の制限ライン
const TOP_LIMIT = 50;
let score = 0;
let gameOver = false;
let dropX = width / 2;
let currentDroppingFruit = null;
// 通常フルーツ定義(全9段階)
const fruitTypes = [
{ stage: 1, name: "さくらんぼ", color: "#e74c3c" },
{ stage: 2, name: "ぶどう", color: "#8e44ad" },
{ stage: 3, name: "みかん", color: "#e67e22" },
{ stage: 4, name: "りんご", color: "#c0392b" },
{ stage: 5, name: "なし", color: "#27ae60" },
{ stage: 6, name: "もも", color: "#fd79a8" },
{ stage: 7, name: "パイナップル", color: "#f1c40f" },
{ stage: 8, name: "メロン", color: "#2ecc71" },
{ stage: 9, name: "スイカ", color: "#1abc9c" }
];
const currentFruit = fruitTypes[0];
let fruitComposites = [];
let fruitIdCounter = 0;
// 静的な境界の生成
const floor = Bodies.rectangle(width / 2, height + 10, width, 20, { isStatic: true });
const leftWall = Bodies.rectangle(-10, height / 2, 20, height, { isStatic: true });
const rightWall = Bodies.rectangle(width + 10, height / 2, 20, height, { isStatic: true });
World.add(world, [floor, leftWall, rightWall]);
// 次のフルーツのデータを生成する関数
function generateNextFruit() {
if (Math.random() < 0.4) {
return { stage: 1, name: "お邪魔フルーツ", color: "#000000", isOjama: true };
} else {
return { stage: 1, name: "さくらんぼ", color: "#e74c3c", isOjama: false };
}
}
// グローバル変数として次のフルーツデータを保持し、表示更新
let nextFruitData = generateNextFruit();
document.getElementById('current-fruit').innerText = "次のフルーツ: " + nextFruitData.name;
// ─────────────────────────────
// ソフトフルーツ生成関数
// 通常フルーツの場合は、基本サイズに対して 1.2^(stage-1) 倍のサイズ、
// お邪魔フルーツの場合はサイズは固定(baseRadius をそのまま使用)
// fruitData: { stage, name, color, isOjama (任意) }
// fruitData.name は fruitName として保存し、同一種判定に利用する
function createSoftFruit(x, y, baseRadius, fruitData) {
const stage = fruitData.stage;
const color = fruitData.color;
const isOjama = fruitData.isOjama || false;
const fruitName = fruitData.name;
const scaledRadius = isOjama ? baseRadius : baseRadius * Math.pow(1.2, stage - 1);
const bodyOptions = {
restitution: 0.3, friction: 0.8, density: 0.001,
render: { fillStyle: color }
};
const compositeFruit = Composite.create();
// 落下開始時は中心パーツのサイズを通常の約1/3にする
const centerRadius = (scaledRadius * 0.5) / 3;
const centerBody = Bodies.circle(x, y, centerRadius, bodyOptions);
//const numNodes = 12;
const numNodes = 10;
const peripheralBodies = [];
const nodeRadius = scaledRadius * 0.3;
const distance = scaledRadius * 0.9;
for (let i = 0; i < numNodes; i++) {
const angle = (Math.PI * 2 / numNodes) * i;
const px = x + distance * Math.cos(angle);
const py = y + distance * Math.sin(angle);
const node = Bodies.circle(px, py, nodeRadius, bodyOptions);
peripheralBodies.push(node);
}
Composite.add(compositeFruit, centerBody);
Composite.add(compositeFruit, peripheralBodies);
let constraintsList = [];
peripheralBodies.forEach(node => {
constraintsList.push(Constraint.create({
bodyA: centerBody,
bodyB: node,
length: distance,
stiffness: 0.05,
damping: 0.1
}));
});
for (let i = 0; i < peripheralBodies.length; i++) {
let next = peripheralBodies[(i + 1) % peripheralBodies.length];
let chordLength = 2 * distance * Math.sin(Math.PI / numNodes);
constraintsList.push(Constraint.create({
bodyA: peripheralBodies[i],
bodyB: next,
length: chordLength,
stiffness: 0.05,
damping: 0.1
}));
}
Composite.add(compositeFruit, constraintsList);
compositeFruit.fruitStage = stage;
compositeFruit.fruitColor = color;
compositeFruit.fruitName = fruitName;
compositeFruit.fruitID = fruitIdCounter++;
compositeFruit.isMerging = false;
compositeFruit.isActive = true;
compositeFruit.central = centerBody;
if(isOjama) {
compositeFruit.isOjama = true;
}
compositeFruit.spawnTime = Date.now();
Composite.allBodies(compositeFruit).forEach(body => {
body.isFruit = true;
body.fruitStage = stage;
body.fruitComposite = compositeFruit;
body.fruitID = compositeFruit.fruitID;
});
return compositeFruit;
}
// ─────────────────────────────
// 衝突イベント(collisionActive)による合成判定
// 合成発生条件:
// 接触グループ内からお邪魔フルーツを除いた通常フルーツのみを対象に、
// fruitName ごとにグループ化し、連鎖的な接触(直接接触でなくても連鎖的な接触をもって連結状態とする)によって
// 同一種の通常フルーツの最終連結成分のサイズが3体以上であれば合成が発生する。
// ※お邪魔フルーツは条件カウント対象にならず、合成が発生した場合は接触していた全フルーツ(通常+お邪魔)のうち、
// お邪魔フルーツはゲームエリア内に存在する全候補から、合成後の段階 newStage に応じた最大数(2×(newStage–1) 個まで)をランダムに選択して削除する。
Events.on(engine, 'collisionActive', function(event) {
const contacts = {};
event.pairs.forEach(pair => {
const bodyA = pair.bodyA, bodyB = pair.bodyB;
if(bodyA.isFruit && bodyB.isFruit) {
const compA = bodyA.fruitComposite;
const compB = bodyB.fruitComposite;
if(compA && compB && compA.fruitID !== compB.fruitID && bodyA.fruitStage === bodyB.fruitStage) {
const idA = String(compA.fruitID);
const idB = String(compB.fruitID);
if(!contacts[idA]) contacts[idA] = new Set();
if(!contacts[idB]) contacts[idB] = new Set();
contacts[idA].add(idB);
contacts[idB].add(idA);
}
}
});
isMargeOccurred = false;
const visited = new Set();
Object.keys(contacts).forEach(id => {
if(visited.has(id)) return;
if(isMargeOccurred) return;
const group = new Set();
const stack = [id];
while(stack.length) {
const current = stack.pop();
if(!group.has(current)) {
group.add(current);
visited.add(current);
if(contacts[current]) {
contacts[current].forEach(neighbor => {
if(!group.has(neighbor)) {
stack.push(neighbor);
}
});
}
}
}
// グループ内の合成候補(未合成のもの)
let groupComposites = fruitComposites.filter(comp => group.has(String(comp.fruitID)) && !comp.isMerging);
// お邪魔フルーツを除いた通常フルーツのみ
let normalComposites = groupComposites.filter(comp => !comp.isOjama);
// fruitName ごとにグループ化
const groupsByName = {};
normalComposites.forEach(comp => {
const name = comp.fruitName;
if(!groupsByName[name]) groupsByName[name] = [];
groupsByName[name].push(comp);
});
let mergeTriggered = false;
for (let name in groupsByName) {
if (groupsByName[name].length >= 3) {
// DFS により直接接触情報から連結状態の連結成分のサイズを求める
let nodes = groupsByName[name].map(c => String(c.fruitID));
let visitedNodes = new Set();
let maxComponentSize = 0;
nodes.forEach(node => {
if(!visitedNodes.has(node)) {
let component = new Set();
let stack2 = [node];
while(stack2.length > 0) {
let cur = stack2.pop();
if(!component.has(cur)) {
component.add(cur);
visitedNodes.add(cur);
if(contacts[cur]) {
contacts[cur].forEach(neighbor => {
if(nodes.includes(neighbor) && !component.has(neighbor)) {
stack2.push(neighbor);
}
});
}
}
}
maxComponentSize = Math.max(maxComponentSize, component.size);
}
});
if(maxComponentSize >= 3) {
mergeTriggered = true;
break;
}
}
}
if(mergeTriggered) {
// 合成対象:グループ内の通常フルーツは全て消滅する
let normalGroup = groupComposites.filter(comp => !comp.isOjama);
// お邪魔フルーツは、ゲームエリア内に存在する全ての候補から選ぶ
let allOjama = fruitComposites.filter(comp => comp.isOjama);
// 新たな段階は、対象となった通常フルーツの段階 + 1
const newStage = normalGroup[0].fruitStage + 1;
// 最大消滅数は、段階ごとに2ずつ増加するので、2×(newStage–1) 個まで
let maxOjamaRemoval = 2 * (newStage - 1);
let ojamaToRemove = [];
if(allOjama.length > maxOjamaRemoval) {
let shuffled = allOjama.sort(() => 0.5 - Math.random());
ojamaToRemove = shuffled.slice(0, maxOjamaRemoval);
} else {
ojamaToRemove = allOjama;
}
let mergeX = 0, mergeY = 0;
normalGroup.forEach(comp => {
mergeX += comp.central.position.x;
mergeY += comp.central.position.y;
});
mergeX /= normalGroup.length;
mergeY /= normalGroup.length;
let fusionGroup = normalGroup.concat(ojamaToRemove);
fusionGroup.forEach(comp => { comp.isMerging = true; });
// let mergeX = 0, mergeY = 0;
// fusionGroup.forEach(comp => {
// mergeX += comp.central.position.x;
// mergeY += comp.central.position.y;
// });
// mergeX /= fusionGroup.length;
// mergeY /= fusionGroup.length;
fusionGroup.forEach(comp => {
Composite.remove(world, comp);
});
removeGroup = new Set(fusionGroup.map(comp => String(comp.fruitID)));
// グループ全体を fruitComposites 配列から除去
//fruitComposites = fruitComposites.filter(comp => !group.has(String(comp.fruitID)));
fruitComposites = fruitComposites.filter(comp => !removeGroup.has(String(comp.fruitID)));
if(newStage <= 9) {
// 合成後の新たなフルーツは常に通常フルーツとして生成する
let fruitInfo = fruitTypes[newStage - 1];
let baseSize = FRUIT_RADIUS;
const newFruit = createSoftFruit(mergeX, mergeY, baseSize, fruitInfo);
Composite.add(world, newFruit);
fruitComposites.push(newFruit);
score += newStage * 10;
updateScore();
//if(currentDroppingFruit && group.has(String(currentDroppingFruit.fruitID))) {
if(currentDroppingFruit && removeGroup.has(String(currentDroppingFruit.fruitID))) {
currentDroppingFruit = null;
}
}
isMargeOccurred = true;
}
});
});
// スコア表示更新
function updateScore() {
document.getElementById('score').innerText = "Score: " + score;
}
// ─────────────────────────────
// 各操作ボタンのイベントリスナ
// 左右ボタンは長押し対応のため mousedown/touchstart と mouseup/touchend を設定
let leftInterval, rightInterval;
const leftBtn = document.getElementById('left');
const rightBtn = document.getElementById('right');
// 左ボタン
leftBtn.addEventListener('mousedown', function() {
leftInterval = setInterval(function() {
dropX -= 20;
if (dropX < FRUIT_RADIUS) dropX = FRUIT_RADIUS;
}, 100);
});
leftBtn.addEventListener('mouseup', function() {
clearInterval(leftInterval);
});
leftBtn.addEventListener('mouseleave', function() {
clearInterval(leftInterval);
});
leftBtn.addEventListener('touchstart', function(e) {
e.preventDefault();
leftInterval = setInterval(function() {
dropX -= 20;
if (dropX < FRUIT_RADIUS) dropX = FRUIT_RADIUS;
}, 100);
});
leftBtn.addEventListener('touchend', function() {
clearInterval(leftInterval);
});
// 右ボタン
rightBtn.addEventListener('mousedown', function() {
rightInterval = setInterval(function() {
dropX += 20;
if (dropX > width - FRUIT_RADIUS) dropX = width - FRUIT_RADIUS;
}, 100);
});
rightBtn.addEventListener('mouseup', function() {
clearInterval(rightInterval);
});
rightBtn.addEventListener('mouseleave', function() {
clearInterval(rightInterval);
});
rightBtn.addEventListener('touchstart', function(e) {
e.preventDefault();
rightInterval = setInterval(function() {
dropX += 20;
if (dropX > width - FRUIT_RADIUS) dropX = width - FRUIT_RADIUS;
}, 100);
});
rightBtn.addEventListener('touchend', function() {
clearInterval(rightInterval);
});
document.getElementById('up').addEventListener('click', function() {
if (!gameOver && !currentDroppingFruit) {
const spawnY = TOP_LIMIT + FRUIT_RADIUS;
// 次のフルーツはグローバル変数 nextFruitData により事前に決定
let fruitData = nextFruitData;
let baseSize = fruitData.isOjama ? FRUIT_RADIUS * 1.5 : FRUIT_RADIUS;
const newFruit = createSoftFruit(dropX, spawnY, baseSize, fruitData);
Composite.add(world, newFruit);
fruitComposites.push(newFruit);
currentDroppingFruit = newFruit;
score += fruitData.stage * 10;
updateScore();
// 新たな次のフルーツを生成し、表示更新
nextFruitData = generateNextFruit();
document.getElementById('current-fruit').innerText = "次のフルーツ: " + nextFruitData.name;
}
});
// ─────────────────────────────
// ニューゲームボタンの処理
const newGameBtn = document.getElementById('newGame');
newGameBtn.addEventListener('click', function() {
// 1. ゲームオーバー状態を解除
gameOver = false;
// 2. スコア、落下位置、落下中フルーツの初期化
score = 0;
dropX = width / 2;
currentDroppingFruit = null;
// 3. fruitComposites 配列に含まれる全フルーツを World から削除
fruitComposites.forEach(comp => {
Composite.remove(world, comp);
});
fruitComposites = [];
// 4. ワールド内の全コンポジットから、fruitID を持つものを削除する
const allComposites = Composite.allComposites(world).slice();
allComposites.forEach(comp => {
if(comp.fruitID !== undefined) {
Composite.remove(world, comp);
}
});
// 5. 次のフルーツのデータを再生成して表示更新
nextFruitData = generateNextFruit();
document.getElementById('current-fruit').innerText = "次のフルーツ: " + nextFruitData.name;
// 6. ニューゲームボタンを非表示、操作ボタン群を再表示
newGameBtn.style.display = "none";
document.getElementById('controls').style.display = "block";
// 7. ゲームオーバーメッセージを非表示
document.getElementById('gameOver').style.display = "none";
updateScore();
});
// ゲームオーバー時の処理:操作ボタン群を非表示し、ニューゲームボタンを表示する
function checkGameOver() {
if (gameOver) {
document.getElementById('controls').style.display = "none";
newGameBtn.style.display = "block";
}
}
function updateWorld() {
if (!gameOver) {
Engine.update(engine, 1000 / 60);
ctx.clearRect(0, 0, width, height);
ctx.beginPath();
ctx.moveTo(0, TOP_LIMIT);
ctx.lineTo(width, TOP_LIMIT);
ctx.strokeStyle = 'red';
ctx.lineWidth = 2;
ctx.stroke();
ctx.beginPath();
ctx.arc(dropX, TOP_LIMIT - 10, 5, 0, Math.PI * 2);
ctx.fillStyle = 'black';
ctx.fill();
Composite.allBodies(world).forEach(body => {
if (body.circleRadius) {
ctx.beginPath();
ctx.arc(body.position.x, body.position.y, body.circleRadius, 0, Math.PI * 2);
ctx.fillStyle = body.isFruit ? (body.render.fillStyle || "#999") : "#666";
ctx.fill();
}
});
if (currentDroppingFruit) {
const vel = currentDroppingFruit.central.velocity;
const speed = Math.hypot(vel.x, vel.y);
if (speed < 0.5) {
currentDroppingFruit.isActive = false;
currentDroppingFruit = null;
}
}
const graceTime = 1000;
fruitComposites.forEach(comp => {
if (!comp.isActive &&
Date.now() - comp.spawnTime > graceTime &&
comp.central.position.y - FRUIT_RADIUS < TOP_LIMIT) {
gameOver = true;
document.getElementById('gameOver').style.display = 'block';
}
});
}
checkGameOver();
}
// ─────────────────────────────
// メインループ:Matter.Engine の更新およびキャンバスへの描画
function mainLoop() {
updateWorld();
requestAnimationFrame(mainLoop);
}
mainLoop();
</script>
</body>
</html>
@ryogrid
Copy link
Author

ryogrid commented Feb 9, 2025

ChatGPT先生に95%くらい作ってもらって、最後のバグ修正と調整だけ自分でやった。
https://chatgpt.com/share/67a60620-ddc8-8007-b15a-0f89dc56bf66

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment