Bersihkan sisa AETHER CLASH, kutipan layar loading, dan daftar Yang baru (RENCANA 2.5, 2.7, 2.8)

- Latar menu utama dan About: kolase tujuh potret petarung dengan CSS (lapisan miring, cahaya, gerak lambat yang menghormati prefers-reduced-motion); video dan banner game asal tidak dipakai lagi (berkas aset tetap ada)
- Teks sisa AETHER/ARCO/FENR/Mecha/Demi-Human dibersihkan dari index.html, tag arena, pengaturan, label aksesibilitas, dan dokumen; kredit Bang Tutorial tetap di About dan README
- brand.js, content/loading.js: kutipan acak berbahasa Indonesia di layar boot dan loading, berganti tiap dimuat
- brand.js, content/changelog.js: panel Yang baru di About dan toast Ada yang baru satu kali saat versi berubah
- README ditulis ulang untuk Konoha Fighting, DEPLOY.md dan catatan mesin diperbarui
- tests/t2-brand.mjs: 51 pemeriksaan
This commit is contained in:
rizkyaz0 2026-10-03 07:53:09 +07:00
parent b9a0a1b06c
commit cc923f35ad
17 changed files with 685 additions and 310 deletions

View File

@ -1,4 +1,4 @@
# AETHER CLASH (Apache / LiteSpeed). nginx ignores this file; see install.md. # Konoha Fighting, based on AETHER CLASH by Bang Tutorial (Apache / LiteSpeed). nginx ignores this file; see install.md.
# Game files are revalidated on every load (ETag/Last-Modified); the game keeps its own hashed copies in Cache Storage. # Game files are revalidated on every load (ETag/Last-Modified); the game keeps its own hashed copies in Cache Storage.
<IfModule mod_mime.c> <IfModule mod_mime.c>
AddType text/javascript .js AddType text/javascript .js

View File

@ -1,43 +1,45 @@
# Deploying AETHER CLASH # Deploying Konoha Fighting
AETHER CLASH is a static website: HTML, CSS, JavaScript, WebP images, MP3 audio, one MP4 loop and TTF fonts (about 34 MB, listed in `files.json`). It needs **no** Node.js, PHP, database or build step. Any web server that serves static files can host it. > Konoha Fighting is derived from AETHER CLASH by Bang Tutorial (code MIT, assets CC BY-NC 4.0, see [LICENSE-ASSETS.md](LICENSE-ASSETS.md)). The hosting steps below are unchanged from the original project; only the folder name in the examples differs.
The examples below install it in a subfolder, `https://example.com/aether-clash/`. For a domain root, drop the `/aether-clash` prefix. Konoha Fighting is a static website: HTML, CSS, JavaScript, WebP images, MP3 audio, one MP4 loop and TTF fonts (about 34 MB, listed in `files.json`). It needs **no** Node.js, PHP, database or build step. Any web server that serves static files can host it.
The examples below install it in a subfolder, `https://example.com/konoha-fighting/`. For a domain root, drop the `/konoha-fighting` prefix.
## 1. Requirements ## 1. Requirements
- **HTTPS.** The loading screen caches files through a service worker, and browsers only enable service workers on HTTPS (or `localhost`). Without HTTPS the game still runs, just without the cache. - **HTTPS.** The loading screen caches files through a service worker, and browsers only enable service workers on HTTPS (or `localhost`). Without HTTPS the game still runs, just without the cache.
- **Trailing slash.** Every path in the game is relative, so the page must be opened as `/aether-clash/`. Without the slash the browser looks for assets at the domain root and gets 404s. Redirect `/aether-clash` to `/aether-clash/` with a 301. nginx and Apache do this on their own for real folders; for an nginx `alias` add the redirect yourself (below). - **Trailing slash.** Every path in the game is relative, so the page must be opened as `/konoha-fighting/`. Without the slash the browser looks for assets at the domain root and gets 404s. Redirect `/konoha-fighting` to `/konoha-fighting/` with a 301. nginx and Apache do this on their own for real folders; for an nginx `alias` add the redirect yourself (below).
- The service worker's scope is limited to its own folder (`/aether-clash/`), so it never touches the rest of the site. - The service worker's scope is limited to its own folder (`/konoha-fighting/`), so it never touches the rest of the site.
## 2. Copy the files ## 2. Copy the files
Upload the repository contents (everything except `.git`, `README.md`, `DEPLOY.md`, the license files, `docs/` and `guide/`) into the site's document root, in a folder called `aether-clash`: Upload the repository contents (everything except `.git`, `README.md`, `DEPLOY.md`, the license files, `docs/` and `guide/`) into the site's document root, in a folder called `konoha-fighting`:
```bash ```bash
DOCROOT=/var/www/example.com # your site's document root DOCROOT=/var/www/example.com # your site's document root
sudo mkdir -p "$DOCROOT/aether-clash" sudo mkdir -p "$DOCROOT/konoha-fighting"
sudo rsync -a --delete \ sudo rsync -a --delete \
--exclude .git --exclude README.md --exclude DEPLOY.md --exclude 'LICENSE*' --exclude docs --exclude guide \ --exclude .git --exclude README.md --exclude DEPLOY.md --exclude 'LICENSE*' --exclude docs --exclude guide \
./ "$DOCROOT/aether-clash/" ./ "$DOCROOT/konoha-fighting/"
``` ```
Give the files to the web server's user (`www-data` on Debian/Ubuntu, `nginx` or `apache` on RHEL, or your panel's site user): Give the files to the web server's user (`www-data` on Debian/Ubuntu, `nginx` or `apache` on RHEL, or your panel's site user):
```bash ```bash
OWNER=$(stat -c '%U:%G' "$DOCROOT") OWNER=$(stat -c '%U:%G' "$DOCROOT")
sudo chown -R "$OWNER" "$DOCROOT/aether-clash" sudo chown -R "$OWNER" "$DOCROOT/konoha-fighting"
sudo find "$DOCROOT/aether-clash" -type d -exec chmod 755 {} \; sudo find "$DOCROOT/konoha-fighting" -type d -exec chmod 755 {} \;
sudo find "$DOCROOT/aether-clash" -type f -exec chmod 644 {} \; sudo find "$DOCROOT/konoha-fighting" -type f -exec chmod 644 {} \;
``` ```
If the main site runs WordPress or another CMS, that is fine: a real `aether-clash/` folder is served directly as static files. If the main site runs WordPress or another CMS, that is fine: a real `konoha-fighting/` folder is served directly as static files.
## 3. Configure the web server ## 3. Configure the web server
Aim for: Aim for:
1. `/aether-clash` → 301 → `/aether-clash/` 1. `/konoha-fighting` → 301 → `/konoha-fighting/`
2. Correct MIME types: `.js` JavaScript, `.webp` `image/webp`, `.mp3` `audio/mpeg`, `.mp4` `video/mp4`, `.ttf` `font/ttf` 2. Correct MIME types: `.js` JavaScript, `.webp` `image/webp`, `.mp3` `audio/mpeg`, `.mp4` `video/mp4`, `.ttf` `font/ttf`
3. `Cache-Control: no-cache` for the game files. Browsers still reuse their copy after an ETag/Last-Modified check, and the game keeps hashed copies in Cache Storage, so updates show up at once. Avoid long `expires`/`max-age` rules for this path. 3. `Cache-Control: no-cache` for the game files. Browsers still reuse their copy after an ETag/Last-Modified check, and the game keeps hashed copies in Cache Storage, so updates show up at once. Avoid long `expires`/`max-age` rules for this path.
4. MP3/MP4 served with range requests (206), which is the default for static files, and not gzipped. 4. MP3/MP4 served with range requests (206), which is the default for static files, and not gzipped.
@ -47,10 +49,10 @@ Aim for:
Inside the site's HTTPS `server { … }` block: Inside the site's HTTPS `server { … }` block:
```nginx ```nginx
location = /aether-clash { return 301 /aether-clash/; } location = /konoha-fighting { return 301 /konoha-fighting/; }
location ^~ /aether-clash/ { location ^~ /konoha-fighting/ {
# If the files are NOT inside the document root, point to them (keep the trailing slash): # If the files are NOT inside the document root, point to them (keep the trailing slash):
# alias /var/www/aether-clash/; # alias /var/www/konoha-fighting/;
try_files $uri $uri/ =404; try_files $uri $uri/ =404;
add_header Cache-Control "no-cache" always; add_header Cache-Control "no-cache" always;
gzip on; gzip on;
@ -78,8 +80,8 @@ sudo systemctl reload apache2
### Caddy ### Caddy
```caddy ```caddy
redir /aether-clash /aether-clash/ 301 redir /konoha-fighting /konoha-fighting/ 301
header /aether-clash/* Cache-Control "no-cache" header /konoha-fighting/* Cache-Control "no-cache"
``` ```
### GitHub Pages, Netlify and similar hosts ### GitHub Pages, Netlify and similar hosts
@ -89,8 +91,8 @@ Publish the repository as it is. These hosts already send correct MIME types and
## 4. Check the deployment ## 4. Check the deployment
```bash ```bash
BASE=https://example.com/aether-clash BASE=https://example.com/konoha-fighting
curl -sI "$BASE" | grep -iE "^HTTP|^location" # 301 -> /aether-clash/ curl -sI "$BASE" | grep -iE "^HTTP|^location" # 301 -> /konoha-fighting/
curl -sI "$BASE/" | grep -iE "^HTTP|content-type" # 200 text/html curl -sI "$BASE/" | grep -iE "^HTTP|content-type" # 200 text/html
curl -sI "$BASE/sw.js" | grep -iE "^HTTP|content-type|cache-control" # 200, javascript, no-cache curl -sI "$BASE/sw.js" | grep -iE "^HTTP|content-type|cache-control" # 200, javascript, no-cache
curl -sI "$BASE/assets/stage.webp" | grep -iE "^HTTP|content-type" # 200 image/webp curl -sI "$BASE/assets/stage.webp" | grep -iE "^HTTP|content-type" # 200 image/webp
@ -103,7 +105,7 @@ Check that every file in `files.json` is reachable:
```bash ```bash
python3 - <<'PY' python3 - <<'PY'
import json, urllib.request import json, urllib.request
base = "https://example.com/aether-clash/" base = "https://example.com/konoha-fighting/"
files = json.load(urllib.request.urlopen(base + "files.json"))["list"] files = json.load(urllib.request.urlopen(base + "files.json"))["list"]
bad = [] bad = []
for f in files: for f in files:
@ -120,7 +122,7 @@ PY
Then open the game in a browser: Then open the game in a browser:
1. The first visit shows the loading screen ("Mengunduh aset game · X%") until all assets are downloaded, then the main menu. 1. The first visit shows the loading screen ("Mengunduh aset game · X%") until all assets are downloaded, then the main menu.
2. DevTools → Console shows no 404 or MIME errors, and Application → Service Workers lists `sw.js` with the scope `…/aether-clash/`. 2. DevTools → Console shows no 404 or MIME errors, and Application → Service Workers lists `sw.js` with the scope `…/konoha-fighting/`.
3. A reload is much faster (served from the cache). 3. A reload is much faster (served from the cache).
4. On a phone, the page switches to `mobile.html` (landscape with touch controls). 4. On a phone, the page switches to `mobile.html` (landscape with touch controls).
@ -135,6 +137,6 @@ Upload the new files over the old ones (the `rsync --delete` above) and fix the
| Blank page or every asset 404 | Opened without the trailing slash, or the files are in the wrong folder. Check the redirect and where `index.html` is | | Blank page or every asset 404 | Opened without the trailing slash, or the files are in the wrong folder. Check the redirect and where `index.html` is |
| Loading stalls or the service worker fails | `sw.js` is not served as JavaScript, or is cached for too long. Check `curl -sI …/sw.js` | | Loading stalls or the service worker fails | `sw.js` is not served as JavaScript, or is cached for too long. Check `curl -sI …/sw.js` |
| No audio/video on Safari or iPhone | Range requests are not supported. Serve MP3/MP4 directly as static files, without gzip or a proxy that drops the `Range` header | | No audio/video on Safari or iPhone | Range requests are not supported. Serve MP3/MP4 directly as static files, without gzip or a proxy that drops the `Range` header |
| Changes don't appear | A long cache rule (a global `expires`, a cache plugin or a CDN). Exclude `/aether-clash/` from it | | Changes don't appear | A long cache rule (a global `expires`, a cache plugin or a CDN). Exclude `/konoha-fighting/` from it |
| HTTP 500 after upload (Apache/LiteSpeed) | `AllowOverride` does not allow the `.htaccess` directives. Allow `FileInfo`, or delete `.htaccess` and set MIME types and headers in the server config | | HTTP 500 after upload (Apache/LiteSpeed) | `AllowOverride` does not allow the `.htaccess` directives. Allow `FileInfo`, or delete `.htaccess` and set MIME types and headers in the server config |
| Behind Cloudflare | Bypass the cache for `/aether-clash/*` (or purge after each deploy) and turn Rocket Loader off for this path | | Behind Cloudflare | Bypass the cache for `/konoha-fighting/*` (or purge after each deploy) and turn Rocket Loader off for this path |

290
README.md
View File

@ -1,5 +1,5 @@
> [!NOTE] > [!NOTE]
> **Konoha Fighting** adalah proyek turunan dari **AETHER CLASH** karya **Bang Tutorial** ([repo asli](https://github.com/bangtutorial/aether-clash), [tutorial YouTube](https://www.youtube.com/watch?v=UN_0bNC2wTU)). Kode asli berlisensi [MIT](LICENSE); aset bawaan (karakter, seni, audio, panduan) berlisensi [CC BY-NC 4.0](LICENSE-ASSETS.md), jadi **tidak untuk penggunaan komersial**. Perubahan di sini: roster karakter diganti dengan karakter parodi buatan sendiri. Bagian di bawah ini masih dokumentasi asli dan akan diperbarui bertahap. > **Konoha Fighting** adalah proyek turunan dari **AETHER CLASH** karya **Bang Tutorial** ([repo asli](https://github.com/bangtutorial/aether-clash), [tutorial YouTube](https://www.youtube.com/watch?v=UN_0bNC2wTU)). Kode asli berlisensi [MIT](LICENSE); aset bawaan (karakter, seni, audio, panduan) berlisensi [CC BY-NC 4.0](LICENSE-ASSETS.md), jadi **tidak untuk penggunaan komersial**. Perubahan di sini: roster karakter diganti dengan karakter parodi buatan sendiri, ditambah mode, toko, efek jurus, dan menu baru. Rincian kredit ada di bagian [Kredit dan lisensi](#kredit-dan-lisensi).
# Konoha Fighting # Konoha Fighting
@ -13,6 +13,8 @@ python3 serve.py 8141 # server lokal tanpa cache
Buka `http://localhost:8141/?touch=0`. (Mode sentuh otomatis di HP.) Buka `http://localhost:8141/?touch=0`. (Mode sentuh otomatis di HP.)
Cukup server file statis apa pun; tidak ada langkah build. Sajikan lewat `http://localhost` atau HTTPS agar layar loading dan cache service worker aktif. Membuka `index.html` langsung dari disk (`file://`) tetap bisa dimainkan, hanya tanpa cache. Browser yang didukung: Chrome, Edge, Firefox, dan Safari terbaru (desktop dan HP). Audio mulai setelah klik atau ketukan pertama.
## Kontrol ## Kontrol
| Tombol | Fungsi | | Tombol | Fungsi |
@ -25,249 +27,79 @@ Buka `http://localhost:8141/?touch=0`. (Mode sentuh otomatis di HP.)
| K | jurus toko | | K | jurus toko |
| L (tahan) / L (ketuk) | blok / parry | | L (tahan) / L (ketuk) | blok / parry |
| J | lempar (menembus blok) | | J | lempar (menembus blok) |
| Esc | jeda |
| R | ulang (ronde di Latihan, pertandingan di VS Komputer) |
| P2: ← → ↑ N M , . | jalan, lompat, serang, skill 1, skill 2, ultimate | | P2: ← → ↑ N M , . | jalan, lompat, serang, skill 1, skill 2, ultimate |
Di menu: W A S D atau tombol panah untuk memilih, Enter untuk konfirmasi, Esc untuk kembali.
**Sentuh (HP dan tablet):** pad arah di kiri, tombol serang besar dengan skill dan ultimate di kanan lengkap dengan hitungan cooldown, ditambah tombol jurus perintah, blok, dan lempar. HP otomatis membuka `mobile.html` dalam tampilan landscape; `?touch=1` memaksa tampilan sentuh di desktop dan `?touch=0` mematikannya.
## Fitur ## Fitur
- Mode: VS Komputer (4 tingkat), Arcade, Survival, Dua Pemain, Latihan. - Mode: VS Komputer (4 tingkat, best of 3 ronde), Arcade, Survival, Dua Pemain, dan Latihan.
- Ultimate sinematik dan mode pasca-ultimate untuk setiap karakter (A Nice berubah jadi Senator). - Ultimate sinematik dan mode pasca-ultimate untuk setiap karakter (A Nice berubah jadi Senator).
- Koin, toko (jurus toko, varian skill per karakter, skin warna), menu Karakter, 20 pencapaian, easter egg. - Efek jurus yang bergerak sendiri (gelombang, jurus area, lari dengan bayangan, genangan) dan rasa kena (hit-stop, getaran layar, K.O. gerak lambat, angka damage, peringkat kombo).
- Rencana pengembangan: [ROADMAP.md](ROADMAP.md). Alur membuat karakter baru: `work/WORKFLOW.md` di repo induk. - Blok, parry, dan lempar; koin, toko (jurus toko, varian skill, skin warna), menu Karakter, 20 pencapaian, dan easter egg.
- Lima arena, pengumuman ronde, dan kutipan acak di layar loading.
- Menu utama berlatar kolase tujuh petarung, tanpa gambar tambahan: seluruhnya disusun dengan CSS dari potret yang sudah ada, dan geraknya menghormati pengaturan "kurangi gerak" di perangkat.
- Daftar "Yang baru" di layar About, dengan pemberitahuan singkat satu kali saat ada pembaruan.
- Rencana pengembangan: [RENCANA.md](RENCANA.md) (urutan kerja) dan [ROADMAP.md](ROADMAP.md) (katalog ide).
## Kredit dan lisensi ## Struktur proyek
Berbasis **AETHER CLASH** oleh **Bang Tutorial**. Kode berlisensi MIT, aset bawaan CC BY-NC 4.0 (tidak untuk penggunaan komersial). Dokumentasi asli proyek induk tetap di bawah ini.
---
<div align="center">
# ⚔️ AETHER CLASH
**A 2D browser fighting game — Mecha vs Demi-Human.**
Twelve chibi fighters, cinematic ultimates and a CPU that reads your moves. Runs in any modern browser, on desktop and mobile, with no install.
[![Play Online](https://img.shields.io/badge/▶_Play-Online-f0b15e?style=for-the-badge)](https://bangtutorial.id/aether-clash/)
[![YouTube Tutorial](https://img.shields.io/badge/YouTube-Tutorial-FF0000?style=for-the-badge&logo=youtube&logoColor=white)](https://www.youtube.com/watch?v=UN_0bNC2wTU)
![HTML5](https://img.shields.io/badge/HTML5-E34F26?style=flat-square&logo=html5&logoColor=white)
![CSS3](https://img.shields.io/badge/CSS3-1572B6?style=flat-square&logo=css3&logoColor=white)
![JavaScript](https://img.shields.io/badge/JavaScript-ES2020-F7DF1E?style=flat-square&logo=javascript&logoColor=black)
![Canvas 2D](https://img.shields.io/badge/Canvas-2D-4a90d9?style=flat-square)
![Dependencies](https://img.shields.io/badge/dependencies-0-brightgreen?style=flat-square)
![Build](https://img.shields.io/badge/build_step-none-brightgreen?style=flat-square)
![Platforms](https://img.shields.io/badge/platform-desktop_%7C_mobile-8a5cf6?style=flat-square)
![Offline cache](https://img.shields.io/badge/service_worker-offline_cache-0f9d58?style=flat-square)
[![Code license: MIT](https://img.shields.io/badge/code-MIT-blue?style=flat-square)](LICENSE)
[![Assets license: CC BY-NC 4.0](https://img.shields.io/badge/assets-CC_BY--NC_4.0-lightgrey?style=flat-square)](LICENSE-ASSETS.md)
<img src="docs/screenshots/menu.jpg" alt="AETHER CLASH main menu" width="860">
</div>
---
## 📺 Tutorial
The whole game — fighters, animations, voices and code — was made step by step on YouTube (in Bahasa Indonesia):
<a href="https://www.youtube.com/watch?v=UN_0bNC2wTU">
<img src="docs/youtube-tutorial.jpg" alt="Watch the tutorial on YouTube: Bikin Game Fighting Pakai ChatGPT (GPT-6 Astra + Higgsfield MCP)" width="640">
</a>
**[Bikin Game Fighting Pakai ChatGPT (GPT-6 Astra + Higgsfield MCP)](https://www.youtube.com/watch?v=UN_0bNC2wTU)** — by **Bang Tutorial**
## 📑 Contents
- [Features](#-features)
- [Screenshots](#-screenshots)
- [Roster](#-roster)
- [Controls](#-controls)
- [Getting started](#-getting-started)
- [Deployment](#-deployment)
- [Project structure](#-project-structure)
- [Guides](#-guides)
- [How it works](#-how-it-works)
- [Credits](#-credits)
- [License](#-license)
## ✨ Features
- **12 playable fighters** in two factions (6 Mecha, 6 Demi-Human). Each has a 3-hit basic chain, two skills and an ultimate with a full-screen cut-in and a voice line.
- **VS Computer**: best of 3 rounds, 90 seconds each, with **4 CPU levels** (Easy, Medium, Hard, Excellent). The CPU dodges projectiles, punishes whiffs, anti-airs jumps and only continues combos that actually landed.
- **Training mode** with unlimited time, a passive dummy, a combo tracker and a hitbox view.
- **5 arenas**: Bellora Courtyard, Sunspire Terrace, Azure Harbor, Elderwood Glade and Moonrise Bastion.
- **Announcer** calls for character select, rounds, K.O. and the winner.
- **Mobile ready**: phones open a landscape shell with touch controls (d-pad plus MOBA-style skill buttons with cooldown timers) and a fullscreen button.
- **Fast loading**: the first visit downloads every asset behind a progress screen (about 34 MB); after that a service worker serves images from the cache, so screens change instantly and reloads are near-instant.
- **Settings**: master volume, background music, sound test, hitboxes and fullscreen.
- **No framework, no build step**: plain HTML, CSS and JavaScript with Canvas 2D. Deploy it as static files anywhere.
## 🖼️ Screenshots
<table>
<tr>
<td width="50%"><img src="docs/screenshots/roster.jpg" alt="Character select"><br><sub><b>Character select</b> — 12 fighters, player then rival then arena</sub></td>
<td width="50%"><img src="docs/screenshots/fight.jpg" alt="SOLAN vs NIB at Moonrise Bastion"><br><sub><b>VS Computer</b> — SOLAN's Solar Crescent at Moonrise Bastion</sub></td>
</tr>
<tr>
<td width="50%"><img src="docs/screenshots/ultimate-cutin.jpg" alt="Elder Tortoise cut-in"><br><sub><b>Ultimate cut-in</b> — EDDA calls the Elder Tortoise</sub></td>
<td width="50%"><img src="docs/screenshots/ultimate.jpg" alt="Elder Tortoise stomp"><br><sub><b>Ultimate</b> — the spirit stomps three times</sub></td>
</tr>
<tr>
<td colspan="2" align="center"><img src="docs/screenshots/mobile.jpg" alt="Mobile layout" width="640"><br><sub><b>Mobile</b> — landscape layout with touch controls</sub></td>
</tr>
</table>
## 🥊 Roster
| | Fighter | Faction | Style | Basic | Ultimate |
| :-: | --- | --- | --- | --- | --- |
| <img src="assets/ui/arco-avatar.webp" width="48"> | **ARCO** — The Aether Arm | Mecha | Brawler / drone summon | Iron Chain | Helios Squadron |
| <img src="assets/fenr/ui/portrait-human.webp" width="48"> | **FENR** — The Wolf Ranger | Demi-Human | Agile / werewolf transformation | Ranger Chain | Feral Awakening |
| <img src="assets/mira/ui/portrait.webp" width="48"> | **MIRA** — The Candy Pilot | Mecha | Heavy mech / rocket barrage | Mitten Chain | Rocket Parade |
| <img src="assets/cora/ui/portrait.webp" width="48"> | **CORA** — The Raven Dancer | Demi-Human | Agile / raven swarm | Feather Waltz | Night Murmuration |
| <img src="assets/naja/ui/portrait.webp" width="48"> | **NAJA** — The Dune Cobra | Demi-Human | Mid-range / urumi whip and sand serpent | Viper Lash | Dune Serpent |
| <img src="assets/haldor/ui/portrait.webp" width="48"> | **HALDOR** — The Walking Forge | Mecha | Heavy tank / steam forge hammer | Forge Chain | Forge Quake |
| <img src="assets/zanni/ui/portrait.webp" width="48"> | **ZANNI** — The Clockwork Jester | Mecha | Trickster / scissor arms and bladed rings | Scissor Jab | Grand Finale |
| <img src="assets/isolde/ui/portrait.webp" width="48"> | **ISOLDE** — The White Strider | Mecha | Long-range lancer / stilt-leg charges | Lance Line | Skyfall Lances |
| <img src="assets/rhea/ui/portrait.webp" width="48"> | **RHEA** — The Little Orrery | Mecha | Zoner / orbiting orrery spheres | Orbit Strike | Grand Orrery |
| <img src="assets/solan/ui/portrait.webp" width="48"> | **SOLAN** — The Sunmane | Demi-Human | Powerhouse / greatsword and roar | Sunblade | Sunmane Roar |
| <img src="assets/nib/ui/portrait.webp" width="48"> | **NIB** — The Rooftop Post | Demi-Human | Rushdown / twin courier batons | Baton Flurry | Special Delivery |
| <img src="assets/edda/ui/portrait.webp" width="48"> | **EDDA** — The Shell Sage | Demi-Human | Counter defender / staff and shell guard | Staff Forms | Elder Tortoise |
## 🎮 Controls
### Keyboard
| Key | Action |
| --- | --- |
| `A` / `D` | Move left / right |
| Double-tap `A` / `D` and hold | Run |
| `W` | Jump (press again in the air to double jump) |
| `S` | Crouch on the ground, fast fall in the air; double-tap for a somersault double jump |
| `Space` | Basic attack (tap up to 3 times for the full chain) |
| `I` | Skill 1 |
| `O` | Skill 2 |
| `P` | Ultimate |
| `Esc` | Pause / resume |
| `R` | Restart (the round in Training, the match in VS Computer) |
Skills cancel a basic attack at any moment. The ultimate pose locks you for only 0.5 s; the summon then finishes on its own while you keep fighting.
In the menus: `W A S D` / arrow keys to select, `Enter` to confirm, `Esc` to go back.
### Touch (phones and tablets)
- **Left**: ◀ ▼ ▶ with ▲ above ▼. Slide your finger between buttons; double-tap ◀/▶ to run, tap ▲ again in the air to double jump.
- **Right**: a big **Basic Attack** button with **Skill 1 / Skill 2 / Ultimate** around it, showing each fighter's icons and cooldowns.
- Phones open `mobile.html` automatically in landscape. Add `?touch=1` to force the mobile layout on desktop, or `?touch=0` to turn it off.
## 🚀 Getting started
The game is a set of static files, so there is nothing to install or build.
```bash
git clone https://github.com/<your-username>/aether-clash.git
cd aether-clash
# any static file server works, for example:
python -m http.server 8000
# or
npx serve .
```
Then open <http://localhost:8000/>.
> [!TIP]
> Serve the folder over `http://localhost` or HTTPS to get the loading screen and the service-worker cache. Opening `index.html` straight from disk (`file://`) still plays, just without caching.
**Browser support:** current Chrome, Edge, Firefox and Safari (desktop and mobile). Audio starts after the first click or tap, because browsers require a user gesture.
## 🌐 Deployment
Upload the folder to any static host (nginx, Apache, LiteSpeed, Caddy, GitHub Pages, Netlify and so on). All paths are relative, so it works at a domain root or in a subfolder such as `https://example.com/aether-clash/`.
Server checklist:
- Open the game **with the trailing slash** (`/aether-clash/`) and redirect `/aether-clash` to it.
- Serve `.js` as JavaScript, `.webp` as `image/webp`, `.mp3` as `audio/mpeg`, `.mp4` as `video/mp4` and `.ttf` as `font/ttf`.
- Send `Cache-Control: no-cache` for the game files. The game keeps its own hashed copies in Cache Storage, so updates show up immediately.
- Serve MP3/MP4 files with range requests (the default for static files) and without gzip.
Step-by-step instructions for nginx, Apache/LiteSpeed and Caddy, plus a script that checks every file after upload, are in **[DEPLOY.md](DEPLOY.md)**. An `.htaccess` for Apache/LiteSpeed is included.
## 🗂️ Project structure
```text ```text
. .
├── index.html # desktop entry: menu, HUD, dialogs, script order ├── index.html # pintu masuk desktop: menu, HUD, dialog, urutan skrip
├── mobile.html # landscape shell for phones (rotation, fullscreen) ├── mobile.html # cangkang landscape untuk HP (rotasi, layar penuh)
├── style.css # arena HUD, cut-ins, touch controls ├── style.css # HUD arena, cut-in, kontrol sentuh
├── menu.css # main menu, character and arena select, results ├── menu.css # menu utama, pilih karakter dan arena, hasil, latar kolase, About
├── game.js # engine: loop, physics, combat, CPU brain, summons, rendering, HUD ├── feel.css # peringkat kombo
├── menu.js # front end: menu flow, roster, arena and difficulty select ├── meta.css # toko, karakter, koleksi, toast
├── match.js # match rules: rounds, timer, stages, difficulty tuning ├── game.js # mesin: loop, fisika, pertarungan, otak CPU, rendering, HUD
├── announcer.js # announcer voice queue ├── menu.js # menu: alur layar, roster, arena, tingkat kesulitan, kolase latar
├── touch.js # on-screen touch controls ├── match.js # aturan ronde, timer, arena, penyetelan tingkat CPU
├── fenr.js … edda.js # one kit file per fighter (moves, balance, timings) ├── feel.js # rasa kena: hit-stop, getaran, K.O. gerak lambat
├── preload.js # first-visit loading screen, fills the cache ├── meta.js # koin, toko, skin, pencapaian, Arcade/Survival
├── precache.js # generated list of runtime files with sizes and hashes ├── brand.js # kutipan layar loading dan daftar "Yang baru"
├── sw.js # service worker: cache-first images, network-first code ├── content/ # data teks: loading.js (kutipan), changelog.js (Yang baru)
├── files.json # file list with sizes (used by the deploy check) ├── announcer.js # antrean suara pengumuman
├── LICENSE # MIT (source code) ├── touch.js # kontrol sentuh di layar
├── LICENSE-ASSETS.md # CC BY-NC 4.0 (art, audio, guides) and exceptions ├── browo.js, fenr.js # kit tulisan tangan (karakter lain berbasis data di assets/<id>/manifest.js)
├── guide/ # design and production guides (Bahasa Indonesia) ├── preload.js # layar boot kunjungan pertama, mengisi cache
├── docs/screenshots/ # images for this README ├── precache.js # daftar berkas runtime (dibuat oleh tools/build_precache.py)
├── sw.js # service worker: gambar dari cache, kode dari jaringan dulu
├── tests/ # uji headless (Playwright): node tests/harness.mjs smoke
├── docs/ # catatan mesin untuk pengembang (ENGINE_NOTES.md)
├── guide/ # panduan produksi warisan AETHER CLASH
├── LICENSE # MIT (kode)
├── LICENSE-ASSETS.md # CC BY-NC 4.0 (seni, audio, panduan) dan pengecualian
└── assets/ └── assets/
├── <fighter>/ # sprite atlas (WebP) + manifest.js, portrait, icons, cut-in, FX, voice ├── <petarung>/ # atlas sprite (WebP) + manifest.js, potret, ikon, FX
├── audio/ # announcer clips and background music ├── audio/ # klip pengumuman dan musik latar
├── menu/ # menu art, arena backgrounds, character select art ├── menu/ # seni pilih karakter dan latar arena
├── ui/ # shared HUD art (ARCO kit, drone, cut-in) ├── ui/ # seni HUD bersama
└── fonts/ # Rajdhani (SIL Open Font License) └── fonts/ # Rajdhani (SIL Open Font License)
``` ```
## 📚 Guides ## Cara kerja
The [`guide/`](guide/) folder holds the design and production notes behind the game (in Bahasa Indonesia). Start from [guide/README.md](guide/README.md). - **Simulasi langkah tetap.** Game maju dalam langkah 1/120 detik dengan pengacak berbenih, jadi pertarungan berjalan sama di setiap perangkat.
- **Sprite.** Tiap petarung berupa atlas sprite WebP (diam, jalan, lari, lompat, jongkok, tiga serangan, dua skill, ultimate, terkena, jatuh, bangun) dengan manifest berisi bingkai, titik pijak, dan titik pukul terukur.
- **Kit.** Aturan tiap petarung (damage, jangkauan, cooldown, perilaku proyektil) ada di data manifest; pemain dan CPU memakai data yang persis sama.
- **Otak CPU.** CPU bereaksi dengan jeda mirip manusia: melompati proyektil, mundur dari serangan, menghukum saat lawan lengah, dan melanjutkan kombo yang benar-benar kena. Tiap tingkat kesulitan mengubah waktu reaksi, agresi, dan kesalahan.
- **Loading.** `precache.js` mendaftar semua berkas runtime beserta hash isinya. `preload.js` mengunduhnya ke Cache Storage di balik layar boot, dan `sw.js` melayani gambar dan font dari sana. Pada kunjungan berikutnya hanya berkas yang berubah yang diunduh lagi. Setelah menambah atau mengubah berkas, jalankan `python3 tools/build_precache.py`.
| Topic | Guide | ## Penerapan
| --- | --- |
| Adding a new fighter, step by step | [character-workflow.md](guide/character-workflow.md) |
| Shared gameplay rules (movement, combos, HP, cooldowns) | [gameplay-standard.md](guide/gameplay-standard.md) |
| Sprite prompts, pipeline, QA and known issues | [sprite-prompts.md](guide/sprite-prompts.md), [sprite-pipeline.md](guide/sprite-pipeline.md), [sprite-qa.md](guide/sprite-qa.md), [sprite-known-issues.md](guide/sprite-known-issues.md) |
| CPU brain and difficulty benchmark | [cpu-ai.md](guide/cpu-ai.md) |
| Menu flow and match rules | [main-menu.md](guide/main-menu.md) |
| Announcer and ultimate voices | [announcer-system.md](guide/announcer-system.md), [ultimate-voice.md](guide/ultimate-voice.md) |
| Arena backgrounds and menu video | [stage-background.md](guide/stage-background.md), [menu-video.md](guide/menu-video.md) |
| One reference per fighter (kit, balance, assets, voice) | [arco](guide/arco-reference.md) · [fenr](guide/fenr-reference.md) · [mira](guide/mira-reference.md) · [cora](guide/cora-reference.md) · [naja](guide/naja-reference.md) · [haldor](guide/haldor-reference.md) · [zanni](guide/zanni-reference.md) · [isolde](guide/isolde-reference.md) · [rhea](guide/rhea-reference.md) · [solan](guide/solan-reference.md) · [nib](guide/nib-reference.md) · [edda](guide/edda-reference.md) |
The guides also mention the full production workspace (raw generation sources, pipeline tools, sprite lab pages). Those are several GB and are not part of this repository. Unggah folder ke hosting file statis (nginx, Apache, LiteSpeed, Caddy, GitHub Pages, Netlify, dan sejenisnya). Semua jalur bersifat relatif, jadi bisa di akar domain atau subfolder. Buka game dengan garis miring penutup, kirim `Cache-Control: no-cache` untuk berkas game, dan sajikan MP3 dan MP4 dengan permintaan rentang dan tanpa gzip. Langkah rinci untuk nginx, Apache/LiteSpeed, dan Caddy, beserta skrip pemeriksa berkas setelah unggah, ada di [DEPLOY.md](DEPLOY.md) (berbahasa Inggris, dari proyek asal). `.htaccess` untuk Apache/LiteSpeed sudah disertakan.
## 🔧 How it works ## Kredit dan lisensi
- **Fixed-step simulation.** The game advances in 1/120 s steps with a seeded random generator, so combat plays the same way on every machine. - **Berbasis AETHER CLASH oleh Bang Tutorial** ([repo asli](https://github.com/bangtutorial/aether-clash), [tutorial YouTube](https://www.youtube.com/watch?v=UN_0bNC2wTU)): kerangka mesin, sistem menu, layar loading, pengumuman, kontrol sentuh, dan panduan di folder `guide/` berasal dari karya itu. Game asalnya dibuat langkah demi langkah di YouTube (berbahasa Indonesia).
- **Sprites.** Each fighter is a WebP sprite atlas (idle, walk, run, jump, crouch, three attacks, two skills, ultimate, hurt, down and recover) with a manifest of frames, foot anchors and measured hit points. Sprites are drawn pixel-exact, never rescaled at runtime. - **Visual awal** dibuat dengan Higgsfield (GPT Image); **suara dan pengumuman** memakai suara ElevenLabs lewat Higgsfield; **musik latar** "Midday Showdown" dibuat dengan [Suno](https://suno.com).
- **Kits.** The rules for each fighter (damage, reach, cooldowns, projectile and summon behaviour) live in their own small file. The player and the CPU use exactly the same kit. - **Tipografi**: [Rajdhani](https://fonts.google.com/specimen/Rajdhani) oleh Indian Type Foundry, lisensi SIL Open Font License (lihat `assets/fonts/OFL.txt`).
- **CPU brain.** The CPU reacts with a human-like delay: it jumps projectiles, backs out of attacks, punishes recovery, anti-airs and confirms combos. Each difficulty level changes reaction time, aggression and mistakes. - **Kode** (HTML, CSS, JavaScript) berlisensi [MIT](LICENSE).
- **Loading.** `precache.js` lists every runtime file with a content hash. `preload.js` downloads them into Cache Storage behind the loading screen, and `sw.js` serves images and fonts from there. On later visits only files whose hash changed are downloaded again. - **Aset bawaan** (karakter, sprite, ilustrasi, efek, video, suara, musik latar, tangkapan layar, dan dokumen `guide/`) berlisensi [CC BY-NC 4.0](LICENSE-ASSETS.md): bebas dibagikan dan diadaptasi dengan menyebut sumber, tetapi **tidak untuk penggunaan komersial**.
- Pengecualian: font Rajdhani tetap di bawah SIL Open Font License. Lihat [LICENSE-ASSETS.md](LICENSE-ASSETS.md).
## 🙏 Credits - Semua karakter di Konoha Fighting adalah karikatur untuk hiburan, bukan pernyataan tentang orang sebenarnya.
- **Game design and development**: [Bang Tutorial](https://www.youtube.com/watch?v=UN_0bNC2wTU)
- **Visuals**: generated with Higgsfield (GPT Image)
- **Voices and announcer**: ElevenLabs voices via Higgsfield
- **Typography**: [Rajdhani](https://fonts.google.com/specimen/Rajdhani) by Indian Type Foundry, under the SIL Open Font License (see `assets/fonts/OFL.txt`)
- **Background music**: "Midday Showdown", generated with [Suno](https://suno.com)
## 📄 License
- **Source code** (HTML, CSS, JavaScript) is released under the [MIT License](LICENSE).
- **Assets** (characters, sprites, artwork, effects, video, voices, background music, screenshots and the `guide/` documents) are released under [CC BY-NC 4.0](LICENSE-ASSETS.md): free to share and adapt with credit, but not for commercial use.
- **Exception**: the Rajdhani font keeps the [SIL Open Font License](assets/fonts/OFL.txt). See [LICENSE-ASSETS.md](LICENSE-ASSETS.md).
<div align="center">
If you enjoyed the project, give it a ⭐ and check out the [tutorial on YouTube](https://www.youtube.com/watch?v=UN_0bNC2wTU)!
</div>

128
brand.js Normal file
View File

@ -0,0 +1,128 @@
/* Merek dan kabar pembaruan (RENCANA 2.7 dan 2.8). Tanpa dependensi; dimuat sebelum precache.js supaya kutipan loading tampil di kartu boot.
- Kutipan loading: window.LOADING_QUOTES (content/loading.js) dipilih acak tiap halaman dimuat, tidak pernah sama dengan yang terakhir
tampil (localStorage['konoha.loadquote']); tampil di #boot (.boot-quote) dan #load-state (.load-quote). ?quote=N memaksa kutipan ke-N (uji).
- Yang baru: window.CHANGELOG (content/changelog.js). Panel di layar About lewat Brand.decorate(root, screen) yang dipanggil menu.js
setelah setiap render, dan toast "Ada yang baru" satu kali saat versi teratas berbeda dari yang terakhir dilihat
(localStorage['konoha.changelog.seen']). Pemain baru (belum punya simpanan) tidak diberi toast; versinya langsung dicatat.
Pengacak di sini hanya kosmetik, tidak memengaruhi pertandingan. Semua akses localStorage dibungkus try/catch. */
(() => {
'use strict';
const KEY_QUOTE = 'konoha.loadquote', KEY_SEEN = 'konoha.changelog.seen', KEY_SAVE = 'konoha.save.v1';
const store = {
get(k) { try { return localStorage.getItem(k); } catch (_) { return null; } },
set(k, v) { try { localStorage.setItem(k, v); } catch (_) {} }
};
const MONTHS = ['Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni', 'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember'];
const state = { quote: '', quoteIndex: -1 };
// ---------- kutipan loading ----------
function pickQuote() {
const list = window.LOADING_QUOTES;
if (!Array.isArray(list) || !list.length) return;
const forced = new URLSearchParams(location.search).get('quote');
let i;
if (forced !== null && /^\d+$/.test(forced)) i = Number(forced) % list.length;
else {
const last = Number(store.get(KEY_QUOTE));
i = Math.floor(Math.random() * list.length);
if (list.length > 1 && i === last) i = (i + 1 + Math.floor(Math.random() * (list.length - 1))) % list.length;
}
store.set(KEY_QUOTE, String(i));
state.quote = String(list[i]); state.quoteIndex = i;
}
function paintQuote() {
if (!state.quote) return;
for (const el of document.querySelectorAll('.boot-quote,.load-quote')) el.textContent = state.quote;
}
pickQuote(); paintQuote();
// #load-state berada di <main>, yang belum ada saat skrip ini berjalan.
document.addEventListener('DOMContentLoaded', paintQuote);
// ---------- Yang baru ----------
const log = () => Array.isArray(window.CHANGELOG) ? window.CHANGELOG.filter(e => e && e.version) : [];
const latest = () => log()[0] || null;
const parts = v => String(v).split('.').map(n => parseInt(n, 10) || 0);
function cmp(a, b) {
const x = parts(a), y = parts(b);
for (let i = 0; i < Math.max(x.length, y.length); i++) { const d = (x[i] || 0) - (y[i] || 0); if (d) return d < 0 ? -1 : 1; }
return 0;
}
function dateText(iso) {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(iso || ''));
return m ? `${Number(m[3])} ${MONTHS[Number(m[2]) - 1] || ''} ${m[1]}`.replace(' ', ' ') : '';
}
// Versi terakhir yang dilihat pemain saat halaman dimuat (null = belum pernah). Dibaca sekali supaya lencana BARU bertahan selama sesi.
const seenAtLoad = store.get(KEY_SEEN);
const isNew = e => seenAtLoad !== null && cmp(e.version, seenAtLoad) > 0;
function markSeen() { const l = latest(); if (l) store.set(KEY_SEEN, String(l.version)); }
const el = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };
function panel() {
const entries = log();
if (!entries.length) return null;
const box = el('aside', 'whatsnew-panel'); box.setAttribute('aria-label', 'Yang baru');
const head = el('header', 'wn-head'); head.append(el('span', 'eyebrow', 'YANG BARU'), el('h2', '', 'Catatan pembaruan'));
const scroller = el('div', 'wn-scroll'); scroller.tabIndex = 0; scroller.setAttribute('role', 'region'); scroller.setAttribute('aria-label', 'Daftar pembaruan');
entries.forEach((e, idx) => {
const sec = el('section', 'wn-entry' + (idx === 0 ? ' latest' : ''));
const h = el('h3', ''); h.append(el('b', '', 'v' + e.version));
if (e.date) h.append(el('small', '', dateText(e.date)));
if (isNew(e)) h.append(el('i', 'wn-badge', 'BARU'));
sec.append(h);
if (e.title) sec.append(el('p', 'wn-title', e.title));
const ul = el('ul', ''); for (const it of e.items || []) ul.append(el('li', '', it)); sec.append(ul);
scroller.append(sec);
});
box.append(head, scroller);
return box;
}
// ---------- toast "Ada yang baru" ----------
let toastPending = false, toastShown = false;
function showToast(root) {
const l = latest(); if (!l || toastShown) return;
toastShown = true;
const t = el('div', 'brand-toast'); t.setAttribute('role', 'status'); t.setAttribute('aria-live', 'polite');
const text = el('div', 'bt-text'); text.append(el('b', '', 'Ada yang baru'), el('span', '', `Versi ${l.version}: ${l.title || 'pembaruan baru'}.`));
const look = el('button', 'bt-look', 'Lihat'), close = el('button', 'bt-close', '✕');
look.type = close.type = 'button'; close.setAttribute('aria-label', 'Tutup pemberitahuan');
const dismiss = () => { t.classList.add('out'); setTimeout(() => t.remove(), 450); };
look.onclick = () => { dismiss(); (root || document).querySelector('[data-cmd="about"]')?.click(); };
close.onclick = dismiss;
t.append(text, look, close); document.body.append(t);
setTimeout(dismiss, 9000);
markSeen();
}
function maybeToast(root) {
const l = latest(); if (!l || toastShown || toastPending) return;
if (store.get(KEY_SEEN) === String(l.version)) { toastShown = true; return; }
// Pemain baru (tanpa simpanan dan tanpa catatan versi): cukup catat versinya, tanpa pemberitahuan.
if (seenAtLoad === null && store.get(KEY_SAVE) === null) { toastShown = true; markSeen(); return; }
toastPending = true;
setTimeout(() => { toastPending = false; if (root.dataset.screen === 'home' && !root.hidden) showToast(root); }, 900);
}
// Dipanggil menu.js di akhir render(): about mendapat panel Yang baru + tautan kredit; home memicu toast satu kali.
function decorate(root, screen) {
if (!root) return;
if (screen === 'about') {
const p = panel(); if (p) root.append(p);
const small = root.querySelector('.about-panel > small');
if (small && !small.querySelector('a')) {
small.append(document.createElement('br'));
const a = el('a', 'about-link', 'Repo asli AETHER CLASH'), b = el('a', 'about-link', 'Tutorial YouTube');
a.href = 'https://github.com/bangtutorial/aether-clash'; b.href = 'https://www.youtube.com/watch?v=UN_0bNC2wTU';
for (const x of [a, b]) { x.target = '_blank'; x.rel = 'noopener noreferrer'; }
small.append(a, document.createTextNode(' · '), b);
}
markSeen();
} else if (screen === 'home') maybeToast(root);
}
window.Brand = {
decorate, panel, markSeen,
get version() { return latest()?.version || ''; },
get quote() { return state.quote; },
state: () => ({ quote: state.quote, quoteIndex: state.quoteIndex, seen: store.get(KEY_SEEN), seenAtLoad, toast: toastShown, version: latest()?.version || '' })
};
})();

48
content/changelog.js Normal file
View File

@ -0,0 +1,48 @@
/* Daftar "Yang baru" (RENCANA 2.8): window.CHANGELOG = [{version, date, title, items:[...]}], yang terbaru PALING ATAS.
Dibaca brand.js: panel "Yang baru" di layar About, dan toast "Ada yang baru" satu kali saat versi teratas berbeda
dari versi terakhir yang dilihat pemain (disimpan di localStorage['konoha.changelog.seen']).
Cara menambah pembaruan: sisipkan objek baru di awal daftar dengan version lebih besar (format angka bertitik, mis. '0.5');
date berbentuk 'YYYY-MM-DD'; items berupa kalimat pendek berbahasa Indonesia untuk pemain, bukan catatan teknis. */
window.CHANGELOG = [
{
version: '0.4', date: '2026-10-03', title: 'Wajah baru menu',
items: [
'Latar menu utama kini kolase tujuh petarung Konoha Fighting dengan gerak pelan, tidak lagi memakai gambar bawaan game asal.',
'Layar loading menampilkan kutipan acak yang berganti setiap kali game dibuka.',
'Daftar "Yang baru" di layar About, plus pemberitahuan singkat satu kali saat ada pembaruan.',
'Sisa nama dan teks game asal di tampilan dan dokumen dibersihkan; kredit untuk Bang Tutorial tetap ada di About.'
]
},
{
version: '0.3', date: '2026-10-02', title: 'Jurus lebih hidup, pukulan lebih terasa',
items: [
'Efek jurus baru: gelombang yang tumbuh dan bergoyang, jurus area dengan aba-aba cincin dan gelombang kejut, lari dengan bayangan jejak.',
'Jurus mode berbeda mekanik: api meninggalkan genangan terbakar, banteng menembus dan memantul, asam menggenang.',
'Semut Gufron lebih banyak dan mengitari lawan; damage naik untuk tiap semut yang kena.',
'Rasa kena: jeda singkat saat pukulan mendarat, layar bergetar sesuai kekuatan, dan K.O. dalam gerak lambat.',
'Angka damage membesar lalu memudar, dengan warna berbeda untuk counter dan parry.',
'Peringkat kombo besar di tengah layar.',
'Perbaikan: gelombang membelah tidak lagi memukul dua kali, dan damage berkelanjutan tidak menaikkan hitungan kombo.'
]
},
{
version: '0.2', date: '2026-10-01', title: 'Mode baru, toko, dan teknik bertahan',
items: [
'Blok (tahan L), parry (ketuk L), dan lempar (J) yang menembus blok.',
'Gerakan perintah: uppercut (↓ ↓ + Spasi) dan tinju maju (↓ → + Spasi), plus lompat ganda.',
'Mode Arcade, Survival, dan Dua Pemain di satu keyboard, selain VS Komputer dengan empat tingkat dan Latihan.',
'Koin dari tiap pertandingan, Toko jurus dan skin warna, menu Karakter untuk memasang jurus toko (tombol K), varian skill, dan menu Koleksi.',
'20 pencapaian dan beberapa kejutan tersembunyi; kolom kode di Pengaturan.',
'Teriakan nama jurus, tombol sentuh tambahan untuk HP, dan menu yang muat di layar pendek.'
]
},
{
version: '0.1', date: '2026-10-01', title: 'Tujuh petarung pertama',
items: [
'Tujuh petarung parodi: Bro Wo, Mega Chan, Son of Solo Man, Ethanol Man, A Nice, Solo Man, dan Gufron, masing-masing dengan kombo, dua skill, dan ultimate sinematik.',
'Mode pasca-ultimate untuk tiap petarung, misalnya Bro Wo mengamuk dan A Nice menjadi Senator.',
'Latihan bebas dengan penghitung kombo, dan VS Komputer tiga ronde dengan lima arena.',
'Lima slot misteri untuk petarung yang akan datang.'
]
}
];

40
content/loading.js Normal file
View File

@ -0,0 +1,40 @@
/* Kutipan layar loading (RENCANA 2.7): window.LOADING_QUOTES = [teks, ...].
Dipilih acak oleh brand.js tiap kali halaman dimuat (tidak pernah sama dengan yang terakhir tampil).
Pedoman isi: plesetan ringan tentang menunggu, arena, dan pertarungan. Tanpa tuduhan fakta, tanpa unsur agama,
tanpa merendahkan pribadi atau kelompok. Rujukan ke karakter hanya meme ringan yang sudah ada di game. Satu kalimat, maksimal sekitar 80 huruf. */
window.LOADING_QUOTES = [
'Memanaskan jempol sebelum kombo pertama…',
'Menyiapkan rapat koordinasi sebelum adu pukul.',
'Mengantre di loket pendaftaran arena. Nomor antrean 007.',
'Menunggu surat disposisi untuk menyalakan lampu arena.',
'Mencari remote arena. Biasanya terselip di bawah bantal sofa.',
'Menghitung jumlah kombo yang belum terjadi.',
'Mengisi bensin jurus rahasia. Eceran, bukan kemasan.',
'Mengumpulkan semut untuk jurus rahasia. Syududu…',
'Latihan joget pemanasan, hitungan ke delapan.',
'Meniup debu dari sarung. Sarung siap tempur.',
'Mengecek apakah banteng sudah sarapan.',
'Menyusun kalimat pembuka yang panjang untuk ronde pertama.',
'Mengisi tangki etanol sampai penuh, bukan separuh.',
'Menunggu wasit selesai mengikat tali sepatu.',
'Membersihkan lantai arena dari sisa gorengan.',
'Mengatur kursi penonton. Yang berdiri juga boleh.',
'Sabar. Loading itu juga bagian dari pertarungan.',
'Menyiapkan es teh untuk sang pemenang. Manis, tentu saja.',
'Mencari sandal yang tertukar di depan arena.',
'Memasang spanduk "Dilarang Baper di Dalam Arena".',
'Menyetel tinggi lompatan. Naiknya sedikit, turunnya cepat.',
'Menghitung ulang koin. Jumlahnya tetap, tapi lebih meyakinkan.',
'Menimbang blok atau parry. Dua-duanya ada tombolnya.',
'Membuka kotak P3K. Isinya plester dan semangat.',
'Mengecek sinyal. Pukulan tak butuh sinyal, loading butuh.',
'Meminta penonton menurunkan ponselnya selama kombo.',
'Rapat kilat, agenda tunggal: yang kalah traktir bakso.',
'Memanggil juru parkir untuk mengatur lalu lintas peluru.',
'Melatih ekspresi serius sebelum disorot kamera.',
'Menyeduh kopi untuk para petarung. Gulanya boleh dikurangi.',
'Mengukur ulang jarak antara tinju dan wajah. Aman, kok.',
'Menyortir tepuk tangan penonton. Yang paling keras duduk di depan.',
'Memeriksa apakah tombol Spasi masih bersemangat.',
'Menunggu pemanasan pidato selesai. Kata sambutannya belum habis.'
];

View File

@ -16,6 +16,8 @@ Game fighting 2D canvas, tanpa dependensi dan tanpa langkah build. Semua teks un
- `meta.js`: `window.Meta` — koin, toko, skin (filter warna), jurus toko (tombol K), varian skill, menu Karakter/Koleksi, pencapaian, easter egg, Arcade/Survival (`startRun`), kolom kode cheat di Pengaturan. Simpanan: `localStorage['konoha.save.v1']`. - `meta.js`: `window.Meta` — koin, toko, skin (filter warna), jurus toko (tombol K), varian skill, menu Karakter/Koleksi, pencapaian, easter egg, Arcade/Survival (`startRun`), kolom kode cheat di Pengaturan. Simpanan: `localStorage['konoha.save.v1']`.
- `menu.js`: menu utama (home, pilih karakter, arena, hasil, about) di `#front-menu`; `window.FrontEnd`. - `menu.js`: menu utama (home, pilih karakter, arena, hasil, about) di `#front-menu`; `window.FrontEnd`.
- `touch.js`: kontrol sentuh; tombol memanggil `__game.input(key, down)`. - `touch.js`: kontrol sentuh; tombol memanggil `__game.input(key, down)`.
- `brand.js` (`window.Brand`) + `content/loading.js` (`window.LOADING_QUOTES`) + `content/changelog.js` (`window.CHANGELOG`): dimuat di `index.html` tepat setelah kartu `#boot`, sebelum `precache.js`. Kutipan acak (tidak pernah sama dengan yang terakhir, kunci `konoha.loadquote`; `?quote=N` memaksa) tampil di `#boot .boot-quote` dan `#load-state .load-quote`. "Yang baru": `Brand.decorate(root, screen)` dipanggil `menu.js` di akhir `render()`; layar `about` mendapat panel `.whatsnew-panel` dan tautan kredit, layar `home` memicu toast `.brand-toast` satu kali bila versi teratas CHANGELOG berbeda dari `localStorage['konoha.changelog.seen']` (pemain tanpa simpanan `konoha.save.v1` tidak diberi toast). Menambah pembaruan: sisipkan objek baru di AWAL `content/changelog.js` dengan version lebih besar. Berkas `content/*.js` ikut `tools/build_precache.py`.
- Latar menu utama dan About: `menu.js` `collageHtml()` menyusun tujuh potret `assets/menu/<id>-select.webp` (lima di layar sentuh) menjadi kolase CSS (`.hc-*` di bagian "Merek" paling bawah `menu.css`). Gerak berhenti lewat kelas `.bg-still` pada `#front-menu` (`FrontEnd.syncBackground()`: Pengaturan terbuka, tab tersembunyi, saklar "Animasi latar menu", `prefers-reduced-motion`). Video dan gambar bergaya game asal (`home-factions.webp`, `home-factions-loop.mp4`, `home-art.webp`) tidak dipakai lagi tetapi berkasnya masih ada (dan ikut daftar precache).
- `assets/<id>/` atlas sprite (15 state), `manifest.js` (data + metrik), `ui/`; `work/` (di repo induk, luar folder ini) berisi pipeline pembuat karakter. - `assets/<id>/` atlas sprite (15 state), `manifest.js` (data + metrik), `ui/`; `work/` (di repo induk, luar folder ini) berisi pipeline pembuat karakter.
- `precache.js`/`files.json`: **dibuat otomatis** oleh `tools/build_precache.py` — jangan diedit atau di-commit oleh cabang fitur; perintahkan integrator membangun ulang. - `precache.js`/`files.json`: **dibuat otomatis** oleh `tools/build_precache.py` — jangan diedit atau di-commit oleh cabang fitur; perintahkan integrator membangun ulang.
@ -25,11 +27,12 @@ Game fighting 2D canvas, tanpa dependensi dan tanpa langkah build. Semua teks un
`__game.hitDummy`, `__game.feel` dan `__game.feelState()` membuka tabel rasa kena, status K.O./angka untuk `tests/t1-feel.mjs`. `__game.fx.snapshot()` melaporkan keadaan efek (bayangan, genangan api, DoT, aba-aba, proyektil beserta gaya/umur) untuk skenario uji. Untuk uji deterministik set `__game.manual = true` dulu; kalau tidak, loop waktu nyata ikut memajukan simulasi saat screenshot. `__game.hitDummy`, `__game.feel` dan `__game.feelState()` membuka tabel rasa kena, status K.O./angka untuk `tests/t1-feel.mjs`. `__game.fx.snapshot()` melaporkan keadaan efek (bayangan, genangan api, DoT, aba-aba, proyektil beserta gaya/umur) untuk skenario uji. Untuk uji deterministik set `__game.manual = true` dulu; kalau tidak, loop waktu nyata ikut memajukan simulasi saat screenshot.
## Alat uji headless ## Alat uji headless
`node tests/harness.mjs smoke` — 7 karakter × easy/hard melawan CPU, gagal bila ada galat halaman. `node tests/harness.mjs tests/foo.mjs` — menjalankan skenario: `export default async ({ page, game, errors, characters }) => hasil` (`game(fn, arg)` = `page.evaluate`). Screenshot: `await page.screenshot({path})` lalu `Read` gambar. `tests/t1-fx.mjs` melepas semua jurus enam karakter berbasis data dari kedua sisi (env `FX_SHOTS` = folder screenshot, `FX_ONLY` = regex nama jurus). Node dan Playwright sudah terpasang; tidak perlu `npm install`. `node tests/harness.mjs smoke` — 7 karakter × easy/hard melawan CPU, gagal bila ada galat halaman. `node tests/harness.mjs tests/foo.mjs` — menjalankan skenario: `export default async ({ page, game, errors, characters }) => hasil` (`game(fn, arg)` = `page.evaluate`). Screenshot: `await page.screenshot({path})` lalu `Read` gambar. `tests/t1-fx.mjs` melepas semua jurus enam karakter berbasis data dari kedua sisi (env `FX_SHOTS` = folder screenshot, `FX_ONLY` = regex nama jurus). Node dan Playwright sudah terpasang; tidak perlu `npm install`. `tests/t2-brand.mjs` (env `BRAND_SHOTS`, `BRAND_ONLY=text|quote|log|menu|flow|docs`) menguji sisa nama game asal di seluruh layar, kutipan loading, Yang baru dan toast, serta latar kolase.
## Aturan kerja ## Aturan kerja
- Jangan memakai `Math.random`/`Date.now` untuk logika yang memengaruhi hasil pertandingan bila ada pengacak `random()` bawaan (benih tetap). - Jangan memakai `Math.random`/`Date.now` untuk logika yang memengaruhi hasil pertandingan bila ada pengacak `random()` bawaan (benih tetap).
- Tampilan harus tetap muat di jendela setinggi 520 px; jangan memotong menu. - Tampilan harus tetap muat di jendela setinggi 520 px; jangan memotong menu.
- Tidak ada pustaka eksternal baru. Tidak ada gambar baru (tidak boleh memanggil generator gambar). - Tidak ada pustaka eksternal baru. Tidak ada gambar baru (tidak boleh memanggil generator gambar).
- Commit atas nama `rizkyaz0 <rizkyadizaelani@gmail.com>`, tanpa baris Co-Authored-By atau atribusi apa pun. **Jangan pernah `git push`** dan jangan mengubah remote. - Commit atas nama `rizkyaz0 <rizkyadizaelani@gmail.com>`, tanpa baris Co-Authored-By atau atribusi apa pun. **Jangan pernah `git push`** dan jangan mengubah remote.
- Teks HUD di `index.html` (nama, kelas, jurus) hanya placeholder netral; `game.js` menimpanya lewat `fighterInfo`. Cabang lama di `fighterInfo` dan manifest `assets/mecha`, `assets/fenr` adalah sisa game asal yang tidak terjangkau dari roster; jangan menampilkannya ke pemain.
- Jaga gaya kode yang ada (ringkas, satu baris per pernyataan kecil, komentar hanya bila perlu). - Jaga gaya kode yang ada (ringkas, satu baris per pernyataan kecil, komentar hanya bila perlu).

View File

@ -1,5 +1,8 @@
# Panduan game fighting dan karakter # Panduan game fighting dan karakter
> [!NOTE]
> Seluruh isi folder `guide/` adalah warisan **AETHER CLASH** karya Bang Tutorial (CC BY-NC 4.0). Nama seperti ARCO, FENR, Mecha, dan Demi-Human merujuk ke karakter game asal yang tidak lagi ada di roster Konoha Fighting; panduan ini tetap berguna sebagai acuan alur pembuatan karakter, sprite, dan suara.
> [!NOTE] > [!NOTE]
> Panduan ini ditulis di workspace produksi lengkap, yang juga berisi bahan generate (strip mentah, original, kandidat voice, QA), tool pipeline (`tools/`), dan halaman lab sprite. Bahan itu tidak ikut di repository ini karena ukurannya beberapa GB. Karena itu, path yang disebut tanpa tautan hanya ada di workspace tersebut; file game yang dirujuk (`game.js`, kit karakter, `index.html`, dan sebagainya) ada di root repository ini. > Panduan ini ditulis di workspace produksi lengkap, yang juga berisi bahan generate (strip mentah, original, kandidat voice, QA), tool pipeline (`tools/`), dan halaman lab sprite. Bahan itu tidak ikut di repository ini karena ukurannya beberapa GB. Karena itu, path yang disebut tanpa tautan hanya ada di workspace tersebut; file game yang dirujuk (`game.js`, kit karakter, `index.html`, dan sebagainya) ada di root repository ini.

View File

@ -1,5 +1,8 @@
# Main menu, character select dan VS Computer # Main menu, character select dan VS Computer
> [!NOTE]
> Dokumen ini warisan AETHER CLASH. Di Konoha Fighting, home dan About berlatar kolase tujuh petarung (CSS), bukan banner faksi, dan teks branding lama sudah dibersihkan.
Status: main menu menjadi layar awal. Implementasi UI ada di [menu.js](../menu.js)/[menu.css](../menu.css), aturan ronde/difficulty/arena di [match.js](../match.js), dan integrasi combat di [game.js](../game.js). Status: main menu menjadi layar awal. Implementasi UI ada di [menu.js](../menu.js)/[menu.css](../menu.css), aturan ronde/difficulty/arena di [match.js](../match.js), dan integrasi combat di [game.js](../game.js).
Branding final: **AETHER CLASH — Mecha vs Demi-Human**, berpusat pada dua faksi dan roster yang akan berkembang. Home/About tidak memakai ARCO/FENR sebagai pasangan tokoh utama. Lihat [branding.md](branding.md); key art aktif `home-factions.png`, sedangkan artwork duo awal diarsipkan. Branding final: **AETHER CLASH — Mecha vs Demi-Human**, berpusat pada dua faksi dan roster yang akan berkembang. Home/About tidak memakai ARCO/FENR sebagai pasangan tokoh utama. Lihat [branding.md](branding.md); key art aktif `home-factions.png`, sedangkan artwork duo awal diarsipkan.

View File

@ -1,5 +1,8 @@
# Background video main menu # Background video main menu
> [!NOTE]
> Konoha Fighting tidak lagi memakai video ini di menu utama. Latar menu kini kolase potret petarung yang disusun dengan CSS (`menu.js` `collageHtml()`, gaya di bagian "Merek" `menu.css`). Berkas video tetap ada sebagai bagian aset warisan AETHER CLASH.
Main menu memakai video loop dari **Higgsfield Seedance2.5,1080p**. Artwork faksi yang sama (`home-factions.png`, job `e0ac186a-ef20-4b0d-849c-c35d718e14c3`) dipakai sebagai start/end reference. Kamera tetap; banner dan partikel aether bergerak halus, tanpa karakter atau teks yang digenerate. Main menu memakai video loop dari **Higgsfield Seedance2.5,1080p**. Artwork faksi yang sama (`home-factions.png`, job `e0ac186a-ef20-4b0d-849c-c35d718e14c3`) dipakai sebagai start/end reference. Kamera tetap; banner dan partikel aether bergerak halus, tanpa karakter atau teks yang digenerate.
## File dan provenance ## File dan provenance

View File

@ -2,27 +2,27 @@
<html lang="id"> <html lang="id">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#101923"><script>/* Phones/tablets: mark .touch and open the landscape mobile shell (mobile.html). ?touch=1 forces it on desktop, ?touch=0 turns it off. */(()=>{const q=new URLSearchParams(location.search),f=q.get('touch'),t=f?f==='1':matchMedia('(pointer: coarse)').matches||(navigator.maxTouchPoints>0&&/Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent));if(!t)return;document.documentElement.classList.add('touch');if(window.top===window&&!q.has('embed'))location.replace('mobile.html'+location.search+location.hash);})();</script><title>Konoha Fighting</title><link rel="preload" href="assets/fonts/Rajdhani-Bold.ttf" as="font" type="font/ttf" crossorigin><link rel="stylesheet" href="style.css"><link rel="stylesheet" href="menu.css"><link rel="stylesheet" href="meta.css"> <head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#101923"><script>/* Phones/tablets: mark .touch and open the landscape mobile shell (mobile.html). ?touch=1 forces it on desktop, ?touch=0 turns it off. */(()=>{const q=new URLSearchParams(location.search),f=q.get('touch'),t=f?f==='1':matchMedia('(pointer: coarse)').matches||(navigator.maxTouchPoints>0&&/Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent));if(!t)return;document.documentElement.classList.add('touch');if(window.top===window&&!q.has('embed'))location.replace('mobile.html'+location.search+location.hash);})();</script><title>Konoha Fighting</title><link rel="preload" href="assets/fonts/Rajdhani-Bold.ttf" as="font" type="font/ttf" crossorigin><link rel="stylesheet" href="style.css"><link rel="stylesheet" href="menu.css"><link rel="stylesheet" href="meta.css">
<link rel="stylesheet" href="feel.css"></head> <link rel="stylesheet" href="feel.css"></head>
<body><div id="boot" role="status" aria-live="polite"><div class="boot-card"><strong class="boot-logo">KONOHA<br><em>FIGHTING</em></strong><div class="boot-bar"><i></i></div><span class="boot-status">Menyiapkan…</span></div></div><script src="precache.js"></script><script src="preload.js"></script><main id="game-shell" aria-label="Konoha Fighting, game fighting parodi"> <body><div id="boot" role="status" aria-live="polite"><div class="boot-card"><strong class="boot-logo">KONOHA<br><em>FIGHTING</em></strong><div class="boot-bar"><i></i></div><span class="boot-status">Menyiapkan…</span><p class="boot-quote"></p></div></div><script src="content/loading.js"></script><script src="content/changelog.js"></script><script src="brand.js"></script><script src="precache.js"></script><script src="preload.js"></script><main id="game-shell" aria-label="Konoha Fighting, game fighting parodi">
<canvas id="arena" tabindex="0" aria-label="Arena pertarungan. WASD bergerak, Spasi menyerang, I O P untuk skill."></canvas><div class="vignette" aria-hidden="true"></div> <canvas id="arena" tabindex="0" aria-label="Arena pertarungan. WASD bergerak, Spasi menyerang, I O P untuk skill."></canvas><div class="vignette" aria-hidden="true"></div>
<header class="combat-header"> <header class="combat-header">
<section class="fighter fighter-one" aria-label="Status pemain"> <section class="fighter fighter-one" aria-label="Status pemain">
<div class="portrait-frame"><img id="player-portrait" class="portrait-image" data-portrait-side="player" src="assets/ui/arco-avatar.webp" alt="Portrait ARCO"><b class="player-tag">P1</b></div> <div class="portrait-frame"><img id="player-portrait" class="portrait-image" data-portrait-side="player" src="assets/ui/arco-avatar.webp" alt="Potret pemain"><b class="player-tag">P1</b></div>
<div class="fighter-fields"><div class="name-row"><h1 id="player-name">ARCO</h1><span class="class-tag" id="player-class">MECHA</span></div><div class="life-frame" aria-label="Satu bar dengan dua lapis HP, 100 HP per lapis"><div class="life-track"><div id="health-reserve" class="life-fill reserve-fill"></div><div id="health-fill" class="life-fill"></div></div></div><div class="bar-caption"><span id="player-title">AETHER ARM</span><span id="health-label">200 / 200</span></div><div class="fighter-mark"><i></i><i></i><i></i><b class="round-wins" id="round-wins-player"></b><span id="player-status">CORE ACTIVE</span></div></div></section> <div class="fighter-fields"><div class="name-row"><h1 id="player-name">PEMAIN 1</h1><span class="class-tag" id="player-class">HUMAN</span></div><div class="life-frame" aria-label="Satu bar dengan dua lapis HP, 100 HP per lapis"><div class="life-track"><div id="health-reserve" class="life-fill reserve-fill"></div><div id="health-fill" class="life-fill"></div></div></div><div class="bar-caption"><span id="player-title">PETARUNG</span><span id="health-label">200 / 200</span></div><div class="fighter-mark"><i></i><i></i><i></i><b class="round-wins" id="round-wins-player"></b><span id="player-status">SIAP</span></div></div></section>
<section class="match-center" aria-label="Mode latihan tanpa batas waktu"><div class="mode-kicker" id="mode-label">FREE TRAINING</div><div class="match-clock" id="match-clock">∞</div><div class="match-rule"><i></i><span id="match-score">VS</span><i></i></div></section> <section class="match-center" aria-label="Mode latihan tanpa batas waktu"><div class="mode-kicker" id="mode-label">FREE TRAINING</div><div class="match-clock" id="match-clock">∞</div><div class="match-rule"><i></i><span id="match-score">VS</span><i></i></div></section>
<section class="fighter fighter-two" aria-label="Status lawan FENR"><div class="fighter-fields"><div class="name-row"><span class="class-tag" id="enemy-class">DEMI-HUMAN</span><h2 id="enemy-name">FENR</h2></div><div class="life-frame" aria-label="Satu bar dengan dua lapis HP, 100 HP per lapis"><div class="life-track"><div id="guard-reserve" class="life-fill reserve-fill"></div><div id="guard-fill" class="life-fill"></div></div></div><div class="bar-caption"><span id="guard-label">HP 200 / 200</span><span id="enemy-title">WOLF RANGER</span></div><div class="fighter-mark"><b class="round-wins" id="round-wins-enemy"></b><span id="dummy-state">READY</span><i></i><i></i><i></i></div></div><div class="portrait-frame"><img id="enemy-portrait" class="portrait-image" data-portrait-side="enemy" src="assets/fenr/ui/portrait-human.webp" alt="Portrait FENR"><b class="player-tag">CPU</b></div></section> <section class="fighter fighter-two" aria-label="Status lawan"><div class="fighter-fields"><div class="name-row"><span class="class-tag" id="enemy-class">HUMAN</span><h2 id="enemy-name">LAWAN</h2></div><div class="life-frame" aria-label="Satu bar dengan dua lapis HP, 100 HP per lapis"><div class="life-track"><div id="guard-reserve" class="life-fill reserve-fill"></div><div id="guard-fill" class="life-fill"></div></div></div><div class="bar-caption"><span id="guard-label">HP 200 / 200</span><span id="enemy-title">PETARUNG</span></div><div class="fighter-mark"><b class="round-wins" id="round-wins-enemy"></b><span id="dummy-state">READY</span><i></i><i></i><i></i></div></div><div class="portrait-frame"><img id="enemy-portrait" class="portrait-image" data-portrait-side="enemy" src="assets/fenr/ui/portrait-human.webp" alt="Potret lawan"><b class="player-tag">CPU</b></div></section>
</header><div class="form-timers"><div id="player-form-timer" class="form-timer" hidden></div><div id="enemy-form-timer" class="form-timer" hidden></div></div> </header><div class="form-timers"><div id="player-form-timer" class="form-timer" hidden></div><div id="enemy-form-timer" class="form-timer" hidden></div></div>
<nav class="match-actions" aria-label="Menu arena"><button id="settings-btn" aria-label="Buka pengaturan" title="Pengaturan"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m9 3-1 3-3 1-2 5 2 5 3 1 1 3h6l1-3 3-1 2-5-2-5-3-1-1-3Z"/><circle cx="12" cy="12" r="3"/></svg></button><button id="pause-btn" aria-label="Jeda game" title="Jeda (Esc)">Ⅱ</button></nav> <nav class="match-actions" aria-label="Menu arena"><button id="settings-btn" aria-label="Buka pengaturan" title="Pengaturan"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m9 3-1 3-3 1-2 5 2 5 3 1 1 3h6l1-3 3-1 2-5-2-5-3-1-1-3Z"/><circle cx="12" cy="12" r="3"/></svg></button><button id="pause-btn" aria-label="Jeda game" title="Jeda (Esc)">Ⅱ</button></nav>
<section id="combo" class="combo-counter" aria-live="polite"><span id="combo-count">0</span><div><strong>HITS</strong><small id="combo-damage">0 DAMAGE</small></div></section><div id="announcement" class="announcement" role="status"></div><div id="combo-rank" class="combo-rank" aria-hidden="true"><b></b><small></small><i></i></div> <section id="combo" class="combo-counter" aria-live="polite"><span id="combo-count">0</span><div><strong>HITS</strong><small id="combo-damage">0 DAMAGE</small></div></section><div id="announcement" class="announcement" role="status"></div><div id="combo-rank" class="combo-rank" aria-hidden="true"><b></b><small></small><i></i></div>
<section id="round-call" hidden aria-live="polite"><small id="round-call-detail">FIRST TO TWO WINS</small><strong id="round-call-title"></strong></section><aside id="ultimate-cutin" class="ultimate-cutin" aria-hidden="true" aria-label="ARCO ultimate: Helios Squadron"><div class="cutin-circuit"></div><img class="cutin-art" src="assets/ui/ultimate-cutin.webp" alt=""><div class="cutin-caption"><span class="cutin-kicker">ARCO / AETHER COMMAND</span><strong>HELIOS<br><em>SQUADRON</em></strong><div class="cutin-detail"><span class="cutin-units"><i></i><i></i><i></i><i></i></span>ORBITAL LASER STRIKE</div></div><div class="cutin-edge"></div></aside> <section id="round-call" hidden aria-live="polite"><small id="round-call-detail">FIRST TO TWO WINS</small><strong id="round-call-title"></strong></section><aside id="ultimate-cutin" class="ultimate-cutin" aria-hidden="true" aria-label="Ultimate"><div class="cutin-circuit"></div><img class="cutin-art" src="assets/ui/ultimate-cutin.webp" alt=""><div class="cutin-caption"><span class="cutin-kicker">ULTIMATE</span><strong>ULTIMATE<br><em>MOVE</em></strong><div class="cutin-detail"><span class="cutin-units"><i></i><i></i><i></i><i></i></span>SERANGAN PAMUNGKAS</div></div><div class="cutin-edge"></div></aside>
<footer class="bottom-hud"><section class="skill-deck" aria-label="Serangan dan skill"><div class="deck-heading"><span id="deck-character">ARCO</span><i></i><b>MOVE SET</b></div><div class="skill-bar"> <footer class="bottom-hud"><section class="skill-deck" aria-label="Serangan dan skill"><div class="deck-heading"><span id="deck-character">PETARUNG</span><i></i><b>MOVE SET</b></div><div class="skill-bar">
<button class="skill-card basic" data-action="attack" aria-label="Basic attack, Spasi, 3 kombo"><span class="skill-art"><img src="assets/ui/attack.webp" alt=""><kbd>SPACE</kbd><span class="combo-dots"><i></i><i></i><i></i></span></span><span class="skill-name">IRON CHAIN</span></button> <button class="skill-card basic" data-action="attack" aria-label="Serangan dasar, Spasi, 3 kombo"><span class="skill-art"><img src="assets/ui/attack.webp" alt=""><kbd>SPACE</kbd><span class="combo-dots"><i></i><i></i><i></i></span></span><span class="skill-name">SERANGAN</span></button>
<button class="skill-card " data-action="skill1" aria-label="Aether Bolt, tombol I"><span class="skill-art"><img src="assets/ui/skill1.webp" alt=""><span class="cooldown-wipe" aria-hidden="true"></span><kbd>I</kbd></span><span class="skill-name">AETHER BOLT</span></button> <button class="skill-card " data-action="skill1" aria-label="Skill 1, tombol I"><span class="skill-art"><img src="assets/ui/skill1.webp" alt=""><span class="cooldown-wipe" aria-hidden="true"></span><kbd>I</kbd></span><span class="skill-name">SKILL 1</span></button>
<button class="skill-card " data-action="skill2" aria-label="Seismic Drive, tombol O"><span class="skill-art"><img src="assets/ui/skill2.webp" alt=""><span class="cooldown-wipe" aria-hidden="true"></span><kbd>O</kbd></span><span class="skill-name">SEISMIC DRIVE</span></button> <button class="skill-card " data-action="skill2" aria-label="Skill 2, tombol O"><span class="skill-art"><img src="assets/ui/skill2.webp" alt=""><span class="cooldown-wipe" aria-hidden="true"></span><kbd>O</kbd></span><span class="skill-name">SKILL 2</span></button>
<button class="skill-card ultimate" data-action="ultimate" aria-label="Helios Squadron, tombol P"><span class="skill-art"><img src="assets/ui/squadron-icon.webp" alt=""><span class="cooldown-wipe" aria-hidden="true"></span><kbd>P</kbd></span><span class="skill-name">HELIOS SQUADRON</span></button> <button class="skill-card ultimate" data-action="ultimate" aria-label="Ultimate, tombol P"><span class="skill-art"><img src="assets/ui/squadron-icon.webp" alt=""><span class="cooldown-wipe" aria-hidden="true"></span><kbd>P</kbd></span><span class="skill-name">ULTIMATE</span></button>
</div></section><div class="stage-sign"><span id="stage-name">BELLORA</span><small id="stage-subtitle">COURTYARD</small><i></i></div><section class="training-data" aria-label="Hasil latihan"><div><span>TOTAL DAMAGE</span><strong id="total-damage">0 <small>DMG</small></strong></div><div><span>BEST CHAIN</span><strong id="best-combo">0 <small>HITS</small></strong></div><small class="reset-hint">R · RESET<span>ESC · PAUSE</span></small></section></footer> </div></section><div class="stage-sign"><span id="stage-name">BELLORA</span><small id="stage-subtitle">COURTYARD</small><i></i></div><section class="training-data" aria-label="Hasil latihan"><div><span>TOTAL DAMAGE</span><strong id="total-damage">0 <small>DMG</small></strong></div><div><span>BEST CHAIN</span><strong id="best-combo">0 <small>HITS</small></strong></div><small class="reset-hint">R · RESET<span>ESC · PAUSE</span></small></section></footer>
<section id="touch-controls" aria-hidden="true"><div class="dpad"><button class="dbtn dbtn-up" data-key="w" tabindex="-1" aria-label="Atas / lompat"><i></i></button><button class="dbtn dbtn-left" data-key="a" tabindex="-1" aria-label="Kiri"><i></i></button><button class="dbtn dbtn-down" data-key="s" tabindex="-1" aria-label="Bawah / jongkok"><i></i></button><button class="dbtn dbtn-right" data-key="d" tabindex="-1" aria-label="Kanan"><i></i></button></div><div class="action-pad"><button class="tbtn tbtn-attack" data-key=" " data-slot="attack" tabindex="-1"><img src="assets/ui/attack.webp" alt=""></button><button class="tbtn tbtn-skill tbtn-s1" data-key="i" data-slot="skill1" tabindex="-1"><img src="assets/ui/skill1.webp" alt=""><span class="cooldown-wipe"></span><em class="cooldown-num"></em></button><button class="tbtn tbtn-skill tbtn-s2" data-key="o" data-slot="skill2" tabindex="-1"><img src="assets/ui/skill2.webp" alt=""><span class="cooldown-wipe"></span><em class="cooldown-num"></em></button><button class="tbtn tbtn-skill tbtn-ult" data-key="p" data-slot="ultimate" tabindex="-1"><img src="assets/ui/squadron-icon.webp" alt=""><span class="cooldown-wipe"></span><em class="cooldown-num"></em></button></div><div class="extra-pad"><button class="tbtn tbtn-x" data-key="cmd:uppercut" tabindex="-1">UP</button><button class="tbtn tbtn-x" data-key="cmd:lunge" tabindex="-1">→P</button><button class="tbtn tbtn-x" data-key="k" tabindex="-1">K</button><button class="tbtn tbtn-x" data-key="l" tabindex="-1">BLOK</button><button class="tbtn tbtn-x" data-key="j" tabindex="-1">LEMPAR</button></div></section> <section id="touch-controls" aria-hidden="true"><div class="dpad"><button class="dbtn dbtn-up" data-key="w" tabindex="-1" aria-label="Atas / lompat"><i></i></button><button class="dbtn dbtn-left" data-key="a" tabindex="-1" aria-label="Kiri"><i></i></button><button class="dbtn dbtn-down" data-key="s" tabindex="-1" aria-label="Bawah / jongkok"><i></i></button><button class="dbtn dbtn-right" data-key="d" tabindex="-1" aria-label="Kanan"><i></i></button></div><div class="action-pad"><button class="tbtn tbtn-attack" data-key=" " data-slot="attack" tabindex="-1"><img src="assets/ui/attack.webp" alt=""></button><button class="tbtn tbtn-skill tbtn-s1" data-key="i" data-slot="skill1" tabindex="-1"><img src="assets/ui/skill1.webp" alt=""><span class="cooldown-wipe"></span><em class="cooldown-num"></em></button><button class="tbtn tbtn-skill tbtn-s2" data-key="o" data-slot="skill2" tabindex="-1"><img src="assets/ui/skill2.webp" alt=""><span class="cooldown-wipe"></span><em class="cooldown-num"></em></button><button class="tbtn tbtn-skill tbtn-ult" data-key="p" data-slot="ultimate" tabindex="-1"><img src="assets/ui/squadron-icon.webp" alt=""><span class="cooldown-wipe"></span><em class="cooldown-num"></em></button></div><div class="extra-pad"><button class="tbtn tbtn-x" data-key="cmd:uppercut" tabindex="-1">UP</button><button class="tbtn tbtn-x" data-key="cmd:lunge" tabindex="-1">→P</button><button class="tbtn tbtn-x" data-key="k" tabindex="-1">K</button><button class="tbtn tbtn-x" data-key="l" tabindex="-1">BLOK</button><button class="tbtn tbtn-x" data-key="j" tabindex="-1">LEMPAR</button></div></section>
<div id="load-state" class="load-state"><span class="loading-glyph">✦</span><strong>KONOHA FIGHTING</strong><span id="load-message">Menyiapkan arena…</span></div> <div id="load-state" class="load-state"><span class="loading-glyph">✦</span><strong>KONOHA FIGHTING</strong><p class="load-quote"></p><span id="load-message">Menyiapkan arena…</span></div>
<dialog id="settings-panel"><div class="dialog-kicker">KONOHA FIGHTING / SETTINGS</div><h2>Pengaturan arena</h2><p>Atur audio, tampilan, dan kontrol permainan.</p><label class="setting-row training-only"><span>Karakter pemain<small>Mengganti karakter memulai ulang latihan</small></span><select id="character-select"><option value="browo">BRO WO · Human</option><option value="gufron">GUFRON · Human</option><option value="soloman">SOLO MAN · Human</option><option value="anice">A NICE · Human</option><option value="ethanolman">ETHANOL MAN · Human</option><option value="sonofsolo">SON OF SOLO MAN · Human</option><option value="megachan">MEGA CHAN · Human</option></select></label><label class="setting-row training-only"><span>Lawan aktif<small>Aktifkan serangan CPU saat latihan</small></span><input type="checkbox" id="ai-toggle" checked></label><label class="setting-row"><span>Visualisasi hitbox<small>Area tubuh dan jangkauan serangan</small></span><input type="checkbox" id="hitbox-toggle"></label><label class="setting-row"><span>Audio aktif<small>Suara karakter, benturan, dan skill</small></span><input type="checkbox" id="sound-toggle" checked></label><label class="setting-row"><span>Musik latar<small>Midday Showdown, diputar berulang di menu dan arena</small></span><input type="checkbox" id="music-toggle" checked></label><label class="volume-row"><span>Musik</span><input type="range" id="music-slider" min="0" max="100" value="5" aria-label="Volume musik"><output id="music-value">5%</output></label><label class="setting-row"><span>Animasi latar menu<small>Video loop pada main menu</small></span><input type="checkbox" id="menu-video-toggle" checked></label><label class="volume-row"><span>Suara</span><input type="range" id="volume-slider" min="0" max="100" value="45" aria-label="Volume audio"><output id="volume-value">45%</output></label><div class="dialog-buttons"><button id="sound-test">Tes audio</button><button id="fullscreen-btn">Layar penuh</button></div><details class="controls-help"><summary>Kontrol keyboard</summary><p>A / D · Gerak &nbsp; 2× A / D · Lari<br>W · Lompat (2× W = lompat ganda) &nbsp; S · Menunduk<br>SPACE · Kombo 3 pukulan &nbsp; ↓ ↓ + SPACE · Uppercut &nbsp; ↓ → + SPACE · Tinju maju<br>I / O · Skill &nbsp; P · Ultimate &nbsp; K · Jurus toko<br>L · Blok (ketuk = parry) &nbsp; J · Lempar</p></details><button class="primary-button" id="close-settings">Kembali ke arena <span>↗</span></button></dialog> <dialog id="settings-panel"><div class="dialog-kicker">KONOHA FIGHTING / SETTINGS</div><h2>Pengaturan arena</h2><p>Atur audio, tampilan, dan kontrol permainan.</p><label class="setting-row training-only"><span>Karakter pemain<small>Mengganti karakter memulai ulang latihan</small></span><select id="character-select"><option value="browo">BRO WO · Human</option><option value="gufron">GUFRON · Human</option><option value="soloman">SOLO MAN · Human</option><option value="anice">A NICE · Human</option><option value="ethanolman">ETHANOL MAN · Human</option><option value="sonofsolo">SON OF SOLO MAN · Human</option><option value="megachan">MEGA CHAN · Human</option></select></label><label class="setting-row training-only"><span>Lawan aktif<small>Aktifkan serangan CPU saat latihan</small></span><input type="checkbox" id="ai-toggle" checked></label><label class="setting-row"><span>Visualisasi hitbox<small>Area tubuh dan jangkauan serangan</small></span><input type="checkbox" id="hitbox-toggle"></label><label class="setting-row"><span>Audio aktif<small>Suara karakter, benturan, dan skill</small></span><input type="checkbox" id="sound-toggle" checked></label><label class="setting-row"><span>Musik latar<small>Midday Showdown, diputar berulang di menu dan arena</small></span><input type="checkbox" id="music-toggle" checked></label><label class="volume-row"><span>Musik</span><input type="range" id="music-slider" min="0" max="100" value="5" aria-label="Volume musik"><output id="music-value">5%</output></label><label class="setting-row"><span>Animasi latar menu<small>Gerak pelan petarung di latar main menu</small></span><input type="checkbox" id="menu-video-toggle" checked></label><label class="volume-row"><span>Suara</span><input type="range" id="volume-slider" min="0" max="100" value="45" aria-label="Volume audio"><output id="volume-value">45%</output></label><div class="dialog-buttons"><button id="sound-test">Tes audio</button><button id="fullscreen-btn">Layar penuh</button></div><details class="controls-help"><summary>Kontrol keyboard</summary><p>A / D · Gerak &nbsp; 2× A / D · Lari<br>W · Lompat (2× W = lompat ganda) &nbsp; S · Menunduk<br>SPACE · Kombo 3 pukulan &nbsp; ↓ ↓ + SPACE · Uppercut &nbsp; ↓ → + SPACE · Tinju maju<br>I / O · Skill &nbsp; P · Ultimate &nbsp; K · Jurus toko<br>L · Blok (ketuk = parry) &nbsp; J · Lempar</p></details><button class="primary-button" id="close-settings">Kembali ke arena <span>↗</span></button></dialog>
<dialog id="pause-panel"><div class="dialog-kicker">KONOHA FIGHTING / PAUSE</div><h2>PAUSED</h2><p>Pertarungan dijeda. Siap melanjutkan?</p><button class="primary-button" id="resume-btn">Lanjut bertarung <span>↗</span></button><button class="text-button" id="restart-btn">Mulai ulang</button><button class="text-button" id="back-menu">Kembali ke main menu</button></dialog> <dialog id="pause-panel"><div class="dialog-kicker">KONOHA FIGHTING / PAUSE</div><h2>PAUSED</h2><p>Pertarungan dijeda. Siap melanjutkan?</p><button class="primary-button" id="resume-btn">Lanjut bertarung <span>↗</span></button><button class="text-button" id="restart-btn">Mulai ulang</button><button class="text-button" id="back-menu">Kembali ke main menu</button></dialog>
</main><section id="front-menu" aria-label="Main menu Konoha Fighting"></section><script src="assets/mecha/manifest.js"></script><script src="assets/stage-config.js"></script><script src="assets/ui/drone-config.js"></script><script src="assets/fenr/human/manifest.js"></script><script src="assets/fenr/wolf/manifest.js"></script><script src="fenr.js"></script><script src="assets/browo/manifest.js"></script><script src="browo.js"></script><script src="assets/megachan/manifest.js"></script><script src="assets/sonofsolo/manifest.js"></script><script src="assets/ethanolman/manifest.js"></script><script src="assets/anice/manifest.js"></script><script src="assets/soloman/manifest.js"></script><script src="assets/gufron/manifest.js"></script><script src="assets/audio/announcer/manifest.js"></script><script src="announcer.js"></script><script src="match.js"></script><script src="feel.js"></script><script>window.FRONTEND_ENABLED=true;</script><script src="game.js"></script><script src="touch.js"></script><script src="menu.js"></script><script src="meta.js"></script></body></html> </main><section id="front-menu" aria-label="Main menu Konoha Fighting"></section><script src="assets/mecha/manifest.js"></script><script src="assets/stage-config.js"></script><script src="assets/ui/drone-config.js"></script><script src="assets/fenr/human/manifest.js"></script><script src="assets/fenr/wolf/manifest.js"></script><script src="fenr.js"></script><script src="assets/browo/manifest.js"></script><script src="browo.js"></script><script src="assets/megachan/manifest.js"></script><script src="assets/sonofsolo/manifest.js"></script><script src="assets/ethanolman/manifest.js"></script><script src="assets/anice/manifest.js"></script><script src="assets/soloman/manifest.js"></script><script src="assets/gufron/manifest.js"></script><script src="assets/audio/announcer/manifest.js"></script><script src="announcer.js"></script><script src="match.js"></script><script src="feel.js"></script><script>window.FRONTEND_ENABLED=true;</script><script src="game.js"></script><script src="touch.js"></script><script src="menu.js"></script><script src="meta.js"></script></body></html>

View File

@ -12,12 +12,12 @@
excellent:{label:'Excellent',speed:1.32,recovery:.12,reaction:.08,combo:3,skillEvery:1,ultimateAfter:3.5,aggression:.97,evade:.92,punish:.95,antiAir:.9,ender:1,mistakes:0,wake:.02,immunity:.9,comboCap:4} excellent:{label:'Excellent',speed:1.32,recovery:.12,reaction:.08,combo:3,skillEvery:1,ultimateAfter:3.5,aggression:.97,evade:.92,punish:.95,antiAir:.9,ender:1,mistakes:0,wake:.02,immunity:.9,comboCap:4}
}; };
const stages={ const stages={
bellora:{name:'Bellora Courtyard',short:'BELLORA',subtitle:'COURTYARD',image:'assets/stage.webp',groundY:599,tag:'The city of aether',time:'SUNNY DAYLIGHT'}, bellora:{name:'Bellora Courtyard',short:'BELLORA',subtitle:'COURTYARD',image:'assets/stage.webp',groundY:599,tag:'Pelataran kota tua',time:'SUNNY DAYLIGHT'},
sunspire:{name:'Sunspire Terrace',short:'SUNSPIRE',subtitle:'TERRACE',image:'assets/menu/sunspire.webp',groundY:599,tag:'Above the kingdom',time:'SUNNY DAYLIGHT'}, sunspire:{name:'Sunspire Terrace',short:'SUNSPIRE',subtitle:'TERRACE',image:'assets/menu/sunspire.webp',groundY:599,tag:'Teras di atas kerajaan',time:'SUNNY DAYLIGHT'},
harbor:{name:'Azure Harbor',short:'AZURE',subtitle:'HARBOR',image:'assets/menu/azure-harbor.webp',groundY:599,tag:'Where the tides meet',time:'SUNNY DAYLIGHT'}, harbor:{name:'Azure Harbor',short:'AZURE',subtitle:'HARBOR',image:'assets/menu/azure-harbor.webp',groundY:599,tag:'Tempat air pasang bertemu',time:'SUNNY DAYLIGHT'},
// groundY measured on the 1280x720 floor band (elderwood 560-655, moonrise 570-705); see docs/stage-background.md. // groundY measured on the 1280x720 floor band (elderwood 560-655, moonrise 570-705); see docs/stage-background.md.
elderwood:{name:'Elderwood Glade',short:'ELDERWOOD',subtitle:'GLADE',image:'assets/menu/elderwood.webp',groundY:606,tag:'Where the old trees whisper',time:'DAPPLED SUNLIGHT'}, elderwood:{name:'Elderwood Glade',short:'ELDERWOOD',subtitle:'GLADE',image:'assets/menu/elderwood.webp',groundY:606,tag:'Tempat pohon tua berbisik',time:'DAPPLED SUNLIGHT'},
moonrise:{name:'Moonrise Bastion',short:'MOONRISE',subtitle:'BASTION',image:'assets/menu/moonrise.webp',groundY:618,tag:'Under the full moon',time:'FULL MOON NIGHT'} moonrise:{name:'Moonrise Bastion',short:'MOONRISE',subtitle:'BASTION',image:'assets/menu/moonrise.webp',groundY:618,tag:'Di bawah bulan purnama',time:'FULL MOON NIGHT'}
}; };
function introTiming(m){const clips=window.ANNOUNCER_MANIFEST?.clips;m.fightAt=Math.max(1.05,(clips?.['round_'+m.round]?.duration||0)+.12);m.introDuration=Math.max(1.85,m.fightAt+(clips?.fight?.duration||0)+.10);} function introTiming(m){const clips=window.ANNOUNCER_MANIFEST?.clips;m.fightAt=Math.max(1.05,(clips?.['round_'+m.round]?.duration||0)+.12);m.introDuration=Math.max(1.85,m.fightAt+(clips?.fight?.duration||0)+.10);}
function create(mode='training') {const m={mode,phase:mode==='versus'?'intro':'fight',round:1,playerWins:0,enemyWins:0,seconds:90,phaseTime:0,fightCue:false,lastWinner:null,winner:null,koDuration:2.2};introTiming(m);return m;} function create(mode='training') {const m={mode,phase:mode==='versus'?'intro':'fight',round:1,playerWins:0,enemyWins:0,seconds:90,phaseTime:0,fightCue:false,lastWinner:null,winner:null,koDuration:2.2};introTiming(m);return m;}

View File

@ -1,11 +1,11 @@
/* Aether Arm front-end: arcade typography, opposing fighter art and roster panels. */ /* Front-end menu: tipografi arcade, seni petarung dan panel roster. Latar menu utama (kolase) ada di bagian Merek, paling bawah. */
#front-menu{position:fixed;inset:0;z-index:20;overflow:auto;background:#0b1b25;color:#f6eedb;font-family:Rajdhani,Arial,sans-serif;isolation:isolate} #front-menu{position:fixed;inset:0;z-index:20;overflow:auto;background:#0b1b25;color:#f6eedb;font-family:Rajdhani,Arial,sans-serif;isolation:isolate}
#front-menu[hidden]{display:none}#front-menu[data-screen=home]{overflow:hidden}#front-menu button{font-family:inherit}#front-menu button:focus-visible,#front-menu a:focus-visible{outline:2px solid #fff0c5;outline-offset:4px} #front-menu[hidden]{display:none}#front-menu[data-screen=home]{overflow:hidden}#front-menu button{font-family:inherit}#front-menu button:focus-visible,#front-menu a:focus-visible{outline:2px solid #fff0c5;outline-offset:4px}
.in-menu .combat-header,.in-menu .match-actions,.in-menu .bottom-hud,.in-menu .combo-counter,.in-menu .form-timers,.in-menu #announcement,.in-menu #ultimate-cutin{visibility:hidden} .in-menu .combat-header,.in-menu .match-actions,.in-menu .bottom-hud,.in-menu .combo-counter,.in-menu .form-timers,.in-menu #announcement,.in-menu #ultimate-cutin{visibility:hidden}
.in-menu .training-only,.in-versus .training-only{display:none}.round-wins{color:#f1d39d;letter-spacing:5px;font-size:14px}.in-versus .fighter-mark>i{display:none} .in-menu .training-only,.in-versus .training-only{display:none}.round-wins{color:#f1d39d;letter-spacing:5px;font-size:14px}.in-versus .fighter-mark>i{display:none}
#round-call{position:absolute;left:0;right:0;top:34%;z-index:8;text-align:center;pointer-events:none;text-shadow:4px 5px 0 #142733,0 12px 30px #0008}#round-call[hidden]{display:none}#round-call small{display:block;font:700 13px Rajdhani;letter-spacing:4px;color:#ffe5ae;text-shadow:0 2px 6px #102b35}#round-call strong{display:block;font:700 italic clamp(70px,11vw,145px)/1 Rajdhani;color:#fff0ce;letter-spacing:5px;animation:round-strike .28s ease-out both}#round-call[data-phase=ko] strong{color:#efad77} #round-call{position:absolute;left:0;right:0;top:34%;z-index:8;text-align:center;pointer-events:none;text-shadow:4px 5px 0 #142733,0 12px 30px #0008}#round-call[hidden]{display:none}#round-call small{display:block;font:700 13px Rajdhani;letter-spacing:4px;color:#ffe5ae;text-shadow:0 2px 6px #102b35}#round-call strong{display:block;font:700 italic clamp(70px,11vw,145px)/1 Rajdhani;color:#fff0ce;letter-spacing:5px;animation:round-strike .28s ease-out both}#round-call[data-phase=ko] strong{color:#efad77}
@keyframes round-strike{from{transform:scale(1.3) skew(-10deg);opacity:0}to{transform:scale(1) skew(-5deg);opacity:1}} @keyframes round-strike{from{transform:scale(1.3) skew(-10deg);opacity:0}to{transform:scale(1) skew(-5deg);opacity:1}}
.home-art,.selection-backdrop,.arena-screen-bg,.result-bg{position:absolute;inset:0;z-index:-2;background-size:cover;background-position:center}.home-art{background-image:linear-gradient(90deg,#061a29eb 0%,#0a2036bd 30%,#10283210 70%),url('assets/menu/home-factions.webp')}.home-art:after{content:'';position:absolute;inset:0;background:linear-gradient(0deg,#061520e6,transparent 32%,transparent 70%,#07182655)}.menu-grain{position:absolute;inset:0;z-index:-1;pointer-events:none;opacity:.15;background:repeating-linear-gradient(0deg,transparent 0 3px,#94cdb912 3px 4px)} .home-art,.selection-backdrop,.arena-screen-bg,.result-bg{position:absolute;inset:0;z-index:-2;background-size:cover;background-position:center}.menu-grain{position:absolute;inset:0;z-index:-1;pointer-events:none;opacity:.15;background:repeating-linear-gradient(0deg,transparent 0 3px,#94cdb912 3px 4px)}
.home-top{display:flex;justify-content:space-between;padding:27px 4.5%}.eyebrow,.build-tag{font-weight:700;letter-spacing:3px;font-size:11px}.eyebrow{color:#d8c394}.build-tag{font-size:10px;color:#d4e1df} .home-top{display:flex;justify-content:space-between;padding:27px 4.5%}.eyebrow,.build-tag{font-weight:700;letter-spacing:3px;font-size:11px}.eyebrow{color:#d8c394}.build-tag{font-size:10px;color:#d4e1df}
.home-content{width:42%;max-width:540px;margin:clamp(8px,4vh,42px) 0 80px 7%}.home-title>span{color:#a7d9cc;font-size:12px;font-weight:700;letter-spacing:4px}.home-title h1{font-size:clamp(64px,8.5vw,130px);line-height:.77;font-style:italic;letter-spacing:-3px;margin:21px 0 20px;position:relative;text-shadow:4px 5px 0 #05151e7a}.home-title h1 em{font-style:italic;color:#ecd7a6;letter-spacing:10px}.home-title h1 i{font-size:35px;position:absolute;top:12%;margin-left:17px;color:#86d6c7}.home-title p{color:#c3d1d3;font-size:17px;letter-spacing:2px;margin:0 0 30px} .home-content{width:42%;max-width:540px;margin:clamp(8px,4vh,42px) 0 80px 7%}.home-title>span{color:#a7d9cc;font-size:12px;font-weight:700;letter-spacing:4px}.home-title h1{font-size:clamp(64px,8.5vw,130px);line-height:.77;font-style:italic;letter-spacing:-3px;margin:21px 0 20px;position:relative;text-shadow:4px 5px 0 #05151e7a}.home-title h1 em{font-style:italic;color:#ecd7a6;letter-spacing:10px}.home-title h1 i{font-size:35px;position:absolute;top:12%;margin-left:17px;color:#86d6c7}.home-title p{color:#c3d1d3;font-size:17px;letter-spacing:2px;margin:0 0 30px}
.home-nav{display:flex;flex-direction:column;gap:8px;max-width:420px}.home-nav button{display:flex;align-items:center;gap:17px;text-align:left;background:#102b37a6;border:1px solid #7dada144;border-left:3px solid #7fa69866;padding:11px 17px 11px 15px;color:#e6e6d7;transform:skewX(-7deg);transition:background .14s,border-color .14s,transform .14s;font-weight:700}.home-nav button>*{transform:skewX(7deg)}.home-nav .nav-index{font-size:12px;color:#8eb5b6;align-self:flex-start;padding-top:7px}.home-nav button>span:nth-child(2){font-size:27px;letter-spacing:1px;flex:1;line-height:1.05}.home-nav small{display:block;font-size:11px;font-weight:500;letter-spacing:.4px;margin-top:5px;color:#a8c2c5}.home-nav b{font-size:26px;font-weight:500}.home-nav button:first-child{background:#d2b57d;color:#102734;border-color:#f1dba5;box-shadow:0 5px 20px #050d1955}.home-nav button:first-child small,.home-nav button:first-child .nav-index{color:#24444c}.home-nav button:hover,.home-nav button:focus-visible{background:#f0d49d;color:#122837;border-color:#fff0c8;transform:skewX(-7deg) translateX(8px)}.home-nav button:hover small,.home-nav button:focus-visible small{color:#2e5156} .home-nav{display:flex;flex-direction:column;gap:8px;max-width:420px}.home-nav button{display:flex;align-items:center;gap:17px;text-align:left;background:#102b37a6;border:1px solid #7dada144;border-left:3px solid #7fa69866;padding:11px 17px 11px 15px;color:#e6e6d7;transform:skewX(-7deg);transition:background .14s,border-color .14s,transform .14s;font-weight:700}.home-nav button>*{transform:skewX(7deg)}.home-nav .nav-index{font-size:12px;color:#8eb5b6;align-self:flex-start;padding-top:7px}.home-nav button>span:nth-child(2){font-size:27px;letter-spacing:1px;flex:1;line-height:1.05}.home-nav small{display:block;font-size:11px;font-weight:500;letter-spacing:.4px;margin-top:5px;color:#a8c2c5}.home-nav b{font-size:26px;font-weight:500}.home-nav button:first-child{background:#d2b57d;color:#102734;border-color:#f1dba5;box-shadow:0 5px 20px #050d1955}.home-nav button:first-child small,.home-nav button:first-child .nav-index{color:#24444c}.home-nav button:hover,.home-nav button:focus-visible{background:#f0d49d;color:#122837;border-color:#fff0c8;transform:skewX(-7deg) translateX(8px)}.home-nav button:hover small,.home-nav button:focus-visible small{color:#2e5156}
@ -28,7 +28,7 @@
.result-bg{background:radial-gradient(ellipse at 30% 60%,#6486756e,transparent 52%),linear-gradient(130deg,#163443,#0a1a26)}.result-layout{display:flex;align-items:center;justify-content:center;gap:7%;height:calc(100vh - 110px);padding:30px 7%}.result-fighter{height:min(75vh,680px);max-width:44%;object-fit:contain;filter:drop-shadow(0 20px 25px #0007)}.result-copy{width:390px;max-width:47%}.result-copy h1{font-size:clamp(48px,7vw,98px);font-style:italic;letter-spacing:1px;line-height:1;color:#edcd92;margin:12px 0}.result-copy>p{font-size:20px;letter-spacing:5px;color:#b7dacb}.final-score{display:flex;gap:25px;align-items:center;font-size:72px;font-weight:700;line-height:1.15}.final-score i{font-size:28px;font-style:normal;color:#748f95}.result-copy>small{display:block;font-size:12px;color:#98b3b7;margin:8px 0 30px}.result-copy>.menu-primary{width:100%}.result-secondary{display:block;width:100%;margin-top:10px;border:1px solid #8ba89d66;padding:11px;background:#183440;color:#cbddd4;font-size:14px;font-weight:700;letter-spacing:2px} .result-bg{background:radial-gradient(ellipse at 30% 60%,#6486756e,transparent 52%),linear-gradient(130deg,#163443,#0a1a26)}.result-layout{display:flex;align-items:center;justify-content:center;gap:7%;height:calc(100vh - 110px);padding:30px 7%}.result-fighter{height:min(75vh,680px);max-width:44%;object-fit:contain;filter:drop-shadow(0 20px 25px #0007)}.result-copy{width:390px;max-width:47%}.result-copy h1{font-size:clamp(48px,7vw,98px);font-style:italic;letter-spacing:1px;line-height:1;color:#edcd92;margin:12px 0}.result-copy>p{font-size:20px;letter-spacing:5px;color:#b7dacb}.final-score{display:flex;gap:25px;align-items:center;font-size:72px;font-weight:700;line-height:1.15}.final-score i{font-size:28px;font-style:normal;color:#748f95}.result-copy>small{display:block;font-size:12px;color:#98b3b7;margin:8px 0 30px}.result-copy>.menu-primary{width:100%}.result-secondary{display:block;width:100%;margin-top:10px;border:1px solid #8ba89d66;padding:11px;background:#183440;color:#cbddd4;font-size:14px;font-weight:700;letter-spacing:2px}
@media(min-width:1600px){.home-content{margin-top:7vh}.home-nav button{padding:15px 21px}.home-title p{margin-bottom:40px}.select-stage{grid-template-columns:1fr 440px 1fr}.roster-grid{gap:10px}.roster-tile{aspect-ratio:.94}.selection-art{height:55vh}.arena-content{margin-top:30px}} @media(min-width:1600px){.home-content{margin-top:7vh}.home-nav button{padding:15px 21px}.home-title p{margin-bottom:40px}.select-stage{grid-template-columns:1fr 440px 1fr}.roster-grid{gap:10px}.roster-tile{aspect-ratio:.94}.selection-art{height:55vh}.arena-content{margin-top:30px}}
@media(max-height:700px) and (min-width:800px){.home-content{margin-top:0;margin-bottom:50px}.home-title h1{font-size:76px;margin:14px 0}.home-title p{margin-bottom:20px}.home-nav button{padding:9px 14px}.home-nav button>span:nth-child(2){font-size:23px}.select-header{height:82px}.selection-steps{padding-top:12px}.select-stage{height:calc(100vh - 151px);grid-template-columns:1fr 300px 1fr}.roster-tile{aspect-ratio:1}.selection-art{height:43vh;bottom:104px}.fighter-copy h2{font-size:47px}.fighter-copy small{font-size:10px}.fighter-copy p{font-size:10px}.confirm-fighter{padding:10px 14px;font-size:14px}.fighter-moves{height:44px}.roster-caption{margin-bottom:8px}.arena-content{margin-top:8px;margin-bottom:50px}.arena-window{height:19vh;margin:10px 0}.arena-tiles .arena-tile{height:71px}.match-options{padding:12px 0}.menu-footer{padding:10px 3.5%}.about-panel{margin-top:20px;padding:20px 28px}.about-panel h2{font-size:40px}.about-panel>p{font-size:15px}.about-stats{margin:10px 0}} @media(max-height:700px) and (min-width:800px){.home-content{margin-top:0;margin-bottom:50px}.home-title h1{font-size:76px;margin:14px 0}.home-title p{margin-bottom:20px}.home-nav button{padding:9px 14px}.home-nav button>span:nth-child(2){font-size:23px}.select-header{height:82px}.selection-steps{padding-top:12px}.select-stage{height:calc(100vh - 151px);grid-template-columns:1fr 300px 1fr}.roster-tile{aspect-ratio:1}.selection-art{height:43vh;bottom:104px}.fighter-copy h2{font-size:47px}.fighter-copy small{font-size:10px}.fighter-copy p{font-size:10px}.confirm-fighter{padding:10px 14px;font-size:14px}.fighter-moves{height:44px}.roster-caption{margin-bottom:8px}.arena-content{margin-top:8px;margin-bottom:50px}.arena-window{height:19vh;margin:10px 0}.arena-tiles .arena-tile{height:71px}.match-options{padding:12px 0}.menu-footer{padding:10px 3.5%}.about-panel{margin-top:20px;padding:20px 28px}.about-panel h2{font-size:40px}.about-panel>p{font-size:15px}.about-stats{margin:10px 0}}
@media(max-width:800px){.home-content{width:65%;margin-left:8%;margin-top:7vh}.home-title h1{font-size:83px}.home-title>span{font-size:10px}.home-art{background-position:60% center;background-image:linear-gradient(90deg,#061a29f0,#0a2036b5 50%,#10283235),url('assets/menu/home-factions.webp')}.home-caption{display:none}.home-top{padding:20px 6%}.build-tag{display:none}.home-footer{font-size:9px}.select-header{height:89px;padding:12px 3%;gap:14px}.select-header .wordmark{display:none}.select-header h1{font-size:28px}.select-header .eyebrow{font-size:9px;letter-spacing:2px}.select-stage{height:auto;min-height:calc(100vh - 170px);grid-template-columns:1fr minmax(248px,340px) 1fr;padding:0 12px;gap:0}#player-preview,#enemy-preview{min-height:430px}.selection-art{height:32vh;max-width:210%;bottom:142px}.fighter-copy{left:0;right:-30px;bottom:42px}.enemy .fighter-copy{left:-30px;right:0}.fighter-copy h2{font-size:33px}.fighter-copy>span,.fighter-copy small{display:none}.fighter-copy p{font-size:8px;letter-spacing:1px}.side-tag{font-size:8px;left:0;top:12%;max-width:74px}.fighter-plinth.enemy .side-tag{right:0}.roster-tile{aspect-ratio:.87}.roster-caption>span{display:none}.menu-footer{font-size:8px;padding:11px 3%}.menu-footer>span:last-child{display:none}.menu-footer kbd{font-size:8px}.arena-content{width:91%;margin-top:10px}.match-options{align-items:flex-start;gap:10px}.difficulty-buttons{gap:4px}.difficulty-buttons button{min-width:60px;padding:7px;font-size:11px}.match-rules{font-size:10px}.arena-window h2{font-size:29px}.arena-day{display:none}.arena-tile span{font-size:11px}.versus-strip{gap:15px}.versus-strip>div{min-width:130px}.start-battle{width:100%}.about-panel{margin:25px auto;width:90%;padding:25px}.result-layout{gap:4%;padding:20px 4%}.result-copy{max-width:55%}.result-copy h1{font-size:55px}.result-copy .eyebrow{font-size:9px}.result-copy>p{font-size:15px}.result-fighter{max-width:40%}} @media(max-width:800px){.home-content{width:65%;margin-left:8%;margin-top:7vh}.home-title h1{font-size:83px}.home-title>span{font-size:10px}.home-caption{display:none}.home-top{padding:20px 6%}.build-tag{display:none}.home-footer{font-size:9px}.select-header{height:89px;padding:12px 3%;gap:14px}.select-header .wordmark{display:none}.select-header h1{font-size:28px}.select-header .eyebrow{font-size:9px;letter-spacing:2px}.select-stage{height:auto;min-height:calc(100vh - 170px);grid-template-columns:1fr minmax(248px,340px) 1fr;padding:0 12px;gap:0}#player-preview,#enemy-preview{min-height:430px}.selection-art{height:32vh;max-width:210%;bottom:142px}.fighter-copy{left:0;right:-30px;bottom:42px}.enemy .fighter-copy{left:-30px;right:0}.fighter-copy h2{font-size:33px}.fighter-copy>span,.fighter-copy small{display:none}.fighter-copy p{font-size:8px;letter-spacing:1px}.side-tag{font-size:8px;left:0;top:12%;max-width:74px}.fighter-plinth.enemy .side-tag{right:0}.roster-tile{aspect-ratio:.87}.roster-caption>span{display:none}.menu-footer{font-size:8px;padding:11px 3%}.menu-footer>span:last-child{display:none}.menu-footer kbd{font-size:8px}.arena-content{width:91%;margin-top:10px}.match-options{align-items:flex-start;gap:10px}.difficulty-buttons{gap:4px}.difficulty-buttons button{min-width:60px;padding:7px;font-size:11px}.match-rules{font-size:10px}.arena-window h2{font-size:29px}.arena-day{display:none}.arena-tile span{font-size:11px}.versus-strip{gap:15px}.versus-strip>div{min-width:130px}.start-battle{width:100%}.about-panel{margin:25px auto;width:90%;padding:25px}.result-layout{gap:4%;padding:20px 4%}.result-copy{max-width:55%}.result-copy h1{font-size:55px}.result-copy .eyebrow{font-size:9px}.result-copy>p{font-size:15px}.result-fighter{max-width:40%}}
@media(max-width:550px){.home-content{width:80%;margin-top:8vh}.home-title h1{font-size:77px}.home-nav button>span:nth-child(2){font-size:23px}.home-title p{font-size:14px}.home-footer{letter-spacing:1px}.home-top .eyebrow{font-size:9px;letter-spacing:2px}.select-stage{grid-template-columns:1fr 1fr;padding:0 16px;align-items:start}.roster-center{grid-column:1/-1;grid-row:2;padding:0 8px 70px}#player-preview,#enemy-preview{height:26vh;min-height:180px}.selection-art{height:22vh;bottom:3px;max-width:100%}.fighter-copy{left:0;bottom:0;right:auto}.enemy .fighter-copy{right:0;left:auto;bottom:0}.fighter-copy h2{font-size:28px;text-shadow:2px 2px #132e33}.fighter-copy p{margin:3px 0}.side-tag{top:3%;font-size:8px;max-width:none}.fighter-plinth.enemy .side-tag{right:0}.selection-steps{padding-top:13px}.roster-grid{gap:6px}.roster-tile{aspect-ratio:1.2}.roster-caption{margin:5px 0 8px}.roster-caption strong{font-size:10px}.fighter-moves{height:40px;padding-top:8px}.fighter-moves b{font-size:12px}.confirm-fighter{padding:10px 15px;margin-top:6px}.roster-notice{font-size:9px;margin-top:7px}.menu-footer{position:fixed}.arena-content{margin-bottom:65px}.arena-window{height:22vh}.arena-window h2{font-size:24px}.arena-window>div{left:12px;bottom:12px}.arena-tiles{gap:6px;grid-template-columns:repeat(3,1fr)}.arena-tile{height:73px}.arena-tile span{font-size:10px;letter-spacing:.2px}.match-options{display:block}.match-rules{text-align:left;margin-top:15px}.difficulty-buttons button{flex:1}.difficulty-buttons{width:100%}.difficulty-area .eyebrow{font-size:9px}.versus-strip strong{font-size:22px}.versus-strip img{width:39px;height:39px}.versus-strip>div{min-width:110px}.result-layout{position:relative;justify-content:flex-end}.result-fighter{position:absolute;left:-13%;opacity:.5;height:65vh;max-width:80%}.result-copy{position:relative;max-width:82%;width:82%}.result-copy h1{font-size:62px}.about-panel h2{font-size:39px}.about-fighters{display:block}.about-stats{gap:30px}} @media(max-width:550px){.home-content{width:80%;margin-top:8vh}.home-title h1{font-size:77px}.home-nav button>span:nth-child(2){font-size:23px}.home-title p{font-size:14px}.home-footer{letter-spacing:1px}.home-top .eyebrow{font-size:9px;letter-spacing:2px}.select-stage{grid-template-columns:1fr 1fr;padding:0 16px;align-items:start}.roster-center{grid-column:1/-1;grid-row:2;padding:0 8px 70px}#player-preview,#enemy-preview{height:26vh;min-height:180px}.selection-art{height:22vh;bottom:3px;max-width:100%}.fighter-copy{left:0;bottom:0;right:auto}.enemy .fighter-copy{right:0;left:auto;bottom:0}.fighter-copy h2{font-size:28px;text-shadow:2px 2px #132e33}.fighter-copy p{margin:3px 0}.side-tag{top:3%;font-size:8px;max-width:none}.fighter-plinth.enemy .side-tag{right:0}.selection-steps{padding-top:13px}.roster-grid{gap:6px}.roster-tile{aspect-ratio:1.2}.roster-caption{margin:5px 0 8px}.roster-caption strong{font-size:10px}.fighter-moves{height:40px;padding-top:8px}.fighter-moves b{font-size:12px}.confirm-fighter{padding:10px 15px;margin-top:6px}.roster-notice{font-size:9px;margin-top:7px}.menu-footer{position:fixed}.arena-content{margin-bottom:65px}.arena-window{height:22vh}.arena-window h2{font-size:24px}.arena-window>div{left:12px;bottom:12px}.arena-tiles{gap:6px;grid-template-columns:repeat(3,1fr)}.arena-tile{height:73px}.arena-tile span{font-size:10px;letter-spacing:.2px}.match-options{display:block}.match-rules{text-align:left;margin-top:15px}.difficulty-buttons button{flex:1}.difficulty-buttons{width:100%}.difficulty-area .eyebrow{font-size:9px}.versus-strip strong{font-size:22px}.versus-strip img{width:39px;height:39px}.versus-strip>div{min-width:110px}.result-layout{position:relative;justify-content:flex-end}.result-fighter{position:absolute;left:-13%;opacity:.5;height:65vh;max-width:80%}.result-copy{position:relative;max-width:82%;width:82%}.result-copy h1{font-size:62px}.about-panel h2{font-size:39px}.about-fighters{display:block}.about-stats{gap:30px}}
@media(prefers-reduced-motion:reduce){#front-menu *,#round-call strong{animation:none!important;transition:none!important}} @media(prefers-reduced-motion:reduce){#front-menu *,#round-call strong{animation:none!important;transition:none!important}}
@media(max-height:650px) and (max-width:800px){.home-content{margin-top:2vh}.home-title h1{font-size:65px;margin:14px 0}.home-title p{font-size:14px;margin-bottom:18px}.home-nav button{padding:9px 13px}.home-nav button>span:nth-child(2){font-size:22px}} @media(max-height:650px) and (max-width:800px){.home-content{margin-top:2vh}.home-title h1{font-size:65px;margin:14px 0}.home-title p{font-size:14px;margin-bottom:18px}.home-nav button{padding:9px 13px}.home-nav button>span:nth-child(2){font-size:22px}}
@ -37,13 +37,6 @@
/* Faction-wide branding, independent from individual roster members. */ /* Faction-wide branding, independent from individual roster members. */
.home-caption{font-size:clamp(16px,2.1vw,26px);letter-spacing:2px}.home-caption i{font-size:17px;margin:0 12px}.wordmark>span{letter-spacing:3px} .home-caption{font-size:clamp(16px,2.1vw,26px);letter-spacing:2px}.home-caption i{font-size:17px;margin:0 12px}.wordmark>span{letter-spacing:3px}
.roster-tile img.portrait-image{transform:skewX(4deg) scale(1.08) scaleX(var(--portrait-direction,1))} .roster-tile img.portrait-image{transform:skewX(4deg) scale(1.08) scaleX(var(--portrait-direction,1))}
/* Silent video sits behind the same readable menu scrims; poster remains until playback. */
.home-art.video-backed{background-image:url('assets/menu/home-factions.webp')}
.menu-background-video{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;object-position:center;pointer-events:none;opacity:0;transition:opacity .25s linear}
.menu-background-video.is-playing{opacity:1}
.home-art.video-backed:before{content:'';position:absolute;inset:0;z-index:1;background:linear-gradient(90deg,#061a29eb 0%,#0a2036bd 30%,#10283210 70%);pointer-events:none}
.home-art.video-backed:after{z-index:2;pointer-events:none}
@media(max-width:800px){.menu-background-video{object-position:60% center}.home-art.video-backed:before{background:linear-gradient(90deg,#061a29f0,#0a2036b5 50%,#10283235)}}
html.touch .menu-footer>span:first-child{visibility:hidden} html.touch .menu-footer>span:first-child{visibility:hidden}
/* Phone landscape (about 375-420 px tall): the four home buttons, title and footer must all fit. */ /* Phone landscape (about 375-420 px tall): the four home buttons, title and footer must all fit. */
@media(max-height:420px){.home-top{padding:9px 4.5%}.home-title h1{font-size:46px;margin:8px 0 10px;letter-spacing:-2px}.home-title>span{font-size:8px;letter-spacing:3px}.home-nav{gap:5px;max-width:340px}.home-nav button{padding:4px 12px}.home-nav button>span:nth-child(2){font-size:16px}.home-caption{font-size:18px;bottom:16%}.home-footer{bottom:6px;font-size:8px}} @media(max-height:420px){.home-top{padding:9px 4.5%}.home-title h1{font-size:46px;margin:8px 0 10px;letter-spacing:-2px}.home-title>span{font-size:8px;letter-spacing:3px}.home-nav{gap:5px;max-width:340px}.home-nav button{padding:4px 12px}.home-nav button>span:nth-child(2){font-size:16px}.home-caption{font-size:18px;bottom:16%}.home-footer{bottom:6px;font-size:8px}}
@ -71,3 +64,67 @@ html.touch .home-top{padding-right:calc(4.5% + 50px)}
@keyframes pick-tile{0%{filter:brightness(1.6)}50%,100%{filter:none}} @keyframes pick-tile{0%{filter:brightness(1.6)}50%,100%{filter:none}}
@keyframes pick-art{0%{filter:brightness(1.6) drop-shadow(0 16px 16px #020d18a6)}50%,100%{filter:drop-shadow(0 16px 16px #020d18a6)}} @keyframes pick-art{0%{filter:brightness(1.6) drop-shadow(0 16px 16px #020d18a6)}50%,100%{filter:drop-shadow(0 16px 16px #020d18a6)}}
@media (prefers-reduced-motion:reduce){.roster-tile.picked,#player-preview.picked .selection-art,#enemy-preview.picked .selection-art{animation:none;filter:brightness(1.35)}} @media (prefers-reduced-motion:reduce){.roster-tile.picked,#player-preview.picked .selection-art,#enemy-preview.picked .selection-art{animation:none;filter:brightness(1.35)}}
/* ===== Merek (RENCANA 2.5, 2.7, 2.8) =====
Latar menu utama dan About: kolase potret tujuh petarung (assets/menu/<id>-select.webp, disusun menu.js collageHtml) dengan
lapisan miring, cahaya, dan gerak lambat. Hanya CSS dan gambar yang sudah ada. Gerak berhenti lewat .bg-still (Pengaturan,
tab tersembunyi) dan prefers-reduced-motion. */
.home-art{overflow:hidden;background:linear-gradient(112deg,#071b29 0%,#0c2c3a 44%,#2a2f36 100%)}
.hc-sky{position:absolute;inset:0;background:radial-gradient(ellipse 44% 78% at 70% 62%,#f3c97c4d,transparent 70%),radial-gradient(ellipse 52% 46% at 92% 6%,#6ad3c23d,transparent 72%),radial-gradient(ellipse 40% 50% at 52% 100%,#d6282833,transparent 70%)}
.hc-stripes{position:absolute;inset:-10% -30%;opacity:.55;transform:skewX(-22deg);background:repeating-linear-gradient(90deg,transparent 0 54px,#f1d39d12 54px 58px,transparent 58px 112px,#86d6c70e 112px 114px);animation:hc-slide 48s linear infinite;will-change:transform}
.hc-dots{position:absolute;inset:0;opacity:.5;background:radial-gradient(#ffffff1c 1.2px,transparent 1.6px) 0 0/16px 16px;-webkit-mask-image:linear-gradient(100deg,transparent 40%,#000 78%);mask-image:linear-gradient(100deg,transparent 40%,#000 78%)}
.hc-row{position:absolute;left:0;right:0;top:0;bottom:0}
.hc-slot{--h:calc(88vh - var(--a) * 7vh);position:absolute;margin:0;bottom:2%;left:calc(70% + var(--p) * 10.2%);width:calc(var(--h) * .768);height:var(--h);margin-left:calc(var(--h) * -.384);z-index:calc(10 - var(--a) * 2);animation:hc-bob 7s ease-in-out infinite alternate;animation-delay:calc(var(--k) * -1.3s);will-change:transform}
.hc-beam{position:absolute;left:50%;bottom:-2%;width:74%;height:112%;margin-left:-37%;transform:skewX(-13deg);background:linear-gradient(0deg,var(--cA),var(--cB) 82%);border-left:2px solid var(--cA);opacity:calc(1 - var(--a) * .1)}
.hc-slot:before{content:'';position:absolute;left:2%;right:2%;bottom:-1.2%;height:5.5%;background:radial-gradient(ellipse at center,#000c,transparent 70%)}
.hc-fighter{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;object-position:50% 100%;transform:scaleX(var(--f,1));filter:brightness(calc(1 - var(--a) * .1)) drop-shadow(0 12px 14px #020d18c0);-webkit-user-drag:none;user-select:none}
.hc-floor{position:absolute;left:0;right:0;bottom:0;height:17%;background:linear-gradient(0deg,#03101a,#03101a00)}
.home-art:after{content:'';position:absolute;inset:0;z-index:30;pointer-events:none;background:linear-gradient(90deg,#061a29f5 0%,#071d2dd9 26%,#0a203666 46%,transparent 64%),linear-gradient(0deg,#061520d9,transparent 26%,transparent 80%,#07182666)}
#front-menu.bg-still .home-art *{animation-play-state:paused}
@keyframes hc-bob{from{transform:translateY(0)}to{transform:translateY(-1.8%)}}
@keyframes hc-slide{to{transform:skewX(-22deg) translateX(-114px)}}
@media(max-width:800px){.hc-slot{left:calc(76% + var(--p) * 9.5%);--h:calc(80vh - var(--a) * 8vh)}}
@media(max-height:420px){.hc-slot{--h:calc(84vh - var(--a) * 8vh)}}
/* Semboyan di atas kolase: pelat gelap supaya tetap terbaca di atas kaki petarung. */
.home-caption{right:4.5%;bottom:34px;padding:8px 18px 7px;background:linear-gradient(90deg,#071827d9,#071827a6);border-left:3px solid #d7b87d;z-index:40}
@media(max-height:420px){.home-caption{bottom:20px;padding:5px 12px 4px}.home-caption small{font-size:8px;letter-spacing:2px;margin-top:2px}}
/* Kutipan layar loading (#boot dan #load-state). */
.boot-quote,.load-quote{margin:0 auto;max-width:34ch;text-align:center;font-style:italic;font-size:15px;line-height:1.35;color:#d9e7e1}
.boot-quote{margin-top:16px;min-height:2.7em}.boot-quote:empty,.load-quote:empty{display:none}
.load-quote{color:#b9cfd0}
/* Layar About: panel kredit dan panel "Yang baru" berdampingan; menumpuk di layar sempit. */
#front-menu[data-screen=about]{display:grid;grid-template-columns:minmax(16px,1fr) minmax(0,640px) minmax(0,420px) minmax(16px,1fr);align-content:start;padding-bottom:16px}
#front-menu[data-screen=about]>.select-header{grid-column:1/-1}
#front-menu[data-screen=about]>.about-panel{grid-column:2;width:auto;margin:26px 0 0;align-self:start}
.about-link{color:#e8d19c;text-decoration:underline;text-underline-offset:2px}.about-link:hover,.about-link:focus-visible{color:#fff0c5}
.whatsnew-panel{grid-column:3;margin:26px 0 0 26px;padding:22px 24px 14px;background:#112935dc;border-left:3px solid #86d6c7;display:flex;flex-direction:column;min-height:0;max-height:calc(100vh - 150px);align-self:start}
.wn-head h2{font-size:30px;line-height:1;margin:8px 0 12px;letter-spacing:.5px}
.wn-scroll{overflow:auto;min-height:0;padding-right:8px;margin-right:-6px;scrollbar-width:thin;scrollbar-color:#6f9a9a #0b1d29}.wn-scroll:focus-visible{outline:2px solid #fff0c5;outline-offset:2px}
.wn-entry{padding:0 0 14px;margin:0 0 14px;border-bottom:1px solid #678c8644}.wn-entry:last-child{border-bottom:0;margin-bottom:0}
.wn-entry h3{display:flex;align-items:baseline;gap:10px;margin:0;font-size:20px;letter-spacing:1px;color:#f3e5bd}.wn-entry h3 small{font-size:12px;font-weight:500;letter-spacing:1px;color:#9fb8bc}
.wn-badge{font-style:normal;font-size:10px;font-weight:700;letter-spacing:2px;color:#10252f;background:#ffd66b;padding:2px 6px;transform:skewX(-8deg)}
.wn-title{margin:3px 0 6px;font-size:14px;font-weight:700;letter-spacing:.5px;color:#a6dccf}
.wn-entry ul{margin:0;padding-left:18px}.wn-entry li{font-size:14px;line-height:1.38;color:#c6d6d4;margin:0 0 4px}
.wn-entry.latest h3 b{color:#ffe29a}
@media(max-width:1100px){#front-menu[data-screen=about]{grid-template-columns:16px minmax(0,1fr) 16px;row-gap:16px}#front-menu[data-screen=about]>.about-panel,.whatsnew-panel{grid-column:2}.whatsnew-panel{margin:0 0 34px;max-height:none}.wn-scroll{overflow:visible}#front-menu[data-screen=about]>.about-panel{margin-top:20px}}
@media(max-height:640px) and (min-width:1101px){
#front-menu[data-screen=about]>.about-panel{padding:16px 24px;margin-top:16px}.about-panel h2{font-size:32px;margin:6px 0 4px}.about-panel h2 br{display:none}.about-panel h2 br+em{margin-left:.28em}.about-panel>p{font-size:14px;line-height:1.35;margin:6px 0 8px}
.about-fighters{gap:20px;padding-top:8px}.about-fighters span{font-size:12px;margin-top:3px}.about-stats{gap:28px;margin:10px 0}.about-panel>small{font-size:11px;line-height:1.35}.about-panel .menu-primary{padding:8px 16px;font-size:14px;margin-top:8px}
.whatsnew-panel{margin-top:16px;padding:16px 20px 10px;max-height:calc(100vh - 100px)}.wn-head h2{font-size:24px;margin:4px 0 8px}
}
@media(max-height:420px){.wn-entry li{font-size:12px}.wn-head h2{font-size:20px}.whatsnew-panel{max-height:calc(100vh - 62px)}}
/* Toast "Ada yang baru": sekali per versi, muncul di menu utama. */
.brand-toast{position:fixed;left:50%;bottom:18px;z-index:95;transform:translateX(-50%);display:flex;align-items:center;gap:12px;max-width:min(560px,calc(100vw - 32px));padding:9px 10px 9px 16px;background:#101923f2;border:1px solid #e8b84a;border-left-width:4px;border-radius:6px;color:#f6eedb;font:600 15px/1.25 Rajdhani,Arial,sans-serif;box-shadow:0 8px 24px #0009;animation:bt-in .35s ease-out both;transition:opacity .4s,transform .4s}
.brand-toast.out{opacity:0;transform:translateX(-50%) translateY(10px)}
.bt-text{display:flex;flex-direction:column;min-width:0}.bt-text b{color:#ffd66b;letter-spacing:2px;font-size:12px}
.bt-look,.bt-close{font:700 14px Rajdhani,Arial,sans-serif;cursor:pointer;touch-action:manipulation}
.bt-look{padding:6px 14px;background:#e0bf85;color:#10252f;border:1px solid #ffe8b4;letter-spacing:1px}.bt-look:hover,.bt-look:focus-visible{background:#ffe4b0}
.bt-close{padding:6px 9px;background:transparent;color:#bcd0d0;border:1px solid #8cadb14d}.bt-close:hover,.bt-close:focus-visible{color:#fff;border-color:#fff8}
@keyframes bt-in{from{opacity:0;transform:translateX(-50%) translateY(14px)}to{opacity:1;transform:translateX(-50%)}}
@media(max-height:420px){.brand-toast{bottom:8px;font-size:13px;padding:6px 8px 6px 12px}}
@media(prefers-reduced-motion:reduce){.hc-stripes,.hc-slot,.brand-toast{animation:none!important}.brand-toast{opacity:1}}

45
menu.js
View File

@ -30,42 +30,35 @@
const ids=Object.keys(fighters); const ids=Object.keys(fighters);
let p2=false,runKind=null,screen='home',step=0,mode='versus',player=ids[0],enemy=ids[1]||ids[0],hover=ids[0],stage='bellora',level='medium',result=null,interacted=false,lastSound=0; let p2=false,runKind=null,screen='home',step=0,mode='versus',player=ids[0],enemy=ids[1]||ids[0],hover=ids[0],stage='bellora',level='medium',result=null,interacted=false,lastSound=0;
const reducedMotion=window.matchMedia?.('(prefers-reduced-motion: reduce)'); const reducedMotion=window.matchMedia?.('(prefers-reduced-motion: reduce)');
let backgroundEnabled=!reducedMotion?.matches,backgroundVideo=null,videoPlayToken=0; // Latar menu utama dan About: kolase potret petarung (CSS, tanpa gambar baru; gaya di bagian Merek menu.css). Geraknya berhenti
// (.bg-still) bila dimatikan di Pengaturan, saat Pengaturan terbuka, saat tab tersembunyi, atau bila pemain meminta gerak dikurangi.
let backgroundEnabled=!reducedMotion?.matches;
const backgroundToggle=document.querySelector('#menu-video-toggle'); const backgroundToggle=document.querySelector('#menu-video-toggle');
function shouldAnimate(){return backgroundEnabled && screen==='home' && !root.hidden && !document.hidden && !game.snapshot().settingsOpen;} function shouldAnimate(){return backgroundEnabled && !root.hidden && !document.hidden && !game.snapshot().settingsOpen;}
function syncBackground(){ function syncBackground(){root.classList.toggle('bg-still',!shouldAnimate());}
if(!backgroundVideo)return;const token=++videoPlayToken; // Susunan kiri ke kanan: petarung pertama di tengah, berikutnya bergantian ke kanan dan kiri; sisi kiri dicerminkan agar menghadap tengah.
if(!shouldAnimate()){backgroundVideo.pause();return;} // Layar sentuh memakai paling banyak lima (hemat memori gambar besar).
if(backgroundVideo.paused){const p=backgroundVideo.play();p?.catch(()=>{if(token===videoPlayToken)backgroundVideo.dataset.state='autoplay-blocked';});} function collageHtml(){
const list=Object.keys(fighters).filter(id=>fighters[id].art).slice(0,touch?5:7),row=[];
list.forEach((id,i)=>{if(i%2===1||i===0)row.push(id);else row.unshift(id);});
return '<div class="hc-sky"></div><div class="hc-stripes"></div><div class="hc-dots"></div><div class="hc-row">'+row.map((id,k)=>{
const f=fighters[id],pos=k-(row.length-1)/2,c=/^#[0-9a-f]{6}$/i.test(f.color||'')?f.color:'#7fb8c8';
return `<figure class="hc-slot" style="--c:${c};--cA:${c}b0;--cB:${c}00;--p:${pos};--a:${Math.abs(pos)};--k:${k};--f:${pos<0?-1:1}"><i class="hc-beam"></i><img class="hc-fighter" src="${f.art}" alt="" decoding="async" draggable="false"></figure>`;}).join('')+'</div><div class="hc-floor"></div>';
} }
function attachBackground(){ function attachBackground(){const art=root.querySelector('.home-art');if(art&&!art.firstChild)art.innerHTML=collageHtml();art?.setAttribute('aria-hidden','true');syncBackground();}
if(screen!=='home')return; if(backgroundToggle){backgroundToggle.checked=backgroundEnabled;backgroundToggle.onchange=e=>{backgroundEnabled=e.target.checked;syncBackground();};}
if(!backgroundVideo && backgroundEnabled){
backgroundVideo=document.createElement('video');backgroundVideo.id='menu-background-video';backgroundVideo.className='menu-background-video';
backgroundVideo.muted=true;backgroundVideo.defaultMuted=true;backgroundVideo.autoplay=true;backgroundVideo.loop=true;backgroundVideo.playsInline=true;backgroundVideo.preload='auto';
backgroundVideo.setAttribute('muted','');backgroundVideo.setAttribute('playsinline','');backgroundVideo.setAttribute('aria-hidden','true');backgroundVideo.tabIndex=-1;backgroundVideo.disablePictureInPicture=true;
backgroundVideo.poster='assets/menu/home-factions.webp';backgroundVideo.dataset.state='loading';
backgroundVideo.addEventListener('playing',()=>{if(!shouldAnimate()){backgroundVideo.pause();return;}backgroundVideo.classList.add('is-playing');backgroundVideo.dataset.state='playing';});
backgroundVideo.addEventListener('error',()=>{backgroundVideo.classList.remove('is-playing');backgroundVideo.dataset.state='poster-fallback';});
backgroundVideo.src='assets/menu/home-factions-loop.mp4';
}
if(backgroundVideo)root.querySelector('.home-art')?.prepend(backgroundVideo);
syncBackground();
}
if(backgroundToggle){backgroundToggle.checked=backgroundEnabled;backgroundToggle.onchange=e=>{backgroundEnabled=e.target.checked;attachBackground();syncBackground();};}
document.addEventListener('visibilitychange',syncBackground); document.addEventListener('visibilitychange',syncBackground);
function sfx(kind='move',explicit=false){if(!interacted&&!explicit)return;interacted=true;const now=performance.now();if(kind==='move'&&now-lastSound<75)return;lastSound=now;game.menuSound(kind);} function sfx(kind='move',explicit=false){if(!interacted&&!explicit)return;interacted=true;const now=performance.now();if(kind==='move'&&now-lastSound<75)return;lastSound=now;game.menuSound(kind);}
root.addEventListener('pointerdown',()=>{interacted=true;},true); root.addEventListener('pointerdown',()=>{interacted=true;},true);
const brand='<span class="wordmark">KONOHA<span>FIGHTING</span><i>✦</i></span>'; const brand='<span class="wordmark">KONOHA<span>FIGHTING</span><i>✦</i></span>',versionText=()=>window.Brand?.version?'VERSI '+window.Brand.version:'';
const footer=()=>'<footer class="menu-footer"><span><kbd>W A S D</kbd> / <kbd>↑ ↓ ← →</kbd> SELECT &nbsp; <kbd>ENTER</kbd> CONFIRM &nbsp; <kbd>ESC</kbd> BACK</span><span>LOCAL PLAY <i></i> BUILD 02</span></footer>'; const footer=()=>'<footer class="menu-footer"><span><kbd>W A S D</kbd> / <kbd>↑ ↓ ← →</kbd> SELECT &nbsp; <kbd>ENTER</kbd> CONFIRM &nbsp; <kbd>ESC</kbd> BACK</span><span>LOCAL PLAY <i></i> '+(versionText()||'BUILD 02')+'</span></footer>';
function navHeader(title,subtitle){return `<header class="select-header"><button class="menu-back" data-cmd="back" aria-label="Kembali">← <span>BACK</span></button><div><span class="eyebrow">${subtitle}</span><h1>${title}</h1></div>${brand}</header>`;} function navHeader(title,subtitle){return `<header class="select-header"><button class="menu-back" data-cmd="back" aria-label="Kembali">← <span>BACK</span></button><div><span class="eyebrow">${subtitle}</span><h1>${title}</h1></div>${brand}</header>`;}
function fighterPanel(id,side){const f=info(id);return `<div class="fighter-plinth ${side} ${f?'':'unknown'} ${soon(id)?'soon':''}"><div class="plinth-light"></div><span class="side-tag">${side==='player'?'P1 / YOUR FIGHTER':'CPU / YOUR RIVAL'}</span><img class="selection-art" src="${f?.art||'assets/menu/locked.svg'}" alt="${f?.name||'Karakter belum dipilih'}"><div class="fighter-copy"><span>${f?.tag||'AWAITING CHALLENGER'}</span><h2>${f?.name||'???'}</h2><p>${f?.race||'ROSTER EXPANDING'}</p><small>${f?.detail||'A new challenger is on the way.'}</small></div></div>`;} function fighterPanel(id,side){const f=info(id);return `<div class="fighter-plinth ${side} ${f?'':'unknown'} ${soon(id)?'soon':''}"><div class="plinth-light"></div><span class="side-tag">${side==='player'?'P1 / YOUR FIGHTER':'CPU / YOUR RIVAL'}</span><img class="selection-art" src="${f?.art||'assets/menu/locked.svg'}" alt="${f?.name||'Karakter belum dipilih'}"><div class="fighter-copy"><span>${f?.tag||'AWAITING CHALLENGER'}</span><h2>${f?.name||'???'}</h2><p>${f?.race||'ROSTER EXPANDING'}</p><small>${f?.detail||'A new challenger is on the way.'}</small></div></div>`;}
function render(){ function render(){
if(lockIn){clearTimeout(lockIn);lockIn=null;}for(const t of lockTicks)clearTimeout(t);lockTicks=[]; if(lockIn){clearTimeout(lockIn);lockIn=null;}for(const t of lockTicks)clearTimeout(t);lockTicks=[];
if(backgroundVideo){videoPlayToken++;backgroundVideo.pause();backgroundVideo.remove();}
root.dataset.screen=screen;root.dataset.step=String(step);root.dataset.mode=mode; root.dataset.screen=screen;root.dataset.step=String(step);root.dataset.mode=mode;
if(screen==='home'){ if(screen==='home'){
root.innerHTML=`<div class="home-art video-backed"></div><div class="menu-grain"></div><header class="home-top"><span></span><span class="home-top-right"><span class="build-tag">PARODY FIGHTER / VOL. 03</span>${canFullscreen?`<button class="menu-fullscreen${fullscreenHint?' hinted':''}" data-cmd="fullscreen" aria-label="Layar penuh" title="Layar penuh"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/></svg></button>`:''}</span></header><div class="home-content"><div class="home-title"><h1>KONOHA<br><em>FIGHTING</em><i>✦</i></h1><p>Parodi tokoh publik. Satu arena.</p></div><nav class="home-nav" aria-label="Main menu"><button data-cmd="versus"><span class="nav-index">01</span><span>VS COMPUTER<small>Choose your rival. Claim the arena.</small></span><b>↗</b></button><button data-cmd="training"><span class="nav-index">02</span><span>TRAINING<small>Learn your fighter. Refine your combo.</small></span><b>↗</b></button><div class="home-row"><button data-cmd="arcade"><span>ARCADE</span></button><button data-cmd="survival"><span>SURVIVAL</span></button><button data-cmd="2p"><span>2 PEMAIN</span></button></div><button data-cmd="shop" class="meta-nav"><span class="nav-index">03</span><span>TOKO<small>Beli jurus dan skin dengan koin.</small></span><b>◆</b></button><button data-cmd="locker" class="meta-nav"><span class="nav-index">04</span><span>KARAKTER<small>Atur skin, jurus toko, daftar gerakan.</small></span><b>↗</b></button><div class="home-row"><button data-cmd="book" class="meta-nav"><span>KOLEKSI</span><b>★</b></button><button data-cmd="settings"><span>PENGATURAN</span></button><button data-cmd="about"><span>ABOUT</span></button></div></nav></div><div class="home-caption"><span>BRO WO</span><i>VS</i><span>SEMUANYA</span><small>PARODI · SATIRE · JANGAN DIANGGAP SERIUS</small></div><footer class="home-footer"><span></span><span id="menu-ready">${game.snapshot().ready?'READY TO FIGHT':'PREPARING ARENA…'}</span></footer>`; root.innerHTML=`<div class="home-art"></div><div class="menu-grain"></div><header class="home-top"><span></span><span class="home-top-right"><span class="build-tag">GAME PARODI${versionText()?' / '+versionText():''}</span>${canFullscreen?`<button class="menu-fullscreen${fullscreenHint?' hinted':''}" data-cmd="fullscreen" aria-label="Layar penuh" title="Layar penuh"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/></svg></button>`:''}</span></header><div class="home-content"><div class="home-title"><h1>KONOHA<br><em>FIGHTING</em><i>✦</i></h1><p>Parodi tokoh publik. Satu arena.</p></div><nav class="home-nav" aria-label="Main menu"><button data-cmd="versus"><span class="nav-index">01</span><span>VS COMPUTER<small>Choose your rival. Claim the arena.</small></span><b>↗</b></button><button data-cmd="training"><span class="nav-index">02</span><span>TRAINING<small>Learn your fighter. Refine your combo.</small></span><b>↗</b></button><div class="home-row"><button data-cmd="arcade"><span>ARCADE</span></button><button data-cmd="survival"><span>SURVIVAL</span></button><button data-cmd="2p"><span>2 PEMAIN</span></button></div><button data-cmd="shop" class="meta-nav"><span class="nav-index">03</span><span>TOKO<small>Beli jurus dan skin dengan koin.</small></span><b>◆</b></button><button data-cmd="locker" class="meta-nav"><span class="nav-index">04</span><span>KARAKTER<small>Atur skin, jurus toko, daftar gerakan.</small></span><b>↗</b></button><div class="home-row"><button data-cmd="book" class="meta-nav"><span>KOLEKSI</span><b>★</b></button><button data-cmd="settings"><span>PENGATURAN</span></button><button data-cmd="about"><span>ABOUT</span></button></div></nav></div><div class="home-caption"><span>BRO WO</span><i>VS</i><span>SEMUANYA</span><small>PARODI · SATIRE · JANGAN DIANGGAP SERIUS</small></div><footer class="home-footer"><span></span><span id="menu-ready">${game.snapshot().ready?'READY TO FIGHT':'PREPARING ARENA…'}</span></footer>`;
} else if(screen==='select'){ } else if(screen==='select'){
root.innerHTML=`<div class="selection-backdrop"></div>${navHeader(step===0?'SELECT YOUR FIGHTER':'SELECT YOUR RIVAL',mode==='versus'?(runKind?runKind.toUpperCase():p2?'DUA PEMAIN':'VERSUS COMPUTER'):'TRAINING ROOM')}<div class="selection-steps"><span class="${step===0?'current':'done'}">01 <b>PLAYER</b></span><i></i><span class="${step===1?'current':''}">02 <b>RIVAL</b></span><i></i><span>03 <b>ARENA</b></span></div><div class="select-stage"><div id="player-preview">${fighterPanel(step===0?hover:player,'player')}</div><div class="roster-center"><div class="roster-caption"><strong>${step===0?'PLAYER 1':'COMPUTER'}</strong><span>CHOOSE A FIGHTER</span></div><div class="roster-grid" role="group" aria-label="Roster karakter">${roster.map((id,i)=>{const f=info(id);return `<button class="roster-tile ${id===hover?'highlight':''} ${!f?'locked':''} ${soon(id)?'soon':''}" data-fighter="${id}" aria-label="${f?f.name+' — '+f.race+(soon(id)?', belum bisa dimainkan':''):'Slot '+(i+1)+' terkunci'}" aria-disabled="${!fighters[id]}"><img class="${f?'portrait-image':''}" data-portrait-side="${step===1?'enemy':'player'}" src="${f?.portrait||'assets/menu/locked.svg'}" alt=""><span>${f?.name||'LOCKED'}</span><b>${step===1&&id===player?'P1':''}</b></button>`;}).join('')}</div><div id="fighter-moves" class="fighter-moves"></div><button class="menu-primary confirm-fighter" data-cmd="confirm">${step===0?'CONFIRM FIGHTER':'CONFIRM RIVAL'} <b>→</b></button><p id="roster-notice" class="roster-notice" aria-live="polite">${roster.length>opened.length?(roster.length-opened.length)+' challenger slots reserved.':''}</p></div><div id="enemy-preview">${fighterPanel(step===1?hover:null,'enemy')}</div></div>${footer()}`; root.innerHTML=`<div class="selection-backdrop"></div>${navHeader(step===0?'SELECT YOUR FIGHTER':'SELECT YOUR RIVAL',mode==='versus'?(runKind?runKind.toUpperCase():p2?'DUA PEMAIN':'VERSUS COMPUTER'):'TRAINING ROOM')}<div class="selection-steps"><span class="${step===0?'current':'done'}">01 <b>PLAYER</b></span><i></i><span class="${step===1?'current':''}">02 <b>RIVAL</b></span><i></i><span>03 <b>ARENA</b></span></div><div class="select-stage"><div id="player-preview">${fighterPanel(step===0?hover:player,'player')}</div><div class="roster-center"><div class="roster-caption"><strong>${step===0?'PLAYER 1':'COMPUTER'}</strong><span>CHOOSE A FIGHTER</span></div><div class="roster-grid" role="group" aria-label="Roster karakter">${roster.map((id,i)=>{const f=info(id);return `<button class="roster-tile ${id===hover?'highlight':''} ${!f?'locked':''} ${soon(id)?'soon':''}" data-fighter="${id}" aria-label="${f?f.name+' — '+f.race+(soon(id)?', belum bisa dimainkan':''):'Slot '+(i+1)+' terkunci'}" aria-disabled="${!fighters[id]}"><img class="${f?'portrait-image':''}" data-portrait-side="${step===1?'enemy':'player'}" src="${f?.portrait||'assets/menu/locked.svg'}" alt=""><span>${f?.name||'LOCKED'}</span><b>${step===1&&id===player?'P1':''}</b></button>`;}).join('')}</div><div id="fighter-moves" class="fighter-moves"></div><button class="menu-primary confirm-fighter" data-cmd="confirm">${step===0?'CONFIRM FIGHTER':'CONFIRM RIVAL'} <b>→</b></button><p id="roster-notice" class="roster-notice" aria-live="polite">${roster.length>opened.length?(roster.length-opened.length)+' challenger slots reserved.':''}</p></div><div id="enemy-preview">${fighterPanel(step===1?hover:null,'enemy')}</div></div>${footer()}`;
updatePreview(false); updatePreview(false);
@ -76,7 +69,7 @@
} else if(screen==='result'){ } else if(screen==='result'){
const won=result.winner==='player',winner=won?result.player:result.enemy;root.innerHTML=`<div class="result-bg"></div>${navHeader('MATCH COMPLETE',MatchRules.stages[result.stage].name.toUpperCase())}<div class="result-layout"><img class="result-fighter" src="${fighters[winner].art}" alt="${fighters[winner].name}"><div class="result-copy"><span class="eyebrow">${won?'PLAYER 1':'COMPUTER'} TAKES THE MATCH</span><h1>${won?'VICTORY':'DEFEAT'}</h1><p>${fighters[winner].name} WINS</p><div class="final-score"><span>${result.playerWins}</span><i>—</i><span>${result.enemyWins}</span></div><small>${fighters[result.player].name} vs ${fighters[result.enemy].name} · ${MatchRules.difficulties[result.level].label}</small><button class="menu-primary" data-cmd="rematch">REMATCH <b>↗</b></button><button class="result-secondary" data-cmd="reselect">CHARACTER SELECT</button><button class="result-secondary" data-cmd="home">MAIN MENU</button></div></div>`; const won=result.winner==='player',winner=won?result.player:result.enemy;root.innerHTML=`<div class="result-bg"></div>${navHeader('MATCH COMPLETE',MatchRules.stages[result.stage].name.toUpperCase())}<div class="result-layout"><img class="result-fighter" src="${fighters[winner].art}" alt="${fighters[winner].name}"><div class="result-copy"><span class="eyebrow">${won?'PLAYER 1':'COMPUTER'} TAKES THE MATCH</span><h1>${won?'VICTORY':'DEFEAT'}</h1><p>${fighters[winner].name} WINS</p><div class="final-score"><span>${result.playerWins}</span><i>—</i><span>${result.enemyWins}</span></div><small>${fighters[result.player].name} vs ${fighters[result.enemy].name} · ${MatchRules.difficulties[result.level].label}</small><button class="menu-primary" data-cmd="rematch">REMATCH <b>↗</b></button><button class="result-secondary" data-cmd="reselect">CHARACTER SELECT</button><button class="result-secondary" data-cmd="home">MAIN MENU</button></div></div>`;
} }
bind();attachBackground(); bind();attachBackground();window.Brand?.decorate(root,screen);
} }
function updatePreview(play=true){ function updatePreview(play=true){
root.querySelectorAll('[data-fighter]').forEach(b=>b.classList.toggle('highlight',b.dataset.fighter===hover)); root.querySelectorAll('[data-fighter]').forEach(b=>b.classList.toggle('highlight',b.dataset.fighter===hover));

2
sw.js
View File

@ -1,4 +1,4 @@
/* AETHER CLASH service worker. preload.js fills the 'aether-assets' cache (with content hashes) on each visit, so this /* Konoha Fighting service worker (based on AETHER CLASH by Bang Tutorial). preload.js fills the 'aether-assets' cache (with content hashes) on each visit, so this
worker only has to answer from it: worker only has to answer from it:
- pictures and fonts: cache first (instant screens, works offline); - pictures and fonts: cache first (instant screens, works offline);
- pages, CSS and JS: network first, so a new deploy shows up at once; the cache is the offline fallback; - pages, CSS and JS: network first, so a new deploy shows up at once; the cache is the offline fallback;

261
tests/t2-brand.mjs Normal file
View File

@ -0,0 +1,261 @@
// Tahap 2 (brand): sisa AETHER CLASH, kutipan layar loading (2.7), daftar "Yang baru" (2.8), latar menu kolase (2.5).
// node tests/harness.mjs tests/t2-brand.mjs
// BRAND_SHOTS=folder simpan screenshot (menu, About, toast, boot) untuk dilihat
// BRAND_ONLY=regex hanya jalankan bagian yang namanya cocok (text|quote|log|menu|flow|docs)
// Jendela 1280x520 dipakai sengaja: tampilan harus muat di tinggi 520 px.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
export const options = { viewport: { width: 1280, height: 520 } };
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
export default async ({ page, game, url, errors }) => {
const checks = [], ok = (name, pass, info) => checks.push({ name, pass: !!pass, info });
const only = process.env.BRAND_ONLY ? new RegExp(process.env.BRAND_ONLY) : null, want = n => !only || only.test(n);
const shots = process.env.BRAND_SHOTS; if (shots) fs.mkdirSync(shots, { recursive: true });
const shot = async (p, name) => { if (shots) await p.screenshot({ path: path.join(shots, name + '.png') }); };
const wait = ms => new Promise(r => setTimeout(r, ms));
const BAD = /aether|mecha|demi-?human|\barco\b|\bfenr\b|helios|wolf ranger|seismic/i;
const read = f => fs.readFileSync(path.join(ROOT, f), 'utf8');
const reload = async (p, query = '?touch=0') => { await p.goto(url + query); await p.waitForFunction(() => window.__game && window.__game.snapshot().ready && window.FrontEnd && document.querySelector('#front-menu[data-screen]'), null, { timeout: 60000 }); };
const setStore = (p, obj) => p.evaluate(o => { localStorage.clear(); for (const [k, v] of Object.entries(o)) if (v != null) localStorage.setItem(k, v); }, obj);
// teks dan atribut (aria-label, alt, title) yang benar-benar tampil atau terbaca pembaca layar; dialog (Pengaturan, Jeda) ikut dihitung
// walau sedang tertutup. HUD yang sedang disembunyikan menu (visibility:hidden) tidak ikut: isinya diisi ulang JS saat pertandingan mulai.
const textDump = p => p.evaluate(() => {
const out = [], w = document.createTreeWalker(document.documentElement, NodeFilter.SHOW_ELEMENT);
for (let n = w.currentNode; n; n = w.nextNode()) {
if (/^(SCRIPT|STYLE|NOSCRIPT|HEAD|TITLE|META|LINK)$/.test(n.tagName)) continue;
if (!(n.checkVisibility({ visibilityProperty: true }) || n.closest('dialog'))) continue;
for (const a of ['aria-label', 'alt', 'title', 'placeholder']) if (n.getAttribute(a)) out.push(`[${n.tagName.toLowerCase()} ${a}] ${n.getAttribute(a)}`);
for (const c of n.childNodes) if (c.nodeType === 3 && c.textContent.trim()) out.push(c.textContent.trim());
}
out.push('[title] ' + document.title);
return out;
});
const hits = lines => lines.filter(t => BAD.test(t));
// ---------- 1. sisa AETHER CLASH tidak terlihat pemain ----------
if (want('text')) {
await game(() => window.FrontEnd.home());
const home = await textDump(page); const homeHits = hits(home);
ok('teks: halaman dasar (index.html, HUD, dialog) bersih dari AETHER/ARCO/FENR/Mecha/Demi-Human', homeHits.length === 0, homeHits);
const screens = {};
await game(() => document.querySelector('[data-cmd="versus"]').click()); screens.pilihPemain = hits(await textDump(page));
await game(() => document.querySelector('[data-cmd="back"]').click());
await game(() => document.querySelector('[data-cmd="training"]').click());
await page.click('[data-fighter="browo"]'); await wait(1100); await page.click('[data-fighter="megachan"]'); await wait(1100);
screens.arena = hits(await textDump(page));
const tags = await game(() => Object.values(window.MatchRules.stages).map(s => s.tag));
ok('teks: semua tag arena berbahasa Indonesia tanpa "aether"', tags.every(t => t && !BAD.test(t) && !/^(The|Above|Where|Under)\b/.test(t)), tags);
await game(() => window.FrontEnd.home());
await game(() => document.querySelector('[data-cmd="about"]').click());
const about = await textDump(page);
const credit = about.filter(t => BAD.test(t)), allCredit = about.join(' ');
ok('teks: About memuat kredit AETHER CLASH oleh Bang Tutorial (satu-satunya yang boleh menyebut nama itu)',
/AETHER CLASH oleh Bang Tutorial/.test(allCredit) && credit.every(t => /AETHER CLASH|Bang Tutorial/.test(t)), credit);
ok('teks: About memuat tautan repo asli dan tutorial', await game(() => [...document.querySelectorAll('.about-panel a')].map(a => a.href)).then(h => h.some(x => /github\.com\/bangtutorial\/aether-clash/.test(x)) && h.some(x => /youtube\.com/.test(x))));
await game(() => window.FrontEnd.home());
// HUD pertandingan sungguhan: nama petarung asli, tanpa sisa placeholder lama
await game(() => { const g = window.__game; g.startMatch({ player: 'sonofsolo', enemy: 'anice', stage: 'bellora', level: 'easy', mode: 'versus' }); g.manual = true; g.advance(.5); g.draw(); });
const hud = await textDump(page); screens.hudPertandingan = hits(hud);
ok('teks: HUD pertandingan menampilkan nama dan jurus asli (SON OF SOLO MAN vs A NICE)', hud.includes('SON OF SOLO MAN') && hud.includes('A NICE'), hud.filter(t => /SOLO|NICE/.test(t)));
await game(() => { window.__game.manual = false; window.FrontEnd.home(); });
// layar hasil dan overlay Meta
await game(() => window.FrontEnd.showResult({ winner: 'player', playerWins: 2, enemyWins: 1, player: 'browo', enemy: 'megachan', stage: 'bellora', level: 'hard' })); screens.hasil = hits(await textDump(page));
await game(() => window.FrontEnd.home());
for (const tab of ['shop', 'locker', 'book']) { await game(t => window.Meta.open(t), tab); screens['meta-' + tab] = hits(await textDump(page)); await game(() => window.Meta.close()); }
await game(() => window.FrontEnd.home());
ok('teks: layar pilih, arena, hasil, Toko, Karakter, Koleksi bersih', Object.values(screens).every(a => a.length === 0), screens);
ok('teks: di jendela 520 px tidak ada galat halaman selama menjelajah layar', errors.length === 0, errors);
// teks sumber yang dibaca pemain (di luar About)
const src = ['index.html', 'mobile.html', 'menu.js'].map(f => [f, read(f)]);
const srcHits = [];
for (const [f, t] of src) {
const stripped = t.replace(/(src|href)="[^"]*"/g, '').replace(/assets\/[\w./-]+/g, '').replace(/aether\.fullscreenHint|AETHER_PRELOAD|aetherFullscreen|aetherActivate|aether-activate|aether-fullscreen/g, '').replace(/Berbasis AETHER CLASH oleh Bang Tutorial/g, '').replace(/AETHER CLASH/g, m => (f === 'menu.js' ? '' : m));
for (const m of stripped.matchAll(new RegExp(BAD.source, 'gi'))) srcHits.push(f + ': ' + stripped.slice(Math.max(0, m.index - 30), m.index + 30).replace(/\s+/g, ' '));
}
ok('teks: sumber index.html, mobile.html, menu.js tanpa sisa nama lama (selain jalur aset dan kunci internal)', srcHits.length === 0, srcHits.slice(0, 8));
}
// ---------- 2. kutipan layar loading ----------
if (want('quote')) {
const q = await game(() => window.LOADING_QUOTES);
const evil = /tuhan|allah|agama|surga|neraka|kafir|haram|gereja|masjid|ulama|pendeta|korupsi|tuduh|bodoh|goblok|tolol|bego|babi|anjing|mati\b/i;
ok('quote: data LOADING_QUOTES ada, minimal 20 kutipan unik', Array.isArray(q) && q.length >= 20 && new Set(q).size === q.length, q?.length);
ok('quote: tiap kutipan teks pendek (<=90 huruf) dan lolos pedoman (tanpa agama/tuduhan/hinaan)', q.every(s => typeof s === 'string' && s.length > 10 && s.length <= 90 && !evil.test(s)), q.filter(s => s.length > 90 || evil.test(s)));
// kartu boot di halaman baru: dipaksa ?quote=N, dan acak tanpa pengulangan berturut-turut
const browser = page.context().browser(), ctx = await browser.newContext({ viewport: { width: 1280, height: 520 }, serviceWorkers: 'block' });
const p2 = await ctx.newPage(), errs2 = []; p2.on('pageerror', e => errs2.push(e.message));
await p2.route('**/assets/**', async r => { await wait(250); r.continue(); });
await p2.goto(url + '?touch=0&quote=5', { waitUntil: 'domcontentloaded' }); await wait(700);
const boot = await p2.evaluate(() => ({ boot: document.querySelector('#boot .boot-quote')?.textContent, load: document.querySelector('#load-state .load-quote')?.textContent, vis: !!document.getElementById('boot') }));
ok('quote: kartu boot dan #load-state menampilkan kutipan ke-5', boot.boot === q[5] && boot.load === q[5], boot);
await shot(p2, 'boot');
await p2.close();
const seen = [];
for (let i = 0; i < 9; i++) {
const p3 = await ctx.newPage(); await p3.route('**/assets/**', r => r.abort()); await p3.goto(url + '?touch=0', { waitUntil: 'domcontentloaded' });
seen.push(await p3.evaluate(() => window.Brand.quote)); await p3.close();
}
ok('quote: tiap pemuatan menampilkan kutipan dari daftar', seen.every(s => q.includes(s)), seen);
ok('quote: dua pemuatan berturut-turut tidak pernah kutipan sama', seen.every((s, i) => i === 0 || s !== seen[i - 1]), seen);
ok('quote: kutipan berganti (lebih dari 3 jenis dalam 9 pemuatan)', new Set(seen).size > 3, [...new Set(seen)].length);
await ctx.close();
// localStorage menolak akses: tetap tampil, tanpa galat
const ctx2 = await browser.newContext({ viewport: { width: 1280, height: 520 }, serviceWorkers: 'block' });
await ctx2.addInitScript(() => { const boom = () => { throw new Error('storage blocked'); }; Storage.prototype.getItem = boom; Storage.prototype.setItem = boom; });
const p4 = await ctx2.newPage(), errs4 = []; p4.on('pageerror', e => errs4.push(e.message));
await p4.goto(url + '?touch=0'); await p4.waitForFunction(() => window.__game && window.__game.snapshot().ready, null, { timeout: 60000 });
ok('quote: storage diblokir, game tetap siap tanpa galat dan kutipan tetap ada', errs4.length === 0 && !!(await p4.evaluate(() => window.Brand.quote)), errs4);
await ctx2.close();
// content/loading.js gagal dimuat: boot tidak rusak
const ctx3 = await browser.newContext({ viewport: { width: 1280, height: 520 }, serviceWorkers: 'block' });
const p5 = await ctx3.newPage(), errs5 = []; p5.on('pageerror', e => errs5.push(e.message));
await p5.route('**/content/loading.js', r => r.abort());
await p5.goto(url + '?touch=0'); await p5.waitForFunction(() => window.__game && window.__game.snapshot().ready, null, { timeout: 60000 });
ok('quote: tanpa content/loading.js game tetap siap, tanpa galat', errs5.length === 0, errs5);
await ctx3.close();
}
// ---------- 3. daftar Yang baru ----------
if (want('log')) {
const cl = await game(() => window.CHANGELOG);
const iso = /^\d{4}-\d{2}-\d{2}$/, ver = v => String(v).split('.').map(Number);
const desc = cl.every((e, i) => i === 0 || (ver(cl[i - 1].version) > ver(e.version) || (ver(cl[i - 1].version)[0] === ver(e.version)[0] && ver(cl[i - 1].version)[1] > ver(e.version)[1])));
ok('log: CHANGELOG berisi >= 3 versi, tiap versi punya tanggal ISO, judul, dan item berbahasa Indonesia', cl.length >= 3 && cl.every(e => e.version && iso.test(e.date) && e.title && Array.isArray(e.items) && e.items.length && e.items.every(s => typeof s === 'string' && s.length > 15)), cl.map(e => e.version));
ok('log: versi menurun (terbaru paling atas) dan tanggal tidak naik', desc && cl.every((e, i) => i === 0 || cl[i - 1].date >= e.date), cl.map(e => e.version + ' ' + e.date));
const all = JSON.stringify(cl).toLowerCase();
ok('log: mencakup fitur yang sudah ada (efek jurus, rasa kena, blok/parry/lempar, Arcade/Survival/dua pemain, toko)', ['efek jurus', 'rasa kena', 'blok', 'parry', 'lempar', 'arcade', 'survival', 'dua pemain', 'toko'].every(k => all.includes(k)), null);
const latest = cl[0].version;
const ver0 = await game(() => window.Brand.version);
ok('log: Brand.version = versi teratas', ver0 === latest, ver0);
// pemain baru: tanpa simpanan dan tanpa catatan versi -> tanpa toast, versi langsung dicatat
await setStore(page, {}); await reload(page); await wait(1700);
let st = await game(() => ({ toast: !!document.querySelector('.brand-toast'), seen: localStorage.getItem('konoha.changelog.seen') }));
ok('log: pemain baru tidak diberi toast, versi tercatat', !st.toast && st.seen === latest, st);
// pemain lama (ada simpanan) tanpa catatan versi -> toast sekali
await setStore(page, { 'konoha.save.v1': JSON.stringify({ v: 1, coins: 50, earned: 50 }) }); await reload(page);
await page.waitForSelector('.brand-toast', { timeout: 5000 }).catch(() => {});
st = await game(() => ({ toast: document.querySelector('.brand-toast')?.innerText, seen: localStorage.getItem('konoha.changelog.seen'), ab: document.querySelector('.brand-toast')?.getAttribute('role') }));
ok('log: pemain lama melihat toast "Ada yang baru" memuat nomor versi', /Ada yang baru/.test(st.toast || '') && (st.toast || '').includes(latest) && st.ab === 'status', st);
ok('log: versi dicatat begitu toast tampil (satu kali)', st.seen === latest, st);
await shot(page, 'toast-home');
// reload: tidak muncul lagi
await reload(page); await wait(1700);
ok('log: setelah dimuat ulang toast tidak muncul lagi', !(await game(() => !!document.querySelector('.brand-toast'))), null);
// versi berubah: lama 0.2 -> toast + lencana BARU di About untuk versi yang lebih baru saja
await setStore(page, { 'konoha.save.v1': JSON.stringify({ v: 1, coins: 5, earned: 5 }), 'konoha.changelog.seen': '0.2' }); await reload(page);
await page.waitForSelector('.brand-toast', { timeout: 5000 }).catch(() => {});
ok('log: versi berubah (0.2 -> terbaru) memunculkan toast', await game(() => !!document.querySelector('.brand-toast')), null);
await page.click('.brand-toast .bt-look');
await page.waitForSelector('#front-menu[data-screen="about"] .whatsnew-panel', { timeout: 4000 });
const ab = await game(() => ({ entries: document.querySelectorAll('.wn-entry').length, badges: [...document.querySelectorAll('.wn-entry')].filter(e => e.querySelector('.wn-badge')).map(e => e.querySelector('h3 b').textContent), first: document.querySelector('.wn-entry h3 b').textContent, scroll: !!document.querySelector('.wn-scroll'), label: document.querySelector('.whatsnew-panel').getAttribute('aria-label') }));
ok('log: tombol "Lihat" membuka About dengan panel Yang baru (satu entri per versi, terbaru di atas)', ab.entries === cl.length && ab.first === 'v' + latest && ab.label === 'Yang baru', ab);
const expectBadges = cl.filter(e => ver(e.version)[1] > 2).map(e => 'v' + e.version);
ok('log: lencana BARU hanya pada versi yang belum dilihat', JSON.stringify(ab.badges) === JSON.stringify(expectBadges), { got: ab.badges, expectBadges });
await shot(page, 'about-changelog');
const fit = await game(() => { const m = document.querySelector('#front-menu'), p = document.querySelector('.whatsnew-panel').getBoundingClientRect(), a = document.querySelector('.about-panel').getBoundingClientRect(); return { panelBottom: Math.round(p.bottom), aboutBottom: Math.round(a.bottom), h: innerHeight, scrollH: m.scrollHeight, clientH: m.clientHeight }; });
ok('log: di tinggi 520 px panel About dan Yang baru muat di layar (tanpa menggulung halaman)', fit.panelBottom <= fit.h && fit.aboutBottom <= fit.h && fit.scrollH <= fit.clientH + 1, fit);
// tidak ada toast ganda saat kembali ke menu utama
await game(() => window.FrontEnd.home()); await wait(1500);
ok('log: kembali ke home tidak memunculkan toast lagi', !(await game(() => !!document.querySelector('.brand-toast'))), null);
// data hilang: tidak ada galat
const ctx = await page.context().browser().newContext({ viewport: { width: 1280, height: 520 }, serviceWorkers: 'block' });
const p6 = await ctx.newPage(), errs6 = []; p6.on('pageerror', e => errs6.push(e.message));
await p6.route('**/content/changelog.js', r => r.abort());
await p6.goto(url + '?touch=0'); await p6.waitForFunction(() => window.__game && window.__game.snapshot().ready && document.querySelector('#front-menu[data-screen]'), null, { timeout: 60000 });
await p6.evaluate(() => document.querySelector('[data-cmd="about"]').click()); await wait(300);
ok('log: tanpa content/changelog.js About tetap tampil tanpa panel dan tanpa galat', errs6.length === 0 && (await p6.evaluate(() => !document.querySelector('.whatsnew-panel') && !!document.querySelector('.about-panel'))), errs6);
await ctx.close();
await setStore(page, {}); await reload(page);
}
// ---------- 4. latar menu kolase ----------
if (want('menu')) {
await reload(page); await game(() => window.FrontEnd.home()); await wait(500);
const m = await game(() => {
const art = document.querySelector('.home-art'), slots = [...art.querySelectorAll('.hc-slot img')].map(i => i.getAttribute('src'));
const cs = getComputedStyle(art), r = art.querySelector('.hc-slot'), vid = document.querySelectorAll('#front-menu video').length;
return { slots, bg: cs.backgroundImage, vid, aria: art.getAttribute('aria-hidden'), anim: getComputedStyle(r).animationName, play: getComputedStyle(r).animationPlayState, imgsLoaded: [...art.querySelectorAll('img')].every(i => i.complete && i.naturalWidth > 0), ids: Object.keys(window.FIGHTERS || {}) };
});
ok('menu: kolase memuat tujuh petarung dari assets/menu/<id>-select.webp', m.slots.length === 7 && m.slots.every(s => /^assets\/menu\/[a-z]+-select\.webp$/.test(s)) && new Set(m.slots).size === 7 && m.imgsLoaded, m);
ok('menu: tanpa video dan tanpa gambar latar bergaya game asal', m.vid === 0 && !/home-factions|home-art/.test(m.bg), { vid: m.vid, bg: m.bg });
ok('menu: kolase disembunyikan dari pembaca layar dan bergerak (animasi aktif)', m.aria === 'true' && m.anim !== 'none' && m.play === 'running', m);
await shot(page, 'home');
const tag = await game(() => ({ build: document.querySelector('.build-tag').textContent, version: window.Brand.version }));
ok('menu: tag di pojok menu utama menampilkan versi game (bukan teks lama VOL. 03)', tag.build.includes('VERSI ' + tag.version) && !/VOL\. 03|PARODY FIGHTER/.test(tag.build), tag);
const fitHome = await game(() => { const n = document.querySelector('.home-nav').getBoundingClientRect(), f = document.querySelector('.home-footer').getBoundingClientRect(); return { navBottom: Math.round(n.bottom), footerTop: Math.round(f.top), h: innerHeight }; });
ok('menu: di tinggi 520 px semua tombol menu utama muat di layar', fitHome.navBottom <= fitHome.h, fitHome);
// matikan lewat Pengaturan: gerak berhenti, nyalakan lagi: jalan
await game(() => document.querySelector('[data-cmd="settings"]').click()); await wait(200);
let still = await game(() => ({ cls: document.querySelector('#front-menu').classList.contains('bg-still'), play: getComputedStyle(document.querySelector('.hc-slot')).animationPlayState }));
ok('menu: saat Pengaturan terbuka gerak latar berhenti', still.cls && still.play === 'paused', still);
await page.click('#close-settings'); await wait(150);
still = await game(() => ({ cls: document.querySelector('#front-menu').classList.contains('bg-still'), play: getComputedStyle(document.querySelector('.hc-slot')).animationPlayState }));
ok('menu: setelah Pengaturan ditutup gerak latar jalan lagi', !still.cls && still.play === 'running', still);
await game(() => document.querySelector('[data-cmd="settings"]').click()); await wait(150);
await game(() => { const t = document.querySelector('#menu-video-toggle'); t.checked = false; t.dispatchEvent(new Event('change')); });
await page.click('#close-settings'); await wait(150);
still = await game(() => ({ cls: document.querySelector('#front-menu').classList.contains('bg-still'), play: getComputedStyle(document.querySelector('.hc-slot')).animationPlayState }));
ok('menu: saklar "Animasi latar menu" mematikan gerak', still.cls && still.play === 'paused', still);
await game(() => document.querySelector('[data-cmd="settings"]').click()); await wait(150);
await game(() => { const t = document.querySelector('#menu-video-toggle'); t.checked = true; t.dispatchEvent(new Event('change')); });
await page.click('#close-settings'); await wait(150);
ok('menu: saklar dinyalakan lagi, gerak jalan', !(await game(() => document.querySelector('#front-menu').classList.contains('bg-still'))), null);
const label = await game(() => document.querySelector('#menu-video-toggle').closest('label').innerText);
ok('menu: label saklar tidak lagi menyebut video', !/video/i.test(label), label);
// prefers-reduced-motion: tidak ada animasi, saklar mati
const ctx = await page.context().browser().newContext({ viewport: { width: 1280, height: 520 }, serviceWorkers: 'block', reducedMotion: 'reduce' });
const p7 = await ctx.newPage(), errs7 = []; p7.on('pageerror', e => errs7.push(e.message));
await p7.goto(url + '?touch=0'); await p7.waitForFunction(() => window.__game && window.__game.snapshot().ready && document.querySelector('.home-art .hc-slot'), null, { timeout: 60000 }); await wait(300);
const rm = await p7.evaluate(() => ({ anim: getComputedStyle(document.querySelector('.hc-slot')).animationName, stripes: getComputedStyle(document.querySelector('.hc-stripes')).animationName, still: document.querySelector('#front-menu').classList.contains('bg-still') }));
ok('menu: prefers-reduced-motion mematikan semua gerak latar', rm.anim === 'none' && rm.stripes === 'none' && rm.still && errs7.length === 0, { rm, errs7 });
await ctx.close();
// layar sentuh: paling banyak lima petarung (hemat memori gambar)
const ctx2 = await page.context().browser().newContext({ viewport: { width: 812, height: 375 }, serviceWorkers: 'block', hasTouch: true, isMobile: true });
const p8 = await ctx2.newPage();
await p8.goto(url + '?touch=1&embed=1'); await p8.waitForFunction(() => window.__game && window.__game.snapshot().ready && document.querySelector('.home-art .hc-slot'), null, { timeout: 60000 });
const nt = await p8.evaluate(() => ({ n: document.querySelectorAll('.hc-slot').length, touch: document.documentElement.classList.contains('touch') }));
ok('menu: layar sentuh memuat lima petarung di kolase', nt.touch && nt.n === 5, nt);
await shot(p8, 'home-phone');
await ctx2.close();
// arena di bawah layar menu tidak ikut: kembali dari About ke home membangun ulang kolase
await game(() => document.querySelector('[data-cmd="about"]').click()); await wait(200);
const ab = await game(() => document.querySelectorAll('.home-art .hc-slot').length);
ok('menu: About juga memakai kolase (tanpa gambar bawaan lama)', ab === 7, ab);
}
// ---------- 5. alur permainan tetap jalan ----------
if (want('flow')) {
await reload(page); await game(() => window.FrontEnd.home());
await page.click('[data-cmd="versus"]'); await page.click('[data-fighter="browo"]'); await wait(1100); await page.click('[data-fighter="gufron"]'); await wait(1100);
await page.click('[data-cmd="start"]'); await wait(300);
const s = await game(() => { const g = window.__game, sn = g.snapshot(); g.manual = true; g.advance(4); return { menuOpen: sn.menuOpen, mode: sn.match?.mode, hero: Math.round(g.hero.hp), hud: [document.querySelector('#player-name').textContent, document.querySelector('#enemy-name').textContent, document.querySelector('.skill-name').textContent] }; });
ok('flow: VS Komputer lewat menu: HUD memakai nama petarung sebenarnya (bukan placeholder)', !s.menuOpen && s.mode === 'versus' && s.hud[0] === 'BRO WO' && s.hud[1] === 'GUFRON' && !/SERANGAN|LAWAN|PEMAIN/.test(s.hud[0] + s.hud[1]) && s.hud[2] === 'TINJU SAFARI', s);
for (const mode of ['training', 'versus']) {
const r = await game(({ mode }) => { const g = window.__game; g.startMatch({ player: 'megachan', enemy: 'sonofsolo', stage: 'moonrise', level: 'hard', mode }); g.setAI(true); g.manual = true; for (let i = 0; i < 40; i++) { g.advance(.25); if (i % 4 === 0) { g.press('o'); g.release('o'); } } g.draw(); return { hp: Math.round(g.hero.hp), name: document.querySelector('#enemy-name').textContent }; }, { mode });
ok(`flow: ${mode} berjalan 10 detik tanpa galat`, r.name === 'SON OF SOLO MAN', r);
}
await game(() => { window.__game.manual = false; window.FrontEnd.home(); });
await game(() => window.Meta?.startRun('arcade', 'browo')); await wait(600);
ok('flow: Arcade dimulai dari menu', await game(() => window.__game.snapshot().menuOpen === false), null);
await game(() => { window.Meta.cancelRun(); window.FrontEnd.home(); });
await game(() => { const g = window.__game; g.setP2(false); });
}
// ---------- 6. dokumen ----------
if (want('docs')) {
const readme = read('README.md'), deploy = read('DEPLOY.md');
ok('docs: README tetap mengkredit AETHER CLASH karya Bang Tutorial (dengan tautan repo asli dan lisensi)', /AETHER CLASH/.test(readme) && /Bang Tutorial/.test(readme) && /github\.com\/bangtutorial\/aether-clash/.test(readme) && /CC BY-NC/.test(readme) && /MIT/.test(readme), null);
ok('docs: README tidak lagi mengaku sebagai "Mecha vs Demi-Human" / dua belas petarung asal', !/Mecha vs Demi-Human|12 playable|Twelve chibi|\bARCO\b|\bFENR\b/.test(readme), readme.match(/Mecha vs Demi-Human|12 playable|Twelve chibi|\bARCO\b|\bFENR\b/));
ok('docs: DEPLOY.md bertajuk Konoha Fighting', /^# .*Konoha Fighting/m.test(deploy) && !/^# Deploying AETHER CLASH/m.test(deploy), deploy.split('\n')[0]);
ok('docs: LICENSE-ASSETS.md tetap memuat kredit', /AETHER CLASH by Bang Tutorial/.test(read('LICENSE-ASSETS.md')), null);
}
const failed = checks.filter(c => !c.pass);
return { passed: checks.length - failed.length, failed: failed.length, failures: failed, all: checks.map(c => (c.pass ? 'OK ' : 'FAIL ') + c.name) };
};

View File

@ -3,10 +3,12 @@ import os,json,hashlib,subprocess
os.chdir(os.path.join(os.path.dirname(os.path.abspath(__file__)),'..')) os.chdir(os.path.join(os.path.dirname(os.path.abspath(__file__)),'..'))
# every top-level page, style and script (new files like feel.js/meta.js are picked up automatically) # every top-level page, style and script (new files like feel.js/meta.js are picked up automatically)
root=sorted(f for f in os.listdir('.') if f.endswith(('.html','.css','.js')) and f not in ('sw.js','precache.js')) root=sorted(f for f in os.listdir('.') if f.endswith(('.html','.css','.js')) and f not in ('sw.js','precache.js'))
# data files under content/ (kutipan, dst.) are runtime files too
content=sorted('content/'+f for f in os.listdir('content') if f.endswith(('.js','.json'))) if os.path.isdir('content') else []
tracked=subprocess.run(['git','ls-files','assets'],capture_output=True,text=True).stdout.split('\n') tracked=subprocess.run(['git','ls-files','assets'],capture_output=True,text=True).stdout.split('\n')
others=subprocess.run(['git','ls-files','--others','--exclude-standard','assets'],capture_output=True,text=True).stdout.split('\n') others=subprocess.run(['git','ls-files','--others','--exclude-standard','assets'],capture_output=True,text=True).stdout.split('\n')
assets=sorted({f for f in tracked+others if f and os.path.isfile(f)}) assets=sorted({f for f in tracked+others if f and os.path.isfile(f)})
files=[f for f in root+assets if os.path.isfile(f)] files=[f for f in root+content+assets if os.path.isfile(f)]
entries=[] entries=[]
for f in files: for f in files:
b=open(f,'rb').read(); entries.append([f,len(b),hashlib.sha1(b).hexdigest()[:12]]) b=open(f,'rb').read(); entries.append([f,len(b),hashlib.sha1(b).hexdigest()[:12]])