tempo-game / index.html
Tingchenliang's picture
Add 2 files
5008555 verified
<!DOCTYPE html>
<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>