<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>白桜の歩み - Sakura Rhythm Game</title>
<!-- Tailwind CSS for rich modern UI styling -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Google Fonts for premium Japanese elegant look -->
<link href="https://fonts.googleapis.com/css2?family=Kiwi+Maru:wght@400;500&family=Murecho:wght@400;700&display=swap" rel="stylesheet">
<!-- FontAwesome icons for elegant control buttons -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
body {
font-family: 'Murecho', 'Kiwi Maru', sans-serif;
background: #05020a;
overflow: hidden;
user-select: none;
-webkit-user-select: none;
}
/* Pulse glow animation for glowing assets */
@keyframes pulseGlow {
0%, 100% { filter: drop-shadow(0 0 5px rgba(244, 63, 94, 0.4)); }
50% { filter: drop-shadow(0 0 15px rgba(244, 63, 94, 0.8)); }
}
.glowing-sakura {
animation: pulseGlow 3s infinite ease-in-out;
}
/* Fancy scrollbar styling */
::-webkit-scrollbar {
width: 4px;
}
::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
}
::-webkit-scrollbar-thumb {
background: rgba(244, 63, 94, 0.3);
border-radius: 2px;
}
</style>
</head>
<body class="relative h-screen w-screen text-white flex justify-center items-center">
<!-- Main game container styled as a premium centered game window (like mobile rhythm apps) -->
<div class="relative w-full h-screen max-w-4xl bg-[#08020d] shadow-2xl flex flex-col justify-between border-x border-pink-500/20 overflow-hidden">
<!-- Decorative Floating Sakura Petals in Background (HTML Particle Elements) -->
<div id="html-petals-container" class="absolute inset-0 pointer-events-none overflow-hidden z-0"></div>
<!-- Start / Splash Screen Overlay -->
<div id="start-screen" class="absolute inset-0 z-50 flex flex-col items-center justify-center bg-gradient-to-br from-[#0c0312] via-[#14081c] to-[#040107] transition-all duration-700 overflow-y-auto py-8">
<div class="text-center px-4 max-w-xl w-full">
<!-- Glowing Kitsune Mascot Icon -->
<div class="mb-2 text-pink-400 text-5xl glowing-sakura flex justify-center gap-4">
<span class="text-rose-400 transform -rotate-12"><i class="fa-solid fa-mask"></i></span>
<span class="text-pink-300 animate-bounce"><i class="fa-solid fa-wind"></i></span>
</div>
<h1 class="text-4xl md:text-5xl font-bold tracking-widest text-transparent bg-clip-text bg-gradient-to-r from-pink-300 via-rose-400 to-orange-200 mb-1 font-['Kiwi_Maru']">
白桜の歩み
</h1>
<p class="text-[10px] text-pink-300 tracking-widest uppercase mb-4 opacity-80">~ Kitsune Lantern Festival Walk ~</p>
<!-- Play Guide -->
<div class="bg-white/5 border border-pink-500/20 rounded-2xl p-4 md:p-5 backdrop-blur-md mb-4 text-left text-xs md:text-sm text-pink-100/80 leading-relaxed">
<p class="mb-2 text-pink-300 font-semibold"><i class="fa-solid fa-circle-info mr-2"></i>遊び方 :</p>
<p class="mb-1.5">・お狐様の夜桜並木を歩きながら、奥から流れてくる四角が判定線に重なる瞬間に対応するキー(D, F, J, K)を押してください。</p>
<p class="mb-2 font-semibold text-orange-300">💡 スマホの方は、画面下部の各ボタンを直接タップすることでも遊べます!</p>
<div class="grid grid-cols-4 gap-2 text-center my-2 font-mono font-bold text-pink-400">
<span class="bg-pink-950/40 py-1 rounded border border-pink-500/30">D (左)</span>
<span class="bg-pink-950/40 py-1 rounded border border-pink-500/30">F (中左)</span>
<span class="bg-pink-950/40 py-1 rounded border border-pink-500/30">J (中右)</span>
<span class="bg-pink-950/40 py-1 rounded border border-pink-500/30">K (右)</span>
</div>
</div>
<!-- Difficulty Selection Panel -->
<div class="bg-white/5 border border-pink-500/20 rounded-2xl p-4 backdrop-blur-md mb-6 text-left">
<p class="mb-3 text-pink-300 font-semibold text-xs md:text-sm text-center">
<i class="fa-solid fa-sliders mr-2"></i>難易度を選んでください (Select Difficulty)
</p>
<div class="grid grid-cols-3 gap-2">
<!-- Easy -->
<button id="diff-easy-btn" onclick="setDifficulty('easy')" class="py-2 px-3 rounded-xl border border-emerald-500/30 bg-emerald-950/20 hover:bg-emerald-950/40 text-emerald-300 text-xs md:text-sm font-semibold transition-all flex flex-col items-center gap-1">
<span class="text-base"><i class="fa-solid fa-feather-pointed"></i></span>
<span>かんたん</span>
<span class="text-[9px] opacity-70">ゆっくり</span>
</button>
<!-- Normal (Selected by default) -->
<button id="diff-normal-btn" onclick="setDifficulty('normal')" class="py-2 px-3 rounded-xl border-2 border-pink-500 bg-pink-950/40 text-white text-xs md:text-sm font-bold transition-all shadow-lg shadow-pink-500/10 flex flex-col items-center gap-1">
<span class="text-base"><i class="fa-solid fa-music"></i></span>
<span>ふつう</span>
<span class="text-[9px] opacity-70">標準</span>
</button>
<!-- Hard -->
<button id="diff-hard-btn" onclick="setDifficulty('hard')" class="py-2 px-3 rounded-xl border border-rose-500/30 bg-rose-950/20 hover:bg-rose-950/40 text-rose-300 text-xs md:text-sm font-semibold transition-all flex flex-col items-center gap-1">
<span class="text-base"><i class="fa-solid fa-fire"></i></span>
<span>むずかしい</span>
<span class="text-[9px] opacity-70">高密度・高速</span>
</button>
</div>
</div>
<!-- Loading Box and Play Button -->
<div id="loading-box" class="flex flex-col items-center">
<!-- Music loading progress -->
<div class="w-64 h-2 bg-pink-950/60 rounded-full overflow-hidden mb-3 border border-pink-500/10">
<div id="loading-bar" class="w-0 h-full bg-gradient-to-r from-pink-500 via-rose-400 to-orange-400 transition-all duration-300"></div>
</div>
<p id="loading-status" class="text-xs text-pink-300/70 tracking-wider font-mono">神楽曲「白桜の舟」をロード中...</p>
<button id="start-btn" class="hidden mt-4 px-10 py-4 bg-gradient-to-r from-rose-600 via-pink-600 to-orange-500 hover:from-pink-500 hover:to-rose-400 text-white font-bold text-lg rounded-full shadow-lg shadow-rose-500/20 transform hover:scale-105 active:scale-95 transition-all duration-200 uppercase tracking-widest border border-pink-400/30 animate-pulse">
<i class="fa-solid fa-play mr-2"></i> 夜宮を奏で歩む
</button>
</div>
</div>
</div>
<!-- Beautiful Background & Scenery Canvas (Full Height, behind the notes) -->
<canvas id="scenery-canvas" class="absolute inset-0 w-full h-full object-cover z-10 pointer-events-none"></canvas>
<!-- Rhythm Lane Overlay Canvas (renders falling 3D perspective glowing tiles) -->
<canvas id="game-canvas" class="absolute inset-0 w-full h-full object-cover z-20 pointer-events-none"></canvas>
<!-- Top HUD Panel (Glassmorphism layout) -->
<div class="absolute top-4 left-4 right-4 flex justify-between items-center z-30 pointer-events-auto">
<div class="flex items-center gap-3 bg-black/55 backdrop-blur-md px-4 py-2 rounded-full border border-pink-500/20">
<div class="w-2.5 h-2.5 rounded-full bg-orange-400 animate-pulse"></div>
<span class="text-xs md:text-sm font-semibold tracking-wide text-pink-100">
白桜の舟 <span id="hud-diff-badge" class="ml-2 px-2 py-0.5 rounded-full bg-pink-500/30 text-[10px] text-pink-300 font-bold border border-pink-500/40">ふつう</span>
</span>
</div>
<div class="flex gap-2">
<button id="restart-btn" class="p-2.5 bg-black/45 hover:bg-pink-600/30 backdrop-blur-md rounded-full border border-pink-500/20 hover:border-pink-500/50 transition-all duration-150 text-white text-xs" title="もう一度遊ぶ">
<i class="fa-solid fa-rotate-right"></i>
</button>
</div>
</div>
<!-- Combo Counter & Live Score HUD -->
<div class="absolute inset-x-0 top-1/4 flex flex-col items-center justify-center pointer-events-none z-30 select-none">
<div class="text-center">
<p id="hud-combo" class="text-5xl font-extrabold tracking-wider text-white hidden transform scale-100 transition-transform duration-100 drop-shadow-[0_0_12px_rgba(244,63,94,0.8)]">
0 <span class="text-xs tracking-widest font-bold text-orange-300 block uppercase mt-1">Combo</span>
</p>
<div id="hud-judgment" class="text-3xl font-black tracking-widest text-pink-300 opacity-0 transform translate-y-2 transition-all duration-100 h-10 drop-shadow-[0_0_8px_rgba(255,255,255,0.7)]">
PERFECT
</div>
</div>
</div>
<!-- Interactive Invisible Touch Zones for Tap Response -->
<div class="absolute inset-x-0 bottom-24 top-0 flex z-20 pointer-events-auto">
<div id="lane-touch-0" class="flex-1 h-full cursor-pointer relative" onclick="handleLanePress(0)"></div>
<div id="lane-touch-1" class="flex-1 h-full cursor-pointer relative" onclick="handleLanePress(1)"></div>
<div id="lane-touch-2" class="flex-1 h-full cursor-pointer relative" onclick="handleLanePress(2)"></div>
<div id="lane-touch-3" class="flex-1 h-full cursor-pointer relative" onclick="handleLanePress(3)"></div>
</div>
<!-- Real-time HUD and Tap Keys container at the bottom -->
<div class="w-full absolute bottom-0 inset-x-0 z-40 flex flex-col items-center bg-gradient-to-t from-[#05010a] via-[#08020e]/95 to-transparent pt-6 pb-4 pointer-events-auto">
<!-- Realtime Metrics: Score, Accuracy, Max Combo -->
<div class="w-full max-w-2xl px-6 flex justify-between items-center text-xs text-pink-200/90 mb-3 font-mono">
<div>SCORE: <span id="stat-score" class="font-bold text-white text-sm tracking-widest">0000000</span></div>
<div>ACC: <span id="stat-accuracy" class="font-bold text-orange-300 text-sm">100.0%</span></div>
<div>MAX COMBO: <span id="stat-maxcombo" class="font-bold text-pink-300 text-sm">0</span></div>
</div>
<!-- Tactile Input Buttons (Seamlessly matches the 4 perspective lanes width) -->
<div class="w-full max-w-2xl px-4">
<div class="grid grid-cols-4 border-t border-pink-500/20 bg-black/70 rounded-2xl overflow-hidden shadow-2xl">
<!-- Button 1 -->
<button id="tap-btn-0" class="py-4 border-r border-pink-500/10 bg-pink-950/20 hover:bg-pink-950/35 active:bg-pink-500/40 shadow-inner text-white font-black flex flex-col items-center justify-center transition-all duration-75 active:scale-95" onmousedown="triggerTapVisual(0)" onmouseup="releaseTapVisual(0)" ontouchstart="triggerTapVisual(0); event.stopPropagation();" ontouchend="releaseTapVisual(0); event.stopPropagation();">
<span class="text-pink-300 font-['Kiwi_Maru'] text-[10px] font-normal mb-0.5">桜色</span>
<span class="text-lg font-bold tracking-wider font-mono">D</span>
</button>
<!-- Button 2 -->
<button id="tap-btn-1" class="py-4 border-r border-pink-500/10 bg-pink-950/20 hover:bg-pink-950/35 active:bg-pink-500/40 shadow-inner text-white font-black flex flex-col items-center justify-center transition-all duration-75 active:scale-95" onmousedown="triggerTapVisual(1)" onmouseup="releaseTapVisual(1)" ontouchstart="triggerTapVisual(1); event.stopPropagation();" ontouchend="releaseTapVisual(1); event.stopPropagation();">
<span class="text-pink-300 font-['Kiwi_Maru'] text-[10px] font-normal mb-0.5">神楽</span>
<span class="text-lg font-bold tracking-wider font-mono">F</span>
</button>
<!-- Button 3 -->
<button id="tap-btn-2" class="py-4 border-r border-pink-500/10 bg-pink-950/20 hover:bg-pink-950/35 active:bg-pink-500/40 shadow-inner text-white font-black flex flex-col items-center justify-center transition-all duration-75 active:scale-95" onmousedown="triggerTapVisual(2)" onmouseup="releaseTapVisual(2)" ontouchstart="triggerTapVisual(2); event.stopPropagation();" ontouchend="releaseTapVisual(2); event.stopPropagation();">
<span class="text-pink-300 font-['Kiwi_Maru'] text-[10px] font-normal mb-0.5">狐面</span>
<span class="text-lg font-bold tracking-wider font-mono">J</span>
</button>
<!-- Button 4 -->
<button id="tap-btn-3" class="py-4 bg-pink-950/20 hover:bg-pink-950/35 active:bg-pink-500/40 shadow-inner text-white font-black flex flex-col items-center justify-center transition-all duration-75 active:scale-95" onmousedown="triggerTapVisual(3)" onmouseup="releaseTapVisual(3)" ontouchstart="triggerTapVisual(3); event.stopPropagation();" ontouchend="releaseTapVisual(3); event.stopPropagation();">
<span class="text-pink-300 font-['Kiwi_Maru'] text-[10px] font-normal mb-0.5">祭囃</span>
<span class="text-lg font-bold tracking-wider font-mono">K</span>
</button>
</div>
</div>
</div>
</div>
<!-- Score Results Dialog -->
<div id="results-screen" class="hidden absolute inset-0 z-50 bg-black/95 backdrop-blur-xl flex items-center justify-center p-4 transition-all duration-500">
<div class="bg-gradient-to-b from-[#1b081e] to-[#040108] border border-pink-500/30 rounded-3xl p-8 max-w-md w-full text-center shadow-2xl shadow-pink-500/20">
<h2 class="text-3xl font-bold text-pink-300 font-['Kiwi_Maru'] mb-2 tracking-wide">白桜の歩み 結び</h2>
<p class="text-xs text-orange-400/60 uppercase tracking-widest mb-6">— Result Evaluation —</p>
<!-- Overall Rank Badge -->
<div class="flex justify-center mb-6">
<div id="result-rank" class="w-24 h-24 rounded-full bg-pink-500/10 border-2 border-pink-500/40 flex items-center justify-center text-5xl font-black text-orange-300 glowing-sakura">
S
</div>
</div>
<!-- Stats Table -->
<div class="bg-black/40 rounded-2xl p-4 mb-6 space-y-2 text-sm font-mono text-left">
<div class="flex justify-between border-b border-pink-500/10 pb-1.5 text-pink-100">
<span>SCORE :</span>
<span id="res-score" class="font-bold text-pink-400 text-base">000,000</span>
</div>
<div class="flex justify-between border-b border-pink-500/10 pb-1.5 text-pink-100">
<span>ACCURACY :</span>
<span id="res-acc" class="font-bold text-orange-400 text-base">100.00%</span>
</div>
<div class="flex justify-between border-b border-pink-500/10 pb-1.5 text-pink-100 text-pink-300/90">
<span>MAX COMBO :</span>
<span id="res-maxcombo" class="font-bold text-pink-400 text-base">0</span>
</div>
<div class="flex justify-between text-xs text-white/60 pt-2 grid grid-cols-4 gap-1 text-center font-bold">
<div class="bg-pink-900/10 border border-emerald-500/20 py-1.5 rounded">
<span class="block text-[10px] text-emerald-400">PERFECT</span>
<span id="res-perf">0</span>
</div>
<div class="bg-pink-900/10 border border-cyan-500/20 py-1.5 rounded">
<span class="block text-[10px] text-cyan-400">GREAT</span>
<span id="res-great">0</span>
</div>
<div class="bg-pink-900/10 border border-amber-500/20 py-1.5 rounded">
<span class="block text-[10px] text-amber-400">GOOD</span>
<span id="res-good">0</span>
</div>
<div class="bg-pink-900/10 border border-rose-500/20 py-1.5 rounded">
<span class="block text-[10px] text-rose-400">MISS</span>
<span id="res-miss">0</span>
</div>
</div>
</div>
<!-- Buttons -->
<div class="flex flex-col gap-2">
<button id="res-retry-btn" class="w-full py-3.5 bg-gradient-to-r from-rose-600 to-pink-600 hover:from-pink-500 hover:to-rose-400 text-white font-bold rounded-xl transition-all duration-200 shadow-lg shadow-pink-500/20">
<i class="fa-solid fa-rotate-right mr-2"></i> もう一度歩む
</button>
<button id="res-close-btn" class="w-full py-2.5 bg-white/5 hover:bg-white/10 text-pink-300 text-xs rounded-xl border border-pink-500/20 transition-all duration-200">
閉じる
</button>
</div>
</div>
</div>
<script>
// Game state and calibration configurations
const BPM = 128;
const BEAT_INTERVAL = 60 / BPM; // ~0.46875s per beat
// Difficulty Setting Configuration
let difficulty = 'normal'; // 'easy', 'normal', 'hard'
let NOTE_SPEED = 1.1; // Seconds notes take to drop
const JUDGMENT_LINE_Y_RATIO = 0.82; // Position of the horizontal judgment bar
// Key guidance map helper
const KEY_LABELS = ['D', 'F', 'J', 'K'];
// Audio Object & Web Audio Setup for Interactive Clicking Sounds
const audio = new Audio();
audio.src = "https://petitacode.web2050.jp/users/original/tag/tmp/%E7%99%BD%E6%A1%9C%E3%81%AE%E8%88%9F.mp3";
let audioContext;
let isAudioFallbackMode = false; // BGMの読み込みに失敗した場合にシンセサイザーBGMに切り替える
let synthBgmIntervalId = null; // シンセサイザーBGM演奏用タイマー
// 外部音楽が鳴らないときの「安全なミリ秒経過タイマー」
let gameStartTime = 0;
let elapsedTime = 0;
// Initialize Web Audio API on click to satisfy user gesture policy
function initSynthAudio() {
if (!audioContext) {
audioContext = new (window.AudioContext || window.webkitAudioContext)();
}
if (audioContext && audioContext.state === 'suspended') {
audioContext.resume();
}
}
// Tap synthesizer sound generator (beautiful retro bubble/cherry plop sound)
function playTapSound(freq = 523.25, type = 'sine', duration = 0.08) {
if (!audioContext) return;
try {
const osc = audioContext.createOscillator();
const gain = audioContext.createGain();
osc.type = type;
osc.frequency.setValueAtTime(freq, audioContext.currentTime);
osc.frequency.exponentialRampToValueAtTime(freq * 0.4, audioContext.currentTime + duration);
gain.gain.setValueAtTime(0.12, audioContext.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, audioContext.currentTime + duration);
osc.connect(gain);
gain.connect(audioContext.destination);
osc.start();
osc.stop(audioContext.currentTime + duration);
} catch (e) {
console.warn("Audio synthesis error: ", e);
}
}
// 🌟 100%音が鳴る!バックアップ用・和風琴風シンセサイザーBGMシステム
let notePatternIndex = 0;
const KOTO_SCALE = [261.63, 293.66, 311.13, 349.23, 392.00, 415.30, 466.16, 523.25]; // 陰旋法に近い、和風の情緒的なスケール
function playSynthBgmStep() {
if (!audioContext || !gamePlaying) return;
try {
// 拍子に合わせた和太鼓のようなポンというリズム
const drumOsc = audioContext.createOscillator();
const drumGain = audioContext.createGain();
drumOsc.type = 'sine';
drumOsc.frequency.setValueAtTime(100, audioContext.currentTime);
drumOsc.frequency.exponentialRampToValueAtTime(30, audioContext.currentTime + 0.15);
drumGain.gain.setValueAtTime(notePatternIndex % 4 === 0 ? 0.3 : 0.15, audioContext.currentTime);
drumGain.gain.exponentialRampToValueAtTime(0.001, audioContext.currentTime + 0.15);
drumOsc.connect(drumGain);
drumGain.connect(audioContext.destination);
drumOsc.start();
drumOsc.stop(audioContext.currentTime + 0.15);
// 和琴(Koto)のような涼しげできらきらした音階メロディ
if (notePatternIndex % 2 === 0) {
const melodyOsc = audioContext.createOscillator();
const melodyGain = audioContext.createGain();
// 情緒的に変化する和風メロディ
const pitch = KOTO_SCALE[(notePatternIndex + Math.floor(Math.sin(notePatternIndex) * 3) + 8) % KOTO_SCALE.length];
melodyOsc.type = 'triangle';
melodyOsc.frequency.setValueAtTime(pitch, audioContext.currentTime);
// 減衰する余韻
melodyGain.gain.setValueAtTime(0.15, audioContext.currentTime);
melodyGain.gain.exponentialRampToValueAtTime(0.001, audioContext.currentTime + 0.55);
melodyOsc.connect(melodyGain);
melodyGain.connect(audioContext.destination);
melodyOsc.start();
melodyOsc.stop(audioContext.currentTime + 0.6);
}
notePatternIndex++;
} catch (e) {
console.error("Synth BGM step play error: ", e);
}
}
// BGMを開始
function startSynthBgm() {
stopSynthBgm();
notePatternIndex = 0;
// BPM 128に同期した間隔(8分音符で演奏:約0.234秒間隔)
const intervalMs = (60 / BPM / 2) * 1000;
synthBgmIntervalId = setInterval(playSynthBgmStep, intervalMs);
}
// BGMを停止
function stopSynthBgm() {
if (synthBgmIntervalId) {
clearInterval(synthBgmIntervalId);
synthBgmIntervalId = null;
}
}
// Difficulty toggler handler UI
function setDifficulty(level) {
difficulty = level;
// Adjust speeds based on easy/normal/hard
if (difficulty === 'easy') {
NOTE_SPEED = 1.4; // Slower drop speed for easier reading
} else if (difficulty === 'normal') {
NOTE_SPEED = 1.1;
} else if (difficulty === 'hard') {
NOTE_SPEED = 0.8; // Fast drop speed for high action response
}
// Update UI selected state
const easyBtn = document.getElementById('diff-easy-btn');
const normalBtn = document.getElementById('diff-normal-btn');
const hardBtn = document.getElementById('diff-hard-btn');
const hudDiffBadge = document.getElementById('hud-diff-badge');
// Reset classes
easyBtn.className = "py-2 px-3 rounded-xl border border-emerald-500/30 bg-emerald-950/20 hover:bg-emerald-950/40 text-emerald-300 text-xs md:text-sm font-semibold transition-all flex flex-col items-center gap-1";
normalBtn.className = "py-2 px-3 rounded-xl border border-pink-500/30 bg-pink-950/20 hover:bg-pink-950/40 text-pink-300 text-xs md:text-sm font-semibold transition-all flex flex-col items-center gap-1";
hardBtn.className = "py-2 px-3 rounded-xl border border-rose-500/30 bg-rose-950/20 hover:bg-rose-950/40 text-rose-300 text-xs md:text-sm font-semibold transition-all flex flex-col items-center gap-1";
if (level === 'easy') {
easyBtn.className = "py-2 px-3 rounded-xl border-2 border-emerald-500 bg-emerald-950/40 text-white text-xs md:text-sm font-bold transition-all shadow-lg shadow-emerald-500/10 flex flex-col items-center gap-1";
hudDiffBadge.textContent = "かんたん";
hudDiffBadge.className = "ml-2 px-2 py-0.5 rounded-full bg-emerald-500/30 text-[10px] text-emerald-300 font-bold border border-emerald-500/40";
} else if (level === 'normal') {
normalBtn.className = "py-2 px-3 rounded-xl border-2 border-pink-500 bg-pink-950/40 text-white text-xs md:text-sm font-bold transition-all shadow-lg shadow-pink-500/10 flex flex-col items-center gap-1";
hudDiffBadge.textContent = "ふつう";
hudDiffBadge.className = "ml-2 px-2 py-0.5 rounded-full bg-pink-500/30 text-[10px] text-pink-300 font-bold border border-pink-500/40";
} else if (level === 'hard') {
hardBtn.className = "py-2 px-3 rounded-xl border-2 border-rose-500 bg-rose-950/40 text-white text-xs md:text-sm font-bold transition-all shadow-lg shadow-rose-500/10 flex flex-col items-center gap-1";
hudDiffBadge.textContent = "むずかしい";
hudDiffBadge.className = "ml-2 px-2 py-0.5 rounded-full bg-rose-500/30 text-[10px] text-rose-300 font-bold border border-rose-500/40";
}
// High pitch note click for UI confirmation
playTapSound(600, 'triangle', 0.05);
}
const startScreen = document.getElementById('start-screen');
const resultsScreen = document.getElementById('results-screen');
const loadingBar = document.getElementById('loading-bar');
const loadingStatus = document.getElementById('loading-status');
const startBtn = document.getElementById('start-btn');
const restartBtn = document.getElementById('restart-btn');
const resRetryBtn = document.getElementById('res-retry-btn');
const resCloseBtn = document.getElementById('res-close-btn');
const statScore = document.getElementById('stat-score');
const statAccuracy = document.getElementById('stat-accuracy');
const statMaxCombo = document.getElementById('stat-maxcombo');
const hudCombo = document.getElementById('hud-combo');
const hudJudgment = document.getElementById('hud-judgment');
// Rhythm Map Definitions & Spawning Engine
let notes = [];
let score = 0;
let combo = 0;
let maxCombo = 0;
let totalHitsAttempted = 0;
let totalNotesCount = 0;
// Detailed counters for final tally screen
let countPerfect = 0;
let countGreat = 0;
let countGood = 0;
let countMiss = 0;
let gamePlaying = false;
let spawnPointer = 0;
// Structured procedural song rhythm track template
let beatMap = [];
function generateBeatMap() {
beatMap = [];
const trackLength = 120; // safe fallback length in seconds
let currentTimer = 4.0; // Start note block around 4s to allow intro fade-in
const patterns = [
[0, 1, 2, 3], // Straight ramp
[0, 2, 1, 3], // Zigzag
[0, 0, 3, 3], // Double outer
[1, 2, 1, 2], // Inner swing
[0, 3, 1, 2], // Inward focus
[1, 1, 2, 2], // Sync doublets
[0, 2, 3, 1] // Dynamic scatter
];
let patternIndex = 0;
while (currentTimer < trackLength) {
const pattern = patterns[patternIndex % patterns.length];
const section = Math.floor(currentTimer / 20); // change feel every 20 seconds
let step = BEAT_INTERVAL; // 1 beat (~0.47s)
if (difficulty === 'easy') {
// EASY MODE: Spawning notes sparsely (mostly every 2 or 4 beats)
const lane = Math.floor(Math.sin(currentTimer) * 2) + 1.5; // oscillate lane 1 or 2 comfortably
beatMap.push({ time: currentTimer, lane: Math.floor(lane) % 4 });
currentTimer += step * 2; // Slower cadence: two-beat spacing
if (currentTimer % 12 === 0) {
currentTimer += step * 2; // occasional pauses
}
}
else if (difficulty === 'normal') {
// NORMAL MODE: Traditional rhythm pacing
if (section === 0 || section === 5) {
// Soft introduction/outro: slow single notes
const lane = Math.floor(Math.sin(currentTimer) * 2) + 1;
beatMap.push({ time: currentTimer, lane: lane });
currentTimer += step * 2;
} else if (section === 1 || section === 3) {
for (let i = 0; i < 4; i++) {
beatMap.push({ time: currentTimer, lane: pattern[i] });
currentTimer += step;
}
} else if (section === 2) {
// Regular flow sequence
for (let i = 0; i < 6; i++) {
beatMap.push({ time: currentTimer, lane: Math.floor(Math.random() * 4) });
currentTimer += step;
}
} else {
// Mild dense syncopations
beatMap.push({ time: currentTimer, lane: pattern[0] });
currentTimer += step;
beatMap.push({ time: currentTimer, lane: pattern[3] });
currentTimer += step;
}
}
else if (difficulty === 'hard') {
// HARD MODE: Intense 8th notes and dual-lane cords!
if (section === 0) {
// Rapid warmup
beatMap.push({ time: currentTimer, lane: pattern[0] });
currentTimer += step;
beatMap.push({ time: currentTimer, lane: pattern[2] });
currentTimer += step;
} else if (section === 2 || section === 4) {
// Fast consecutive streams (1/2 beats)
for (let i = 0; i < 8; i++) {
beatMap.push({ time: currentTimer, lane: Math.floor(Math.random() * 4) });
currentTimer += step * 0.5; // Extreme 8th-note streams!
}
currentTimer += step;
} else {
// Simultaneous double-hits!
beatMap.push({ time: currentTimer, lane: pattern[0] });
beatMap.push({ time: currentTimer, lane: pattern[3] });
currentTimer += step;
beatMap.push({ time: currentTimer, lane: pattern[1] });
beatMap.push({ time: currentTimer, lane: pattern[2] });
currentTimer += step;
}
}
patternIndex++;
}
beatMap.sort((a, b) => a.time - b.time);
totalNotesCount = beatMap.length;
}
// Web Audio & File load logic with solid error protection
audio.addEventListener('canplaythrough', () => {
loadingBar.style.width = '100%';
loadingStatus.textContent = "白桜の舟 - ロード完了!";
startBtn.classList.remove('hidden');
});
// Error fallback (if external domain MP3 block occurs, allow play immediately)
audio.addEventListener('error', (e) => {
console.warn("External MP3 load delay. Activating synthetic stabilizer backup.");
loadingBar.style.width = '100%';
loadingStatus.textContent = "和風シンセサイザー音源を準備しました!";
isAudioFallbackMode = true;
startBtn.classList.remove('hidden');
});
// 3秒経っても曲が読み込めない場合は、自動的にシンセサイザーBGMモードの準備をする(フリーズ防止!)
setTimeout(() => {
if (loadingBar.style.width !== '100%') {
console.log("Audio load timeout. Activating fallback synthetic sound...");
loadingBar.style.width = '100%';
loadingStatus.textContent = "お狐様のお囃子シンセ演奏モードで開始可能!";
isAudioFallbackMode = true;
startBtn.classList.remove('hidden');
}
}, 3000);
// Canvas element setups
const sceneryCanvas = document.getElementById('scenery-canvas');
const sCtx = sceneryCanvas.getContext('2d');
// Game canvas
const gameCanvas = document.getElementById('game-canvas');
const gCtx = gameCanvas.getContext('2d');
// Handle scaling across high PPI monitors dynamically
function resizeCanvases() {
const dpr = window.devicePixelRatio || 1;
// Scenery Canvas dimensions (Fill screen)
sceneryCanvas.width = sceneryCanvas.clientWidth * dpr;
sceneryCanvas.height = sceneryCanvas.clientHeight * dpr;
sCtx.scale(dpr, dpr);
// Game Board Canvas dimensions (Fill screen)
gameCanvas.width = gameCanvas.clientWidth * dpr;
gameCanvas.height = gameCanvas.clientHeight * dpr;
gCtx.scale(dpr, dpr);
}
window.addEventListener('resize', resizeCanvases);
setTimeout(resizeCanvases, 200);
// Parallax parameters
let scrollOffset = 0;
const speed = 1.2; // Scrolling velocity of the cherry blossom street
// Sakura Petal Particle System
class Petal {
constructor(spawnOutside = false) {
this.reset(spawnOutside);
}
reset(spawnOutside) {
const w = sceneryCanvas.clientWidth || window.innerWidth;
const h = sceneryCanvas.clientHeight || window.innerHeight;
this.x = spawnOutside ? -50 : Math.random() * (w + 100) - 50;
this.y = spawnOutside ? Math.random() * (h * 0.8) : -30;
this.size = Math.random() * 6 + 4;
this.speedX = Math.random() * 2 + 1.2; // drifting rightwards
this.speedY = Math.random() * 1.5 + 0.8; // drifting downwards
this.spin = Math.random() * Math.PI * 2;
this.spinSpeed = Math.random() * 0.04 - 0.02;
this.opacity = Math.random() * 0.5 + 0.4;
}
update(width, height) {
this.x += this.speedX;
this.y += this.speedY;
this.spin += this.spinSpeed;
if (this.x > width + 50 || this.y > height + 20) {
this.reset(true);
}
}
draw(ctx) {
ctx.save();
ctx.translate(this.x, this.y);
ctx.rotate(this.spin);
ctx.fillStyle = `rgba(255, 192, 203, ${this.opacity})`;
ctx.shadowColor = 'rgba(244, 63, 94, 0.4)';
ctx.shadowBlur = 4;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.bezierCurveTo(-this.size/2, -this.size/2, -this.size, this.size/3, 0, this.size);
ctx.bezierCurveTo(this.size, this.size/3, this.size/2, -this.size/2, 0, 0);
ctx.closePath();
ctx.fill();
ctx.restore();
}
}
const petalsList = [];
for (let i = 0; i < 45; i++) {
petalsList.push(new Petal(false));
}
// Background scenery: trees list
class SakuraTree {
constructor(x, scale, isForeground = false) {
this.x = x;
this.scale = scale;
this.isForeground = isForeground;
}
draw(ctx, width, height) {
const groundY = height * 0.72;
const treeX = this.x;
const size = 110 * this.scale;
ctx.save();
// Draw trunk
ctx.strokeStyle = '#1b0918';
ctx.lineWidth = 10 * this.scale;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(treeX, groundY);
ctx.quadraticCurveTo(treeX - 15 * this.scale, groundY - size * 0.5, treeX - 5 * this.scale, groundY - size);
ctx.stroke();
// Draw branches
ctx.strokeStyle = '#1b0918';
ctx.lineWidth = 5 * this.scale;
ctx.beginPath();
ctx.moveTo(treeX - 5 * this.scale, groundY - size);
ctx.lineTo(treeX - 35 * this.scale, groundY - size - 30 * this.scale);
ctx.moveTo(treeX - 5 * this.scale, groundY - size);
ctx.lineTo(treeX + 25 * this.scale, groundY - size - 25 * this.scale);
ctx.stroke();
// Draw clouds of lush pink cherry blossoms
const petalClouds = [
{ rx: -25, ry: -25, r: 45 },
{ rx: 25, ry: -15, r: 40 },
{ rx: -5, ry: -45, r: 55 },
{ rx: -45, ry: -10, r: 35 },
{ rx: 40, ry: -30, r: 32 },
];
ctx.shadowColor = 'rgba(251, 113, 133, 0.4)';
ctx.shadowBlur = 12;
petalClouds.forEach(cloud => {
const grad = ctx.createRadialGradient(
treeX + cloud.rx * this.scale, groundY - size + cloud.ry * this.scale, 5,
treeX + cloud.rx * this.scale, groundY - size + cloud.ry * this.scale, cloud.r * this.scale
);
grad.addColorStop(0, '#ffe4e6'); // super bright pink core
grad.addColorStop(0.5, '#fecdd3'); // standard blossom
grad.addColorStop(1, 'rgba(244, 63, 94, 0)'); // fade out edge
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(treeX + cloud.rx * this.scale, groundY - size + cloud.ry * this.scale, cloud.r * this.scale, 0, Math.PI * 2);
ctx.fill();
});
ctx.restore();
}
}
const backgroundTrees = [];
for (let i = 0; i < 10; i++) {
const treeScale = 0.8 + Math.random() * 0.5;
backgroundTrees.push(new SakuraTree(i * 180, treeScale, false));
}
// Draw the highly customizable anime girl matching image_73dc06.png (Yukata, Kitsune Mask, Side highlights)
function drawCuteAnimeGirl(ctx, x, y, scale, tick) {
ctx.save();
ctx.translate(x, y);
// Gentle idle walking motion variables
const animSpeed = 5.0;
const cycle = tick * animSpeed;
const bobbing = Math.abs(Math.sin(cycle)) * 6.5 * scale;
ctx.translate(0, -bobbing); // walk-bob
// --- 1. Large Traditional Red Wagasa Umbrella (後ろの和傘) ---
ctx.save();
const umbrellaX = 5 * scale;
const umbrellaY = -115 * scale;
ctx.translate(umbrellaX, umbrellaY);
ctx.rotate(0.04 * Math.sin(tick * 2) - 0.05);
// Red/orange canopy gradient matching the vibrant warm tones of the image
const uGrad = ctx.createRadialGradient(0, 0, 5, 0, 0, 95 * scale);
uGrad.addColorStop(0, '#fecdd3'); // bright center
uGrad.addColorStop(0.3, '#f43f5e'); // primary red-pink
uGrad.addColorStop(0.85, '#9f1239'); // deep rose red
uGrad.addColorStop(1, '#4c0519'); // dark border
ctx.shadowColor = 'rgba(0, 0, 0, 0.35)';
ctx.shadowBlur = 12;
ctx.fillStyle = uGrad;
ctx.beginPath();
ctx.arc(0, 0, 95 * scale, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0; // reset
// 24 fine bamboo rib lines
ctx.strokeStyle = '#e11d48';
ctx.lineWidth = 1.2 * scale;
for (let i = 0; i < 24; i++) {
const angle = (i * Math.PI * 2) / 24;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(Math.cos(angle) * 95 * scale, Math.sin(angle) * 95 * scale);
ctx.stroke();
}
// Concentric circle pattern
ctx.strokeStyle = 'rgba(255, 255, 255, 0.25)';
ctx.lineWidth = 1 * scale;
ctx.beginPath();
ctx.arc(0, 0, 55 * scale, 0, Math.PI * 2);
ctx.stroke();
// Umbrella handle stick
ctx.strokeStyle = '#27272a';
ctx.lineWidth = 3.2 * scale;
ctx.beginPath();
ctx.moveTo(0, 25 * scale);
ctx.lineTo(-45 * scale, 120 * scale); // tilted resting on shoulder
ctx.stroke();
ctx.restore();
// --- 2. Elegant Dark Bun Hair (髪型・お団子) ---
ctx.fillStyle = '#1c1917'; // rich dark hair
// Left & Right buns
ctx.beginPath();
ctx.arc(-18 * scale, -125 * scale, 15 * scale, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(18 * scale, -125 * scale, 15 * scale, 0, Math.PI * 2);
ctx.fill();
// Hair ribbons
ctx.fillStyle = '#e11d48'; // red ribbon
ctx.fillRect(-23 * scale, -118 * scale, 8 * scale, 4 * scale);
ctx.fillRect(15 * scale, -118 * scale, 8 * scale, 4 * scale);
// Flowing back hair strands
ctx.fillStyle = '#1c1917';
ctx.beginPath();
ctx.moveTo(-25 * scale, -100 * scale);
ctx.quadraticCurveTo(-38 * scale, -70 * scale + Math.sin(cycle)*4, -28 * scale, -40 * scale);
ctx.lineTo(-18 * scale, -75 * scale);
ctx.closePath();
ctx.fill();
// --- 3. Bare legs and Geta sandals (下半身・ゲタ) ---
const leftLegSway = Math.sin(cycle) * 0.45;
const rightLegSway = -Math.sin(cycle) * 0.45;
// Left Leg (Back layer)
ctx.save();
ctx.translate(-7 * scale, -20 * scale);
ctx.rotate(leftLegSway);
ctx.fillStyle = '#ffe4e6'; // lovely skin
ctx.fillRect(-4 * scale, 0, 8 * scale, 18 * scale);
// Geta wooden sole
ctx.fillStyle = '#7c2d12';
ctx.fillRect(-5 * scale, 18 * scale, 11 * scale, 4 * scale);
// Red strap
ctx.fillStyle = '#ef4444';
ctx.fillRect(-1 * scale, 16 * scale, 4 * scale, 3 * scale);
ctx.restore();
// Right Leg (Front layer)
ctx.save();
ctx.translate(7 * scale, -20 * scale);
ctx.rotate(rightLegSway);
ctx.fillStyle = '#ffe4e6';
ctx.fillRect(-4 * scale, 0, 8 * scale, 18 * scale);
// Geta
ctx.fillStyle = '#7c2d12';
ctx.fillRect(-5 * scale, 18 * scale, 11 * scale, 4 * scale);
// Red strap
ctx.fillStyle = '#ef4444';
ctx.fillRect(-1 * scale, 16 * scale, 4 * scale, 3 * scale);
ctx.restore();
// --- 4. GORGEOUS GRADIENT YUKATA (淡いピンク〜水色グラデーション浴衣) ---
ctx.save();
// Draw yukata body silhouette
const yukataGrad = ctx.createLinearGradient(0, -100 * scale, 0, -22 * scale);
yukataGrad.addColorStop(0, '#fbcfe8'); // pale pink top
yukataGrad.addColorStop(0.5, '#e0f2fe'); // ice blue middle
yukataGrad.addColorStop(1, '#bae6fd'); // soft sky blue bottom
ctx.fillStyle = yukataGrad;
ctx.beginPath();
ctx.moveTo(-16 * scale, -100 * scale);
ctx.lineTo(16 * scale, -100 * scale);
ctx.lineTo(24 * scale, -22 * scale);
ctx.lineTo(-24 * scale, -22 * scale);
ctx.closePath();
ctx.fill();
// --- Large red blossoms matching image_73dc06.png ---
// Large red flower in center-bottom
ctx.fillStyle = '#dc2626'; // vibrant red flower
const drawFlower = (fx, fy, fr) => {
ctx.beginPath();
ctx.arc(fx, fy, fr, 0, Math.PI * 2);
ctx.fill();
// Outer petals details (using white/gold thin strokes)
ctx.strokeStyle = '#fef08a'; // yellow-gold lines
ctx.lineWidth = 0.8 * scale;
for (let k = 0; k < 6; k++) {
const ang = (k * Math.PI) / 3;
ctx.beginPath();
ctx.moveTo(fx, fy);
ctx.lineTo(fx + Math.cos(ang) * fr * 1.2, fy + Math.sin(ang) * fr * 1.2);
ctx.stroke();
}
};
// Draw beautiful large floral motifs from the image
drawFlower(-8 * scale, -50 * scale, 11 * scale);
drawFlower(10 * scale, -32 * scale, 8 * scale);
// Small green leaves matching yukata motif
ctx.fillStyle = '#15803d'; // green leaves
ctx.beginPath();
ctx.ellipse(-14 * scale, -42 * scale, 4 * scale, 2 * scale, Math.PI/4, 0, Math.PI * 2);
ctx.ellipse(3 * scale, -38 * scale, 3 * scale, 1.5 * scale, -Math.PI/4, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
// --- 5. Rich Indigo-Black Obi with Golden thread (金和柄の帯) ---
ctx.fillStyle = '#18181b'; // rich dark charcoal obi
ctx.fillRect(-18 * scale, -70 * scale, 36 * scale, 15 * scale);
// Gold details on Obi
ctx.strokeStyle = '#ca8a04';
ctx.lineWidth = 1.5 * scale;
ctx.beginPath();
ctx.moveTo(-18 * scale, -63 * scale);
ctx.lineTo(18 * scale, -63 * scale);
ctx.stroke();
// Ribbon bow of obi in the back
ctx.fillStyle = '#18181b';
ctx.beginPath();
ctx.ellipse(-15 * scale, -63 * scale, 6 * scale, 12 * scale, -Math.PI/6, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#ca8a04';
ctx.stroke();
// --- 6. Cute Head & Face with Warm Rim-light Glow ---
ctx.fillStyle = '#ffe4e6'; // soft skin
ctx.beginPath();
ctx.arc(0, -110 * scale, 15 * scale, 0, Math.PI * 2);
ctx.fill();
// Hair fringe bangs (前髪)
ctx.fillStyle = '#1c1917';
ctx.beginPath();
ctx.arc(0, -114 * scale, 15 * scale, Math.PI, 0);
ctx.fill();
// Side locks framing the face (Lighter left side to capture gorgeous sunset-matsuri rim light!)
// Warm Golden Highlights (リムライト)
ctx.fillStyle = '#fdba74'; // warm yellow/orange rim light
ctx.fillRect(-16.5 * scale, -112 * scale, 4 * scale, 18 * scale);
ctx.fillStyle = '#1c1917'; // dark hair overlap on right
ctx.fillRect(-15 * scale, -112 * scale, 2 * scale, 18 * scale);
ctx.fillRect(12.5 * scale, -112 * scale, 3.5 * scale, 18 * scale);
// Blushing pink cheeks (ほっぺ)
ctx.fillStyle = 'rgba(244, 63, 94, 0.45)';
ctx.beginPath();
ctx.arc(-8 * scale, -104 * scale, 3 * scale, 0, Math.PI * 2);
ctx.arc(8 * scale, -104 * scale, 3 * scale, 0, Math.PI * 2);
ctx.fill();
// Cute happy smile (口)
ctx.strokeStyle = '#991b1b';
ctx.lineWidth = 1.2 * scale;
ctx.beginPath();
ctx.arc(0, -103 * scale, 3 * scale, 0.1, Math.PI - 0.1);
ctx.stroke();
// Blinking system
const blinkCycle = tick % 4.0;
const isBlinking = blinkCycle > 3.82; // close eyes briefly for 0.18s
if (isBlinking) {
// Closed eyes (cute arcs)
ctx.strokeStyle = '#1c1917';
ctx.lineWidth = 2 * scale;
ctx.beginPath();
ctx.arc(-7 * scale, -108 * scale, 3 * scale, Math.PI + 0.2, Math.PI * 2 - 0.2);
ctx.arc(7 * scale, -108 * scale, 3 * scale, Math.PI + 0.2, Math.PI * 2 - 0.2);
ctx.stroke();
} else {
// Sparkly anime eyes (purple & highlights)
ctx.fillStyle = '#312e81'; // dark indigo
ctx.beginPath();
ctx.arc(-7 * scale, -108 * scale, 3.5 * scale, 0, Math.PI * 2);
ctx.arc(7 * scale, -108 * scale, 3.5 * scale, 0, Math.PI * 2);
ctx.fill();
// Shiny white highlight reflections
ctx.fillStyle = '#ffffff';
ctx.beginPath();
ctx.arc(-8.2 * scale, -109.5 * scale, 1.2 * scale, 0, Math.PI * 2);
ctx.arc(5.8 * scale, -109.5 * scale, 1.2 * scale, 0, Math.PI * 2);
ctx.fill();
}
// --- 7. EXQUISITE KITSUNE MASK TILTED ON HAIR (お狐様のお面) ---
ctx.save();
ctx.translate(14 * scale, -125 * scale); // tilted on upper right head
ctx.rotate(0.25); // stylized angle
// Mask Shadow
ctx.shadowColor = 'rgba(0, 0, 0, 0.3)';
ctx.shadowBlur = 6;
// White base shape of Kitsune Mask
ctx.fillStyle = '#fafafa';
ctx.beginPath();
ctx.moveTo(0, -12 * scale);
ctx.bezierCurveTo(-10 * scale, -12 * scale, -14 * scale, -2 * scale, -14 * scale, 6 * scale);
ctx.bezierCurveTo(-14 * scale, 14 * scale, -6 * scale, 24 * scale, 0, 24 * scale); // chin
ctx.bezierCurveTo(6 * scale, 24 * scale, 14 * scale, 14 * scale, 14 * scale, 6 * scale);
ctx.bezierCurveTo(14 * scale, -2 * scale, 10 * scale, -12 * scale, 0, -12 * scale);
ctx.fill();
ctx.shadowBlur = 0; // reset
// Kitsune Ears (Left & Right)
ctx.fillStyle = '#fafafa';
ctx.beginPath();
ctx.moveTo(-11 * scale, -3 * scale);
ctx.lineTo(-12 * scale, -20 * scale); // pointy ear
ctx.lineTo(-4 * scale, -9 * scale);
ctx.closePath();
ctx.fill();
ctx.beginPath();
ctx.moveTo(11 * scale, -3 * scale);
ctx.lineTo(12 * scale, -20 * scale); // pointy ear
ctx.lineTo(4 * scale, -9 * scale);
ctx.closePath();
ctx.fill();
// 朱赤の耳飾りマーキング (Red inner ear markings)
ctx.fillStyle = '#dc2626';
ctx.beginPath();
ctx.moveTo(-10.5 * scale, -6 * scale);
ctx.lineTo(-11 * scale, -16 * scale);
ctx.lineTo(-6 * scale, -9 * scale);
ctx.closePath();
ctx.fill();
ctx.beginPath();
ctx.moveTo(10.5 * scale, -6 * scale);
ctx.lineTo(11 * scale, -16 * scale);
ctx.lineTo(6 * scale, -9 * scale);
ctx.closePath();
ctx.fill();
// 朱赤の狐ひげ・目元文様 (Red cheek swirl markings & closed eyes)
ctx.strokeStyle = '#dc2626';
ctx.lineWidth = 1.2 * scale;
// Whisker curves
ctx.beginPath();
ctx.arc(-8 * scale, 12 * scale, 3 * scale, Math.PI, Math.PI * 1.5);
ctx.stroke();
ctx.beginPath();
ctx.arc(8 * scale, 12 * scale, 3 * scale, Math.PI * 1.5, Math.PI * 2);
ctx.stroke();
// Kitsune elegant curved eyes (smiling closed arches)
ctx.strokeStyle = '#111827'; // black eyes
ctx.lineWidth = 1.5 * scale;
ctx.beginPath();
ctx.arc(-5 * scale, 3 * scale, 3 * scale, Math.PI + 0.2, Math.PI * 2 - 0.2);
ctx.stroke();
ctx.beginPath();
ctx.arc(5 * scale, 3 * scale, 3 * scale, Math.PI + 0.2, Math.PI * 2 - 0.2);
ctx.stroke();
// Red eyebrow accents
ctx.fillStyle = '#dc2626';
ctx.beginPath();
ctx.ellipse(-4 * scale, -1 * scale, 2 * scale, 0.8 * scale, 0.2, 0, Math.PI * 2);
ctx.ellipse(4 * scale, -1 * scale, 2 * scale, 0.8 * scale, -0.2, 0, Math.PI * 2);
ctx.fill();
// Red nose dot
ctx.fillRect(-1 * scale, 12 * scale, 2 * scale, 2 * scale);
ctx.restore();
// --- 8. Floral Kanzashi Hairpins & Tassels behind Mask ---
ctx.save();
ctx.translate(18 * scale, -112 * scale);
// Flower Beads in hair (Red, blue, purple matching image_73dc06.png)
const colors = ['#38bdf8', '#dc2626', '#a855f7']; // light blue, red, purple
colors.forEach((col, idx) => {
ctx.fillStyle = col;
ctx.beginPath();
ctx.arc((idx - 1) * 6 * scale, -4 * scale, 4 * scale, 0, Math.PI * 2);
ctx.fill();
});
// Dangling golden tassels (揺れるかんざし飾り)
ctx.save();
const tasselSway = Math.sin(tick * 3.5) * 0.15;
ctx.rotate(tasselSway);
ctx.strokeStyle = '#facc15'; // golden tassels
ctx.lineWidth = 1 * scale;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(-2 * scale, 18 * scale);
ctx.moveTo(3 * scale, 0);
ctx.lineTo(2 * scale, 14 * scale);
ctx.stroke();
ctx.restore();
ctx.restore();
// --- 9. Wide Kimono Sleeves (袂・たもと) ---
// Left Sleeve
ctx.save();
ctx.translate(-15 * scale, -90 * scale);
ctx.rotate(0.1 + 0.08 * Math.sin(cycle));
ctx.fillStyle = '#fbcfe8'; // lavender/pink sleeve top
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(-12 * scale, 15 * scale);
ctx.lineTo(-12 * scale, 38 * scale);
ctx.lineTo(2 * scale, 25 * scale);
ctx.closePath();
ctx.fill();
ctx.restore();
// Right Sleeve
ctx.save();
ctx.translate(15 * scale, -90 * scale);
ctx.rotate(-0.1 - 0.08 * Math.sin(cycle));
ctx.fillStyle = '#bae6fd'; // sky blue sleeve
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(12 * scale, 15 * scale);
ctx.lineTo(12 * scale, 38 * scale);
ctx.lineTo(-2 * scale, 25 * scale);
ctx.closePath();
ctx.fill();
ctx.restore();
ctx.restore();
}
// Background painter rendering parallax tree rows, beautiful glowing lanterns and night moon
class Lantern {
constructor(x, y, scale) {
this.x = x;
this.y = y;
this.scale = scale;
this.phase = Math.random() * Math.PI * 2;
}
draw(ctx, tick) {
const wave = Math.sin(tick * 2 + this.phase);
const sway = Math.sin(tick * 0.8 + this.phase) * 3;
const sizeW = 16 * this.scale;
const sizeH = 26 * this.scale;
ctx.save();
ctx.translate(this.x + sway, this.y);
// Glow shadow
ctx.shadowColor = 'rgba(251, 146, 60, 0.8)'; // bright orange glow
ctx.shadowBlur = 15 + wave * 5;
// Lantern gradient (Warm paper glow)
const lGrad = ctx.createLinearGradient(0, -sizeH/2, 0, sizeH/2);
lGrad.addColorStop(0, '#ffedd5'); // white core
lGrad.addColorStop(0.3, '#fed7aa'); // bright yellow-orange
lGrad.addColorStop(0.8, '#ea580c'); // orange rim
lGrad.addColorStop(1, '#9a3412'); // dark base
ctx.fillStyle = lGrad;
// Rounded paper lantern shape
ctx.beginPath();
ctx.moveTo(-sizeW/2, -sizeH/2 + 4);
ctx.quadraticCurveTo(-sizeW/2 - 4, 0, -sizeW/2, sizeH/2 - 4);
ctx.lineTo(sizeW/2, sizeH/2 - 4);
ctx.quadraticCurveTo(sizeW/2 + 4, 0, sizeW/2, -sizeH/2 + 4);
ctx.closePath();
ctx.fill();
ctx.shadowBlur = 0; // reset shadow
// Black top/bottom wood caps
ctx.fillStyle = '#1e293b';
ctx.fillRect(-sizeW/2 + 2, -sizeH/2 - 2, sizeW - 4, 3);
ctx.fillRect(-sizeW/2 + 2, sizeH/2 - 1, sizeW - 4, 3);
// Hanging wire
ctx.strokeStyle = '#1e293b';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, -sizeH/2 - 2);
ctx.lineTo(0, -sizeH/2 - 15);
ctx.stroke();
// Kanji character inside lantern ("桜" or traditional symbol)
ctx.fillStyle = 'rgba(15, 23, 42, 0.65)';
ctx.font = `bold ${10 * this.scale}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('桜', 0, 0);
ctx.restore();
}
}
const backgroundLanterns = [];
// Spawn glowing lanterns hanging at various heights
for (let i = 0; i < 12; i++) {
backgroundLanterns.push(new Lantern(60 + i * 110, 80 + Math.sin(i) * 20, 0.8 + Math.random() * 0.4));
}
function drawScenery(tick) {
const w = sceneryCanvas.clientWidth;
const h = sceneryCanvas.clientHeight;
if (!w || !h) return;
sCtx.clearRect(0, 0, w, h);
// 1. NIGHT FESTIVAL SKY GRADIENT (深淵な夜祭り紺紫グラデーション)
const skyGrad = sCtx.createLinearGradient(0, 0, 0, h);
skyGrad.addColorStop(0, '#030008'); // pitch black sky
skyGrad.addColorStop(0.3, '#0c0517'); // indigo night
skyGrad.addColorStop(0.65, '#2e1035'); // twilight purple
skyGrad.addColorStop(0.88, '#5b0e2d'); // lantern warm horizon glow
skyGrad.addColorStop(1, '#1e0513'); // ground base dark
sCtx.fillStyle = skyGrad;
sCtx.fillRect(0, 0, w, h);
// Large, fuzzy ambient light from the distant festival lanterns
const ambientGrad = sCtx.createRadialGradient(w * 0.5, h * 0.5, 5, w * 0.5, h * 0.5, w * 0.6);
ambientGrad.addColorStop(0, 'rgba(251, 146, 60, 0.08)'); // warm amber ambient
ambientGrad.addColorStop(1, 'rgba(0,0,0,0)');
sCtx.fillStyle = ambientGrad;
sCtx.beginPath();
sCtx.arc(w * 0.5, h * 0.5, w * 0.6, 0, Math.PI * 2);
sCtx.fill();
// 2. Far Shrine / Torii Gate Silhouettes in background
sCtx.fillStyle = 'rgba(15, 4, 18, 0.8)';
// Torii Gate drawing
const toriiX = w * 0.72;
const toriiY = h * 0.72;
const tw = 80;
const th = 90;
sCtx.beginPath();
// Pillars
sCtx.rect(toriiX - tw/2, toriiY - th, 8, th);
sCtx.rect(toriiX + tw/2 - 8, toriiY - th, 8, th);
// Top beam
sCtx.rect(toriiX - tw/2 - 12, toriiY - th - 5, tw + 24, 8);
sCtx.rect(toriiX - tw/2 - 4, toriiY - th + 8, tw + 8, 6);
sCtx.fill();
// 3. Ground Walkway Base (桜色に照らされた石畳)
const groundY = h * 0.72;
const groundGrad = sCtx.createLinearGradient(0, groundY, 0, h);
groundGrad.addColorStop(0, '#09030c');
groundGrad.addColorStop(1, '#030105');
sCtx.fillStyle = groundGrad;
sCtx.fillRect(0, groundY, w, h - groundY);
// 4. Update and Draw background trees (Avenue of cherry blossoms)
backgroundTrees.forEach(tree => {
if (gamePlaying) {
tree.x -= speed;
}
if (tree.x < -200) {
tree.x = w + 150 + Math.random() * 150;
}
tree.draw(sCtx, w, h);
});
// 5. Update & draw gorgeous glowing festival lanterns
backgroundLanterns.forEach(lantern => {
if (gamePlaying) {
lantern.x -= speed;
}
if (lantern.x < -100) {
lantern.x = w + 100;
}
lantern.draw(sCtx, tick);
});
// 6. Update and render drifting soft petals
petalsList.forEach(petal => {
if (gamePlaying) {
petal.update(w, h);
}
petal.draw(sCtx);
});
// 7. Cute standing/walking animated Yukata girl with Kitsune mask
const girlX = w * 0.16;
const girlScale = Math.min(w, h) / 360; // perfect responsive scale
drawCuteAnimeGirl(sCtx, girlX, groundY, girlScale, tick);
}
// Tap hit explosion particles array
const hitExplosions = [];
class HitParticle {
constructor(x, y, color) {
this.x = x;
this.y = y;
this.color = color;
this.vx = (Math.random() - 0.5) * 10;
this.vy = (Math.random() - 0.5) * 10 - 4; // drift upward
this.alpha = 1.0;
this.size = Math.random() * 7 + 3;
this.decay = Math.random() * 0.04 + 0.02;
}
update() {
this.x += this.vx;
this.y += this.vy;
this.vy += 0.12; // mild gravity
this.alpha -= this.decay;
}
draw(ctx) {
ctx.save();
ctx.globalAlpha = this.alpha;
ctx.shadowColor = this.color;
ctx.shadowBlur = 12;
ctx.fillStyle = this.color;
ctx.beginPath();
ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
}
// Triggers glowing splash particles at the target circles
function triggerSplashParticles(lane) {
const w = gameCanvas.clientWidth;
const h = gameCanvas.clientHeight;
// Layout calculations (guarantees 100% exact alignment with lanes)
const centerX = w * 0.55; // aligned to match 3D center
const bottomWidth = w; // spans across the full width
const bottomLeftX = 0;
const bottomLaneWidth = bottomWidth / 4;
const hitX = bottomLeftX + (lane + 0.5) * bottomLaneWidth;
const hitY = h * JUDGMENT_LINE_Y_RATIO;
const colors = ['#f43f5e', '#fb923c', '#fed7aa', '#fff']; // pink/orange/warm colors
for (let i = 0; i < 18; i++) {
const color = colors[Math.floor(Math.random() * colors.length)];
hitExplosions.push(new HitParticle(hitX, hitY, color));
}
}
// Custom Lane Activation (Flashes on press)
const laneFlashTimer = [0, 0, 0, 0];
function drawGameBoard() {
const w = gameCanvas.clientWidth;
const h = gameCanvas.clientHeight;
if (!w || !h) return;
gCtx.clearRect(0, 0, w, h);
// 3D Perspective Projection Anchor Configurations (Exactly like reference image)
const horizonX = w * 0.55; // Slightly offset right to give girl on the left room
const horizonY = h * 0.28; // high up vanishing point
const horizonWidth = 20; // very narrow top
const bottomWidth = w; // wide bottom matching real tactile keys
const bottomLeftX = 0;
const bottomLaneWidth = bottomWidth / 4;
const judgmentLineY = h * JUDGMENT_LINE_Y_RATIO;
// 1. Draw 3D perspective board grid lanes (収束する桜路の3D枠線)
for (let i = 0; i <= 4; i++) {
const startX = horizonX + (i - 2) * (horizonWidth / 4);
const endX = bottomLeftX + i * bottomLaneWidth;
// Glowing neon path boundary
gCtx.strokeStyle = 'rgba(251, 146, 60, 0.35)'; // warm orange border lines
gCtx.lineWidth = i === 0 || i === 4 ? 3 : 1.2;
gCtx.shadowColor = 'rgba(251, 146, 60, 0.5)';
gCtx.shadowBlur = 8;
gCtx.beginPath();
gCtx.moveTo(startX, horizonY);
gCtx.lineTo(endX, h);
gCtx.stroke();
gCtx.shadowBlur = 0; // reset
}
// Draw scrolling horizontal stone road markers
const scrollTimeVal = isAudioFallbackMode ? (elapsedTime / 1000) : audio.currentTime;
const scrollSpeed = (scrollTimeVal * 2) % 1.0;
for (let j = 0; j < 8; j++) {
const p = (j + scrollSpeed) / 8;
const easedP = Math.pow(p, 2.2);
const lineY = horizonY + easedP * (h - horizonY);
const widthAtY = horizonWidth * (1 - easedP) + bottomWidth * easedP;
const leftAtY = horizonX * (1 - easedP) + bottomLeftX * easedP - widthAtY / 2;
gCtx.strokeStyle = 'rgba(251, 146, 60, 0.08)';
gCtx.lineWidth = 2 * easedP;
gCtx.beginPath();
gCtx.moveTo(horizonX - widthAtY/2, lineY);
gCtx.lineTo(horizonX + widthAtY/2, lineY);
gCtx.stroke();
}
// 2. Draw Active Lane Glowing overlay flasher
for (let i = 0; i < 4; i++) {
if (laneFlashTimer[i] > 0) {
gCtx.fillStyle = `rgba(251, 146, 60, ${0.18 * laneFlashTimer[i]})`;
gCtx.beginPath();
// build 3D trapezoid
const tLeftTop = horizonX + (i - 2) * (horizonWidth / 4);
const tRightTop = horizonX + (i - 1) * (horizonWidth / 4);
const bLeftBot = bottomLeftX + i * bottomLaneWidth;
const bRightBot = bottomLeftX + (i + 1) * bottomLaneWidth;
gCtx.moveTo(tLeftTop, horizonY);
gCtx.lineTo(tRightTop, horizonY);
gCtx.lineTo(bRightBot, h);
gCtx.lineTo(bLeftBot, h);
gCtx.closePath();
gCtx.fill();
laneFlashTimer[i] -= 0.08; // fade out fast
}
}
// 3. Draw Horizontal Neon Judgment Bar (お祭りの熱気ネオン判定線)
gCtx.save();
gCtx.strokeStyle = '#f43f5e';
gCtx.shadowColor = '#fdba74';
gCtx.shadowBlur = 15;
gCtx.lineWidth = 5;
gCtx.beginPath();
gCtx.moveTo(0, judgmentLineY);
gCtx.lineTo(w, judgmentLineY);
gCtx.stroke();
gCtx.restore();
// 4. Draw Glow targets (D, F, J, K target circles matching exact lanes bottom center)
for (let i = 0; i < 4; i++) {
const targetX = bottomLeftX + (i + 0.5) * bottomLaneWidth;
gCtx.save();
gCtx.shadowColor = 'rgba(244, 63, 94, 0.85)';
gCtx.shadowBlur = 12;
gCtx.fillStyle = 'rgba(12, 3, 16, 0.85)';
gCtx.strokeStyle = '#fff';
gCtx.lineWidth = 3;
gCtx.beginPath();
gCtx.arc(targetX, judgmentLineY, 26, 0, Math.PI * 2);
gCtx.fill();
gCtx.stroke();
// Inner white neon ring guide
gCtx.strokeStyle = 'rgba(251, 146, 60, 0.8)';
gCtx.lineWidth = 2;
gCtx.beginPath();
gCtx.arc(targetX, judgmentLineY, 20, 0, Math.PI * 2);
gCtx.stroke();
// Key Hint in circle
gCtx.fillStyle = '#fff';
gCtx.shadowBlur = 0;
gCtx.font = 'bold 15px "Murecho", sans-serif';
gCtx.textAlign = 'center';
gCtx.textBaseline = 'middle';
gCtx.fillText(KEY_LABELS[i], targetX, judgmentLineY);
gCtx.restore();
}
// 5. Draw Perspective Falling Square Notes with Acceleration
const currentTime = isAudioFallbackMode ? (elapsedTime / 1000) : audio.currentTime;
for (let i = 0; i < notes.length; i++) {
const note = notes[i];
if (note.hit) continue;
const timeDiff = note.time - currentTime;
// Auto MISS detection
if (timeDiff < -0.22) {
note.hit = true;
showFeedback("MISS", "text-rose-500");
combo = 0;
countMiss++;
updateHUD();
continue;
}
// Spawning filter based on timeline speed limit
if (timeDiff > NOTE_SPEED) continue;
const linearProgress = 1.0 - (timeDiff / NOTE_SPEED);
const easedProgress = Math.pow(linearProgress, 2.2);
const noteY = horizonY + easedProgress * (judgmentLineY - horizonY);
const topX_i = horizonX + (note.lane - 1.5) * (horizonWidth / 4);
const bottomX_i = bottomLeftX + (note.lane + 0.5) * bottomLaneWidth;
const noteX = topX_i * (1 - easedProgress) + bottomX_i * easedProgress;
// Size scaling (grows larger as it drops)
const noteSize = 12 + easedProgress * 36;
// Render note (Cute pink glowing frame with custom key text)
gCtx.save();
gCtx.shadowColor = '#fd923c';
gCtx.shadowBlur = 12;
gCtx.fillStyle = 'rgba(244, 63, 94, 0.95)'; // bright red-pink core
gCtx.strokeStyle = '#fff';
gCtx.lineWidth = 2.5;
// Rounded Rect for note square
const rx = noteX - noteSize / 2;
const ry = noteY - noteSize / 2;
const rad = Math.max(3, noteSize * 0.18);
gCtx.beginPath();
gCtx.moveTo(rx + rad, ry);
gCtx.lineTo(rx + noteSize - rad, ry);
gCtx.quadraticCurveTo(rx + noteSize, ry, rx + noteSize, ry + rad);
gCtx.lineTo(rx + noteSize, ry + noteSize - rad);
gCtx.quadraticCurveTo(rx + noteSize, ry + noteSize, rx + noteSize - rad, ry + noteSize);
gCtx.lineTo(rx + rad, ry + noteSize);
gCtx.quadraticCurveTo(rx, ry + noteSize, rx, ry + noteSize - rad);
gCtx.lineTo(rx, ry + rad);
gCtx.quadraticCurveTo(rx, ry, rx + rad, ry);
gCtx.closePath();
gCtx.fill();
gCtx.stroke();
// Key tag guide inside notes
gCtx.shadowBlur = 0;
gCtx.fillStyle = '#fff';
gCtx.font = `bold ${Math.max(10, noteSize * 0.45)}px sans-serif`;
gCtx.textAlign = 'center';
gCtx.textBaseline = 'middle';
gCtx.fillText(KEY_LABELS[note.lane], noteX, noteY);
gCtx.restore();
}
// 6. Update and Render floating splash particle impacts
for (let i = hitExplosions.length - 1; i >= 0; i--) {
const p = hitExplosions[i];
p.update();
if (p.alpha <= 0) {
hitExplosions.splice(i, 1);
} else {
p.draw(gCtx);
}
}
}
// Judgment Feedback overlays
let feedbackTimeout;
function showFeedback(text, colorClass) {
hudJudgment.textContent = text;
hudJudgment.className = `text-3xl font-black tracking-widest ${colorClass} transition-all transform scale-125 duration-75`;
setTimeout(() => {
hudJudgment.className = `text-3xl font-black tracking-widest ${colorClass} transition-all transform scale-100 duration-100`;
}, 80);
clearTimeout(feedbackTimeout);
feedbackTimeout = setTimeout(() => {
hudJudgment.className = "text-3xl font-black tracking-widest opacity-0 transform translate-y-2 h-10";
}, 800);
}
// Update statistics HUD panel
function updateHUD() {
const formattedScore = String(score).padStart(7, '0');
statScore.textContent = formattedScore;
// Accuracy Calculation
let acc = 100;
if (totalHitsAttempted > 0) {
const maxPossiblePoints = totalHitsAttempted * 300;
const currentEarnedPoints = (countPerfect * 300) + (countGreat * 200) + (countGood * 100);
acc = (currentEarnedPoints / maxPossiblePoints) * 100;
}
statAccuracy.textContent = acc.toFixed(1) + '%';
// Combo display
if (combo > 0) {
hudCombo.textContent = combo;
hudCombo.classList.remove('hidden');
hudCombo.classList.remove('scale-100');
hudCombo.classList.add('scale-115');
setTimeout(() => {
hudCombo.classList.remove('scale-115');
hudCombo.classList.add('scale-100');
}, 100);
} else {
hudCombo.classList.add('hidden');
}
if (combo > maxCombo) {
maxCombo = combo;
}
statMaxCombo.textContent = maxCombo;
}
// Handles Tapping logic on keyboard presses & mouse/screen taps
function handleLanePress(lane) {
if (!gamePlaying) return;
initSynthAudio();
const currentTime = isAudioFallbackMode ? (elapsedTime / 1000) : audio.currentTime;
let closestNote = null;
let minDiff = Infinity;
for (let i = 0; i < notes.length; i++) {
const note = notes[i];
if (note.lane === lane && !note.hit) {
const diff = Math.abs(note.time - currentTime);
if (diff < minDiff) {
minDiff = diff;
closestNote = note;
}
}
}
playTapSound(440 + lane * 100, 'sine', 0.08);
laneFlashTimer[lane] = 1.0;
if (closestNote) {
const diff = Math.abs(closestNote.time - currentTime);
if (diff < 0.25) {
closestNote.hit = true;
totalHitsAttempted++;
triggerSplashParticles(lane);
if (diff <= 0.05) {
// PERFECT
score += 300;
combo++;
countPerfect++;
showFeedback("PERFECT", "text-pink-300");
} else if (diff <= 0.11) {
// GREAT
score += 200;
combo++;
countGreat++;
showFeedback("GREAT", "text-cyan-400");
} else if (diff <= 0.18) {
// GOOD
score += 100;
combo++;
countGood++;
showFeedback("GOOD", "text-amber-400");
} else {
combo = 0;
countMiss++;
showFeedback("MISS", "text-rose-500");
}
updateHUD();
}
}
}
// Tap Button Glow triggers (mouse & mobile friendly)
function triggerTapVisual(lane) {
handleLanePress(lane);
const btn = document.getElementById(`tap-btn-${lane}`);
if (btn) {
btn.classList.add('bg-pink-500/40', 'border-pink-300', 'scale-95');
}
}
function releaseTapVisual(lane) {
const btn = document.getElementById(`tap-btn-${lane}`);
if (btn) {
btn.classList.remove('bg-pink-500/40', 'border-pink-300', 'scale-95');
}
}
// Key Down handlers mapping D/F/J/K keys
window.addEventListener('keydown', (e) => {
if (e.repeat) return;
const key = e.key.toLowerCase();
if (key === 'd') {
triggerTapVisual(0);
} else if (key === 'f') {
triggerTapVisual(1);
} else if (key === 'j') {
triggerTapVisual(2);
} else if (key === 'k') {
triggerTapVisual(3);
}
});
window.addEventListener('keyup', (e) => {
const key = e.key.toLowerCase();
if (key === 'd') {
releaseTapVisual(0);
} else if (key === 'f') {
releaseTapVisual(1);
} else if (key === 'j') {
releaseTapVisual(2);
} else if (key === 'k') {
releaseTapVisual(3);
}
});
let tickCounter = 0;
let animationFrameId;
function gameLoop() {
tickCounter += 0.05;
if (gamePlaying) {
elapsedTime = performance.now() - gameStartTime;
}
drawScenery(tickCounter);
drawGameBoard();
const isSongFinished = isAudioFallbackMode ? (elapsedTime > 120 * 1000) : audio.ended;
if (isSongFinished && gamePlaying) {
endGame();
}
animationFrameId = requestAnimationFrame(gameLoop);
}
function startGame() {
initSynthAudio();
if (isAudioFallbackMode) {
console.log("Playing game in Synthetic BGM mode.");
startSynthBgm();
} else {
audio.play().then(() => {
console.log("Main music track started playing successfully!");
}).catch(err => {
console.warn("Main audio play failed, switching to backup synthetic sound immediately.", err);
isAudioFallbackMode = true;
startSynthBgm();
});
}
score = 0;
combo = 0;
maxCombo = 0;
totalHitsAttempted = 0;
countPerfect = 0;
countGreat = 0;
countGood = 0;
countMiss = 0;
generateBeatMap();
notes = JSON.parse(JSON.stringify(beatMap));
gameStartTime = performance.now();
elapsedTime = 0;
gamePlaying = true;
updateHUD();
startScreen.classList.add('opacity-0', 'pointer-events-none');
resultsScreen.classList.add('hidden');
}
function endGame() {
gamePlaying = false;
stopSynthBgm();
if (!isAudioFallbackMode) {
audio.pause();
audio.currentTime = 0;
}
let acc = 100;
if (totalHitsAttempted > 0) {
const maxPossiblePoints = totalHitsAttempted * 300;
const currentEarnedPoints = (countPerfect * 300) + (countGreat * 200) + (countGood * 100);
acc = (currentEarnedPoints / maxPossiblePoints) * 100;
}
let rank = 'D';
if (acc >= 95) rank = 'S';
else if (acc >= 85) rank = 'A';
else if (acc >= 75) rank = 'B';
else if (acc >= 60) rank = 'C';
document.getElementById('result-rank').textContent = rank;
document.getElementById('res-score').textContent = score.toLocaleString();
document.getElementById('res-acc').textContent = acc.toFixed(2) + '%';
document.getElementById('res-maxcombo').textContent = maxCombo;
document.getElementById('res-perf').textContent = countPerfect;
document.getElementById('res-great').textContent = countGreat;
document.getElementById('res-good').textContent = countGood;
document.getElementById('res-miss').textContent = countMiss;
resultsScreen.classList.remove('hidden');
}
// Connect button listeners
startBtn.addEventListener('click', startGame);
restartBtn.addEventListener('click', () => {
endGame();
startGame();
});
resRetryBtn.addEventListener('click', () => {
resultsScreen.classList.add('hidden');
startGame();
});
resCloseBtn.addEventListener('click', () => {
resultsScreen.classList.add('hidden');
startScreen.classList.remove('opacity-0', 'pointer-events-none');
});
// Initialize animation loop instantly
window.onload = function() {
gameLoop();
};
// Pre-create floating sakura overlay petals
const htmlPetalsContainer = document.getElementById('html-petals-container');
for (let j = 0; j < 15; j++) {
const hPetal = document.createElement('div');
hPetal.className = 'absolute bg-pink-300 rounded-full opacity-45 pointer-events-none transition-transform';
hPetal.style.width = (Math.random() * 8 + 6) + 'px';
hPetal.style.height = (Math.random() * 6 + 4) + 'px';
hPetal.style.left = Math.random() * 100 + 'vw';
hPetal.style.top = Math.random() * 100 + 'vh';
hPetal.style.animation = `pulseGlow ${Math.random() * 4 + 2}s infinite alternate`;
htmlPetalsContainer.appendChild(hPetal);
}
</script>
</body>
</html>
たまに違うのが飛び出してきます。気にしないでください。