Spaces:
Running
Running
<html lang="en"> | |
<head> | |
<meta charset="UTF-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
<title>桜のリズム - Sakura Rhythm</title> | |
<script src="https://cdn.tailwindcss.com"></script> | |
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> | |
<style> | |
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@400;700&display=swap'); | |
body { | |
font-family: 'Noto Sans JP', sans-serif; | |
background-color: #0f0f1a; | |
color: #f8f8f8; | |
overflow: hidden; | |
} | |
.sakura { | |
position: absolute; | |
background-color: #ffb7c5; | |
width: 6px; | |
height: 6px; | |
border-radius: 50% 0 50% 50%; | |
transform: rotate(-45deg); | |
opacity: 0; | |
animation: fall linear infinite; | |
} | |
@keyframes fall { | |
0% { | |
transform: translateY(-10vh) rotate(-45deg); | |
opacity: 0; | |
} | |
10% { | |
opacity: 1; | |
} | |
90% { | |
opacity: 1; | |
} | |
100% { | |
transform: translateY(110vh) rotate(-45deg); | |
opacity: 0; | |
} | |
} | |
.hit-circle { | |
position: absolute; | |
width: 80px; | |
height: 80px; | |
border-radius: 50%; | |
background: radial-gradient(circle, #ff3366 0%, #990033 100%); | |
box-shadow: 0 0 20px #ff3366; | |
display: flex; | |
justify-content: center; | |
align-items: center; | |
color: white; | |
font-weight: bold; | |
font-size: 24px; | |
transform: scale(0); | |
opacity: 0; | |
transition: transform 0.2s, opacity 0.2s; | |
z-index: 10; | |
} | |
.hit-circle.active { | |
transform: scale(1); | |
opacity: 1; | |
} | |
.hit-circle.perfect { | |
background: radial-gradient(circle, #66ff33 0%, #339900 100%); | |
box-shadow: 0 0 20px #66ff33; | |
} | |
.hit-circle.good { | |
background: radial-gradient(circle, #3366ff 0%, #003399 100%); | |
box-shadow: 0 0 20px #3366ff; | |
} | |
.hit-circle.miss { | |
background: radial-gradient(circle, #888888 0%, #333333 100%); | |
box-shadow: 0 0 20px #888888; | |
} | |
.combo-display { | |
position: absolute; | |
font-size: 48px; | |
font-weight: bold; | |
color: #ffcc00; | |
text-shadow: 0 0 10px #ff9900; | |
opacity: 0; | |
transform: scale(0.5); | |
transition: all 0.3s; | |
z-index: 20; | |
} | |
.combo-display.show { | |
opacity: 1; | |
transform: scale(1); | |
} | |
.judgement { | |
position: absolute; | |
font-size: 36px; | |
font-weight: bold; | |
opacity: 0; | |
transform: translateY(20px); | |
transition: all 0.3s; | |
z-index: 20; | |
} | |
.judgement.show { | |
opacity: 1; | |
transform: translateY(0); | |
} | |
.judgement.perfect { | |
color: #66ff33; | |
text-shadow: 0 0 10px #339900; | |
} | |
.judgement.good { | |
color: #3366ff; | |
text-shadow: 0 0 10px #003399; | |
} | |
.judgement.miss { | |
color: #888888; | |
text-shadow: 0 0 10px #333333; | |
} | |
.progress-bar { | |
height: 4px; | |
background: linear-gradient(90deg, #ff3366, #990033); | |
transition: width 0.1s; | |
} | |
.torii-gate { | |
position: absolute; | |
bottom: 0; | |
left: 50%; | |
transform: translateX(-50%); | |
width: 200px; | |
height: 150px; | |
background-color: #cc0033; | |
clip-path: polygon(0 0, 100% 0, 90% 100%, 10% 100%); | |
z-index: 5; | |
} | |
.torii-gate:before { | |
content: ''; | |
position: absolute; | |
top: 30px; | |
left: 20px; | |
right: 20px; | |
height: 20px; | |
background-color: #fff; | |
clip-path: polygon(0 0, 100% 0, 95% 100%, 5% 100%); | |
} | |
.torii-gate:after { | |
content: '鳥居'; | |
position: absolute; | |
bottom: 10px; | |
left: 0; | |
right: 0; | |
text-align: center; | |
color: white; | |
font-weight: bold; | |
} | |
</style> | |
</head> | |
<body class="relative h-screen w-screen"> | |
<!-- Sakura blossoms --> | |
<div id="sakura-container"></div> | |
<!-- Game elements --> | |
<div id="game-container" class="absolute inset-0 flex flex-col"> | |
<!-- Header --> | |
<div class="flex justify-between items-center p-4"> | |
<div class="text-2xl font-bold text-pink-500">桜のリズム</div> | |
<div class="flex space-x-4"> | |
<div class="bg-gray-800 bg-opacity-50 rounded-full px-4 py-1"> | |
<span class="text-yellow-300">スコア: </span> | |
<span id="score" class="font-bold">0</span> | |
</div> | |
<div class="bg-gray-800 bg-opacity-50 rounded-full px-4 py-1"> | |
<span class="text-blue-300">コンボ: </span> | |
<span id="combo" class="font-bold">0</span> | |
</div> | |
<div class="bg-gray-800 bg-opacity-50 rounded-full px-4 py-1"> | |
<span class="text-green-300">精度: </span> | |
<span id="accuracy" class="font-bold">100%</span> | |
</div> | |
</div> | |
</div> | |
<!-- Progress bar --> | |
<div class="w-full bg-gray-800 bg-opacity-50"> | |
<div id="progress-bar" class="progress-bar" style="width: 0%"></div> | |
</div> | |
<!-- Main game area --> | |
<div class="flex-1 relative overflow-hidden"> | |
<!-- Torii gate at bottom --> | |
<div class="torii-gate"></div> | |
<!-- Hit circles will be dynamically added here --> | |
</div> | |
<!-- Controls --> | |
<div class="flex justify-center p-4 space-x-8"> | |
<button id="start-btn" class="bg-pink-600 hover:bg-pink-700 text-white font-bold py-2 px-6 rounded-full transition-all transform hover:scale-105"> | |
<i class="fas fa-play mr-2"></i>スタート | |
</button> | |
<button id="pause-btn" class="bg-gray-600 hover:bg-gray-700 text-white font-bold py-2 px-6 rounded-full transition-all transform hover:scale-105" disabled> | |
<i class="fas fa-pause mr-2"></i>一時停止 | |
</button> | |
<div class="flex items-center space-x-2"> | |
<span class="text-gray-400">難易度:</span> | |
<select id="difficulty" class="bg-gray-800 text-white rounded px-3 py-1"> | |
<option value="easy">簡単</option> | |
<option value="medium" selected>普通</option> | |
<option value="hard">難しい</option> | |
<option value="expert">達人</option> | |
</select> | |
</div> | |
</div> | |
</div> | |
<!-- Song selection modal --> | |
<div id="song-modal" class="fixed inset-0 bg-black bg-opacity-80 flex items-center justify-center z-50"> | |
<div class="bg-gray-900 rounded-lg p-6 w-full max-w-2xl"> | |
<h2 class="text-2xl font-bold text-pink-500 mb-6 text-center">曲を選んでください</h2> | |
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> | |
<div class="song-card bg-gray-800 rounded-lg p-4 hover:bg-gray-700 transition cursor-pointer" data-song="1"> | |
<div class="text-xl font-bold text-white">桜の詩</div> | |
<div class="text-gray-400">BPM: 120</div> | |
</div> | |
<div class="song-card bg-gray-800 rounded-lg p-4 hover:bg-gray-700 transition cursor-pointer" data-song="2"> | |
<div class="text-xl font-bold text-white">月のワルツ</div> | |
<div class="text-gray-400">BPM: 140</div> | |
</div> | |
<div class="song-card bg-gray-800 rounded-lg p-4 hover:bg-gray-700 transition cursor-pointer" data-song="3"> | |
<div class="text-xl font-bold text-white">風の唄</div> | |
<div class="text-gray-400">BPM: 160</div> | |
</div> | |
<div class="song-card bg-gray-800 rounded-lg p-4 hover:bg-gray-700 transition cursor-pointer" data-song="4"> | |
<div class="text-xl font-bold text-white">雷鳴</div> | |
<div class="text-gray-400">BPM: 180</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
<script> | |
// Game state | |
const state = { | |
score: 0, | |
combo: 0, | |
hits: 0, | |
totalHits: 0, | |
gameActive: false, | |
currentSong: null, | |
difficulty: 'medium', | |
hitObjects: [], | |
nextHitTime: 0, | |
startTime: 0, | |
currentTime: 0, | |
audioContext: null, | |
audioBuffer: null, | |
audioSource: null, | |
analyser: null, | |
animationFrame: null | |
}; | |
// Difficulty settings | |
const difficulties = { | |
easy: { speed: 1.0, spawnRate: 1500, accuracyWindow: 300 }, | |
medium: { speed: 1.2, spawnRate: 1200, accuracyWindow: 250 }, | |
hard: { speed: 1.5, spawnRate: 900, accuracyWindow: 200 }, | |
expert: { speed: 2.0, spawnRate: 600, accuracyWindow: 150 } | |
}; | |
// Songs data | |
const songs = { | |
1: { name: '桜の詩', bpm: 120, pattern: [1, 2, 3, 4] }, | |
2: { name: '月のワルツ', bpm: 140, pattern: [1, 3, 2, 4] }, | |
3: { name: '風の唄', bpm: 160, pattern: [1, 4, 2, 3] }, | |
4: { name: '雷鳴', bpm: 180, pattern: [1, 2, 1, 3, 4] } | |
}; | |
// DOM elements | |
const elements = { | |
gameContainer: document.getElementById('game-container'), | |
sakuraContainer: document.getElementById('sakura-container'), | |
score: document.getElementById('score'), | |
combo: document.getElementById('combo'), | |
accuracy: document.getElementById('accuracy'), | |
progressBar: document.getElementById('progress-bar'), | |
startBtn: document.getElementById('start-btn'), | |
pauseBtn: document.getElementById('pause-btn'), | |
difficulty: document.getElementById('difficulty'), | |
songModal: document.getElementById('song-modal'), | |
songCards: document.querySelectorAll('.song-card') | |
}; | |
// Initialize game | |
function init() { | |
// Create sakura blossoms | |
createSakura(); | |
// Event listeners | |
elements.startBtn.addEventListener('click', startGame); | |
elements.pauseBtn.addEventListener('click', pauseGame); | |
elements.difficulty.addEventListener('change', updateDifficulty); | |
elements.songCards.forEach(card => { | |
card.addEventListener('click', () => selectSong(card.dataset.song)); | |
}); | |
// Show song selection modal | |
elements.songModal.classList.remove('hidden'); | |
// Set up keyboard controls | |
document.addEventListener('keydown', handleKeyPress); | |
// Set up click/tap controls | |
elements.gameContainer.addEventListener('click', handleTap); | |
} | |
// Create sakura blossoms | |
function createSakura() { | |
for (let i = 0; i < 30; i++) { | |
const sakura = document.createElement('div'); | |
sakura.className = 'sakura'; | |
// Random position and size | |
const size = Math.random() * 6 + 4; | |
sakura.style.width = `${size}px`; | |
sakura.style.height = `${size}px`; | |
sakura.style.left = `${Math.random() * 100}%`; | |
// Random animation duration and delay | |
const duration = Math.random() * 10 + 5; | |
const delay = Math.random() * 10; | |
sakura.style.animationDuration = `${duration}s`; | |
sakura.style.animationDelay = `${delay}s`; | |
elements.sakuraContainer.appendChild(sakura); | |
} | |
} | |
// Select song | |
function selectSong(songId) { | |
state.currentSong = songs[songId]; | |
elements.songModal.classList.add('hidden'); | |
// Update UI with song info | |
document.querySelector('.text-pink-500').textContent = `桜のリズム - ${state.currentSong.name}`; | |
// Initialize audio context | |
state.audioContext = new (window.AudioContext || window.webkitAudioContext)(); | |
state.analyser = state.audioContext.createAnalyser(); | |
// For demo purposes, we'll simulate audio with a timer | |
// In a real game, you would load actual audio files here | |
} | |
// Update difficulty | |
function updateDifficulty() { | |
state.difficulty = elements.difficulty.value; | |
} | |
// Start game | |
function startGame() { | |
if (!state.currentSong) { | |
elements.songModal.classList.remove('hidden'); | |
return; | |
} | |
if (state.gameActive) return; | |
// Reset game state | |
state.score = 0; | |
state.combo = 0; | |
state.hits = 0; | |
state.totalHits = 0; | |
state.hitObjects = []; | |
state.nextHitTime = 0; | |
state.gameActive = true; | |
// Update UI | |
elements.score.textContent = '0'; | |
elements.combo.textContent = '0'; | |
elements.accuracy.textContent = '100%'; | |
elements.progressBar.style.width = '0%'; | |
// Clear existing hit objects | |
document.querySelectorAll('.hit-circle').forEach(el => el.remove()); | |
document.querySelectorAll('.combo-display').forEach(el => el.remove()); | |
document.querySelectorAll('.judgement').forEach(el => el.remove()); | |
// Enable/disable buttons | |
elements.startBtn.disabled = true; | |
elements.pauseBtn.disabled = false; | |
// Start music (simulated) | |
state.startTime = Date.now(); | |
state.currentTime = 0; | |
// Start game loop | |
gameLoop(); | |
} | |
// Pause game | |
function pauseGame() { | |
if (!state.gameActive) return; | |
state.gameActive = false; | |
// Pause audio | |
if (state.audioSource) { | |
state.audioSource.stop(); | |
state.audioSource = null; | |
} | |
// Cancel animation frame | |
if (state.animationFrame) { | |
cancelAnimationFrame(state.animationFrame); | |
state.animationFrame = null; | |
} | |
// Update buttons | |
elements.startBtn.disabled = false; | |
elements.pauseBtn.disabled = true; | |
} | |
// Game loop | |
function gameLoop() { | |
if (!state.gameActive) return; | |
state.currentTime = Date.now() - state.startTime; | |
// Update progress bar | |
const progress = (state.currentTime / 60000) * 100; // Demo: 1 minute song | |
elements.progressBar.style.width = `${Math.min(progress, 100)}%`; | |
// Spawn hit objects | |
const difficultySettings = difficulties[state.difficulty]; | |
const bpm = state.currentSong.bpm; | |
const beatInterval = 60000 / bpm; | |
if (state.currentTime >= state.nextHitTime) { | |
spawnHitObject(); | |
state.nextHitTime = state.currentTime + beatInterval / difficultySettings.speed; | |
} | |
// Remove old hit objects | |
cleanupHitObjects(); | |
// Continue loop | |
state.animationFrame = requestAnimationFrame(gameLoop); | |
// End game after 1 minute (demo) | |
if (state.currentTime >= 60000) { | |
endGame(); | |
} | |
} | |
// Spawn hit object | |
function spawnHitObject() { | |
const gameWidth = elements.gameContainer.clientWidth; | |
const gameHeight = elements.gameContainer.clientHeight; | |
// Random position (avoid edges) | |
const x = Math.random() * (gameWidth - 160) + 80; | |
const y = Math.random() * (gameHeight - 200) + 80; | |
// Create hit circle | |
const hitCircle = document.createElement('div'); | |
hitCircle.className = 'hit-circle'; | |
hitCircle.style.left = `${x}px`; | |
hitCircle.style.top = `${y}px`; | |
// Set number (1-4) | |
const number = state.currentSong.pattern[Math.floor(Math.random() * state.currentSong.pattern.length)]; | |
hitCircle.textContent = number; | |
hitCircle.dataset.number = number; | |
// Set timeout for auto-miss | |
const difficultySettings = difficulties[state.difficulty]; | |
hitCircle.dataset.spawnTime = state.currentTime; | |
// Add to DOM | |
elements.gameContainer.appendChild(hitCircle); | |
// Animate in | |
setTimeout(() => { | |
hitCircle.classList.add('active'); | |
}, 10); | |
// Add to state | |
state.hitObjects.push({ | |
element: hitCircle, | |
spawnTime: state.currentTime, | |
hit: false | |
}); | |
state.totalHits++; | |
} | |
// Clean up old hit objects | |
function cleanupHitObjects() { | |
const difficultySettings = difficulties[state.difficulty]; | |
const missThreshold = difficultySettings.accuracyWindow * 2; | |
for (let i = state.hitObjects.length - 1; i >= 0; i--) { | |
const obj = state.hitObjects[i]; | |
const age = state.currentTime - obj.spawnTime; | |
if (!obj.hit && age > missThreshold) { | |
// Missed this hit object | |
obj.element.classList.remove('active'); | |
obj.element.classList.add('miss'); | |
obj.hit = true; | |
// Show miss judgement | |
showJudgement('miss', obj.element); | |
// Reset combo | |
state.combo = 0; | |
elements.combo.textContent = '0'; | |
// Remove after animation | |
setTimeout(() => { | |
obj.element.remove(); | |
state.hitObjects.splice(i, 1); | |
}, 500); | |
} else if (obj.hit && age > 1000) { | |
// Remove hit objects after they've been judged | |
obj.element.remove(); | |
state.hitObjects.splice(i, 1); | |
} | |
} | |
} | |
// Handle tap/click | |
function handleTap(e) { | |
if (!state.gameActive) return; | |
// Check if we hit a circle | |
const hitCircle = e.target.closest('.hit-circle'); | |
if (hitCircle && hitCircle.classList.contains('active')) { | |
evaluateHit(hitCircle); | |
} | |
} | |
// Handle keyboard press | |
function handleKeyPress(e) { | |
if (!state.gameActive) return; | |
// Map keys 1-4 to hit objects | |
const key = parseInt(e.key); | |
if (key >= 1 && key <= 4) { | |
// Find the oldest active hit object with this number | |
const now = state.currentTime; | |
const difficultySettings = difficulties[state.difficulty]; | |
for (let i = 0; i < state.hitObjects.length; i++) { | |
const obj = state.hitObjects[i]; | |
if (!obj.hit && | |
obj.element.dataset.number == key && | |
obj.element.classList.contains('active')) { | |
const age = now - obj.spawnTime; | |
const windowSize = difficultySettings.accuracyWindow; | |
// Only hit if within timing window | |
if (Math.abs(age - 500) <= windowSize) { | |
evaluateHit(obj.element); | |
break; | |
} | |
} | |
} | |
} | |
} | |
// Evaluate hit accuracy | |
function evaluateHit(hitCircle) { | |
const now = state.currentTime; | |
const spawnTime = parseInt(hitCircle.dataset.spawnTime); | |
const age = now - spawnTime; | |
const difficultySettings = difficulties[state.difficulty]; | |
const windowSize = difficultySettings.accuracyWindow; | |
// Calculate accuracy | |
const timeDiff = Math.abs(age - 500); // 500ms is perfect timing | |
let judgement = ''; | |
let points = 0; | |
if (timeDiff <= windowSize * 0.3) { | |
judgement = 'perfect'; | |
points = 300; | |
} else if (timeDiff <= windowSize * 0.7) { | |
judgement = 'good'; | |
points = 100; | |
} else if (timeDiff <= windowSize) { | |
judgement = 'good'; | |
points = 50; | |
} else { | |
judgement = 'miss'; | |
points = 0; | |
} | |
// Update score and combo | |
if (judgement !== 'miss') { | |
state.score += points; | |
state.combo++; | |
state.hits++; | |
// Update UI | |
elements.score.textContent = state.score; | |
elements.combo.textContent = state.combo; | |
elements.accuracy.textContent = `${Math.round((state.hits / state.totalHits) * 100)}%`; | |
// Mark as hit | |
const objIndex = state.hitObjects.findIndex(obj => obj.element === hitCircle); | |
if (objIndex !== -1) { | |
state.hitObjects[objIndex].hit = true; | |
} | |
// Animate hit | |
hitCircle.classList.remove('active'); | |
hitCircle.classList.add(judgement); | |
// Show judgement | |
showJudgement(judgement, hitCircle); | |
// Show combo if applicable | |
if (state.combo > 0 && state.combo % 10 === 0) { | |
showCombo(state.combo, hitCircle); | |
} | |
// Remove after animation | |
setTimeout(() => { | |
hitCircle.remove(); | |
}, 500); | |
} | |
} | |
// Show judgement text | |
function showJudgement(judgement, element) { | |
const x = parseInt(element.style.left) + 40; | |
const y = parseInt(element.style.top) + 40; | |
const judgementEl = document.createElement('div'); | |
judgementEl.className = `judgement ${judgement}`; | |
judgementEl.textContent = | |
judgement === 'perfect' ? '完璧!' : | |
judgement === 'good' ? '良い!' : 'ミス!'; | |
judgementEl.style.left = `${x}px`; | |
judgementEl.style.top = `${y}px`; | |
elements.gameContainer.appendChild(judgementEl); | |
// Animate in | |
setTimeout(() => { | |
judgementEl.classList.add('show'); | |
}, 10); | |
// Remove after animation | |
setTimeout(() => { | |
judgementEl.remove(); | |
}, 1000); | |
} | |
// Show combo text | |
function showCombo(combo, element) { | |
const x = parseInt(element.style.left) + 40; | |
const y = parseInt(element.style.top) - 60; | |
const comboEl = document.createElement('div'); | |
comboEl.className = 'combo-display'; | |
comboEl.textContent = `${combo} コンボ!`; | |
comboEl.style.left = `${x}px`; | |
comboEl.style.top = `${y}px`; | |
elements.gameContainer.appendChild(comboEl); | |
// Animate in | |
setTimeout(() => { | |
comboEl.classList.add('show'); | |
}, 10); | |
// Remove after animation | |
setTimeout(() => { | |
comboEl.remove(); | |
}, 1000); | |
} | |
// End game | |
function endGame() { | |
state.gameActive = false; | |
// Cancel animation frame | |
if (state.animationFrame) { | |
cancelAnimationFrame(state.animationFrame); | |
state.animationFrame = null; | |
} | |
// Update buttons | |
elements.startBtn.disabled = false; | |
elements.pauseBtn.disabled = true; | |
// Show results | |
setTimeout(() => { | |
alert(`ゲーム終了!\nスコア: ${state.score}\nコンボ: ${state.combo}\n精度: ${Math.round((state.hits / state.totalHits) * 100)}%`); | |
}, 500); | |
} | |
// Initialize when DOM is loaded | |
document.addEventListener('DOMContentLoaded', init); | |
</script> | |
<p style="border-radius: 8px; text-align: center; font-size: 12px; color: #fff; margin-top: 16px;position: fixed; left: 8px; bottom: 8px; z-index: 10; background: rgba(0, 0, 0, 0.8); padding: 4px 8px;">Made with <img src="https://enzostvs-deepsite.hf.space/logo.svg" alt="DeepSite Logo" style="width: 16px; height: 16px; vertical-align: middle;display:inline-block;margin-right:3px;filter:brightness(0) invert(1);"><a href="https://enzostvs-deepsite.hf.space" style="color: #fff;text-decoration: underline;" target="_blank" >DeepSite</a> - 🧬 <a href="https://enzostvs-deepsite.hf.space?remix=Tingchenliang/tempo-game" style="color: #fff;text-decoration: underline;" target="_blank" >Remix</a></p></body> | |
</html> |