Created
February 9, 2025 03:45
-
-
Save ryogrid/ec20d633657277b90161b37361b23100 to your computer and use it in GitHub Desktop.
フルーツ合成パズルゲーム
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!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> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
ChatGPT先生に95%くらい作ってもらって、最後のバグ修正と調整だけ自分でやった。
https://chatgpt.com/share/67a60620-ddc8-8007-b15a-0f89dc56bf66