diff --git a/README.md b/README.md index c3185b1..1519dd2 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,34 @@ -# ๐ธ Multilingual Calendar 2026 +# ๐ธ Multilingual Calendar 2026 | ๅค่ฏญ่จๆฅๅ 2026 | ๋ค๊ตญ์ด ๋ฌ๋ ฅ 2026 -[](https://github.com/mrfeixiang/2026/actions/workflows/deploy.yml) -[](https://mrfeixiang.github.io/2026/) +## Piano Hero -A beautiful, interactive calendar application that combines date management with language learning features for English and Chinese, enhanced with stunning Portuguese flower imagery for each month. +Play: https://mrfeixiang.github.io/piano-hero/ -## ๐ [**Live Demo โ**](https://mrfeixiang.github.io/2026/) +A Guitar Hero-style piano game in the browser. Press keyboard keys when falling notes hit the bottom to score points. -## โจ Features +- Free Play: improvise freely with any key +- Practice Songs: Twinkle Twinkle, Ode to Joy, Happy Birthday, and more +- Scoring: Perfect / Good hits build combos and multiply your score +- Controls: A S D F G H J K = white keys C-C5, W E T Y U = black keys -### ๐๏ธ Calendar Functionality +--- + +## ๐ฌ๐ง English + +A beautiful, interactive calendar application for 2026 that combines date management with language learning features for English and Chinese, enhanced with stunning Portuguese flower imagery for each month. + +### ๐ [Live Demo โ](https://mrfeixiang.github.io/2026/) + +### โจ Features + +#### ๐๏ธ Calendar Functionality - Complete 2026 calendar with intuitive navigation - Current date highlighting and weekend distinction - Responsive design that works on all devices - Smooth month-to-month transitions - Keyboard navigation support (arrow keys) -### ๐บ Portuguese Flowers +#### ๐บ Portuguese Flowers Each month features a beautiful Portuguese flower with names in three languages: | Month | Portuguese | English | Chinese | Seasonal Theme | @@ -34,105 +46,179 @@ Each month features a beautiful Portuguese flower with names in three languages: | November | Ciclรขmen | Cyclamen | ไปๅฎขๆฅ | Late autumn beauty | | December | Bico-de-papagaio | Poinsettia | ไธๅ็บข | Holiday celebration | -## ๐ฏ Language Learning Features - -### ๐ English Learning -- Daily quotes and vocabulary explanations -- Pronunciation guides for month names -- Flower-related idioms and expressions -- Grammar tips for time expressions -- Fun facts across multiple categories (history, science, culture, nature) - -### ๐ณ Chinese Learning -- Chinese characters for dates and numbers -- Month names in simplified and traditional characters -- Weekday names with pinyin pronunciation +#### ๐ Language Learning +- Daily English quotes, vocabulary, idioms, and grammar tips +- Chinese characters for dates, weekdays, and month names with pinyin - Cultural information about Chinese holidays and festivals -- Traditional greetings and customs -### ๐ฎ Chinese Holidays 2026 -- **New Year's Day**: January 1-3 -- **Spring Festival** (Year of Fire Horse): February 17-25 (Golden Week) -- **Tomb-Sweeping Day**: April 5-6 -- **Labor Day**: May 1-2 +#### ๐ฎ Chinese Holidays 2026 +- **New Year's Day**: January 1โ3 +- **Spring Festival** (Year of Fire Horse): February 17โ25 +- **Tomb-Sweeping Day**: April 5โ6 +- **Labor Day**: May 1โ2 - **Dragon Boat Festival**: June 19 - **Mid-Autumn Festival**: September 25 -- **National Day**: October 1-7 (Golden Week) - -## ๐ ๏ธ Technical Features - -- **Pure Vanilla JS**: No external dependencies for maximum performance -- **Responsive Design**: Perfect on desktop, tablet, and mobile devices -- **Accessibility**: Full ARIA labels, keyboard navigation, and screen reader support -- **Performance Optimized**: <50ms calendar generation, lazy loading, preloading -- **Error Handling**: Graceful fallbacks and comprehensive error recovery -- **Comprehensive Testing**: 30+ automated tests covering all functionality - -## ๐งช Testing - -The application includes a comprehensive test suite: - -- **Integration Tests**: Core functionality validation -- **Unit Tests**: Individual component testing -- **Performance Tests**: Speed and efficiency validation -- **Accessibility Tests**: ARIA labels and keyboard navigation -- **Error Handling Tests**: Edge cases and invalid inputs +- **National Day**: October 1โ7 + +#### ๐ ๏ธ Technical +- Pure Vanilla JS โ no external dependencies +- Fully responsive (desktop, tablet, mobile) +- WCAG 2.1 AA accessible (ARIA labels, keyboard navigation) +- <50ms calendar generation +- 30+ automated tests + +#### ๐ Quick Start +```bash +git clone https://github.com/mrfeixiang/2026.git +cd 2026 +python3 -m http.server 8000 +# Open http://localhost:8000 +``` + +#### ๐ฑ Browser Support +Chrome ยท Firefox ยท Safari ยท Edge ยท iOS Safari ยท Chrome Mobile -Run tests by opening any of these files: -- `test-integration.html` - Basic integration tests -- `comprehensive-test.html` - Full test suite -- `final-test-report.html` - Professional test report - -## ๐จ Design Philosophy - -This calendar combines functionality with beauty, creating an immersive experience that makes learning enjoyable. The Portuguese flowers add natural elegance while multilingual features provide practical language learning opportunities in a culturally rich context. - -## ๐ฑ Browser Support - -- โ Chrome (recommended) -- โ Firefox -- โ Safari -- โ Edge -- โ Mobile browsers (iOS Safari, Chrome Mobile) - -## ๐ Quick Start - -1. **Visit the live demo**: [https://mrfeixiang.github.io/2026/](https://mrfeixiang.github.io/2026/) -2. **Or run locally**: - ```bash - git clone https://github.com/mrfeixiang/2026.git - cd 2026 - python3 -m http.server 8000 - # Open http://localhost:8000 - ``` - -## ๐ง Development - -Built with modern web standards: -- **HTML5**: Semantic structure with accessibility features -- **CSS3**: Flexbox/Grid layouts, custom properties, animations -- **ES6+ JavaScript**: Classes, modules, async/await, modern APIs -- **Progressive Enhancement**: Works without JavaScript for basic functionality +--- -## ๐ Project Stats +## ๐จ๐ณ ไธญๆ + +ไธๆฌพ็ฒพ็พ็ 2026 ๅนดไบๅจๆฅๅๅบ็จ๏ผๅฐๆฅๆ็ฎก็ไธ่ฑ่ฏญใไธญๆ่ฏญ่จๅญฆไน ็ธ็ปๅ๏ผๅนถไธบๆฏไธชๆ้ ไปฅ็ฒพ็พ็่ก่็่ฑๅๅพๅใ + +### ๐ [ๅจ็บฟๆผ็คบ โ](https://mrfeixiang.github.io/2026/) + +### โจ ๅ่ฝ็น่ฒ + +#### ๐๏ธ ๆฅๅๅ่ฝ +- ๅฎๆด็ 2026 ๅนดๆฅๅ๏ผๆฏๆ็ด่งๅฏผ่ช +- ๅฝๅๆฅๆ้ซไบฎๆพ็คบ๏ผๅบๅๅทฅไฝๆฅไธๅจๆซ +- ๅๅบๅผ่ฎพ่ฎก๏ผ้้ ๆๆ่ฎพๅค +- ๆต็ ็ๆไปฝๅๆข่ฟๆธกๆๆ +- ๆฏๆ้ฎ็ๆนๅ้ฎๅฏผ่ช + +#### ๐บ ่ก่็่ฑๅ +ๆฏไธชๆ้ฝๅฑ็คบไธ็ง็พไธฝ็่ก่็่ฑๅ๏ผๅนถๆไพไธ็ง่ฏญ่จ็ๅ็งฐ๏ผ + +| ๆไปฝ | ่ก่็่ฏญ | ่ฑ่ฏญ | ไธญๆ | ๅญฃ่ไธป้ข | +|------|----------|------|------|----------| +| ไธๆ | Camรฉlia | Camellia | ๅฑฑ่ถ่ฑ | ๅฌๆฅๅ ธ้ | +| ไบๆ | Flor de Amendoeira | Almond Blossom | ๆ่ฑ | ๆฉๆฅๅธๆ | +| ไธๆ | Narciso | Daffodil | ๆฐดไป่ฑ | ๆฅๆฅ่ง้ | +| ๅๆ | Flor de Cerejeira | Cherry Blossom | ๆจฑ่ฑ | ็็ถๆฐ็ | +| ไบๆ | Rosa | Rose | ็ซ็ฐ | ่กๅผๅญ่บ | +| ๅ ญๆ | Alfazema | Lavender | ่ฐ่กฃ่ | ๅคๆฅ่ฌ่ณ | +| ไธๆ | Girassol | Sunflower | ๅๆฅ่ต | ๅคๆฅ้ณๅ | +| ๅ ซๆ | Buganvรญlia | Bougainvillea | ไธ่งๆข | ๅฐไธญๆตทๆๆ | +| ไนๆ | Dรกlia | Dahlia | ๅคงไธฝ่ฑ | ็งๆฅไธฐ็ | +| ๅๆ | Crisรขntemo | Chrysanthemum | ่่ฑ | ้็งไผ ็ป | +| ๅไธๆ | Ciclรขmen | Cyclamen | ไปๅฎขๆฅ | ๆทฑ็งไน็พ | +| ๅไบๆ | Bico-de-papagaio | Poinsettia | ไธๅ็บข | ่ๆฅๅบๅ ธ | + +#### ๐ ่ฏญ่จๅญฆไน ๅ่ฝ +- ๆฏๆฅ่ฑ่ฏญๅ่จใ่ฏๆฑ่ฎฒ่งฃใไน ่ฏญไธ่ฏญๆณๆ็คบ +- ไธญๆๆฅๆใๆๆไธๆไปฝๅ็งฐ๏ผๅซๆผ้ณ๏ผ +- ไธญๅฝ่ๅๆฅไธไผ ็ปๆๅไป็ป + +#### ๐ฎ 2026 ๅนดไธญๅฝ่ๅๆฅ +- **ๅ ๆฆ**๏ผ1ๆ1ๆฅโ3ๆฅ +- **ๆฅ่**๏ผไธๅ็ซ้ฉฌๅนด๏ผ๏ผ2ๆ17ๆฅโ25ๆฅ +- **ๆธ ๆ่**๏ผ4ๆ5ๆฅโ6ๆฅ +- **ๅณๅจ่**๏ผ5ๆ1ๆฅโ2ๆฅ +- **็ซฏๅ่**๏ผ6ๆ19ๆฅ +- **ไธญ็ง่**๏ผ9ๆ25ๆฅ +- **ๅฝๅบ่**๏ผ10ๆ1ๆฅโ7ๆฅ + +#### ๐ ๏ธ ๆๆฏ็น็น +- ็บฏๅ็ JS๏ผๆ ็ฌฌไธๆนไพ่ต +- ๅฎๅ จๅๅบๅผ๏ผๆก้ขใๅนณๆฟใๆๆบ๏ผ +- ็ฌฆๅ WCAG 2.1 AA ๆ ้็ขๆ ๅ +- ๆฅๅ็ๆ้ๅบฆ <50ms +- ่ฆ็ 30+ ้กน่ชๅจๅๆต่ฏ + +#### ๐ ๅฟซ้ๅผๅง +```bash +git clone https://github.com/mrfeixiang/2026.git +cd 2026 +python3 -m http.server 8000 +# ๆๅผ http://localhost:8000 +``` -- **Lines of Code**: ~1,500 (HTML/CSS/JS) -- **Test Coverage**: 30+ comprehensive tests -- **Performance**: <50ms calendar generation -- **Accessibility**: WCAG 2.1 AA compliant -- **Languages**: 3 (Portuguese, English, Chinese) -- **Flowers**: 12 unique Portuguese flowers +--- -## ๐ค Contributing +## ๐ฐ๐ท ํ๊ตญ์ด + +2026๋ ์ ์ํ ์๋ฆ๋ค์ด ์ธํฐ๋ํฐ๋ธ ๋ฌ๋ ฅ ์ฑ์ผ๋ก, ๋ ์ง ๊ด๋ฆฌ์ ์์ดยท์ค๊ตญ์ด ์ธ์ด ํ์ต ๊ธฐ๋ฅ์ ๊ฒฐํฉํ๊ณ ๋งค๋ฌ ํฌ๋ฅดํฌ๊ฐ ๊ฝ ์ด๋ฏธ์ง๋ก ๊พธ๋ฉฐ์ง ์ฑ์ ๋๋ค. + +### ๐ [๋ผ์ด๋ธ ๋ฐ๋ชจ โ](https://mrfeixiang.github.io/2026/) + +### โจ ์ฃผ์ ๊ธฐ๋ฅ + +#### ๐๏ธ ๋ฌ๋ ฅ ๊ธฐ๋ฅ +- ์ง๊ด์ ์ธ ํ์์ด ๊ฐ๋ฅํ ์์ ํ 2026๋ ๋ฌ๋ ฅ +- ์ค๋ ๋ ์ง ๊ฐ์กฐ ํ์ ๋ฐ ์ฃผ๋ง ๊ตฌ๋ถ +- ๋ชจ๋ ๊ธฐ๊ธฐ์์ ์๋ํ๋ ๋ฐ์ํ ๋์์ธ +- ๋ถ๋๋ฌ์ด ์๊ฐ ์ ํ ํจ๊ณผ +- ํค๋ณด๋ ๋ฐฉํฅํค ํ์ ์ง์ + +#### ๐บ ํฌ๋ฅดํฌ๊ฐ ๊ฝ +๋งค๋ฌ ์๋ฆ๋ค์ด ํฌ๋ฅดํฌ๊ฐ ๊ฝ์ 3๊ฐ ์ธ์ด ์ด๋ฆ๊ณผ ํจ๊ป ์๊ฐํฉ๋๋ค: + +| ์ | ํฌ๋ฅดํฌ๊ฐ์ด | ์์ด | ์ค๊ตญ์ด | ๊ณ์ ํ ๋ง | +|----|-----------|------|--------|----------| +| 1์ | Camรฉlia | Camellia | ๅฑฑ่ถ่ฑ | ๊ฒจ์ธ์ ์ฐ์ํจ | +| 2์ | Flor de Amendoeira | Almond Blossom | ๆ่ฑ | ์ด๋ฅธ ๋ด์ ํฌ๋ง | +| 3์ | Narciso | Daffodil | ๆฐดไป่ฑ | ๋ด์ ๊ฐ์ฑ | +| 4์ | Flor de Cerejeira | Cherry Blossom | ๆจฑ่ฑ | ์๋ก์ด ์์ | +| 5์ | Rosa | Rose | ็ซ็ฐ | ํฌ๋ฅดํฌ๊ฐ ์ ์ | +| 6์ | Alfazema | Lavender | ่ฐ่กฃ่ | ์ฌ๋ฆ์ ํฅ๊ธฐ | +| 7์ | Girassol | Sunflower | ๅๆฅ่ต | ์ฌ๋ฆ์ ๋ฐ์ | +| 8์ | Buganvรญlia | Bougainvillea | ไธ่งๆข | ์ง์คํด์ ๋ฐ๋ปํจ | +| 9์ | Dรกlia | Dahlia | ๅคงไธฝ่ฑ | ๊ฐ์์ ํ์ | +| 10์ | Crisรขntemo | Chrysanthemum | ่่ฑ | ๊ฐ์ ์ ํต | +| 11์ | Ciclรขmen | Cyclamen | ไปๅฎขๆฅ | ๋ฆ๊ฐ์์ ์๋ฆ๋ค์ | +| 12์ | Bico-de-papagaio | Poinsettia | ไธๅ็บข | ์ฐ๋ง ์ถ์ | + +#### ๐ ์ธ์ด ํ์ต ๊ธฐ๋ฅ +- ๋งค์ผ ์์ด ๋ช ์ธ, ์ดํ, ๊ด์ฉ์ด, ๋ฌธ๋ฒ ํ ์ ๊ณต +- ๋ ์งยท์์ผยท์ ์ด๋ฆ์ ์ค๊ตญ์ด ํ์ ๋ฐ ๋ณ์ +- ์ค๊ตญ ๊ณตํด์ผ๊ณผ ์ ํต ๋ฌธํ ์ ๋ณด + +#### ๐ฎ 2026๋ ์ค๊ตญ ๊ณตํด์ผ +- **์ ์ **: 1์ 1์ผโ3์ผ +- **์ถ์ ** (๋ณ์ค๋ ): 2์ 17์ผโ25์ผ +- **์ฒญ๋ช ์ **: 4์ 5์ผโ6์ผ +- **๋ ธ๋์ **: 5์ 1์ผโ2์ผ +- **๋จ์ค์ **: 6์ 19์ผ +- **์ค์ถ์ **: 9์ 25์ผ +- **๊ตญ๊ฒฝ์ **: 10์ 1์ผโ7์ผ + +#### ๐ ๏ธ ๊ธฐ์ ์ ํน์ง +- ์์ ๋ฐ๋๋ผ JS โ ์ธ๋ถ ์์กด์ฑ ์์ +- ์์ ๋ฐ์ํ (๋ฐ์คํฌํฑ, ํ๋ธ๋ฆฟ, ๋ชจ๋ฐ์ผ) +- WCAG 2.1 AA ์ ๊ทผ์ฑ ์ค์ (ARIA ๋ ์ด๋ธ, ํค๋ณด๋ ํ์) +- ๋ฌ๋ ฅ ์์ฑ ์๋ <50ms +- 30๊ฐ ์ด์์ ์๋ํ ํ ์คํธ + +#### ๐ ๋น ๋ฅธ ์์ +```bash +git clone https://github.com/mrfeixiang/2026.git +cd 2026 +python3 -m http.server 8000 +# http://localhost:8000 ์ด๊ธฐ +``` + +#### ๐ฑ ๋ธ๋ผ์ฐ์ ์ง์ +Chrome ยท Firefox ยท Safari ยท Edge ยท iOS Safari ยท Chrome Mobile -Contributions are welcome! Please feel free to submit a Pull Request. For major changes, please open an issue first to discuss what you would like to change. +--- -## ๐ License +## ๐ License / ่ฎธๅฏ่ฏ / ๋ผ์ด์ ์ค -This project is open source and available under the [MIT License](LICENSE). +MIT License โ open source and free to use. +MIT ่ฎธๅฏ่ฏ โ ๅผๆบๅ ่ดนไฝฟ็จใ +MIT ๋ผ์ด์ ์ค โ ์คํ ์์ค, ์์ ๋กญ๊ฒ ์ฌ์ฉ ๊ฐ๋ฅํฉ๋๋ค. --- -**๐ธ Enjoy exploring the beautiful intersection of time, language, and nature! ๐ ๐** - -*Made with โค๏ธ for language learners and calendar enthusiasts* \ No newline at end of file +**๐ธ Enjoy the beautiful intersection of time, language, and nature!** +**๐ธ ไบซๅๆถ้ดใ่ฏญ่จไธ่ช็ถ็็พไธฝไบคๆฑ๏ผ** +**๐ธ ์๊ฐ, ์ธ์ด, ์์ฐ์ด ์๋ฆ๋ต๊ฒ ๋ง๋๋ ๊ณณ์ ์ฆ๊ฒจ๋ณด์ธ์!** diff --git a/index.html b/index.html index ab7af04..296ac4d 100644 --- a/index.html +++ b/index.html @@ -85,6 +85,7 @@ Multilingual Calendar 2026 Learn English & Chinese with Portuguese Flowers + ๐น Play Piano Hero diff --git a/piano.css b/piano.css new file mode 100644 index 0000000..6f41d95 --- /dev/null +++ b/piano.css @@ -0,0 +1,647 @@ +/* โโโ Reset & Base โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg: #0a0a0f; + --bg-card: #12121a; + --bg-card-2: #1a1a28; + --text: #e0e0f0; + --text-dim: #7070a0; + --border: rgba(255,255,255,0.08); + --hit-zone-h: 18px; + --keyboard-h: 160px; + --hud-h: 54px; + + /* Note lane colors */ + --color-C4: #ff4444; + --color-D4: #ff8800; + --color-E4: #ffdd00; + --color-F4: #88ff00; + --color-G4: #00ffcc; + --color-A4: #0088ff; + --color-B4: #8844ff; + --color-C5: #ff44aa; + --color-Cs4: #cc2222; + --color-Ds4: #cc6600; + --color-Fs4: #55aa00; + --color-Gs4: #007a66; + --color-As4: #005fcc; +} + +html, body { + width: 100%; + height: 100%; + overflow: hidden; + background: var(--bg); + color: var(--text); + font-family: system-ui, -apple-system, sans-serif; +} + +button { cursor: pointer; border: none; outline: none; } +.hidden { display: none !important; } + +/* โโโ Screens โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */ +.screen { + display: none; + width: 100%; + height: 100%; + flex-direction: column; + align-items: center; + justify-content: center; +} +.screen.active { display: flex; } + +.screen.overlay { + position: fixed; + inset: 0; + z-index: 200; + background: rgba(0,0,0,0.75); + backdrop-filter: blur(4px); +} + +/* โโโ Buttons โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */ +.btn { + padding: 14px 32px; + border-radius: 10px; + font-size: 1.1rem; + font-weight: 700; + letter-spacing: 0.03em; + transition: transform 0.1s, box-shadow 0.1s, background 0.15s; + user-select: none; +} +.btn:hover { transform: translateY(-2px); } +.btn:active { transform: translateY(1px); } + +.btn-primary { + background: linear-gradient(135deg, #5533ff, #9944ff); + color: #fff; + box-shadow: 0 4px 20px rgba(120,60,255,0.45); +} +.btn-primary:hover { box-shadow: 0 6px 28px rgba(120,60,255,0.7); } + +.btn-secondary { + background: var(--bg-card-2); + color: var(--text); + border: 1px solid rgba(255,255,255,0.15); +} +.btn-secondary:hover { background: #22223a; } + +.btn-danger { + background: linear-gradient(135deg, #cc2222, #ff4444); + color: #fff; +} + +.btn-back { + background: transparent; + color: var(--text-dim); + border: 1px solid var(--border); + margin-top: 24px; +} +.btn-back:hover { color: var(--text); border-color: rgba(255,255,255,0.3); } + +.btn-hud { + background: rgba(255,255,255,0.08); + color: var(--text); + padding: 8px 16px; + font-size: 0.85rem; + border-radius: 6px; + border: 1px solid var(--border); +} +.btn-hud:hover { background: rgba(255,255,255,0.15); } + +/* โโโ Menu Screen โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */ +.menu-content { + display: flex; + flex-direction: column; + align-items: center; + gap: 24px; + max-width: 500px; + width: 100%; + padding: 32px 16px; +} + +.game-title { + font-size: clamp(2.5rem, 8vw, 5rem); + font-weight: 900; + background: linear-gradient(135deg, #ff44aa, #8844ff, #00ffcc); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + letter-spacing: -0.02em; + text-align: center; +} + +.menu-subtitle { + color: var(--text-dim); + font-size: 1rem; + text-align: center; +} + +.menu-buttons { + display: flex; + flex-direction: column; + gap: 12px; + width: 100%; + max-width: 320px; +} +.menu-buttons .btn { text-align: center; width: 100%; } + +.menu-controls { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 12px; + padding: 16px 20px; + width: 100%; + max-width: 380px; +} +.menu-controls h3 { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--text-dim); + margin-bottom: 10px; +} +.control-row { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + margin-bottom: 8px; +} +.key-badge { + background: var(--bg-card-2); + border: 1px solid rgba(255,255,255,0.2); + border-bottom: 3px solid rgba(255,255,255,0.2); + border-radius: 5px; + padding: 2px 7px; + font-size: 0.75rem; + font-family: monospace; + font-weight: 700; +} +.key-badge.black-kbd { + background: #1a1a2e; + border-color: rgba(255,255,255,0.1); +} +.control-label { font-size: 0.8rem; color: var(--text-dim); } + +/* โโโ Song Selection Screen โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */ +.songs-content { + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + width: 100%; + max-width: 600px; + padding: 24px 16px; + height: 100%; + overflow-y: auto; +} +.songs-content h2 { + font-size: 2rem; + font-weight: 800; +} + +.song-list { + display: flex; + flex-direction: column; + gap: 12px; + width: 100%; +} + +.song-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 14px; + padding: 16px 20px; + display: flex; + align-items: center; + justify-content: space-between; + cursor: pointer; + transition: background 0.15s, border-color 0.15s, transform 0.1s; + user-select: none; +} +.song-card:hover { + background: var(--bg-card-2); + border-color: rgba(136,68,255,0.5); + transform: translateX(4px); +} +.song-card:active { transform: translateX(2px); } + +.song-card-left { display: flex; flex-direction: column; gap: 4px; } +.song-title { font-size: 1.1rem; font-weight: 700; } +.song-meta { font-size: 0.8rem; color: var(--text-dim); } + +.song-card-right { display: flex; flex-direction: column; align-items: flex-end; gap: 6px; } +.difficulty-badge { + padding: 3px 10px; + border-radius: 20px; + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; +} +.diff-easy { background: rgba(136,255,0,0.2); color: #88ff00; } +.diff-medium { background: rgba(255,136,0,0.2); color: #ff8800; } +.diff-hard { background: rgba(255,68,68,0.2); color: #ff4444; } + +.song-best-score { font-size: 0.78rem; color: var(--text-dim); } + +/* โโโ Game Screen Layout โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */ +#screen-game { + display: none; + flex-direction: column; + justify-content: stretch; + align-items: stretch; + overflow: hidden; +} +#screen-game.active { display: flex; } + +/* โโโ HUD โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */ +#hud { + height: var(--hud-h); + background: rgba(0,0,0,0.6); + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 16px; + flex-shrink: 0; + z-index: 10; +} +.hud-left { display: flex; gap: 20px; align-items: center; } +.hud-center { font-weight: 700; font-size: 1rem; color: var(--text-dim); } +.hud-right { display: flex; gap: 8px; } + +#hud-score { + font-size: 1.1rem; + font-weight: 800; + color: #ffdd00; +} +.combo-display { + font-size: 1rem; + font-weight: 700; + color: var(--color-G4); + transition: transform 0.15s; +} +.combo-display.pop { + transform: scale(1.5); +} + +/* โโโ Countdown Overlay โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */ +#countdown-overlay { + position: absolute; + inset: 0; + z-index: 100; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0,0,0,0.5); + pointer-events: none; +} +#countdown-number { + font-size: 8rem; + font-weight: 900; + color: #fff; + text-shadow: 0 0 40px rgba(255,255,255,0.8); + animation: countdown-pulse 0.9s ease-in-out; +} +@keyframes countdown-pulse { + 0% { transform: scale(1.6); opacity: 0; } + 30% { transform: scale(1.0); opacity: 1; } + 80% { transform: scale(1.0); opacity: 1; } + 100% { transform: scale(0.7); opacity: 0; } +} + +/* โโโ Note Highway โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */ +#note-highway { + flex: 1; + display: flex; + flex-direction: row; + position: relative; + overflow: hidden; + background: linear-gradient(to bottom, #050508, #0a0a14); +} + +.lane { + flex: 1; + border-right: 1px solid var(--border); + position: relative; + overflow: hidden; +} +.lane:last-of-type { border-right: none; } + +/* Lane subtle background glow per note */ +#lane-C4 { background: rgba(255, 68, 68, 0.03); } +#lane-D4 { background: rgba(255,136, 0, 0.03); } +#lane-E4 { background: rgba(255,221, 0, 0.03); } +#lane-F4 { background: rgba(136,255, 0, 0.03); } +#lane-G4 { background: rgba( 0,255,204, 0.03); } +#lane-A4 { background: rgba( 0,136,255, 0.03); } +#lane-B4 { background: rgba(136, 68,255, 0.03); } +#lane-C5 { background: rgba(255, 68,170, 0.03); } + +.lane-label { + position: absolute; + bottom: calc(var(--hit-zone-h) + 4px); + left: 50%; + transform: translateX(-50%); + font-size: 0.65rem; + font-weight: 700; + color: rgba(255,255,255,0.15); + pointer-events: none; + z-index: 5; +} + +/* Black key lanes: narrow, absolutely positioned over white lanes */ +.black-lane { + position: absolute; + top: 0; + width: 6.25%; /* 50% of 1/8 white key width */ + z-index: 3; + flex: none; + overflow: hidden; + border-right: none; +} +/* Positions computed as: (whiteKeyIndex + 0.625) / 8 * 100% - 3.125% */ +#lane-Cs4 { left: calc(0.625 / 8 * 100% - 3.125%); } +#lane-Ds4 { left: calc(1.625 / 8 * 100% - 3.125%); } +#lane-Fs4 { left: calc(3.625 / 8 * 100% - 3.125%); } +#lane-Gs4 { left: calc(4.625 / 8 * 100% - 3.125%); } +#lane-As4 { left: calc(5.625 / 8 * 100% - 3.125%); } + +/* Hit zone strip */ +#hit-zone { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: var(--hit-zone-h); + background: rgba(255,255,255,0.07); + border-top: 2px solid rgba(255,255,255,0.25); + z-index: 4; + pointer-events: none; +} + +/* โโโ Falling Notes โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */ +.falling-note { + position: absolute; + left: 3px; + right: 3px; + height: 38px; + border-radius: 7px; + will-change: transform; + transform: translateZ(0); + transition: opacity 0.12s; + z-index: 2; +} +.falling-note.miss-flash { + opacity: 0.2; + transition: opacity 0.15s; +} +.falling-note.hit-pop { + animation: note-hit-pop 0.2s ease-out forwards; +} + +@keyframes note-hit-pop { + 0% { transform: scaleY(1.2); opacity: 1; } + 100% { transform: scaleY(0); opacity: 0; } +} + +/* Per-note colors */ +.note-C4 { background: var(--color-C4); box-shadow: 0 0 10px var(--color-C4); } +.note-D4 { background: var(--color-D4); box-shadow: 0 0 10px var(--color-D4); } +.note-E4 { background: var(--color-E4); box-shadow: 0 0 10px var(--color-E4); } +.note-F4 { background: var(--color-F4); box-shadow: 0 0 10px var(--color-F4); } +.note-G4 { background: var(--color-G4); box-shadow: 0 0 10px var(--color-G4); } +.note-A4 { background: var(--color-A4); box-shadow: 0 0 10px var(--color-A4); } +.note-B4 { background: var(--color-B4); box-shadow: 0 0 10px var(--color-B4); } +.note-C5 { background: var(--color-C5); box-shadow: 0 0 10px var(--color-C5); } +.note-Cs4 { background: var(--color-Cs4); box-shadow: 0 0 10px var(--color-Cs4); } +.note-Ds4 { background: var(--color-Ds4); box-shadow: 0 0 10px var(--color-Ds4); } +.note-Fs4 { background: var(--color-Fs4); box-shadow: 0 0 10px var(--color-Fs4); } +.note-Gs4 { background: var(--color-Gs4); box-shadow: 0 0 10px var(--color-Gs4); } +.note-As4 { background: var(--color-As4); box-shadow: 0 0 10px var(--color-As4); } + +/* โโโ Feedback Popup โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */ +#feedback-popup { + position: fixed; + top: 35%; + left: 50%; + transform: translateX(-50%); + font-size: 2.4rem; + font-weight: 900; + pointer-events: none; + z-index: 150; + letter-spacing: 0.04em; + text-shadow: 0 2px 12px rgba(0,0,0,0.8); + opacity: 0; +} +#feedback-popup.show-perfect { + color: #ffd700; + animation: feedback-anim 0.55s ease-out forwards; +} +#feedback-popup.show-good { + color: #00ff99; + animation: feedback-anim 0.55s ease-out forwards; +} +#feedback-popup.show-miss { + color: #ff4444; + animation: feedback-anim 0.55s ease-out forwards; +} + +@keyframes feedback-anim { + 0% { opacity: 1; transform: translateX(-50%) translateY(0) scale(1.3); } + 60% { opacity: 1; transform: translateX(-50%) translateY(-10px) scale(1.0); } + 100% { opacity: 0; transform: translateX(-50%) translateY(-30px) scale(0.9); } +} + +/* โโโ Piano Keyboard โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */ +#piano-keyboard { + height: var(--keyboard-h); + flex-shrink: 0; + display: flex; + flex-direction: row; + position: relative; + background: #111; + border-top: 2px solid rgba(255,255,255,0.12); + user-select: none; + z-index: 20; +} + +.white-key { + flex: 1; + background: #e8e8e8; + border: 1px solid #888; + border-top: none; + border-radius: 0 0 8px 8px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-end; + padding-bottom: 8px; + position: relative; + z-index: 1; + cursor: pointer; + transition: background 0.06s; + -webkit-tap-highlight-color: transparent; +} +.white-key:active, .white-key.active { + background: #ccc; +} + +/* Per-note active colors for white keys */ +#key-C4.active { background: color-mix(in srgb, var(--color-C4) 40%, #fff); } +#key-D4.active { background: color-mix(in srgb, var(--color-D4) 40%, #fff); } +#key-E4.active { background: color-mix(in srgb, var(--color-E4) 40%, #fff); } +#key-F4.active { background: color-mix(in srgb, var(--color-F4) 40%, #fff); } +#key-G4.active { background: color-mix(in srgb, var(--color-G4) 40%, #fff); } +#key-A4.active { background: color-mix(in srgb, var(--color-A4) 40%, #fff); } +#key-B4.active { background: color-mix(in srgb, var(--color-B4) 40%, #fff); } +#key-C5.active { background: color-mix(in srgb, var(--color-C5) 40%, #fff); } + +.key-note-label { + font-size: 0.7rem; + font-weight: 600; + color: #888; + line-height: 1; +} +.key-kbd-label { + font-size: 0.65rem; + font-family: monospace; + font-weight: 700; + color: #aaa; + background: rgba(0,0,0,0.08); + border-radius: 3px; + padding: 1px 4px; + margin-top: 2px; + line-height: 1; +} + +.black-key .key-kbd-label { + font-size: 0.55rem; + color: #ccc; + background: rgba(255,255,255,0.12); +} + +.black-key { + position: absolute; + top: 0; + width: 7%; + height: 62%; + background: #1a1a1a; + z-index: 2; + border-radius: 0 0 5px 5px; + cursor: pointer; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-end; + padding-bottom: 6px; + box-shadow: 2px 4px 8px rgba(0,0,0,0.5); + transition: background 0.06s; + -webkit-tap-highlight-color: transparent; +} +.black-key:active, .black-key.active { + background: #333; + box-shadow: 1px 2px 4px rgba(0,0,0,0.5); +} + +/* Black key active colors */ +#key-Cs4.active { background: var(--color-Cs4); } +#key-Ds4.active { background: var(--color-Ds4); } +#key-Fs4.active { background: var(--color-Fs4); } +#key-Gs4.active { background: var(--color-Gs4); } +#key-As4.active { background: var(--color-As4); } + +/* Black key positions: centered over the gap between adjacent white keys */ +/* White key width = 100%/8 = 12.5%. Black key centered at (n + 0.625) * 12.5% */ +#key-Cs4 { left: calc(0.625 * 12.5% - 3.5%); } +#key-Ds4 { left: calc(1.625 * 12.5% - 3.5%); } +/* No black key between E and F (index 2 โ 3 gap) */ +#key-Fs4 { left: calc(3.625 * 12.5% - 3.5%); } +#key-Gs4 { left: calc(4.625 * 12.5% - 3.5%); } +#key-As4 { left: calc(5.625 * 12.5% - 3.5%); } +/* No black key between B and C */ + +/* โโโ Pause Overlay โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */ +.overlay-box { + background: var(--bg-card); + border: 1px solid rgba(255,255,255,0.15); + border-radius: 20px; + padding: 40px 56px; + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; +} +.overlay-box h2 { + font-size: 2.5rem; + font-weight: 900; + margin-bottom: 8px; +} +.overlay-box .btn { width: 220px; text-align: center; } + +/* โโโ Results Screen โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */ +.results-content { + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + padding: 32px 16px; + max-width: 480px; + width: 100%; +} +.results-content h2 { + font-size: 2rem; + font-weight: 900; +} +.result-song-name { + font-size: 1.1rem; + color: var(--text-dim); +} +.result-grade { + font-size: 6rem; + font-weight: 900; + line-height: 1; +} +.grade-S { color: #ffd700; text-shadow: 0 0 30px rgba(255,215,0,0.7); } +.grade-A { color: #00ff99; text-shadow: 0 0 30px rgba(0,255,153,0.5); } +.grade-B { color: #0088ff; text-shadow: 0 0 30px rgba(0,136,255,0.5); } +.grade-C { color: #ff8800; } +.grade-D { color: #888; } + +.result-score { + font-size: 2rem; + font-weight: 800; + color: #ffdd00; +} +.result-new-best { + font-size: 1rem; + font-weight: 700; + color: #ffd700; + background: rgba(255,215,0,0.1); + border: 1px solid rgba(255,215,0,0.3); + border-radius: 20px; + padding: 6px 18px; +} +.result-stats { + display: flex; + gap: 24px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 12px; + padding: 16px 24px; + font-size: 0.9rem; +} +.stat-item { display: flex; flex-direction: column; align-items: center; gap: 4px; } +.stat-label { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.1em; color: var(--text-dim); } +.stat-value { font-size: 1.4rem; font-weight: 800; } +.stat-perfect { color: #ffd700; } +.stat-good { color: #00ff99; } +.stat-miss { color: #ff4444; } + +.result-buttons { + display: flex; + gap: 12px; + margin-top: 8px; +} diff --git a/piano.html b/piano.html new file mode 100644 index 0000000..b28e6be --- /dev/null +++ b/piano.html @@ -0,0 +1,194 @@ + + + + + + Piano Hero + + + + + + + + Piano Hero + Press the keys when notes reach the bottom! + + Free Play + Practice Songs + + + Controls + + ASDFGHJK + = White Keys (C D E F G A B C5) + + + WETYU + = Black Keys (C# D# F# G# A#) + + + + + + + + + Choose a Song + + โ Back + + + + + + + + + + Score: 0 + Combo: 0x + + + Free Play + + + โธ Pause + + + + + + + + + + + + A + + + + S + + + + D + + + + F + + + + G + + + + H + + + + J + + + + K + + + + + + + + + + + + + + + + + + + + C + A + + + D + S + + + E + D + + + F + F + + + G + G + + + A + H + + + B + J + + + C + K + + + + W + + + E + + + T + + + Y + + + U + + + + + + + + + Paused + โถ Resume + Quit to Menu + + + + + + + Song Complete! + + + + ๐ New Best! + + + Play Again + Main Menu + + + + + + + diff --git a/piano.js b/piano.js new file mode 100644 index 0000000..15175c6 --- /dev/null +++ b/piano.js @@ -0,0 +1,828 @@ +'use strict'; + +// โโโ Constants โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ +const FALL_SPEED = 280; // px per second +const PERFECT_WIN = 55; // ms either side +const GOOD_WIN = 130; // ms either side +const LOOKAHEAD_MS = 2200; // spawn notes this far ahead + +const NOTE_FREQS = { + 'C4': 261.63, 'C#4': 277.18, 'D4': 293.66, 'D#4': 311.13, + 'E4': 329.63, 'F4': 349.23, 'F#4': 369.99, 'G4': 392.00, + 'G#4': 415.30,'A4': 440.00, 'A#4': 466.16, 'B4': 493.88, + 'C5': 523.25 +}; + +// Keyboard key โ note name +const KEY_TO_NOTE = { + 'a': 'C4', 's': 'D4', 'd': 'E4', 'f': 'F4', + 'g': 'G4', 'h': 'A4', 'j': 'B4', 'k': 'C5', + 'w': 'C#4','e': 'D#4','t': 'F#4','y': 'G#4','u': 'A#4' +}; + +// Note name โ CSS-safe class suffix (no # symbols) +const NOTE_CLASS = { + 'C4':'C4','D4':'D4','E4':'E4','F4':'F4', + 'G4':'G4','A4':'A4','B4':'B4','C5':'C5', + 'C#4':'Cs4','D#4':'Ds4','F#4':'Fs4','G#4':'Gs4','A#4':'As4' +}; + +// Note name โ lane element id +const NOTE_LANE_ID = { + 'C4':'lane-C4','D4':'lane-D4','E4':'lane-E4','F4':'lane-F4', + 'G4':'lane-G4','A4':'lane-A4','B4':'lane-B4','C5':'lane-C5', + 'C#4':'lane-Cs4','D#4':'lane-Ds4','F#4':'lane-Fs4','G#4':'lane-Gs4','A#4':'lane-As4' +}; + +// Note name โ piano key element id +const NOTE_KEY_ID = { + 'C4':'key-C4','D4':'key-D4','E4':'key-E4','F4':'key-F4', + 'G4':'key-G4','A4':'key-A4','B4':'key-B4','C5':'key-C5', + 'C#4':'key-Cs4','D#4':'key-Ds4','F#4':'key-Fs4','G#4':'key-Gs4','A#4':'key-As4' +}; + +// โโโ SongLibrary โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ +class SongLibrary { + static SONGS = { + twinkle: { + id: 'twinkle', + title: 'Twinkle Twinkle Little Star', + bpm: 96, + difficulty: 'easy', + notes: [ + // "Twinkle twinkle" + {beat:0, note:'C4', dur:1}, {beat:1, note:'C4', dur:1}, + {beat:2, note:'G4', dur:1}, {beat:3, note:'G4', dur:1}, + // "little star" + {beat:4, note:'A4', dur:1}, {beat:5, note:'A4', dur:1}, + {beat:6, note:'G4', dur:2}, + // "How I wonder" + {beat:8, note:'F4', dur:1}, {beat:9, note:'F4', dur:1}, + {beat:10, note:'E4', dur:1}, {beat:11, note:'E4', dur:1}, + // "what you are" + {beat:12, note:'D4', dur:1}, {beat:13, note:'D4', dur:1}, + {beat:14, note:'C4', dur:2}, + // "Up above the world so high" + {beat:16, note:'G4', dur:1}, {beat:17, note:'G4', dur:1}, + {beat:18, note:'F4', dur:1}, {beat:19, note:'F4', dur:1}, + {beat:20, note:'E4', dur:1}, {beat:21, note:'E4', dur:1}, + {beat:22, note:'D4', dur:2}, + // "Like a diamond in the sky" + {beat:24, note:'G4', dur:1}, {beat:25, note:'G4', dur:1}, + {beat:26, note:'F4', dur:1}, {beat:27, note:'F4', dur:1}, + {beat:28, note:'E4', dur:1}, {beat:29, note:'E4', dur:1}, + {beat:30, note:'D4', dur:2}, + // "Twinkle twinkle little star" + {beat:32, note:'C4', dur:1}, {beat:33, note:'C4', dur:1}, + {beat:34, note:'G4', dur:1}, {beat:35, note:'G4', dur:1}, + {beat:36, note:'A4', dur:1}, {beat:37, note:'A4', dur:1}, + {beat:38, note:'G4', dur:2}, + // "How I wonder what you are" + {beat:40, note:'F4', dur:1}, {beat:41, note:'F4', dur:1}, + {beat:42, note:'E4', dur:1}, {beat:43, note:'E4', dur:1}, + {beat:44, note:'D4', dur:1}, {beat:45, note:'D4', dur:1}, + {beat:46, note:'C4', dur:2}, + ] + }, + mary: { + id: 'mary', + title: 'Mary Had a Little Lamb', + bpm: 108, + difficulty: 'easy', + notes: [ + {beat:0, note:'E4', dur:1}, {beat:1, note:'D4', dur:1}, + {beat:2, note:'C4', dur:1}, {beat:3, note:'D4', dur:1}, + {beat:4, note:'E4', dur:1}, {beat:5, note:'E4', dur:1}, + {beat:6, note:'E4', dur:2}, + {beat:8, note:'D4', dur:1}, {beat:9, note:'D4', dur:1}, + {beat:10, note:'D4', dur:2}, + {beat:12, note:'E4', dur:1}, {beat:13, note:'G4', dur:1}, + {beat:14, note:'G4', dur:2}, + {beat:16, note:'E4', dur:1}, {beat:17, note:'D4', dur:1}, + {beat:18, note:'C4', dur:1}, {beat:19, note:'D4', dur:1}, + {beat:20, note:'E4', dur:1}, {beat:21, note:'E4', dur:1}, + {beat:22, note:'E4', dur:1}, {beat:23, note:'E4', dur:1}, + {beat:24, note:'D4', dur:1}, {beat:25, note:'D4', dur:1}, + {beat:26, note:'E4', dur:1}, {beat:27, note:'D4', dur:1}, + {beat:28, note:'C4', dur:4}, + ] + }, + hotcrossbuns: { + id: 'hotcrossbuns', + title: 'Hot Cross Buns', + bpm: 112, + difficulty: 'easy', + notes: [ + {beat:0, note:'E4', dur:1}, {beat:1, note:'D4', dur:1}, + {beat:2, note:'C4', dur:2}, + {beat:4, note:'E4', dur:1}, {beat:5, note:'D4', dur:1}, + {beat:6, note:'C4', dur:2}, + {beat:8, note:'C4', dur:0.5}, {beat:8.5, note:'C4', dur:0.5}, + {beat:9, note:'C4', dur:0.5}, {beat:9.5, note:'C4', dur:0.5}, + {beat:10, note:'D4', dur:0.5}, {beat:10.5,note:'D4', dur:0.5}, + {beat:11, note:'D4', dur:0.5}, {beat:11.5,note:'D4', dur:0.5}, + {beat:12, note:'E4', dur:1}, {beat:13, note:'D4', dur:1}, + {beat:14, note:'C4', dur:2}, + ] + }, + birthday: { + id: 'birthday', + title: 'Happy Birthday', + bpm: 90, + difficulty: 'medium', + notes: [ + {beat:0, note:'C4', dur:0.5}, {beat:0.5, note:'C4', dur:0.5}, + {beat:1, note:'D4', dur:1}, {beat:2, note:'C4', dur:1}, + {beat:3, note:'F4', dur:1}, {beat:4, note:'E4', dur:2}, + {beat:6, note:'C4', dur:0.5}, {beat:6.5, note:'C4', dur:0.5}, + {beat:7, note:'D4', dur:1}, {beat:8, note:'C4', dur:1}, + {beat:9, note:'G4', dur:1}, {beat:10, note:'F4', dur:2}, + {beat:12, note:'C4', dur:0.5}, {beat:12.5,note:'C4', dur:0.5}, + {beat:13, note:'C5', dur:1}, {beat:14, note:'A4', dur:1}, + {beat:15, note:'F4', dur:1}, {beat:16, note:'E4', dur:1}, + {beat:17, note:'D4', dur:2}, + {beat:19, note:'B4', dur:0.5}, {beat:19.5,note:'B4', dur:0.5}, + {beat:20, note:'A4', dur:1}, {beat:21, note:'F4', dur:1}, + {beat:22, note:'G4', dur:1}, {beat:23, note:'F4', dur:3}, + ] + }, + odetojoy: { + id: 'odetojoy', + title: 'Ode to Joy', + bpm: 100, + difficulty: 'medium', + notes: [ + {beat:0, note:'E4', dur:1}, {beat:1, note:'E4', dur:1}, + {beat:2, note:'F4', dur:1}, {beat:3, note:'G4', dur:1}, + {beat:4, note:'G4', dur:1}, {beat:5, note:'F4', dur:1}, + {beat:6, note:'E4', dur:1}, {beat:7, note:'D4', dur:1}, + {beat:8, note:'C4', dur:1}, {beat:9, note:'C4', dur:1}, + {beat:10, note:'D4', dur:1}, {beat:11, note:'E4', dur:1}, + {beat:12, note:'E4', dur:1.5},{beat:13.5,note:'D4',dur:0.5}, + {beat:14, note:'D4', dur:2}, + {beat:16, note:'E4', dur:1}, {beat:17, note:'E4', dur:1}, + {beat:18, note:'F4', dur:1}, {beat:19, note:'G4', dur:1}, + {beat:20, note:'G4', dur:1}, {beat:21, note:'F4', dur:1}, + {beat:22, note:'E4', dur:1}, {beat:23, note:'D4', dur:1}, + {beat:24, note:'C4', dur:1}, {beat:25, note:'C4', dur:1}, + {beat:26, note:'D4', dur:1}, {beat:27, note:'E4', dur:1}, + {beat:28, note:'D4', dur:1.5},{beat:29.5,note:'C4',dur:0.5}, + {beat:30, note:'C4', dur:2}, + ] + } + }; + + static getSong(id) { return this.SONGS[id] || null; } + static getAllSongs() { return Object.values(this.SONGS); } +} + +// โโโ AudioEngine โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ +class AudioEngine { + constructor() { + this.ctx = null; + } + + init() { + if (this.ctx) return; + this.ctx = new (window.AudioContext || window.webkitAudioContext)(); + } + + resume() { + if (this.ctx && this.ctx.state === 'suspended') { + this.ctx.resume(); + } + } + + playNote(noteName, duration = 1.4) { + if (!this.ctx) return; + this.resume(); + const freq = NOTE_FREQS[noteName]; + if (!freq) return; + const now = this.ctx.currentTime; + + const osc1 = this.ctx.createOscillator(); + osc1.type = 'triangle'; + osc1.frequency.value = freq; + + const osc2 = this.ctx.createOscillator(); + osc2.type = 'triangle'; + osc2.frequency.value = freq * 2; + + const overtoneGain = this.ctx.createGain(); + overtoneGain.gain.value = 0.12; + + const env = this.ctx.createGain(); + env.gain.setValueAtTime(0, now); + env.gain.linearRampToValueAtTime(0.65, now + 0.006); + env.gain.exponentialRampToValueAtTime(0.2, now + 0.22); + env.gain.setValueAtTime(0.2, now + duration - 0.1); + env.gain.exponentialRampToValueAtTime(0.001, now + duration + 1.0); + + const filter = this.ctx.createBiquadFilter(); + filter.type = 'lowpass'; + filter.frequency.value = 3800; + + osc2.connect(overtoneGain); + overtoneGain.connect(env); + osc1.connect(env); + env.connect(filter); + filter.connect(this.ctx.destination); + + const stop = now + duration + 1.2; + osc1.start(now); osc1.stop(stop); + osc2.start(now); osc2.stop(stop); + } +} + +// โโโ InputManager โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ +class InputManager { + constructor(onNoteOn, onNoteOff) { + this._onNoteOn = onNoteOn; + this._onNoteOff = onNoteOff; + this._held = new Set(); + this._kbDown = this._handleKeyDown.bind(this); + this._kbUp = this._handleKeyUp.bind(this); + this._pointers = new Map(); // touch/mouse tracking per key el + document.addEventListener('keydown', this._kbDown); + document.addEventListener('keyup', this._kbUp); + this._bindPianoKeys(); + } + + _handleKeyDown(e) { + if (e.repeat) return; + const note = KEY_TO_NOTE[e.key.toLowerCase()]; + if (!note) return; + if (this._held.has(note)) return; + this._held.add(note); + this._onNoteOn(note); + } + + _handleKeyUp(e) { + const note = KEY_TO_NOTE[e.key.toLowerCase()]; + if (!note) return; + this._held.delete(note); + this._onNoteOff(note); + } + + _bindPianoKeys() { + document.querySelectorAll('.white-key, .black-key').forEach(el => { + const note = el.dataset.note; + + const onDown = (ev) => { + ev.preventDefault(); + if (!this._pointers.has(el)) { + this._pointers.set(el, true); + this._onNoteOn(note); + } + }; + const onUp = (ev) => { + ev.preventDefault(); + if (this._pointers.has(el)) { + this._pointers.delete(el); + this._onNoteOff(note); + } + }; + + el.addEventListener('mousedown', onDown); + el.addEventListener('mouseup', onUp); + el.addEventListener('mouseleave', onUp); + el.addEventListener('touchstart', onDown, { passive: false }); + el.addEventListener('touchend', onUp, { passive: false }); + el.addEventListener('touchcancel',onUp, { passive: false }); + }); + } + + destroy() { + document.removeEventListener('keydown', this._kbDown); + document.removeEventListener('keyup', this._kbUp); + this._held.clear(); + this._pointers.clear(); + } +} + +// โโโ NoteRenderer โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ +class NoteRenderer { + constructor(highwayEl) { + this.highway = highwayEl; + this._notes = new Map(); // id โ { el, lane, hitTimeMs } + this._nextId = 0; + } + + _hitZoneY() { + return this.highway.clientHeight - parseInt( + getComputedStyle(document.documentElement).getPropertyValue('--hit-zone-h') || '18' + ); + } + + spawnNote(noteName, hitTimeMs) { + const id = this._nextId++; + const laneId = NOTE_LANE_ID[noteName]; + if (!laneId) return null; + const laneEl = document.getElementById(laneId); + if (!laneEl) return null; + + const el = document.createElement('div'); + el.className = `falling-note note-${NOTE_CLASS[noteName]}`; + laneEl.appendChild(el); + + this._notes.set(id, { el, laneEl, noteName, hitTimeMs }); + return id; + } + + update(nowMs) { + const hitZoneY = this._hitZoneY(); + this._notes.forEach((note) => { + const timeUntil = (note.hitTimeMs - nowMs) / 1000; + const yPos = hitZoneY - FALL_SPEED * timeUntil; + note.el.style.transform = `translateY(${yPos}px)`; + }); + } + + hitNote(id) { + const note = this._notes.get(id); + if (!note) return; + note.el.classList.add('hit-pop'); + note.el.addEventListener('animationend', () => { + note.el.remove(); + }, { once: true }); + this._notes.delete(id); + } + + missNote(id) { + const note = this._notes.get(id); + if (!note) return; + note.el.classList.add('miss-flash'); + setTimeout(() => note.el.remove(), 200); + this._notes.delete(id); + } + + clear() { + this._notes.forEach(note => note.el.remove()); + this._notes.clear(); + } +} + +// โโโ ScoreTracker โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ +class ScoreTracker { + constructor() { this.reset(); } + + reset() { + this.score = 0; + this.combo = 0; + this.maxCombo= 0; + this.perfect = 0; + this.good = 0; + this.miss = 0; + } + + _multiplier() { + if (this.combo >= 30) return 4; + if (this.combo >= 20) return 3; + if (this.combo >= 10) return 2; + return 1; + } + + recordHit(deltaMs) { + const isPerfect = deltaMs <= PERFECT_WIN; + const pts = isPerfect ? 300 : 100; + this.combo++; + if (this.combo > this.maxCombo) this.maxCombo = this.combo; + this.score += pts * this._multiplier(); + if (isPerfect) this.perfect++; else this.good++; + return isPerfect ? 'perfect' : 'good'; + } + + recordMiss() { + this.combo = 0; + this.miss++; + } + + getGrade() { + const total = this.perfect + this.good + this.miss; + if (total === 0) return 'D'; + const pct = (this.perfect * 300 + this.good * 100) / (total * 300); + if (pct >= 0.95) return 'S'; + if (pct >= 0.80) return 'A'; + if (pct >= 0.65) return 'B'; + if (pct >= 0.50) return 'C'; + return 'D'; + } +} + +// โโโ GameEngine โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ +class GameEngine { + constructor() { + this.state = 'menu'; + this.audio = new AudioEngine(); + this.score = new ScoreTracker(); + this.renderer = null; // created after DOM ready + this.input = null; + this.song = null; + this.noteQueue = []; // sorted by hitTimeMs, not yet spawned + this.activeNotes = []; // { id, noteName, hitTimeMs } + this.startTime = 0; // performance.now() when song started + this.animFrame = null; + this._songLastBeat = 0; + this._feedbackTimeout = null; + this._lastPlayedSongId = null; + this._heldNotes = new Set(); + } + + init() { + this.renderer = new NoteRenderer(document.getElementById('note-highway')); + this._buildSongCards(); + this._bindUI(); + this.setState('menu'); + } + + // โโ UI wiring โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + _bindUI() { + document.getElementById('btn-freeplay').addEventListener('click', () => { + this.audio.init(); + this.startFreePlay(); + }); + document.getElementById('btn-songs').addEventListener('click', () => { + this.audio.init(); + this.setState('songs'); + }); + document.getElementById('btn-back-menu').addEventListener('click', () => { + this.setState('menu'); + }); + document.getElementById('btn-pause').addEventListener('click', () => { + this.pause(); + }); + document.getElementById('btn-resume').addEventListener('click', () => { + this.resume(); + }); + document.getElementById('btn-quit').addEventListener('click', () => { + this._stopGame(); + this.setState('menu'); + }); + document.getElementById('btn-play-again').addEventListener('click', () => { + if (this._lastPlayedSongId) { + this.startSong(this._lastPlayedSongId); + } else { + this.startFreePlay(); + } + }); + document.getElementById('btn-result-menu').addEventListener('click', () => { + this.setState('menu'); + }); + } + + _buildSongCards() { + const list = document.getElementById('song-list'); + SongLibrary.getAllSongs().forEach(song => { + const card = document.createElement('div'); + card.className = 'song-card'; + const best = this._getBest(song.id); + const diffLabel = song.difficulty.charAt(0).toUpperCase() + song.difficulty.slice(1); + card.innerHTML = ` + + ${song.title} + ${song.bpm} BPM ยท ${song.notes.length} notes + + + ${diffLabel} + Best: ${best > 0 ? best.toLocaleString() : '--'} + + `; + card.addEventListener('click', () => { + this.audio.init(); + this.startSong(song.id); + }); + list.appendChild(card); + }); + } + + _refreshSongCards() { + const list = document.getElementById('song-list'); + list.innerHTML = ''; + this._buildSongCards(); + } + + // โโ State machine โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + setState(name) { + // Hide all screens + document.querySelectorAll('.screen').forEach(s => s.classList.remove('active')); + const el = document.getElementById(`screen-${name}`); + if (el) el.classList.add('active'); + + if (name !== 'playing' && name !== 'paused') { + this._stopLoop(); + } + if (name === 'menu' || name === 'songs' || name === 'results') { + if (this.input) { this.input.destroy(); this.input = null; } + this._heldNotes.clear(); + } + this.state = name; + } + + // โโ Song start โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + startSong(songId) { + const song = SongLibrary.getSong(songId); + if (!song) return; + this._lastPlayedSongId = songId; + this.song = song; + this._prepareSong(song); + this._countdown(() => this._beginGameLoop()); + } + + startFreePlay() { + this._lastPlayedSongId = null; + this.song = null; + this.score.reset(); + this.noteQueue = []; + this.activeNotes = []; + this.renderer.clear(); + this.setState('playing'); + document.getElementById('hud-song-name').textContent = 'Free Play'; + this._updateHUD(); + this.input = new InputManager( + note => this._noteOn(note), + note => this._noteOff(note) + ); + // No game loop needed for free play (no notes to fall) + this._startLoop(); + } + + _prepareSong(song) { + this.score.reset(); + this.renderer.clear(); + this.activeNotes = []; + const secPerBeat = 60 / song.bpm; + this.noteQueue = song.notes.map((n, i) => ({ + id: null, + note: n.note, + hitTimeMs: n.beat * secPerBeat * 1000, + duration: n.dur, + spawned: false, + index: i + })).sort((a, b) => a.hitTimeMs - b.hitTimeMs); + this._songLastBeat = Math.max(...song.notes.map(n => n.beat + n.dur)); + this._songDurationMs = (this._songLastBeat + 2) * (60 / song.bpm) * 1000; + } + + _countdown(cb) { + this.setState('playing'); + document.getElementById('hud-song-name').textContent = this.song.title; + this._updateHUD(); + + const overlay = document.getElementById('countdown-overlay'); + const numEl = document.getElementById('countdown-number'); + overlay.classList.remove('hidden'); + + const steps = ['3','2','1','GO!']; + let i = 0; + const tick = () => { + numEl.textContent = steps[i]; + // Restart animation + numEl.style.animation = 'none'; + void numEl.offsetWidth; + numEl.style.animation = ''; + i++; + if (i < steps.length) { + setTimeout(tick, 900); + } else { + setTimeout(() => { + overlay.classList.add('hidden'); + cb(); + }, 600); + } + }; + tick(); + } + + _beginGameLoop() { + this.startTime = performance.now(); + if (this.input) { this.input.destroy(); this.input = null; } + this.input = new InputManager( + note => this._noteOn(note), + note => this._noteOff(note) + ); + this._startLoop(); + } + + // โโ RAF loop โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + _startLoop() { + if (this.animFrame) return; + const loop = (ts) => { + this.animFrame = requestAnimationFrame(loop); + this._tick(ts); + }; + this.animFrame = requestAnimationFrame(loop); + } + + _stopLoop() { + if (this.animFrame) { cancelAnimationFrame(this.animFrame); this.animFrame = null; } + } + + _stopGame() { + this._stopLoop(); + if (this.input) { this.input.destroy(); this.input = null; } + this.renderer.clear(); + this.activeNotes = []; + this.noteQueue = []; + this._heldNotes.clear(); + this._clearAllKeyHighlights(); + } + + _tick(ts) { + if (this.state === 'paused') return; + + const nowMs = performance.now() - this.startTime; + + // Spawn upcoming notes + if (this.song) { + for (const qn of this.noteQueue) { + if (qn.spawned) continue; + if (qn.hitTimeMs - nowMs <= LOOKAHEAD_MS) { + const rendId = this.renderer.spawnNote(qn.note, qn.hitTimeMs); + qn.spawned = true; + this.activeNotes.push({ rendId, note: qn.note, hitTimeMs: qn.hitTimeMs }); + } + } + } + + // Update note positions + this.renderer.update(nowMs); + + // Detect misses (notes that passed the hit zone) + for (let i = this.activeNotes.length - 1; i >= 0; i--) { + const an = this.activeNotes[i]; + if (nowMs > an.hitTimeMs + GOOD_WIN) { + this.score.recordMiss(); + this.renderer.missNote(an.rendId); + this.activeNotes.splice(i, 1); + this._showFeedback('miss'); + this._updateHUD(); + } + } + + // Check if song is done + if (this.song && nowMs > this._songDurationMs && this.activeNotes.length === 0) { + this._endSong(); + } + } + + // โโ Note input โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + _noteOn(noteName) { + if (this._heldNotes.has(noteName)) return; + this._heldNotes.add(noteName); + + // Highlight key + const keyId = NOTE_KEY_ID[noteName]; + if (keyId) document.getElementById(keyId)?.classList.add('active'); + + // Play sound + this.audio.playNote(noteName); + + if (this.state !== 'playing') return; + + if (this.song) { + // Hit detection + const nowMs = performance.now() - this.startTime; + const candidates = this.activeNotes.filter(n => + n.note === noteName && Math.abs(nowMs - n.hitTimeMs) <= GOOD_WIN + ); + if (candidates.length === 0) return; + + const hit = candidates.reduce((best, n) => + Math.abs(nowMs - n.hitTimeMs) < Math.abs(nowMs - best.hitTimeMs) ? n : best + ); + const delta = Math.abs(nowMs - hit.hitTimeMs); + const result = this.score.recordHit(delta); + this.renderer.hitNote(hit.rendId); + this.activeNotes = this.activeNotes.filter(n => n !== hit); + this._showFeedback(result); + this._updateHUD(); + } + } + + _noteOff(noteName) { + this._heldNotes.delete(noteName); + const keyId = NOTE_KEY_ID[noteName]; + if (keyId) document.getElementById(keyId)?.classList.remove('active'); + } + + // โโ Feedback โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + _showFeedback(type) { + const el = document.getElementById('feedback-popup'); + // Reset + el.className = ''; + el.textContent = ''; + void el.offsetWidth; // force reflow + + const map = { perfect: ['PERFECT', 'show-perfect'], good: ['GOOD', 'show-good'], miss: ['MISS', 'show-miss'] }; + const [text, cls] = map[type] || ['', '']; + el.textContent = text; + el.className = cls; + + if (this._feedbackTimeout) clearTimeout(this._feedbackTimeout); + this._feedbackTimeout = setTimeout(() => { el.className = ''; }, 600); + } + + // โโ HUD โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + _updateHUD() { + document.getElementById('hud-score').textContent = `Score: ${this.score.score.toLocaleString()}`; + const comboEl = document.getElementById('hud-combo'); + const mult = this.score._multiplier ? this.score._multiplier() : 1; + comboEl.textContent = `Combo: ${this.score.combo}x`; + if (mult > 1) comboEl.textContent += ` (ร${mult})`; + + // Pop animation on combo increment + comboEl.classList.remove('pop'); + void comboEl.offsetWidth; + if (this.score.combo > 0) comboEl.classList.add('pop'); + setTimeout(() => comboEl.classList.remove('pop'), 200); + } + + // โโ Pause / Resume โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + pause() { + if (this.state !== 'playing') return; + this.setState('pause'); + this.audio.ctx && this.audio.ctx.suspend && this.audio.ctx.suspend(); + this._pauseTime = performance.now(); + } + + resume() { + if (this.state !== 'pause') return; + this.setState('playing'); + this.audio.resume(); + // Shift startTime forward by the duration of the pause + const pausedFor = performance.now() - this._pauseTime; + this.startTime += pausedFor; + this._startLoop(); + } + + // โโ Song end โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + _endSong() { + this._stopLoop(); + if (this.input) { this.input.destroy(); this.input = null; } + this.renderer.clear(); + + const grade = this.score.getGrade(); + const total = this.score.perfect + this.score.good + this.score.miss; + + // High score + const isNew = this._saveBest(this.song.id, this.score.score); + + // Populate results screen + document.getElementById('result-song-name').textContent = this.song.title; + + const gradeEl = document.getElementById('result-grade'); + gradeEl.textContent = grade; + gradeEl.className = `result-grade grade-${grade}`; + + document.getElementById('result-score').textContent = this.score.score.toLocaleString(); + + const newBestEl = document.getElementById('result-new-best'); + isNew ? newBestEl.classList.remove('hidden') : newBestEl.classList.add('hidden'); + + document.getElementById('result-stats').innerHTML = ` + + ${this.score.perfect} + Perfect + + + ${this.score.good} + Good + + + ${this.score.miss} + Miss + + + ${this.score.maxCombo} + Max Combo + + `; + + this._refreshSongCards(); + this.setState('results'); + } + + // โโ Local storage โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + _getBest(songId) { + return parseInt(localStorage.getItem(`piano-hero-best-${songId}`) || '0'); + } + + _saveBest(songId, newScore) { + const prev = this._getBest(songId); + if (newScore > prev) { + localStorage.setItem(`piano-hero-best-${songId}`, String(newScore)); + return true; + } + return false; + } + + // โโ Helpers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + _clearAllKeyHighlights() { + document.querySelectorAll('.white-key.active, .black-key.active') + .forEach(el => el.classList.remove('active')); + } +} + +// โโโ Bootstrap โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ +const game = new GameEngine(); +document.addEventListener('DOMContentLoaded', () => game.init()); diff --git a/styles.css b/styles.css index 003a98c..0dd2a64 100644 --- a/styles.css +++ b/styles.css @@ -67,6 +67,25 @@ body { font-weight: 300; } +.piano-hero-link { + display: inline-block; + margin-top: 12px; + padding: 8px 20px; + background: linear-gradient(135deg, #1a1a2e, #16213e); + color: #e0d0ff; + border-radius: 25px; + text-decoration: none; + font-size: 0.95rem; + font-weight: 500; + letter-spacing: 0.5px; + transition: transform 0.2s, box-shadow 0.2s; + box-shadow: 0 2px 12px rgba(100, 60, 200, 0.35); +} +.piano-hero-link:hover { + transform: translateY(-2px); + box-shadow: 0 4px 20px rgba(100, 60, 200, 0.55); +} + .calendar-navigation { grid-column: 1; display: flex;
Learn English & Chinese with Portuguese Flowers
Press the keys when notes reach the bottom!