commit a0d1ffd393b9c78916f2f343bb0481896f16a6ee Author: rizkyaz0 Date: Thu Oct 1 10:11:31 2026 +0700 Initial commit: base game dari AETHER CLASH (Bang Tutorial), MIT + CC BY-NC 4.0 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..13ae3e4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# OS +.DS_Store +Thumbs.db +desktop.ini + +# Editors +.vscode/ +.idea/ +*.swp + +# Tooling +node_modules/ +*.log diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..6979950 --- /dev/null +++ b/.htaccess @@ -0,0 +1,20 @@ +# AETHER CLASH (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. + + AddType text/javascript .js + AddType image/webp .webp + AddType audio/mpeg .mp3 + AddType video/mp4 .mp4 + AddType font/ttf .ttf + AddType application/json .json + AddType image/svg+xml .svg + + + + Header set Cache-Control "no-cache" + + +# Text only: MP3/MP4 must stay uncompressed so range requests (206) keep working. + + AddOutputFilterByType DEFLATE text/html text/css text/javascript application/javascript application/json image/svg+xml + diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..9e03e12 --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,140 @@ +# Deploying AETHER CLASH + +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. + +The examples below install it in a subfolder, `https://example.com/aether-clash/`. For a domain root, drop the `/aether-clash` prefix. + +## 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. +- **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). +- The service worker's scope is limited to its own folder (`/aether-clash/`), so it never touches the rest of the site. + +## 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`: + +```bash +DOCROOT=/var/www/example.com # your site's document root +sudo mkdir -p "$DOCROOT/aether-clash" +sudo rsync -a --delete \ + --exclude .git --exclude README.md --exclude DEPLOY.md --exclude 'LICENSE*' --exclude docs --exclude guide \ + ./ "$DOCROOT/aether-clash/" +``` + +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 +OWNER=$(stat -c '%U:%G' "$DOCROOT") +sudo chown -R "$OWNER" "$DOCROOT/aether-clash" +sudo find "$DOCROOT/aether-clash" -type d -exec chmod 755 {} \; +sudo find "$DOCROOT/aether-clash" -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. + +## 3. Configure the web server + +Aim for: + +1. `/aether-clash` → 301 → `/aether-clash/` +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. +4. MP3/MP4 served with range requests (206), which is the default for static files, and not gzipped. + +### nginx + +Inside the site's HTTPS `server { … }` block: + +```nginx +location = /aether-clash { return 301 /aether-clash/; } +location ^~ /aether-clash/ { + # If the files are NOT inside the document root, point to them (keep the trailing slash): + # alias /var/www/aether-clash/; + try_files $uri $uri/ =404; + add_header Cache-Control "no-cache" always; + gzip on; + gzip_types text/css application/javascript text/javascript application/json image/svg+xml; +} +``` + +`^~` keeps other rules (image caching, PHP) from taking over this path. Recent nginx versions already map `webp`, `mp3`, `mp4` and `js` in `/etc/nginx/mime.types`. If `font/ttf` is missing, add `font/ttf ttf;` to that file; don't use a `types { }` block inside the location, because it replaces every other MIME type. Then reload: + +```bash +sudo nginx -t && sudo systemctl reload nginx +``` + +### Apache / LiteSpeed + +The included `.htaccess` sets the MIME types, `no-cache` and compression. It needs `AllowOverride` to allow at least `FileInfo` for the document root, plus `mod_mime`, `mod_headers` and `mod_deflate`: + +```bash +sudo a2enmod headers mime deflate +sudo systemctl reload apache2 +``` + +`mod_dir` adds the trailing-slash redirect for the folder automatically. + +### Caddy + +```caddy +redir /aether-clash /aether-clash/ 301 +header /aether-clash/* Cache-Control "no-cache" +``` + +### GitHub Pages, Netlify and similar hosts + +Publish the repository as it is. These hosts already send correct MIME types and add the trailing-slash redirect for folders. + +## 4. Check the deployment + +```bash +BASE=https://example.com/aether-clash +curl -sI "$BASE" | grep -iE "^HTTP|^location" # 301 -> /aether-clash/ +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/assets/stage.webp" | grep -iE "^HTTP|content-type" # 200 image/webp +curl -s -o /dev/null -w "%{http_code}\n" -H "Range: bytes=0-99" \ + "$BASE/assets/audio/music/midday-showdown.mp3" # 206 +``` + +Check that every file in `files.json` is reachable: + +```bash +python3 - <<'PY' +import json, urllib.request +base = "https://example.com/aether-clash/" +files = json.load(urllib.request.urlopen(base + "files.json"))["list"] +bad = [] +for f in files: + try: + with urllib.request.urlopen(urllib.request.Request(base + f["file"], method="HEAD")) as r: + if r.status != 200: bad.append((f["file"], r.status)) + except Exception as e: + bad.append((f["file"], str(e))) +print(len(files), "files checked,", len(bad), "problems") +for b in bad[:30]: print(b) +PY +``` + +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. +2. DevTools → Console shows no 404 or MIME errors, and Application → Service Workers lists `sw.js` with the scope `…/aether-clash/`. +3. A reload is much faster (served from the cache). +4. On a phone, the page switches to `mobile.html` (landscape with touch controls). + +## Updating + +Upload the new files over the old ones (the `rsync --delete` above) and fix the ownership again. No cache purge is needed: HTML and JS use `no-cache`, and `precache.js` carries a hash for every file, so players only download what changed. + +## Troubleshooting + +| Symptom | Cause and fix | +| --- | --- | +| 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` | +| 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 | +| 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 | diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fd4c939 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Bang Tutorial + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/LICENSE-ASSETS.md b/LICENSE-ASSETS.md new file mode 100644 index 0000000..f3b42ba --- /dev/null +++ b/LICENSE-ASSETS.md @@ -0,0 +1,34 @@ +# Asset license + +This repository uses two licenses. + +| What | License | +| --- | --- | +| **Source code**: every `.html`, `.css` and `.js` file, including the `manifest.js` and `*-config.js` data files | [MIT](LICENSE) | +| **Assets**: character designs, sprite atlases, portraits, icons, cut-ins, effects, arena and menu artwork, the menu video, voices and announcer clips, the background music "Midday Showdown" (generated with Suno on a paid plan), screenshots, and the documents in `guide/` | [Creative Commons Attribution-NonCommercial 4.0 International (CC BY-NC 4.0)](https://creativecommons.org/licenses/by-nc/4.0/) | + +Copyright © 2026 Bang Tutorial. + +## CC BY-NC 4.0 in short + +You may: + +- **Share**: copy and redistribute the assets in any medium or format. +- **Adapt**: remix, transform and build upon them. + +Under these terms: + +- **Attribution**: credit "AETHER CLASH by Bang Tutorial", link to this repository or to the [YouTube tutorial](https://www.youtube.com/watch?v=UN_0bNC2wTU), and say whether you made changes. +- **NonCommercial**: do not use the assets for commercial purposes. Selling them, putting them in a paid game or app, or using them in paid advertising all need written permission from Bang Tutorial. + +This summary is not the license itself. The full legal text is at . + +## Not covered by these licenses + +| File(s) | Terms | +| --- | --- | +| `assets/fonts/Rajdhani-*.ttf` | Rajdhani by Indian Type Foundry, under the [SIL Open Font License 1.1](assets/fonts/OFL.txt) | + +## Commercial use + +For commercial use of the assets, contact Bang Tutorial through the [YouTube channel](https://www.youtube.com/watch?v=UN_0bNC2wTU). diff --git a/README.md b/README.md new file mode 100644 index 0000000..9f0d8c7 --- /dev/null +++ b/README.md @@ -0,0 +1,234 @@ +> [!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. + +
+ +# ⚔️ 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) + +AETHER CLASH main menu + +
+ +--- + +## 📺 Tutorial + +The whole game — fighters, animations, voices and code — was made step by step on YouTube (in Bahasa Indonesia): + + + Watch the tutorial on YouTube: Bikin Game Fighting Pakai ChatGPT (GPT-6 Astra + Higgsfield MCP) + + +**[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 + + + + + + + + + + + + + +
Character select
Character select — 12 fighters, player then rival then arena
SOLAN vs NIB at Moonrise Bastion
VS Computer — SOLAN's Solar Crescent at Moonrise Bastion
Elder Tortoise cut-in
Ultimate cut-in — EDDA calls the Elder Tortoise
Elder Tortoise stomp
Ultimate — the spirit stomps three times
Mobile layout
Mobile — landscape layout with touch controls
+ +## 🥊 Roster + +| | Fighter | Faction | Style | Basic | Ultimate | +| :-: | --- | --- | --- | --- | --- | +| | **ARCO** — The Aether Arm | Mecha | Brawler / drone summon | Iron Chain | Helios Squadron | +| | **FENR** — The Wolf Ranger | Demi-Human | Agile / werewolf transformation | Ranger Chain | Feral Awakening | +| | **MIRA** — The Candy Pilot | Mecha | Heavy mech / rocket barrage | Mitten Chain | Rocket Parade | +| | **CORA** — The Raven Dancer | Demi-Human | Agile / raven swarm | Feather Waltz | Night Murmuration | +| | **NAJA** — The Dune Cobra | Demi-Human | Mid-range / urumi whip and sand serpent | Viper Lash | Dune Serpent | +| | **HALDOR** — The Walking Forge | Mecha | Heavy tank / steam forge hammer | Forge Chain | Forge Quake | +| | **ZANNI** — The Clockwork Jester | Mecha | Trickster / scissor arms and bladed rings | Scissor Jab | Grand Finale | +| | **ISOLDE** — The White Strider | Mecha | Long-range lancer / stilt-leg charges | Lance Line | Skyfall Lances | +| | **RHEA** — The Little Orrery | Mecha | Zoner / orbiting orrery spheres | Orbit Strike | Grand Orrery | +| | **SOLAN** — The Sunmane | Demi-Human | Powerhouse / greatsword and roar | Sunblade | Sunmane Roar | +| | **NIB** — The Rooftop Post | Demi-Human | Rushdown / twin courier batons | Baton Flurry | Special Delivery | +| | **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//aether-clash.git +cd aether-clash + +# any static file server works, for example: +python -m http.server 8000 +# or +npx serve . +``` + +Then open . + +> [!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 +. +├── index.html # desktop entry: menu, HUD, dialogs, script order +├── mobile.html # landscape shell for phones (rotation, fullscreen) +├── style.css # arena HUD, cut-ins, touch controls +├── menu.css # main menu, character and arena select, results +├── game.js # engine: loop, physics, combat, CPU brain, summons, rendering, HUD +├── menu.js # front end: menu flow, roster, arena and difficulty select +├── match.js # match rules: rounds, timer, stages, difficulty tuning +├── announcer.js # announcer voice queue +├── touch.js # on-screen touch controls +├── fenr.js … edda.js # one kit file per fighter (moves, balance, timings) +├── preload.js # first-visit loading screen, fills the cache +├── precache.js # generated list of runtime files with sizes and hashes +├── sw.js # service worker: cache-first images, network-first code +├── files.json # file list with sizes (used by the deploy check) +├── LICENSE # MIT (source code) +├── LICENSE-ASSETS.md # CC BY-NC 4.0 (art, audio, guides) and exceptions +├── guide/ # design and production guides (Bahasa Indonesia) +├── docs/screenshots/ # images for this README +└── assets/ + ├── / # sprite atlas (WebP) + manifest.js, portrait, icons, cut-in, FX, voice + ├── audio/ # announcer clips and background music + ├── menu/ # menu art, arena backgrounds, character select art + ├── ui/ # shared HUD art (ARCO kit, drone, cut-in) + └── fonts/ # Rajdhani (SIL Open Font License) +``` + +## 📚 Guides + +The [`guide/`](guide/) folder holds the design and production notes behind the game (in Bahasa Indonesia). Start from [guide/README.md](guide/README.md). + +| Topic | Guide | +| --- | --- | +| 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. + +## 🔧 How it works + +- **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. +- **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. +- **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. +- **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. +- **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. + +## 🙏 Credits + +- **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). + +
+ +If you enjoyed the project, give it a ⭐ and check out the [tutorial on YouTube](https://www.youtube.com/watch?v=UN_0bNC2wTU)! + +
diff --git a/announcer.js b/announcer.js new file mode 100644 index 0000000..74a91e4 --- /dev/null +++ b/announcer.js @@ -0,0 +1,48 @@ +/* Grady is the locked system announcer. One serialized speaker, never overlapping cues. */ +(() => { + 'use strict'; + function create({clips,canPlay,isPaused,onStart=()=>{},onActivity=()=>{}}){ + const bank={},failed=new Set();let queue=[],active=null,serial=0,playToken=0,starts=0,lastCue='',error='',volume=.45,muted=false; + function mix(){for(const media of Object.values(bank)){media.volume=Math.max(0,Math.min(1,volume*1.25));media.muted=muted;}onActivity();} + function clear(){serial++;playToken++;queue=[];if(active){active.media.pause();try{active.media.currentTime=0;}catch(_){}}active=null;onActivity();} + function finish(){if(active)active.media.pause();active=null;playToken++;next();} + function next(){ + if(active)return; + const key=queue.shift();if(!key){onActivity();return;} + if(failed.has(key)){next();return;} + const media=bank[key];active={key,media,elapsed:0,serial:++serial};lastCue=key;starts++;error='';try{media.currentTime=0;}catch(_){} + onStart();mix();sync(); + } + function sync(){ + if(!active)return; + const entry=active; + if(!canPlay()||isPaused()){playToken++;entry.media.pause();onActivity();return;} + if(!entry.media.paused)return; + const token=++playToken; + try{const p=entry.media.play();p?.catch(e=>{if(active!==entry||token!==playToken)return;error=e?.name||'audio-playback-failed';finish();});} + catch(e){if(active===entry&&token===playToken){error=e?.name||'audio-playback-failed';finish();}} + } + for(const [key,clip] of Object.entries(clips)){ + try{ + const media=new Audio();bank[key]=media;media.preload='auto'; + media.addEventListener('ended',()=>{if(active?.media===media)finish();}); + media.addEventListener('error',()=>{failed.add(key);if(active?.media===media){error='audio-load-failed';finish();}}); + media.addEventListener('playing',()=>{if(active?.media!==media||!canPlay()||isPaused()){media.pause();return;}mix();}); + media.src=clip.file;media.load(); + }catch(_){failed.add(key);} + } + return { + play(keys,{replace=true}={}){ + if(!canPlay())return false; + const valid=(Array.isArray(keys)?keys:[keys]).filter(k=>Object.hasOwn(bank,k)&&!failed.has(k)); + if(!valid.length)return false;if(replace)clear();queue.push(...valid);queue=queue.slice(0,4);next();return true; + }, + clear,sync,setMix(v,m){volume=v;muted=m;for(const media of Object.values(bank)){media.volume=Math.max(0,Math.min(1,v*1.25));media.muted=m;}}, + tick(dt){if(!active||!canPlay()||isPaused())return;active.elapsed+=dt;const duration=clips[active.key].duration||3;if(active.elapsed>duration+.4)finish();}, + get busy(){return !!active||queue.length>0;}, + get speaking(){return !!active&&!active.media.paused;}, + snapshot(){return {voice:'Grady',cue:active?.key||'',lastCue,active:!!active,paused:active?.media.paused??true,time:active?.media.currentTime||0,readyState:active?.media.readyState||0,starts,error,queue:[...queue]};} + }; + } + window.SystemAnnouncer={create}; +})(); diff --git a/assets/audio/announcer/arco_wins.mp3 b/assets/audio/announcer/arco_wins.mp3 new file mode 100644 index 0000000..f43de37 Binary files /dev/null and b/assets/audio/announcer/arco_wins.mp3 differ diff --git a/assets/audio/announcer/cora_wins.mp3 b/assets/audio/announcer/cora_wins.mp3 new file mode 100644 index 0000000..834c4fa Binary files /dev/null and b/assets/audio/announcer/cora_wins.mp3 differ diff --git a/assets/audio/announcer/double_ko.mp3 b/assets/audio/announcer/double_ko.mp3 new file mode 100644 index 0000000..b4bf4a4 Binary files /dev/null and b/assets/audio/announcer/double_ko.mp3 differ diff --git a/assets/audio/announcer/draw.mp3 b/assets/audio/announcer/draw.mp3 new file mode 100644 index 0000000..0fb0d2d Binary files /dev/null and b/assets/audio/announcer/draw.mp3 differ diff --git a/assets/audio/announcer/edda_wins.mp3 b/assets/audio/announcer/edda_wins.mp3 new file mode 100644 index 0000000..e6f7bbb Binary files /dev/null and b/assets/audio/announcer/edda_wins.mp3 differ diff --git a/assets/audio/announcer/fenr_wins.mp3 b/assets/audio/announcer/fenr_wins.mp3 new file mode 100644 index 0000000..7be7c30 Binary files /dev/null and b/assets/audio/announcer/fenr_wins.mp3 differ diff --git a/assets/audio/announcer/fight.mp3 b/assets/audio/announcer/fight.mp3 new file mode 100644 index 0000000..2166bf5 Binary files /dev/null and b/assets/audio/announcer/fight.mp3 differ diff --git a/assets/audio/announcer/haldor_wins.mp3 b/assets/audio/announcer/haldor_wins.mp3 new file mode 100644 index 0000000..5c26592 Binary files /dev/null and b/assets/audio/announcer/haldor_wins.mp3 differ diff --git a/assets/audio/announcer/isolde_wins.mp3 b/assets/audio/announcer/isolde_wins.mp3 new file mode 100644 index 0000000..52ac1d8 Binary files /dev/null and b/assets/audio/announcer/isolde_wins.mp3 differ diff --git a/assets/audio/announcer/ko.mp3 b/assets/audio/announcer/ko.mp3 new file mode 100644 index 0000000..e0c1686 Binary files /dev/null and b/assets/audio/announcer/ko.mp3 differ diff --git a/assets/audio/announcer/manifest.js b/assets/audio/announcer/manifest.js new file mode 100644 index 0000000..0261aef --- /dev/null +++ b/assets/audio/announcer/manifest.js @@ -0,0 +1 @@ +window.ANNOUNCER_MANIFEST = {"voice":{"name":"Grady","voice_id":"e2a2d2e6-9ed2-59cd-82af-feaa27f8a678","voice_type":"preset","provider":"Higgsfield","model":"text2speech_v2","variant":"elevenlabs","language":"English","locked":true},"clips":{"select_arco":{"file":"assets/audio/announcer/select_arco.mp3","duration":0.914286,"text":"Arco!"},"select_fenr":{"file":"assets/audio/announcer/select_fenr.mp3","duration":1.07102,"text":"Fenr!"},"round_1":{"file":"assets/audio/announcer/round_1.mp3","duration":1.07102,"text":"Round one!"},"round_2":{"file":"assets/audio/announcer/round_2.mp3","duration":2.194286,"text":"Round two!"},"round_3":{"file":"assets/audio/announcer/round_3.mp3","duration":1.306122,"text":"Round three!"},"fight":{"file":"assets/audio/announcer/fight.mp3","duration":1.07102,"text":"Fight!"},"ko":{"file":"assets/audio/announcer/ko.mp3","duration":0.679184,"text":"K.O.!"},"arco_wins":{"file":"assets/audio/announcer/arco_wins.mp3","duration":1.410612,"text":"Arco wins!"},"fenr_wins":{"file":"assets/audio/announcer/fenr_wins.mp3","duration":1.48898,"text":"Fenr wins!"},"draw":{"file":"assets/audio/announcer/draw.mp3","duration":1.07102,"text":"Draw!"},"time_up":{"file":"assets/audio/announcer/time_up.mp3","duration":1.227755,"text":"Time up!"},"double_ko":{"file":"assets/audio/announcer/double_ko.mp3","duration":1.48898,"text":"Double K.O.!"},"select_mira":{"file":"assets/audio/announcer/select_mira.mp3","duration":1.306122,"text":"Mira!"},"mira_wins":{"file":"assets/audio/announcer/mira_wins.mp3","duration":1.645714,"text":"Mira wins!"},"select_cora":{"file":"assets/audio/announcer/select_cora.mp3","duration":0.914286,"text":"Cora!"},"cora_wins":{"file":"assets/audio/announcer/cora_wins.mp3","duration":1.306122,"text":"Cora wins!"},"select_naja":{"file":"assets/audio/announcer/select_naja.mp3","duration":1.149388,"text":"Naja!"},"naja_wins":{"file":"assets/audio/announcer/naja_wins.mp3","duration":1.410612,"text":"Naja wins!"},"select_haldor":{"file":"assets/audio/announcer/select_haldor.mp3","duration":1.149388,"text":"Haldor!"},"haldor_wins":{"file":"assets/audio/announcer/haldor_wins.mp3","duration":1.306122,"text":"Haldor wins!"},"select_zanni":{"file":"assets/audio/announcer/select_zanni.mp3","duration":1.149388,"text":"Zanni!"},"zanni_wins":{"file":"assets/audio/announcer/zanni_wins.mp3","duration":1.48898,"text":"Zanni wins!"},"select_isolde":{"file":"assets/audio/announcer/select_isolde.mp3","duration":0.679,"text":"Isolde!"},"isolde_wins":{"file":"assets/audio/announcer/isolde_wins.mp3","duration":1.489,"text":"Isolde wins!"},"select_rhea":{"file":"assets/audio/announcer/select_rhea.mp3","duration":0.993,"text":"Rhea!"},"rhea_wins":{"file":"assets/audio/announcer/rhea_wins.mp3","duration":1.306,"text":"Rhea wins!"},"select_solan":{"file":"assets/audio/announcer/select_solan.mp3","duration":0.914286,"text":"Solan!"},"solan_wins":{"file":"assets/audio/announcer/solan_wins.mp3","duration":1.38449,"text":"Solan wins!"},"select_nib":{"file":"assets/audio/announcer/select_nib.mp3","duration":0.914286,"text":"Nib!"},"nib_wins":{"file":"assets/audio/announcer/nib_wins.mp3","duration":1.410612,"text":"Nib wins!"},"select_edda":{"file":"assets/audio/announcer/select_edda.mp3","duration":0.992653,"text":"Edda!"},"edda_wins":{"file":"assets/audio/announcer/edda_wins.mp3","duration":1.07102,"text":"Edda wins!"}}}; diff --git a/assets/audio/announcer/mira_wins.mp3 b/assets/audio/announcer/mira_wins.mp3 new file mode 100644 index 0000000..7258dad Binary files /dev/null and b/assets/audio/announcer/mira_wins.mp3 differ diff --git a/assets/audio/announcer/naja_wins.mp3 b/assets/audio/announcer/naja_wins.mp3 new file mode 100644 index 0000000..61f8285 Binary files /dev/null and b/assets/audio/announcer/naja_wins.mp3 differ diff --git a/assets/audio/announcer/nib_wins.mp3 b/assets/audio/announcer/nib_wins.mp3 new file mode 100644 index 0000000..be606ef Binary files /dev/null and b/assets/audio/announcer/nib_wins.mp3 differ diff --git a/assets/audio/announcer/rhea_wins.mp3 b/assets/audio/announcer/rhea_wins.mp3 new file mode 100644 index 0000000..d166e03 Binary files /dev/null and b/assets/audio/announcer/rhea_wins.mp3 differ diff --git a/assets/audio/announcer/round_1.mp3 b/assets/audio/announcer/round_1.mp3 new file mode 100644 index 0000000..6847dc0 Binary files /dev/null and b/assets/audio/announcer/round_1.mp3 differ diff --git a/assets/audio/announcer/round_2.mp3 b/assets/audio/announcer/round_2.mp3 new file mode 100644 index 0000000..5504fb8 Binary files /dev/null and b/assets/audio/announcer/round_2.mp3 differ diff --git a/assets/audio/announcer/round_3.mp3 b/assets/audio/announcer/round_3.mp3 new file mode 100644 index 0000000..5fca4d1 Binary files /dev/null and b/assets/audio/announcer/round_3.mp3 differ diff --git a/assets/audio/announcer/select_arco.mp3 b/assets/audio/announcer/select_arco.mp3 new file mode 100644 index 0000000..9f02fe7 Binary files /dev/null and b/assets/audio/announcer/select_arco.mp3 differ diff --git a/assets/audio/announcer/select_cora.mp3 b/assets/audio/announcer/select_cora.mp3 new file mode 100644 index 0000000..76d758e Binary files /dev/null and b/assets/audio/announcer/select_cora.mp3 differ diff --git a/assets/audio/announcer/select_edda.mp3 b/assets/audio/announcer/select_edda.mp3 new file mode 100644 index 0000000..e4c3322 Binary files /dev/null and b/assets/audio/announcer/select_edda.mp3 differ diff --git a/assets/audio/announcer/select_fenr.mp3 b/assets/audio/announcer/select_fenr.mp3 new file mode 100644 index 0000000..2463c5f Binary files /dev/null and b/assets/audio/announcer/select_fenr.mp3 differ diff --git a/assets/audio/announcer/select_haldor.mp3 b/assets/audio/announcer/select_haldor.mp3 new file mode 100644 index 0000000..6ceda29 Binary files /dev/null and b/assets/audio/announcer/select_haldor.mp3 differ diff --git a/assets/audio/announcer/select_isolde.mp3 b/assets/audio/announcer/select_isolde.mp3 new file mode 100644 index 0000000..47900cb Binary files /dev/null and b/assets/audio/announcer/select_isolde.mp3 differ diff --git a/assets/audio/announcer/select_mira.mp3 b/assets/audio/announcer/select_mira.mp3 new file mode 100644 index 0000000..04f5f07 Binary files /dev/null and b/assets/audio/announcer/select_mira.mp3 differ diff --git a/assets/audio/announcer/select_naja.mp3 b/assets/audio/announcer/select_naja.mp3 new file mode 100644 index 0000000..4897217 Binary files /dev/null and b/assets/audio/announcer/select_naja.mp3 differ diff --git a/assets/audio/announcer/select_nib.mp3 b/assets/audio/announcer/select_nib.mp3 new file mode 100644 index 0000000..732de88 Binary files /dev/null and b/assets/audio/announcer/select_nib.mp3 differ diff --git a/assets/audio/announcer/select_rhea.mp3 b/assets/audio/announcer/select_rhea.mp3 new file mode 100644 index 0000000..ecb8e93 Binary files /dev/null and b/assets/audio/announcer/select_rhea.mp3 differ diff --git a/assets/audio/announcer/select_solan.mp3 b/assets/audio/announcer/select_solan.mp3 new file mode 100644 index 0000000..41ebcf0 Binary files /dev/null and b/assets/audio/announcer/select_solan.mp3 differ diff --git a/assets/audio/announcer/select_zanni.mp3 b/assets/audio/announcer/select_zanni.mp3 new file mode 100644 index 0000000..5e14d00 Binary files /dev/null and b/assets/audio/announcer/select_zanni.mp3 differ diff --git a/assets/audio/announcer/solan_wins.mp3 b/assets/audio/announcer/solan_wins.mp3 new file mode 100644 index 0000000..6b44d66 Binary files /dev/null and b/assets/audio/announcer/solan_wins.mp3 differ diff --git a/assets/audio/announcer/time_up.mp3 b/assets/audio/announcer/time_up.mp3 new file mode 100644 index 0000000..a2fd9ba Binary files /dev/null and b/assets/audio/announcer/time_up.mp3 differ diff --git a/assets/audio/announcer/zanni_wins.mp3 b/assets/audio/announcer/zanni_wins.mp3 new file mode 100644 index 0000000..29ff4aa Binary files /dev/null and b/assets/audio/announcer/zanni_wins.mp3 differ diff --git a/assets/audio/arco-ultimate-dylan.mp3 b/assets/audio/arco-ultimate-dylan.mp3 new file mode 100644 index 0000000..f1ae7e3 Binary files /dev/null and b/assets/audio/arco-ultimate-dylan.mp3 differ diff --git a/assets/audio/music/midday-showdown.mp3 b/assets/audio/music/midday-showdown.mp3 new file mode 100644 index 0000000..18a0a83 Binary files /dev/null and b/assets/audio/music/midday-showdown.mp3 differ diff --git a/assets/cora/audio/cora-ultimate-anika.mp3 b/assets/cora/audio/cora-ultimate-anika.mp3 new file mode 100644 index 0000000..917b2e4 Binary files /dev/null and b/assets/cora/audio/cora-ultimate-anika.mp3 differ diff --git a/assets/cora/audio/cora_wins.mp3 b/assets/cora/audio/cora_wins.mp3 new file mode 100644 index 0000000..834c4fa Binary files /dev/null and b/assets/cora/audio/cora_wins.mp3 differ diff --git a/assets/cora/audio/select_cora.mp3 b/assets/cora/audio/select_cora.mp3 new file mode 100644 index 0000000..76d758e Binary files /dev/null and b/assets/cora/audio/select_cora.mp3 differ diff --git a/assets/cora/manifest.js b/assets/cora/manifest.js new file mode 100644 index 0000000..083c0bd --- /dev/null +++ b/assets/cora/manifest.js @@ -0,0 +1,2 @@ +window.CORA_MANIFEST = {"characterId": "cora", "engine": "component-row", "game_input": "sprite-sheet-alpha.png", "degraded_static_fallback": false, "curation_applied": true, "frame_variant": "pixel", "sprite_sheet_alpha": "sprite-sheet-alpha.png", "sprite_sheet_alpha_report": "sprite-sheet-alpha.report.json", "base_image": "base-source.png", "cell": {"shape": "rect", "width": 448, "height": 288, "safe_margin_x": 10, "safe_margin_y": 8}, "chroma_key": {"name": "green", "hex": "#00FF00", "rgb": [0, 255, 0], "selection": "manual"}, "animation": {"cellWidth": 448, "cellHeight": 288, "columns": 4, "rows": {"idle": {"row": 0, "frames": 4, "fps": 5, "durations_ms": [200, 200, 200, 200], "loop": true, "frame_variant": "pixel"}, "walk": {"row": 1, "frames": 4, "fps": 10, "durations_ms": [100, 100, 100, 100], "loop": true, "frame_variant": "pixel"}, "run": {"row": 2, "frames": 4, "fps": 13, "durations_ms": [77, 77, 77, 77], "loop": true, "frame_variant": "pixel"}, "jump": {"row": 3, "frames": 1, "fps": 10, "durations_ms": [100], "loop": false, "frame_variant": "pixel"}, "doublejump": {"row": 4, "frames": 1, "fps": 12, "durations_ms": [83], "loop": false, "frame_variant": "pixel"}, "crouch": {"row": 5, "frames": 4, "fps": 8, "durations_ms": [125, 125, 125, 125], "loop": false, "frame_variant": "pixel"}, "attack1": {"row": 6, "frames": 4, "fps": 13, "durations_ms": [77, 77, 77, 77], "loop": false, "frame_variant": "pixel"}, "attack2": {"row": 7, "frames": 4, "fps": 13, "durations_ms": [77, 77, 77, 77], "loop": false, "frame_variant": "pixel"}, "attack3": {"row": 8, "frames": 4, "fps": 11, "durations_ms": [91, 91, 91, 91], "loop": false, "frame_variant": "pixel"}, "skill1": {"row": 9, "frames": 4, "fps": 11, "durations_ms": [91, 91, 91, 91], "loop": false, "frame_variant": "pixel"}, "skill2": {"row": 10, "frames": 4, "fps": 10, "durations_ms": [100, 100, 100, 100], "loop": false, "frame_variant": "pixel"}, "ultimate": {"row": 11, "frames": 4, "fps": 9, "durations_ms": [111, 111, 111, 111], "loop": false, "frame_variant": "pixel"}, "hurt": {"row": 12, "frames": 4, "fps": 9, "durations_ms": [111, 111, 111, 111], "loop": false, "frame_variant": "pixel"}, "down": {"row": 13, "frames": 4, "fps": 7, "durations_ms": [143, 143, 143, 143], "loop": false, "frame_variant": "pixel"}, "recover": {"row": 14, "frames": 4, "fps": 8, "durations_ms": [125, 125, 125, 125], "loop": false, "frame_variant": "pixel"}}}, "frame_layout": {"sheetWidth": 1792, "sheetHeight": 4320, "cellWidth": 448, "cellHeight": 288, "rows": {"idle": [{"x": 0, "y": 0, "w": 448, "h": 288}, {"x": 448, "y": 0, "w": 448, "h": 288}, {"x": 896, "y": 0, "w": 448, "h": 288}, {"x": 1344, "y": 0, "w": 448, "h": 288}], "walk": [{"x": 0, "y": 288, "w": 448, "h": 288}, {"x": 448, "y": 288, "w": 448, "h": 288}, {"x": 896, "y": 288, "w": 448, "h": 288}, {"x": 1344, "y": 288, "w": 448, "h": 288}], "run": [{"x": 0, "y": 576, "w": 448, "h": 288}, {"x": 448, "y": 576, "w": 448, "h": 288}, {"x": 896, "y": 576, "w": 448, "h": 288}, {"x": 1344, "y": 576, "w": 448, "h": 288}], "jump": [{"x": 0, "y": 864, "w": 448, "h": 288}], "doublejump": [{"x": 0, "y": 1152, "w": 448, "h": 288}], "crouch": [{"x": 0, "y": 1440, "w": 448, "h": 288}, {"x": 448, "y": 1440, "w": 448, "h": 288}, {"x": 896, "y": 1440, "w": 448, "h": 288}, {"x": 1344, "y": 1440, "w": 448, "h": 288}], "attack1": [{"x": 0, "y": 1728, "w": 448, "h": 288}, {"x": 448, "y": 1728, "w": 448, "h": 288}, {"x": 896, "y": 1728, "w": 448, "h": 288}, {"x": 1344, "y": 1728, "w": 448, "h": 288}], "attack2": [{"x": 0, "y": 2016, "w": 448, "h": 288}, {"x": 448, "y": 2016, "w": 448, "h": 288}, {"x": 896, "y": 2016, "w": 448, "h": 288}, {"x": 1344, "y": 2016, "w": 448, "h": 288}], "attack3": [{"x": 0, "y": 2304, "w": 448, "h": 288}, {"x": 448, "y": 2304, "w": 448, "h": 288}, {"x": 896, "y": 2304, "w": 448, "h": 288}, {"x": 1344, "y": 2304, "w": 448, "h": 288}], "skill1": [{"x": 0, "y": 2592, "w": 448, "h": 288}, {"x": 448, "y": 2592, "w": 448, "h": 288}, {"x": 896, "y": 2592, "w": 448, "h": 288}, {"x": 1344, "y": 2592, "w": 448, "h": 288}], "skill2": [{"x": 0, "y": 2880, "w": 448, "h": 288}, {"x": 448, "y": 2880, "w": 448, "h": 288}, {"x": 896, "y": 2880, "w": 448, "h": 288}, {"x": 1344, "y": 2880, "w": 448, "h": 288}], "ultimate": [{"x": 0, "y": 3168, "w": 448, "h": 288}, {"x": 448, "y": 3168, "w": 448, "h": 288}, {"x": 896, "y": 3168, "w": 448, "h": 288}, {"x": 1344, "y": 3168, "w": 448, "h": 288}], "hurt": [{"x": 0, "y": 3456, "w": 448, "h": 288}, {"x": 448, "y": 3456, "w": 448, "h": 288}, {"x": 896, "y": 3456, "w": 448, "h": 288}, {"x": 1344, "y": 3456, "w": 448, "h": 288}], "down": [{"x": 0, "y": 3744, "w": 448, "h": 288}, {"x": 448, "y": 3744, "w": 448, "h": 288}, {"x": 896, "y": 3744, "w": 448, "h": 288}, {"x": 1344, "y": 3744, "w": 448, "h": 288}], "recover": [{"x": 0, "y": 4032, "w": 448, "h": 288}, {"x": 448, "y": 4032, "w": 448, "h": 288}, {"x": 896, "y": 4032, "w": 448, "h": 288}, {"x": 1344, "y": 4032, "w": 448, "h": 288}]}}}; +window.CORA_METRICS = {"anchor": {"x": 224, "y": 280}, "states": {"idle": {"frames": [{"frame": 0, "bounds": {"left": -78, "right": 51, "top": -184, "bottom": 0}, "height": 184}, {"frame": 1, "bounds": {"left": -79, "right": 56, "top": -184, "bottom": 0}, "height": 184}, {"frame": 2, "bounds": {"left": -81, "right": 57, "top": -183, "bottom": 0}, "height": 183}, {"frame": 3, "bounds": {"left": -78, "right": 52, "top": -184, "bottom": 0}, "height": 184}]}, "walk": {"frames": [{"frame": 0, "bounds": {"left": -73, "right": 62, "top": -182, "bottom": 0}, "height": 182}, {"frame": 1, "bounds": {"left": -71, "right": 58, "top": -183, "bottom": 0}, "height": 183}, {"frame": 2, "bounds": {"left": -73, "right": 62, "top": -182, "bottom": 0}, "height": 182}, {"frame": 3, "bounds": {"left": -71, "right": 58, "top": -183, "bottom": 0}, "height": 183}]}, "run": {"frames": [{"frame": 0, "bounds": {"left": -79, "right": 85, "top": -153, "bottom": 0}, "height": 153}, {"frame": 1, "bounds": {"left": -69, "right": 75, "top": -155, "bottom": 0}, "height": 155}, {"frame": 2, "bounds": {"left": -77, "right": 88, "top": -153, "bottom": 0}, "height": 153}, {"frame": 3, "bounds": {"left": -71, "right": 76, "top": -155, "bottom": 0}, "height": 155}]}, "jump": {"frames": [{"frame": 0, "bounds": {"left": -109, "right": 62, "top": -216, "bottom": 0}, "height": 216}]}, "doublejump": {"frames": [{"frame": 0, "bounds": {"left": -79, "right": 41, "top": -136, "bottom": 0}, "height": 136}]}, "crouch": {"frames": [{"frame": 0, "bounds": {"left": -58, "right": 67, "top": -136, "bottom": 0}, "height": 136}, {"frame": 1, "bounds": {"left": -66, "right": 60, "top": -122, "bottom": 0}, "height": 122}, {"frame": 2, "bounds": {"left": -57, "right": 57, "top": -123, "bottom": 0}, "height": 123}, {"frame": 3, "bounds": {"left": -61, "right": 62, "top": -137, "bottom": 0}, "height": 137}]}, "attack1": {"frames": [{"frame": 0, "bounds": {"left": -80, "right": 59, "top": -178, "bottom": 0}, "height": 178}, {"frame": 1, "bounds": {"left": -84, "right": 63, "top": -178, "bottom": 0}, "height": 178}, {"frame": 2, "bounds": {"left": -84, "right": 87, "top": -178, "bottom": 0}, "height": 178}, {"frame": 3, "bounds": {"left": -77, "right": 58, "top": -178, "bottom": 0}, "height": 178}]}, "attack2": {"frames": [{"frame": 0, "bounds": {"left": -79, "right": 66, "top": -176, "bottom": 0}, "height": 176}, {"frame": 1, "bounds": {"left": -77, "right": 74, "top": -182, "bottom": 0}, "height": 182}, {"frame": 2, "bounds": {"left": -79, "right": 68, "top": -206, "bottom": 0}, "height": 206}, {"frame": 3, "bounds": {"left": -83, "right": 60, "top": -175, "bottom": 0}, "height": 175}]}, "attack3": {"frames": [{"frame": 0, "bounds": {"left": -52, "right": 51, "top": -188, "bottom": 0}, "height": 188}, {"frame": 1, "bounds": {"left": -99, "right": 85, "top": -189, "bottom": 0}, "height": 189}, {"frame": 2, "bounds": {"left": -113, "right": 115, "top": -203, "bottom": 0}, "height": 203}, {"frame": 3, "bounds": {"left": -69, "right": 54, "top": -188, "bottom": 0}, "height": 188}]}, "skill1": {"frames": [{"frame": 0, "bounds": {"left": -83, "right": 64, "top": -172, "bottom": 0}, "height": 172}, {"frame": 1, "bounds": {"left": -110, "right": 61, "top": -172, "bottom": 0}, "height": 172}, {"frame": 2, "bounds": {"left": -91, "right": 101, "top": -173, "bottom": 0}, "height": 173}, {"frame": 3, "bounds": {"left": -80, "right": 64, "top": -172, "bottom": 0}, "height": 172}]}, "skill2": {"frames": [{"frame": 0, "bounds": {"left": -113, "right": 68, "top": -236, "bottom": 0}, "height": 236}, {"frame": 1, "bounds": {"left": -79, "right": 95, "top": -188, "bottom": 0}, "height": 188}, {"frame": 2, "bounds": {"left": -105, "right": 93, "top": -175, "bottom": 0}, "height": 175}, {"frame": 3, "bounds": {"left": -78, "right": 59, "top": -184, "bottom": 0}, "height": 184}]}, "ultimate": {"frames": [{"frame": 0, "bounds": {"left": -61, "right": 55, "top": -178, "bottom": 0}, "height": 178}, {"frame": 1, "bounds": {"left": -104, "right": 74, "top": -178, "bottom": 0}, "height": 178}, {"frame": 2, "bounds": {"left": -95, "right": 58, "top": -216, "bottom": 0}, "height": 216}, {"frame": 3, "bounds": {"left": -93, "right": 58, "top": -216, "bottom": 0}, "height": 216}]}, "hurt": {"frames": [{"frame": 0, "bounds": {"left": -92, "right": 59, "top": -172, "bottom": 0}, "height": 172}, {"frame": 1, "bounds": {"left": -73, "right": 50, "top": -177, "bottom": 0}, "height": 177}, {"frame": 2, "bounds": {"left": -65, "right": 63, "top": -158, "bottom": 0}, "height": 158}, {"frame": 3, "bounds": {"left": -79, "right": 56, "top": -177, "bottom": 0}, "height": 177}]}, "down": {"frames": [{"frame": 0, "bounds": {"left": -64, "right": 65, "top": -151, "bottom": 0}, "height": 151}, {"frame": 1, "bounds": {"left": -75, "right": 72, "top": -110, "bottom": 0}, "height": 110}, {"frame": 2, "bounds": {"left": -71, "right": 92, "top": -59, "bottom": 0}, "height": 59}, {"frame": 3, "bounds": {"left": -73, "right": 90, "top": -56, "bottom": 0}, "height": 56}]}, "recover": {"frames": [{"frame": 0, "bounds": {"left": -72, "right": 98, "top": -60, "bottom": 0}, "height": 60}, {"frame": 1, "bounds": {"left": -75, "right": 70, "top": -113, "bottom": 0}, "height": 113}, {"frame": 2, "bounds": {"left": -71, "right": 75, "top": -146, "bottom": 0}, "height": 146}, {"frame": 3, "bounds": {"left": -80, "right": 63, "top": -182, "bottom": 0}, "height": 182}]}}, "playback": {"jump": {"airFrame": 0, "torsoPivot": {"x": -24, "y": -108}}, "doublejump": {"frame": 0, "duration": 0.28, "pivot": {"x": -19, "y": -68}}, "measurement": "Centers measured from final curated atlas, including integer air-pose alignment; no runtime resizing."}, "emitters": {"attack1": {"x": 87, "y": -130, "frame": 2, "method": "Farthest-right opaque endpoint on impact frame; six-pixel tip region, facing right"}, "attack2": {"x": 68, "y": -204, "frame": 2, "method": "Farthest-right opaque endpoint on impact frame; six-pixel tip region, facing right"}, "attack3": {"x": 115, "y": -188, "frame": 2, "method": "Farthest-right opaque endpoint on impact frame; six-pixel tip region, facing right"}, "skill1": {"x": 101, "y": -144, "frame": 2, "method": "Farthest-right opaque endpoint on impact frame; six-pixel tip region, facing right"}, "skill2": {"x": 93, "y": -122, "frame": 2, "method": "Farthest-right opaque endpoint on impact frame; six-pixel tip region, facing right"}}}; diff --git a/assets/cora/run/sprite-sheet-alpha.webp b/assets/cora/run/sprite-sheet-alpha.webp new file mode 100644 index 0000000..e288c6b Binary files /dev/null and b/assets/cora/run/sprite-sheet-alpha.webp differ diff --git a/assets/cora/ui/cutin.webp b/assets/cora/ui/cutin.webp new file mode 100644 index 0000000..fc451e7 Binary files /dev/null and b/assets/cora/ui/cutin.webp differ diff --git a/assets/cora/ui/fx-feather.webp b/assets/cora/ui/fx-feather.webp new file mode 100644 index 0000000..5220f87 Binary files /dev/null and b/assets/cora/ui/fx-feather.webp differ diff --git a/assets/cora/ui/fx-gust.webp b/assets/cora/ui/fx-gust.webp new file mode 100644 index 0000000..0cd3bf3 Binary files /dev/null and b/assets/cora/ui/fx-gust.webp differ diff --git a/assets/cora/ui/fx-raven.webp b/assets/cora/ui/fx-raven.webp new file mode 100644 index 0000000..d7d51f3 Binary files /dev/null and b/assets/cora/ui/fx-raven.webp differ diff --git a/assets/cora/ui/icon-basic.webp b/assets/cora/ui/icon-basic.webp new file mode 100644 index 0000000..b9f1735 Binary files /dev/null and b/assets/cora/ui/icon-basic.webp differ diff --git a/assets/cora/ui/icon-skill1.webp b/assets/cora/ui/icon-skill1.webp new file mode 100644 index 0000000..b980963 Binary files /dev/null and b/assets/cora/ui/icon-skill1.webp differ diff --git a/assets/cora/ui/icon-skill2.webp b/assets/cora/ui/icon-skill2.webp new file mode 100644 index 0000000..3e510a3 Binary files /dev/null and b/assets/cora/ui/icon-skill2.webp differ diff --git a/assets/cora/ui/icon-ultimate.webp b/assets/cora/ui/icon-ultimate.webp new file mode 100644 index 0000000..253b4fd Binary files /dev/null and b/assets/cora/ui/icon-ultimate.webp differ diff --git a/assets/cora/ui/portrait.webp b/assets/cora/ui/portrait.webp new file mode 100644 index 0000000..88631ca Binary files /dev/null and b/assets/cora/ui/portrait.webp differ diff --git a/assets/edda/audio/edda-ultimate.mp3 b/assets/edda/audio/edda-ultimate.mp3 new file mode 100644 index 0000000..a0fc4de Binary files /dev/null and b/assets/edda/audio/edda-ultimate.mp3 differ diff --git a/assets/edda/audio/edda_wins.mp3 b/assets/edda/audio/edda_wins.mp3 new file mode 100644 index 0000000..e6f7bbb Binary files /dev/null and b/assets/edda/audio/edda_wins.mp3 differ diff --git a/assets/edda/audio/select_edda.mp3 b/assets/edda/audio/select_edda.mp3 new file mode 100644 index 0000000..e4c3322 Binary files /dev/null and b/assets/edda/audio/select_edda.mp3 differ diff --git a/assets/edda/manifest.js b/assets/edda/manifest.js new file mode 100644 index 0000000..2ee3573 --- /dev/null +++ b/assets/edda/manifest.js @@ -0,0 +1,2 @@ +window.EDDA_MANIFEST = {"characterId": "edda", "engine": "component-row", "game_input": "sprite-sheet-alpha.png", "degraded_static_fallback": false, "curation_applied": true, "frame_variant": "pixel", "sprite_sheet_alpha": "sprite-sheet-alpha.png", "sprite_sheet_alpha_report": "sprite-sheet-alpha.report.json", "base_image": "base-source.png", "cell": {"shape": "rect", "width": 448, "height": 288, "safe_margin_x": 10, "safe_margin_y": 8}, "chroma_key": {"name": "magenta", "hex": "#FF00FF", "rgb": [255, 0, 255], "selection": "manual"}, "animation": {"cellWidth": 448, "cellHeight": 288, "columns": 4, "rows": {"idle": {"row": 0, "frames": 4, "fps": 5, "durations_ms": [200, 200, 200, 200], "loop": true, "frame_variant": "pixel"}, "walk": {"row": 1, "frames": 4, "fps": 8, "durations_ms": [125, 125, 125, 125], "loop": true, "frame_variant": "pixel"}, "run": {"row": 2, "frames": 4, "fps": 11, "durations_ms": [91, 91, 91, 91], "loop": true, "frame_variant": "pixel"}, "jump": {"row": 3, "frames": 1, "fps": 10, "durations_ms": [100], "loop": false, "frame_variant": "pixel"}, "doublejump": {"row": 4, "frames": 1, "fps": 12, "durations_ms": [83], "loop": false, "frame_variant": "pixel"}, "crouch": {"row": 5, "frames": 4, "fps": 8, "durations_ms": [125, 125, 125, 125], "loop": false, "frame_variant": "pixel"}, "attack1": {"row": 6, "frames": 4, "fps": 11, "durations_ms": [91, 91, 91, 91], "loop": false, "frame_variant": "pixel"}, "attack2": {"row": 7, "frames": 4, "fps": 11, "durations_ms": [91, 91, 91, 91], "loop": false, "frame_variant": "pixel"}, "attack3": {"row": 8, "frames": 4, "fps": 9, "durations_ms": [111, 111, 111, 111], "loop": false, "frame_variant": "pixel"}, "skill1": {"row": 9, "frames": 4, "fps": 10, "durations_ms": [100, 100, 100, 100], "loop": false, "frame_variant": "pixel"}, "skill2": {"row": 10, "frames": 4, "fps": 6, "durations_ms": [167, 167, 167, 167], "loop": false, "frame_variant": "pixel"}, "ultimate": {"row": 11, "frames": 4, "fps": 8, "durations_ms": [125, 125, 125, 125], "loop": false, "frame_variant": "pixel"}, "hurt": {"row": 12, "frames": 4, "fps": 10, "durations_ms": [100, 100, 100, 100], "loop": false, "frame_variant": "pixel"}, "down": {"row": 13, "frames": 4, "fps": 8, "durations_ms": [125, 125, 125, 125], "loop": false, "frame_variant": "pixel"}, "recover": {"row": 14, "frames": 4, "fps": 10, "durations_ms": [100, 100, 100, 100], "loop": false, "frame_variant": "pixel"}}}, "frame_layout": {"sheetWidth": 1792, "sheetHeight": 4320, "cellWidth": 448, "cellHeight": 288, "rows": {"idle": [{"x": 0, "y": 0, "w": 448, "h": 288}, {"x": 448, "y": 0, "w": 448, "h": 288}, {"x": 896, "y": 0, "w": 448, "h": 288}, {"x": 1344, "y": 0, "w": 448, "h": 288}], "walk": [{"x": 0, "y": 288, "w": 448, "h": 288}, {"x": 448, "y": 288, "w": 448, "h": 288}, {"x": 896, "y": 288, "w": 448, "h": 288}, {"x": 1344, "y": 288, "w": 448, "h": 288}], "run": [{"x": 0, "y": 576, "w": 448, "h": 288}, {"x": 448, "y": 576, "w": 448, "h": 288}, {"x": 896, "y": 576, "w": 448, "h": 288}, {"x": 1344, "y": 576, "w": 448, "h": 288}], "jump": [{"x": 0, "y": 864, "w": 448, "h": 288}], "doublejump": [{"x": 0, "y": 1152, "w": 448, "h": 288}], "crouch": [{"x": 0, "y": 1440, "w": 448, "h": 288}, {"x": 448, "y": 1440, "w": 448, "h": 288}, {"x": 896, "y": 1440, "w": 448, "h": 288}, {"x": 1344, "y": 1440, "w": 448, "h": 288}], "attack1": [{"x": 0, "y": 1728, "w": 448, "h": 288}, {"x": 448, "y": 1728, "w": 448, "h": 288}, {"x": 896, "y": 1728, "w": 448, "h": 288}, {"x": 1344, "y": 1728, "w": 448, "h": 288}], "attack2": [{"x": 0, "y": 2016, "w": 448, "h": 288}, {"x": 448, "y": 2016, "w": 448, "h": 288}, {"x": 896, "y": 2016, "w": 448, "h": 288}, {"x": 1344, "y": 2016, "w": 448, "h": 288}], "attack3": [{"x": 0, "y": 2304, "w": 448, "h": 288}, {"x": 448, "y": 2304, "w": 448, "h": 288}, {"x": 896, "y": 2304, "w": 448, "h": 288}, {"x": 1344, "y": 2304, "w": 448, "h": 288}], "skill1": [{"x": 0, "y": 2592, "w": 448, "h": 288}, {"x": 448, "y": 2592, "w": 448, "h": 288}, {"x": 896, "y": 2592, "w": 448, "h": 288}, {"x": 1344, "y": 2592, "w": 448, "h": 288}], "skill2": [{"x": 0, "y": 2880, "w": 448, "h": 288}, {"x": 448, "y": 2880, "w": 448, "h": 288}, {"x": 896, "y": 2880, "w": 448, "h": 288}, {"x": 1344, "y": 2880, "w": 448, "h": 288}], "ultimate": [{"x": 0, "y": 3168, "w": 448, "h": 288}, {"x": 448, "y": 3168, "w": 448, "h": 288}, {"x": 896, "y": 3168, "w": 448, "h": 288}, {"x": 1344, "y": 3168, "w": 448, "h": 288}], "hurt": [{"x": 0, "y": 3456, "w": 448, "h": 288}, {"x": 448, "y": 3456, "w": 448, "h": 288}, {"x": 896, "y": 3456, "w": 448, "h": 288}, {"x": 1344, "y": 3456, "w": 448, "h": 288}], "down": [{"x": 0, "y": 3744, "w": 448, "h": 288}, {"x": 448, "y": 3744, "w": 448, "h": 288}, {"x": 896, "y": 3744, "w": 448, "h": 288}, {"x": 1344, "y": 3744, "w": 448, "h": 288}], "recover": [{"x": 0, "y": 4032, "w": 448, "h": 288}, {"x": 448, "y": 4032, "w": 448, "h": 288}, {"x": 896, "y": 4032, "w": 448, "h": 288}, {"x": 1344, "y": 4032, "w": 448, "h": 288}]}}}; +window.EDDA_METRICS = {"anchor": {"x": 224, "y": 280}, "states": {"idle": {"frames": [{"frame": 0, "bounds": {"left": -63, "right": 75, "top": -171, "bottom": 0}, "height": 171}, {"frame": 1, "bounds": {"left": -63, "right": 75, "top": -171, "bottom": 0}, "height": 171}, {"frame": 2, "bounds": {"left": -63, "right": 75, "top": -171, "bottom": 0}, "height": 171}, {"frame": 3, "bounds": {"left": -63, "right": 75, "top": -171, "bottom": 0}, "height": 171}]}, "walk": {"frames": [{"frame": 0, "bounds": {"left": -60, "right": 73, "top": -157, "bottom": 0}, "height": 157}, {"frame": 1, "bounds": {"left": -57, "right": 75, "top": -156, "bottom": 0}, "height": 156}, {"frame": 2, "bounds": {"left": -60, "right": 86, "top": -156, "bottom": 0}, "height": 156}, {"frame": 3, "bounds": {"left": -58, "right": 77, "top": -155, "bottom": 0}, "height": 155}]}, "run": {"frames": [{"frame": 0, "bounds": {"left": -54, "right": 93, "top": -137, "bottom": 0}, "height": 137}, {"frame": 1, "bounds": {"left": -52, "right": 90, "top": -137, "bottom": 0}, "height": 137}, {"frame": 2, "bounds": {"left": -55, "right": 91, "top": -137, "bottom": 0}, "height": 137}, {"frame": 3, "bounds": {"left": -51, "right": 90, "top": -137, "bottom": 0}, "height": 137}]}, "jump": {"frames": [{"frame": 0, "bounds": {"left": -118, "right": 91, "top": -194, "bottom": 0}, "height": 194}]}, "doublejump": {"frames": [{"frame": 0, "bounds": {"left": -77, "right": 55, "top": -137, "bottom": 0}, "height": 137}]}, "crouch": {"frames": [{"frame": 0, "bounds": {"left": -64, "right": 69, "top": -163, "bottom": 0}, "height": 163}, {"frame": 1, "bounds": {"left": -63, "right": 73, "top": -158, "bottom": 0}, "height": 158}, {"frame": 2, "bounds": {"left": -62, "right": 68, "top": -157, "bottom": 0}, "height": 157}, {"frame": 3, "bounds": {"left": -64, "right": 68, "top": -160, "bottom": 0}, "height": 160}]}, "attack1": {"frames": [{"frame": 0, "bounds": {"left": -72, "right": 73, "top": -151, "bottom": 0}, "height": 151}, {"frame": 1, "bounds": {"left": -60, "right": 94, "top": -149, "bottom": 0}, "height": 149}, {"frame": 2, "bounds": {"left": -56, "right": 127, "top": -151, "bottom": 0}, "height": 151}, {"frame": 3, "bounds": {"left": -61, "right": 72, "top": -166, "bottom": 0}, "height": 166}]}, "attack2": {"frames": [{"frame": 0, "bounds": {"left": -82, "right": 82, "top": -150, "bottom": 0}, "height": 150}, {"frame": 1, "bounds": {"left": -60, "right": 113, "top": -185, "bottom": 0}, "height": 185}, {"frame": 2, "bounds": {"left": -59, "right": 112, "top": -223, "bottom": 0}, "height": 223}, {"frame": 3, "bounds": {"left": -67, "right": 78, "top": -171, "bottom": 0}, "height": 171}]}, "attack3": {"frames": [{"frame": 0, "bounds": {"left": -66, "right": 83, "top": -213, "bottom": 0}, "height": 213}, {"frame": 1, "bounds": {"left": -62, "right": 82, "top": -218, "bottom": 0}, "height": 218}, {"frame": 2, "bounds": {"left": -66, "right": 83, "top": -160, "bottom": 0}, "height": 160}, {"frame": 3, "bounds": {"left": -63, "right": 82, "top": -213, "bottom": 0}, "height": 213}]}, "skill1": {"frames": [{"frame": 0, "bounds": {"left": -61, "right": 64, "top": -167, "bottom": 0}, "height": 167}, {"frame": 1, "bounds": {"left": -64, "right": 73, "top": -166, "bottom": 0}, "height": 166}, {"frame": 2, "bounds": {"left": -60, "right": 87, "top": -167, "bottom": 0}, "height": 167}, {"frame": 3, "bounds": {"left": -62, "right": 59, "top": -169, "bottom": 0}, "height": 169}]}, "skill2": {"frames": [{"frame": 0, "bounds": {"left": -65, "right": 59, "top": -167, "bottom": 0}, "height": 167}, {"frame": 1, "bounds": {"left": -59, "right": 53, "top": -169, "bottom": 0}, "height": 169}, {"frame": 2, "bounds": {"left": -64, "right": 125, "top": -139, "bottom": 0}, "height": 139}, {"frame": 3, "bounds": {"left": -66, "right": 62, "top": -172, "bottom": 0}, "height": 172}]}, "ultimate": {"frames": [{"frame": 0, "bounds": {"left": -62, "right": 56, "top": -163, "bottom": 0}, "height": 163}, {"frame": 1, "bounds": {"left": -61, "right": 69, "top": -203, "bottom": 0}, "height": 203}, {"frame": 2, "bounds": {"left": -80, "right": 109, "top": -143, "bottom": 0}, "height": 143}, {"frame": 3, "bounds": {"left": -58, "right": 47, "top": -153, "bottom": 0}, "height": 153}]}, "hurt": {"frames": [{"frame": 0, "bounds": {"left": -82, "right": 59, "top": -168, "bottom": 0}, "height": 168}, {"frame": 1, "bounds": {"left": -71, "right": 70, "top": -167, "bottom": 0}, "height": 167}, {"frame": 2, "bounds": {"left": -84, "right": 93, "top": -173, "bottom": 0}, "height": 173}, {"frame": 3, "bounds": {"left": -69, "right": 76, "top": -167, "bottom": 0}, "height": 167}]}, "down": {"frames": [{"frame": 0, "bounds": {"left": -67, "right": 67, "top": -167, "bottom": 0}, "height": 167}, {"frame": 1, "bounds": {"left": -78, "right": 60, "top": -122, "bottom": 0}, "height": 122}, {"frame": 2, "bounds": {"left": -75, "right": 59, "top": -119, "bottom": 0}, "height": 119}, {"frame": 3, "bounds": {"left": -76, "right": 59, "top": -132, "bottom": 0}, "height": 132}]}, "recover": {"frames": [{"frame": 0, "bounds": {"left": -63, "right": 68, "top": -142, "bottom": 0}, "height": 142}, {"frame": 1, "bounds": {"left": -64, "right": 88, "top": -156, "bottom": 0}, "height": 156}, {"frame": 2, "bounds": {"left": -50, "right": 74, "top": -159, "bottom": 0}, "height": 159}, {"frame": 3, "bounds": {"left": -63, "right": 80, "top": -166, "bottom": 0}, "height": 166}]}}, "playback": {"jump": {"airFrame": 0, "torsoPivot": {"x": -14, "y": -97}}, "doublejump": {"frame": 0, "duration": 0.28, "pivot": {"x": -11, "y": -68}}, "measurement": "Centers measured from final curated atlas, including integer air-pose alignment; no runtime resizing."}, "emitters": {"attack1": {"x": 126, "y": -86, "frame": 2, "method": "Staff tip or hand on the impact frame (frame 3), facing right: farthest-right tip for thrusts, top-right extreme for the rising sweep, mid-shaft for the planted thump"}, "attack2": {"x": 111, "y": -201, "frame": 2, "method": "Staff tip or hand on the impact frame (frame 3), facing right: farthest-right tip for thrusts, top-right extreme for the rising sweep, mid-shaft for the planted thump"}, "attack3": {"x": 82, "y": -70, "frame": 2, "method": "Staff tip or hand on the impact frame (frame 3), facing right: farthest-right tip for thrusts, top-right extreme for the rising sweep, mid-shaft for the planted thump"}, "skill1": {"x": 86, "y": -85, "frame": 2, "method": "Staff tip or hand on the impact frame (frame 3), facing right: farthest-right tip for thrusts, top-right extreme for the rising sweep, mid-shaft for the planted thump"}, "skill2": {"x": 124, "y": -76, "frame": 2, "method": "Staff tip or hand on the impact frame (frame 3), facing right: farthest-right tip for thrusts, top-right extreme for the rising sweep, mid-shaft for the planted thump"}, "ultimate": {"x": 108, "y": -13, "frame": 2, "method": "Staff tip or hand on the impact frame (frame 3), facing right: farthest-right tip for thrusts, top-right extreme for the rising sweep, mid-shaft for the planted thump"}}}; diff --git a/assets/edda/run/sprite-sheet-alpha.webp b/assets/edda/run/sprite-sheet-alpha.webp new file mode 100644 index 0000000..7005678 Binary files /dev/null and b/assets/edda/run/sprite-sheet-alpha.webp differ diff --git a/assets/edda/ui/cutin.webp b/assets/edda/ui/cutin.webp new file mode 100644 index 0000000..398109a Binary files /dev/null and b/assets/edda/ui/cutin.webp differ diff --git a/assets/edda/ui/fx-ripple.webp b/assets/edda/ui/fx-ripple.webp new file mode 100644 index 0000000..5e1e7b6 Binary files /dev/null and b/assets/edda/ui/fx-ripple.webp differ diff --git a/assets/edda/ui/fx-shell.webp b/assets/edda/ui/fx-shell.webp new file mode 100644 index 0000000..e468e56 Binary files /dev/null and b/assets/edda/ui/fx-shell.webp differ diff --git a/assets/edda/ui/fx-stomp.webp b/assets/edda/ui/fx-stomp.webp new file mode 100644 index 0000000..abfad69 Binary files /dev/null and b/assets/edda/ui/fx-stomp.webp differ diff --git a/assets/edda/ui/fx-stone.webp b/assets/edda/ui/fx-stone.webp new file mode 100644 index 0000000..a3f05d9 Binary files /dev/null and b/assets/edda/ui/fx-stone.webp differ diff --git a/assets/edda/ui/fx-tortoise.webp b/assets/edda/ui/fx-tortoise.webp new file mode 100644 index 0000000..eb00364 Binary files /dev/null and b/assets/edda/ui/fx-tortoise.webp differ diff --git a/assets/edda/ui/icon-basic.webp b/assets/edda/ui/icon-basic.webp new file mode 100644 index 0000000..aaf0553 Binary files /dev/null and b/assets/edda/ui/icon-basic.webp differ diff --git a/assets/edda/ui/icon-skill1.webp b/assets/edda/ui/icon-skill1.webp new file mode 100644 index 0000000..3fb1313 Binary files /dev/null and b/assets/edda/ui/icon-skill1.webp differ diff --git a/assets/edda/ui/icon-skill2.webp b/assets/edda/ui/icon-skill2.webp new file mode 100644 index 0000000..895b8d4 Binary files /dev/null and b/assets/edda/ui/icon-skill2.webp differ diff --git a/assets/edda/ui/icon-ultimate.webp b/assets/edda/ui/icon-ultimate.webp new file mode 100644 index 0000000..4dd2a88 Binary files /dev/null and b/assets/edda/ui/icon-ultimate.webp differ diff --git a/assets/edda/ui/portrait.webp b/assets/edda/ui/portrait.webp new file mode 100644 index 0000000..bb58b2f Binary files /dev/null and b/assets/edda/ui/portrait.webp differ diff --git a/assets/fenr/audio/fenr-ultimate-holden.mp3 b/assets/fenr/audio/fenr-ultimate-holden.mp3 new file mode 100644 index 0000000..1087952 Binary files /dev/null and b/assets/fenr/audio/fenr-ultimate-holden.mp3 differ diff --git a/assets/fenr/human/manifest.js b/assets/fenr/human/manifest.js new file mode 100644 index 0000000..c64724e --- /dev/null +++ b/assets/fenr/human/manifest.js @@ -0,0 +1,2 @@ +window.FENR_HUMAN_MANIFEST = {"characterId": "fenr-human", "engine": "component-row", "game_input": "sprite-sheet-alpha.png", "degraded_static_fallback": false, "curation_applied": true, "frame_variant": "pixel", "sprite_sheet_alpha": "sprite-sheet-alpha.png", "sprite_sheet_alpha_report": "sprite-sheet-alpha.report.json", "base_image": "base-source.png", "cell": {"shape": "rect", "width": 320, "height": 288, "safe_margin_x": 10, "safe_margin_y": 8}, "chroma_key": {"name": "magenta", "hex": "#FF00FF", "rgb": [255, 0, 255], "selection": "manual"}, "animation": {"cellWidth": 320, "cellHeight": 288, "columns": 4, "rows": {"idle": {"row": 0, "frames": 4, "fps": 5, "durations_ms": [200, 200, 200, 200], "loop": true, "frame_variant": "pixel"}, "walk": {"row": 1, "frames": 4, "fps": 10, "durations_ms": [100, 100, 100, 100], "loop": true, "frame_variant": "pixel"}, "run": {"row": 2, "frames": 4, "fps": 13, "durations_ms": [77, 77, 77, 77], "loop": true, "frame_variant": "pixel"}, "jump": {"row": 3, "frames": 1, "fps": 10, "durations_ms": [100], "loop": false, "frame_variant": "pixel"}, "doublejump": {"row": 4, "frames": 1, "fps": 12, "durations_ms": [83], "loop": false, "frame_variant": "pixel"}, "crouch": {"row": 5, "frames": 4, "fps": 8, "durations_ms": [125, 125, 125, 125], "loop": false, "frame_variant": "pixel"}, "attack1": {"row": 6, "frames": 4, "fps": 13, "durations_ms": [77, 77, 77, 77], "loop": false, "frame_variant": "pixel"}, "attack2": {"row": 7, "frames": 4, "fps": 13, "durations_ms": [77, 77, 77, 77], "loop": false, "frame_variant": "pixel"}, "attack3": {"row": 8, "frames": 4, "fps": 11, "durations_ms": [91, 91, 91, 91], "loop": false, "frame_variant": "pixel"}, "skill1": {"row": 9, "frames": 4, "fps": 11, "durations_ms": [91, 91, 91, 91], "loop": false, "frame_variant": "pixel"}, "skill2": {"row": 10, "frames": 4, "fps": 10, "durations_ms": [100, 100, 100, 100], "loop": false, "frame_variant": "pixel"}, "ultimate": {"row": 11, "frames": 4, "fps": 9, "durations_ms": [111, 111, 111, 111], "loop": false, "frame_variant": "pixel"}, "hurt": {"row": 12, "frames": 4, "fps": 9, "durations_ms": [111, 111, 111, 111], "loop": false, "frame_variant": "pixel"}, "down": {"row": 13, "frames": 4, "fps": 7, "durations_ms": [143, 143, 143, 143], "loop": false, "frame_variant": "pixel"}, "recover": {"row": 14, "frames": 4, "fps": 8, "durations_ms": [125, 125, 125, 125], "loop": false, "frame_variant": "pixel"}}}, "frame_layout": {"sheetWidth": 1280, "sheetHeight": 4320, "cellWidth": 320, "cellHeight": 288, "rows": {"idle": [{"x": 0, "y": 0, "w": 320, "h": 288}, {"x": 320, "y": 0, "w": 320, "h": 288}, {"x": 640, "y": 0, "w": 320, "h": 288}, {"x": 960, "y": 0, "w": 320, "h": 288}], "walk": [{"x": 0, "y": 288, "w": 320, "h": 288}, {"x": 320, "y": 288, "w": 320, "h": 288}, {"x": 640, "y": 288, "w": 320, "h": 288}, {"x": 960, "y": 288, "w": 320, "h": 288}], "run": [{"x": 0, "y": 576, "w": 320, "h": 288}, {"x": 320, "y": 576, "w": 320, "h": 288}, {"x": 640, "y": 576, "w": 320, "h": 288}, {"x": 960, "y": 576, "w": 320, "h": 288}], "jump": [{"x": 0, "y": 864, "w": 320, "h": 288}], "doublejump": [{"x": 0, "y": 1152, "w": 320, "h": 288}], "crouch": [{"x": 0, "y": 1440, "w": 320, "h": 288}, {"x": 320, "y": 1440, "w": 320, "h": 288}, {"x": 640, "y": 1440, "w": 320, "h": 288}, {"x": 960, "y": 1440, "w": 320, "h": 288}], "attack1": [{"x": 0, "y": 1728, "w": 320, "h": 288}, {"x": 320, "y": 1728, "w": 320, "h": 288}, {"x": 640, "y": 1728, "w": 320, "h": 288}, {"x": 960, "y": 1728, "w": 320, "h": 288}], "attack2": [{"x": 0, "y": 2016, "w": 320, "h": 288}, {"x": 320, "y": 2016, "w": 320, "h": 288}, {"x": 640, "y": 2016, "w": 320, "h": 288}, {"x": 960, "y": 2016, "w": 320, "h": 288}], "attack3": [{"x": 0, "y": 2304, "w": 320, "h": 288}, {"x": 320, "y": 2304, "w": 320, "h": 288}, {"x": 640, "y": 2304, "w": 320, "h": 288}, {"x": 960, "y": 2304, "w": 320, "h": 288}], "skill1": [{"x": 0, "y": 2592, "w": 320, "h": 288}, {"x": 320, "y": 2592, "w": 320, "h": 288}, {"x": 640, "y": 2592, "w": 320, "h": 288}, {"x": 960, "y": 2592, "w": 320, "h": 288}], "skill2": [{"x": 0, "y": 2880, "w": 320, "h": 288}, {"x": 320, "y": 2880, "w": 320, "h": 288}, {"x": 640, "y": 2880, "w": 320, "h": 288}, {"x": 960, "y": 2880, "w": 320, "h": 288}], "ultimate": [{"x": 0, "y": 3168, "w": 320, "h": 288}, {"x": 320, "y": 3168, "w": 320, "h": 288}, {"x": 640, "y": 3168, "w": 320, "h": 288}, {"x": 960, "y": 3168, "w": 320, "h": 288}], "hurt": [{"x": 0, "y": 3456, "w": 320, "h": 288}, {"x": 320, "y": 3456, "w": 320, "h": 288}, {"x": 640, "y": 3456, "w": 320, "h": 288}, {"x": 960, "y": 3456, "w": 320, "h": 288}], "down": [{"x": 0, "y": 3744, "w": 320, "h": 288}, {"x": 320, "y": 3744, "w": 320, "h": 288}, {"x": 640, "y": 3744, "w": 320, "h": 288}, {"x": 960, "y": 3744, "w": 320, "h": 288}], "recover": [{"x": 0, "y": 4032, "w": 320, "h": 288}, {"x": 320, "y": 4032, "w": 320, "h": 288}, {"x": 640, "y": 4032, "w": 320, "h": 288}, {"x": 960, "y": 4032, "w": 320, "h": 288}]}}}; +window.FENR_HUMAN_METRICS = {"anchor": {"x": 160, "y": 280}, "states": {"idle": {"frames": [{"frame": 0, "bounds": {"left": -72, "right": 71, "top": -194, "bottom": 0}, "height": 194}, {"frame": 1, "bounds": {"left": -71, "right": 71, "top": -194, "bottom": 0}, "height": 194}, {"frame": 2, "bounds": {"left": -69, "right": 70, "top": -194, "bottom": 0}, "height": 194}, {"frame": 3, "bounds": {"left": -72, "right": 73, "top": -194, "bottom": 0}, "height": 194}]}, "walk": {"frames": [{"frame": 0, "bounds": {"left": -80, "right": 68, "top": -211, "bottom": 0}, "height": 211}, {"frame": 1, "bounds": {"left": -68, "right": 46, "top": -212, "bottom": 0}, "height": 212}, {"frame": 2, "bounds": {"left": -80, "right": 73, "top": -211, "bottom": 0}, "height": 211}, {"frame": 3, "bounds": {"left": -76, "right": 46, "top": -207, "bottom": 0}, "height": 207}]}, "run": {"frames": [{"frame": 0, "bounds": {"left": -90, "right": 78, "top": -184, "bottom": 0}, "height": 184}, {"frame": 1, "bounds": {"left": -73, "right": 50, "top": -178, "bottom": 0}, "height": 178}, {"frame": 2, "bounds": {"left": -87, "right": 78, "top": -182, "bottom": 0}, "height": 182}, {"frame": 3, "bounds": {"left": -75, "right": 54, "top": -179, "bottom": 0}, "height": 179}]}, "jump": {"frames": [{"frame": 0, "bounds": {"left": -88, "right": 76, "top": -195, "bottom": 0}, "height": 195}]}, "doublejump": {"frames": [{"frame": 0, "bounds": {"left": -58, "right": 59, "top": -144, "bottom": 0}, "height": 144}]}, "crouch": {"frames": [{"frame": 0, "bounds": {"left": -71, "right": 70, "top": -188, "bottom": 0}, "height": 188}, {"frame": 1, "bounds": {"left": -84, "right": 63, "top": -163, "bottom": 0}, "height": 163}, {"frame": 2, "bounds": {"left": -82, "right": 68, "top": -132, "bottom": 0}, "height": 132}, {"frame": 3, "bounds": {"left": -72, "right": 59, "top": -171, "bottom": 0}, "height": 171}]}, "attack1": {"frames": [{"frame": 0, "bounds": {"left": -74, "right": 71, "top": -190, "bottom": 0}, "height": 190}, {"frame": 1, "bounds": {"left": -81, "right": 73, "top": -197, "bottom": 0}, "height": 197}, {"frame": 2, "bounds": {"left": -81, "right": 102, "top": -196, "bottom": 0}, "height": 196}, {"frame": 3, "bounds": {"left": -76, "right": 71, "top": -190, "bottom": 0}, "height": 190}]}, "attack2": {"frames": [{"frame": 0, "bounds": {"left": -78, "right": 75, "top": -179, "bottom": 0}, "height": 179}, {"frame": 1, "bounds": {"left": -77, "right": 80, "top": -188, "bottom": 0}, "height": 188}, {"frame": 2, "bounds": {"left": -81, "right": 88, "top": -198, "bottom": 0}, "height": 198}, {"frame": 3, "bounds": {"left": -80, "right": 70, "top": -185, "bottom": 0}, "height": 185}]}, "attack3": {"frames": [{"frame": 0, "bounds": {"left": -70, "right": 64, "top": -190, "bottom": 0}, "height": 190}, {"frame": 1, "bounds": {"left": -68, "right": 53, "top": -198, "bottom": 0}, "height": 198}, {"frame": 2, "bounds": {"left": -68, "right": 140, "top": -195, "bottom": 0}, "height": 195}, {"frame": 3, "bounds": {"left": -73, "right": 64, "top": -191, "bottom": 0}, "height": 191}]}, "skill1": {"frames": [{"frame": 0, "bounds": {"left": -68, "right": 64, "top": -194, "bottom": 0}, "height": 194}, {"frame": 1, "bounds": {"left": -71, "right": 79, "top": -187, "bottom": 0}, "height": 187}, {"frame": 2, "bounds": {"left": -74, "right": 89, "top": -187, "bottom": 0}, "height": 187}, {"frame": 3, "bounds": {"left": -74, "right": 68, "top": -195, "bottom": 0}, "height": 195}]}, "skill2": {"frames": [{"frame": 0, "bounds": {"left": -83, "right": 75, "top": -134, "bottom": 0}, "height": 134}, {"frame": 1, "bounds": {"left": -37, "right": 133, "top": -170, "bottom": 0}, "height": 170}, {"frame": 2, "bounds": {"left": -85, "right": 99, "top": -178, "bottom": 0}, "height": 178}, {"frame": 3, "bounds": {"left": -74, "right": 64, "top": -182, "bottom": 0}, "height": 182}]}, "ultimate": {"frames": [{"frame": 0, "bounds": {"left": -64, "right": 55, "top": -194, "bottom": 0}, "height": 194}, {"frame": 1, "bounds": {"left": -77, "right": 61, "top": -196, "bottom": 0}, "height": 196}, {"frame": 2, "bounds": {"left": -86, "right": 65, "top": -195, "bottom": 0}, "height": 195}, {"frame": 3, "bounds": {"left": -80, "right": 67, "top": -137, "bottom": 0}, "height": 137}]}, "hurt": {"frames": [{"frame": 0, "bounds": {"left": -80, "right": 51, "top": -188, "bottom": 0}, "height": 188}, {"frame": 1, "bounds": {"left": -83, "right": 57, "top": -184, "bottom": 0}, "height": 184}, {"frame": 2, "bounds": {"left": -73, "right": 66, "top": -171, "bottom": 0}, "height": 171}, {"frame": 3, "bounds": {"left": -73, "right": 66, "top": -188, "bottom": 0}, "height": 188}]}, "down": {"frames": [{"frame": 0, "bounds": {"left": -75, "right": 58, "top": -189, "bottom": 0}, "height": 189}, {"frame": 1, "bounds": {"left": -98, "right": 74, "top": -124, "bottom": 0}, "height": 124}, {"frame": 2, "bounds": {"left": -85, "right": 87, "top": -61, "bottom": 0}, "height": 61}, {"frame": 3, "bounds": {"left": -92, "right": 110, "top": -59, "bottom": 0}, "height": 59}]}, "recover": {"frames": [{"frame": 0, "bounds": {"left": -96, "right": 80, "top": -67, "bottom": 0}, "height": 67}, {"frame": 1, "bounds": {"left": -80, "right": 66, "top": -114, "bottom": 0}, "height": 114}, {"frame": 2, "bounds": {"left": -61, "right": 53, "top": -167, "bottom": 0}, "height": 167}, {"frame": 3, "bounds": {"left": -78, "right": 70, "top": -185, "bottom": 0}, "height": 185}]}}, "playback": {"jump": {"airFrame": 0, "torsoPivot": {"x": -6, "y": -98}}, "doublejump": {"frame": 0, "duration": 0.28, "pivot": {"x": 0, "y": -72}}, "measurement": "Centers measured from final curated atlas, including integer air-pose alignment; no runtime resizing."}, "emitters": {"attack1": {"x": 101, "y": -138, "method": "Farthest-right opaque endpoint on final impact frame; six-pixel tip region, facing right"}, "attack2": {"x": 87, "y": -182, "method": "Farthest-right opaque endpoint on final impact frame; six-pixel tip region, facing right"}, "attack3": {"x": 139, "y": -131, "method": "Farthest-right opaque endpoint on final impact frame; six-pixel tip region, facing right"}, "skill1": {"x": 88, "y": -142, "method": "Farthest-right opaque endpoint on final impact frame; six-pixel tip region, facing right"}, "skill2": {"x": 98, "y": -118, "method": "Farthest-right opaque endpoint on final impact frame; six-pixel tip region, facing right"}}}; diff --git a/assets/fenr/human/run/sprite-sheet-alpha.webp b/assets/fenr/human/run/sprite-sheet-alpha.webp new file mode 100644 index 0000000..b3883fa Binary files /dev/null and b/assets/fenr/human/run/sprite-sheet-alpha.webp differ diff --git a/assets/fenr/ui/cutin.webp b/assets/fenr/ui/cutin.webp new file mode 100644 index 0000000..3711981 Binary files /dev/null and b/assets/fenr/ui/cutin.webp differ diff --git a/assets/fenr/ui/fx-bite.webp b/assets/fenr/ui/fx-bite.webp new file mode 100644 index 0000000..6140c98 Binary files /dev/null and b/assets/fenr/ui/fx-bite.webp differ diff --git a/assets/fenr/ui/fx-claw.webp b/assets/fenr/ui/fx-claw.webp new file mode 100644 index 0000000..b723a64 Binary files /dev/null and b/assets/fenr/ui/fx-claw.webp differ diff --git a/assets/fenr/ui/fx-gale.webp b/assets/fenr/ui/fx-gale.webp new file mode 100644 index 0000000..bc775fb Binary files /dev/null and b/assets/fenr/ui/fx-gale.webp differ diff --git a/assets/fenr/ui/fx-howl.webp b/assets/fenr/ui/fx-howl.webp new file mode 100644 index 0000000..82017b8 Binary files /dev/null and b/assets/fenr/ui/fx-howl.webp differ diff --git a/assets/fenr/ui/fx-rush.webp b/assets/fenr/ui/fx-rush.webp new file mode 100644 index 0000000..c4e3783 Binary files /dev/null and b/assets/fenr/ui/fx-rush.webp differ diff --git a/assets/fenr/ui/fx-transform.webp b/assets/fenr/ui/fx-transform.webp new file mode 100644 index 0000000..449ec8c Binary files /dev/null and b/assets/fenr/ui/fx-transform.webp differ diff --git a/assets/fenr/ui/icon-human-basic.webp b/assets/fenr/ui/icon-human-basic.webp new file mode 100644 index 0000000..84a4d7e Binary files /dev/null and b/assets/fenr/ui/icon-human-basic.webp differ diff --git a/assets/fenr/ui/icon-human-skill1.webp b/assets/fenr/ui/icon-human-skill1.webp new file mode 100644 index 0000000..5da9930 Binary files /dev/null and b/assets/fenr/ui/icon-human-skill1.webp differ diff --git a/assets/fenr/ui/icon-human-skill2.webp b/assets/fenr/ui/icon-human-skill2.webp new file mode 100644 index 0000000..3186aec Binary files /dev/null and b/assets/fenr/ui/icon-human-skill2.webp differ diff --git a/assets/fenr/ui/icon-ultimate.webp b/assets/fenr/ui/icon-ultimate.webp new file mode 100644 index 0000000..806761c Binary files /dev/null and b/assets/fenr/ui/icon-ultimate.webp differ diff --git a/assets/fenr/ui/icon-wolf-basic.webp b/assets/fenr/ui/icon-wolf-basic.webp new file mode 100644 index 0000000..b491056 Binary files /dev/null and b/assets/fenr/ui/icon-wolf-basic.webp differ diff --git a/assets/fenr/ui/icon-wolf-skill1.webp b/assets/fenr/ui/icon-wolf-skill1.webp new file mode 100644 index 0000000..2614f15 Binary files /dev/null and b/assets/fenr/ui/icon-wolf-skill1.webp differ diff --git a/assets/fenr/ui/icon-wolf-skill2.webp b/assets/fenr/ui/icon-wolf-skill2.webp new file mode 100644 index 0000000..8e84c71 Binary files /dev/null and b/assets/fenr/ui/icon-wolf-skill2.webp differ diff --git a/assets/fenr/ui/portrait-human.webp b/assets/fenr/ui/portrait-human.webp new file mode 100644 index 0000000..3d3cbea Binary files /dev/null and b/assets/fenr/ui/portrait-human.webp differ diff --git a/assets/fenr/ui/portrait-wolf.webp b/assets/fenr/ui/portrait-wolf.webp new file mode 100644 index 0000000..07f9851 Binary files /dev/null and b/assets/fenr/ui/portrait-wolf.webp differ diff --git a/assets/fenr/wolf/manifest.js b/assets/fenr/wolf/manifest.js new file mode 100644 index 0000000..01fe408 --- /dev/null +++ b/assets/fenr/wolf/manifest.js @@ -0,0 +1,2 @@ +window.FENR_WOLF_MANIFEST = {"characterId": "fenr-wolf", "engine": "component-row", "game_input": "sprite-sheet-alpha.png", "degraded_static_fallback": false, "curation_applied": true, "frame_variant": "pixel", "sprite_sheet_alpha": "sprite-sheet-alpha.png", "sprite_sheet_alpha_report": "sprite-sheet-alpha.report.json", "base_image": "base-source.png", "cell": {"shape": "rect", "width": 448, "height": 288, "safe_margin_x": 10, "safe_margin_y": 8}, "chroma_key": {"name": "magenta", "hex": "#FF00FF", "rgb": [255, 0, 255], "selection": "manual"}, "animation": {"cellWidth": 448, "cellHeight": 288, "columns": 4, "rows": {"idle": {"row": 0, "frames": 4, "fps": 5, "durations_ms": [200, 200, 200, 200], "loop": true, "frame_variant": "pixel"}, "walk": {"row": 1, "frames": 4, "fps": 10, "durations_ms": [100, 100, 100, 100], "loop": true, "frame_variant": "pixel"}, "run": {"row": 2, "frames": 4, "fps": 13, "durations_ms": [77, 77, 77, 77], "loop": true, "frame_variant": "pixel"}, "jump": {"row": 3, "frames": 1, "fps": 10, "durations_ms": [100], "loop": false, "frame_variant": "pixel"}, "doublejump": {"row": 4, "frames": 1, "fps": 12, "durations_ms": [83], "loop": false, "frame_variant": "pixel"}, "crouch": {"row": 5, "frames": 4, "fps": 8, "durations_ms": [125, 125, 125, 125], "loop": false, "frame_variant": "pixel"}, "attack1": {"row": 6, "frames": 4, "fps": 13, "durations_ms": [77, 77, 77, 77], "loop": false, "frame_variant": "pixel"}, "attack2": {"row": 7, "frames": 4, "fps": 13, "durations_ms": [77, 77, 77, 77], "loop": false, "frame_variant": "pixel"}, "attack3": {"row": 8, "frames": 4, "fps": 11, "durations_ms": [91, 91, 91, 91], "loop": false, "frame_variant": "pixel"}, "skill1": {"row": 9, "frames": 4, "fps": 11, "durations_ms": [91, 91, 91, 91], "loop": false, "frame_variant": "pixel"}, "skill2": {"row": 10, "frames": 4, "fps": 10, "durations_ms": [100, 100, 100, 100], "loop": false, "frame_variant": "pixel"}, "ultimate": {"row": 11, "frames": 4, "fps": 9, "durations_ms": [111, 111, 111, 111], "loop": false, "frame_variant": "pixel"}, "hurt": {"row": 12, "frames": 4, "fps": 9, "durations_ms": [111, 111, 111, 111], "loop": false, "frame_variant": "pixel"}, "down": {"row": 13, "frames": 4, "fps": 7, "durations_ms": [143, 143, 143, 143], "loop": false, "frame_variant": "pixel"}, "recover": {"row": 14, "frames": 4, "fps": 8, "durations_ms": [125, 125, 125, 125], "loop": false, "frame_variant": "pixel"}}}, "frame_layout": {"sheetWidth": 1792, "sheetHeight": 4320, "cellWidth": 448, "cellHeight": 288, "rows": {"idle": [{"x": 0, "y": 0, "w": 448, "h": 288}, {"x": 448, "y": 0, "w": 448, "h": 288}, {"x": 896, "y": 0, "w": 448, "h": 288}, {"x": 1344, "y": 0, "w": 448, "h": 288}], "walk": [{"x": 0, "y": 288, "w": 448, "h": 288}, {"x": 448, "y": 288, "w": 448, "h": 288}, {"x": 896, "y": 288, "w": 448, "h": 288}, {"x": 1344, "y": 288, "w": 448, "h": 288}], "run": [{"x": 0, "y": 576, "w": 448, "h": 288}, {"x": 448, "y": 576, "w": 448, "h": 288}, {"x": 896, "y": 576, "w": 448, "h": 288}, {"x": 1344, "y": 576, "w": 448, "h": 288}], "jump": [{"x": 0, "y": 864, "w": 448, "h": 288}], "doublejump": [{"x": 0, "y": 1152, "w": 448, "h": 288}], "crouch": [{"x": 0, "y": 1440, "w": 448, "h": 288}, {"x": 448, "y": 1440, "w": 448, "h": 288}, {"x": 896, "y": 1440, "w": 448, "h": 288}, {"x": 1344, "y": 1440, "w": 448, "h": 288}], "attack1": [{"x": 0, "y": 1728, "w": 448, "h": 288}, {"x": 448, "y": 1728, "w": 448, "h": 288}, {"x": 896, "y": 1728, "w": 448, "h": 288}, {"x": 1344, "y": 1728, "w": 448, "h": 288}], "attack2": [{"x": 0, "y": 2016, "w": 448, "h": 288}, {"x": 448, "y": 2016, "w": 448, "h": 288}, {"x": 896, "y": 2016, "w": 448, "h": 288}, {"x": 1344, "y": 2016, "w": 448, "h": 288}], "attack3": [{"x": 0, "y": 2304, "w": 448, "h": 288}, {"x": 448, "y": 2304, "w": 448, "h": 288}, {"x": 896, "y": 2304, "w": 448, "h": 288}, {"x": 1344, "y": 2304, "w": 448, "h": 288}], "skill1": [{"x": 0, "y": 2592, "w": 448, "h": 288}, {"x": 448, "y": 2592, "w": 448, "h": 288}, {"x": 896, "y": 2592, "w": 448, "h": 288}, {"x": 1344, "y": 2592, "w": 448, "h": 288}], "skill2": [{"x": 0, "y": 2880, "w": 448, "h": 288}, {"x": 448, "y": 2880, "w": 448, "h": 288}, {"x": 896, "y": 2880, "w": 448, "h": 288}, {"x": 1344, "y": 2880, "w": 448, "h": 288}], "ultimate": [{"x": 0, "y": 3168, "w": 448, "h": 288}, {"x": 448, "y": 3168, "w": 448, "h": 288}, {"x": 896, "y": 3168, "w": 448, "h": 288}, {"x": 1344, "y": 3168, "w": 448, "h": 288}], "hurt": [{"x": 0, "y": 3456, "w": 448, "h": 288}, {"x": 448, "y": 3456, "w": 448, "h": 288}, {"x": 896, "y": 3456, "w": 448, "h": 288}, {"x": 1344, "y": 3456, "w": 448, "h": 288}], "down": [{"x": 0, "y": 3744, "w": 448, "h": 288}, {"x": 448, "y": 3744, "w": 448, "h": 288}, {"x": 896, "y": 3744, "w": 448, "h": 288}, {"x": 1344, "y": 3744, "w": 448, "h": 288}], "recover": [{"x": 0, "y": 4032, "w": 448, "h": 288}, {"x": 448, "y": 4032, "w": 448, "h": 288}, {"x": 896, "y": 4032, "w": 448, "h": 288}, {"x": 1344, "y": 4032, "w": 448, "h": 288}]}}}; +window.FENR_WOLF_METRICS = {"anchor": {"x": 224, "y": 280}, "states": {"idle": {"frames": [{"frame": 0, "bounds": {"left": -77, "right": 79, "top": -216, "bottom": 0}, "height": 216}, {"frame": 1, "bounds": {"left": -71, "right": 74, "top": -216, "bottom": 0}, "height": 216}, {"frame": 2, "bounds": {"left": -73, "right": 73, "top": -217, "bottom": 0}, "height": 217}, {"frame": 3, "bounds": {"left": -75, "right": 75, "top": -216, "bottom": 0}, "height": 216}]}, "walk": {"frames": [{"frame": 0, "bounds": {"left": -85, "right": 69, "top": -221, "bottom": 0}, "height": 221}, {"frame": 1, "bounds": {"left": -81, "right": 60, "top": -231, "bottom": 0}, "height": 231}, {"frame": 2, "bounds": {"left": -91, "right": 76, "top": -229, "bottom": 0}, "height": 229}, {"frame": 3, "bounds": {"left": -72, "right": 53, "top": -231, "bottom": 0}, "height": 231}]}, "run": {"frames": [{"frame": 0, "bounds": {"left": -103, "right": 99, "top": -206, "bottom": 0}, "height": 206}, {"frame": 1, "bounds": {"left": -74, "right": 73, "top": -209, "bottom": 0}, "height": 209}, {"frame": 2, "bounds": {"left": -105, "right": 105, "top": -207, "bottom": 0}, "height": 207}, {"frame": 3, "bounds": {"left": -88, "right": 75, "top": -217, "bottom": 0}, "height": 217}]}, "jump": {"frames": [{"frame": 0, "bounds": {"left": -120, "right": 83, "top": -219, "bottom": 0}, "height": 219}]}, "doublejump": {"frames": [{"frame": 0, "bounds": {"left": -89, "right": 72, "top": -173, "bottom": 0}, "height": 173}]}, "crouch": {"frames": [{"frame": 0, "bounds": {"left": -76, "right": 78, "top": -199, "bottom": 0}, "height": 199}, {"frame": 1, "bounds": {"left": -94, "right": 71, "top": -169, "bottom": 0}, "height": 169}, {"frame": 2, "bounds": {"left": -78, "right": 81, "top": -198, "bottom": 0}, "height": 198}, {"frame": 3, "bounds": {"left": -77, "right": 79, "top": -205, "bottom": 0}, "height": 205}]}, "attack1": {"frames": [{"frame": 0, "bounds": {"left": -85, "right": 86, "top": -235, "bottom": 0}, "height": 235}, {"frame": 1, "bounds": {"left": -89, "right": 84, "top": -235, "bottom": 0}, "height": 235}, {"frame": 2, "bounds": {"left": -87, "right": 107, "top": -211, "bottom": 0}, "height": 211}, {"frame": 3, "bounds": {"left": -78, "right": 83, "top": -235, "bottom": 0}, "height": 235}]}, "attack2": {"frames": [{"frame": 0, "bounds": {"left": -98, "right": 92, "top": -187, "bottom": 0}, "height": 187}, {"frame": 1, "bounds": {"left": -99, "right": 89, "top": -223, "bottom": 0}, "height": 223}, {"frame": 2, "bounds": {"left": -93, "right": 81, "top": -272, "bottom": 0}, "height": 272}, {"frame": 3, "bounds": {"left": -99, "right": 89, "top": -220, "bottom": 0}, "height": 220}]}, "attack3": {"frames": [{"frame": 0, "bounds": {"left": -88, "right": 93, "top": -220, "bottom": 0}, "height": 220}, {"frame": 1, "bounds": {"left": -89, "right": 89, "top": -211, "bottom": 0}, "height": 211}, {"frame": 2, "bounds": {"left": -101, "right": 120, "top": -212, "bottom": 0}, "height": 212}, {"frame": 3, "bounds": {"left": -91, "right": 89, "top": -219, "bottom": 0}, "height": 219}]}, "skill1": {"frames": [{"frame": 0, "bounds": {"left": -96, "right": 95, "top": -202, "bottom": 0}, "height": 202}, {"frame": 1, "bounds": {"left": -37, "right": 179, "top": -218, "bottom": 0}, "height": 218}, {"frame": 2, "bounds": {"left": -52, "right": 200, "top": -204, "bottom": 0}, "height": 204}, {"frame": 3, "bounds": {"left": -90, "right": 109, "top": -183, "bottom": 0}, "height": 183}]}, "skill2": {"frames": [{"frame": 0, "bounds": {"left": -87, "right": 84, "top": -224, "bottom": 0}, "height": 224}, {"frame": 1, "bounds": {"left": -86, "right": 84, "top": -227, "bottom": 0}, "height": 227}, {"frame": 2, "bounds": {"left": -106, "right": 93, "top": -224, "bottom": 0}, "height": 224}, {"frame": 3, "bounds": {"left": -92, "right": 82, "top": -222, "bottom": 0}, "height": 222}]}, "ultimate": {"frames": [{"frame": 0, "bounds": {"left": -83, "right": 89, "top": -228, "bottom": 0}, "height": 228}, {"frame": 1, "bounds": {"left": -101, "right": 94, "top": -245, "bottom": 0}, "height": 245}, {"frame": 2, "bounds": {"left": -87, "right": 94, "top": -227, "bottom": 0}, "height": 227}, {"frame": 3, "bounds": {"left": -85, "right": 88, "top": -228, "bottom": 0}, "height": 228}]}, "hurt": {"frames": [{"frame": 0, "bounds": {"left": -86, "right": 85, "top": -221, "bottom": 0}, "height": 221}, {"frame": 1, "bounds": {"left": -97, "right": 72, "top": -222, "bottom": 0}, "height": 222}, {"frame": 2, "bounds": {"left": -84, "right": 62, "top": -196, "bottom": 0}, "height": 196}, {"frame": 3, "bounds": {"left": -85, "right": 83, "top": -222, "bottom": 0}, "height": 222}]}, "down": {"frames": [{"frame": 0, "bounds": {"left": -77, "right": 84, "top": -200, "bottom": 0}, "height": 200}, {"frame": 1, "bounds": {"left": -110, "right": 75, "top": -147, "bottom": 0}, "height": 147}, {"frame": 2, "bounds": {"left": -95, "right": 117, "top": -83, "bottom": 0}, "height": 83}, {"frame": 3, "bounds": {"left": -94, "right": 122, "top": -77, "bottom": 0}, "height": 77}]}, "recover": {"frames": [{"frame": 0, "bounds": {"left": -112, "right": 121, "top": -79, "bottom": 0}, "height": 79}, {"frame": 1, "bounds": {"left": -87, "right": 82, "top": -138, "bottom": 0}, "height": 138}, {"frame": 2, "bounds": {"left": -78, "right": 91, "top": -167, "bottom": 0}, "height": 167}, {"frame": 3, "bounds": {"left": -90, "right": 86, "top": -218, "bottom": 0}, "height": 218}]}}, "playback": {"jump": {"airFrame": 0, "torsoPivot": {"x": -18, "y": -110}}, "doublejump": {"frame": 0, "duration": 0.28, "pivot": {"x": -8, "y": -86}}, "measurement": "Centers measured from final curated atlas, including integer air-pose alignment; no runtime resizing."}, "emitters": {"attack1": {"x": 106, "y": -144, "method": "Farthest-right opaque endpoint on final impact frame; six-pixel tip region, facing right"}, "attack2": {"x": 42, "y": -258, "method": "Raised claw at top22px of uppercut impact silhouette; far-right-foot detector rejected"}, "attack3": {"x": 119, "y": -153, "method": "Farthest-right opaque endpoint on final impact frame; six-pixel tip region, facing right"}, "skill1": {"x": 199, "y": -150, "method": "Farthest-right opaque endpoint on final impact frame; six-pixel tip region, facing right"}, "skill2": {"x": 91, "y": -144, "method": "Farthest-right opaque endpoint on final impact frame; six-pixel tip region, facing right"}}}; diff --git a/assets/fenr/wolf/run/sprite-sheet-alpha.webp b/assets/fenr/wolf/run/sprite-sheet-alpha.webp new file mode 100644 index 0000000..f41656b Binary files /dev/null and b/assets/fenr/wolf/run/sprite-sheet-alpha.webp differ diff --git a/assets/fonts/OFL.txt b/assets/fonts/OFL.txt new file mode 100644 index 0000000..6dd8096 --- /dev/null +++ b/assets/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright (c) 2014, Indian Type Foundry (info@indiantypefoundry.com). + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/Rajdhani-Bold.ttf b/assets/fonts/Rajdhani-Bold.ttf new file mode 100644 index 0000000..47af157 Binary files /dev/null and b/assets/fonts/Rajdhani-Bold.ttf differ diff --git a/assets/fonts/Rajdhani-Medium.ttf b/assets/fonts/Rajdhani-Medium.ttf new file mode 100644 index 0000000..a6960c6 Binary files /dev/null and b/assets/fonts/Rajdhani-Medium.ttf differ diff --git a/assets/haldor/audio/haldor-ultimate.mp3 b/assets/haldor/audio/haldor-ultimate.mp3 new file mode 100644 index 0000000..e0fdc43 Binary files /dev/null and b/assets/haldor/audio/haldor-ultimate.mp3 differ diff --git a/assets/haldor/audio/haldor_wins.mp3 b/assets/haldor/audio/haldor_wins.mp3 new file mode 100644 index 0000000..5c26592 Binary files /dev/null and b/assets/haldor/audio/haldor_wins.mp3 differ diff --git a/assets/haldor/audio/select_haldor.mp3 b/assets/haldor/audio/select_haldor.mp3 new file mode 100644 index 0000000..6ceda29 Binary files /dev/null and b/assets/haldor/audio/select_haldor.mp3 differ diff --git a/assets/haldor/manifest.js b/assets/haldor/manifest.js new file mode 100644 index 0000000..309b350 --- /dev/null +++ b/assets/haldor/manifest.js @@ -0,0 +1,2 @@ +window.HALDOR_MANIFEST = {"characterId": "haldor", "engine": "component-row", "game_input": "sprite-sheet-alpha.png", "degraded_static_fallback": false, "curation_applied": true, "frame_variant": "pixel", "sprite_sheet_alpha": "sprite-sheet-alpha.png", "sprite_sheet_alpha_report": "sprite-sheet-alpha.report.json", "base_image": "base-source.png", "cell": {"shape": "rect", "width": 448, "height": 288, "safe_margin_x": 10, "safe_margin_y": 8}, "chroma_key": {"name": "green", "hex": "#00FF00", "rgb": [0, 255, 0], "selection": "manual"}, "animation": {"cellWidth": 448, "cellHeight": 288, "columns": 4, "rows": {"idle": {"row": 0, "frames": 4, "fps": 5, "durations_ms": [200, 200, 200, 200], "loop": true, "frame_variant": "pixel"}, "walk": {"row": 1, "frames": 4, "fps": 9, "durations_ms": [111, 111, 111, 111], "loop": true, "frame_variant": "pixel"}, "run": {"row": 2, "frames": 4, "fps": 11, "durations_ms": [91, 91, 91, 91], "loop": true, "frame_variant": "pixel"}, "jump": {"row": 3, "frames": 1, "fps": 10, "durations_ms": [100], "loop": false, "frame_variant": "pixel"}, "doublejump": {"row": 4, "frames": 1, "fps": 12, "durations_ms": [83], "loop": false, "frame_variant": "pixel"}, "crouch": {"row": 5, "frames": 4, "fps": 8, "durations_ms": [125, 125, 125, 125], "loop": false, "frame_variant": "pixel"}, "attack1": {"row": 6, "frames": 4, "fps": 11, "durations_ms": [91, 91, 91, 91], "loop": false, "frame_variant": "pixel"}, "attack2": {"row": 7, "frames": 4, "fps": 11, "durations_ms": [91, 91, 91, 91], "loop": false, "frame_variant": "pixel"}, "attack3": {"row": 8, "frames": 4, "fps": 9, "durations_ms": [111, 111, 111, 111], "loop": false, "frame_variant": "pixel"}, "skill1": {"row": 9, "frames": 4, "fps": 10, "durations_ms": [100, 100, 100, 100], "loop": false, "frame_variant": "pixel"}, "skill2": {"row": 10, "frames": 4, "fps": 10, "durations_ms": [100, 100, 100, 100], "loop": false, "frame_variant": "pixel"}, "ultimate": {"row": 11, "frames": 4, "fps": 8, "durations_ms": [125, 125, 125, 125], "loop": false, "frame_variant": "pixel"}, "hurt": {"row": 12, "frames": 4, "fps": 10, "durations_ms": [100, 100, 100, 100], "loop": false, "frame_variant": "pixel"}, "down": {"row": 13, "frames": 4, "fps": 8, "durations_ms": [125, 125, 125, 125], "loop": false, "frame_variant": "pixel"}, "recover": {"row": 14, "frames": 4, "fps": 10, "durations_ms": [100, 100, 100, 100], "loop": false, "frame_variant": "pixel"}}}, "frame_layout": {"sheetWidth": 1792, "sheetHeight": 4320, "cellWidth": 448, "cellHeight": 288, "rows": {"idle": [{"x": 0, "y": 0, "w": 448, "h": 288}, {"x": 448, "y": 0, "w": 448, "h": 288}, {"x": 896, "y": 0, "w": 448, "h": 288}, {"x": 1344, "y": 0, "w": 448, "h": 288}], "walk": [{"x": 0, "y": 288, "w": 448, "h": 288}, {"x": 448, "y": 288, "w": 448, "h": 288}, {"x": 896, "y": 288, "w": 448, "h": 288}, {"x": 1344, "y": 288, "w": 448, "h": 288}], "run": [{"x": 0, "y": 576, "w": 448, "h": 288}, {"x": 448, "y": 576, "w": 448, "h": 288}, {"x": 896, "y": 576, "w": 448, "h": 288}, {"x": 1344, "y": 576, "w": 448, "h": 288}], "jump": [{"x": 0, "y": 864, "w": 448, "h": 288}], "doublejump": [{"x": 0, "y": 1152, "w": 448, "h": 288}], "crouch": [{"x": 0, "y": 1440, "w": 448, "h": 288}, {"x": 448, "y": 1440, "w": 448, "h": 288}, {"x": 896, "y": 1440, "w": 448, "h": 288}, {"x": 1344, "y": 1440, "w": 448, "h": 288}], "attack1": [{"x": 0, "y": 1728, "w": 448, "h": 288}, {"x": 448, "y": 1728, "w": 448, "h": 288}, {"x": 896, "y": 1728, "w": 448, "h": 288}, {"x": 1344, "y": 1728, "w": 448, "h": 288}], "attack2": [{"x": 0, "y": 2016, "w": 448, "h": 288}, {"x": 448, "y": 2016, "w": 448, "h": 288}, {"x": 896, "y": 2016, "w": 448, "h": 288}, {"x": 1344, "y": 2016, "w": 448, "h": 288}], "attack3": [{"x": 0, "y": 2304, "w": 448, "h": 288}, {"x": 448, "y": 2304, "w": 448, "h": 288}, {"x": 896, "y": 2304, "w": 448, "h": 288}, {"x": 1344, "y": 2304, "w": 448, "h": 288}], "skill1": [{"x": 0, "y": 2592, "w": 448, "h": 288}, {"x": 448, "y": 2592, "w": 448, "h": 288}, {"x": 896, "y": 2592, "w": 448, "h": 288}, {"x": 1344, "y": 2592, "w": 448, "h": 288}], "skill2": [{"x": 0, "y": 2880, "w": 448, "h": 288}, {"x": 448, "y": 2880, "w": 448, "h": 288}, {"x": 896, "y": 2880, "w": 448, "h": 288}, {"x": 1344, "y": 2880, "w": 448, "h": 288}], "ultimate": [{"x": 0, "y": 3168, "w": 448, "h": 288}, {"x": 448, "y": 3168, "w": 448, "h": 288}, {"x": 896, "y": 3168, "w": 448, "h": 288}, {"x": 1344, "y": 3168, "w": 448, "h": 288}], "hurt": [{"x": 0, "y": 3456, "w": 448, "h": 288}, {"x": 448, "y": 3456, "w": 448, "h": 288}, {"x": 896, "y": 3456, "w": 448, "h": 288}, {"x": 1344, "y": 3456, "w": 448, "h": 288}], "down": [{"x": 0, "y": 3744, "w": 448, "h": 288}, {"x": 448, "y": 3744, "w": 448, "h": 288}, {"x": 896, "y": 3744, "w": 448, "h": 288}, {"x": 1344, "y": 3744, "w": 448, "h": 288}], "recover": [{"x": 0, "y": 4032, "w": 448, "h": 288}, {"x": 448, "y": 4032, "w": 448, "h": 288}, {"x": 896, "y": 4032, "w": 448, "h": 288}, {"x": 1344, "y": 4032, "w": 448, "h": 288}]}}}; +window.HALDOR_METRICS = {"anchor": {"x": 224, "y": 280}, "states": {"idle": {"frames": [{"frame": 0, "bounds": {"left": -81, "right": 85, "top": -169, "bottom": 0}, "height": 169}, {"frame": 1, "bounds": {"left": -81, "right": 87, "top": -169, "bottom": 0}, "height": 169}, {"frame": 2, "bounds": {"left": -82, "right": 87, "top": -169, "bottom": 0}, "height": 169}, {"frame": 3, "bounds": {"left": -81, "right": 83, "top": -169, "bottom": 0}, "height": 169}]}, "walk": {"frames": [{"frame": 0, "bounds": {"left": -77, "right": 87, "top": -167, "bottom": 0}, "height": 167}, {"frame": 1, "bounds": {"left": -77, "right": 82, "top": -167, "bottom": 0}, "height": 167}, {"frame": 2, "bounds": {"left": -77, "right": 86, "top": -167, "bottom": 0}, "height": 167}, {"frame": 3, "bounds": {"left": -77, "right": 72, "top": -167, "bottom": 0}, "height": 167}]}, "run": {"frames": [{"frame": 0, "bounds": {"left": -99, "right": 68, "top": -157, "bottom": 0}, "height": 157}, {"frame": 1, "bounds": {"left": -99, "right": 55, "top": -156, "bottom": 0}, "height": 156}, {"frame": 2, "bounds": {"left": -99, "right": 67, "top": -157, "bottom": 0}, "height": 157}, {"frame": 3, "bounds": {"left": -99, "right": 55, "top": -156, "bottom": 0}, "height": 156}]}, "jump": {"frames": [{"frame": 0, "bounds": {"left": -100, "right": 65, "top": -218, "bottom": 0}, "height": 218}]}, "doublejump": {"frames": [{"frame": 0, "bounds": {"left": -59, "right": 94, "top": -144, "bottom": 0}, "height": 144}]}, "crouch": {"frames": [{"frame": 0, "bounds": {"left": -67, "right": 94, "top": -149, "bottom": 0}, "height": 149}, {"frame": 1, "bounds": {"left": -77, "right": 72, "top": -133, "bottom": 0}, "height": 133}, {"frame": 2, "bounds": {"left": -79, "right": 74, "top": -130, "bottom": 0}, "height": 130}, {"frame": 3, "bounds": {"left": -80, "right": 73, "top": -146, "bottom": 0}, "height": 146}]}, "attack1": {"frames": [{"frame": 0, "bounds": {"left": -97, "right": 73, "top": -157, "bottom": 0}, "height": 157}, {"frame": 1, "bounds": {"left": -69, "right": 105, "top": -158, "bottom": 0}, "height": 158}, {"frame": 2, "bounds": {"left": -69, "right": 131, "top": -157, "bottom": 0}, "height": 157}, {"frame": 3, "bounds": {"left": -73, "right": 68, "top": -158, "bottom": 0}, "height": 158}]}, "attack2": {"frames": [{"frame": 0, "bounds": {"left": -82, "right": 83, "top": -153, "bottom": 0}, "height": 153}, {"frame": 1, "bounds": {"left": -67, "right": 101, "top": -184, "bottom": 0}, "height": 184}, {"frame": 2, "bounds": {"left": -68, "right": 83, "top": -222, "bottom": 0}, "height": 222}, {"frame": 3, "bounds": {"left": -79, "right": 75, "top": -153, "bottom": 0}, "height": 153}]}, "attack3": {"frames": [{"frame": 0, "bounds": {"left": -82, "right": 70, "top": -225, "bottom": 0}, "height": 225}, {"frame": 1, "bounds": {"left": -72, "right": 112, "top": -198, "bottom": 0}, "height": 198}, {"frame": 2, "bounds": {"left": -101, "right": 85, "top": -151, "bottom": 0}, "height": 151}, {"frame": 3, "bounds": {"left": -72, "right": 97, "top": -197, "bottom": 0}, "height": 197}]}, "skill1": {"frames": [{"frame": 0, "bounds": {"left": -75, "right": 70, "top": -156, "bottom": 0}, "height": 156}, {"frame": 1, "bounds": {"left": -85, "right": 73, "top": -146, "bottom": 0}, "height": 146}, {"frame": 2, "bounds": {"left": -77, "right": 108, "top": -156, "bottom": 0}, "height": 156}, {"frame": 3, "bounds": {"left": -73, "right": 72, "top": -156, "bottom": 0}, "height": 156}]}, "skill2": {"frames": [{"frame": 0, "bounds": {"left": -76, "right": 80, "top": -136, "bottom": 0}, "height": 136}, {"frame": 1, "bounds": {"left": -69, "right": 88, "top": -150, "bottom": 0}, "height": 150}, {"frame": 2, "bounds": {"left": -78, "right": 95, "top": -137, "bottom": 0}, "height": 137}, {"frame": 3, "bounds": {"left": -74, "right": 76, "top": -151, "bottom": 0}, "height": 151}]}, "ultimate": {"frames": [{"frame": 0, "bounds": {"left": -72, "right": 75, "top": -225, "bottom": 0}, "height": 225}, {"frame": 1, "bounds": {"left": -99, "right": 89, "top": -159, "bottom": 0}, "height": 159}, {"frame": 2, "bounds": {"left": -99, "right": 87, "top": -148, "bottom": 0}, "height": 148}, {"frame": 3, "bounds": {"left": -84, "right": 85, "top": -172, "bottom": 0}, "height": 172}]}, "hurt": {"frames": [{"frame": 0, "bounds": {"left": -102, "right": 76, "top": -162, "bottom": 0}, "height": 162}, {"frame": 1, "bounds": {"left": -70, "right": 87, "top": -156, "bottom": 0}, "height": 156}, {"frame": 2, "bounds": {"left": -98, "right": 70, "top": -181, "bottom": 0}, "height": 181}, {"frame": 3, "bounds": {"left": -77, "right": 78, "top": -155, "bottom": 0}, "height": 155}]}, "down": {"frames": [{"frame": 0, "bounds": {"left": -82, "right": 64, "top": -164, "bottom": 0}, "height": 164}, {"frame": 1, "bounds": {"left": -79, "right": 78, "top": -130, "bottom": 0}, "height": 130}, {"frame": 2, "bounds": {"left": -58, "right": 101, "top": -82, "bottom": 0}, "height": 82}, {"frame": 3, "bounds": {"left": -59, "right": 100, "top": -95, "bottom": 0}, "height": 95}]}, "recover": {"frames": [{"frame": 0, "bounds": {"left": -86, "right": 108, "top": -110, "bottom": 0}, "height": 110}, {"frame": 1, "bounds": {"left": -70, "right": 62, "top": -128, "bottom": 0}, "height": 128}, {"frame": 2, "bounds": {"left": -66, "right": 68, "top": -156, "bottom": 0}, "height": 156}, {"frame": 3, "bounds": {"left": -69, "right": 75, "top": -171, "bottom": 0}, "height": 171}]}}, "playback": {"jump": {"airFrame": 0, "torsoPivot": {"x": -18, "y": -109}}, "doublejump": {"frame": 0, "duration": 0.28, "pivot": {"x": 18, "y": -72}}, "measurement": "Centers measured from final curated atlas, including integer air-pose alignment; no runtime resizing."}, "emitters": {"attack1": {"x": 131, "y": -83, "frame": 2, "method": "Farthest-right opaque endpoint on impact frame; six-pixel tip region, facing right"}, "attack2": {"x": 83, "y": -212, "frame": 2, "method": "Farthest-right opaque endpoint on impact frame; six-pixel tip region, facing right"}, "attack3": {"x": 85, "y": -10, "frame": 2, "method": "Farthest-right opaque endpoint on impact frame; six-pixel tip region, facing right"}, "skill1": {"x": 108, "y": -111, "frame": 2, "method": "Farthest-right opaque endpoint on impact frame; six-pixel tip region, facing right"}, "skill2": {"x": 95, "y": -62, "frame": 2, "method": "Farthest-right opaque endpoint on impact frame; six-pixel tip region, facing right"}}}; diff --git a/assets/haldor/run/sprite-sheet-alpha.webp b/assets/haldor/run/sprite-sheet-alpha.webp new file mode 100644 index 0000000..a38001b Binary files /dev/null and b/assets/haldor/run/sprite-sheet-alpha.webp differ diff --git a/assets/haldor/ui/cutin.webp b/assets/haldor/ui/cutin.webp new file mode 100644 index 0000000..324584f Binary files /dev/null and b/assets/haldor/ui/cutin.webp differ diff --git a/assets/haldor/ui/fx-quake.webp b/assets/haldor/ui/fx-quake.webp new file mode 100644 index 0000000..23f048e Binary files /dev/null and b/assets/haldor/ui/fx-quake.webp differ diff --git a/assets/haldor/ui/fx-slag.webp b/assets/haldor/ui/fx-slag.webp new file mode 100644 index 0000000..0727b91 Binary files /dev/null and b/assets/haldor/ui/fx-slag.webp differ diff --git a/assets/haldor/ui/fx-splash.webp b/assets/haldor/ui/fx-splash.webp new file mode 100644 index 0000000..bd97961 Binary files /dev/null and b/assets/haldor/ui/fx-splash.webp differ diff --git a/assets/haldor/ui/fx-steam.webp b/assets/haldor/ui/fx-steam.webp new file mode 100644 index 0000000..91b4fca Binary files /dev/null and b/assets/haldor/ui/fx-steam.webp differ diff --git a/assets/haldor/ui/icon-basic.webp b/assets/haldor/ui/icon-basic.webp new file mode 100644 index 0000000..4418f78 Binary files /dev/null and b/assets/haldor/ui/icon-basic.webp differ diff --git a/assets/haldor/ui/icon-skill1.webp b/assets/haldor/ui/icon-skill1.webp new file mode 100644 index 0000000..4555a8e Binary files /dev/null and b/assets/haldor/ui/icon-skill1.webp differ diff --git a/assets/haldor/ui/icon-skill2.webp b/assets/haldor/ui/icon-skill2.webp new file mode 100644 index 0000000..8f1c5cf Binary files /dev/null and b/assets/haldor/ui/icon-skill2.webp differ diff --git a/assets/haldor/ui/icon-ultimate.webp b/assets/haldor/ui/icon-ultimate.webp new file mode 100644 index 0000000..981de5b Binary files /dev/null and b/assets/haldor/ui/icon-ultimate.webp differ diff --git a/assets/haldor/ui/portrait.webp b/assets/haldor/ui/portrait.webp new file mode 100644 index 0000000..b3bdf99 Binary files /dev/null and b/assets/haldor/ui/portrait.webp differ diff --git a/assets/isolde/audio/isolde-ultimate.mp3 b/assets/isolde/audio/isolde-ultimate.mp3 new file mode 100644 index 0000000..4a90342 Binary files /dev/null and b/assets/isolde/audio/isolde-ultimate.mp3 differ diff --git a/assets/isolde/audio/isolde_wins.mp3 b/assets/isolde/audio/isolde_wins.mp3 new file mode 100644 index 0000000..52ac1d8 Binary files /dev/null and b/assets/isolde/audio/isolde_wins.mp3 differ diff --git a/assets/isolde/audio/select_isolde.mp3 b/assets/isolde/audio/select_isolde.mp3 new file mode 100644 index 0000000..47900cb Binary files /dev/null and b/assets/isolde/audio/select_isolde.mp3 differ diff --git a/assets/isolde/manifest.js b/assets/isolde/manifest.js new file mode 100644 index 0000000..5a5639e --- /dev/null +++ b/assets/isolde/manifest.js @@ -0,0 +1,2 @@ +window.ISOLDE_MANIFEST = {"characterId": "isolde", "engine": "component-row", "game_input": "sprite-sheet-alpha.png", "degraded_static_fallback": false, "curation_applied": true, "frame_variant": "pixel", "sprite_sheet_alpha": "sprite-sheet-alpha.png", "sprite_sheet_alpha_report": "sprite-sheet-alpha.report.json", "base_image": "base-source.png", "cell": {"shape": "rect", "width": 448, "height": 288, "safe_margin_x": 10, "safe_margin_y": 8}, "chroma_key": {"name": "green", "hex": "#00FF00", "rgb": [0, 255, 0], "selection": "manual"}, "animation": {"cellWidth": 448, "cellHeight": 288, "columns": 4, "rows": {"idle": {"row": 0, "frames": 4, "fps": 5, "durations_ms": [200, 200, 200, 200], "loop": true, "frame_variant": "pixel"}, "walk": {"row": 1, "frames": 4, "fps": 9, "durations_ms": [111, 111, 111, 111], "loop": true, "frame_variant": "pixel"}, "run": {"row": 2, "frames": 4, "fps": 11, "durations_ms": [91, 91, 91, 91], "loop": true, "frame_variant": "pixel"}, "jump": {"row": 3, "frames": 1, "fps": 10, "durations_ms": [100], "loop": false, "frame_variant": "pixel"}, "doublejump": {"row": 4, "frames": 1, "fps": 12, "durations_ms": [83], "loop": false, "frame_variant": "pixel"}, "crouch": {"row": 5, "frames": 4, "fps": 8, "durations_ms": [125, 125, 125, 125], "loop": false, "frame_variant": "pixel"}, "attack1": {"row": 6, "frames": 4, "fps": 12, "durations_ms": [83, 83, 83, 83], "loop": false, "frame_variant": "pixel"}, "attack2": {"row": 7, "frames": 4, "fps": 12, "durations_ms": [83, 83, 83, 83], "loop": false, "frame_variant": "pixel"}, "attack3": {"row": 8, "frames": 4, "fps": 10, "durations_ms": [100, 100, 100, 100], "loop": false, "frame_variant": "pixel"}, "skill1": {"row": 9, "frames": 4, "fps": 10, "durations_ms": [100, 100, 100, 100], "loop": false, "frame_variant": "pixel"}, "skill2": {"row": 10, "frames": 4, "fps": 10, "durations_ms": [100, 100, 100, 100], "loop": false, "frame_variant": "pixel"}, "ultimate": {"row": 11, "frames": 4, "fps": 8, "durations_ms": [125, 125, 125, 125], "loop": false, "frame_variant": "pixel"}, "hurt": {"row": 12, "frames": 4, "fps": 10, "durations_ms": [100, 100, 100, 100], "loop": false, "frame_variant": "pixel"}, "down": {"row": 13, "frames": 4, "fps": 8, "durations_ms": [125, 125, 125, 125], "loop": false, "frame_variant": "pixel"}, "recover": {"row": 14, "frames": 4, "fps": 10, "durations_ms": [100, 100, 100, 100], "loop": false, "frame_variant": "pixel"}}}, "frame_layout": {"sheetWidth": 1792, "sheetHeight": 4320, "cellWidth": 448, "cellHeight": 288, "rows": {"idle": [{"x": 0, "y": 0, "w": 448, "h": 288}, {"x": 448, "y": 0, "w": 448, "h": 288}, {"x": 896, "y": 0, "w": 448, "h": 288}, {"x": 1344, "y": 0, "w": 448, "h": 288}], "walk": [{"x": 0, "y": 288, "w": 448, "h": 288}, {"x": 448, "y": 288, "w": 448, "h": 288}, {"x": 896, "y": 288, "w": 448, "h": 288}, {"x": 1344, "y": 288, "w": 448, "h": 288}], "run": [{"x": 0, "y": 576, "w": 448, "h": 288}, {"x": 448, "y": 576, "w": 448, "h": 288}, {"x": 896, "y": 576, "w": 448, "h": 288}, {"x": 1344, "y": 576, "w": 448, "h": 288}], "jump": [{"x": 0, "y": 864, "w": 448, "h": 288}], "doublejump": [{"x": 0, "y": 1152, "w": 448, "h": 288}], "crouch": [{"x": 0, "y": 1440, "w": 448, "h": 288}, {"x": 448, "y": 1440, "w": 448, "h": 288}, {"x": 896, "y": 1440, "w": 448, "h": 288}, {"x": 1344, "y": 1440, "w": 448, "h": 288}], "attack1": [{"x": 0, "y": 1728, "w": 448, "h": 288}, {"x": 448, "y": 1728, "w": 448, "h": 288}, {"x": 896, "y": 1728, "w": 448, "h": 288}, {"x": 1344, "y": 1728, "w": 448, "h": 288}], "attack2": [{"x": 0, "y": 2016, "w": 448, "h": 288}, {"x": 448, "y": 2016, "w": 448, "h": 288}, {"x": 896, "y": 2016, "w": 448, "h": 288}, {"x": 1344, "y": 2016, "w": 448, "h": 288}], "attack3": [{"x": 0, "y": 2304, "w": 448, "h": 288}, {"x": 448, "y": 2304, "w": 448, "h": 288}, {"x": 896, "y": 2304, "w": 448, "h": 288}, {"x": 1344, "y": 2304, "w": 448, "h": 288}], "skill1": [{"x": 0, "y": 2592, "w": 448, "h": 288}, {"x": 448, "y": 2592, "w": 448, "h": 288}, {"x": 896, "y": 2592, "w": 448, "h": 288}, {"x": 1344, "y": 2592, "w": 448, "h": 288}], "skill2": [{"x": 0, "y": 2880, "w": 448, "h": 288}, {"x": 448, "y": 2880, "w": 448, "h": 288}, {"x": 896, "y": 2880, "w": 448, "h": 288}, {"x": 1344, "y": 2880, "w": 448, "h": 288}], "ultimate": [{"x": 0, "y": 3168, "w": 448, "h": 288}, {"x": 448, "y": 3168, "w": 448, "h": 288}, {"x": 896, "y": 3168, "w": 448, "h": 288}, {"x": 1344, "y": 3168, "w": 448, "h": 288}], "hurt": [{"x": 0, "y": 3456, "w": 448, "h": 288}, {"x": 448, "y": 3456, "w": 448, "h": 288}, {"x": 896, "y": 3456, "w": 448, "h": 288}, {"x": 1344, "y": 3456, "w": 448, "h": 288}], "down": [{"x": 0, "y": 3744, "w": 448, "h": 288}, {"x": 448, "y": 3744, "w": 448, "h": 288}, {"x": 896, "y": 3744, "w": 448, "h": 288}, {"x": 1344, "y": 3744, "w": 448, "h": 288}], "recover": [{"x": 0, "y": 4032, "w": 448, "h": 288}, {"x": 448, "y": 4032, "w": 448, "h": 288}, {"x": 896, "y": 4032, "w": 448, "h": 288}, {"x": 1344, "y": 4032, "w": 448, "h": 288}]}}}; +window.ISOLDE_METRICS = {"anchor": {"x": 224, "y": 280}, "states": {"idle": {"frames": [{"frame": 0, "bounds": {"left": -53, "right": 76, "top": -199, "bottom": 0}, "height": 199}, {"frame": 1, "bounds": {"left": -54, "right": 79, "top": -199, "bottom": 0}, "height": 199}, {"frame": 2, "bounds": {"left": -54, "right": 77, "top": -199, "bottom": 0}, "height": 199}, {"frame": 3, "bounds": {"left": -53, "right": 76, "top": -199, "bottom": 0}, "height": 199}]}, "walk": {"frames": [{"frame": 0, "bounds": {"left": -55, "right": 90, "top": -199, "bottom": 0}, "height": 199}, {"frame": 1, "bounds": {"left": -52, "right": 86, "top": -198, "bottom": 0}, "height": 198}, {"frame": 2, "bounds": {"left": -55, "right": 89, "top": -200, "bottom": 0}, "height": 200}, {"frame": 3, "bounds": {"left": -53, "right": 84, "top": -200, "bottom": 0}, "height": 200}]}, "run": {"frames": [{"frame": 0, "bounds": {"left": -76, "right": 79, "top": -155, "bottom": 0}, "height": 155}, {"frame": 1, "bounds": {"left": -77, "right": 72, "top": -154, "bottom": 0}, "height": 154}, {"frame": 2, "bounds": {"left": -73, "right": 78, "top": -155, "bottom": 0}, "height": 155}, {"frame": 3, "bounds": {"left": -73, "right": 68, "top": -155, "bottom": 0}, "height": 155}]}, "jump": {"frames": [{"frame": 0, "bounds": {"left": -77, "right": 117, "top": -185, "bottom": 0}, "height": 185}]}, "doublejump": {"frames": [{"frame": 0, "bounds": {"left": -83, "right": 58, "top": -159, "bottom": 0}, "height": 159}]}, "crouch": {"frames": [{"frame": 0, "bounds": {"left": -47, "right": 79, "top": -172, "bottom": 0}, "height": 172}, {"frame": 1, "bounds": {"left": -49, "right": 91, "top": -141, "bottom": 0}, "height": 141}, {"frame": 2, "bounds": {"left": -45, "right": 76, "top": -142, "bottom": 0}, "height": 142}, {"frame": 3, "bounds": {"left": -44, "right": 83, "top": -163, "bottom": 0}, "height": 163}]}, "attack1": {"frames": [{"frame": 0, "bounds": {"left": -54, "right": 90, "top": -164, "bottom": 0}, "height": 164}, {"frame": 1, "bounds": {"left": -57, "right": 100, "top": -161, "bottom": 0}, "height": 161}, {"frame": 2, "bounds": {"left": -59, "right": 157, "top": -161, "bottom": 0}, "height": 161}, {"frame": 3, "bounds": {"left": -55, "right": 92, "top": -164, "bottom": 0}, "height": 164}]}, "attack2": {"frames": [{"frame": 0, "bounds": {"left": -116, "right": 82, "top": -160, "bottom": 0}, "height": 160}, {"frame": 1, "bounds": {"left": -60, "right": 110, "top": -183, "bottom": 0}, "height": 183}, {"frame": 2, "bounds": {"left": -63, "right": 131, "top": -224, "bottom": 0}, "height": 224}, {"frame": 3, "bounds": {"left": -59, "right": 84, "top": -198, "bottom": 0}, "height": 198}]}, "attack3": {"frames": [{"frame": 0, "bounds": {"left": -94, "right": 58, "top": -145, "bottom": 0}, "height": 145}, {"frame": 1, "bounds": {"left": -39, "right": 155, "top": -158, "bottom": 0}, "height": 158}, {"frame": 2, "bounds": {"left": -77, "right": 184, "top": -144, "bottom": 0}, "height": 144}, {"frame": 3, "bounds": {"left": -55, "right": 70, "top": -193, "bottom": 0}, "height": 193}]}, "skill1": {"frames": [{"frame": 0, "bounds": {"left": -98, "right": 77, "top": -152, "bottom": 0}, "height": 152}, {"frame": 1, "bounds": {"left": -62, "right": 78, "top": -237, "bottom": 0}, "height": 237}, {"frame": 2, "bounds": {"left": -59, "right": 147, "top": -168, "bottom": 0}, "height": 168}, {"frame": 3, "bounds": {"left": -59, "right": 86, "top": -193, "bottom": 0}, "height": 193}]}, "skill2": {"frames": [{"frame": 0, "bounds": {"left": -50, "right": 105, "top": -138, "bottom": 0}, "height": 138}, {"frame": 1, "bounds": {"left": -12, "right": 138, "top": -142, "bottom": 0}, "height": 142}, {"frame": 2, "bounds": {"left": -67, "right": 135, "top": -138, "bottom": 0}, "height": 138}, {"frame": 3, "bounds": {"left": -64, "right": 63, "top": -155, "bottom": 0}, "height": 155}]}, "ultimate": {"frames": [{"frame": 0, "bounds": {"left": -70, "right": 86, "top": -264, "bottom": 0}, "height": 264}, {"frame": 1, "bounds": {"left": -68, "right": 63, "top": -264, "bottom": 0}, "height": 264}, {"frame": 2, "bounds": {"left": -107, "right": 89, "top": -167, "bottom": 0}, "height": 167}, {"frame": 3, "bounds": {"left": -63, "right": 83, "top": -216, "bottom": 0}, "height": 216}]}, "hurt": {"frames": [{"frame": 0, "bounds": {"left": -106, "right": 67, "top": -199, "bottom": 0}, "height": 199}, {"frame": 1, "bounds": {"left": -55, "right": 69, "top": -201, "bottom": 0}, "height": 201}, {"frame": 2, "bounds": {"left": -109, "right": 64, "top": -199, "bottom": 0}, "height": 199}, {"frame": 3, "bounds": {"left": -53, "right": 66, "top": -201, "bottom": 0}, "height": 201}]}, "down": {"frames": [{"frame": 0, "bounds": {"left": -72, "right": 80, "top": -160, "bottom": 0}, "height": 160}, {"frame": 1, "bounds": {"left": -72, "right": 71, "top": -154, "bottom": 0}, "height": 154}, {"frame": 2, "bounds": {"left": -114, "right": 44, "top": -75, "bottom": 0}, "height": 75}, {"frame": 3, "bounds": {"left": -115, "right": 43, "top": -86, "bottom": 0}, "height": 86}]}, "recover": {"frames": [{"frame": 0, "bounds": {"left": -88, "right": 85, "top": -102, "bottom": 0}, "height": 102}, {"frame": 1, "bounds": {"left": -67, "right": 74, "top": -163, "bottom": 0}, "height": 163}, {"frame": 2, "bounds": {"left": -46, "right": 98, "top": -182, "bottom": 0}, "height": 182}, {"frame": 3, "bounds": {"left": -55, "right": 93, "top": -199, "bottom": 0}, "height": 199}]}}, "playback": {"jump": {"airFrame": 0, "torsoPivot": {"x": 20, "y": -92}}, "doublejump": {"frame": 0, "duration": 0.28, "pivot": {"x": -12, "y": -80}}, "measurement": "Centers measured from final curated atlas, including integer air-pose alignment; no runtime resizing."}, "emitters": {"attack1": {"x": 157, "y": -88, "frame": 2, "method": "Lance point on impact frame (farthest-right six-pixel tip; top-right extreme for the rising thrusts), facing right"}, "attack2": {"x": 131, "y": -224, "frame": 2, "method": "Lance point on impact frame (farthest-right six-pixel tip; top-right extreme for the rising thrusts), facing right"}, "attack3": {"x": 184, "y": -70, "frame": 2, "method": "Lance point on impact frame (farthest-right six-pixel tip; top-right extreme for the rising thrusts), facing right"}, "skill1": {"x": 147, "y": -168, "frame": 2, "method": "Lance point on impact frame (farthest-right six-pixel tip; top-right extreme for the rising thrusts), facing right"}, "skill2": {"x": 135, "y": -63, "frame": 2, "method": "Lance point on impact frame (farthest-right six-pixel tip; top-right extreme for the rising thrusts), facing right"}, "ultimate": {"x": 89, "y": -23, "frame": 2, "method": "Lance point on impact frame (farthest-right six-pixel tip; top-right extreme for the rising thrusts), facing right"}}}; diff --git a/assets/isolde/run/sprite-sheet-alpha.webp b/assets/isolde/run/sprite-sheet-alpha.webp new file mode 100644 index 0000000..edde381 Binary files /dev/null and b/assets/isolde/run/sprite-sheet-alpha.webp differ diff --git a/assets/isolde/ui/cutin.webp b/assets/isolde/ui/cutin.webp new file mode 100644 index 0000000..538c79f Binary files /dev/null and b/assets/isolde/ui/cutin.webp differ diff --git a/assets/isolde/ui/fx-frost.webp b/assets/isolde/ui/fx-frost.webp new file mode 100644 index 0000000..74066d7 Binary files /dev/null and b/assets/isolde/ui/fx-frost.webp differ diff --git a/assets/isolde/ui/fx-piercer.webp b/assets/isolde/ui/fx-piercer.webp new file mode 100644 index 0000000..ee070ed Binary files /dev/null and b/assets/isolde/ui/fx-piercer.webp differ diff --git a/assets/isolde/ui/fx-shatter.webp b/assets/isolde/ui/fx-shatter.webp new file mode 100644 index 0000000..1281622 Binary files /dev/null and b/assets/isolde/ui/fx-shatter.webp differ diff --git a/assets/isolde/ui/fx-skyfall.webp b/assets/isolde/ui/fx-skyfall.webp new file mode 100644 index 0000000..e29e84f Binary files /dev/null and b/assets/isolde/ui/fx-skyfall.webp differ diff --git a/assets/isolde/ui/icon-basic.webp b/assets/isolde/ui/icon-basic.webp new file mode 100644 index 0000000..b54f8bc Binary files /dev/null and b/assets/isolde/ui/icon-basic.webp differ diff --git a/assets/isolde/ui/icon-skill1.webp b/assets/isolde/ui/icon-skill1.webp new file mode 100644 index 0000000..f07a8f1 Binary files /dev/null and b/assets/isolde/ui/icon-skill1.webp differ diff --git a/assets/isolde/ui/icon-skill2.webp b/assets/isolde/ui/icon-skill2.webp new file mode 100644 index 0000000..ac06262 Binary files /dev/null and b/assets/isolde/ui/icon-skill2.webp differ diff --git a/assets/isolde/ui/icon-ultimate.webp b/assets/isolde/ui/icon-ultimate.webp new file mode 100644 index 0000000..72adbef Binary files /dev/null and b/assets/isolde/ui/icon-ultimate.webp differ diff --git a/assets/isolde/ui/portrait.webp b/assets/isolde/ui/portrait.webp new file mode 100644 index 0000000..a28340b Binary files /dev/null and b/assets/isolde/ui/portrait.webp differ diff --git a/assets/mecha/manifest.js b/assets/mecha/manifest.js new file mode 100644 index 0000000..a809372 --- /dev/null +++ b/assets/mecha/manifest.js @@ -0,0 +1,2 @@ +window.MECHA_MANIFEST = {"characterId": "arco", "engine": "component-row", "game_input": "sprite-sheet-alpha.png", "degraded_static_fallback": false, "curation_applied": true, "frame_variant": "pixel", "sprite_sheet_alpha": "sprite-sheet-alpha.png", "sprite_sheet_alpha_report": "sprite-sheet-alpha.report.json", "base_image": "base-source.png", "cell": {"shape": "rect", "width": 320, "height": 288, "safe_margin_x": 10, "safe_margin_y": 8}, "chroma_key": {"name": "magenta", "hex": "#FF00FF", "rgb": [255, 0, 255], "selection": "manual"}, "animation": {"cellWidth": 320, "cellHeight": 288, "columns": 4, "rows": {"idle": {"row": 0, "frames": 4, "fps": 5, "durations_ms": [200, 200, 200, 200], "loop": true, "frame_variant": "pixel"}, "walk": {"row": 1, "frames": 4, "fps": 10, "durations_ms": [100, 100, 100, 100], "loop": true, "frame_variant": "pixel"}, "run": {"row": 2, "frames": 4, "fps": 13, "durations_ms": [77, 77, 77, 77], "loop": true, "frame_variant": "pixel"}, "jump": {"row": 3, "frames": 1, "fps": 10, "durations_ms": [100], "loop": false, "frame_variant": "pixel"}, "doublejump": {"row": 4, "frames": 1, "fps": 12, "durations_ms": [83], "loop": false, "frame_variant": "pixel"}, "crouch": {"row": 5, "frames": 4, "fps": 8, "durations_ms": [125, 125, 125, 125], "loop": false, "frame_variant": "pixel"}, "attack1": {"row": 6, "frames": 4, "fps": 13, "durations_ms": [77, 77, 77, 77], "loop": false, "frame_variant": "pixel"}, "attack2": {"row": 7, "frames": 4, "fps": 13, "durations_ms": [77, 77, 77, 77], "loop": false, "frame_variant": "pixel"}, "attack3": {"row": 8, "frames": 4, "fps": 11, "durations_ms": [91, 91, 91, 91], "loop": false, "frame_variant": "pixel"}, "skill1": {"row": 9, "frames": 4, "fps": 11, "durations_ms": [91, 91, 91, 91], "loop": false, "frame_variant": "pixel"}, "skill2": {"row": 10, "frames": 4, "fps": 10, "durations_ms": [100, 100, 100, 100], "loop": false, "frame_variant": "pixel"}, "ultimate": {"row": 11, "frames": 4, "fps": 9, "durations_ms": [111, 111, 111, 111], "loop": false, "frame_variant": "pixel"}, "hurt": {"row": 12, "frames": 4, "fps": 9, "durations_ms": [111, 111, 111, 111], "loop": false, "frame_variant": "pixel"}, "down": {"row": 13, "frames": 4, "fps": 7, "durations_ms": [143, 143, 143, 143], "loop": false, "frame_variant": "pixel"}}}, "frame_layout": {"sheetWidth": 1280, "sheetHeight": 4032, "cellWidth": 320, "cellHeight": 288, "rows": {"idle": [{"x": 0, "y": 0, "w": 320, "h": 288}, {"x": 320, "y": 0, "w": 320, "h": 288}, {"x": 640, "y": 0, "w": 320, "h": 288}, {"x": 960, "y": 0, "w": 320, "h": 288}], "walk": [{"x": 0, "y": 288, "w": 320, "h": 288}, {"x": 320, "y": 288, "w": 320, "h": 288}, {"x": 640, "y": 288, "w": 320, "h": 288}, {"x": 960, "y": 288, "w": 320, "h": 288}], "run": [{"x": 0, "y": 576, "w": 320, "h": 288}, {"x": 320, "y": 576, "w": 320, "h": 288}, {"x": 640, "y": 576, "w": 320, "h": 288}, {"x": 960, "y": 576, "w": 320, "h": 288}], "jump": [{"x": 0, "y": 864, "w": 320, "h": 288}], "doublejump": [{"x": 0, "y": 1152, "w": 320, "h": 288}], "crouch": [{"x": 0, "y": 1440, "w": 320, "h": 288}, {"x": 320, "y": 1440, "w": 320, "h": 288}, {"x": 640, "y": 1440, "w": 320, "h": 288}, {"x": 960, "y": 1440, "w": 320, "h": 288}], "attack1": [{"x": 0, "y": 1728, "w": 320, "h": 288}, {"x": 320, "y": 1728, "w": 320, "h": 288}, {"x": 640, "y": 1728, "w": 320, "h": 288}, {"x": 960, "y": 1728, "w": 320, "h": 288}], "attack2": [{"x": 0, "y": 2016, "w": 320, "h": 288}, {"x": 320, "y": 2016, "w": 320, "h": 288}, {"x": 640, "y": 2016, "w": 320, "h": 288}, {"x": 960, "y": 2016, "w": 320, "h": 288}], "attack3": [{"x": 0, "y": 2304, "w": 320, "h": 288}, {"x": 320, "y": 2304, "w": 320, "h": 288}, {"x": 640, "y": 2304, "w": 320, "h": 288}, {"x": 960, "y": 2304, "w": 320, "h": 288}], "skill1": [{"x": 0, "y": 2592, "w": 320, "h": 288}, {"x": 320, "y": 2592, "w": 320, "h": 288}, {"x": 640, "y": 2592, "w": 320, "h": 288}, {"x": 960, "y": 2592, "w": 320, "h": 288}], "skill2": [{"x": 0, "y": 2880, "w": 320, "h": 288}, {"x": 320, "y": 2880, "w": 320, "h": 288}, {"x": 640, "y": 2880, "w": 320, "h": 288}, {"x": 960, "y": 2880, "w": 320, "h": 288}], "ultimate": [{"x": 0, "y": 3168, "w": 320, "h": 288}, {"x": 320, "y": 3168, "w": 320, "h": 288}, {"x": 640, "y": 3168, "w": 320, "h": 288}, {"x": 960, "y": 3168, "w": 320, "h": 288}], "hurt": [{"x": 0, "y": 3456, "w": 320, "h": 288}, {"x": 320, "y": 3456, "w": 320, "h": 288}, {"x": 640, "y": 3456, "w": 320, "h": 288}, {"x": 960, "y": 3456, "w": 320, "h": 288}], "down": [{"x": 0, "y": 3744, "w": 320, "h": 288}, {"x": 320, "y": 3744, "w": 320, "h": 288}, {"x": 640, "y": 3744, "w": 320, "h": 288}, {"x": 960, "y": 3744, "w": 320, "h": 288}]}}}; +window.MECHA_METRICS = {"source": "final composed atlas after curation", "anchor": {"x": 160, "y": 280}, "states": {"idle": {"frames": [{"frame": 0, "bounds": {"left": -55, "right": 40, "top": -180, "bottom": 0}, "height": 180, "feet_spread": 72, "opaque_pixels": 9366}, {"frame": 1, "bounds": {"left": -54, "right": 41, "top": -181, "bottom": 0}, "height": 181, "feet_spread": 72, "opaque_pixels": 9368}, {"frame": 2, "bounds": {"left": -54, "right": 40, "top": -181, "bottom": 0}, "height": 181, "feet_spread": 71, "opaque_pixels": 9311}, {"frame": 3, "bounds": {"left": -54, "right": 40, "top": -181, "bottom": 0}, "height": 181, "feet_spread": 71, "opaque_pixels": 9308}]}, "walk": {"frames": [{"frame": 0, "bounds": {"left": -65, "right": 74, "top": -177, "bottom": 0}, "height": 177, "feet_spread": 125, "opaque_pixels": 10136}, {"frame": 1, "bounds": {"left": -52, "right": 41, "top": -177, "bottom": 0}, "height": 177, "feet_spread": 33, "opaque_pixels": 8616}, {"frame": 2, "bounds": {"left": -66, "right": 75, "top": -177, "bottom": 0}, "height": 177, "feet_spread": 126, "opaque_pixels": 10096}, {"frame": 3, "bounds": {"left": -51, "right": 37, "top": -177, "bottom": 0}, "height": 177, "feet_spread": 34, "opaque_pixels": 8337}], "stride_estimate": 252, "stride_status": "silhouette estimate; same-foot contact still requires motion review"}, "run": {"frames": [{"frame": 0, "bounds": {"left": -80, "right": 65, "top": -140, "bottom": 0}, "height": 140, "feet_spread": 127, "opaque_pixels": 8016}, {"frame": 1, "bounds": {"left": -60, "right": 47, "top": -150, "bottom": 0}, "height": 150, "feet_spread": 34, "opaque_pixels": 7401}, {"frame": 2, "bounds": {"left": -84, "right": 69, "top": -141, "bottom": 0}, "height": 141, "feet_spread": 138, "opaque_pixels": 8161}, {"frame": 3, "bounds": {"left": -61, "right": 46, "top": -150, "bottom": 0}, "height": 150, "feet_spread": 34, "opaque_pixels": 7417}], "stride_estimate": 276, "stride_status": "silhouette estimate; same-foot contact still requires motion review"}, "jump": {"frames": [{"frame": 0, "bounds": {"left": -60, "right": 57, "top": -177, "bottom": 0}, "height": 177, "feet_spread": 14, "opaque_pixels": 9028}]}, "doublejump": {"frames": [{"frame": 0, "bounds": {"left": -41, "right": 39, "top": -130, "bottom": 0}, "height": 130, "feet_spread": 24, "opaque_pixels": 6398}]}, "crouch": {"frames": [{"frame": 0, "bounds": {"left": -44, "right": 49, "top": -119, "bottom": 0}, "height": 119, "feet_spread": 92, "opaque_pixels": 6979}, {"frame": 1, "bounds": {"left": -40, "right": 53, "top": -104, "bottom": 0}, "height": 104, "feet_spread": 85, "opaque_pixels": 6413}, {"frame": 2, "bounds": {"left": -42, "right": 49, "top": -116, "bottom": 0}, "height": 116, "feet_spread": 89, "opaque_pixels": 6869}, {"frame": 3, "bounds": {"left": -42, "right": 51, "top": -114, "bottom": 0}, "height": 114, "feet_spread": 88, "opaque_pixels": 6663}]}, "attack1": {"frames": [{"frame": 0, "bounds": {"left": -64, "right": 44, "top": -179, "bottom": 0}, "height": 179, "feet_spread": 103, "opaque_pixels": 9566}, {"frame": 1, "bounds": {"left": -59, "right": 95, "top": -175, "bottom": 0}, "height": 175, "feet_spread": 121, "opaque_pixels": 9934}, {"frame": 2, "bounds": {"left": -72, "right": 110, "top": -170, "bottom": 0}, "height": 170, "feet_spread": 143, "opaque_pixels": 10326}, {"frame": 3, "bounds": {"left": -55, "right": 50, "top": -179, "bottom": 0}, "height": 179, "feet_spread": 104, "opaque_pixels": 9221}]}, "attack2": {"frames": [{"frame": 0, "bounds": {"left": -61, "right": 60, "top": -157, "bottom": 0}, "height": 157, "feet_spread": 116, "opaque_pixels": 9943}, {"frame": 1, "bounds": {"left": -59, "right": 83, "top": -170, "bottom": 0}, "height": 170, "feet_spread": 120, "opaque_pixels": 10288}, {"frame": 2, "bounds": {"left": -61, "right": 70, "top": -205, "bottom": 0}, "height": 205, "feet_spread": 124, "opaque_pixels": 10204}, {"frame": 3, "bounds": {"left": -58, "right": 41, "top": -180, "bottom": 0}, "height": 180, "feet_spread": 80, "opaque_pixels": 9837}]}, "attack3": {"frames": [{"frame": 0, "bounds": {"left": -101, "right": 41, "top": -175, "bottom": 0}, "height": 175, "feet_spread": 119, "opaque_pixels": 11280}, {"frame": 1, "bounds": {"left": -62, "right": 69, "top": -175, "bottom": 0}, "height": 175, "feet_spread": 130, "opaque_pixels": 9558}, {"frame": 2, "bounds": {"left": -72, "right": 116, "top": -170, "bottom": 0}, "height": 170, "feet_spread": 151, "opaque_pixels": 11103}, {"frame": 3, "bounds": {"left": -54, "right": 53, "top": -181, "bottom": 0}, "height": 181, "feet_spread": 98, "opaque_pixels": 10049}]}, "skill1": {"frames": [{"frame": 0, "bounds": {"left": -48, "right": 58, "top": -179, "bottom": 0}, "height": 179, "feet_spread": 105, "opaque_pixels": 8392}, {"frame": 1, "bounds": {"left": -56, "right": 61, "top": -178, "bottom": 0}, "height": 178, "feet_spread": 116, "opaque_pixels": 8543}, {"frame": 2, "bounds": {"left": -62, "right": 98, "top": -173, "bottom": 0}, "height": 173, "feet_spread": 127, "opaque_pixels": 11167}, {"frame": 3, "bounds": {"left": -54, "right": 45, "top": -179, "bottom": 0}, "height": 179, "feet_spread": 82, "opaque_pixels": 9532}]}, "skill2": {"frames": [{"frame": 0, "bounds": {"left": -58, "right": 53, "top": -182, "bottom": 0}, "height": 182, "feet_spread": 110, "opaque_pixels": 9632}, {"frame": 1, "bounds": {"left": -58, "right": 60, "top": -237, "bottom": 0}, "height": 237, "feet_spread": 117, "opaque_pixels": 11904}, {"frame": 2, "bounds": {"left": -89, "right": 56, "top": -133, "bottom": 0}, "height": 133, "feet_spread": 144, "opaque_pixels": 9798}, {"frame": 3, "bounds": {"left": -59, "right": 55, "top": -181, "bottom": 0}, "height": 181, "feet_spread": 106, "opaque_pixels": 10336}]}, "ultimate": {"frames": [{"frame": 0, "bounds": {"left": -53, "right": 41, "top": -179, "bottom": 0}, "height": 179, "feet_spread": 73, "opaque_pixels": 8949}, {"frame": 1, "bounds": {"left": -34, "right": 90, "top": -179, "bottom": 0}, "height": 179, "feet_spread": 74, "opaque_pixels": 8616}, {"frame": 2, "bounds": {"left": -35, "right": 110, "top": -179, "bottom": 0}, "height": 179, "feet_spread": 76, "opaque_pixels": 8948}, {"frame": 3, "bounds": {"left": -52, "right": 42, "top": -179, "bottom": 0}, "height": 179, "feet_spread": 72, "opaque_pixels": 9185}]}, "hurt": {"frames": [{"frame": 0, "bounds": {"left": -71, "right": 47, "top": -169, "bottom": 0}, "height": 169, "feet_spread": 89, "opaque_pixels": 9248}, {"frame": 1, "bounds": {"left": -76, "right": 54, "top": -164, "bottom": 0}, "height": 164, "feet_spread": 100, "opaque_pixels": 9119}, {"frame": 2, "bounds": {"left": -61, "right": 46, "top": -141, "bottom": 0}, "height": 141, "feet_spread": 88, "opaque_pixels": 8325}, {"frame": 3, "bounds": {"left": -57, "right": 41, "top": -167, "bottom": 0}, "height": 167, "feet_spread": 75, "opaque_pixels": 8772}]}, "down": {"frames": [{"frame": 0, "bounds": {"left": -96, "right": 55, "top": -165, "bottom": 0}, "height": 165, "feet_spread": 98, "opaque_pixels": 10900}, {"frame": 1, "bounds": {"left": -117, "right": 62, "top": -138, "bottom": 0}, "height": 138, "feet_spread": 23, "opaque_pixels": 9823}, {"frame": 2, "bounds": {"left": -76, "right": 103, "top": -67, "bottom": 0}, "height": 67, "feet_spread": 114, "opaque_pixels": 7058}, {"frame": 3, "bounds": {"left": -83, "right": 110, "top": -55, "bottom": 0}, "height": 55, "feet_spread": 180, "opaque_pixels": 6677}]}}, "emitters": {"attack1": {"x": 110, "y": -121, "method": "frame2 bright cyan core; palm right alpha edge for ranged skills; ground for slam"}, "attack2": {"x": 70, "y": -173, "method": "frame2 bright cyan core; palm right alpha edge for ranged skills; ground for slam"}, "attack3": {"x": 116, "y": -119, "method": "frame2 bright cyan core; palm right alpha edge for ranged skills; ground for slam"}, "skill1": {"x": 98, "y": -125, "method": "frame2 bright cyan core; palm right alpha edge for ranged skills; ground for slam"}, "skill2": {"x": 34, "y": 0, "method": "frame2 bright cyan core; palm right alpha edge for ranged skills; ground for slam"}, "ultimate": {"x": 90, "y": -128, "method": "frame2 farthest-right connected bright cyan palm core; forearm core excluded"}}, "playback": {"jump": {"airFrame": 0, "torsoPivot": {"x": 5, "y": -105}}, "doublejump": {"mode": "rotate", "frame": 0, "duration": 0.28, "pivot": {"x": -1, "y": -65}}, "measurement": "Jump v3 top hair landmark aligned to idle with integer dx; roll world pivot aligns jump and tuck head landmarks at zero rotation. Runtime roll duration 0.28 s."}}; diff --git a/assets/mecha/run/sprite-sheet-alpha.webp b/assets/mecha/run/sprite-sheet-alpha.webp new file mode 100644 index 0000000..85e105e Binary files /dev/null and b/assets/mecha/run/sprite-sheet-alpha.webp differ diff --git a/assets/menu/arco-select.webp b/assets/menu/arco-select.webp new file mode 100644 index 0000000..00722ce Binary files /dev/null and b/assets/menu/arco-select.webp differ diff --git a/assets/menu/azure-harbor.webp b/assets/menu/azure-harbor.webp new file mode 100644 index 0000000..a9253a0 Binary files /dev/null and b/assets/menu/azure-harbor.webp differ diff --git a/assets/menu/cora-select.webp b/assets/menu/cora-select.webp new file mode 100644 index 0000000..abca1d1 Binary files /dev/null and b/assets/menu/cora-select.webp differ diff --git a/assets/menu/edda-select.webp b/assets/menu/edda-select.webp new file mode 100644 index 0000000..9f659da Binary files /dev/null and b/assets/menu/edda-select.webp differ diff --git a/assets/menu/elderwood.webp b/assets/menu/elderwood.webp new file mode 100644 index 0000000..709d2e6 Binary files /dev/null and b/assets/menu/elderwood.webp differ diff --git a/assets/menu/fenr-select.webp b/assets/menu/fenr-select.webp new file mode 100644 index 0000000..42980fa Binary files /dev/null and b/assets/menu/fenr-select.webp differ diff --git a/assets/menu/haldor-select.webp b/assets/menu/haldor-select.webp new file mode 100644 index 0000000..de9b00c Binary files /dev/null and b/assets/menu/haldor-select.webp differ diff --git a/assets/menu/home-art.webp b/assets/menu/home-art.webp new file mode 100644 index 0000000..f579292 Binary files /dev/null and b/assets/menu/home-art.webp differ diff --git a/assets/menu/home-factions-loop.mp4 b/assets/menu/home-factions-loop.mp4 new file mode 100644 index 0000000..0dc34d7 Binary files /dev/null and b/assets/menu/home-factions-loop.mp4 differ diff --git a/assets/menu/home-factions.webp b/assets/menu/home-factions.webp new file mode 100644 index 0000000..4bd0ca7 Binary files /dev/null and b/assets/menu/home-factions.webp differ diff --git a/assets/menu/isolde-select.webp b/assets/menu/isolde-select.webp new file mode 100644 index 0000000..be73663 Binary files /dev/null and b/assets/menu/isolde-select.webp differ diff --git a/assets/menu/locked.svg b/assets/menu/locked.svg new file mode 100644 index 0000000..1551e1d --- /dev/null +++ b/assets/menu/locked.svg @@ -0,0 +1 @@ + diff --git a/assets/menu/mira-select.webp b/assets/menu/mira-select.webp new file mode 100644 index 0000000..d238ebb Binary files /dev/null and b/assets/menu/mira-select.webp differ diff --git a/assets/menu/moonrise.webp b/assets/menu/moonrise.webp new file mode 100644 index 0000000..4c145de Binary files /dev/null and b/assets/menu/moonrise.webp differ diff --git a/assets/menu/naja-select.webp b/assets/menu/naja-select.webp new file mode 100644 index 0000000..12bdd0e Binary files /dev/null and b/assets/menu/naja-select.webp differ diff --git a/assets/menu/nib-select.webp b/assets/menu/nib-select.webp new file mode 100644 index 0000000..47fdc9b Binary files /dev/null and b/assets/menu/nib-select.webp differ diff --git a/assets/menu/rhea-select.webp b/assets/menu/rhea-select.webp new file mode 100644 index 0000000..32adb83 Binary files /dev/null and b/assets/menu/rhea-select.webp differ diff --git a/assets/menu/solan-select.webp b/assets/menu/solan-select.webp new file mode 100644 index 0000000..6f34e54 Binary files /dev/null and b/assets/menu/solan-select.webp differ diff --git a/assets/menu/sunspire.webp b/assets/menu/sunspire.webp new file mode 100644 index 0000000..c764b82 Binary files /dev/null and b/assets/menu/sunspire.webp differ diff --git a/assets/menu/zanni-select.webp b/assets/menu/zanni-select.webp new file mode 100644 index 0000000..3f5d43d Binary files /dev/null and b/assets/menu/zanni-select.webp differ diff --git a/assets/mira/audio/mira-ultimate-luna.mp3 b/assets/mira/audio/mira-ultimate-luna.mp3 new file mode 100644 index 0000000..a342422 Binary files /dev/null and b/assets/mira/audio/mira-ultimate-luna.mp3 differ diff --git a/assets/mira/audio/mira_wins.mp3 b/assets/mira/audio/mira_wins.mp3 new file mode 100644 index 0000000..7258dad Binary files /dev/null and b/assets/mira/audio/mira_wins.mp3 differ diff --git a/assets/mira/audio/select_mira.mp3 b/assets/mira/audio/select_mira.mp3 new file mode 100644 index 0000000..04f5f07 Binary files /dev/null and b/assets/mira/audio/select_mira.mp3 differ diff --git a/assets/mira/manifest.js b/assets/mira/manifest.js new file mode 100644 index 0000000..41ccc3d --- /dev/null +++ b/assets/mira/manifest.js @@ -0,0 +1,2 @@ +window.MIRA_MANIFEST = {"characterId": "mira", "engine": "component-row", "game_input": "sprite-sheet-alpha.png", "degraded_static_fallback": false, "curation_applied": true, "frame_variant": "pixel", "sprite_sheet_alpha": "sprite-sheet-alpha.png", "sprite_sheet_alpha_report": "sprite-sheet-alpha.report.json", "base_image": "base-source.png", "cell": {"shape": "rect", "width": 448, "height": 288, "safe_margin_x": 10, "safe_margin_y": 8}, "chroma_key": {"name": "green", "hex": "#00FF00", "rgb": [0, 255, 0], "selection": "manual"}, "animation": {"cellWidth": 448, "cellHeight": 288, "columns": 4, "rows": {"idle": {"row": 0, "frames": 4, "fps": 5, "durations_ms": [200, 200, 200, 200], "loop": true, "frame_variant": "pixel"}, "walk": {"row": 1, "frames": 4, "fps": 10, "durations_ms": [100, 100, 100, 100], "loop": true, "frame_variant": "pixel"}, "run": {"row": 2, "frames": 4, "fps": 13, "durations_ms": [77, 77, 77, 77], "loop": true, "frame_variant": "pixel"}, "jump": {"row": 3, "frames": 1, "fps": 10, "durations_ms": [100], "loop": false, "frame_variant": "pixel"}, "doublejump": {"row": 4, "frames": 1, "fps": 12, "durations_ms": [83], "loop": false, "frame_variant": "pixel"}, "crouch": {"row": 5, "frames": 4, "fps": 8, "durations_ms": [125, 125, 125, 125], "loop": false, "frame_variant": "pixel"}, "attack1": {"row": 6, "frames": 4, "fps": 13, "durations_ms": [77, 77, 77, 77], "loop": false, "frame_variant": "pixel"}, "attack2": {"row": 7, "frames": 4, "fps": 13, "durations_ms": [77, 77, 77, 77], "loop": false, "frame_variant": "pixel"}, "attack3": {"row": 8, "frames": 4, "fps": 11, "durations_ms": [91, 91, 91, 91], "loop": false, "frame_variant": "pixel"}, "skill1": {"row": 9, "frames": 4, "fps": 11, "durations_ms": [91, 91, 91, 91], "loop": false, "frame_variant": "pixel"}, "skill2": {"row": 10, "frames": 4, "fps": 10, "durations_ms": [100, 100, 100, 100], "loop": false, "frame_variant": "pixel"}, "ultimate": {"row": 11, "frames": 4, "fps": 9, "durations_ms": [111, 111, 111, 111], "loop": false, "frame_variant": "pixel"}, "hurt": {"row": 12, "frames": 4, "fps": 9, "durations_ms": [111, 111, 111, 111], "loop": false, "frame_variant": "pixel"}, "down": {"row": 13, "frames": 4, "fps": 7, "durations_ms": [143, 143, 143, 143], "loop": false, "frame_variant": "pixel"}, "recover": {"row": 14, "frames": 4, "fps": 8, "durations_ms": [125, 125, 125, 125], "loop": false, "frame_variant": "pixel"}}}, "frame_layout": {"sheetWidth": 1792, "sheetHeight": 4320, "cellWidth": 448, "cellHeight": 288, "rows": {"idle": [{"x": 0, "y": 0, "w": 448, "h": 288}, {"x": 448, "y": 0, "w": 448, "h": 288}, {"x": 896, "y": 0, "w": 448, "h": 288}, {"x": 1344, "y": 0, "w": 448, "h": 288}], "walk": [{"x": 0, "y": 288, "w": 448, "h": 288}, {"x": 448, "y": 288, "w": 448, "h": 288}, {"x": 896, "y": 288, "w": 448, "h": 288}, {"x": 1344, "y": 288, "w": 448, "h": 288}], "run": [{"x": 0, "y": 576, "w": 448, "h": 288}, {"x": 448, "y": 576, "w": 448, "h": 288}, {"x": 896, "y": 576, "w": 448, "h": 288}, {"x": 1344, "y": 576, "w": 448, "h": 288}], "jump": [{"x": 0, "y": 864, "w": 448, "h": 288}], "doublejump": [{"x": 0, "y": 1152, "w": 448, "h": 288}], "crouch": [{"x": 0, "y": 1440, "w": 448, "h": 288}, {"x": 448, "y": 1440, "w": 448, "h": 288}, {"x": 896, "y": 1440, "w": 448, "h": 288}, {"x": 1344, "y": 1440, "w": 448, "h": 288}], "attack1": [{"x": 0, "y": 1728, "w": 448, "h": 288}, {"x": 448, "y": 1728, "w": 448, "h": 288}, {"x": 896, "y": 1728, "w": 448, "h": 288}, {"x": 1344, "y": 1728, "w": 448, "h": 288}], "attack2": [{"x": 0, "y": 2016, "w": 448, "h": 288}, {"x": 448, "y": 2016, "w": 448, "h": 288}, {"x": 896, "y": 2016, "w": 448, "h": 288}, {"x": 1344, "y": 2016, "w": 448, "h": 288}], "attack3": [{"x": 0, "y": 2304, "w": 448, "h": 288}, {"x": 448, "y": 2304, "w": 448, "h": 288}, {"x": 896, "y": 2304, "w": 448, "h": 288}, {"x": 1344, "y": 2304, "w": 448, "h": 288}], "skill1": [{"x": 0, "y": 2592, "w": 448, "h": 288}, {"x": 448, "y": 2592, "w": 448, "h": 288}, {"x": 896, "y": 2592, "w": 448, "h": 288}, {"x": 1344, "y": 2592, "w": 448, "h": 288}], "skill2": [{"x": 0, "y": 2880, "w": 448, "h": 288}, {"x": 448, "y": 2880, "w": 448, "h": 288}, {"x": 896, "y": 2880, "w": 448, "h": 288}, {"x": 1344, "y": 2880, "w": 448, "h": 288}], "ultimate": [{"x": 0, "y": 3168, "w": 448, "h": 288}, {"x": 448, "y": 3168, "w": 448, "h": 288}, {"x": 896, "y": 3168, "w": 448, "h": 288}, {"x": 1344, "y": 3168, "w": 448, "h": 288}], "hurt": [{"x": 0, "y": 3456, "w": 448, "h": 288}, {"x": 448, "y": 3456, "w": 448, "h": 288}, {"x": 896, "y": 3456, "w": 448, "h": 288}, {"x": 1344, "y": 3456, "w": 448, "h": 288}], "down": [{"x": 0, "y": 3744, "w": 448, "h": 288}, {"x": 448, "y": 3744, "w": 448, "h": 288}, {"x": 896, "y": 3744, "w": 448, "h": 288}, {"x": 1344, "y": 3744, "w": 448, "h": 288}], "recover": [{"x": 0, "y": 4032, "w": 448, "h": 288}, {"x": 448, "y": 4032, "w": 448, "h": 288}, {"x": 896, "y": 4032, "w": 448, "h": 288}, {"x": 1344, "y": 4032, "w": 448, "h": 288}]}}}; +window.MIRA_METRICS = {"anchor": {"x": 224, "y": 280}, "states": {"idle": {"frames": [{"frame": 0, "bounds": {"left": -77, "right": 91, "top": -182, "bottom": 0}, "height": 182}, {"frame": 1, "bounds": {"left": -75, "right": 87, "top": -177, "bottom": 0}, "height": 177}, {"frame": 2, "bounds": {"left": -73, "right": 87, "top": -182, "bottom": 0}, "height": 182}, {"frame": 3, "bounds": {"left": -75, "right": 91, "top": -182, "bottom": 0}, "height": 182}]}, "walk": {"frames": [{"frame": 0, "bounds": {"left": -77, "right": 93, "top": -180, "bottom": 0}, "height": 180}, {"frame": 1, "bounds": {"left": -74, "right": 79, "top": -181, "bottom": 0}, "height": 181}, {"frame": 2, "bounds": {"left": -78, "right": 92, "top": -181, "bottom": 0}, "height": 181}, {"frame": 3, "bounds": {"left": -74, "right": 77, "top": -180, "bottom": 0}, "height": 180}]}, "run": {"frames": [{"frame": 0, "bounds": {"left": -77, "right": 99, "top": -185, "bottom": 0}, "height": 185}, {"frame": 1, "bounds": {"left": -71, "right": 80, "top": -177, "bottom": 0}, "height": 177}, {"frame": 2, "bounds": {"left": -74, "right": 91, "top": -184, "bottom": 0}, "height": 184}, {"frame": 3, "bounds": {"left": -72, "right": 79, "top": -177, "bottom": 0}, "height": 177}]}, "jump": {"frames": [{"frame": 0, "bounds": {"left": -83, "right": 93, "top": -179, "bottom": 0}, "height": 179}]}, "doublejump": {"frames": [{"frame": 0, "bounds": {"left": -86, "right": 55, "top": -145, "bottom": 0}, "height": 145}]}, "crouch": {"frames": [{"frame": 0, "bounds": {"left": -76, "right": 93, "top": -174, "bottom": 0}, "height": 174}, {"frame": 1, "bounds": {"left": -80, "right": 84, "top": -157, "bottom": 0}, "height": 157}, {"frame": 2, "bounds": {"left": -77, "right": 73, "top": -136, "bottom": 0}, "height": 136}, {"frame": 3, "bounds": {"left": -80, "right": 81, "top": -166, "bottom": 0}, "height": 166}]}, "attack1": {"frames": [{"frame": 0, "bounds": {"left": -80, "right": 95, "top": -180, "bottom": 0}, "height": 180}, {"frame": 1, "bounds": {"left": -81, "right": 95, "top": -180, "bottom": 0}, "height": 180}, {"frame": 2, "bounds": {"left": -80, "right": 98, "top": -180, "bottom": 0}, "height": 180}, {"frame": 3, "bounds": {"left": -79, "right": 91, "top": -180, "bottom": 0}, "height": 180}]}, "attack2": {"frames": [{"frame": 0, "bounds": {"left": -79, "right": 88, "top": -174, "bottom": 0}, "height": 174}, {"frame": 1, "bounds": {"left": -73, "right": 85, "top": -178, "bottom": 0}, "height": 178}, {"frame": 2, "bounds": {"left": -75, "right": 80, "top": -181, "bottom": 0}, "height": 181}, {"frame": 3, "bounds": {"left": -78, "right": 80, "top": -178, "bottom": 0}, "height": 178}]}, "attack3": {"frames": [{"frame": 0, "bounds": {"left": -79, "right": 79, "top": -197, "bottom": 0}, "height": 197}, {"frame": 1, "bounds": {"left": -78, "right": 87, "top": -169, "bottom": 0}, "height": 169}, {"frame": 2, "bounds": {"left": -82, "right": 73, "top": -140, "bottom": 0}, "height": 140}, {"frame": 3, "bounds": {"left": -80, "right": 84, "top": -171, "bottom": 0}, "height": 171}]}, "skill1": {"frames": [{"frame": 0, "bounds": {"left": -82, "right": 96, "top": -181, "bottom": 0}, "height": 181}, {"frame": 1, "bounds": {"left": -82, "right": 81, "top": -181, "bottom": 0}, "height": 181}, {"frame": 2, "bounds": {"left": -83, "right": 87, "top": -181, "bottom": 0}, "height": 181}, {"frame": 3, "bounds": {"left": -83, "right": 84, "top": -181, "bottom": 0}, "height": 181}]}, "skill2": {"frames": [{"frame": 0, "bounds": {"left": -81, "right": 91, "top": -152, "bottom": 0}, "height": 152}, {"frame": 1, "bounds": {"left": -73, "right": 93, "top": -170, "bottom": 0}, "height": 170}, {"frame": 2, "bounds": {"left": -107, "right": 91, "top": -156, "bottom": 0}, "height": 156}, {"frame": 3, "bounds": {"left": -75, "right": 80, "top": -171, "bottom": 0}, "height": 171}]}, "ultimate": {"frames": [{"frame": 0, "bounds": {"left": -72, "right": 83, "top": -179, "bottom": 0}, "height": 179}, {"frame": 1, "bounds": {"left": -75, "right": 84, "top": -181, "bottom": 0}, "height": 181}, {"frame": 2, "bounds": {"left": -71, "right": 85, "top": -172, "bottom": 0}, "height": 172}, {"frame": 3, "bounds": {"left": -71, "right": 82, "top": -172, "bottom": 0}, "height": 172}]}, "hurt": {"frames": [{"frame": 0, "bounds": {"left": -84, "right": 102, "top": -187, "bottom": 0}, "height": 187}, {"frame": 1, "bounds": {"left": -82, "right": 100, "top": -186, "bottom": 0}, "height": 186}, {"frame": 2, "bounds": {"left": -84, "right": 79, "top": -173, "bottom": 0}, "height": 173}, {"frame": 3, "bounds": {"left": -81, "right": 84, "top": -183, "bottom": 0}, "height": 183}]}, "down": {"frames": [{"frame": 0, "bounds": {"left": -94, "right": 83, "top": -180, "bottom": 0}, "height": 180}, {"frame": 1, "bounds": {"left": -95, "right": 81, "top": -147, "bottom": 0}, "height": 147}, {"frame": 2, "bounds": {"left": -89, "right": 85, "top": -125, "bottom": 0}, "height": 125}, {"frame": 3, "bounds": {"left": -91, "right": 92, "top": -117, "bottom": 0}, "height": 117}]}, "recover": {"frames": [{"frame": 0, "bounds": {"left": -86, "right": 85, "top": -122, "bottom": 0}, "height": 122}, {"frame": 1, "bounds": {"left": -94, "right": 80, "top": -146, "bottom": 0}, "height": 146}, {"frame": 2, "bounds": {"left": -69, "right": 77, "top": -165, "bottom": 0}, "height": 165}, {"frame": 3, "bounds": {"left": -85, "right": 89, "top": -177, "bottom": 0}, "height": 177}]}}, "playback": {"jump": {"airFrame": 0, "torsoPivot": {"x": 5, "y": -90}}, "doublejump": {"frame": 0, "duration": 0.28, "pivot": {"x": -16, "y": -72}}, "measurement": "Centers measured from final curated atlas, including integer air-pose alignment; no runtime resizing."}, "emitters": {"attack1": {"x": 98, "y": -111, "frame": 2, "method": "Farthest-right opaque endpoint on impact frame; six-pixel tip region, facing right"}, "attack2": {"x": 80, "y": -160, "frame": 2, "method": "Farthest-right opaque endpoint on impact frame; six-pixel tip region, facing right"}, "skill1": {"x": 87, "y": -110, "frame": 2, "method": "Farthest-right opaque endpoint on impact frame; six-pixel tip region, facing right"}, "skill2": {"x": 91, "y": -47, "frame": 2, "method": "Farthest-right opaque endpoint on impact frame; six-pixel tip region, facing right"}, "attack3": {"x": 56, "y": -31, "frame": 2, "method": "Front edge of both slammed fists on impact frame (automatic endpoint caught the front foot and was rejected after visual review); rescaled for the 182 px atlas and re-checked on qa/emitters-check.png"}, "ultimatePods": {"frame": 2, "pods": [{"name": "back", "x": -29, "y": -143}, {"name": "front", "x": 60, "y": -125}], "method": "Centres of the open rocket clusters on ultimate frames 3-4, read from a 2x zoom with anchor guides; rescaled for the 182 px atlas and re-checked on qa/emitters-check.png"}}}; diff --git a/assets/mira/run/sprite-sheet-alpha.webp b/assets/mira/run/sprite-sheet-alpha.webp new file mode 100644 index 0000000..af052a5 Binary files /dev/null and b/assets/mira/run/sprite-sheet-alpha.webp differ diff --git a/assets/mira/ui/cutin.webp b/assets/mira/ui/cutin.webp new file mode 100644 index 0000000..24386c6 Binary files /dev/null and b/assets/mira/ui/cutin.webp differ diff --git a/assets/mira/ui/fx-burst.webp b/assets/mira/ui/fx-burst.webp new file mode 100644 index 0000000..46ccad2 Binary files /dev/null and b/assets/mira/ui/fx-burst.webp differ diff --git a/assets/mira/ui/fx-crash.webp b/assets/mira/ui/fx-crash.webp new file mode 100644 index 0000000..a1a0062 Binary files /dev/null and b/assets/mira/ui/fx-crash.webp differ diff --git a/assets/mira/ui/fx-rocket.webp b/assets/mira/ui/fx-rocket.webp new file mode 100644 index 0000000..8f74726 Binary files /dev/null and b/assets/mira/ui/fx-rocket.webp differ diff --git a/assets/mira/ui/fx-star.webp b/assets/mira/ui/fx-star.webp new file mode 100644 index 0000000..98272e6 Binary files /dev/null and b/assets/mira/ui/fx-star.webp differ diff --git a/assets/mira/ui/icon-basic.webp b/assets/mira/ui/icon-basic.webp new file mode 100644 index 0000000..fa85fea Binary files /dev/null and b/assets/mira/ui/icon-basic.webp differ diff --git a/assets/mira/ui/icon-skill1.webp b/assets/mira/ui/icon-skill1.webp new file mode 100644 index 0000000..1e193be Binary files /dev/null and b/assets/mira/ui/icon-skill1.webp differ diff --git a/assets/mira/ui/icon-skill2.webp b/assets/mira/ui/icon-skill2.webp new file mode 100644 index 0000000..64e5c21 Binary files /dev/null and b/assets/mira/ui/icon-skill2.webp differ diff --git a/assets/mira/ui/icon-ultimate.webp b/assets/mira/ui/icon-ultimate.webp new file mode 100644 index 0000000..b4a6228 Binary files /dev/null and b/assets/mira/ui/icon-ultimate.webp differ diff --git a/assets/mira/ui/portrait.webp b/assets/mira/ui/portrait.webp new file mode 100644 index 0000000..27fca17 Binary files /dev/null and b/assets/mira/ui/portrait.webp differ diff --git a/assets/naja/audio/naja-ultimate-soraya.mp3 b/assets/naja/audio/naja-ultimate-soraya.mp3 new file mode 100644 index 0000000..c7bc573 Binary files /dev/null and b/assets/naja/audio/naja-ultimate-soraya.mp3 differ diff --git a/assets/naja/audio/naja_wins.mp3 b/assets/naja/audio/naja_wins.mp3 new file mode 100644 index 0000000..61f8285 Binary files /dev/null and b/assets/naja/audio/naja_wins.mp3 differ diff --git a/assets/naja/audio/select_naja.mp3 b/assets/naja/audio/select_naja.mp3 new file mode 100644 index 0000000..4897217 Binary files /dev/null and b/assets/naja/audio/select_naja.mp3 differ diff --git a/assets/naja/manifest.js b/assets/naja/manifest.js new file mode 100644 index 0000000..f2dc390 --- /dev/null +++ b/assets/naja/manifest.js @@ -0,0 +1,2 @@ +window.NAJA_MANIFEST = {"characterId": "naja", "engine": "component-row", "game_input": "sprite-sheet-alpha.png", "degraded_static_fallback": false, "curation_applied": true, "frame_variant": "pixel", "sprite_sheet_alpha": "sprite-sheet-alpha.png", "sprite_sheet_alpha_report": "sprite-sheet-alpha.report.json", "base_image": "base-source.png", "cell": {"shape": "rect", "width": 448, "height": 288, "safe_margin_x": 10, "safe_margin_y": 8}, "chroma_key": {"name": "green", "hex": "#00FF00", "rgb": [0, 255, 0], "selection": "manual"}, "animation": {"cellWidth": 448, "cellHeight": 288, "columns": 4, "rows": {"idle": {"row": 0, "frames": 4, "fps": 5, "durations_ms": [200, 200, 200, 200], "loop": true, "frame_variant": "pixel"}, "walk": {"row": 1, "frames": 4, "fps": 10, "durations_ms": [100, 100, 100, 100], "loop": true, "frame_variant": "pixel"}, "run": {"row": 2, "frames": 4, "fps": 13, "durations_ms": [77, 77, 77, 77], "loop": true, "frame_variant": "pixel"}, "jump": {"row": 3, "frames": 1, "fps": 10, "durations_ms": [100], "loop": false, "frame_variant": "pixel"}, "doublejump": {"row": 4, "frames": 1, "fps": 12, "durations_ms": [83], "loop": false, "frame_variant": "pixel"}, "crouch": {"row": 5, "frames": 4, "fps": 8, "durations_ms": [125, 125, 125, 125], "loop": false, "frame_variant": "pixel"}, "attack1": {"row": 6, "frames": 4, "fps": 13, "durations_ms": [77, 77, 77, 77], "loop": false, "frame_variant": "pixel"}, "attack2": {"row": 7, "frames": 4, "fps": 13, "durations_ms": [77, 77, 77, 77], "loop": false, "frame_variant": "pixel"}, "attack3": {"row": 8, "frames": 4, "fps": 11, "durations_ms": [91, 91, 91, 91], "loop": false, "frame_variant": "pixel"}, "skill1": {"row": 9, "frames": 4, "fps": 11, "durations_ms": [91, 91, 91, 91], "loop": false, "frame_variant": "pixel"}, "skill2": {"row": 10, "frames": 4, "fps": 10, "durations_ms": [100, 100, 100, 100], "loop": false, "frame_variant": "pixel"}, "ultimate": {"row": 11, "frames": 4, "fps": 9, "durations_ms": [111, 111, 111, 111], "loop": false, "frame_variant": "pixel"}, "hurt": {"row": 12, "frames": 4, "fps": 9, "durations_ms": [111, 111, 111, 111], "loop": false, "frame_variant": "pixel"}, "down": {"row": 13, "frames": 4, "fps": 7, "durations_ms": [143, 143, 143, 143], "loop": false, "frame_variant": "pixel"}, "recover": {"row": 14, "frames": 4, "fps": 8, "durations_ms": [125, 125, 125, 125], "loop": false, "frame_variant": "pixel"}}}, "frame_layout": {"sheetWidth": 1792, "sheetHeight": 4320, "cellWidth": 448, "cellHeight": 288, "rows": {"idle": [{"x": 0, "y": 0, "w": 448, "h": 288}, {"x": 448, "y": 0, "w": 448, "h": 288}, {"x": 896, "y": 0, "w": 448, "h": 288}, {"x": 1344, "y": 0, "w": 448, "h": 288}], "walk": [{"x": 0, "y": 288, "w": 448, "h": 288}, {"x": 448, "y": 288, "w": 448, "h": 288}, {"x": 896, "y": 288, "w": 448, "h": 288}, {"x": 1344, "y": 288, "w": 448, "h": 288}], "run": [{"x": 0, "y": 576, "w": 448, "h": 288}, {"x": 448, "y": 576, "w": 448, "h": 288}, {"x": 896, "y": 576, "w": 448, "h": 288}, {"x": 1344, "y": 576, "w": 448, "h": 288}], "jump": [{"x": 0, "y": 864, "w": 448, "h": 288}], "doublejump": [{"x": 0, "y": 1152, "w": 448, "h": 288}], "crouch": [{"x": 0, "y": 1440, "w": 448, "h": 288}, {"x": 448, "y": 1440, "w": 448, "h": 288}, {"x": 896, "y": 1440, "w": 448, "h": 288}, {"x": 1344, "y": 1440, "w": 448, "h": 288}], "attack1": [{"x": 0, "y": 1728, "w": 448, "h": 288}, {"x": 448, "y": 1728, "w": 448, "h": 288}, {"x": 896, "y": 1728, "w": 448, "h": 288}, {"x": 1344, "y": 1728, "w": 448, "h": 288}], "attack2": [{"x": 0, "y": 2016, "w": 448, "h": 288}, {"x": 448, "y": 2016, "w": 448, "h": 288}, {"x": 896, "y": 2016, "w": 448, "h": 288}, {"x": 1344, "y": 2016, "w": 448, "h": 288}], "attack3": [{"x": 0, "y": 2304, "w": 448, "h": 288}, {"x": 448, "y": 2304, "w": 448, "h": 288}, {"x": 896, "y": 2304, "w": 448, "h": 288}, {"x": 1344, "y": 2304, "w": 448, "h": 288}], "skill1": [{"x": 0, "y": 2592, "w": 448, "h": 288}, {"x": 448, "y": 2592, "w": 448, "h": 288}, {"x": 896, "y": 2592, "w": 448, "h": 288}, {"x": 1344, "y": 2592, "w": 448, "h": 288}], "skill2": [{"x": 0, "y": 2880, "w": 448, "h": 288}, {"x": 448, "y": 2880, "w": 448, "h": 288}, {"x": 896, "y": 2880, "w": 448, "h": 288}, {"x": 1344, "y": 2880, "w": 448, "h": 288}], "ultimate": [{"x": 0, "y": 3168, "w": 448, "h": 288}, {"x": 448, "y": 3168, "w": 448, "h": 288}, {"x": 896, "y": 3168, "w": 448, "h": 288}, {"x": 1344, "y": 3168, "w": 448, "h": 288}], "hurt": [{"x": 0, "y": 3456, "w": 448, "h": 288}, {"x": 448, "y": 3456, "w": 448, "h": 288}, {"x": 896, "y": 3456, "w": 448, "h": 288}, {"x": 1344, "y": 3456, "w": 448, "h": 288}], "down": [{"x": 0, "y": 3744, "w": 448, "h": 288}, {"x": 448, "y": 3744, "w": 448, "h": 288}, {"x": 896, "y": 3744, "w": 448, "h": 288}, {"x": 1344, "y": 3744, "w": 448, "h": 288}], "recover": [{"x": 0, "y": 4032, "w": 448, "h": 288}, {"x": 448, "y": 4032, "w": 448, "h": 288}, {"x": 896, "y": 4032, "w": 448, "h": 288}, {"x": 1344, "y": 4032, "w": 448, "h": 288}]}}}; +window.NAJA_METRICS = {"anchor": {"x": 224, "y": 280}, "states": {"idle": {"frames": [{"frame": 0, "bounds": {"left": -53, "right": 73, "top": -182, "bottom": 0}, "height": 182}, {"frame": 1, "bounds": {"left": -54, "right": 70, "top": -182, "bottom": 0}, "height": 182}, {"frame": 2, "bounds": {"left": -52, "right": 73, "top": -182, "bottom": 0}, "height": 182}, {"frame": 3, "bounds": {"left": -52, "right": 73, "top": -182, "bottom": 0}, "height": 182}]}, "walk": {"frames": [{"frame": 0, "bounds": {"left": -54, "right": 66, "top": -164, "bottom": 0}, "height": 164}, {"frame": 1, "bounds": {"left": -51, "right": 57, "top": -165, "bottom": 0}, "height": 165}, {"frame": 2, "bounds": {"left": -53, "right": 64, "top": -164, "bottom": 0}, "height": 164}, {"frame": 3, "bounds": {"left": -49, "right": 57, "top": -165, "bottom": 0}, "height": 165}]}, "run": {"frames": [{"frame": 0, "bounds": {"left": -82, "right": 71, "top": -125, "bottom": 0}, "height": 125}, {"frame": 1, "bounds": {"left": -77, "right": 69, "top": -125, "bottom": 0}, "height": 125}, {"frame": 2, "bounds": {"left": -81, "right": 69, "top": -124, "bottom": 0}, "height": 124}, {"frame": 3, "bounds": {"left": -75, "right": 70, "top": -124, "bottom": 0}, "height": 124}]}, "jump": {"frames": [{"frame": 0, "bounds": {"left": -56, "right": 88, "top": -161, "bottom": 0}, "height": 161}]}, "doublejump": {"frames": [{"frame": 0, "bounds": {"left": -45, "right": 57, "top": -146, "bottom": 0}, "height": 146}]}, "crouch": {"frames": [{"frame": 0, "bounds": {"left": -50, "right": 67, "top": -151, "bottom": 0}, "height": 151}, {"frame": 1, "bounds": {"left": -48, "right": 63, "top": -137, "bottom": 0}, "height": 137}, {"frame": 2, "bounds": {"left": -54, "right": 66, "top": -146, "bottom": 0}, "height": 146}, {"frame": 3, "bounds": {"left": -50, "right": 64, "top": -150, "bottom": 0}, "height": 150}]}, "attack1": {"frames": [{"frame": 0, "bounds": {"left": -54, "right": 69, "top": -171, "bottom": 0}, "height": 171}, {"frame": 1, "bounds": {"left": -55, "right": 151, "top": -171, "bottom": 0}, "height": 171}, {"frame": 2, "bounds": {"left": -57, "right": 161, "top": -171, "bottom": 0}, "height": 171}, {"frame": 3, "bounds": {"left": -54, "right": 70, "top": -171, "bottom": 0}, "height": 171}]}, "attack2": {"frames": [{"frame": 0, "bounds": {"left": -57, "right": 82, "top": -156, "bottom": 0}, "height": 156}, {"frame": 1, "bounds": {"left": -65, "right": 76, "top": -156, "bottom": 0}, "height": 156}, {"frame": 2, "bounds": {"left": -53, "right": 161, "top": -157, "bottom": 0}, "height": 157}, {"frame": 3, "bounds": {"left": -49, "right": 65, "top": -160, "bottom": 0}, "height": 160}]}, "attack3": {"frames": [{"frame": 0, "bounds": {"left": -70, "right": 73, "top": -221, "bottom": 0}, "height": 221}, {"frame": 1, "bounds": {"left": -78, "right": 87, "top": -178, "bottom": 0}, "height": 178}, {"frame": 2, "bounds": {"left": -65, "right": 196, "top": -177, "bottom": 0}, "height": 177}, {"frame": 3, "bounds": {"left": -59, "right": 86, "top": -178, "bottom": 0}, "height": 178}]}, "skill1": {"frames": [{"frame": 0, "bounds": {"left": -59, "right": 89, "top": -143, "bottom": 0}, "height": 143}, {"frame": 1, "bounds": {"left": -63, "right": 75, "top": -128, "bottom": 0}, "height": 128}, {"frame": 2, "bounds": {"left": -77, "right": 94, "top": -116, "bottom": 0}, "height": 116}, {"frame": 3, "bounds": {"left": -55, "right": 71, "top": -163, "bottom": 0}, "height": 163}]}, "skill2": {"frames": [{"frame": 0, "bounds": {"left": -58, "right": 91, "top": -186, "bottom": 0}, "height": 186}, {"frame": 1, "bounds": {"left": -64, "right": 91, "top": -186, "bottom": 0}, "height": 186}, {"frame": 2, "bounds": {"left": -61, "right": 118, "top": -185, "bottom": 0}, "height": 185}, {"frame": 3, "bounds": {"left": -56, "right": 77, "top": -186, "bottom": 0}, "height": 186}]}, "ultimate": {"frames": [{"frame": 0, "bounds": {"left": -67, "right": 62, "top": -142, "bottom": 0}, "height": 142}, {"frame": 1, "bounds": {"left": -67, "right": 73, "top": -163, "bottom": 0}, "height": 163}, {"frame": 2, "bounds": {"left": -64, "right": 62, "top": -175, "bottom": 0}, "height": 175}, {"frame": 3, "bounds": {"left": -65, "right": 61, "top": -175, "bottom": 0}, "height": 175}]}, "hurt": {"frames": [{"frame": 0, "bounds": {"left": -69, "right": 72, "top": -166, "bottom": 0}, "height": 166}, {"frame": 1, "bounds": {"left": -70, "right": 68, "top": -165, "bottom": 0}, "height": 165}, {"frame": 2, "bounds": {"left": -55, "right": 70, "top": -161, "bottom": 0}, "height": 161}, {"frame": 3, "bounds": {"left": -53, "right": 81, "top": -173, "bottom": 0}, "height": 173}]}, "down": {"frames": [{"frame": 0, "bounds": {"left": -65, "right": 66, "top": -149, "bottom": 0}, "height": 149}, {"frame": 1, "bounds": {"left": -74, "right": 78, "top": -128, "bottom": 0}, "height": 128}, {"frame": 2, "bounds": {"left": -86, "right": 77, "top": -62, "bottom": 0}, "height": 62}, {"frame": 3, "bounds": {"left": -85, "right": 78, "top": -53, "bottom": 0}, "height": 53}]}, "recover": {"frames": [{"frame": 0, "bounds": {"left": -94, "right": 81, "top": -74, "bottom": 0}, "height": 74}, {"frame": 1, "bounds": {"left": -75, "right": 62, "top": -123, "bottom": 0}, "height": 123}, {"frame": 2, "bounds": {"left": -53, "right": 63, "top": -146, "bottom": 0}, "height": 146}, {"frame": 3, "bounds": {"left": -55, "right": 73, "top": -170, "bottom": 0}, "height": 170}]}}, "playback": {"jump": {"airFrame": 0, "torsoPivot": {"x": 16, "y": -80}}, "doublejump": {"frame": 0, "duration": 0.28, "pivot": {"x": 6, "y": -73}}, "measurement": "Centers measured from final curated atlas, including integer air-pose alignment; no runtime resizing."}, "emitters": {"attack1": {"x": 161, "y": -99, "frame": 2, "method": "Farthest-right opaque endpoint on impact frame; six-pixel tip region, facing right"}, "attack2": {"x": 161, "y": -51, "frame": 2, "method": "Farthest-right opaque endpoint on impact frame; six-pixel tip region, facing right"}, "attack3": {"x": 196, "y": -108, "frame": 2, "method": "Farthest-right opaque endpoint on impact frame; six-pixel tip region, facing right"}, "skill1": {"x": 94, "y": -5, "frame": 2, "method": "Farthest-right opaque endpoint on impact frame; six-pixel tip region, facing right"}, "skill2": {"x": 118, "y": -93, "frame": 2, "method": "Farthest-right opaque endpoint on impact frame; six-pixel tip region, facing right"}}}; diff --git a/assets/naja/run/sprite-sheet-alpha.webp b/assets/naja/run/sprite-sheet-alpha.webp new file mode 100644 index 0000000..c127b12 Binary files /dev/null and b/assets/naja/run/sprite-sheet-alpha.webp differ diff --git a/assets/naja/ui/cutin.webp b/assets/naja/ui/cutin.webp new file mode 100644 index 0000000..d5a35f3 Binary files /dev/null and b/assets/naja/ui/cutin.webp differ diff --git a/assets/naja/ui/fx-cyclone.webp b/assets/naja/ui/fx-cyclone.webp new file mode 100644 index 0000000..73d184f Binary files /dev/null and b/assets/naja/ui/fx-cyclone.webp differ diff --git a/assets/naja/ui/fx-ripple.webp b/assets/naja/ui/fx-ripple.webp new file mode 100644 index 0000000..9e13e8c Binary files /dev/null and b/assets/naja/ui/fx-ripple.webp differ diff --git a/assets/naja/ui/fx-sandwave.webp b/assets/naja/ui/fx-sandwave.webp new file mode 100644 index 0000000..afdd879 Binary files /dev/null and b/assets/naja/ui/fx-sandwave.webp differ diff --git a/assets/naja/ui/fx-serpent.webp b/assets/naja/ui/fx-serpent.webp new file mode 100644 index 0000000..04bb611 Binary files /dev/null and b/assets/naja/ui/fx-serpent.webp differ diff --git a/assets/naja/ui/icon-basic.webp b/assets/naja/ui/icon-basic.webp new file mode 100644 index 0000000..c3f4e22 Binary files /dev/null and b/assets/naja/ui/icon-basic.webp differ diff --git a/assets/naja/ui/icon-skill1.webp b/assets/naja/ui/icon-skill1.webp new file mode 100644 index 0000000..3653c06 Binary files /dev/null and b/assets/naja/ui/icon-skill1.webp differ diff --git a/assets/naja/ui/icon-skill2.webp b/assets/naja/ui/icon-skill2.webp new file mode 100644 index 0000000..c64ac99 Binary files /dev/null and b/assets/naja/ui/icon-skill2.webp differ diff --git a/assets/naja/ui/icon-ultimate.webp b/assets/naja/ui/icon-ultimate.webp new file mode 100644 index 0000000..f00d7a5 Binary files /dev/null and b/assets/naja/ui/icon-ultimate.webp differ diff --git a/assets/naja/ui/portrait.webp b/assets/naja/ui/portrait.webp new file mode 100644 index 0000000..39bf568 Binary files /dev/null and b/assets/naja/ui/portrait.webp differ diff --git a/assets/nib/audio/nib-ultimate.mp3 b/assets/nib/audio/nib-ultimate.mp3 new file mode 100644 index 0000000..7489385 Binary files /dev/null and b/assets/nib/audio/nib-ultimate.mp3 differ diff --git a/assets/nib/audio/nib_wins.mp3 b/assets/nib/audio/nib_wins.mp3 new file mode 100644 index 0000000..be606ef Binary files /dev/null and b/assets/nib/audio/nib_wins.mp3 differ diff --git a/assets/nib/audio/select_nib.mp3 b/assets/nib/audio/select_nib.mp3 new file mode 100644 index 0000000..732de88 Binary files /dev/null and b/assets/nib/audio/select_nib.mp3 differ diff --git a/assets/nib/manifest.js b/assets/nib/manifest.js new file mode 100644 index 0000000..dd1ee8f --- /dev/null +++ b/assets/nib/manifest.js @@ -0,0 +1,2 @@ +window.NIB_MANIFEST = {"characterId": "nib", "engine": "component-row", "game_input": "sprite-sheet-alpha.png", "degraded_static_fallback": false, "curation_applied": true, "frame_variant": "pixel", "sprite_sheet_alpha": "sprite-sheet-alpha.png", "sprite_sheet_alpha_report": "sprite-sheet-alpha.report.json", "base_image": "base-source.png", "cell": {"shape": "rect", "width": 448, "height": 288, "safe_margin_x": 10, "safe_margin_y": 8}, "chroma_key": {"name": "green", "hex": "#00FF00", "rgb": [0, 255, 0], "selection": "manual"}, "animation": {"cellWidth": 448, "cellHeight": 288, "columns": 4, "rows": {"idle": {"row": 0, "frames": 4, "fps": 5, "durations_ms": [200, 200, 200, 200], "loop": true, "frame_variant": "pixel"}, "walk": {"row": 1, "frames": 4, "fps": 9, "durations_ms": [111, 111, 111, 111], "loop": true, "frame_variant": "pixel"}, "run": {"row": 2, "frames": 4, "fps": 12, "durations_ms": [83, 83, 83, 83], "loop": true, "frame_variant": "pixel"}, "jump": {"row": 3, "frames": 1, "fps": 10, "durations_ms": [100], "loop": false, "frame_variant": "pixel"}, "doublejump": {"row": 4, "frames": 1, "fps": 12, "durations_ms": [83], "loop": false, "frame_variant": "pixel"}, "crouch": {"row": 5, "frames": 4, "fps": 8, "durations_ms": [125, 125, 125, 125], "loop": false, "frame_variant": "pixel"}, "attack1": {"row": 6, "frames": 4, "fps": 13, "durations_ms": [77, 77, 77, 77], "loop": false, "frame_variant": "pixel"}, "attack2": {"row": 7, "frames": 4, "fps": 13, "durations_ms": [77, 77, 77, 77], "loop": false, "frame_variant": "pixel"}, "attack3": {"row": 8, "frames": 4, "fps": 11, "durations_ms": [91, 91, 91, 91], "loop": false, "frame_variant": "pixel"}, "skill1": {"row": 9, "frames": 4, "fps": 11, "durations_ms": [91, 91, 91, 91], "loop": false, "frame_variant": "pixel"}, "skill2": {"row": 10, "frames": 4, "fps": 11, "durations_ms": [91, 91, 91, 91], "loop": false, "frame_variant": "pixel"}, "ultimate": {"row": 11, "frames": 4, "fps": 8, "durations_ms": [125, 125, 125, 125], "loop": false, "frame_variant": "pixel"}, "hurt": {"row": 12, "frames": 4, "fps": 10, "durations_ms": [100, 100, 100, 100], "loop": false, "frame_variant": "pixel"}, "down": {"row": 13, "frames": 4, "fps": 8, "durations_ms": [125, 125, 125, 125], "loop": false, "frame_variant": "pixel"}, "recover": {"row": 14, "frames": 4, "fps": 10, "durations_ms": [100, 100, 100, 100], "loop": false, "frame_variant": "pixel"}}}, "frame_layout": {"sheetWidth": 1792, "sheetHeight": 4320, "cellWidth": 448, "cellHeight": 288, "rows": {"idle": [{"x": 0, "y": 0, "w": 448, "h": 288}, {"x": 448, "y": 0, "w": 448, "h": 288}, {"x": 896, "y": 0, "w": 448, "h": 288}, {"x": 1344, "y": 0, "w": 448, "h": 288}], "walk": [{"x": 0, "y": 288, "w": 448, "h": 288}, {"x": 448, "y": 288, "w": 448, "h": 288}, {"x": 896, "y": 288, "w": 448, "h": 288}, {"x": 1344, "y": 288, "w": 448, "h": 288}], "run": [{"x": 0, "y": 576, "w": 448, "h": 288}, {"x": 448, "y": 576, "w": 448, "h": 288}, {"x": 896, "y": 576, "w": 448, "h": 288}, {"x": 1344, "y": 576, "w": 448, "h": 288}], "jump": [{"x": 0, "y": 864, "w": 448, "h": 288}], "doublejump": [{"x": 0, "y": 1152, "w": 448, "h": 288}], "crouch": [{"x": 0, "y": 1440, "w": 448, "h": 288}, {"x": 448, "y": 1440, "w": 448, "h": 288}, {"x": 896, "y": 1440, "w": 448, "h": 288}, {"x": 1344, "y": 1440, "w": 448, "h": 288}], "attack1": [{"x": 0, "y": 1728, "w": 448, "h": 288}, {"x": 448, "y": 1728, "w": 448, "h": 288}, {"x": 896, "y": 1728, "w": 448, "h": 288}, {"x": 1344, "y": 1728, "w": 448, "h": 288}], "attack2": [{"x": 0, "y": 2016, "w": 448, "h": 288}, {"x": 448, "y": 2016, "w": 448, "h": 288}, {"x": 896, "y": 2016, "w": 448, "h": 288}, {"x": 1344, "y": 2016, "w": 448, "h": 288}], "attack3": [{"x": 0, "y": 2304, "w": 448, "h": 288}, {"x": 448, "y": 2304, "w": 448, "h": 288}, {"x": 896, "y": 2304, "w": 448, "h": 288}, {"x": 1344, "y": 2304, "w": 448, "h": 288}], "skill1": [{"x": 0, "y": 2592, "w": 448, "h": 288}, {"x": 448, "y": 2592, "w": 448, "h": 288}, {"x": 896, "y": 2592, "w": 448, "h": 288}, {"x": 1344, "y": 2592, "w": 448, "h": 288}], "skill2": [{"x": 0, "y": 2880, "w": 448, "h": 288}, {"x": 448, "y": 2880, "w": 448, "h": 288}, {"x": 896, "y": 2880, "w": 448, "h": 288}, {"x": 1344, "y": 2880, "w": 448, "h": 288}], "ultimate": [{"x": 0, "y": 3168, "w": 448, "h": 288}, {"x": 448, "y": 3168, "w": 448, "h": 288}, {"x": 896, "y": 3168, "w": 448, "h": 288}, {"x": 1344, "y": 3168, "w": 448, "h": 288}], "hurt": [{"x": 0, "y": 3456, "w": 448, "h": 288}, {"x": 448, "y": 3456, "w": 448, "h": 288}, {"x": 896, "y": 3456, "w": 448, "h": 288}, {"x": 1344, "y": 3456, "w": 448, "h": 288}], "down": [{"x": 0, "y": 3744, "w": 448, "h": 288}, {"x": 448, "y": 3744, "w": 448, "h": 288}, {"x": 896, "y": 3744, "w": 448, "h": 288}, {"x": 1344, "y": 3744, "w": 448, "h": 288}], "recover": [{"x": 0, "y": 4032, "w": 448, "h": 288}, {"x": 448, "y": 4032, "w": 448, "h": 288}, {"x": 896, "y": 4032, "w": 448, "h": 288}, {"x": 1344, "y": 4032, "w": 448, "h": 288}]}}}; +window.NIB_METRICS = {"anchor": {"x": 224, "y": 280}, "states": {"idle": {"frames": [{"frame": 0, "bounds": {"left": -63, "right": 63, "top": -150, "bottom": 0}, "height": 150}, {"frame": 1, "bounds": {"left": -59, "right": 61, "top": -142, "bottom": 0}, "height": 142}, {"frame": 2, "bounds": {"left": -66, "right": 59, "top": -150, "bottom": 0}, "height": 150}, {"frame": 3, "bounds": {"left": -63, "right": 63, "top": -150, "bottom": 0}, "height": 150}]}, "walk": {"frames": [{"frame": 0, "bounds": {"left": -56, "right": 66, "top": -159, "bottom": 0}, "height": 159}, {"frame": 1, "bounds": {"left": -51, "right": 67, "top": -160, "bottom": 0}, "height": 160}, {"frame": 2, "bounds": {"left": -54, "right": 65, "top": -160, "bottom": 0}, "height": 160}, {"frame": 3, "bounds": {"left": -52, "right": 66, "top": -161, "bottom": 0}, "height": 161}]}, "run": {"frames": [{"frame": 0, "bounds": {"left": -57, "right": 74, "top": -126, "bottom": 0}, "height": 126}, {"frame": 1, "bounds": {"left": -61, "right": 71, "top": -125, "bottom": 0}, "height": 125}, {"frame": 2, "bounds": {"left": -56, "right": 75, "top": -127, "bottom": 0}, "height": 127}, {"frame": 3, "bounds": {"left": -59, "right": 72, "top": -120, "bottom": 0}, "height": 120}]}, "jump": {"frames": [{"frame": 0, "bounds": {"left": -70, "right": 79, "top": -152, "bottom": 0}, "height": 152}]}, "doublejump": {"frames": [{"frame": 0, "bounds": {"left": -56, "right": 40, "top": -120, "bottom": 0}, "height": 120}]}, "crouch": {"frames": [{"frame": 0, "bounds": {"left": -60, "right": 62, "top": -131, "bottom": 0}, "height": 131}, {"frame": 1, "bounds": {"left": -67, "right": 51, "top": -127, "bottom": 0}, "height": 127}, {"frame": 2, "bounds": {"left": -66, "right": 51, "top": -127, "bottom": 0}, "height": 127}, {"frame": 3, "bounds": {"left": -57, "right": 65, "top": -137, "bottom": 0}, "height": 137}]}, "attack1": {"frames": [{"frame": 0, "bounds": {"left": -60, "right": 70, "top": -157, "bottom": 0}, "height": 157}, {"frame": 1, "bounds": {"left": -52, "right": 69, "top": -157, "bottom": 0}, "height": 157}, {"frame": 2, "bounds": {"left": -56, "right": 107, "top": -158, "bottom": 0}, "height": 158}, {"frame": 3, "bounds": {"left": -61, "right": 73, "top": -157, "bottom": 0}, "height": 157}]}, "attack2": {"frames": [{"frame": 0, "bounds": {"left": -72, "right": 75, "top": -154, "bottom": 0}, "height": 154}, {"frame": 1, "bounds": {"left": -59, "right": 67, "top": -154, "bottom": 0}, "height": 154}, {"frame": 2, "bounds": {"left": -63, "right": 83, "top": -160, "bottom": 0}, "height": 160}, {"frame": 3, "bounds": {"left": -63, "right": 74, "top": -155, "bottom": 0}, "height": 155}]}, "attack3": {"frames": [{"frame": 0, "bounds": {"left": -70, "right": 64, "top": -145, "bottom": 0}, "height": 145}, {"frame": 1, "bounds": {"left": -63, "right": 76, "top": -140, "bottom": 0}, "height": 140}, {"frame": 2, "bounds": {"left": -80, "right": 101, "top": -140, "bottom": 0}, "height": 140}, {"frame": 3, "bounds": {"left": -61, "right": 67, "top": -146, "bottom": 0}, "height": 146}]}, "skill1": {"frames": [{"frame": 0, "bounds": {"left": -68, "right": 62, "top": -155, "bottom": 0}, "height": 155}, {"frame": 1, "bounds": {"left": -68, "right": 62, "top": -154, "bottom": 0}, "height": 154}, {"frame": 2, "bounds": {"left": -57, "right": 81, "top": -152, "bottom": 0}, "height": 152}, {"frame": 3, "bounds": {"left": -65, "right": 64, "top": -155, "bottom": 0}, "height": 155}]}, "skill2": {"frames": [{"frame": 0, "bounds": {"left": -63, "right": 71, "top": -122, "bottom": 0}, "height": 122}, {"frame": 1, "bounds": {"left": -36, "right": 103, "top": -113, "bottom": 0}, "height": 113}, {"frame": 2, "bounds": {"left": -78, "right": 105, "top": -108, "bottom": 0}, "height": 108}, {"frame": 3, "bounds": {"left": -59, "right": 54, "top": -128, "bottom": 0}, "height": 128}]}, "ultimate": {"frames": [{"frame": 0, "bounds": {"left": -51, "right": 48, "top": -152, "bottom": 0}, "height": 152}, {"frame": 1, "bounds": {"left": -65, "right": 51, "top": -152, "bottom": 0}, "height": 152}, {"frame": 2, "bounds": {"left": -56, "right": 73, "top": -152, "bottom": 0}, "height": 152}, {"frame": 3, "bounds": {"left": -63, "right": 36, "top": -154, "bottom": 0}, "height": 154}]}, "hurt": {"frames": [{"frame": 0, "bounds": {"left": -87, "right": 28, "top": -149, "bottom": 0}, "height": 149}, {"frame": 1, "bounds": {"left": -62, "right": 60, "top": -142, "bottom": 0}, "height": 142}, {"frame": 2, "bounds": {"left": -88, "right": 45, "top": -147, "bottom": 0}, "height": 147}, {"frame": 3, "bounds": {"left": -63, "right": 57, "top": -152, "bottom": 0}, "height": 152}]}, "down": {"frames": [{"frame": 0, "bounds": {"left": -75, "right": 49, "top": -122, "bottom": 0}, "height": 122}, {"frame": 1, "bounds": {"left": -61, "right": 54, "top": -108, "bottom": 0}, "height": 108}, {"frame": 2, "bounds": {"left": -64, "right": 69, "top": -94, "bottom": 0}, "height": 94}, {"frame": 3, "bounds": {"left": -64, "right": 65, "top": -112, "bottom": 0}, "height": 112}]}, "recover": {"frames": [{"frame": 0, "bounds": {"left": -90, "right": 71, "top": -104, "bottom": 0}, "height": 104}, {"frame": 1, "bounds": {"left": -66, "right": 56, "top": -125, "bottom": 0}, "height": 125}, {"frame": 2, "bounds": {"left": -46, "right": 69, "top": -141, "bottom": 0}, "height": 141}, {"frame": 3, "bounds": {"left": -64, "right": 59, "top": -164, "bottom": 0}, "height": 164}]}}, "playback": {"jump": {"airFrame": 0, "torsoPivot": {"x": 4, "y": -76}}, "doublejump": {"frame": 0, "duration": 0.28, "pivot": {"x": -8, "y": -60}}, "measurement": "Centers measured from final curated atlas, including integer air-pose alignment; no runtime resizing."}, "emitters": {"attack1": {"x": 107, "y": -94, "frame": 2, "method": "Baton tip or hand on impact frame (farthest-right six-pixel tip; top-right extreme for the rising swing), facing right"}, "attack2": {"x": 80, "y": -160, "frame": 2, "method": "Baton tip or hand on impact frame (farthest-right six-pixel tip; top-right extreme for the rising swing), facing right"}, "attack3": {"x": 101, "y": -80, "frame": 2, "method": "Baton tip or hand on impact frame (farthest-right six-pixel tip; top-right extreme for the rising swing), facing right"}, "skill1": {"x": 81, "y": -94, "frame": 2, "method": "Baton tip or hand on impact frame (farthest-right six-pixel tip; top-right extreme for the rising swing), facing right"}, "skill2": {"x": 105, "y": -51, "frame": 2, "method": "Baton tip or hand on impact frame (farthest-right six-pixel tip; top-right extreme for the rising swing), facing right"}, "ultimate": {"x": 73, "y": -102, "frame": 2, "method": "Baton tip or hand on impact frame (farthest-right six-pixel tip; top-right extreme for the rising swing), facing right"}}}; diff --git a/assets/nib/run/sprite-sheet-alpha.webp b/assets/nib/run/sprite-sheet-alpha.webp new file mode 100644 index 0000000..bbb8df4 Binary files /dev/null and b/assets/nib/run/sprite-sheet-alpha.webp differ diff --git a/assets/nib/ui/cutin.webp b/assets/nib/ui/cutin.webp new file mode 100644 index 0000000..0157a25 Binary files /dev/null and b/assets/nib/ui/cutin.webp differ diff --git a/assets/nib/ui/fx-letter.webp b/assets/nib/ui/fx-letter.webp new file mode 100644 index 0000000..f63a9ec Binary files /dev/null and b/assets/nib/ui/fx-letter.webp differ diff --git a/assets/nib/ui/fx-plane.webp b/assets/nib/ui/fx-plane.webp new file mode 100644 index 0000000..b76d57e Binary files /dev/null and b/assets/nib/ui/fx-plane.webp differ diff --git a/assets/nib/ui/fx-slip.webp b/assets/nib/ui/fx-slip.webp new file mode 100644 index 0000000..01dcacb Binary files /dev/null and b/assets/nib/ui/fx-slip.webp differ diff --git a/assets/nib/ui/fx-stamp.webp b/assets/nib/ui/fx-stamp.webp new file mode 100644 index 0000000..f48e19c Binary files /dev/null and b/assets/nib/ui/fx-stamp.webp differ diff --git a/assets/nib/ui/icon-basic.webp b/assets/nib/ui/icon-basic.webp new file mode 100644 index 0000000..4de5427 Binary files /dev/null and b/assets/nib/ui/icon-basic.webp differ diff --git a/assets/nib/ui/icon-skill1.webp b/assets/nib/ui/icon-skill1.webp new file mode 100644 index 0000000..3312403 Binary files /dev/null and b/assets/nib/ui/icon-skill1.webp differ diff --git a/assets/nib/ui/icon-skill2.webp b/assets/nib/ui/icon-skill2.webp new file mode 100644 index 0000000..5370ee2 Binary files /dev/null and b/assets/nib/ui/icon-skill2.webp differ diff --git a/assets/nib/ui/icon-ultimate.webp b/assets/nib/ui/icon-ultimate.webp new file mode 100644 index 0000000..d1239ec Binary files /dev/null and b/assets/nib/ui/icon-ultimate.webp differ diff --git a/assets/nib/ui/portrait.webp b/assets/nib/ui/portrait.webp new file mode 100644 index 0000000..3d6d65d Binary files /dev/null and b/assets/nib/ui/portrait.webp differ diff --git a/assets/rhea/audio/rhea-ultimate.mp3 b/assets/rhea/audio/rhea-ultimate.mp3 new file mode 100644 index 0000000..28b05d0 Binary files /dev/null and b/assets/rhea/audio/rhea-ultimate.mp3 differ diff --git a/assets/rhea/audio/rhea_wins.mp3 b/assets/rhea/audio/rhea_wins.mp3 new file mode 100644 index 0000000..d166e03 Binary files /dev/null and b/assets/rhea/audio/rhea_wins.mp3 differ diff --git a/assets/rhea/audio/select_rhea.mp3 b/assets/rhea/audio/select_rhea.mp3 new file mode 100644 index 0000000..ecb8e93 Binary files /dev/null and b/assets/rhea/audio/select_rhea.mp3 differ diff --git a/assets/rhea/manifest.js b/assets/rhea/manifest.js new file mode 100644 index 0000000..ed508c5 --- /dev/null +++ b/assets/rhea/manifest.js @@ -0,0 +1,2 @@ +window.RHEA_MANIFEST = {"characterId": "rhea", "engine": "component-row", "game_input": "sprite-sheet-alpha.png", "degraded_static_fallback": false, "curation_applied": true, "frame_variant": "pixel", "sprite_sheet_alpha": "sprite-sheet-alpha.png", "sprite_sheet_alpha_report": "sprite-sheet-alpha.report.json", "base_image": "base-source.png", "cell": {"shape": "rect", "width": 448, "height": 288, "safe_margin_x": 10, "safe_margin_y": 8}, "chroma_key": {"name": "green", "hex": "#00FF00", "rgb": [0, 255, 0], "selection": "manual"}, "animation": {"cellWidth": 448, "cellHeight": 288, "columns": 4, "rows": {"idle": {"row": 0, "frames": 4, "fps": 5, "durations_ms": [200, 200, 200, 200], "loop": true, "frame_variant": "pixel"}, "walk": {"row": 1, "frames": 4, "fps": 9, "durations_ms": [111, 111, 111, 111], "loop": true, "frame_variant": "pixel"}, "run": {"row": 2, "frames": 4, "fps": 11, "durations_ms": [91, 91, 91, 91], "loop": true, "frame_variant": "pixel"}, "jump": {"row": 3, "frames": 1, "fps": 10, "durations_ms": [100], "loop": false, "frame_variant": "pixel"}, "doublejump": {"row": 4, "frames": 1, "fps": 12, "durations_ms": [83], "loop": false, "frame_variant": "pixel"}, "crouch": {"row": 5, "frames": 4, "fps": 8, "durations_ms": [125, 125, 125, 125], "loop": false, "frame_variant": "pixel"}, "attack1": {"row": 6, "frames": 4, "fps": 12, "durations_ms": [83, 83, 83, 83], "loop": false, "frame_variant": "pixel"}, "attack2": {"row": 7, "frames": 4, "fps": 12, "durations_ms": [83, 83, 83, 83], "loop": false, "frame_variant": "pixel"}, "attack3": {"row": 8, "frames": 4, "fps": 10, "durations_ms": [100, 100, 100, 100], "loop": false, "frame_variant": "pixel"}, "skill1": {"row": 9, "frames": 4, "fps": 10, "durations_ms": [100, 100, 100, 100], "loop": false, "frame_variant": "pixel"}, "skill2": {"row": 10, "frames": 4, "fps": 10, "durations_ms": [100, 100, 100, 100], "loop": false, "frame_variant": "pixel"}, "ultimate": {"row": 11, "frames": 4, "fps": 8, "durations_ms": [125, 125, 125, 125], "loop": false, "frame_variant": "pixel"}, "hurt": {"row": 12, "frames": 4, "fps": 10, "durations_ms": [100, 100, 100, 100], "loop": false, "frame_variant": "pixel"}, "down": {"row": 13, "frames": 4, "fps": 8, "durations_ms": [125, 125, 125, 125], "loop": false, "frame_variant": "pixel"}, "recover": {"row": 14, "frames": 4, "fps": 10, "durations_ms": [100, 100, 100, 100], "loop": false, "frame_variant": "pixel"}}}, "frame_layout": {"sheetWidth": 1792, "sheetHeight": 4320, "cellWidth": 448, "cellHeight": 288, "rows": {"idle": [{"x": 0, "y": 0, "w": 448, "h": 288}, {"x": 448, "y": 0, "w": 448, "h": 288}, {"x": 896, "y": 0, "w": 448, "h": 288}, {"x": 1344, "y": 0, "w": 448, "h": 288}], "walk": [{"x": 0, "y": 288, "w": 448, "h": 288}, {"x": 448, "y": 288, "w": 448, "h": 288}, {"x": 896, "y": 288, "w": 448, "h": 288}, {"x": 1344, "y": 288, "w": 448, "h": 288}], "run": [{"x": 0, "y": 576, "w": 448, "h": 288}, {"x": 448, "y": 576, "w": 448, "h": 288}, {"x": 896, "y": 576, "w": 448, "h": 288}, {"x": 1344, "y": 576, "w": 448, "h": 288}], "jump": [{"x": 0, "y": 864, "w": 448, "h": 288}], "doublejump": [{"x": 0, "y": 1152, "w": 448, "h": 288}], "crouch": [{"x": 0, "y": 1440, "w": 448, "h": 288}, {"x": 448, "y": 1440, "w": 448, "h": 288}, {"x": 896, "y": 1440, "w": 448, "h": 288}, {"x": 1344, "y": 1440, "w": 448, "h": 288}], "attack1": [{"x": 0, "y": 1728, "w": 448, "h": 288}, {"x": 448, "y": 1728, "w": 448, "h": 288}, {"x": 896, "y": 1728, "w": 448, "h": 288}, {"x": 1344, "y": 1728, "w": 448, "h": 288}], "attack2": [{"x": 0, "y": 2016, "w": 448, "h": 288}, {"x": 448, "y": 2016, "w": 448, "h": 288}, {"x": 896, "y": 2016, "w": 448, "h": 288}, {"x": 1344, "y": 2016, "w": 448, "h": 288}], "attack3": [{"x": 0, "y": 2304, "w": 448, "h": 288}, {"x": 448, "y": 2304, "w": 448, "h": 288}, {"x": 896, "y": 2304, "w": 448, "h": 288}, {"x": 1344, "y": 2304, "w": 448, "h": 288}], "skill1": [{"x": 0, "y": 2592, "w": 448, "h": 288}, {"x": 448, "y": 2592, "w": 448, "h": 288}, {"x": 896, "y": 2592, "w": 448, "h": 288}, {"x": 1344, "y": 2592, "w": 448, "h": 288}], "skill2": [{"x": 0, "y": 2880, "w": 448, "h": 288}, {"x": 448, "y": 2880, "w": 448, "h": 288}, {"x": 896, "y": 2880, "w": 448, "h": 288}, {"x": 1344, "y": 2880, "w": 448, "h": 288}], "ultimate": [{"x": 0, "y": 3168, "w": 448, "h": 288}, {"x": 448, "y": 3168, "w": 448, "h": 288}, {"x": 896, "y": 3168, "w": 448, "h": 288}, {"x": 1344, "y": 3168, "w": 448, "h": 288}], "hurt": [{"x": 0, "y": 3456, "w": 448, "h": 288}, {"x": 448, "y": 3456, "w": 448, "h": 288}, {"x": 896, "y": 3456, "w": 448, "h": 288}, {"x": 1344, "y": 3456, "w": 448, "h": 288}], "down": [{"x": 0, "y": 3744, "w": 448, "h": 288}, {"x": 448, "y": 3744, "w": 448, "h": 288}, {"x": 896, "y": 3744, "w": 448, "h": 288}, {"x": 1344, "y": 3744, "w": 448, "h": 288}], "recover": [{"x": 0, "y": 4032, "w": 448, "h": 288}, {"x": 448, "y": 4032, "w": 448, "h": 288}, {"x": 896, "y": 4032, "w": 448, "h": 288}, {"x": 1344, "y": 4032, "w": 448, "h": 288}]}}}; +window.RHEA_METRICS = {"anchor": {"x": 224, "y": 280}, "states": {"idle": {"frames": [{"frame": 0, "bounds": {"left": -56, "right": 63, "top": -170, "bottom": 0}, "height": 170}, {"frame": 1, "bounds": {"left": -57, "right": 62, "top": -172, "bottom": 0}, "height": 172}, {"frame": 2, "bounds": {"left": -55, "right": 64, "top": -167, "bottom": 0}, "height": 167}, {"frame": 3, "bounds": {"left": -56, "right": 62, "top": -170, "bottom": 0}, "height": 170}]}, "walk": {"frames": [{"frame": 0, "bounds": {"left": -54, "right": 66, "top": -164, "bottom": 0}, "height": 164}, {"frame": 1, "bounds": {"left": -54, "right": 65, "top": -167, "bottom": 0}, "height": 167}, {"frame": 2, "bounds": {"left": -54, "right": 66, "top": -164, "bottom": 0}, "height": 164}, {"frame": 3, "bounds": {"left": -54, "right": 65, "top": -167, "bottom": 0}, "height": 167}]}, "run": {"frames": [{"frame": 0, "bounds": {"left": -66, "right": 51, "top": -146, "bottom": 0}, "height": 146}, {"frame": 1, "bounds": {"left": -64, "right": 50, "top": -147, "bottom": 0}, "height": 147}, {"frame": 2, "bounds": {"left": -67, "right": 50, "top": -146, "bottom": 0}, "height": 146}, {"frame": 3, "bounds": {"left": -64, "right": 49, "top": -148, "bottom": 0}, "height": 148}]}, "jump": {"frames": [{"frame": 0, "bounds": {"left": -53, "right": 79, "top": -146, "bottom": 0}, "height": 146}]}, "doublejump": {"frames": [{"frame": 0, "bounds": {"left": -52, "right": 54, "top": -153, "bottom": 0}, "height": 153}]}, "crouch": {"frames": [{"frame": 0, "bounds": {"left": -47, "right": 57, "top": -140, "bottom": 0}, "height": 140}, {"frame": 1, "bounds": {"left": -45, "right": 56, "top": -131, "bottom": 0}, "height": 131}, {"frame": 2, "bounds": {"left": -44, "right": 59, "top": -132, "bottom": 0}, "height": 132}, {"frame": 3, "bounds": {"left": -42, "right": 55, "top": -141, "bottom": 0}, "height": 141}]}, "attack1": {"frames": [{"frame": 0, "bounds": {"left": -51, "right": 60, "top": -152, "bottom": 0}, "height": 152}, {"frame": 1, "bounds": {"left": -51, "right": 72, "top": -153, "bottom": 0}, "height": 153}, {"frame": 2, "bounds": {"left": -51, "right": 109, "top": -152, "bottom": 0}, "height": 152}, {"frame": 3, "bounds": {"left": -48, "right": 63, "top": -152, "bottom": 0}, "height": 152}]}, "attack2": {"frames": [{"frame": 0, "bounds": {"left": -46, "right": 74, "top": -155, "bottom": 0}, "height": 155}, {"frame": 1, "bounds": {"left": -46, "right": 78, "top": -155, "bottom": 0}, "height": 155}, {"frame": 2, "bounds": {"left": -45, "right": 80, "top": -174, "bottom": 0}, "height": 174}, {"frame": 3, "bounds": {"left": -46, "right": 78, "top": -155, "bottom": 0}, "height": 155}]}, "attack3": {"frames": [{"frame": 0, "bounds": {"left": -44, "right": 56, "top": -156, "bottom": 0}, "height": 156}, {"frame": 1, "bounds": {"left": -48, "right": 54, "top": -155, "bottom": 0}, "height": 155}, {"frame": 2, "bounds": {"left": -50, "right": 128, "top": -156, "bottom": 0}, "height": 156}, {"frame": 3, "bounds": {"left": -45, "right": 50, "top": -157, "bottom": 0}, "height": 157}]}, "skill1": {"frames": [{"frame": 0, "bounds": {"left": -55, "right": 70, "top": -166, "bottom": 0}, "height": 166}, {"frame": 1, "bounds": {"left": -55, "right": 70, "top": -165, "bottom": 0}, "height": 165}, {"frame": 2, "bounds": {"left": -58, "right": 70, "top": -165, "bottom": 0}, "height": 165}, {"frame": 3, "bounds": {"left": -60, "right": 66, "top": -165, "bottom": 0}, "height": 165}]}, "skill2": {"frames": [{"frame": 0, "bounds": {"left": -58, "right": 67, "top": -161, "bottom": 0}, "height": 161}, {"frame": 1, "bounds": {"left": -49, "right": 67, "top": -160, "bottom": 0}, "height": 160}, {"frame": 2, "bounds": {"left": -49, "right": 91, "top": -151, "bottom": 0}, "height": 151}, {"frame": 3, "bounds": {"left": -57, "right": 60, "top": -160, "bottom": 0}, "height": 160}]}, "ultimate": {"frames": [{"frame": 0, "bounds": {"left": -60, "right": 68, "top": -161, "bottom": 0}, "height": 161}, {"frame": 1, "bounds": {"left": -54, "right": 77, "top": -161, "bottom": 0}, "height": 161}, {"frame": 2, "bounds": {"left": -67, "right": 76, "top": -161, "bottom": 0}, "height": 161}, {"frame": 3, "bounds": {"left": -55, "right": 60, "top": -161, "bottom": 0}, "height": 161}]}, "hurt": {"frames": [{"frame": 0, "bounds": {"left": -80, "right": 50, "top": -145, "bottom": 0}, "height": 145}, {"frame": 1, "bounds": {"left": -77, "right": 38, "top": -142, "bottom": 0}, "height": 142}, {"frame": 2, "bounds": {"left": -71, "right": 64, "top": -144, "bottom": 0}, "height": 144}, {"frame": 3, "bounds": {"left": -48, "right": 70, "top": -147, "bottom": 0}, "height": 147}]}, "down": {"frames": [{"frame": 0, "bounds": {"left": -79, "right": 48, "top": -116, "bottom": 0}, "height": 116}, {"frame": 1, "bounds": {"left": -67, "right": 47, "top": -106, "bottom": 0}, "height": 106}, {"frame": 2, "bounds": {"left": -71, "right": 58, "top": -79, "bottom": 0}, "height": 79}, {"frame": 3, "bounds": {"left": -69, "right": 58, "top": -79, "bottom": 0}, "height": 79}]}, "recover": {"frames": [{"frame": 0, "bounds": {"left": -76, "right": 59, "top": -103, "bottom": 0}, "height": 103}, {"frame": 1, "bounds": {"left": -64, "right": 54, "top": -121, "bottom": 0}, "height": 121}, {"frame": 2, "bounds": {"left": -53, "right": 69, "top": -145, "bottom": 0}, "height": 145}, {"frame": 3, "bounds": {"left": -57, "right": 63, "top": -158, "bottom": 0}, "height": 158}]}}, "playback": {"jump": {"airFrame": 0, "torsoPivot": {"x": 13, "y": -73}}, "doublejump": {"frame": 0, "duration": 0.28, "pivot": {"x": 1, "y": -76}}, "measurement": "Centers measured from final curated atlas, including integer air-pose alignment; no runtime resizing."}, "emitters": {"attack1": {"x": 109, "y": -91, "frame": 2, "method": "Farthest-right opaque endpoint on impact frame (sphere or hand); six-pixel tip region, facing right"}, "attack2": {"x": 80, "y": -163, "frame": 2, "method": "Farthest-right opaque endpoint on impact frame (sphere or hand); six-pixel tip region, facing right"}, "attack3": {"x": 128, "y": -91, "frame": 2, "method": "Farthest-right opaque endpoint on impact frame (sphere or hand); six-pixel tip region, facing right"}, "skill1": {"x": 70, "y": -106, "frame": 2, "method": "Farthest-right opaque endpoint on impact frame (sphere or hand); six-pixel tip region, facing right"}, "skill2": {"x": 91, "y": -82, "frame": 2, "method": "Farthest-right opaque endpoint on impact frame (sphere or hand); six-pixel tip region, facing right"}, "ultimate": {"x": 76, "y": -111, "frame": 2, "method": "Farthest-right opaque endpoint on impact frame (sphere or hand); six-pixel tip region, facing right"}}}; diff --git a/assets/rhea/run/sprite-sheet-alpha.webp b/assets/rhea/run/sprite-sheet-alpha.webp new file mode 100644 index 0000000..e0b77d2 Binary files /dev/null and b/assets/rhea/run/sprite-sheet-alpha.webp differ diff --git a/assets/rhea/ui/cutin.webp b/assets/rhea/ui/cutin.webp new file mode 100644 index 0000000..f47656e Binary files /dev/null and b/assets/rhea/ui/cutin.webp differ diff --git a/assets/rhea/ui/fx-burst.webp b/assets/rhea/ui/fx-burst.webp new file mode 100644 index 0000000..35bc903 Binary files /dev/null and b/assets/rhea/ui/fx-burst.webp differ diff --git a/assets/rhea/ui/fx-drift.webp b/assets/rhea/ui/fx-drift.webp new file mode 100644 index 0000000..c255cc6 Binary files /dev/null and b/assets/rhea/ui/fx-drift.webp differ diff --git a/assets/rhea/ui/fx-planet.webp b/assets/rhea/ui/fx-planet.webp new file mode 100644 index 0000000..a370030 Binary files /dev/null and b/assets/rhea/ui/fx-planet.webp differ diff --git a/assets/rhea/ui/fx-well.webp b/assets/rhea/ui/fx-well.webp new file mode 100644 index 0000000..3643c3d Binary files /dev/null and b/assets/rhea/ui/fx-well.webp differ diff --git a/assets/rhea/ui/icon-basic.webp b/assets/rhea/ui/icon-basic.webp new file mode 100644 index 0000000..38de485 Binary files /dev/null and b/assets/rhea/ui/icon-basic.webp differ diff --git a/assets/rhea/ui/icon-skill1.webp b/assets/rhea/ui/icon-skill1.webp new file mode 100644 index 0000000..6462e19 Binary files /dev/null and b/assets/rhea/ui/icon-skill1.webp differ diff --git a/assets/rhea/ui/icon-skill2.webp b/assets/rhea/ui/icon-skill2.webp new file mode 100644 index 0000000..d172545 Binary files /dev/null and b/assets/rhea/ui/icon-skill2.webp differ diff --git a/assets/rhea/ui/icon-ultimate.webp b/assets/rhea/ui/icon-ultimate.webp new file mode 100644 index 0000000..9ffb3c7 Binary files /dev/null and b/assets/rhea/ui/icon-ultimate.webp differ diff --git a/assets/rhea/ui/portrait.webp b/assets/rhea/ui/portrait.webp new file mode 100644 index 0000000..7ff314d Binary files /dev/null and b/assets/rhea/ui/portrait.webp differ diff --git a/assets/showcase/edda/portrait.webp b/assets/showcase/edda/portrait.webp new file mode 100644 index 0000000..bb58b2f Binary files /dev/null and b/assets/showcase/edda/portrait.webp differ diff --git a/assets/showcase/haldor/portrait.webp b/assets/showcase/haldor/portrait.webp new file mode 100644 index 0000000..b3bdf99 Binary files /dev/null and b/assets/showcase/haldor/portrait.webp differ diff --git a/assets/showcase/isolde/portrait.webp b/assets/showcase/isolde/portrait.webp new file mode 100644 index 0000000..a28340b Binary files /dev/null and b/assets/showcase/isolde/portrait.webp differ diff --git a/assets/showcase/naja/portrait.webp b/assets/showcase/naja/portrait.webp new file mode 100644 index 0000000..39bf568 Binary files /dev/null and b/assets/showcase/naja/portrait.webp differ diff --git a/assets/showcase/nib/portrait.webp b/assets/showcase/nib/portrait.webp new file mode 100644 index 0000000..3d6d65d Binary files /dev/null and b/assets/showcase/nib/portrait.webp differ diff --git a/assets/showcase/rhea/portrait.webp b/assets/showcase/rhea/portrait.webp new file mode 100644 index 0000000..7ff314d Binary files /dev/null and b/assets/showcase/rhea/portrait.webp differ diff --git a/assets/showcase/showcase.js b/assets/showcase/showcase.js new file mode 100644 index 0000000..ec20605 --- /dev/null +++ b/assets/showcase/showcase.js @@ -0,0 +1,2 @@ +/* Select-screen showcase fighters: art and avatar only, not playable yet. Generated by tools/showcase_assets.py. */ +window.SHOWCASE_FIGHTERS = {}; diff --git a/assets/showcase/solan/portrait.webp b/assets/showcase/solan/portrait.webp new file mode 100644 index 0000000..62d59ce Binary files /dev/null and b/assets/showcase/solan/portrait.webp differ diff --git a/assets/showcase/zanni/portrait.webp b/assets/showcase/zanni/portrait.webp new file mode 100644 index 0000000..6510392 Binary files /dev/null and b/assets/showcase/zanni/portrait.webp differ diff --git a/assets/solan/audio/select_solan.mp3 b/assets/solan/audio/select_solan.mp3 new file mode 100644 index 0000000..41ebcf0 Binary files /dev/null and b/assets/solan/audio/select_solan.mp3 differ diff --git a/assets/solan/audio/solan-ultimate.mp3 b/assets/solan/audio/solan-ultimate.mp3 new file mode 100644 index 0000000..8e2167f Binary files /dev/null and b/assets/solan/audio/solan-ultimate.mp3 differ diff --git a/assets/solan/audio/solan_wins-raw.mp3 b/assets/solan/audio/solan_wins-raw.mp3 new file mode 100644 index 0000000..6fde2d4 Binary files /dev/null and b/assets/solan/audio/solan_wins-raw.mp3 differ diff --git a/assets/solan/audio/solan_wins.mp3 b/assets/solan/audio/solan_wins.mp3 new file mode 100644 index 0000000..6b44d66 Binary files /dev/null and b/assets/solan/audio/solan_wins.mp3 differ diff --git a/assets/solan/manifest.js b/assets/solan/manifest.js new file mode 100644 index 0000000..69c1c16 --- /dev/null +++ b/assets/solan/manifest.js @@ -0,0 +1,2 @@ +window.SOLAN_MANIFEST = {"characterId": "solan", "engine": "component-row", "game_input": "sprite-sheet-alpha.png", "degraded_static_fallback": false, "curation_applied": true, "frame_variant": "pixel", "sprite_sheet_alpha": "sprite-sheet-alpha.png", "sprite_sheet_alpha_report": "sprite-sheet-alpha.report.json", "base_image": "base-source.png", "cell": {"shape": "rect", "width": 448, "height": 288, "safe_margin_x": 10, "safe_margin_y": 8}, "chroma_key": {"name": "green", "hex": "#00FF00", "rgb": [0, 255, 0], "selection": "manual"}, "animation": {"cellWidth": 448, "cellHeight": 288, "columns": 4, "rows": {"idle": {"row": 0, "frames": 4, "fps": 5, "durations_ms": [200, 200, 200, 200], "loop": true, "frame_variant": "pixel"}, "walk": {"row": 1, "frames": 4, "fps": 9, "durations_ms": [111, 111, 111, 111], "loop": true, "frame_variant": "pixel"}, "run": {"row": 2, "frames": 4, "fps": 11, "durations_ms": [91, 91, 91, 91], "loop": true, "frame_variant": "pixel"}, "jump": {"row": 3, "frames": 1, "fps": 10, "durations_ms": [100], "loop": false, "frame_variant": "pixel"}, "doublejump": {"row": 4, "frames": 1, "fps": 12, "durations_ms": [83], "loop": false, "frame_variant": "pixel"}, "crouch": {"row": 5, "frames": 4, "fps": 8, "durations_ms": [125, 125, 125, 125], "loop": false, "frame_variant": "pixel"}, "attack1": {"row": 6, "frames": 4, "fps": 11, "durations_ms": [91, 91, 91, 91], "loop": false, "frame_variant": "pixel"}, "attack2": {"row": 7, "frames": 4, "fps": 11, "durations_ms": [91, 91, 91, 91], "loop": false, "frame_variant": "pixel"}, "attack3": {"row": 8, "frames": 4, "fps": 9, "durations_ms": [111, 111, 111, 111], "loop": false, "frame_variant": "pixel"}, "skill1": {"row": 9, "frames": 4, "fps": 10, "durations_ms": [100, 100, 100, 100], "loop": false, "frame_variant": "pixel"}, "skill2": {"row": 10, "frames": 4, "fps": 10, "durations_ms": [100, 100, 100, 100], "loop": false, "frame_variant": "pixel"}, "ultimate": {"row": 11, "frames": 4, "fps": 8, "durations_ms": [125, 125, 125, 125], "loop": false, "frame_variant": "pixel"}, "hurt": {"row": 12, "frames": 4, "fps": 10, "durations_ms": [100, 100, 100, 100], "loop": false, "frame_variant": "pixel"}, "down": {"row": 13, "frames": 4, "fps": 8, "durations_ms": [125, 125, 125, 125], "loop": false, "frame_variant": "pixel"}, "recover": {"row": 14, "frames": 4, "fps": 10, "durations_ms": [100, 100, 100, 100], "loop": false, "frame_variant": "pixel"}}}, "frame_layout": {"sheetWidth": 1792, "sheetHeight": 4320, "cellWidth": 448, "cellHeight": 288, "rows": {"idle": [{"x": 0, "y": 0, "w": 448, "h": 288}, {"x": 448, "y": 0, "w": 448, "h": 288}, {"x": 896, "y": 0, "w": 448, "h": 288}, {"x": 1344, "y": 0, "w": 448, "h": 288}], "walk": [{"x": 0, "y": 288, "w": 448, "h": 288}, {"x": 448, "y": 288, "w": 448, "h": 288}, {"x": 896, "y": 288, "w": 448, "h": 288}, {"x": 1344, "y": 288, "w": 448, "h": 288}], "run": [{"x": 0, "y": 576, "w": 448, "h": 288}, {"x": 448, "y": 576, "w": 448, "h": 288}, {"x": 896, "y": 576, "w": 448, "h": 288}, {"x": 1344, "y": 576, "w": 448, "h": 288}], "jump": [{"x": 0, "y": 864, "w": 448, "h": 288}], "doublejump": [{"x": 0, "y": 1152, "w": 448, "h": 288}], "crouch": [{"x": 0, "y": 1440, "w": 448, "h": 288}, {"x": 448, "y": 1440, "w": 448, "h": 288}, {"x": 896, "y": 1440, "w": 448, "h": 288}, {"x": 1344, "y": 1440, "w": 448, "h": 288}], "attack1": [{"x": 0, "y": 1728, "w": 448, "h": 288}, {"x": 448, "y": 1728, "w": 448, "h": 288}, {"x": 896, "y": 1728, "w": 448, "h": 288}, {"x": 1344, "y": 1728, "w": 448, "h": 288}], "attack2": [{"x": 0, "y": 2016, "w": 448, "h": 288}, {"x": 448, "y": 2016, "w": 448, "h": 288}, {"x": 896, "y": 2016, "w": 448, "h": 288}, {"x": 1344, "y": 2016, "w": 448, "h": 288}], "attack3": [{"x": 0, "y": 2304, "w": 448, "h": 288}, {"x": 448, "y": 2304, "w": 448, "h": 288}, {"x": 896, "y": 2304, "w": 448, "h": 288}, {"x": 1344, "y": 2304, "w": 448, "h": 288}], "skill1": [{"x": 0, "y": 2592, "w": 448, "h": 288}, {"x": 448, "y": 2592, "w": 448, "h": 288}, {"x": 896, "y": 2592, "w": 448, "h": 288}, {"x": 1344, "y": 2592, "w": 448, "h": 288}], "skill2": [{"x": 0, "y": 2880, "w": 448, "h": 288}, {"x": 448, "y": 2880, "w": 448, "h": 288}, {"x": 896, "y": 2880, "w": 448, "h": 288}, {"x": 1344, "y": 2880, "w": 448, "h": 288}], "ultimate": [{"x": 0, "y": 3168, "w": 448, "h": 288}, {"x": 448, "y": 3168, "w": 448, "h": 288}, {"x": 896, "y": 3168, "w": 448, "h": 288}, {"x": 1344, "y": 3168, "w": 448, "h": 288}], "hurt": [{"x": 0, "y": 3456, "w": 448, "h": 288}, {"x": 448, "y": 3456, "w": 448, "h": 288}, {"x": 896, "y": 3456, "w": 448, "h": 288}, {"x": 1344, "y": 3456, "w": 448, "h": 288}], "down": [{"x": 0, "y": 3744, "w": 448, "h": 288}, {"x": 448, "y": 3744, "w": 448, "h": 288}, {"x": 896, "y": 3744, "w": 448, "h": 288}, {"x": 1344, "y": 3744, "w": 448, "h": 288}], "recover": [{"x": 0, "y": 4032, "w": 448, "h": 288}, {"x": 448, "y": 4032, "w": 448, "h": 288}, {"x": 896, "y": 4032, "w": 448, "h": 288}, {"x": 1344, "y": 4032, "w": 448, "h": 288}]}}}; +window.SOLAN_METRICS = {"anchor": {"x": 224, "y": 280}, "states": {"idle": {"frames": [{"frame": 0, "bounds": {"left": -85, "right": 69, "top": -175, "bottom": 0}, "height": 175}, {"frame": 1, "bounds": {"left": -83, "right": 70, "top": -176, "bottom": 0}, "height": 176}, {"frame": 2, "bounds": {"left": -85, "right": 68, "top": -175, "bottom": 0}, "height": 175}, {"frame": 3, "bounds": {"left": -86, "right": 69, "top": -176, "bottom": 0}, "height": 176}]}, "walk": {"frames": [{"frame": 0, "bounds": {"left": -75, "right": 71, "top": -171, "bottom": 0}, "height": 171}, {"frame": 1, "bounds": {"left": -71, "right": 71, "top": -172, "bottom": 0}, "height": 172}, {"frame": 2, "bounds": {"left": -74, "right": 70, "top": -172, "bottom": 0}, "height": 172}, {"frame": 3, "bounds": {"left": -72, "right": 70, "top": -172, "bottom": 0}, "height": 172}]}, "run": {"frames": [{"frame": 0, "bounds": {"left": -94, "right": 65, "top": -143, "bottom": 0}, "height": 143}, {"frame": 1, "bounds": {"left": -91, "right": 60, "top": -139, "bottom": 0}, "height": 139}, {"frame": 2, "bounds": {"left": -97, "right": 57, "top": -143, "bottom": 0}, "height": 143}, {"frame": 3, "bounds": {"left": -91, "right": 60, "top": -143, "bottom": 0}, "height": 143}]}, "jump": {"frames": [{"frame": 0, "bounds": {"left": -129, "right": 51, "top": -197, "bottom": 0}, "height": 197}]}, "doublejump": {"frames": [{"frame": 0, "bounds": {"left": -104, "right": 50, "top": -160, "bottom": 0}, "height": 160}]}, "crouch": {"frames": [{"frame": 0, "bounds": {"left": -87, "right": 73, "top": -155, "bottom": 0}, "height": 155}, {"frame": 1, "bounds": {"left": -85, "right": 63, "top": -145, "bottom": 0}, "height": 145}, {"frame": 2, "bounds": {"left": -85, "right": 62, "top": -145, "bottom": 0}, "height": 145}, {"frame": 3, "bounds": {"left": -84, "right": 55, "top": -155, "bottom": 0}, "height": 155}]}, "attack1": {"frames": [{"frame": 0, "bounds": {"left": -116, "right": 68, "top": -161, "bottom": 0}, "height": 161}, {"frame": 1, "bounds": {"left": -89, "right": 64, "top": -178, "bottom": 0}, "height": 178}, {"frame": 2, "bounds": {"left": -80, "right": 149, "top": -158, "bottom": 0}, "height": 158}, {"frame": 3, "bounds": {"left": -83, "right": 68, "top": -167, "bottom": 0}, "height": 167}]}, "attack2": {"frames": [{"frame": 0, "bounds": {"left": -93, "right": 81, "top": -151, "bottom": 0}, "height": 151}, {"frame": 1, "bounds": {"left": -85, "right": 82, "top": -181, "bottom": 0}, "height": 181}, {"frame": 2, "bounds": {"left": -87, "right": 95, "top": -206, "bottom": 0}, "height": 206}, {"frame": 3, "bounds": {"left": -85, "right": 68, "top": -167, "bottom": 0}, "height": 167}]}, "attack3": {"frames": [{"frame": 0, "bounds": {"left": -90, "right": 70, "top": -214, "bottom": 0}, "height": 214}, {"frame": 1, "bounds": {"left": -107, "right": 87, "top": -169, "bottom": 0}, "height": 169}, {"frame": 2, "bounds": {"left": -109, "right": 74, "top": -154, "bottom": 0}, "height": 154}, {"frame": 3, "bounds": {"left": -85, "right": 68, "top": -220, "bottom": 0}, "height": 220}]}, "skill1": {"frames": [{"frame": 0, "bounds": {"left": -118, "right": 79, "top": -165, "bottom": 0}, "height": 165}, {"frame": 1, "bounds": {"left": -109, "right": 69, "top": -185, "bottom": 0}, "height": 185}, {"frame": 2, "bounds": {"left": -88, "right": 156, "top": -171, "bottom": 0}, "height": 171}, {"frame": 3, "bounds": {"left": -85, "right": 69, "top": -179, "bottom": 0}, "height": 179}]}, "skill2": {"frames": [{"frame": 0, "bounds": {"left": -77, "right": 74, "top": -182, "bottom": 0}, "height": 182}, {"frame": 1, "bounds": {"left": -89, "right": 68, "top": -179, "bottom": 0}, "height": 179}, {"frame": 2, "bounds": {"left": -93, "right": 60, "top": -142, "bottom": 0}, "height": 142}, {"frame": 3, "bounds": {"left": -82, "right": 71, "top": -182, "bottom": 0}, "height": 182}]}, "ultimate": {"frames": [{"frame": 0, "bounds": {"left": -81, "right": 57, "top": -173, "bottom": 0}, "height": 173}, {"frame": 1, "bounds": {"left": -90, "right": 60, "top": -173, "bottom": 0}, "height": 173}, {"frame": 2, "bounds": {"left": -82, "right": 66, "top": -177, "bottom": 0}, "height": 177}, {"frame": 3, "bounds": {"left": -86, "right": 65, "top": -174, "bottom": 0}, "height": 174}]}, "hurt": {"frames": [{"frame": 0, "bounds": {"left": -98, "right": 72, "top": -173, "bottom": 0}, "height": 173}, {"frame": 1, "bounds": {"left": -73, "right": 76, "top": -165, "bottom": 0}, "height": 165}, {"frame": 2, "bounds": {"left": -110, "right": 69, "top": -181, "bottom": 0}, "height": 181}, {"frame": 3, "bounds": {"left": -80, "right": 62, "top": -157, "bottom": 0}, "height": 157}]}, "down": {"frames": [{"frame": 0, "bounds": {"left": -63, "right": 84, "top": -152, "bottom": 0}, "height": 152}, {"frame": 1, "bounds": {"left": -67, "right": 92, "top": -143, "bottom": 0}, "height": 143}, {"frame": 2, "bounds": {"left": -76, "right": 79, "top": -90, "bottom": 0}, "height": 90}, {"frame": 3, "bounds": {"left": -74, "right": 81, "top": -112, "bottom": 0}, "height": 112}]}, "recover": {"frames": [{"frame": 0, "bounds": {"left": -97, "right": 92, "top": -117, "bottom": 0}, "height": 117}, {"frame": 1, "bounds": {"left": -90, "right": 67, "top": -131, "bottom": 0}, "height": 131}, {"frame": 2, "bounds": {"left": -82, "right": 66, "top": -154, "bottom": 0}, "height": 154}, {"frame": 3, "bounds": {"left": -89, "right": 71, "top": -171, "bottom": 0}, "height": 171}]}}, "playback": {"jump": {"airFrame": 0, "torsoPivot": {"x": -39, "y": -98}}, "doublejump": {"frame": 0, "duration": 0.28, "pivot": {"x": -27, "y": -80}}, "measurement": "Centers measured from final curated atlas, including integer air-pose alignment; no runtime resizing."}, "emitters": {"attack1": {"x": 149, "y": -85, "frame": 2, "method": "Blade tip on impact frame (farthest-right six-pixel tip; top-right extreme for the rising slash), facing right"}, "attack2": {"x": 95, "y": -206, "frame": 2, "method": "Blade tip on impact frame (farthest-right six-pixel tip; top-right extreme for the rising slash), facing right"}, "attack3": {"x": 74, "y": -4, "frame": 2, "method": "Blade tip on impact frame (farthest-right six-pixel tip; top-right extreme for the rising slash), facing right"}, "skill1": {"x": 156, "y": -93, "frame": 2, "method": "Blade tip on impact frame (farthest-right six-pixel tip; top-right extreme for the rising slash), facing right"}, "skill2": {"x": 60, "y": -48, "frame": 2, "method": "Blade tip on impact frame (farthest-right six-pixel tip; top-right extreme for the rising slash), facing right"}, "ultimate": {"x": 66, "y": -6, "frame": 2, "method": "Blade tip on impact frame (farthest-right six-pixel tip; top-right extreme for the rising slash), facing right"}}}; diff --git a/assets/solan/run/sprite-sheet-alpha.webp b/assets/solan/run/sprite-sheet-alpha.webp new file mode 100644 index 0000000..502fdd2 Binary files /dev/null and b/assets/solan/run/sprite-sheet-alpha.webp differ diff --git a/assets/solan/ui/cutin.webp b/assets/solan/ui/cutin.webp new file mode 100644 index 0000000..2c55b2c Binary files /dev/null and b/assets/solan/ui/cutin.webp differ diff --git a/assets/solan/ui/fx-crescent.webp b/assets/solan/ui/fx-crescent.webp new file mode 100644 index 0000000..745d756 Binary files /dev/null and b/assets/solan/ui/fx-crescent.webp differ diff --git a/assets/solan/ui/fx-impact.webp b/assets/solan/ui/fx-impact.webp new file mode 100644 index 0000000..c400297 Binary files /dev/null and b/assets/solan/ui/fx-impact.webp differ diff --git a/assets/solan/ui/fx-roar.webp b/assets/solan/ui/fx-roar.webp new file mode 100644 index 0000000..b8ac783 Binary files /dev/null and b/assets/solan/ui/fx-roar.webp differ diff --git a/assets/solan/ui/fx-sunburst.webp b/assets/solan/ui/fx-sunburst.webp new file mode 100644 index 0000000..e419218 Binary files /dev/null and b/assets/solan/ui/fx-sunburst.webp differ diff --git a/assets/solan/ui/icon-basic.webp b/assets/solan/ui/icon-basic.webp new file mode 100644 index 0000000..c158ac7 Binary files /dev/null and b/assets/solan/ui/icon-basic.webp differ diff --git a/assets/solan/ui/icon-skill1.webp b/assets/solan/ui/icon-skill1.webp new file mode 100644 index 0000000..fae66da Binary files /dev/null and b/assets/solan/ui/icon-skill1.webp differ diff --git a/assets/solan/ui/icon-skill2.webp b/assets/solan/ui/icon-skill2.webp new file mode 100644 index 0000000..7485cce Binary files /dev/null and b/assets/solan/ui/icon-skill2.webp differ diff --git a/assets/solan/ui/icon-ultimate.webp b/assets/solan/ui/icon-ultimate.webp new file mode 100644 index 0000000..81c957d Binary files /dev/null and b/assets/solan/ui/icon-ultimate.webp differ diff --git a/assets/solan/ui/portrait.webp b/assets/solan/ui/portrait.webp new file mode 100644 index 0000000..62d59ce Binary files /dev/null and b/assets/solan/ui/portrait.webp differ diff --git a/assets/stage-config.js b/assets/stage-config.js new file mode 100644 index 0000000..ae928a0 --- /dev/null +++ b/assets/stage-config.js @@ -0,0 +1 @@ +window.STAGE_CONFIG = {"width": 1280, "height": 720, "groundY": 599, "floorBand": [528, 719], "name": "Bellora Courtyard", "source": "Seedream 5.0 Pro", "jobId": "d7eefba6-0006-4658-ab30-43e269426dc9", "measurement": "continuous lower limestone band with >=63% bright warm paving; ground 37% into band; visual review required"}; diff --git a/assets/stage.webp b/assets/stage.webp new file mode 100644 index 0000000..d9eed85 Binary files /dev/null and b/assets/stage.webp differ diff --git a/assets/ui/arco-avatar.webp b/assets/ui/arco-avatar.webp new file mode 100644 index 0000000..7400f5f Binary files /dev/null and b/assets/ui/arco-avatar.webp differ diff --git a/assets/ui/attack.webp b/assets/ui/attack.webp new file mode 100644 index 0000000..068037c Binary files /dev/null and b/assets/ui/attack.webp differ diff --git a/assets/ui/drone-config.js b/assets/ui/drone-config.js new file mode 100644 index 0000000..ab786df --- /dev/null +++ b/assets/ui/drone-config.js @@ -0,0 +1 @@ +window.DRONE_ASSET = {"width": 112, "height": 95, "muzzle": {"x": 50.12, "y": 5.62}, "source": "Seedream 5.0 Pro", "measurement": "Farthest-right cyan muzzle region on final transparent asset"}; diff --git a/assets/ui/drone.png b/assets/ui/drone.png new file mode 100644 index 0000000..5ea23cd Binary files /dev/null and b/assets/ui/drone.png differ diff --git a/assets/ui/dummy-avatar.webp b/assets/ui/dummy-avatar.webp new file mode 100644 index 0000000..b6aa72b Binary files /dev/null and b/assets/ui/dummy-avatar.webp differ diff --git a/assets/ui/icons-check.webp b/assets/ui/icons-check.webp new file mode 100644 index 0000000..1cd37ab Binary files /dev/null and b/assets/ui/icons-check.webp differ diff --git a/assets/ui/skill1.webp b/assets/ui/skill1.webp new file mode 100644 index 0000000..7b4bec2 Binary files /dev/null and b/assets/ui/skill1.webp differ diff --git a/assets/ui/skill2.webp b/assets/ui/skill2.webp new file mode 100644 index 0000000..8063bba Binary files /dev/null and b/assets/ui/skill2.webp differ diff --git a/assets/ui/squadron-icon.webp b/assets/ui/squadron-icon.webp new file mode 100644 index 0000000..e722009 Binary files /dev/null and b/assets/ui/squadron-icon.webp differ diff --git a/assets/ui/ultimate-cutin.webp b/assets/ui/ultimate-cutin.webp new file mode 100644 index 0000000..634b8bc Binary files /dev/null and b/assets/ui/ultimate-cutin.webp differ diff --git a/assets/ui/ultimate.webp b/assets/ui/ultimate.webp new file mode 100644 index 0000000..3087cbf Binary files /dev/null and b/assets/ui/ultimate.webp differ diff --git a/assets/zanni/audio/select_zanni.mp3 b/assets/zanni/audio/select_zanni.mp3 new file mode 100644 index 0000000..5e14d00 Binary files /dev/null and b/assets/zanni/audio/select_zanni.mp3 differ diff --git a/assets/zanni/audio/zanni-ultimate.mp3 b/assets/zanni/audio/zanni-ultimate.mp3 new file mode 100644 index 0000000..640fa9a Binary files /dev/null and b/assets/zanni/audio/zanni-ultimate.mp3 differ diff --git a/assets/zanni/audio/zanni_wins-raw.mp3 b/assets/zanni/audio/zanni_wins-raw.mp3 new file mode 100644 index 0000000..dbf21da Binary files /dev/null and b/assets/zanni/audio/zanni_wins-raw.mp3 differ diff --git a/assets/zanni/audio/zanni_wins.mp3 b/assets/zanni/audio/zanni_wins.mp3 new file mode 100644 index 0000000..29ff4aa Binary files /dev/null and b/assets/zanni/audio/zanni_wins.mp3 differ diff --git a/assets/zanni/manifest.js b/assets/zanni/manifest.js new file mode 100644 index 0000000..361945e --- /dev/null +++ b/assets/zanni/manifest.js @@ -0,0 +1,2 @@ +window.ZANNI_MANIFEST = {"characterId": "zanni", "engine": "component-row", "game_input": "sprite-sheet-alpha.png", "degraded_static_fallback": false, "curation_applied": true, "frame_variant": "pixel", "sprite_sheet_alpha": "sprite-sheet-alpha.png", "sprite_sheet_alpha_report": "sprite-sheet-alpha.report.json", "base_image": "base-source.png", "cell": {"shape": "rect", "width": 448, "height": 288, "safe_margin_x": 10, "safe_margin_y": 8}, "chroma_key": {"name": "magenta", "hex": "#FF00FF", "rgb": [255, 0, 255], "selection": "manual"}, "animation": {"cellWidth": 448, "cellHeight": 288, "columns": 4, "rows": {"idle": {"row": 0, "frames": 4, "fps": 5, "durations_ms": [200, 200, 200, 200], "loop": true, "frame_variant": "pixel"}, "walk": {"row": 1, "frames": 4, "fps": 9, "durations_ms": [111, 111, 111, 111], "loop": true, "frame_variant": "pixel"}, "run": {"row": 2, "frames": 4, "fps": 11, "durations_ms": [91, 91, 91, 91], "loop": true, "frame_variant": "pixel"}, "jump": {"row": 3, "frames": 1, "fps": 10, "durations_ms": [100], "loop": false, "frame_variant": "pixel"}, "doublejump": {"row": 4, "frames": 1, "fps": 12, "durations_ms": [83], "loop": false, "frame_variant": "pixel"}, "crouch": {"row": 5, "frames": 4, "fps": 8, "durations_ms": [125, 125, 125, 125], "loop": false, "frame_variant": "pixel"}, "attack1": {"row": 6, "frames": 4, "fps": 12, "durations_ms": [83, 83, 83, 83], "loop": false, "frame_variant": "pixel"}, "attack2": {"row": 7, "frames": 4, "fps": 12, "durations_ms": [83, 83, 83, 83], "loop": false, "frame_variant": "pixel"}, "attack3": {"row": 8, "frames": 4, "fps": 10, "durations_ms": [100, 100, 100, 100], "loop": false, "frame_variant": "pixel"}, "skill1": {"row": 9, "frames": 4, "fps": 10, "durations_ms": [100, 100, 100, 100], "loop": false, "frame_variant": "pixel"}, "skill2": {"row": 10, "frames": 4, "fps": 10, "durations_ms": [100, 100, 100, 100], "loop": false, "frame_variant": "pixel"}, "ultimate": {"row": 11, "frames": 4, "fps": 8, "durations_ms": [125, 125, 125, 125], "loop": false, "frame_variant": "pixel"}, "hurt": {"row": 12, "frames": 4, "fps": 10, "durations_ms": [100, 100, 100, 100], "loop": false, "frame_variant": "pixel"}, "down": {"row": 13, "frames": 4, "fps": 8, "durations_ms": [125, 125, 125, 125], "loop": false, "frame_variant": "pixel"}, "recover": {"row": 14, "frames": 4, "fps": 10, "durations_ms": [100, 100, 100, 100], "loop": false, "frame_variant": "pixel"}}}, "frame_layout": {"sheetWidth": 1792, "sheetHeight": 4320, "cellWidth": 448, "cellHeight": 288, "rows": {"idle": [{"x": 0, "y": 0, "w": 448, "h": 288}, {"x": 448, "y": 0, "w": 448, "h": 288}, {"x": 896, "y": 0, "w": 448, "h": 288}, {"x": 1344, "y": 0, "w": 448, "h": 288}], "walk": [{"x": 0, "y": 288, "w": 448, "h": 288}, {"x": 448, "y": 288, "w": 448, "h": 288}, {"x": 896, "y": 288, "w": 448, "h": 288}, {"x": 1344, "y": 288, "w": 448, "h": 288}], "run": [{"x": 0, "y": 576, "w": 448, "h": 288}, {"x": 448, "y": 576, "w": 448, "h": 288}, {"x": 896, "y": 576, "w": 448, "h": 288}, {"x": 1344, "y": 576, "w": 448, "h": 288}], "jump": [{"x": 0, "y": 864, "w": 448, "h": 288}], "doublejump": [{"x": 0, "y": 1152, "w": 448, "h": 288}], "crouch": [{"x": 0, "y": 1440, "w": 448, "h": 288}, {"x": 448, "y": 1440, "w": 448, "h": 288}, {"x": 896, "y": 1440, "w": 448, "h": 288}, {"x": 1344, "y": 1440, "w": 448, "h": 288}], "attack1": [{"x": 0, "y": 1728, "w": 448, "h": 288}, {"x": 448, "y": 1728, "w": 448, "h": 288}, {"x": 896, "y": 1728, "w": 448, "h": 288}, {"x": 1344, "y": 1728, "w": 448, "h": 288}], "attack2": [{"x": 0, "y": 2016, "w": 448, "h": 288}, {"x": 448, "y": 2016, "w": 448, "h": 288}, {"x": 896, "y": 2016, "w": 448, "h": 288}, {"x": 1344, "y": 2016, "w": 448, "h": 288}], "attack3": [{"x": 0, "y": 2304, "w": 448, "h": 288}, {"x": 448, "y": 2304, "w": 448, "h": 288}, {"x": 896, "y": 2304, "w": 448, "h": 288}, {"x": 1344, "y": 2304, "w": 448, "h": 288}], "skill1": [{"x": 0, "y": 2592, "w": 448, "h": 288}, {"x": 448, "y": 2592, "w": 448, "h": 288}, {"x": 896, "y": 2592, "w": 448, "h": 288}, {"x": 1344, "y": 2592, "w": 448, "h": 288}], "skill2": [{"x": 0, "y": 2880, "w": 448, "h": 288}, {"x": 448, "y": 2880, "w": 448, "h": 288}, {"x": 896, "y": 2880, "w": 448, "h": 288}, {"x": 1344, "y": 2880, "w": 448, "h": 288}], "ultimate": [{"x": 0, "y": 3168, "w": 448, "h": 288}, {"x": 448, "y": 3168, "w": 448, "h": 288}, {"x": 896, "y": 3168, "w": 448, "h": 288}, {"x": 1344, "y": 3168, "w": 448, "h": 288}], "hurt": [{"x": 0, "y": 3456, "w": 448, "h": 288}, {"x": 448, "y": 3456, "w": 448, "h": 288}, {"x": 896, "y": 3456, "w": 448, "h": 288}, {"x": 1344, "y": 3456, "w": 448, "h": 288}], "down": [{"x": 0, "y": 3744, "w": 448, "h": 288}, {"x": 448, "y": 3744, "w": 448, "h": 288}, {"x": 896, "y": 3744, "w": 448, "h": 288}, {"x": 1344, "y": 3744, "w": 448, "h": 288}], "recover": [{"x": 0, "y": 4032, "w": 448, "h": 288}, {"x": 448, "y": 4032, "w": 448, "h": 288}, {"x": 896, "y": 4032, "w": 448, "h": 288}, {"x": 1344, "y": 4032, "w": 448, "h": 288}]}}}; +window.ZANNI_METRICS = {"anchor": {"x": 224, "y": 280}, "states": {"idle": {"frames": [{"frame": 0, "bounds": {"left": -72, "right": 71, "top": -176, "bottom": 0}, "height": 176}, {"frame": 1, "bounds": {"left": -69, "right": 65, "top": -186, "bottom": 0}, "height": 186}, {"frame": 2, "bounds": {"left": -67, "right": 71, "top": -168, "bottom": 0}, "height": 168}, {"frame": 3, "bounds": {"left": -72, "right": 71, "top": -176, "bottom": 0}, "height": 176}]}, "walk": {"frames": [{"frame": 0, "bounds": {"left": -54, "right": 69, "top": -181, "bottom": 0}, "height": 181}, {"frame": 1, "bounds": {"left": -57, "right": 67, "top": -183, "bottom": 0}, "height": 183}, {"frame": 2, "bounds": {"left": -56, "right": 69, "top": -181, "bottom": 0}, "height": 181}, {"frame": 3, "bounds": {"left": -57, "right": 67, "top": -183, "bottom": 0}, "height": 183}]}, "run": {"frames": [{"frame": 0, "bounds": {"left": -66, "right": 82, "top": -167, "bottom": 0}, "height": 167}, {"frame": 1, "bounds": {"left": -69, "right": 79, "top": -164, "bottom": 0}, "height": 164}, {"frame": 2, "bounds": {"left": -67, "right": 81, "top": -167, "bottom": 0}, "height": 167}, {"frame": 3, "bounds": {"left": -70, "right": 76, "top": -160, "bottom": 0}, "height": 160}]}, "jump": {"frames": [{"frame": 0, "bounds": {"left": -72, "right": 83, "top": -177, "bottom": 0}, "height": 177}]}, "doublejump": {"frames": [{"frame": 0, "bounds": {"left": -46, "right": 71, "top": -150, "bottom": 0}, "height": 150}]}, "crouch": {"frames": [{"frame": 0, "bounds": {"left": -56, "right": 77, "top": -154, "bottom": 0}, "height": 154}, {"frame": 1, "bounds": {"left": -50, "right": 65, "top": -141, "bottom": 0}, "height": 141}, {"frame": 2, "bounds": {"left": -50, "right": 66, "top": -142, "bottom": 0}, "height": 142}, {"frame": 3, "bounds": {"left": -59, "right": 74, "top": -152, "bottom": 0}, "height": 152}]}, "attack1": {"frames": [{"frame": 0, "bounds": {"left": -58, "right": 74, "top": -181, "bottom": 0}, "height": 181}, {"frame": 1, "bounds": {"left": -58, "right": 91, "top": -181, "bottom": 0}, "height": 181}, {"frame": 2, "bounds": {"left": -60, "right": 144, "top": -181, "bottom": 0}, "height": 181}, {"frame": 3, "bounds": {"left": -58, "right": 73, "top": -182, "bottom": 0}, "height": 182}]}, "attack2": {"frames": [{"frame": 0, "bounds": {"left": -59, "right": 76, "top": -177, "bottom": 0}, "height": 177}, {"frame": 1, "bounds": {"left": -69, "right": 77, "top": -177, "bottom": 0}, "height": 177}, {"frame": 2, "bounds": {"left": -63, "right": 110, "top": -197, "bottom": 0}, "height": 197}, {"frame": 3, "bounds": {"left": -57, "right": 77, "top": -177, "bottom": 0}, "height": 177}]}, "attack3": {"frames": [{"frame": 0, "bounds": {"left": -62, "right": 77, "top": -167, "bottom": 0}, "height": 167}, {"frame": 1, "bounds": {"left": -66, "right": 103, "top": -167, "bottom": 0}, "height": 167}, {"frame": 2, "bounds": {"left": -67, "right": 161, "top": -163, "bottom": 0}, "height": 163}, {"frame": 3, "bounds": {"left": -57, "right": 78, "top": -167, "bottom": 0}, "height": 167}]}, "skill1": {"frames": [{"frame": 0, "bounds": {"left": -67, "right": 82, "top": -174, "bottom": 0}, "height": 174}, {"frame": 1, "bounds": {"left": -66, "right": 86, "top": -175, "bottom": 0}, "height": 175}, {"frame": 2, "bounds": {"left": -63, "right": 95, "top": -175, "bottom": 0}, "height": 175}, {"frame": 3, "bounds": {"left": -70, "right": 79, "top": -174, "bottom": 0}, "height": 174}]}, "skill2": {"frames": [{"frame": 0, "bounds": {"left": -47, "right": 91, "top": -157, "bottom": 0}, "height": 157}, {"frame": 1, "bounds": {"left": -58, "right": 115, "top": -157, "bottom": 0}, "height": 157}, {"frame": 2, "bounds": {"left": -62, "right": 207, "top": -157, "bottom": 0}, "height": 157}, {"frame": 3, "bounds": {"left": -89, "right": 50, "top": -158, "bottom": 0}, "height": 158}]}, "ultimate": {"frames": [{"frame": 0, "bounds": {"left": -78, "right": 89, "top": -175, "bottom": 0}, "height": 175}, {"frame": 1, "bounds": {"left": -51, "right": 83, "top": -170, "bottom": 0}, "height": 170}, {"frame": 2, "bounds": {"left": -57, "right": 97, "top": -174, "bottom": 0}, "height": 174}, {"frame": 3, "bounds": {"left": -78, "right": 68, "top": -167, "bottom": 0}, "height": 167}]}, "hurt": {"frames": [{"frame": 0, "bounds": {"left": -130, "right": 36, "top": -176, "bottom": 0}, "height": 176}, {"frame": 1, "bounds": {"left": -53, "right": 62, "top": -176, "bottom": 0}, "height": 176}, {"frame": 2, "bounds": {"left": -117, "right": 51, "top": -174, "bottom": 0}, "height": 174}, {"frame": 3, "bounds": {"left": -51, "right": 68, "top": -174, "bottom": 0}, "height": 174}]}, "down": {"frames": [{"frame": 0, "bounds": {"left": -104, "right": 42, "top": -131, "bottom": 0}, "height": 131}, {"frame": 1, "bounds": {"left": -87, "right": 60, "top": -104, "bottom": 0}, "height": 104}, {"frame": 2, "bounds": {"left": -76, "right": 74, "top": -82, "bottom": 0}, "height": 82}, {"frame": 3, "bounds": {"left": -76, "right": 75, "top": -81, "bottom": 0}, "height": 81}]}, "recover": {"frames": [{"frame": 0, "bounds": {"left": -98, "right": 80, "top": -115, "bottom": 0}, "height": 115}, {"frame": 1, "bounds": {"left": -70, "right": 56, "top": -138, "bottom": 0}, "height": 138}, {"frame": 2, "bounds": {"left": -52, "right": 84, "top": -161, "bottom": 0}, "height": 161}, {"frame": 3, "bounds": {"left": -65, "right": 75, "top": -177, "bottom": 0}, "height": 177}]}}, "playback": {"jump": {"airFrame": 0, "torsoPivot": {"x": 6, "y": -88}}, "doublejump": {"frame": 0, "duration": 0.28, "pivot": {"x": 12, "y": -75}}, "measurement": "Centers measured from final curated atlas, including integer air-pose alignment; no runtime resizing."}, "emitters": {"attack1": {"x": 144, "y": -108, "frame": 2, "method": "Farthest-right opaque endpoint on impact frame; six-pixel tip region, facing right"}, "attack2": {"x": 110, "y": -176, "frame": 2, "method": "Farthest-right opaque endpoint on impact frame; six-pixel tip region, facing right"}, "attack3": {"x": 161, "y": -78, "frame": 2, "method": "Farthest-right opaque endpoint on impact frame; six-pixel tip region, facing right"}, "skill1": {"x": 95, "y": -95, "frame": 2, "method": "Farthest-right opaque endpoint on impact frame; six-pixel tip region, facing right"}, "skill2": {"x": 207, "y": -88, "frame": 2, "method": "Farthest-right opaque endpoint on impact frame; six-pixel tip region, facing right"}, "ultimate": {"x": 97, "y": -97, "frame": 2, "method": "Farthest-right opaque endpoint on impact frame; six-pixel tip region, facing right"}}}; diff --git a/assets/zanni/run/sprite-sheet-alpha.webp b/assets/zanni/run/sprite-sheet-alpha.webp new file mode 100644 index 0000000..21296cf Binary files /dev/null and b/assets/zanni/run/sprite-sheet-alpha.webp differ diff --git a/assets/zanni/ui/cutin.webp b/assets/zanni/ui/cutin.webp new file mode 100644 index 0000000..892ac63 Binary files /dev/null and b/assets/zanni/ui/cutin.webp differ diff --git a/assets/zanni/ui/fx-bigring.webp b/assets/zanni/ui/fx-bigring.webp new file mode 100644 index 0000000..5cad4df Binary files /dev/null and b/assets/zanni/ui/fx-bigring.webp differ diff --git a/assets/zanni/ui/fx-confetti.webp b/assets/zanni/ui/fx-confetti.webp new file mode 100644 index 0000000..2fc058b Binary files /dev/null and b/assets/zanni/ui/fx-confetti.webp differ diff --git a/assets/zanni/ui/fx-ring.webp b/assets/zanni/ui/fx-ring.webp new file mode 100644 index 0000000..5307004 Binary files /dev/null and b/assets/zanni/ui/fx-ring.webp differ diff --git a/assets/zanni/ui/fx-snatch.webp b/assets/zanni/ui/fx-snatch.webp new file mode 100644 index 0000000..49c437c Binary files /dev/null and b/assets/zanni/ui/fx-snatch.webp differ diff --git a/assets/zanni/ui/icon-basic.webp b/assets/zanni/ui/icon-basic.webp new file mode 100644 index 0000000..b71ea96 Binary files /dev/null and b/assets/zanni/ui/icon-basic.webp differ diff --git a/assets/zanni/ui/icon-skill1.webp b/assets/zanni/ui/icon-skill1.webp new file mode 100644 index 0000000..239e417 Binary files /dev/null and b/assets/zanni/ui/icon-skill1.webp differ diff --git a/assets/zanni/ui/icon-skill2.webp b/assets/zanni/ui/icon-skill2.webp new file mode 100644 index 0000000..23aa57a Binary files /dev/null and b/assets/zanni/ui/icon-skill2.webp differ diff --git a/assets/zanni/ui/icon-ultimate.webp b/assets/zanni/ui/icon-ultimate.webp new file mode 100644 index 0000000..e8ff136 Binary files /dev/null and b/assets/zanni/ui/icon-ultimate.webp differ diff --git a/assets/zanni/ui/portrait.webp b/assets/zanni/ui/portrait.webp new file mode 100644 index 0000000..6510392 Binary files /dev/null and b/assets/zanni/ui/portrait.webp differ diff --git a/cora.js b/cora.js new file mode 100644 index 0000000..62883df --- /dev/null +++ b/cora.js @@ -0,0 +1,31 @@ +/* Shared CORA moves (raven demi-human sky dancer): player and AI use the same rules. */ +(() => { + 'use strict'; + const balance = { cooldowns:{skill1:3,skill2:6,ultimate:18}, castTime:.5, passDamage:16, basicRefund:.05 }; + const names = ['FEATHER WALTZ','QUILL VOLLEY','WING GUST','NIGHT MURMURATION']; + // Reach follows the measured blade emitters of the 184 px atlas (assets/cora/emitters.json): slash 87, rising 68, cross 115 px. + const combo = [ + {damage:6,knockback:75,reach:90,duration:.34,hitAt:.5}, + {damage:8,knockback:100,reach:78,duration:.40,hitAt:.5}, + {damage:12,knockback:180,reach:115,duration:.50,hitAt:.55} + ]; + // Night Murmuration: three raven passes sweep across the arena in the cast direction at three heights, + // 16 damage each (48 total). Passes 0.3 s apart stay inside one hurt window; a jump can clear the lowest pass. + const murmuration = { cutinDuration:.78, passStart:[.55,.85,1.15], passHeights:[-70,-112,-150], speed:1500, ravens:10, spacing:40, duration:2.4 }; + function move(name,index=1) { + if(name==='attack')return {name:`attack${index}`,type:'attack',index,...combo[index-1]}; + // Quill Volley: three feathers in a narrow fan, 5+5+6 = 16 like other I skills. + if(name==='skill1')return {name,type:name,damage:16,duration:.46,knockback:60,projectile:true,speed:840,feathers:[5,5,6],spread:.1,hitAt:.5}; + // Wing Gust: forward area out to the far edge of the gust sprite (see coraStrike), no dash. + if(name==='skill2')return {name,type:name,damage:24,duration:.62,knockback:300,reach:190,area:true,hitAt:.55}; + return {name:'ultimate',type:'ultimate',duration:balance.castTime,damage:0,hitAt:1}; + } + function start(a,name,index=1) { + if(a.action||a.state==='hurt'||a.state==='down'||a.state==='recover')return false; + if(name!=='attack'&&a.cooldowns[name]>0)return false; + if(name!=='attack')a.cooldowns[name]=balance.cooldowns[name]; + a.action={...move(name,index),t:0,fired:false,queued:0};a.state=a.action.name;a.stateTime=0;return true; + } + function refund(a) {for(const name of Object.keys(balance.cooldowns))a.cooldowns[name]=Math.max(0,a.cooldowns[name]-balance.cooldowns[name]*balance.basicRefund);} + window.Cora={balance,names,combo,murmuration,move,start,refund}; +})(); diff --git a/docs/screenshots/fight.jpg b/docs/screenshots/fight.jpg new file mode 100644 index 0000000..f50d6c6 Binary files /dev/null and b/docs/screenshots/fight.jpg differ diff --git a/docs/screenshots/menu.jpg b/docs/screenshots/menu.jpg new file mode 100644 index 0000000..fd69c17 Binary files /dev/null and b/docs/screenshots/menu.jpg differ diff --git a/docs/screenshots/mobile.jpg b/docs/screenshots/mobile.jpg new file mode 100644 index 0000000..4bd2cae Binary files /dev/null and b/docs/screenshots/mobile.jpg differ diff --git a/docs/screenshots/roster.jpg b/docs/screenshots/roster.jpg new file mode 100644 index 0000000..0a906d1 Binary files /dev/null and b/docs/screenshots/roster.jpg differ diff --git a/docs/screenshots/ultimate-cutin.jpg b/docs/screenshots/ultimate-cutin.jpg new file mode 100644 index 0000000..6bb3f23 Binary files /dev/null and b/docs/screenshots/ultimate-cutin.jpg differ diff --git a/docs/screenshots/ultimate.jpg b/docs/screenshots/ultimate.jpg new file mode 100644 index 0000000..e858a48 Binary files /dev/null and b/docs/screenshots/ultimate.jpg differ diff --git a/docs/youtube-tutorial.jpg b/docs/youtube-tutorial.jpg new file mode 100644 index 0000000..1422c8a Binary files /dev/null and b/docs/youtube-tutorial.jpg differ diff --git a/edda.js b/edda.js new file mode 100644 index 0000000..14413fd --- /dev/null +++ b/edda.js @@ -0,0 +1,36 @@ +/* Shared EDDA moves (tortoise demi-human sage, staff and shell guard): player and AI use the same rules. */ +(() => { + 'use strict'; + const balance = { cooldowns:{skill1:3,skill2:6,ultimate:18}, castTime:.5, stompDamage:16, basicRefund:.05 }; + const names = ['STAFF FORMS','STONE SKIP','SHELL COUNTER','ELDER TORTOISE']; + // A steady staff chain (reach inside the measured staff tip 126/111/82 px); the old sage is not in a hurry. + const combo = [ + {damage:6,knockback:80,reach:110,duration:.4,hitAt:.5}, + {damage:8,knockback:105,reach:95,duration:.46,hitAt:.5}, + {damage:12,knockback:210,reach:80,duration:.58,hitAt:.58} + ]; + // Stone Skip: a flat jade stone flicked low (60 px up) and skipped along the floor. It hops `bounces` more times (each hop `hop` px/s up) before + // it sinks, so it stays low: a jump clears it. + const stone = { speed:520, rise:120, gravity:1600, bounces:2, hop:300 }; + // Shell Counter: EDDA ducks into her shell for `window` s. The first hit that reaches her in that window is ignored and + // answered with a staff counter on the attacker if they are within `reach` px (projectiles are simply blocked). + const counter = { window:.55, reach:170, damage:24, knockback:260 }; + // Elder Tortoise: a giant jade tortoise spirit rises `ahead` px in front of EDDA and lumbers forward (`speed` px/s), + // stomping three times 0.38 s apart (inside the 0.42 s hurt stun). Each stomp hits a rival on the floor within + // `half` px of the spirit (16 each, 48). A jump at the stomp avoids it; so does keeping away. + const tortoise = { cutinDuration:.78, stomps:[.8,1.18,1.56], appear:.6, ahead:140, speed:120, half:150, size:300, duration:2.3 }; + function move(name,index=1) { + if(name==='attack')return {name:`attack${index}`,type:'attack',index,...combo[index-1]}; + if(name==='skill1')return {name,type:name,damage:16,duration:.5,knockback:110,projectile:true,speed:stone.speed,range:600,hitAt:.55}; + if(name==='skill2')return {name,type:name,damage:0,duration:.7,knockback:0,reach:counter.reach,guard:true,guardEnd:counter.window,hitAt:.99}; + return {name:'ultimate',type:'ultimate',duration:balance.castTime,damage:0,hitAt:1}; + } + function start(a,name,index=1) { + if(a.action||a.state==='hurt'||a.state==='down'||a.state==='recover')return false; + if(name!=='attack'&&a.cooldowns[name]>0)return false; + if(name!=='attack')a.cooldowns[name]=balance.cooldowns[name]; + a.action={...move(name,index),t:0,fired:false,queued:0};a.state=a.action.name;a.stateTime=0;return true; + } + function refund(a) {for(const name of Object.keys(balance.cooldowns))a.cooldowns[name]=Math.max(0,a.cooldowns[name]-balance.cooldowns[name]*balance.basicRefund);} + window.Edda={balance,names,combo,stone,counter,tortoise,move,start,refund}; +})(); diff --git a/fenr.js b/fenr.js new file mode 100644 index 0000000..8ef55ef --- /dev/null +++ b/fenr.js @@ -0,0 +1,38 @@ +/* Shared FENR moves and transformation state: player and AI use the same rules. */ +(() => { + 'use strict'; + const balance = { duration:12, ultimateCooldown:24, castTime:.62, cooldowns:{skill1:3,skill2:6,ultimate:24} }; + const kits = { + human:{names:['RANGER CHAIN','GALE CLAW','RANGER RUSH','FERAL AWAKENING'],combo:[6,8,12],reach:[96,108,130],skill1:16,skill2:24}, + wolf:{names:['SAVAGE CHAIN','FANG POUNCE','MOON HOWL','FERAL AWAKENING'],combo:[7,9,13],reach:[116,130,145],skill1:18,skill2:22} + }; + function reset(a) {Object.assign(a,{form:'human',formTime:0,transformPending:0,transformElapsed:99,formChanges:0});} + function begin(a) { + if(a.form==='wolf'||a.transformPending>0||a.cooldowns.ultimate>0)return false; + a.transformPending=balance.castTime;a.transformElapsed=0;return true; + } + function end(a) {a.form='human';a.formTime=0;a.transformPending=0;a.formChanges++;a.cooldowns.ultimate=balance.ultimateCooldown;a.action=null;} + function advance(a,dt,alive=true) { + a.transformElapsed+=dt; + if(!alive) {if(a.form==='wolf'||a.transformPending>0)end(a);return;} + if(a.transformPending>0) { + a.transformPending=Math.max(0,a.transformPending-dt); + if(a.transformPending<=1e-8) {a.transformPending=0;a.form='wolf';a.formTime=balance.duration;a.formChanges++;} + } else if(a.form==='wolf') {a.formTime=Math.max(0,a.formTime-dt);if(a.formTime<=1e-8)end(a);} + } + function move(a,name,index=1) { + const wolf=a.form==='wolf',kit=kits[wolf?'wolf':'human']; + if(name==='attack')return {name:`attack${index}`,type:'attack',index,damage:kit.combo[index-1],reach:kit.reach[index-1],duration:[.38,.44,.55][index-1],knockback:80+index*25,fx:wolf?'bite':'claw',hitAt:.5}; + if(name==='skill1')return {name,type:name,damage:kit.skill1,reach:wolf?175:600,duration:wolf?.66:.48,knockback:150,fx:wolf?'bite':'gale',projectile:!wolf,dash:wolf?230:0,hitAt:wolf?.58:.5}; + if(name==='skill2')return {name,type:name,damage:kit.skill2,reach:wolf?205:155,duration:wolf?.85:.68,knockback:190,fx:wolf?'howl':'rush',area:wolf,dash:wolf?0:190,hitAt:.56}; + return {name:'ultimate',type:'ultimate',duration:balance.castTime,damage:0,hitAt:1}; + } + function start(a,name,index=1) { + if(a.action||a.transformPending>0||a.state==='hurt'||a.state==='down'||a.state==='recover')return false; + if(name!=='attack' && (a.cooldowns[name]>0 || (name==='ultimate'&&a.form==='wolf')))return false; + if(name==='ultimate') {if(!begin(a))return false;} else if(name!=='attack')a.cooldowns[name]=balance.cooldowns[name]; + a.action={...move(a,name,index),t:0,fired:false,queued:0,form:a.form};a.state=a.action.name;a.stateTime=0;return true; + } + function refund(a) {for(const name of Object.keys(balance.cooldowns))a.cooldowns[name]=Math.max(0,a.cooldowns[name]-balance.cooldowns[name]*.05);} + window.Fenr={balance,kits,reset,begin,end,advance,move,start,refund}; +})(); diff --git a/files.json b/files.json new file mode 100644 index 0000000..e8203e3 --- /dev/null +++ b/files.json @@ -0,0 +1,1114 @@ +{ + "files": 277, + "bytes": 33878708, + "list": [ + { + "file": "index.html", + "bytes": 12682 + }, + { + "file": "mobile.html", + "bytes": 3704 + }, + { + "file": "style.css", + "bytes": 25732 + }, + { + "file": "menu.css", + "bytes": 28728 + }, + { + "file": "preload.js", + "bytes": 4166 + }, + { + "file": "game.js", + "bytes": 170044 + }, + { + "file": "menu.js", + "bytes": 24312 + }, + { + "file": "match.js", + "bytes": 4262 + }, + { + "file": "announcer.js", + "bytes": 3135 + }, + { + "file": "touch.js", + "bytes": 4896 + }, + { + "file": "fenr.js", + "bytes": 2941 + }, + { + "file": "mira.js", + "bytes": 2052 + }, + { + "file": "cora.js", + "bytes": 2302 + }, + { + "file": "naja.js", + "bytes": 2509 + }, + { + "file": "haldor.js", + "bytes": 2677 + }, + { + "file": "zanni.js", + "bytes": 2799 + }, + { + "file": "isolde.js", + "bytes": 2635 + }, + { + "file": "rhea.js", + "bytes": 2656 + }, + { + "file": "solan.js", + "bytes": 2701 + }, + { + "file": "nib.js", + "bytes": 2524 + }, + { + "file": "edda.js", + "bytes": 2832 + }, + { + "file": "assets/ui/drone.png", + "bytes": 18724 + }, + { + "file": "assets/menu/locked.svg", + "bytes": 697 + }, + { + "file": "assets/audio/announcer/arco_wins.mp3", + "bytes": 23031 + }, + { + "file": "assets/audio/announcer/cora_wins.mp3", + "bytes": 21359 + }, + { + "file": "assets/audio/announcer/double_ko.mp3", + "bytes": 24285 + }, + { + "file": "assets/audio/announcer/draw.mp3", + "bytes": 17598 + }, + { + "file": "assets/audio/announcer/edda_wins.mp3", + "bytes": 17598 + }, + { + "file": "assets/audio/announcer/fenr_wins.mp3", + "bytes": 24285 + }, + { + "file": "assets/audio/announcer/fight.mp3", + "bytes": 17598 + }, + { + "file": "assets/audio/announcer/haldor_wins.mp3", + "bytes": 21359 + }, + { + "file": "assets/audio/announcer/isolde_wins.mp3", + "bytes": 24285 + }, + { + "file": "assets/audio/announcer/ko.mp3", + "bytes": 11328 + }, + { + "file": "assets/audio/announcer/manifest.js", + "bytes": 3383 + }, + { + "file": "assets/audio/announcer/mira_wins.mp3", + "bytes": 26793 + }, + { + "file": "assets/audio/announcer/naja_wins.mp3", + "bytes": 23031 + }, + { + "file": "assets/audio/announcer/nib_wins.mp3", + "bytes": 23031 + }, + { + "file": "assets/audio/announcer/rhea_wins.mp3", + "bytes": 21359 + }, + { + "file": "assets/audio/announcer/round_1.mp3", + "bytes": 17598 + }, + { + "file": "assets/audio/announcer/round_2.mp3", + "bytes": 35570 + }, + { + "file": "assets/audio/announcer/round_3.mp3", + "bytes": 21359 + }, + { + "file": "assets/audio/announcer/select_arco.mp3", + "bytes": 15090 + }, + { + "file": "assets/audio/announcer/select_cora.mp3", + "bytes": 15090 + }, + { + "file": "assets/audio/announcer/select_edda.mp3", + "bytes": 16344 + }, + { + "file": "assets/audio/announcer/select_fenr.mp3", + "bytes": 17598 + }, + { + "file": "assets/audio/announcer/select_haldor.mp3", + "bytes": 18852 + }, + { + "file": "assets/audio/announcer/select_isolde.mp3", + "bytes": 11328 + }, + { + "file": "assets/audio/announcer/select_mira.mp3", + "bytes": 21359 + }, + { + "file": "assets/audio/announcer/select_naja.mp3", + "bytes": 18852 + }, + { + "file": "assets/audio/announcer/select_nib.mp3", + "bytes": 15090 + }, + { + "file": "assets/audio/announcer/select_rhea.mp3", + "bytes": 16344 + }, + { + "file": "assets/audio/announcer/select_solan.mp3", + "bytes": 15090 + }, + { + "file": "assets/audio/announcer/select_zanni.mp3", + "bytes": 18852 + }, + { + "file": "assets/audio/announcer/solan_wins.mp3", + "bytes": 22612 + }, + { + "file": "assets/audio/announcer/time_up.mp3", + "bytes": 20106 + }, + { + "file": "assets/audio/announcer/zanni_wins.mp3", + "bytes": 24284 + }, + { + "file": "assets/audio/arco-ultimate-dylan.mp3", + "bytes": 36824 + }, + { + "file": "assets/audio/music/midday-showdown.mp3", + "bytes": 1571832 + }, + { + "file": "assets/cora/audio/cora-ultimate-anika.mp3", + "bytes": 28882 + }, + { + "file": "assets/cora/audio/cora_wins.mp3", + "bytes": 21359 + }, + { + "file": "assets/cora/audio/select_cora.mp3", + "bytes": 15090 + }, + { + "file": "assets/cora/manifest.js", + "bytes": 11440 + }, + { + "file": "assets/cora/run/sprite-sheet-alpha.webp", + "bytes": 1500730 + }, + { + "file": "assets/cora/ui/cutin.webp", + "bytes": 189114 + }, + { + "file": "assets/cora/ui/fx-feather.webp", + "bytes": 5044 + }, + { + "file": "assets/cora/ui/fx-gust.webp", + "bytes": 14152 + }, + { + "file": "assets/cora/ui/fx-raven.webp", + "bytes": 10750 + }, + { + "file": "assets/cora/ui/icon-basic.webp", + "bytes": 21044 + }, + { + "file": "assets/cora/ui/icon-skill1.webp", + "bytes": 17310 + }, + { + "file": "assets/cora/ui/icon-skill2.webp", + "bytes": 19240 + }, + { + "file": "assets/cora/ui/icon-ultimate.webp", + "bytes": 28518 + }, + { + "file": "assets/cora/ui/portrait.webp", + "bytes": 37954 + }, + { + "file": "assets/edda/audio/edda-ultimate.mp3", + "bytes": 23030 + }, + { + "file": "assets/edda/audio/edda_wins.mp3", + "bytes": 17598 + }, + { + "file": "assets/edda/audio/select_edda.mp3", + "bytes": 16344 + }, + { + "file": "assets/edda/manifest.js", + "bytes": 12097 + }, + { + "file": "assets/edda/run/sprite-sheet-alpha.webp", + "bytes": 1595536 + }, + { + "file": "assets/edda/ui/cutin.webp", + "bytes": 264670 + }, + { + "file": "assets/edda/ui/fx-ripple.webp", + "bytes": 6940 + }, + { + "file": "assets/edda/ui/fx-shell.webp", + "bytes": 17640 + }, + { + "file": "assets/edda/ui/fx-stomp.webp", + "bytes": 13468 + }, + { + "file": "assets/edda/ui/fx-stone.webp", + "bytes": 7698 + }, + { + "file": "assets/edda/ui/fx-tortoise.webp", + "bytes": 60852 + }, + { + "file": "assets/edda/ui/icon-basic.webp", + "bytes": 26148 + }, + { + "file": "assets/edda/ui/icon-skill1.webp", + "bytes": 20836 + }, + { + "file": "assets/edda/ui/icon-skill2.webp", + "bytes": 23314 + }, + { + "file": "assets/edda/ui/icon-ultimate.webp", + "bytes": 31242 + }, + { + "file": "assets/edda/ui/portrait.webp", + "bytes": 34176 + }, + { + "file": "assets/fenr/audio/fenr-ultimate-holden.mp3", + "bytes": 23031 + }, + { + "file": "assets/fenr/human/manifest.js", + "bytes": 11411 + }, + { + "file": "assets/fenr/human/run/sprite-sheet-alpha.webp", + "bytes": 1486412 + }, + { + "file": "assets/fenr/ui/cutin.webp", + "bytes": 119558 + }, + { + "file": "assets/fenr/ui/fx-bite.webp", + "bytes": 8948 + }, + { + "file": "assets/fenr/ui/fx-claw.webp", + "bytes": 7178 + }, + { + "file": "assets/fenr/ui/fx-gale.webp", + "bytes": 4970 + }, + { + "file": "assets/fenr/ui/fx-howl.webp", + "bytes": 10784 + }, + { + "file": "assets/fenr/ui/fx-rush.webp", + "bytes": 10026 + }, + { + "file": "assets/fenr/ui/fx-transform.webp", + "bytes": 15202 + }, + { + "file": "assets/fenr/ui/icon-human-basic.webp", + "bytes": 11026 + }, + { + "file": "assets/fenr/ui/icon-human-skill1.webp", + "bytes": 6122 + }, + { + "file": "assets/fenr/ui/icon-human-skill2.webp", + "bytes": 9938 + }, + { + "file": "assets/fenr/ui/icon-ultimate.webp", + "bytes": 14998 + }, + { + "file": "assets/fenr/ui/icon-wolf-basic.webp", + "bytes": 15864 + }, + { + "file": "assets/fenr/ui/icon-wolf-skill1.webp", + "bytes": 10442 + }, + { + "file": "assets/fenr/ui/icon-wolf-skill2.webp", + "bytes": 8652 + }, + { + "file": "assets/fenr/ui/portrait-human.webp", + "bytes": 24324 + }, + { + "file": "assets/fenr/ui/portrait-wolf.webp", + "bytes": 27618 + }, + { + "file": "assets/fenr/wolf/manifest.js", + "bytes": 11436 + }, + { + "file": "assets/fenr/wolf/run/sprite-sheet-alpha.webp", + "bytes": 2360632 + }, + { + "file": "assets/fonts/Rajdhani-Bold.ttf", + "bytes": 400680 + }, + { + "file": "assets/fonts/Rajdhani-Medium.ttf", + "bytes": 384348 + }, + { + "file": "assets/haldor/audio/haldor-ultimate.mp3", + "bytes": 20106 + }, + { + "file": "assets/haldor/audio/haldor_wins.mp3", + "bytes": 21359 + }, + { + "file": "assets/haldor/audio/select_haldor.mp3", + "bytes": 18852 + }, + { + "file": "assets/haldor/manifest.js", + "bytes": 11455 + }, + { + "file": "assets/haldor/run/sprite-sheet-alpha.webp", + "bytes": 1950620 + }, + { + "file": "assets/haldor/ui/cutin.webp", + "bytes": 226036 + }, + { + "file": "assets/haldor/ui/fx-quake.webp", + "bytes": 12184 + }, + { + "file": "assets/haldor/ui/fx-slag.webp", + "bytes": 11300 + }, + { + "file": "assets/haldor/ui/fx-splash.webp", + "bytes": 9562 + }, + { + "file": "assets/haldor/ui/fx-steam.webp", + "bytes": 6432 + }, + { + "file": "assets/haldor/ui/icon-basic.webp", + "bytes": 28334 + }, + { + "file": "assets/haldor/ui/icon-skill1.webp", + "bytes": 16502 + }, + { + "file": "assets/haldor/ui/icon-skill2.webp", + "bytes": 23904 + }, + { + "file": "assets/haldor/ui/icon-ultimate.webp", + "bytes": 31006 + }, + { + "file": "assets/haldor/ui/portrait.webp", + "bytes": 46308 + }, + { + "file": "assets/isolde/audio/isolde-ultimate.mp3", + "bytes": 30972 + }, + { + "file": "assets/isolde/audio/isolde_wins.mp3", + "bytes": 24285 + }, + { + "file": "assets/isolde/audio/select_isolde.mp3", + "bytes": 11328 + }, + { + "file": "assets/isolde/manifest.js", + "bytes": 11797 + }, + { + "file": "assets/isolde/run/sprite-sheet-alpha.webp", + "bytes": 1550448 + }, + { + "file": "assets/isolde/ui/cutin.webp", + "bytes": 226498 + }, + { + "file": "assets/isolde/ui/fx-frost.webp", + "bytes": 8318 + }, + { + "file": "assets/isolde/ui/fx-piercer.webp", + "bytes": 6690 + }, + { + "file": "assets/isolde/ui/fx-shatter.webp", + "bytes": 15232 + }, + { + "file": "assets/isolde/ui/fx-skyfall.webp", + "bytes": 7412 + }, + { + "file": "assets/isolde/ui/icon-basic.webp", + "bytes": 14166 + }, + { + "file": "assets/isolde/ui/icon-skill1.webp", + "bytes": 20634 + }, + { + "file": "assets/isolde/ui/icon-skill2.webp", + "bytes": 23448 + }, + { + "file": "assets/isolde/ui/icon-ultimate.webp", + "bytes": 26876 + }, + { + "file": "assets/isolde/ui/portrait.webp", + "bytes": 41656 + }, + { + "file": "assets/mecha/manifest.js", + "bytes": 13266 + }, + { + "file": "assets/mecha/run/sprite-sheet-alpha.webp", + "bytes": 1140340 + }, + { + "file": "assets/menu/arco-select.webp", + "bytes": 83804 + }, + { + "file": "assets/menu/azure-harbor.webp", + "bytes": 116560 + }, + { + "file": "assets/menu/cora-select.webp", + "bytes": 85066 + }, + { + "file": "assets/menu/edda-select.webp", + "bytes": 98000 + }, + { + "file": "assets/menu/elderwood.webp", + "bytes": 130386 + }, + { + "file": "assets/menu/fenr-select.webp", + "bytes": 84330 + }, + { + "file": "assets/menu/haldor-select.webp", + "bytes": 110328 + }, + { + "file": "assets/menu/home-art.webp", + "bytes": 188424 + }, + { + "file": "assets/menu/home-factions-loop.mp4", + "bytes": 1417101 + }, + { + "file": "assets/menu/home-factions.webp", + "bytes": 148524 + }, + { + "file": "assets/menu/isolde-select.webp", + "bytes": 119740 + }, + { + "file": "assets/menu/mira-select.webp", + "bytes": 89058 + }, + { + "file": "assets/menu/moonrise.webp", + "bytes": 68134 + }, + { + "file": "assets/menu/naja-select.webp", + "bytes": 130418 + }, + { + "file": "assets/menu/nib-select.webp", + "bytes": 77832 + }, + { + "file": "assets/menu/rhea-select.webp", + "bytes": 107980 + }, + { + "file": "assets/menu/solan-select.webp", + "bytes": 112452 + }, + { + "file": "assets/menu/sunspire.webp", + "bytes": 49268 + }, + { + "file": "assets/menu/zanni-select.webp", + "bytes": 117976 + }, + { + "file": "assets/mira/audio/mira-ultimate-luna.mp3", + "bytes": 23448 + }, + { + "file": "assets/mira/audio/mira_wins.mp3", + "bytes": 26793 + }, + { + "file": "assets/mira/audio/select_mira.mp3", + "bytes": 21359 + }, + { + "file": "assets/mira/manifest.js", + "bytes": 11852 + }, + { + "file": "assets/mira/run/sprite-sheet-alpha.webp", + "bytes": 2237726 + }, + { + "file": "assets/mira/ui/cutin.webp", + "bytes": 209146 + }, + { + "file": "assets/mira/ui/fx-burst.webp", + "bytes": 17352 + }, + { + "file": "assets/mira/ui/fx-crash.webp", + "bytes": 13328 + }, + { + "file": "assets/mira/ui/fx-rocket.webp", + "bytes": 6158 + }, + { + "file": "assets/mira/ui/fx-star.webp", + "bytes": 6094 + }, + { + "file": "assets/mira/ui/icon-basic.webp", + "bytes": 17856 + }, + { + "file": "assets/mira/ui/icon-skill1.webp", + "bytes": 15306 + }, + { + "file": "assets/mira/ui/icon-skill2.webp", + "bytes": 25404 + }, + { + "file": "assets/mira/ui/icon-ultimate.webp", + "bytes": 25152 + }, + { + "file": "assets/mira/ui/portrait.webp", + "bytes": 38620 + }, + { + "file": "assets/naja/audio/naja-ultimate-soraya.mp3", + "bytes": 21359 + }, + { + "file": "assets/naja/audio/naja_wins.mp3", + "bytes": 23031 + }, + { + "file": "assets/naja/audio/select_naja.mp3", + "bytes": 18852 + }, + { + "file": "assets/naja/manifest.js", + "bytes": 11430 + }, + { + "file": "assets/naja/run/sprite-sheet-alpha.webp", + "bytes": 1621460 + }, + { + "file": "assets/naja/ui/cutin.webp", + "bytes": 304792 + }, + { + "file": "assets/naja/ui/fx-cyclone.webp", + "bytes": 14950 + }, + { + "file": "assets/naja/ui/fx-ripple.webp", + "bytes": 8958 + }, + { + "file": "assets/naja/ui/fx-sandwave.webp", + "bytes": 9302 + }, + { + "file": "assets/naja/ui/fx-serpent.webp", + "bytes": 65082 + }, + { + "file": "assets/naja/ui/icon-basic.webp", + "bytes": 27428 + }, + { + "file": "assets/naja/ui/icon-skill1.webp", + "bytes": 24914 + }, + { + "file": "assets/naja/ui/icon-skill2.webp", + "bytes": 28412 + }, + { + "file": "assets/naja/ui/icon-ultimate.webp", + "bytes": 29388 + }, + { + "file": "assets/naja/ui/portrait.webp", + "bytes": 38558 + }, + { + "file": "assets/nib/audio/nib-ultimate.mp3", + "bytes": 20523 + }, + { + "file": "assets/nib/audio/nib_wins.mp3", + "bytes": 23031 + }, + { + "file": "assets/nib/audio/select_nib.mp3", + "bytes": 15090 + }, + { + "file": "assets/nib/manifest.js", + "bytes": 11785 + }, + { + "file": "assets/nib/run/sprite-sheet-alpha.webp", + "bytes": 1145842 + }, + { + "file": "assets/nib/ui/cutin.webp", + "bytes": 185052 + }, + { + "file": "assets/nib/ui/fx-letter.webp", + "bytes": 4924 + }, + { + "file": "assets/nib/ui/fx-plane.webp", + "bytes": 4572 + }, + { + "file": "assets/nib/ui/fx-slip.webp", + "bytes": 22866 + }, + { + "file": "assets/nib/ui/fx-stamp.webp", + "bytes": 19112 + }, + { + "file": "assets/nib/ui/icon-basic.webp", + "bytes": 22936 + }, + { + "file": "assets/nib/ui/icon-skill1.webp", + "bytes": 12434 + }, + { + "file": "assets/nib/ui/icon-skill2.webp", + "bytes": 19034 + }, + { + "file": "assets/nib/ui/icon-ultimate.webp", + "bytes": 18786 + }, + { + "file": "assets/nib/ui/portrait.webp", + "bytes": 28462 + }, + { + "file": "assets/rhea/audio/rhea-ultimate.mp3", + "bytes": 21359 + }, + { + "file": "assets/rhea/audio/rhea_wins.mp3", + "bytes": 21359 + }, + { + "file": "assets/rhea/audio/select_rhea.mp3", + "bytes": 16344 + }, + { + "file": "assets/rhea/manifest.js", + "bytes": 11682 + }, + { + "file": "assets/rhea/run/sprite-sheet-alpha.webp", + "bytes": 1274018 + }, + { + "file": "assets/rhea/ui/cutin.webp", + "bytes": 209154 + }, + { + "file": "assets/rhea/ui/fx-burst.webp", + "bytes": 19276 + }, + { + "file": "assets/rhea/ui/fx-drift.webp", + "bytes": 9482 + }, + { + "file": "assets/rhea/ui/fx-planet.webp", + "bytes": 17248 + }, + { + "file": "assets/rhea/ui/fx-well.webp", + "bytes": 21730 + }, + { + "file": "assets/rhea/ui/icon-basic.webp", + "bytes": 17416 + }, + { + "file": "assets/rhea/ui/icon-skill1.webp", + "bytes": 12188 + }, + { + "file": "assets/rhea/ui/icon-skill2.webp", + "bytes": 24152 + }, + { + "file": "assets/rhea/ui/icon-ultimate.webp", + "bytes": 26232 + }, + { + "file": "assets/rhea/ui/portrait.webp", + "bytes": 38584 + }, + { + "file": "assets/showcase/edda/portrait.webp", + "bytes": 34176 + }, + { + "file": "assets/showcase/haldor/portrait.webp", + "bytes": 46308 + }, + { + "file": "assets/showcase/isolde/portrait.webp", + "bytes": 41656 + }, + { + "file": "assets/showcase/naja/portrait.webp", + "bytes": 38558 + }, + { + "file": "assets/showcase/nib/portrait.webp", + "bytes": 28462 + }, + { + "file": "assets/showcase/rhea/portrait.webp", + "bytes": 38584 + }, + { + "file": "assets/showcase/showcase.js", + "bytes": 150 + }, + { + "file": "assets/showcase/solan/portrait.webp", + "bytes": 42714 + }, + { + "file": "assets/showcase/zanni/portrait.webp", + "bytes": 33856 + }, + { + "file": "assets/solan/audio/select_solan.mp3", + "bytes": 15090 + }, + { + "file": "assets/solan/audio/solan-ultimate.mp3", + "bytes": 21359 + }, + { + "file": "assets/solan/audio/solan_wins-raw.mp3", + "bytes": 26793 + }, + { + "file": "assets/solan/audio/solan_wins.mp3", + "bytes": 22612 + }, + { + "file": "assets/solan/manifest.js", + "bytes": 11759 + }, + { + "file": "assets/solan/run/sprite-sheet-alpha.webp", + "bytes": 1812930 + }, + { + "file": "assets/solan/ui/cutin.webp", + "bytes": 221154 + }, + { + "file": "assets/solan/ui/fx-crescent.webp", + "bytes": 10074 + }, + { + "file": "assets/solan/ui/fx-impact.webp", + "bytes": 12828 + }, + { + "file": "assets/solan/ui/fx-roar.webp", + "bytes": 16002 + }, + { + "file": "assets/solan/ui/fx-sunburst.webp", + "bytes": 17286 + }, + { + "file": "assets/solan/ui/icon-basic.webp", + "bytes": 21426 + }, + { + "file": "assets/solan/ui/icon-skill1.webp", + "bytes": 16616 + }, + { + "file": "assets/solan/ui/icon-skill2.webp", + "bytes": 24528 + }, + { + "file": "assets/solan/ui/icon-ultimate.webp", + "bytes": 26478 + }, + { + "file": "assets/solan/ui/portrait.webp", + "bytes": 42714 + }, + { + "file": "assets/stage-config.js", + "bytes": 330 + }, + { + "file": "assets/stage.webp", + "bytes": 84514 + }, + { + "file": "assets/ui/arco-avatar.webp", + "bytes": 28238 + }, + { + "file": "assets/ui/attack.webp", + "bytes": 6744 + }, + { + "file": "assets/ui/drone-config.js", + "bytes": 196 + }, + { + "file": "assets/ui/dummy-avatar.webp", + "bytes": 22410 + }, + { + "file": "assets/ui/icons-check.webp", + "bytes": 32114 + }, + { + "file": "assets/ui/skill1.webp", + "bytes": 8908 + }, + { + "file": "assets/ui/skill2.webp", + "bytes": 9788 + }, + { + "file": "assets/ui/squadron-icon.webp", + "bytes": 6466 + }, + { + "file": "assets/ui/ultimate-cutin.webp", + "bytes": 126218 + }, + { + "file": "assets/ui/ultimate.webp", + "bytes": 7504 + }, + { + "file": "assets/zanni/audio/select_zanni.mp3", + "bytes": 18852 + }, + { + "file": "assets/zanni/audio/zanni-ultimate.mp3", + "bytes": 17598 + }, + { + "file": "assets/zanni/audio/zanni_wins-raw.mp3", + "bytes": 34316 + }, + { + "file": "assets/zanni/audio/zanni_wins.mp3", + "bytes": 24284 + }, + { + "file": "assets/zanni/manifest.js", + "bytes": 11595 + }, + { + "file": "assets/zanni/run/sprite-sheet-alpha.webp", + "bytes": 1555686 + }, + { + "file": "assets/zanni/ui/cutin.webp", + "bytes": 206256 + }, + { + "file": "assets/zanni/ui/fx-bigring.webp", + "bytes": 24056 + }, + { + "file": "assets/zanni/ui/fx-confetti.webp", + "bytes": 16344 + }, + { + "file": "assets/zanni/ui/fx-ring.webp", + "bytes": 14902 + }, + { + "file": "assets/zanni/ui/fx-snatch.webp", + "bytes": 19366 + }, + { + "file": "assets/zanni/ui/icon-basic.webp", + "bytes": 25846 + }, + { + "file": "assets/zanni/ui/icon-skill1.webp", + "bytes": 17230 + }, + { + "file": "assets/zanni/ui/icon-skill2.webp", + "bytes": 19002 + }, + { + "file": "assets/zanni/ui/icon-ultimate.webp", + "bytes": 28306 + }, + { + "file": "assets/zanni/ui/portrait.webp", + "bytes": 33856 + }, + { + "file": "precache.js", + "bytes": 15499 + }, + { + "file": "sw.js", + "bytes": 1358 + } + ] +} diff --git a/game.js b/game.js new file mode 100644 index 0000000..eeec9c0 --- /dev/null +++ b/game.js @@ -0,0 +1,1731 @@ +/* AETHER CLASH — standalone deterministic canvas combat runtime. */ +(() => { + 'use strict'; + const W = 1280, H = 720; + const CONFIG = { groundY: 584, walkSpeed: 320, runSpeed: 520, jumpSpeed: 830, doubleJumpSpeed: 790, gravity: 2900, fastFall: 1180, doubleTapWindow: .26, rollDuration: .28, ...window.STAGE_CONFIG }; + const canvas = document.querySelector('#arena'), ctx = canvas.getContext('2d', { alpha: false }); + const $ = s => document.querySelector(s); + const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v)); + const approach = (v, target, amount) => v < target ? Math.min(target, v + amount) : Math.max(target, v - amount); + const BALANCE = { + combo: [{damage:6,knockback:75},{damage:8,knockback:100},{damage:12,knockback:180}], + skill1: {damage:16,cooldown:3,knockback:140}, + skill2: {damage:24,cooldown:6,knockback:220,radius:220}, + ultimate: {damagePerDrone:12,cooldown:18,castTime:.50,knockback:190}, + dummyMax:200, heroMax:200, hpPerBar:100, basicCooldownRefund:.05 + }; + const cooldownMax = { skill1:BALANCE.skill1.cooldown, skill2:BALANCE.skill2.cooldown, ultimate:BALANCE.ultimate.cooldown }; + const skillNames = { skill1: 'AETHER BOLT', skill2: 'SEISMIC DRIVE', ultimate: 'HELIOS SQUADRON' }; + const images = {}, F = window.Fenr, M = window.Mira && window.MIRA_MANIFEST ? window.Mira : null, C = window.Cora && window.CORA_MANIFEST ? window.Cora : null, N = window.Naja && window.NAJA_MANIFEST ? window.Naja : null, HD = window.Haldor && window.HALDOR_MANIFEST ? window.Haldor : null, Z = window.Zanni && window.ZANNI_MANIFEST ? window.Zanni : null, IS = window.Isolde && window.ISOLDE_MANIFEST ? window.Isolde : null, RH = window.Rhea && window.RHEA_MANIFEST ? window.Rhea : null, SO = window.Solan && window.SOLAN_MANIFEST ? window.Solan : null, NB = window.Nib && window.NIB_MANIFEST ? window.Nib : null, ED = window.Edda && window.EDDA_MANIFEST ? window.Edda : null; + // Kits that share one start/move/refund contract (mira.js, cora.js, naja.js, haldor.js, zanni.js, isolde.js, rhea.js, solan.js, nib.js, edda.js). + const KITS = { ...(M ? { mira: M } : {}), ...(C ? { cora: C } : {}), ...(N ? { naja: N } : {}), ...(HD ? { haldor: HD } : {}), ...(Z ? { zanni: Z } : {}), ...(IS ? { isolde: IS } : {}), ...(RH ? { rhea: RH } : {}), ...(SO ? { solan: SO } : {}), ...(NB ? { nib: NB } : {}), ...(ED ? { edda: ED } : {}) }; + let manifest = window.MECHA_MANIFEST || window.SPRITE_MANIFEST || null, metrics = window.MECHA_METRICS || null; + let selectedCharacter = 'arco', aiEnabled = !!F, fenrCutin = null, hudIdentity = ''; + const Rules = window.MatchRules; + let opponentCharacter = 'fenr', difficulty = 'medium', stageId = 'bellora', menuOpen = !!window.FRONTEND_ENABLED; + let match = Rules?.create('training') || null, roundAnnouncer = null; + const roundCues = []; + let ready = false, paused = false, settingsOpen = false, hitboxes = false, muted = false, volume = .45; + let scale = 1, offsetX = 0, offsetY = 0, viewW = W, viewH = H, pixelRatio = 1; + let time = 0, realTime = 0, lastTime = 0, accumulator = 0, hitstop = 0, trauma = 0, cinematic = 0, announceTimer = 0; + let seed = 18741, stepCount = 0, totalDamage = 0, comboHits = 0, bestCombo = 0, comboDamage = 0, comboTimer = 0, fps = 60; + const keys = new Set(), taps = { a: -10, d: -10, s: -10 }; + const particles = [], effects = [], projectiles = [], damageNumbers = []; + let squad = null, enemySquad = null, parade = null, enemyParade = null, flock = null, enemyFlock = null, serpent = null, enemySerpent = null, quake = null, enemyQuake = null, finale = null, enemyFinale = null, skyfall = null, enemySkyfall = null, orrery = null, enemyOrrery = null, sunroar = null, enemySunroar = null, delivery = null, enemyDelivery = null, tortoise = null, enemyTortoise = null; + const rechargePulse = {skill1:0,skill2:0,ultimate:0}; + // CPU memory: what it has already read about the player, and its current plan. + let cpu = { seen: new WeakMap(), decided: new WeakSet(), read: null, followPunish: null, heroActionAt: 0, heroAction: null, airborne: false, airAt: 0, antiAirRead: false, plan: null, doubleAt: 0 }; + const SQUAD = { duration: 2.85, cutinDuration: .78, firstShot: 1.50, shotGap: .095, departure: 2.24, droneWidth: 112 }; + const hero = { x: 350, y: CONFIG.groundY, vx: 0, vy: 0, facing: 1, grounded: true, jumps: 0, state: 'idle', animTime: 0, walkPhase: 0, smokeDistance:0, action: null, runDirection: 0, hp: BALANCE.heroMax, koTime:0, invuln: 0, hurtTime: 0, doublePose: 0, rollDuration: CONFIG.rollDuration, rollBuffer: null, airTime: 0, cooldowns: { skill1: 0, skill2: 0, ultimate: 0 } }; + const dummy = { x: 915, y: CONFIG.groundY, vx: 0, vy: 0, state: 'idle', stateTime: 0, damage: 0, ko:false, flash: 0, invuln: 0, angle: 0, respawnX: 915 }; + function random() { seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; return seed / 4294967296; } + function loadImage(name, src, required = false) { return new Promise(resolve => { const img = new Image(); img.onload = () => { images[name] = img; resolve(true); }; img.onerror = () => resolve(!required); img.src = src; }); } + function resize() { + viewW = Math.max(1, innerWidth); viewH = Math.max(1, innerHeight); pixelRatio = Math.min(devicePixelRatio || 1, 2); + canvas.width = Math.round(viewW * pixelRatio); canvas.height = Math.round(viewH * pixelRatio); + scale = Math.min(viewW / W, viewH / H); offsetX = (viewW - W * scale) / 2; + // Keep the same world floor and complete combat width on every screen. + offsetY = viewH * .815 - CONFIG.groundY * scale; + ctx.imageSmoothingEnabled = false; + } + function announce(text, seconds = 1.3) { $('#announcement').textContent = text; announceTimer = seconds; } + function state(name) { if (hero.state !== name) { hero.state = name; hero.animTime = 0; } } + function frames(name) { return manifest?.frame_layout?.rows?.[name] || []; } + function animationDuration(name, fallback) { const row = manifest?.animation?.rows?.[name]; const count = frames(name).length; return row?.fps && count ? count / row.fps : fallback; } + function spawnParticles(x, y, color, n, power = 220, kind = 'spark') { + for (let i = 0; i < n; i++) { const a = random() * Math.PI * 2, speed = (random() * .8 + .3) * power; particles.push({ x, y, vx: Math.cos(a) * speed, vy: Math.sin(a) * speed - 65, life: .2 + random() * .4, maxLife: .6, size: 2 + random() * 4, color, kind }); } + } + function dust(x, n = 9) { spawnParticles(x, CONFIG.groundY - 5, '#d6c6a3', n, 80, 'dust'); } + function runSmoke(x,facing) { + for(let i=0;i<2;i++) particles.push({kind:'run-smoke',x:x-facing*(18+i*8),y:CONFIG.groundY-2-i*3-random()*4,vx:-facing*(35+random()*40),vy:-13-random()*12,life:.32+random()*.14,maxLife:.46,size:8+random()*5,color:'#d9e1d5'}); + } + + // A user gesture unlocks audio; AI voices stay silent until then. + let audio = null, master = null, compressor = null, audioSources = 0; + let systemAnnouncer = null; + // Background music: one looping HTMLAudio outside the effects/voice mix. Default 5%; Settings turns it on/off and sets + // its own volume (remembered on this device). Like every sound it starts only after the first user gesture, pauses in a + // hidden tab, follows the master mute, and dips while the announcer or an ultimate voice is speaking. + const MUSIC_PATH = 'assets/audio/music/midday-showdown.mp3'; + const prefs = { get(k, d) { try { const v = localStorage.getItem(k); return v === null ? d : v; } catch (_) { return d; } }, set(k, v) { try { localStorage.setItem(k, v); } catch (_) {} } }; + let musicOn = prefs.get('aether.music', '1') !== '0', musicVolume = clamp(Number(prefs.get('aether.musicVolume', document.documentElement.classList.contains('touch') ? '5' : '15')) / 100 || 0, 0, 1), music = null; + try { if (typeof Audio !== 'undefined') { music = new Audio(); music.loop = true; music.preload = 'none'; music.src = MUSIC_PATH; } } catch (_) { music = null; } + function syncMusic() { + if (!music) return; + const speaking = systemAnnouncer?.speaking || (voiceActive && ultimateVoice && !ultimateVoice.paused); + music.muted = muted; music.volume = clamp(musicVolume * (speaking ? .55 : 1), 0, 1); + const want = musicOn && musicVolume > 0 && !!audio && !document.hidden; + if (want && music.paused) music.play()?.catch?.(() => {}); + else if (!want && !music.paused) music.pause(); + } + const ULTIMATE_VOICE_PATH = 'assets/audio/arco-ultimate-dylan.mp3'; + const FENR_VOICE_PATH = 'assets/fenr/audio/fenr-ultimate-holden.mp3'; + const MIRA_VOICE_PATH = 'assets/mira/audio/mira-ultimate-luna.mp3', CORA_VOICE_PATH = 'assets/cora/audio/cora-ultimate-anika.mp3', NAJA_VOICE_PATH = 'assets/naja/audio/naja-ultimate-soraya.mp3', HALDOR_VOICE_PATH = 'assets/haldor/audio/haldor-ultimate.mp3', ZANNI_VOICE_PATH = 'assets/zanni/audio/zanni-ultimate.mp3', ISOLDE_VOICE_PATH = 'assets/isolde/audio/isolde-ultimate.mp3', RHEA_VOICE_PATH = 'assets/rhea/audio/rhea-ultimate.mp3', SOLAN_VOICE_PATH = 'assets/solan/audio/solan-ultimate.mp3', NIB_VOICE_PATH = 'assets/nib/audio/nib-ultimate.mp3', EDDA_VOICE_PATH = 'assets/edda/audio/edda-ultimate.mp3'; + const VOICE_NAMES = { arco: 'Dylan', fenr: 'Holden', mira: 'Luna', cora: 'Anika', naja: 'Soraya', haldor: 'Gideon', zanni: 'Julian', isolde: 'Vesper', rhea: 'Chloe', solan: 'Xavier', nib: 'Evan', edda: 'Opal' }; + const voiceBank = {}; + let voiceKind = 'arco', voiceName = 'Dylan', voiceActor = null, voiceClock = 0; + let ultimateVoice = null, voiceActive = false, voiceSerial = 0, voicePlayToken = 0, voiceStarts = 0, voiceError = ''; + try { + if (typeof Audio !== 'undefined') { + for (const [kind, path] of [['arco', ULTIMATE_VOICE_PATH], ...(F ? [['fenr', FENR_VOICE_PATH]] : []), ...(M ? [['mira', MIRA_VOICE_PATH]] : []), ...(C ? [['cora', CORA_VOICE_PATH]] : []), ...(N ? [['naja', NAJA_VOICE_PATH]] : []), ...(HD ? [['haldor', HALDOR_VOICE_PATH]] : []), ...(Z ? [['zanni', ZANNI_VOICE_PATH]] : []), ...(IS ? [['isolde', ISOLDE_VOICE_PATH]] : []), ...(RH ? [['rhea', RHEA_VOICE_PATH]] : []), ...(SO ? [['solan', SOLAN_VOICE_PATH]] : []), ...(NB ? [['nib', NIB_VOICE_PATH]] : []), ...(ED ? [['edda', EDDA_VOICE_PATH]] : [])]) { + const media = new Audio(); media.preload = 'auto'; voiceBank[kind] = media; + media.addEventListener('ended', () => { if (ultimateVoice !== media) return; voiceActive = false; applyAudioMix(); }); + media.addEventListener('error', () => { if (ultimateVoice !== media) return; voiceError = 'audio-load-failed'; voiceActive = false; applyAudioMix(); }); + media.addEventListener('playing', () => { + if (ultimateVoice !== media) { media.pause(); return; } + if (!voiceActive || !voiceRelevant()) { stopUltimateVoice(); return; } + const elapsed = voiceElapsed(); + if (media.currentTime < .05 && elapsed > .2 && Number.isFinite(media.duration)) media.currentTime = Math.min(elapsed, media.duration); + applyAudioMix(); + }); + media.src = path; media.load(); + } + ultimateVoice = voiceBank.arco; + } + } catch (_) { voiceError = 'audio-unavailable'; } + if(window.SystemAnnouncer && window.ANNOUNCER_MANIFEST)systemAnnouncer=window.SystemAnnouncer.create({ + clips:window.ANNOUNCER_MANIFEST.clips,canPlay:()=>!!audio&&!document.hidden,isPaused:()=>paused||settingsOpen||document.hidden, + onStart:()=>stopUltimateVoice(),onActivity:()=>applyAudioMix() + }); + function voiceElapsed() { return voiceKind === 'fenr' ? (voiceActor?.transformElapsed ?? Infinity) : voiceKind === 'arco' ? ((voiceActor === dummy ? enemySquad : squad)?.t ?? Infinity) : voiceClock; } + function voiceRelevant() { + if (voiceKind === 'arco') return !!(voiceActor === dummy ? enemySquad : squad); + const alive = !!voiceActor && (voiceActor === hero ? hero.hp > 0 : !dummy.ko); + // MIRA/CORA/NAJA/HALDOR/ZANNI/ISOLDE/RHEA/SOLAN/NIB/EDDA lines run longer than their salvo; they finish unless the caster is K.O. or the fight resets. + if (voiceKind !== 'fenr') return alive; + return alive && (voiceActor.transformPending > 0 || voiceActor.form === 'wolf'); + } + function applyAudioMix() { + if (master) master.gain.value = muted ? 0 : volume * ((systemAnnouncer?.speaking || (voiceActive && ultimateVoice && !ultimateVoice.paused)) ? .38 : 1); + for (const media of Object.values(voiceBank)) { media.muted = muted; media.volume = clamp(volume * 1.25, 0, 1); } + systemAnnouncer?.setMix(volume,muted); + syncMusic(); + } + function stopUltimateVoice() { + voiceSerial++; voicePlayToken++; voiceActive = false; + if (ultimateVoice) { ultimateVoice.pause(); try { ultimateVoice.currentTime = 0; } catch (_) {} } + applyAudioMix(); + } + function syncUltimateVoice() { + systemAnnouncer?.sync(); + if (!ultimateVoice) return; + if (!voiceActive || paused || settingsOpen || document.hidden) { voicePlayToken++; ultimateVoice.pause(); applyAudioMix(); return; } + if (!voiceRelevant() || (Number.isFinite(ultimateVoice.duration) && voiceElapsed() >= ultimateVoice.duration)) { stopUltimateVoice(); return; } + if (ultimateVoice.paused) { + const serial = voiceSerial, playToken = ++voicePlayToken; + const play = ultimateVoice.play(); + if (play?.catch) play.catch(error => { if (serial !== voiceSerial || playToken !== voicePlayToken) return; voiceActive = false; voiceError = error?.name || 'audio-playback-failed'; applyAudioMix(); }); + } + applyAudioMix(); + } + function startUltimateVoice(kind = 'arco', actor = hero) { + // One speaking channel. Player calls take priority over an AI transformation. + if (!audio || systemAnnouncer?.busy || !voiceBank[kind] || (actor === dummy && voiceActive)) return; + stopUltimateVoice(); + ultimateVoice = voiceBank[kind]; voiceKind = kind; voiceActor = actor; voiceName = VOICE_NAMES[kind]; voiceClock = 0; + try { ultimateVoice.currentTime = 0; } catch (_) {} + voiceActive = true; voiceStarts++; voiceError = ''; + syncUltimateVoice(); + } + function unlockAudio() { + try { if (!audio) { const AC = window.AudioContext || window.webkitAudioContext; if (!AC) return; audio = new AC(); master = audio.createGain(); compressor = audio.createDynamicsCompressor(); master.connect(compressor); compressor.connect(audio.destination); } if (audio.state === 'suspended') audio.resume(); applyAudioMix(); } catch (_) { /* Audio is optional; gameplay remains usable. */ } + } + function sound(type) { + if (!audio || audio.state !== 'running' || muted) return; + const start = audio.currentTime, heavy = type === 'heavy' || type === 'ultimate'; + const duration = type === 'ultimate' ? .7 : heavy ? .26 : type === 'cast' ? .22 : .13; + const osc = audio.createOscillator(), gain = audio.createGain(); + osc.type = type === 'jump' || type === 'cast' ? 'sine' : 'triangle'; + const frequency = ({ hit: 210, heavy: 110, cast: 430, jump: 360, ultimate: 72, step: 75, click: 550 })[type] || 190; + osc.frequency.setValueAtTime(frequency, start); osc.frequency.exponentialRampToValueAtTime(type === 'jump' || type === 'cast' ? frequency * 2.5 : frequency * .22, start + duration); + gain.gain.setValueAtTime(.001, start); gain.gain.exponentialRampToValueAtTime(heavy ? .32 : .12, start + .008); gain.gain.exponentialRampToValueAtTime(.001, start + duration); + osc.connect(gain); gain.connect(master); osc.start(start); osc.stop(start + duration + .02); audioSources++; + if (type !== 'jump' && type !== 'click') { const buffer = audio.createBuffer(1, Math.floor(audio.sampleRate * duration), audio.sampleRate), data = buffer.getChannelData(0); for (let i = 0; i < data.length; i++) data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / data.length, 2); const source = audio.createBufferSource(), filter = audio.createBiquadFilter(), noiseGain = audio.createGain(); source.buffer = buffer; filter.type = 'bandpass'; filter.frequency.value = heavy ? 430 : 1800; filter.Q.value = .7; noiseGain.gain.value = heavy ? .35 : .14; source.connect(filter); filter.connect(noiseGain); noiseGain.connect(master); source.start(start); audioSources++; } + } + + function menuSound(kind='move') { + unlockAudio();if(!audio || muted)return; + // pick: the lock-in chime when a fighter is confirmed; blink: the soft tick of each following flash. + const notes={move:[580,820],confirm:[440,660,880],back:[440,300],locked:[155,125],round:[330,440],fight:[440,880,1320],pick:[523,784,1047,1568],blink:[1175]}[kind]||[580]; + const gap=kind==='pick'?.045:.035,peak=kind==='blink'?.03:kind==='pick'?.085:.075,tail=kind==='pick'?.24:.095; + for(const [i,n] of notes.entries()){const at=audio.currentTime+i*gap,o=audio.createOscillator(),g=audio.createGain();o.type=kind==='locked'||kind==='pick'?'triangle':'sine';o.frequency.setValueAtTime(n,at);g.gain.setValueAtTime(.001,at);g.gain.exponentialRampToValueAtTime(peak,at+.005);g.gain.exponentialRampToValueAtTime(.001,at+tail);o.connect(g);g.connect(master);o.start(at);o.stop(at+tail+.015);audioSources++;} + } + function setMenuOpen(value) { + menuOpen=!!value;clearInput();if(menuOpen){systemAnnouncer?.clear();stopUltimateVoice();squad=enemySquad=parade=enemyParade=flock=enemyFlock=serpent=enemySerpent=quake=enemyQuake=finale=enemyFinale=skyfall=enemySkyfall=orrery=enemyOrrery=sunroar=enemySunroar=delivery=enemyDelivery=tortoise=enemyTortoise=null;fenrCutin=null;projectiles.length=effects.length=0;setPaused(false);} + document.body.classList.toggle('in-menu',menuOpen);$('#front-menu').hidden=!menuOpen; + window.FrontEnd?.syncBackground(); + } + function playable() { return ['arco','fenr',...Object.keys(KITS)]; } + function startMatch(options={}) { + if(!ready||!Rules)return false; + const {player='arco',enemy='fenr',stage='bellora',level='medium',mode='versus'}=options; + if(!playable().includes(player)||!playable().includes(enemy)||!Object.hasOwn(Rules.stages,stage)||!Object.hasOwn(Rules.difficulties,level)||!['versus','training'].includes(mode))return false; + if(!images['stage-'+stage])return false; + selectedCharacter=player;opponentCharacter=enemy;stageId=stage;difficulty=level;images.stage=images['stage-'+stage];CONFIG.groundY=Rules.stages[stage].groundY; + aiEnabled=mode==='versus';match=Rules.create(mode);roundCues.length=0;setMenuOpen(false);resetCombat();warmCutins(); + $('#ai-toggle').checked=aiEnabled;$('#character-select').value=selectedCharacter;document.body.classList.toggle('in-versus',mode==='versus'); + if(mode==='versus'){announceTimer=0;emitRoundCue('round',1);}else announce('TRAINING READY',1.3); + return true; + } + function emitRoundCue(cue,round) { + const event={cue,round,text:cue==='round'?`Round ${round}`:'Fight!'};roundCues.push(event);if(roundCues.length>12)roundCues.shift(); + if(audio)menuSound(cue);if(roundAnnouncer)try{roundAnnouncer(event);}catch(_){} + systemAnnouncer?.play(cue==='round'?`round_${round}`:'fight',{replace:cue==='round'}); + } + function advanceMatch(dt) { + if(!Rules||!match||match.mode!=='versus')return; + for(const event of Rules.tick(match,dt,hero.hp,currentDummyHP())) { + if(event.type==='cue')emitRoundCue(event.cue,event.round); + if(event.type==='round-end') { + clearInput();stopUltimateVoice();squad=enemySquad=parade=enemyParade=flock=enemyFlock=serpent=enemySerpent=quake=enemyQuake=finale=enemyFinale=skyfall=enemySkyfall=orrery=enemyOrrery=sunroar=enemySunroar=delivery=enemyDelivery=tortoise=enemyTortoise=null;fenrCutin=null;hero.action=dummy.action=null;projectiles.length=effects.length=0;hitstop=cinematic=trauma=0; + if(hero.hp<=0)state('down');else state('idle');if(currentDummyHP()>0){dummy.state='idle';dummy.stateTime=0;} + match.endText=event.doubleKO?'DOUBLE K.O.':event.timeout?'TIME UP':'K.O.';announceTimer=0;sound('heavy'); + const call=event.doubleKO?'double_ko':event.timeout?'time_up':'ko'; + const outcome=event.winner==='draw'?'draw':`${event.winner==='player'?selectedCharacter:opponentCharacter}_wins`; + const speech=window.ANNOUNCER_MANIFEST?.clips; + if(speech)match.koDuration=Math.max(2.2,(speech[call]?.duration||0)+(speech[outcome]?.duration||0)+.5); + systemAnnouncer?.play([call,outcome]); + } + if(event.type==='next-round'){resetCombat();announceTimer=0;emitRoundCue('round',event.round);} + if(event.type==='complete')window.FrontEnd?.showResult({winner:match.winner,playerWins:match.playerWins,enemyWins:match.enemyWins,player:selectedCharacter,enemy:opponentCharacter,stage:stageId,level:difficulty,mode:'versus'}); + } + } + function updateMatchHud() { + if(!Rules)return; + const versus=match?.mode==='versus',s=Rules.stages[stageId]; + $('#mode-label').textContent=versus?`ROUND ${match.round} / 3`:'FREE TRAINING';$('#match-clock').textContent=versus?String(Math.ceil(match.seconds)).padStart(2,'0'):'∞'; + $('.match-center').setAttribute('aria-label',versus?'Pertandingan, menang dua ronde dari maksimal tiga':'Mode latihan tanpa batas waktu'); + $('#match-score').textContent=versus?`${match.playerWins} — ${match.enemyWins}`:'VS';$('#stage-name').textContent=s.short;$('#stage-subtitle').textContent=s.subtitle; + $('#round-wins-player').textContent=versus?'◆'.repeat(match.playerWins)+'◇'.repeat(2-match.playerWins):'';$('#round-wins-enemy').textContent=versus?'◆'.repeat(match.enemyWins)+'◇'.repeat(2-match.enemyWins):''; + const card=$('#round-call'),show=!menuOpen&&versus&&['intro','ko'].includes(match.phase); + card.hidden=!show;card.dataset.phase=match?.phase||'fight'; + $('#round-call-title').textContent=!show?'':match.phase==='intro'?(match.fightCue?'FIGHT':`ROUND ${match.round}`):match.endText||'K.O.'; + $('#round-call-detail').textContent=match?.phase==='ko'?(match.lastWinner==='draw'?'DRAW · ROUND REPLAY':(match.lastWinner==='player'?selectedCharacter:opponentCharacter).toUpperCase()+' WINS THE ROUND'):'FIRST TO TWO WINS'; + canvas.dataset.mode=match?.mode||'training';canvas.dataset.matchPhase=match?.phase||'fight';canvas.dataset.round=String(match?.round||1);canvas.dataset.opponent=opponentCharacter;canvas.dataset.difficulty=difficulty;canvas.dataset.stage=stageId; + } + + function canAct() { return ready && !menuOpen && (!match || match.phase === 'fight') && !paused && !settingsOpen && hero.hurtTime <= 0 && hero.hp > 0; } + function jump(second = false) { + if (!canAct()) return false; + // A basic attack never locks the player: after its hit a jump cancels the recovery; pressed during the wind-up it is + // kept and happens the moment the hit lands (the swing still connects). + if (hero.action?.type === 'attack') { if (!hero.action.fired) { hero.action.cancelInto = 'jump'; hero.action.queued = 0; return true; } hero.action = null; } + if (hero.action) return false; + if (second) { + if (hero.jumps >= 2) return false; + hero.jumps = 2; hero.vy = -CONFIG.doubleJumpSpeed; hero.grounded = false; state('doublejump'); + effects.push({ type: 'airpulse', x: hero.x, y: hero.y - 8, life: .5, maxLife: .5 }); spawnParticles(hero.x, hero.y - 7, '#b3fff2', 22, 190); sound('cast'); + hero.rollDuration = CONFIG.rollDuration; + hero.doublePose = hero.rollDuration; hero.airTime = 0; return true; + } + if (!hero.grounded && hero.jumps >= 2) return false; + if (!hero.grounded) return jump(true); + hero.jumps = 1; hero.vy = -CONFIG.jumpSpeed; hero.grounded = false; hero.airTime = 0; state('jump'); dust(hero.x, 12); sound('jump'); return true; + } + function startAttack(index = 1) { + if (!canAct()) return false; + if (hero.doublePose > 0) { hero.rollBuffer = 'attack'; return true; } + if (hero.action) { if (hero.action.type === 'attack' && hero.action.index < 3) { hero.action.queued = Math.min(3 - hero.action.index, hero.action.queued + 1); hero.action.cancelInto = null; } return false; } + const name = `attack${index}`, duration = selectedCharacter === 'fenr' ? F.move(hero,'attack',index).duration : KITS[selectedCharacter] ? KITS[selectedCharacter].move('attack',index).duration : animationDuration(name, [.39, .43, .57][index - 1]); + hero.action = { type: 'attack', index, name, t: 0, duration, hit: false, queued: 0, fired: false }; + hero.runDirection = 0; state(name); return true; + } + // Skills cancel a basic attack at any point, so the kit never waits for the combo to finish. A skill that cannot start + // (cooldown, summon still out) leaves the attack untouched. + function cast(name) { + const basic = hero.action?.type === 'attack' ? hero.action : null; + if (basic && canAct() && (hero.cooldowns[name] || 0) <= 0) hero.action = null; + const ok = castMove(name); + if (!ok && basic && !hero.action) { hero.action = basic; state(basic.name); } + return ok; + } + function castMove(name) { + if(selectedCharacter === 'fenr') { + if(!canAct() || hero.action || !Object.hasOwn(cooldownMax,name))return false; + if(hero.doublePose>0){hero.rollBuffer=name;return true;} + if(!F.start(hero,name))return false; + hero.animTime=0;hero.runDirection=0; + if(name==='ultimate')showFenrTransformation(hero); + sound(name==='ultimate'?'ultimate':'cast');return true; + } + if(KITS[selectedCharacter]) { + if(!canAct() || hero.action || !Object.hasOwn(cooldownMax,name) || (name==='ultimate'&&(parade||flock||serpent||quake||finale||skyfall||orrery||sunroar||delivery||tortoise)))return false; + if(hero.doublePose>0){hero.rollBuffer=name;return true;} + if(!KITS[selectedCharacter].start(hero,name))return false; + hero.animTime=0;hero.runDirection=0; + if(name==='ultimate')startSummon(hero); + sound(name==='ultimate'?'ultimate':'cast');return true; + } + if (!canAct() || hero.action || hero.cooldowns[name] > 0 || !Object.hasOwn(cooldownMax, name) || (name === 'ultimate' && squad)) return false; + if (hero.doublePose > 0) { hero.rollBuffer = name; return true; } + hero.cooldowns[name] = cooldownMax[name]; hero.runDirection = 0; + const fallback = { skill1: .66, skill2: .85, ultimate: 1.9 }[name]; + hero.action = { type: name, name, t: 0, duration: name === 'ultimate' ? BALANCE.ultimate.castTime : animationDuration(name, fallback), fired: false, hit: false }; + state(name); sound(name === 'ultimate' ? 'ultimate' : 'cast'); + if (name === 'ultimate') startSquadron(); + return true; + } + function press(key, fromTest = false) { + key = key.toLowerCase(); if (!fromTest) unlockAudio(); + if(menuOpen) {if(key==='escape'&&settingsOpen)closeSettings();return;} + if (key === 'escape') { if (settingsOpen) closeSettings(); else setPaused(!paused); return; } + if (key === 'r') { reset(); return; } + if (!canAct() || keys.has(key)) return; + keys.add(key); + if (key === 'a' || key === 'd') { const direction = key === 'a' ? -1 : 1; if (time - taps[key] <= CONFIG.doubleTapWindow) hero.runDirection = direction; else if (hero.runDirection !== direction) hero.runDirection = 0; taps[key] = time; taps[key === 'a' ? 'd' : 'a'] = -10; } + // A new left/right press cuts a basic attack short: right away after its hit, or as soon as the hit lands. + if ((key === 'a' || key === 'd') && hero.action?.type === 'attack') { if (hero.action.fired) { hero.action = null; state(hero.grounded ? 'idle' : 'jump'); } else { hero.action.cancelInto = 'move'; hero.action.queued = 0; } } + if (key === 'w') jump(); + if (key === 's') { if (time - taps.s <= CONFIG.doubleTapWindow) jump(true); taps.s = time; } + if (key === ' ') startAttack(); + if (key === 'i') cast('skill1'); if (key === 'o') cast('skill2'); if (key === 'p') cast('ultimate'); + } + function release(key) { key = key.toLowerCase(); keys.delete(key); if ((key === 'a' && hero.runDirection === -1) || (key === 'd' && hero.runDirection === 1)) hero.runDirection = 0; } + function clearInput() { keys.clear(); hero.runDirection = 0; taps.a = taps.d = taps.s = -10; } + function resetCombat() { + systemAnnouncer?.clear(); + stopUltimateVoice(); + Object.assign(hero, { x: 350, y: CONFIG.groundY, vx: 0, vy: 0, facing: 1, grounded: true, jumps: 0, state: 'idle', animTime: 0, walkPhase: 0, smokeDistance:0, action: null, runDirection: 0, hp: BALANCE.heroMax, koTime:0, invuln: 0, hurtTime: 0, doublePose: 0, rollDuration: CONFIG.rollDuration, rollBuffer: null, airTime: 0, cooldowns: { skill1: 0, skill2: 0, ultimate: 0 } }); + Object.assign(dummy, { x: 915, y: CONFIG.groundY, vx: 0, vy: 0, state: 'idle', stateTime: 0, damage: 0, ko:false, flash: 0, invuln: 0, angle: 0 }); + if(F) {F.reset(hero);F.reset(dummy);Object.assign(dummy,{facing:-1,action:null,cooldowns:{skill1:0,skill2:0,ultimate:0},aiThink:.4,aiTime:0,aiChain:0,walkPhase:0,jumps:0});cpu={seen:new WeakMap(),decided:new WeakSet(),read:null,heroActionAt:0,heroAction:null,airborne:false,airAt:0,antiAirRead:false,plan:null,doubleAt:0};syncPlayerForm();} + fenrCutin=null;hudIdentity=''; + particles.length = effects.length = projectiles.length = damageNumbers.length = 0; totalDamage = comboHits = bestCombo = comboDamage = comboTimer = hitstop = trauma = cinematic = 0; clearInput(); setPaused(false); announce('TRAINING READY', 1.25); + squad = null; enemySquad = null; parade = null; enemyParade = null; flock = null; enemyFlock = null; serpent = null; enemySerpent = null; quake = null; enemyQuake = null; finale = null; enemyFinale = null; skyfall = null; enemySkyfall = null; orrery = null; enemyOrrery = null; sunroar = null; enemySunroar = null; delivery = null; enemyDelivery = null; tortoise = null; enemyTortoise = null; + for(const name of Object.keys(rechargePulse)) rechargePulse[name]=0; + } + function reset() { + if(Rules)match=Rules.create(match?.mode||'training'); + resetCombat();if(match?.mode==='versus'){announceTimer=0;emitRoundCue('round',1);} + } + function hitDummy(damage, power, color, sourceX, impactY, knockdown = false, freezeWorld = true, shake = 1) { + if (menuOpen || (match && match.phase!=='fight') || dummy.invuln > 0 || dummy.state === 'down' || dummy.state === 'recover') return false; + if (dummy.action?.guard && shellGuard(dummy)) return false; + const dir = sourceX <= dummy.x ? 1 : -1; + dummy.damage += damage; totalDamage += damage; comboHits++; bestCombo = Math.max(bestCombo, comboHits); comboDamage += damage; comboTimer = 2.3; + // Versus combo limit: after comboCap hits in one continuous stun the CPU gets its immunity (like the player's), + // so a re-started chain cannot loop it to K.O. Easy has no limit. + const cap = F && aiEnabled && match?.mode === 'versus' ? cpuProfile().comboCap : 0; + dummy.stunHits = dummy.state === 'hurt' ? (dummy.stunHits || 0) + 1 : 1; + if (cap && dummy.stunHits >= cap) dummy.invuln = .5 + cpuProfile().immunity; + dummy.flash = 0; dummy.vx = dir * power; dummy.stateTime = 0; dummy.state = 'hurt'; + dummy.action=null; + if(F&&dummy.transformPending>0)F.end(dummy); + if (freezeWorld) hitstop = Math.max(hitstop, damage >= 30 ? .085 : .045); + trauma = Math.min(1, trauma + (damage >= 30 ? .45 : .18) * shake); + damageNumbers.push({ x: dummy.x + (random() - .5) * 20, y: impactY - 20, amount: damage, life: .9, maxLife: .9, color }); + spawnParticles(dummy.x - dir * 21, impactY, color, damage >= 30 ? 22 : 14, damage >= 30 ? 320 : 220); + effects.push({ type: 'impact', x: dummy.x - dir * 15, y: impactY, life: .2, maxLife: .2, color, heavy: damage >= 30 }); + if (dummy.damage >= BALANCE.dummyMax || knockdown) { dummy.ko=dummy.damage>=BALANCE.dummyMax; dummy.state = 'down'; dummy.vy = -330; dummy.vx = dir * 290; dummy.fallDirection = dir; announce(dummy.ko?'K.O.':'KNOCKDOWN', .9); } + sound(damage >= 30 ? 'heavy' : 'hit'); return true; + } + function attackRange(index) { + if(selectedCharacter==='fenr')return F.move(hero,'attack',index).reach; + if(KITS[selectedCharacter])return KITS[selectedCharacter].move('attack',index).reach; + const name = `attack${index}`, data = manifest?.combat?.hitRanges?.[name]; + const measured = metrics?.states?.[name]?.frames; + return typeof data === 'number' ? data : data?.reach || (measured?.length ? Math.max(...measured.map(f => f.bounds.right)) : [122, 143, 176][index - 1]); + } + function emitter(name) { + const measured = metrics?.emitters?.[name]; + if (measured && Number.isFinite(measured.x) && Number.isFinite(measured.y)) return { x: hero.x + hero.facing * measured.x, y: hero.y + measured.y }; + // Fallbacks measured on the first atlas, not inferred from the cell dimensions. + const fallback = { attack1: [61, -105], attack2: [27, -157], attack3: [47, -102], skill1: [89, -113], skill2: [30, 0], ultimate: [88, -93] }[name] || [50, -100]; + return { x: hero.x + hero.facing * fallback[0], y: hero.y + fallback[1] }; + } + function meleeHit(index) { + if(selectedCharacter==='fenr'){fenrStrike(hero,F.move(hero,'attack',index));return;} + if(selectedCharacter==='mira'){miraStrike(hero,M.move('attack',index));return;} + if(selectedCharacter==='cora'){coraStrike(hero,C.move('attack',index));return;} + if(selectedCharacter==='naja'){najaStrike(hero,N.move('attack',index));return;} + if(selectedCharacter==='haldor'){haldorStrike(hero,HD.move('attack',index));return;} + if(selectedCharacter==='zanni'){zanniStrike(hero,Z.move('attack',index));return;} + if(selectedCharacter==='isolde'){isoldeStrike(hero,IS.move('attack',index));return;} + if(selectedCharacter==='rhea'){rheaStrike(hero,RH.move('attack',index));return;} + if(selectedCharacter==='solan'){solanStrike(hero,SO.move('attack',index));return;} + if(selectedCharacter==='nib'){nibStrike(hero,NB.move('attack',index));return;} + if(selectedCharacter==='edda'){eddaStrike(hero,ED.move('attack',index));return;} + const reach = attackRange(index), dx = (dummy.x - hero.x) * hero.facing, y = emitter(`attack${index}`).y; + effects.push({ type: 'slash', x: hero.x + hero.facing * 48, y: y + 5, life: .22, maxLife: .22, facing: hero.facing, index, color: index === 3 ? '#ffe4a5' : '#cffbf0' }); + if (dx > -24 && dx < reach + 27 && Math.abs(dummy.y - hero.y) < 113) { + const connected = hitDummy(BALANCE.combo[index-1].damage, BALANCE.combo[index-1].knockback, index === 3 ? '#ffdf92' : '#c6fff0', hero.x, y); + if (connected) for (const name of Object.keys(cooldownMax)) { + if(hero.cooldowns[name]>0) rechargePulse[name]=.22; + hero.cooldowns[name] = Math.max(0, hero.cooldowns[name] - cooldownMax[name] * BALANCE.basicCooldownRefund); + } + } + else sound('step'); + } + function fireSkill(action) { + if(selectedCharacter==='fenr'){fenrStrike(hero,action);return;} + if(selectedCharacter==='mira'){miraStrike(hero,action);return;} + if(selectedCharacter==='cora'){coraStrike(hero,action);return;} + if(selectedCharacter==='naja'){najaStrike(hero,action);return;} + if(selectedCharacter==='haldor'){haldorStrike(hero,action);return;} + if(selectedCharacter==='zanni'){zanniStrike(hero,action);return;} + if(selectedCharacter==='isolde'){isoldeStrike(hero,action);return;} + if(selectedCharacter==='rhea'){rheaStrike(hero,action);return;} + if(selectedCharacter==='solan'){solanStrike(hero,action);return;} + if(selectedCharacter==='nib'){nibStrike(hero,action);return;} + if(selectedCharacter==='edda'){eddaStrike(hero,action);return;} + const name = action.type, origin = emitter(name); + if (name === 'skill1') { projectiles.push({ x: origin.x, y: origin.y, vx: hero.facing * 920, life: 1.6, facing: hero.facing }); effects.push({ type: 'muzzle', x: origin.x, y: origin.y, life: .25, maxLife: .25 }); spawnParticles(origin.x, origin.y, '#a9f8ef', 12, 140); } + if (name === 'skill2') { const x = origin.x; effects.push({ type: 'slam', x, y: CONFIG.groundY - 2, life: .8, maxLife: .8 }); spawnParticles(x, CONFIG.groundY - 8, '#c7eafd', 40, 370); trauma = Math.min(1, trauma + .35); sound('heavy'); if (Math.abs(dummy.x - x) < BALANCE.skill2.radius && dummy.y > CONFIG.groundY - 125) hitDummy(BALANCE.skill2.damage, BALANCE.skill2.knockback, '#bfe9ff', hero.x, dummy.y - 65); } + } + function startSquadron(actor = hero) { + const target=actor===hero?dummy:hero; + const centerX = clamp(actor.x, 220, W - 220), baseY = clamp(actor.y, 480, CONFIG.groundY); + const offsets = [[-150,-240],[-55,-310],[65,-305],[160,-235]]; + const group = { t: 0, owner:actor===hero?'player':'enemy', facing: actor.facing, casterX: actor.x, targeted: (target.x - actor.x) * actor.facing >= 0, + shots: 0, phase: 'cutin', drones: offsets.map(([dx,dy], i) => ({ id: i, x: centerX + actor.facing * (dx - 360), y: -130 - i * 55, + startX: centerX + actor.facing * (dx - 360), startY: -130 - i * 55, targetX: centerX + actor.facing * dx, + targetY: baseY + dy, delay: .46 + i * .10, fired: false, visible: false, alpha: 1, angle: 0 })) }; + if(actor===hero)squad=group;else enemySquad=group; + cinematic = .9; announceTimer = 0; + startUltimateVoice('arco',actor); + } + function updateSquadron(dt, s = squad) { + if (!s) return; + const target=s.owner==='enemy'?hero:dummy;s.t += dt; + s.phase = s.t < SQUAD.cutinDuration ? 'cutin' : s.t < SQUAD.firstShot ? 'lock-on' : s.t < SQUAD.departure ? 'firing' : 'departing'; + for (const d of s.drones) { + const entry = clamp((s.t - d.delay) / .60, 0, 1), ease = 1 - Math.pow(1 - entry, 3); + d.visible = s.t >= d.delay; + d.x = d.startX + (d.targetX - d.startX) * ease; + d.y = d.startY + (d.targetY - d.startY) * ease; + if (entry === 1) d.y += Math.sin((s.t - d.delay) * 5 + d.id) * 2; + if (s.t > SQUAD.departure) { const exit = clamp((s.t - SQUAD.departure) / .6, 0, 1); d.x += s.facing * exit * exit * 280; d.y -= exit * exit * 470; d.alpha = 1 - exit; } + const targetX = s.targeted ? target.x : (s.facing === 1 ? W + 50 : -50); + const targetY = s.targeted ? target.y - 85 : CONFIG.groundY - 90; + d.angle = s.facing * Math.atan2(targetY - d.y, Math.abs(targetX - d.x)); + if (!d.fired && s.t >= SQUAD.firstShot + d.id * SQUAD.shotGap) { + d.fired = true; s.shots++; + const origin = droneMuzzle(d, s.facing); + effects.push({ type: 'drone-laser', owner:s.owner, x: origin.x, y: origin.y, endX: targetX, endY: targetY, life: .65, maxLife: .65, droneId: d.id }); + effects.push({ type: 'muzzle', x: origin.x, y: origin.y, life: .19, maxLife: .19, color: '#fff0be' }); + spawnParticles(origin.x, origin.y, '#a8fff0', 8, 90); + trauma = Math.max(trauma, d.id === 3 ? .5 : .23); sound('cast'); + if (s.targeted && (target.x - s.casterX) * s.facing >= -30) { + if(s.owner==='enemy')window.__game.receiveHit(BALANCE.ultimate.damagePerDrone,{freeze:false,projectile:true}); + else hitDummy(BALANCE.ultimate.damagePerDrone, d.id === 3 ? BALANCE.ultimate.knockback : 45, '#ffe2a0', s.casterX, targetY, false, false); + } + } + } + for (const e of effects) { + if (e.type !== 'drone-laser'||e.owner!==s.owner) continue; + const origin = droneMuzzle(s.drones[e.droneId], s.facing); + e.x = origin.x; e.y = origin.y; + if (s.targeted) { e.endX = target.x; e.endY = target.y - 85; } + } + if (s.t >= SQUAD.duration) { if(s.owner==='enemy')enemySquad=null;else squad=null;if (voiceActive && voiceKind === 'arco' && (voiceActor===dummy)===(s.owner==='enemy')) stopUltimateVoice(); } + } + // MIRA Rocket Parade: 12 rockets from alternating pods, one 4-damage hit each (48 total), no global hit-stop. + function startRocketParade(actor = hero) { + const target = actor === hero ? dummy : hero, P = M.parade; + const group = { t: 0, owner: actor === hero ? 'player' : 'enemy', facing: actor.facing, casterX: actor.x, + targeted: (target.x - actor.x) * actor.facing >= 0, hits: 0, launched: 0, exploded: 0, phase: 'cutin', + rockets: Array.from({ length: M.balance.rockets }, (_, i) => ({ id: i, pod: i % 2, launchAt: P.launchStart + i * P.launchGap, + impactAt: P.impactStart + i * P.impactGap, spread: ((i * 7) % 11 - 5) * 9, lift: 250 + ((i * 5) % 4) * 38, + state: 'loaded', locked: false, x: 0, y: 0, sx: 0, sy: 0, tx: 0, ty: 0, angle: 0 })) }; + if (actor === hero) parade = group; else enemyParade = group; + cinematic = .9; announceTimer = 0; + startUltimateVoice('mira', actor); + } + function paradeAim(s, r, target) { + if (s.targeted) return { x: target.x + r.spread, y: target.y - 70 - Math.abs(r.spread) * .6 }; + return { x: clamp(s.casterX + s.facing * (380 + r.id * 22), 40, W - 40), y: CONFIG.groundY - 14 }; + } + function updateParade(dt, s) { + if (!s) return; + const actor = s.owner === 'enemy' ? dummy : hero, target = s.owner === 'enemy' ? hero : dummy, P = M.parade; + const pods = window.MIRA_METRICS?.emitters?.ultimatePods?.pods || [{ x: -34, y: -168 }, { x: 71, y: -147 }]; + s.t += dt; + for (const r of s.rockets) { + if (r.state === 'loaded' && s.t >= r.launchAt) { + // Rockets leave the pods where the robot stands now; the salvo keeps its own cast direction and target lock. + const pod = pods[r.pod % pods.length]; + r.state = 'flying'; r.sx = r.x = actor.x + actor.facing * pod.x; r.sy = r.y = actor.y + pod.y; s.launched++; + effects.push({ type: 'muzzle', x: r.sx, y: r.sy, life: .16, maxLife: .16, color: '#ffe38a' }); + if (r.id % 3 === 0) sound('cast'); + } + if (r.state !== 'flying') continue; + const u = clamp((s.t - r.launchAt) / (r.impactAt - r.launchAt), 0, 1); + if (!r.locked) { const aim = paradeAim(s, r, target); r.tx = aim.x; r.ty = aim.y; r.locked = u >= .8; } + const cx = r.sx + (r.tx - r.sx) * .35, cy = Math.min(r.sy, r.ty) - r.lift, inv = 1 - u; + const nx = inv * inv * r.sx + 2 * inv * u * cx + u * u * r.tx, ny = inv * inv * r.sy + 2 * inv * u * cy + u * u * r.ty; + if (nx !== r.x || ny !== r.y) r.angle = Math.atan2(ny - r.y, nx - r.x); + r.x = nx; r.y = ny; + if ((stepCount + r.id) % 4 === 0) particles.push({ kind: 'dust', x: r.x - Math.cos(r.angle) * 24, y: r.y - Math.sin(r.angle) * 24, vx: -Math.cos(r.angle) * 40, vy: -Math.sin(r.angle) * 40, life: .32, maxLife: .32, size: 3 + random() * 3, color: r.id % 2 ? '#ffc7ea' : '#ffe39a' }); + if (u < 1) continue; + r.state = 'done'; s.exploded++; + effects.push({ type: 'mira-fx', asset: 'mira-burst', x: r.x, y: r.y, facing: 1, life: .34, maxLife: .34, size: 92 }); + spawnParticles(r.x, r.y, '#ffc6ec', 6, 150); + if (s.targeted && Math.abs(target.x - r.x) < 85 && Math.abs(target.y - 90 - r.y) < 115) { + const last = r.id === s.rockets.length - 1; + const connected = s.owner === 'enemy' ? window.__game.receiveHit(M.balance.rocketDamage, { freeze: false, projectile: true, shake: .3 }) + : hitDummy(M.balance.rocketDamage, last ? 190 : 30, '#ffd86b', s.casterX, r.y, false, false, .3); + if (connected) s.hits++; + } + } + s.phase = s.t < P.cutinDuration ? 'cutin' : s.exploded < s.rockets.length ? 'barrage' : 'clearing'; + if (s.t >= P.duration) { if (s.owner === 'enemy') enemyParade = null; else parade = null; } + } + function startSummon(actor) { const id = actor === hero ? selectedCharacter : opponentCharacter; if (id === 'cora') startMurmuration(actor); else if (id === 'naja') startSerpent(actor); else if (id === 'haldor') startQuake(actor); else if (id === 'zanni') startFinale(actor); else if (id === 'isolde') startSkyfall(actor); else if (id === 'rhea') startOrrery(actor); else if (id === 'solan') startSunroar(actor); else if (id === 'nib') startDelivery(actor); else if (id === 'edda') startTortoise(actor); else startRocketParade(actor); } + // CORA Night Murmuration: three raven passes sweep forward from behind her wings at three heights, + // one 16-damage hit each (48 total), no global hit-stop. A pass hits once, when its lead raven crosses the target. + function startMurmuration(actor = hero) { + const U = C.murmuration; + const group = { t: 0, owner: actor === hero ? 'player' : 'enemy', facing: actor.facing, casterX: actor.x, hits: 0, phase: 'cutin', + passes: U.passStart.map((at, id) => ({ id, at, y: CONFIG.groundY + U.passHeights[id], front: actor.x - actor.facing * 140, state: 'waiting', hit: false })) }; + if (actor === hero) flock = group; else enemyFlock = group; + cinematic = .9; announceTimer = 0; + startUltimateVoice('cora', actor); + } + function updateMurmuration(dt, s) { + if (!s) return; + const target = s.owner === 'enemy' ? hero : dummy, U = C.murmuration, tail = (U.ravens - 1) * U.spacing; + s.t += dt; + for (const p of s.passes) { + if (p.state === 'waiting' && s.t >= p.at) { p.state = 'flying'; sound('cast'); } + if (p.state !== 'flying') continue; + const old = p.front; p.front += s.facing * U.speed * dt; + if (!p.hit && (target.x - old) * s.facing >= 0 && (target.x - p.front) * s.facing < 0) { + // Only a target in front of the cast whose body overlaps this pass height is struck; a jump can clear the low pass. + p.hit = true; + if ((target.x - s.casterX) * s.facing >= -30 && p.y > target.y - 200 && p.y < target.y + 20) { + const connected = s.owner === 'enemy' ? window.__game.receiveHit(C.balance.passDamage, { freeze: false, projectile: true, shake: .6 }) + : hitDummy(C.balance.passDamage, p.id === s.passes.length - 1 ? 190 : 60, '#d9c2ff', s.casterX, p.y, false, false, .6); + if (connected) { s.hits++; spawnParticles(target.x, p.y, '#3a2d58', 10, 180); } + } + } + if ((stepCount + p.id) % 5 === 0) particles.push({ kind: 'dust', x: p.front - s.facing * random() * tail, y: p.y + (random() - .5) * 40, vx: -s.facing * 30, vy: 30, life: .5, maxLife: .5, size: 2 + random() * 2, color: '#3a2d58' }); + const tailX = p.front - s.facing * tail; + if (s.facing > 0 ? tailX > W + 80 : tailX < -80) p.state = 'done'; + } + s.phase = s.t < U.cutinDuration ? 'cutin' : s.passes.some(p => p.state !== 'done') ? 'sweeping' : 'clearing'; + if (s.t >= U.duration) { if (s.owner === 'enemy') enemyFlock = null; else flock = null; } + } + // NAJA Dune Serpent: a sand ripple leaves her palm, chases the rival and locks `telegraph` seconds before each strike, then a giant + // sand cobra erupts there. Three strikes x 16 (48), no global hit-stop. Each ripple starts tracking when the previous + // one locks. The column is too tall to jump; stepping out of the locked ripple dodges it. + function startSerpent(actor = hero) { + const U = N.serpent, target = actor === hero ? dummy : hero; + const group = { t: 0, owner: actor === hero ? 'player' : 'enemy', facing: actor.facing, casterX: actor.x, hits: 0, phase: 'cutin', + strikes: U.strikes.map((at, id) => ({ id, at, lockAt: at - U.telegraph, appearAt: id ? U.strikes[id - 1] - U.telegraph : U.follow, + x: id ? target.x : clamp(actor.x + actor.facing * 90, 60, W - 60), state: 'waiting', lockedAt: 0, hit: false })) }; + if (actor === hero) serpent = group; else enemySerpent = group; + cinematic = .9; announceTimer = 0; + startUltimateVoice('naja', actor); + } + function updateSerpent(dt, s) { + if (!s) return; + const target = s.owner === 'enemy' ? hero : dummy, U = N.serpent; + s.t += dt; + for (const k of s.strikes) { + if (k.state === 'waiting' && s.t >= k.appearAt) { k.state = 'tracking'; if (k.id) k.x = s.strikes[k.id - 1].x; } + if (k.state === 'tracking') { + k.x = clamp(approach(k.x, target.x, U.speed * dt), 60, W - 60); + if (s.t >= k.lockAt) { k.state = 'locked'; k.lockedAt = time; sound('step'); } + } + if (k.state === 'locked' && s.t >= k.at) { + k.state = 'erupting'; trauma = Math.min(1, trauma + .22); sound('heavy'); + spawnParticles(k.x, CONFIG.groundY - 12, '#e3bf78', 22, 300, 'dust'); spawnParticles(k.x, CONFIG.groundY - 60, '#f6dc9c', 12, 260); + if (!k.hit && Math.abs(target.x - k.x) < U.radius && CONFIG.groundY - target.y < U.height) { + k.hit = true; + const last = k.id === s.strikes.length - 1; + const connected = s.owner === 'enemy' ? window.__game.receiveHit(N.balance.biteDamage, { freeze: false, projectile: true, shake: .6 }) + : hitDummy(N.balance.biteDamage, last ? 190 : 60, '#ffd98a', s.casterX, target.y - 100, false, false, .6); + if (connected) s.hits++; + } + } + if (k.state === 'erupting' && s.t >= k.at + U.rise) k.state = 'done'; + } + s.phase = s.t < U.cutinDuration ? 'cutin' : s.strikes.some(k => k.state !== 'done') ? 'striking' : 'clearing'; + if (s.t >= U.duration) { if (s.owner === 'enemy') enemySerpent = null; else serpent = null; } + } + // HALDOR Forge Quake: three hammer slams at the crater in front of him; each sends a molten shockwave along the + // floor both ways (ground projectiles, 16 each, 48 total). HALDOR is free after the 0.5 s cast; a jump clears a wave. + function startQuake(actor = hero) { + const group = { t: 0, owner: actor === hero ? 'player' : 'enemy', facing: actor.facing, x: clamp(actor.x + actor.facing * 70, 60, W - 60), slams: 0, phase: 'cutin' }; + if (actor === hero) quake = group; else enemyQuake = group; + cinematic = .9; announceTimer = 0; + startUltimateVoice('haldor', actor); + } + function updateQuake(dt, s) { + if (!s) return; + const U = HD.quake; + s.t += dt; + while (s.slams < U.slams.length && s.t >= U.slams[s.slams]) { + const last = s.slams === U.slams.length - 1; s.slams++; + effects.push({ type: 'haldor-fx', asset: 'haldor-splash', x: s.x, y: CONFIG.groundY - 24, facing: s.facing, life: .35, maxLife: .35, size: 170 }); + spawnParticles(s.x, CONFIG.groundY - 10, '#ff8a2a', 20, 320); dust(s.x, 16); trauma = Math.min(1, trauma + .3); sound('heavy'); + for (const dir of [-1, 1]) projectiles.push({ x: s.x + dir * 30, y: CONFIG.groundY - 22, vx: dir * U.speed, life: 1.9, facing: dir, owner: s.owner, damage: HD.balance.quakeDamage, knockback: last ? 190 : 60, asset: 'haldor-quake', size: 124, color: '#ffb35c' }); + } + s.phase = s.t < U.cutinDuration ? 'cutin' : s.slams < U.slams.length ? 'slamming' : 'rolling'; + if (s.t >= U.duration) { if (s.owner === 'enemy') enemyQuake = null; else quake = null; } + } + // ZANNI Grand Finale: three giant bladed rings flung forward from his hand 0.38 s apart; each flies to the arena edge + // and boomerangs back to ZANNI (boomerang projectiles, one 16-damage hit per ring, 48 total). ZANNI is free after the + // 0.5 s cast; a rival behind him at the throw is safe, a jump clears a ring on the way out. + function startFinale(actor = hero) { + const group = { t: 0, owner: actor === hero ? 'player' : 'enemy', facing: actor.facing, throws: 0, phase: 'cutin' }; + if (actor === hero) finale = group; else enemyFinale = group; + cinematic = .9; announceTimer = 0; + startUltimateVoice('zanni', actor); + } + function updateFinale(dt, s) { + if (!s) return; + const U = Z.finale, a = s.owner === 'enemy' ? dummy : hero, em = window.ZANNI_METRICS?.emitters?.ultimate; + s.t += dt; + while (s.throws < U.throws.length && s.t >= U.throws[s.throws]) { + const last = s.throws === U.throws.length - 1; s.throws++; + const x = a.x + s.facing * (em?.x ?? 90), y = clamp(a.y + (em?.y ?? -95), a.y - 130, a.y - 70); + projectiles.push({ x, y, vx: s.facing * U.speed, life: 4, facing: s.facing, owner: s.owner, damage: Z.balance.finaleDamage, knockback: last ? 190 : 50, asset: 'zanni-bigring', size: U.size, color: '#e4f36a', + boomerang: true, range: Math.max(120, s.facing > 0 ? W - 60 - x : x - 60), travel: 0, spin: 16, freeze: false }); + effects.push({ type: 'zanni-fx', asset: 'zanni-confetti', x: x + s.facing * 20, y, facing: s.facing, life: .35, maxLife: .35, size: 170 }); + spawnParticles(x, y, '#e4f36a', 14, 260); sound('cast'); + } + s.phase = s.t < U.cutinDuration ? 'cutin' : s.throws < U.throws.length ? 'throwing' : 'returning'; + if (s.t >= U.duration && !projectiles.some(p => p.asset === 'zanni-bigring' && p.owner === s.owner)) { if (s.owner === 'enemy') enemyFinale = null; else finale = null; } + } + // ISOLDE Skyfall Lances: three giant ice lances dive from the sky 0.38 s apart, each aimed at the rival's feet when it + // is called (16 each, 48 total), with a small shatter where it lands. ISOLDE is free after the 0.5 s cast; a standing + // rival takes all three, a moving one makes them land behind. + function startSkyfall(actor = hero) { + const group = { t: 0, owner: actor === hero ? 'player' : 'enemy', facing: actor.facing, calls: 0, phase: 'cutin' }; + if (actor === hero) skyfall = group; else enemySkyfall = group; + cinematic = .9; announceTimer = 0; + startUltimateVoice('isolde', actor); + } + function updateSkyfall(dt, s) { + if (!s) return; + const U = IS.skyfall, target = s.owner === 'enemy' ? hero : dummy; + s.t += dt; + while (s.calls < U.calls.length && s.t >= U.calls[s.calls]) { + const last = s.calls === U.calls.length - 1; s.calls++; + const tx = clamp(target.x, 40, W - 40), land = CONFIG.groundY - 40, x = tx - s.facing * U.back, y = land - U.height; + projectiles.push({ x, y, vx: s.facing * U.back / U.flight, vy: U.height / U.flight, life: U.flight + .3, facing: s.facing, owner: s.owner, damage: IS.balance.lanceDamage, knockback: last ? 190 : 50, + asset: 'isolde-skyfall', size: U.size, color: '#bfe6ff', landY: land, shatter: U.shatter, freeze: false }); + spawnParticles(x, y + 40, '#dff3ff', 8, 160); + } + s.phase = s.t < U.cutinDuration ? 'cutin' : s.calls < U.calls.length ? 'calling' : 'falling'; + if (s.t >= U.duration && !projectiles.some(p => p.asset === 'isolde-skyfall' && p.owner === s.owner)) { if (s.owner === 'enemy') enemySkyfall = null; else skyfall = null; } + } + // Where a Skyfall lance lands: an ice shatter that still catches a rival within `shatter` px. + function lanceShatter(p) { + effects.push({ type: 'isolde-fx', asset: 'isolde-shatter', x: p.x, y: CONFIG.groundY - 40, facing: p.facing, life: .4, maxLife: .4, size: 190 }); + spawnParticles(p.x, CONFIG.groundY - 20, '#dff3ff', 18, 280); dust(p.x, 14); sound('heavy'); trauma = Math.min(1, trauma + .18); + if (p.owner === 'enemy') { if (Math.abs(hero.x - p.x) < p.shatter && hero.y > CONFIG.groundY - 70) window.__game.receiveHit(p.damage, { projectile: true, freeze: false }); } + else if (Math.abs(dummy.x - p.x) < p.shatter && dummy.y > CONFIG.groundY - 70) hitDummy(p.damage, p.knockback, p.color, p.x - p.facing * 20, CONFIG.groundY - 60, false, false); + } + // RHEA Grand Orrery: three giant planets released 0.38 s apart, each making one lap around RHEA (orbit projectiles, + // one 16-damage hit each, 48 total). RHEA is free after the 0.5 s cast; the planets follow her, and a rival beyond + // the orbit is safe. + function startOrrery(actor = hero) { + const group = { t: 0, owner: actor === hero ? 'player' : 'enemy', facing: actor.facing, released: 0, phase: 'cutin' }; + if (actor === hero) orrery = group; else enemyOrrery = group; + cinematic = .9; announceTimer = 0; + startUltimateVoice('rhea', actor); + } + function updateOrrery(dt, s) { + if (!s) return; + const U = RH.orrery, a = s.owner === 'enemy' ? dummy : hero; + s.t += dt; + while (s.released < U.releases.length && s.t >= U.releases[s.released]) { + const last = s.released === U.releases.length - 1; s.released++; + projectiles.push({ x: a.x + s.facing * U.rx, y: a.y - 90, vx: 0, life: U.lap + .2, facing: s.facing, owner: s.owner, damage: RH.balance.planetDamage, knockback: last ? 190 : 40, + asset: 'rhea-planet', size: U.size, color: '#f2c77a', orbit: true, angle: 0, lap: U.lap, rx: U.rx, ry: U.ry, spin: 2, freeze: false }); + effects.push({ type: 'rhea-fx', asset: 'rhea-burst', x: a.x + s.facing * U.rx, y: a.y - 90, facing: s.facing, life: .3, maxLife: .3, size: 150 }); + spawnParticles(a.x, a.y - 90, '#f2c77a', 12, 220); sound('cast'); + } + s.phase = s.t < U.cutinDuration ? 'cutin' : s.released < U.releases.length ? 'releasing' : 'orbiting'; + if (s.t >= U.duration && !projectiles.some(p => p.asset === 'rhea-planet' && p.owner === s.owner)) { if (s.owner === 'enemy') enemyOrrery = null; else orrery = null; } + } + // RHEA Gravity Well collapsing: everything within its radius on the floor takes the hit. + function wellBurst(p) { + effects.push({ type: 'rhea-fx', asset: 'rhea-burst', x: p.x, y: p.y, facing: p.facing, life: .35, maxLife: .35, size: 200 }); + spawnParticles(p.x, p.y, '#e8b0c0', 18, 260); sound('heavy'); trauma = Math.min(1, trauma + .15); + if (p.owner === 'enemy') { if (Math.abs(hero.x - p.x) < p.radius && hero.y > CONFIG.groundY - 150) window.__game.receiveHit(p.damage); } + else if (Math.abs(dummy.x - p.x) < p.radius && dummy.y > CONFIG.groundY - 150) hitDummy(p.damage, p.knockback, p.color, p.x, p.y, false, true); + } + // SOLAN Sunmane Roar: three roars 0.38 s apart from wherever SOLAN stands; each sends a tall sound wave both ways + // along the floor (ground projectiles with `tall`, so a jump does not clear them), 16 each, 48 total, fading after + // SO.roar.range px. SOLAN is free after the 0.5 s cast; a rival farther away than the range is safe. + function startSunroar(actor = hero) { + const group = { t: 0, owner: actor === hero ? 'player' : 'enemy', facing: actor.facing, roars: 0, phase: 'cutin' }; + if (actor === hero) sunroar = group; else enemySunroar = group; + cinematic = .9; announceTimer = 0; + startUltimateVoice('solan', actor); + } + function updateSunroar(dt, s) { + if (!s) return; + const U = SO.roar, a = s.owner === 'enemy' ? dummy : hero; + s.t += dt; + while (s.roars < U.roars.length && s.t >= U.roars[s.roars]) { + const last = s.roars === U.roars.length - 1; s.roars++; + effects.push({ type: 'solan-fx', asset: 'solan-sunburst', x: a.x, y: a.y - 110, facing: s.facing, life: .3, maxLife: .3, size: 160 }); + spawnParticles(a.x, a.y - 95, '#ffd36a', 16, 260); trauma = Math.min(1, trauma + .25); sound('heavy'); + for (const dir of [-1, 1]) projectiles.push({ x: a.x + dir * 40, y: CONFIG.groundY - 110, vx: dir * U.speed, life: U.range / U.speed, facing: dir, owner: s.owner, damage: SO.balance.roarDamage, knockback: last ? 190 : 50, + asset: 'solan-roar', size: U.size, color: '#ffd36a', tall: U.tall, freeze: false }); + } + s.phase = s.t < U.cutinDuration ? 'cutin' : s.roars < U.roars.length ? 'roaring' : 'fading'; + if (s.t >= U.duration && !projectiles.some(p => p.asset === 'solan-roar' && p.owner === s.owner)) { if (s.owner === 'enemy') enemySunroar = null; else sunroar = null; } + } + // NIB Special Delivery: three paper-plane parcels leave the satchel 0.38 s apart and home in on the rival (homing + // projectiles with a limited turn rate, 16 each, 48 total). NIB is free after the 0.5 s cast; a late sidestep or a + // jump makes a plane overshoot. + function startDelivery(actor = hero) { + const group = { t: 0, owner: actor === hero ? 'player' : 'enemy', facing: actor.facing, released: 0, phase: 'cutin' }; + if (actor === hero) delivery = group; else enemyDelivery = group; + cinematic = .9; announceTimer = 0; + startUltimateVoice('nib', actor); + } + function updateDelivery(dt, s) { + if (!s) return; + const U = NB.delivery, a = s.owner === 'enemy' ? dummy : hero; + s.t += dt; + while (s.released < U.releases.length && s.t >= U.releases[s.released]) { + const last = s.released === U.releases.length - 1; s.released++; + const x = a.x + s.facing * 30, y = a.y - 110; + projectiles.push({ x, y, vx: s.facing * U.speed * .6, vy: -U.speed * .8, life: U.life, facing: s.facing, owner: s.owner, damage: NB.balance.planeDamage, knockback: last ? 180 : 40, + asset: 'nib-plane', size: U.size, color: '#8fd4ff', homing: true, speedH: U.speed, turn: U.turn, freeze: false }); + effects.push({ type: 'nib-fx', asset: 'nib-stamp', x, y, facing: s.facing, life: .3, maxLife: .3, size: 130 }); + spawnParticles(x, y, '#f4efe2', 10, 200); sound('cast'); + } + s.phase = s.t < U.cutinDuration ? 'cutin' : s.released < U.releases.length ? 'releasing' : 'flying'; + if (s.t >= U.duration && !projectiles.some(p => p.asset === 'nib-plane' && p.owner === s.owner)) { if (s.owner === 'enemy') enemyDelivery = null; else delivery = null; } + } + // EDDA Elder Tortoise: a giant jade tortoise spirit rises in front of EDDA, lumbers forward and stomps three times + // 0.38 s apart (16 each, 48). A stomp only hits a rival on the floor within ED.tortoise.half px of the spirit. + function startTortoise(actor = hero) { + const U = ED.tortoise, group = { t: 0, owner: actor === hero ? 'player' : 'enemy', facing: actor.facing, x: clamp(actor.x + actor.facing * U.ahead, 60, W - 60), stomped: 0, phase: 'cutin', spirit: null }; + if (actor === hero) tortoise = group; else enemyTortoise = group; + cinematic = .9; announceTimer = 0; + startUltimateVoice('edda', actor); + } + function updateTortoise(dt, s) { + if (!s) return; + const U = ED.tortoise; s.t += dt; + if (!s.spirit && s.t >= U.appear) { + s.spirit = { type: 'edda-spirit', asset: 'edda-tortoise', x: s.x, y: CONFIG.groundY + 8, facing: s.facing, life: U.duration - U.appear, maxLife: U.duration - U.appear, size: U.size, lift: 0 }; + effects.push(s.spirit); spawnParticles(s.x, CONFIG.groundY - 20, '#8fe3b4', 24, 260); sound('cast'); + } + if (s.spirit) { + s.x = clamp(s.x + s.facing * U.speed * dt, 60, W - 60); s.spirit.x = s.x; + s.spirit.lift = U.stomps.reduce((m, ts) => Math.max(m, 34 * Math.max(0, 1 - Math.abs(s.t - (ts - .16)) / .16)), 0); + } + while (s.stomped < U.stomps.length && s.t >= U.stomps[s.stomped]) { + const last = s.stomped === U.stomps.length - 1, target = s.owner === 'enemy' ? hero : dummy; s.stomped++; + effects.push({ type: 'edda-fx', asset: 'edda-stomp', x: s.x + s.facing * 50, y: CONFIG.groundY - 30, facing: s.facing, life: .35, maxLife: .35, size: 220 }); + spawnParticles(s.x + s.facing * 50, CONFIG.groundY - 8, '#d8c089', 22, 320); sound('heavy'); trauma = Math.min(1, trauma + .22); + if (Math.abs(target.x - s.x) < U.half && target.y > CONFIG.groundY - 40) { + if (s.owner === 'enemy') window.__game.receiveHit(ED.balance.stompDamage, { freeze: false }); + else hitDummy(ED.balance.stompDamage, last ? 200 : 40, '#8fe3b4', s.x, CONFIG.groundY - 60, false, false); + } + } + s.phase = s.t < U.cutinDuration ? 'cutin' : s.stomped < U.stomps.length ? 'stomping' : 'fading'; + if (s.t >= U.duration) { if (s.owner === 'enemy') enemyTortoise = null; else tortoise = null; } + } + // EDDA Shell Counter: the first hit inside the guard window is ignored (EDDA ducks into her shell) and an attacker + // within ED.counter.reach px is answered with a staff counter. A shot from further away is simply blocked. + function shellGuard(actor) { + const a = actor.action; if (!a?.guard || a.countered || a.t >= a.guardEnd) return false; + a.countered = true; if (a.t < .34) { a.t = .34; actor.stateTime = .34; } + const target = actor === hero ? dummy : hero, em = window.EDDA_METRICS?.emitters?.skill2, dx = target.x - actor.x; + effects.push({ type: 'edda-fx', asset: 'edda-shell', x: actor.x, y: actor.y - 80, facing: actor.facing, life: .35, maxLife: .35, size: 170 }); + spawnParticles(actor.x, actor.y - 80, '#e8d9a8', 14, 240); sound('heavy'); + if (Math.abs(dx) < ED.counter.reach && Math.abs(target.y - actor.y) < 115) { + actor.facing = Math.sign(dx) || actor.facing; + const hy = actor.y + (em?.y ?? -90); + effects.push({ type: 'whip', x: actor.x, y: hy, facing: actor.facing, tip: em?.x ?? 130, life: .16, maxLife: .16, index: 3, color: '#ffe9a8' }); + if (actor === hero) hitDummy(ED.counter.damage, ED.counter.knockback, '#ffe9a8', actor.x, hy, false, true); else window.__game.receiveHit(ED.counter.damage); + } + return true; + } + function droneMuzzle(d, facing) { + const muzzle = window.DRONE_ASSET?.muzzle || { x: 51, y: 0 }; + const c = Math.cos(d.angle), s = Math.sin(d.angle), x = facing * muzzle.x, y = muzzle.y; + return { x: d.x + x * c - y * s, y: d.y + x * s + y * c }; + } + function step(dt) { + systemAnnouncer?.tick(dt); + if (menuOpen || paused || settingsOpen) return; + if(match?.mode==='versus'&&match.phase!=='fight') { + // Shake is driven by realTime, so trauma must keep decaying here or the K.O./intro screen vibrates. + realTime+=dt;trauma=Math.max(0,trauma-dt*1.35);cinematic=Math.max(0,cinematic-dt);hero.animTime+=dt;dummy.stateTime+=dt;updateVisuals(dt); + if(hero.y 0) { hitstop -= dt; return; } + time += dt; hero.animTime += dt; hero.invuln = Math.max(0, hero.invuln - dt); hero.doublePose = Math.max(0, (hero.doublePose || 0) - dt); + if(fenrCutin) {fenrCutin.t+=dt;if(fenrCutin.t>.78)fenrCutin=null;} + if(F&&selectedCharacter==='fenr'){const changes=hero.formChanges;F.advance(hero,dt,hero.hp>0);if(changes!==hero.formChanges){syncPlayerForm();hero.doublePose=0;state(hero.grounded?'idle':'jump');}} + hero.airTime = hero.grounded ? 0 : hero.airTime + dt; + for (const name of Object.keys(hero.cooldowns)) hero.cooldowns[name] = Math.max(0, hero.cooldowns[name] - dt); + if (hero.doublePose <= 0 && hero.rollBuffer && hero.hurtTime <= 0) { const buffered = hero.rollBuffer; hero.rollBuffer = null; if (buffered === 'attack') startAttack(); else cast(buffered); } + comboTimer = Math.max(0, comboTimer - dt); if (!comboTimer) comboHits = comboDamage = 0; + const dir = (keys.has('d') ? 1 : 0) - (keys.has('a') ? 1 : 0), crouching = keys.has('s') && hero.grounded && !hero.action; + if (!hero.action && hero.hurtTime <= 0 && hero.hp > 0) { + if (dir && hero.doublePose <= 0) hero.facing = dir; + if (hero.runDirection && dir !== hero.runDirection) hero.runDirection = 0; + const speed = (hero.runDirection === dir && dir ? CONFIG.runSpeed : CONFIG.walkSpeed)*(selectedCharacter==='fenr'&&hero.form==='wolf'?1.08:1); + hero.vx = approach(hero.vx, crouching ? 0 : dir * speed, dt * (hero.grounded ? 2800 : 1600)); + if (hero.grounded) state(crouching ? 'crouch' : dir ? hero.runDirection ? 'run' : 'walk' : 'idle'); + else state(hero.doublePose > 0 ? 'doublejump' : 'jump'); + } else hero.vx = approach(hero.vx, 0, dt * 1900); + if (hero.hurtTime > 0) { hero.hurtTime -= dt; state('hurt'); if (hero.hurtTime <= 0) hero.invuln = .9; } + if(hero.hp<=0) { hero.koTime=Math.max(0,hero.koTime-dt); state('down'); if(!hero.koTime&&match?.mode!=='versus') {hero.hp=BALANCE.heroMax;hero.invuln=.9;state(hero.grounded?'idle':'jump');} } + if (hero.action) { + const a = hero.action; a.t += dt; state(a.name); + if((selectedCharacter==='fenr'||selectedCharacter==='mira'||selectedCharacter==='haldor'||selectedCharacter==='isolde'||selectedCharacter==='solan'||selectedCharacter==='nib')&&a.dash&&a.t>a.duration*.2&&a.t= a.duration * (a.hitAt || (a.type === 'skill2' ? .53 : .5))) { a.fired = true; if (a.type === 'attack') meleeHit(a.index); else fireSkill(a); } + if (a.fired && a.cancelInto && hero.action === a) { const into = a.cancelInto; hero.action = null; state(hero.grounded ? 'idle' : 'jump'); if (into === 'jump') jump(); } + if (a.t >= a.duration || (a.type === 'skill1' && a.fired)) { const queued = a.queued && a.index < 3, remaining = Math.max(0, a.queued - 1); const next = (a.index || 0) + 1; hero.action = null; if (queued) { startAttack(next); hero.action.queued = remaining; } else state(hero.grounded ? 'idle' : 'jump'); } + } + const oldHeroX = hero.x; + hero.x = clamp(hero.x + hero.vx * dt, 70, W - 70); + const gap = bodyGap(); + if (dummy.state !== 'down' && dummy.state !== 'recover' && hero.y > CONFIG.groundY - 130 && dummy.y > CONFIG.groundY - 130 && Math.abs(hero.x - dummy.x) < gap) { hero.x = clamp(dummy.x + (oldHeroX <= dummy.x ? -gap : gap), 70, W - 70); hero.vx = 0; } + const travelled=Math.abs(hero.x-oldHeroX); + if(hero.grounded && hero.state==='run' && travelled>0) { + hero.smokeDistance+=travelled; + while(hero.smokeDistance>=30) { hero.smokeDistance-=30; runSmoke(hero.x,hero.facing); } + } else hero.smokeDistance=0; + if (!hero.grounded) { if (keys.has('s') && hero.vy > 0 && hero.doublePose <= 0) hero.vy = Math.max(hero.vy, CONFIG.fastFall); hero.vy += CONFIG.gravity * dt; hero.y += hero.vy * dt; if (hero.y >= CONFIG.groundY) { hero.y = CONFIG.groundY; hero.vy = 0; hero.grounded = true; hero.jumps = 0; hero.doublePose = 0; hero.airTime = 0; dust(hero.x, 11); sound('step'); } } + const stride = metrics?.states?.[hero.state]?.stride_estimate || manifest?.metrics?.[hero.state === 'run' ? 'runStride' : 'walkStride'] || manifest?.locomotion?.[hero.state]?.stride_px || (hero.state === 'run' ? 184 : 148); + hero.walkPhase += Math.abs(hero.vx) * dt / stride; + updateDummy(dt); updateProjectiles(dt); updateSquadron(dt); updateSquadron(dt,enemySquad); updateParade(dt,parade); updateParade(dt,enemyParade); updateMurmuration(dt,flock); updateMurmuration(dt,enemyFlock); updateSerpent(dt,serpent); updateSerpent(dt,enemySerpent); updateQuake(dt,quake); updateFinale(dt,finale); updateFinale(dt,enemyFinale); updateSkyfall(dt,skyfall); updateSkyfall(dt,enemySkyfall); updateOrrery(dt,orrery); updateOrrery(dt,enemyOrrery); updateSunroar(dt,sunroar); updateSunroar(dt,enemySunroar); updateDelivery(dt,delivery); updateDelivery(dt,enemyDelivery); updateTortoise(dt,tortoise); updateTortoise(dt,enemyTortoise); updateQuake(dt,enemyQuake); + if (voiceActive && (voiceKind === 'mira' || voiceKind === 'cora' || voiceKind === 'naja' || voiceKind === 'haldor' || voiceKind === 'zanni' || voiceKind === 'isolde' || voiceKind === 'rhea' || voiceKind === 'solan' || voiceKind === 'nib' || voiceKind === 'edda')) voiceClock += dt; + if (voiceActive && voiceKind !== 'arco' && (!voiceRelevant() || (Number.isFinite(ultimateVoice.duration) && voiceElapsed() >= ultimateVoice.duration))) stopUltimateVoice(); + advanceMatch(dt); + } + // MIRA's robot and HALDOR's exoframe are much wider than the other fighters; keep bodies from sinking into them. + function bodyGap() { return selectedCharacter === 'mira' || opponentCharacter === 'mira' ? 66 : selectedCharacter === 'haldor' || opponentCharacter === 'haldor' ? 62 : 54; } + function currentDummyHP() {return Math.max(0,BALANCE.dummyMax-dummy.damage);} + function hpLayers(value) {return {front:clamp(value-BALANCE.hpPerBar,0,BALANCE.hpPerBar),reserve:clamp(value,0,BALANCE.hpPerBar)};} + function updateDummy(dt) { + if(F){for(const name of Object.keys(dummy.cooldowns))dummy.cooldowns[name]=Math.max(0,dummy.cooldowns[name]-dt);if(opponentCharacter==='fenr')F.advance(dummy,dt,!dummy.ko);} + dummy.stateTime += dt; dummy.flash = Math.max(0, dummy.flash - dt); dummy.invuln = Math.max(0, dummy.invuln - dt); + dummy.x = clamp(dummy.x + dummy.vx * dt, 110, W - 100); if(!F || !aiEnabled || dummy.action || ['hurt','down','recover'].includes(dummy.state))dummy.vx = approach(dummy.vx, 0, dt * 950); + if(F && !['down','recover'].includes(dummy.state) && hero.y>CONFIG.groundY-130 && dummy.y>CONFIG.groundY-130 && Math.abs(dummy.x-hero.x)=hero.x?bodyGap():-bodyGap()),110,W-100); + // Versus CPU follows the player's post-hurt immunity rule by difficulty, so a mashed chain cannot loop it forever. + // Its stun lasts 0.5 s there, which still covers every 3-hit chain gap; the training dummy stays open for practice. + if (dummy.state === 'hurt' && dummy.stateTime > (F && aiEnabled && match?.mode === 'versus' ? .5 : .4)) { dummy.state = dummy.y < CONFIG.groundY ? 'jump' : 'idle'; dummy.stateTime = 0; if (F && aiEnabled && match?.mode === 'versus') dummy.invuln = Math.max(dummy.invuln, cpuProfile().immunity); } + if (dummy.state !== 'down' && (dummy.y < CONFIG.groundY || dummy.vy < 0)) { dummy.vy += CONFIG.gravity * dt; dummy.y += dummy.vy * dt; if (dummy.y >= CONFIG.groundY) { dummy.y = CONFIG.groundY; dummy.vy = 0; dummy.jumps = 0; cpu.doubleAt = 0; if (dummy.state === 'jump') { dummy.state = 'idle'; dummy.stateTime = 0; } dust(dummy.x, 8); } } + if (dummy.state === 'down') { if (dummy.y < CONFIG.groundY || dummy.vy < 0) { dummy.vy += CONFIG.gravity * dt; dummy.y += dummy.vy * dt; if (dummy.y >= CONFIG.groundY) { dummy.y = CONFIG.groundY; dummy.vy = 0; dust(dummy.x, 24); trauma = Math.min(1, trauma + .3); } } if (dummy.stateTime > 1.7 && match?.mode!=='versus') { dummy.state = 'recover'; dummy.stateTime = 0; } } + if (dummy.state === 'recover' && dummy.stateTime > .4) { dummy.state = 'idle'; dummy.stateTime = 0; if(dummy.ko) dummy.damage = 0; dummy.ko=false; dummy.invuln = .5; } + if(F)updateFenrAI(dt); + } + // HALDOR Slag Shot landing: a small molten splash on the floor where the lob comes down. + function slagSplash(p) { + effects.push({ type: 'haldor-fx', asset: 'haldor-splash', x: p.x, y: CONFIG.groundY - 26, facing: p.facing, life: .4, maxLife: .4, size: 150 }); + spawnParticles(p.x, CONFIG.groundY - 12, '#ff9a3c', 16, 260); sound('heavy'); trauma = Math.min(1, trauma + .12); + if (p.owner === 'enemy') { if (Math.abs(hero.x - p.x) < p.splash && hero.y > CONFIG.groundY - 60) window.__game.receiveHit(p.damage, { projectile: true }); } + else if (Math.abs(dummy.x - p.x) < p.splash && dummy.y > CONFIG.groundY - 60) hitDummy(p.damage, p.knockback, p.color, p.x - p.facing * 20, CONFIG.groundY - 45, false, false); + } + function updateProjectiles(dt) { + for (let i = projectiles.length - 1; i >= 0; i--) { const p = projectiles[i], oldX = p.x; + // NIB paper planes steer toward the rival's chest, turning at most p.turn rad/s. + if (p.homing) { const t = p.owner === 'enemy' ? hero : dummy, want = Math.atan2(t.y - 85 - p.y, t.x - p.x), cur = Math.atan2(p.vy, p.vx); let d = want - cur; d = Math.atan2(Math.sin(d), Math.cos(d)); const ang = cur + clamp(d, -p.turn * dt, p.turn * dt); p.vx = Math.cos(ang) * p.speedH; p.vy = Math.sin(ang) * p.speedH; p.facing = p.vx >= 0 ? 1 : -1; } + // EDDA Stone Skip: the stone falls under its own gravity and hops off the floor p.bounces more times, then sinks. + if (p.skip) { p.vy += p.skipG * dt; if (p.vy > 0 && p.y + p.vy * dt >= CONFIG.groundY - 14) { effects.push({ type: 'edda-fx', asset: 'edda-ripple', x: p.x, y: CONFIG.groundY - 14, facing: p.facing, life: .3, maxLife: .3, size: 110 }); if (p.bounces-- > 0) { p.y = CONFIG.groundY - 14; p.vy = -p.hop; sound('step'); } else { projectiles.splice(i, 1); continue; } } } + if (p.gravity) p.vy += p.gravity * dt; p.x += p.vx * dt; p.y += (p.vy || 0) * dt; p.life -= dt; + if (p.gravity && p.y >= CONFIG.groundY - 8) { slagSplash(p); projectiles.splice(i, 1); continue; } + if (p.landY && p.y >= p.landY) { lanceShatter(p); projectiles.splice(i, 1); continue; } + // RHEA: a Gravity Well waits in place and collapses; an Orrery planet laps around its owner. + if (p.inert) { if (p.life <= 0) { wellBurst(p); projectiles.splice(i, 1); } continue; } + if (p.orbit) { + const home = p.owner === 'enemy' ? dummy : hero; p.angle += Math.PI * 2 * dt / p.lap; + if (p.angle >= Math.PI * 2) { projectiles.splice(i, 1); continue; } + p.x = home.x + p.facing * p.rx * Math.cos(p.angle); p.y = home.y - 90 + p.ry * Math.sin(p.angle); + } + // ZANNI rings fly out `range` px, then home back to the thrower and vanish in his hand. One hit per ring (`spent`). + if (p.boomerang) { + p.travel += Math.abs(p.x - oldX); + if (!p.returning && p.travel >= p.range) p.returning = true; + if (p.returning) { const home = p.owner === 'enemy' ? dummy : hero; p.vx = Math.sign(home.x - p.x || 1) * Math.abs(p.vx); p.facing = Math.sign(p.vx); p.y += (clamp(home.y - 95, home.y - 130, home.y - 60) - p.y) * Math.min(1, dt * 6); if (Math.abs(home.x - p.x) < 34 && p.travel > 80) { projectiles.splice(i, 1); continue; } } + } + if (p.spent) { if (p.life <= 0) projectiles.splice(i, 1); continue; } + if(p.owner==='enemy') { + if(Math.min(oldX,p.x)<=hero.x+28&&Math.max(oldX,p.x)>=hero.x-28&&Math.abs(p.y-(hero.y-85))<(p.tall||75)&&window.__game.receiveHit(p.damage,{projectile:true,freeze:p.freeze}))p.boomerang?(p.spent=true,p.returning=true):p.orbit?(p.spent=true):p.life=0; + if(p.life<=0||p.x<-100||p.x>W+100)projectiles.splice(i,1);continue; + } + // Swept x interval keeps a high-speed bolt from tunneling through a target. + if (Math.min(oldX, p.x) <= dummy.x + 30 && Math.max(oldX, p.x) >= dummy.x - 30 && Math.abs(p.y - (dummy.y - (F?95:68))) < (p.tall || (F?90:66)) && dummy.state !== 'down' && dummy.state !== 'recover') { if (hitDummy(p.damage||BALANCE.skill1.damage, p.knockback ?? BALANCE.skill1.knockback, p.color || '#94ffde', oldX, p.y, false, false)) { if (p.boomerang) { p.spent = true; p.returning = true; } else if (p.orbit) p.spent = true; else p.life = 0; } } + if (p.life <= 0 || p.x < -100 || p.x > W + 100) projectiles.splice(i, 1); + } + } + function syncPlayerForm() { + if(selectedCharacter==='fenr') {const wolf=hero.form==='wolf';manifest=wolf?window.FENR_WOLF_MANIFEST:window.FENR_HUMAN_MANIFEST;metrics=wolf?window.FENR_WOLF_METRICS:window.FENR_HUMAN_METRICS;images.hero=images[wolf?'fenrWolf':'fenrHuman'];Object.assign(cooldownMax,F.balance.cooldowns);} + else if(selectedCharacter==='mira') {manifest=window.MIRA_MANIFEST;metrics=window.MIRA_METRICS;images.hero=images.mira;Object.assign(cooldownMax,M.balance.cooldowns);} + else if(selectedCharacter==='cora') {manifest=window.CORA_MANIFEST;metrics=window.CORA_METRICS;images.hero=images.cora;Object.assign(cooldownMax,C.balance.cooldowns);} + else if(selectedCharacter==='naja') {manifest=window.NAJA_MANIFEST;metrics=window.NAJA_METRICS;images.hero=images.naja;Object.assign(cooldownMax,N.balance.cooldowns);} + else if(selectedCharacter==='haldor') {manifest=window.HALDOR_MANIFEST;metrics=window.HALDOR_METRICS;images.hero=images.haldor;Object.assign(cooldownMax,HD.balance.cooldowns);} + else if(selectedCharacter==='edda') {manifest=window.EDDA_MANIFEST;metrics=window.EDDA_METRICS;images.hero=images.edda;Object.assign(cooldownMax,ED.balance.cooldowns);} + else if(selectedCharacter==='nib') {manifest=window.NIB_MANIFEST;metrics=window.NIB_METRICS;images.hero=images.nib;Object.assign(cooldownMax,NB.balance.cooldowns);} + else if(selectedCharacter==='solan') {manifest=window.SOLAN_MANIFEST;metrics=window.SOLAN_METRICS;images.hero=images.solan;Object.assign(cooldownMax,SO.balance.cooldowns);} + else if(selectedCharacter==='rhea') {manifest=window.RHEA_MANIFEST;metrics=window.RHEA_METRICS;images.hero=images.rhea;Object.assign(cooldownMax,RH.balance.cooldowns);} + else if(selectedCharacter==='isolde') {manifest=window.ISOLDE_MANIFEST;metrics=window.ISOLDE_METRICS;images.hero=images.isolde;Object.assign(cooldownMax,IS.balance.cooldowns);} + else if(selectedCharacter==='zanni') {manifest=window.ZANNI_MANIFEST;metrics=window.ZANNI_METRICS;images.hero=images.zanni;Object.assign(cooldownMax,Z.balance.cooldowns);} + else {manifest=window.MECHA_MANIFEST||window.SPRITE_MANIFEST;metrics=window.MECHA_METRICS;images.hero=images.arco||images.hero;Object.assign(cooldownMax,{skill1:3,skill2:6,ultimate:18});} + hudIdentity=''; + } + function selectCharacter(id) { + if(!ready || match?.mode==='versus' || !playable().includes(id) || (id==='fenr'&&!window.FENR_HUMAN_MANIFEST))return false; + selectedCharacter=id;reset();syncPlayerForm();warmCutins();$('#character-select').value=id;updateHud();return true; + } + function showFenrTransformation(actor) { + fenrCutin={t:0,owner:actor===hero?'PLAYER':'RIVAL'};cinematic=.65; + startUltimateVoice('fenr', actor); + effects.push({type:'fenr-fx',asset:'transform',x:actor.x,y:actor.y-95,facing:actor.facing,life:.8,maxLife:.8,size:230}); + } + function fenrStrike(actor,move) { + const enemy=actor===dummy,target=enemy?hero:dummy,actorMetrics=actor.form==='wolf'?window.FENR_WOLF_METRICS:window.FENR_HUMAN_METRICS,origin=actorMetrics?.emitters?.[move.name],fy=actor.y+(origin?.y??-(actor.form==='wolf'?120:105)); + effects.push({type:'fenr-fx',asset:move.fx,x:move.area?actor.x:actor.x+actor.facing*65,y:fy,facing:actor.facing,life:.32,maxLife:.32,size:move.area?move.reach*2:130}); + if(move.projectile){projectiles.push({x:actor.x+actor.facing*(origin?.x??62),y:fy,vx:actor.facing*720,life:1.15,facing:actor.facing,owner:enemy?'enemy':'player',damage:move.damage,asset:'gale'});sound('cast');return;} + const dx=(target.x-actor.x)*actor.facing,near=move.area?Math.abs(dx)-22&&dx115){sound('step');return;} + const connected=enemy?window.__game.receiveHit(move.damage):hitDummy(move.damage,move.knockback,'#ffe6ba',actor.x,fy,false,move.type==='attack'); + if(connected && move.type==='attack') {F.refund(actor);if(!enemy)for(const n of Object.keys(rechargePulse))rechargePulse[n]=.22;} + if(connected)sound('hit'); + } + function miraStrike(actor,move) { + const enemy=actor===dummy,target=enemy?hero:dummy,em=window.MIRA_METRICS?.emitters?.[move.name]; + const ox=actor.x+actor.facing*(em?.x??100),oy=actor.y+(em?.y??-125); + if(move.projectile){ + projectiles.push({x:ox,y:oy,vx:actor.facing*move.speed,life:1.3,facing:actor.facing,owner:enemy?'enemy':'player',damage:move.damage,asset:'mira-star',color:'#ffe27a'}); + effects.push({type:'muzzle',x:ox,y:oy,life:.2,maxLife:.2,color:'#fff1a8'});spawnParticles(ox,oy,'#ffe27a',10,140);sound('cast');return; + } + if(move.type==='attack')effects.push({type:'slash',x:actor.x+actor.facing*62,y:oy+5,life:.22,maxLife:.22,facing:actor.facing,index:move.index,color:move.index===3?'#ffe27a':'#f0d2ff'}); + else effects.push({type:'mira-fx',asset:'mira-crash',x:ox,y:oy,facing:actor.facing,life:.3,maxLife:.3,size:150}); + const dx=(target.x-actor.x)*actor.facing; + if(dx<=-22||dx>=move.reach+27||Math.abs(target.y-actor.y)>115){sound('step');return;} + const connected=enemy?window.__game.receiveHit(move.damage):hitDummy(move.damage,move.knockback,'#ffd6f5',actor.x,oy,false,move.type==='attack'); + if(connected&&move.type==='attack') { + if(enemy)M.refund(dummy); + else for(const name of Object.keys(cooldownMax)){if(hero.cooldowns[name]>0)rechargePulse[name]=.22;hero.cooldowns[name]=Math.max(0,hero.cooldowns[name]-cooldownMax[name]*BALANCE.basicCooldownRefund);} + } + if(connected)sound(move.type==='skill2'?'heavy':'hit'); + } + function coraStrike(actor,move) { + const enemy=actor===dummy,target=enemy?hero:dummy,em=window.CORA_METRICS?.emitters?.[move.name]; + const ox=actor.x+actor.facing*(em?.x??95),oy=actor.y+(em?.y??-140),hy=clamp(oy,actor.y-165,actor.y-60); + if(move.projectile){ + // The straight feather flies a little faster, so the fan lands as a quick three-hit burst instead of one stacked hit. + move.feathers.forEach((damage,i)=>{const angle=(i-1)*move.spread,speed=move.speed*(1-Math.abs(i-1)*.06);projectiles.push({x:ox,y:oy,vx:actor.facing*speed*Math.cos(angle),vy:speed*Math.sin(angle),life:1,facing:actor.facing,owner:enemy?'enemy':'player',damage,knockback:move.knockback,asset:'cora-feather',size:74,color:'#c9a2ff'});}); + effects.push({type:'muzzle',x:ox,y:oy,life:.18,maxLife:.18,color:'#e0c8ff'});spawnParticles(ox,oy,'#b58cff',10,140);sound('cast');return; + } + if(move.type==='attack')effects.push({type:'slash',x:actor.x+actor.facing*55,y:hy+5,life:.22,maxLife:.22,facing:actor.facing,index:move.index,color:move.index===3?'#ffc86b':'#dcc6ff'}); + else {effects.push({type:'cora-fx',asset:'cora-gust',x:actor.x+actor.facing*140,y:actor.y-100,facing:actor.facing,life:.34,maxLife:.34,size:170});spawnParticles(actor.x+actor.facing*120,actor.y-100,'#3a2d58',12,220);} + const dx=(target.x-actor.x)*actor.facing,near=move.area?dx>-30&&dx-22&&dx115){sound('step');return;} + const connected=enemy?window.__game.receiveHit(move.damage):hitDummy(move.damage,move.knockback,move.area?'#e8d8ff':'#e2ccff',actor.x,hy,false,move.type==='attack'); + if(connected&&move.type==='attack') { + if(enemy)C.refund(dummy); + else for(const name of Object.keys(cooldownMax)){if(hero.cooldowns[name]>0)rechargePulse[name]=.22;hero.cooldowns[name]=Math.max(0,hero.cooldowns[name]-cooldownMax[name]*BALANCE.basicCooldownRefund);} + } + if(connected)sound(move.type==='skill2'?'heavy':'hit'); + } + function najaStrike(actor,move) { + const enemy=actor===dummy,target=enemy?hero:dummy,em=window.NAJA_METRICS?.emitters?.[move.name]; + const hy=clamp(actor.y+(em?.y??-100),actor.y-150,actor.y-50); + if(move.projectile){ + // Sand Fang runs along the floor from her palm slam: any small jump clears it. + const x=actor.x+actor.facing*(em?.x??90); + projectiles.push({x,y:actor.y-24,vx:actor.facing*move.speed,life:1.1,facing:actor.facing,owner:enemy?'enemy':'player',damage:move.damage,knockback:move.knockback,asset:'naja-sandwave',size:130,color:'#f2c86b'}); + dust(x,10);spawnParticles(x,actor.y-14,'#e8c27a',10,160);sound('cast');return; + } + // The urumi cracks out to its measured tip; the cyclone spins all the way around her. + if(move.type==='attack')effects.push({type:'whip',x:actor.x,y:hy,facing:actor.facing,tip:em?.x??move.reach+25,life:.2,maxLife:.2,index:move.index,color:move.index===3?'#ffe7a8':'#f2d9a0'}); + else {effects.push({type:'naja-fx',asset:'naja-cyclone',x:actor.x,y:actor.y-72,facing:actor.facing,life:.36,maxLife:.36,size:270});spawnParticles(actor.x,actor.y-40,'#e3bf78',16,260,'dust');} + const dx=(target.x-actor.x)*actor.facing,near=move.around?Math.abs(dx)-22&&dx115){sound('step');return;} + const connected=enemy?window.__game.receiveHit(move.damage):hitDummy(move.damage,move.knockback,move.around?'#ffe7b0':'#f7dca0',actor.x,hy,false,move.type==='attack'); + if(connected&&move.type==='attack') { + if(enemy)N.refund(dummy); + else for(const name of Object.keys(cooldownMax)){if(hero.cooldowns[name]>0)rechargePulse[name]=.22;hero.cooldowns[name]=Math.max(0,hero.cooldowns[name]-cooldownMax[name]*BALANCE.basicCooldownRefund);} + } + if(connected)sound(move.type==='skill2'?'heavy':'hit'); + } + function haldorStrike(actor,move) { + const enemy=actor===dummy,target=enemy?hero:dummy,em=window.HALDOR_METRICS?.emitters?.[move.name]; + const hy=clamp(actor.y+(em?.y??-90),actor.y-150,actor.y-40); + if(move.projectile){ + // Slag Shot: lobbed at where the rival stands now (180-560 px ahead), landing after a fixed flight time. + const L=HD.slag,x0=actor.x+actor.facing*(em?.x??70),y0=clamp(actor.y+(em?.y??-120),actor.y-170,actor.y-60); + const ahead=(target.x-actor.x)*actor.facing,land=actor.x+actor.facing*clamp(ahead,L.minRange,L.maxRange); + const g=2*(CONFIG.groundY-8-y0+L.rise*L.flight)/(L.flight*L.flight); + projectiles.push({x:x0,y:y0,vx:(land-x0)/L.flight,vy:-L.rise,gravity:g,life:L.flight+.4,facing:actor.facing,owner:enemy?'enemy':'player',damage:move.damage,knockback:move.knockback,asset:'haldor-slag',size:64,color:'#ffae52',landX:land,landAt:time+L.flight,splash:L.splash}); + effects.push({type:'muzzle',x:x0,y:y0,life:.2,maxLife:.2,color:'#ffb35c'});spawnParticles(x0,y0,'#ff9a3c',10,150);sound('cast');return; + } + if(move.type==='attack'){ + effects.push({type:'slash',x:actor.x+actor.facing*55,y:hy+5,life:.24,maxLife:.24,facing:actor.facing,index:move.index,color:move.index===3?'#ffd28a':'#ffb05e'}); + // The overhead anvil smash lands on the floor: dust and a small shake even on a miss. + if(move.index===3){dust(actor.x+actor.facing*(em?.x??110),14);trauma=Math.min(1,trauma+.18);} + } + else {effects.push({type:'haldor-fx',asset:'haldor-steam',x:actor.x-actor.facing*70,y:actor.y-80,facing:actor.facing,life:.4,maxLife:.4,size:190});spawnParticles(actor.x+actor.facing*70,actor.y-90,'#f3ecdf',12,220,'dust');} + const dx=(target.x-actor.x)*actor.facing; + if(dx<=-22||dx>=move.reach+27||Math.abs(target.y-actor.y)>115){sound('step');return;} + const connected=enemy?window.__game.receiveHit(move.damage):hitDummy(move.damage,move.knockback,move.type==='skill2'?'#fff1dc':'#ffc27a',actor.x,hy,false,move.type==='attack'); + if(connected&&move.type==='attack') { + if(enemy)HD.refund(dummy); + else for(const name of Object.keys(cooldownMax)){if(hero.cooldowns[name]>0)rechargePulse[name]=.22;hero.cooldowns[name]=Math.max(0,hero.cooldowns[name]-cooldownMax[name]*BALANCE.basicCooldownRefund);} + } + if(connected)sound(move.type==='skill2'||move.index===3?'heavy':'hit'); + } + function zanniStrike(actor,move) { + const enemy=actor===dummy,target=enemy?hero:dummy,em=window.ZANNI_METRICS?.emitters?.[move.name]; + const hy=clamp(actor.y+(em?.y??-100),actor.y-180,actor.y-50); + if(move.projectile){ + // Ring Toss: a bladed ring at hand height flies out ZANNI.ring.range px and comes back to his hand. + const x=actor.x+actor.facing*(em?.x??90),y=clamp(actor.y+(em?.y??-95),actor.y-130,actor.y-70); + projectiles.push({x,y,vx:actor.facing*move.speed,life:2.2,facing:actor.facing,owner:enemy?'enemy':'player',damage:move.damage,knockback:move.knockback,asset:'zanni-ring',size:70,color:'#e4f36a',boomerang:true,range:Z.ring.range,travel:0,spin:18}); + effects.push({type:'muzzle',x,y,life:.2,maxLife:.2,color:'#e4f36a'});spawnParticles(x,y,'#e8d27a',10,150);sound('cast');return; + } + // The scissor lattice telescopes to its measured tip; Spring Snatch reaches 200 px and reels the rival in. + if(move.type==='attack')effects.push({type:'whip',x:actor.x,y:hy,facing:actor.facing,tip:em?.x??move.reach+20,life:.18,maxLife:.18,index:move.index,color:move.index===3?'#f4f7a0':'#e4f36a'}); + else effects.push({type:'whip',x:actor.x,y:hy,facing:actor.facing,tip:em?.x??move.reach,life:.26,maxLife:.26,index:3,color:'#f2d46a'}); + const dx=(target.x-actor.x)*actor.facing; + if(dx<=-22||dx>=move.reach+27||Math.abs(target.y-actor.y)>115){sound('step');return;} + const connected=enemy?window.__game.receiveHit(move.damage):hitDummy(move.damage,move.knockback,move.type==='skill2'?'#f6f3c8':'#e9f27a',actor.x,hy,false,move.type==='attack'); + if(connected&&move.pull){ + // Reel the rival in to `pull` px in front of ZANNI (never pushes a closer rival away). + if(dx>move.pull){target.x=clamp(actor.x+actor.facing*move.pull,70,W-70);target.vx=0;} + effects.push({type:'zanni-fx',asset:'zanni-snatch',x:target.x,y:target.y-90,facing:actor.facing,life:.32,maxLife:.32,size:150}); + } + if(connected&&move.type==='attack') { + if(enemy)Z.refund(dummy); + else for(const name of Object.keys(cooldownMax)){if(hero.cooldowns[name]>0)rechargePulse[name]=.22;hero.cooldowns[name]=Math.max(0,hero.cooldowns[name]-cooldownMax[name]*BALANCE.basicCooldownRefund);} + } + if(connected)sound(move.type==='skill2'||move.index===3?'heavy':'hit'); + } + function isoldeStrike(actor,move) { + const enemy=actor===dummy,target=enemy?hero:dummy,em=window.ISOLDE_METRICS?.emitters?.[move.name]; + const hy=clamp(actor.y+(em?.y??-95),actor.y-190,actor.y-40); + if(move.projectile){ + // Sky Piercer: an ice bolt from the lance point on a rising line (IS.piercer.rise): anti-air, close ground poke. + const P=IS.piercer,x=actor.x+actor.facing*Math.round((em?.x??140)*.8),y=actor.y-P.y; + projectiles.push({x,y,vx:actor.facing*P.speed,vy:-P.speed*P.rise,life:1.1,facing:actor.facing,owner:enemy?'enemy':'player',damage:move.damage,knockback:move.knockback,asset:'isolde-piercer',size:96,color:'#bfe6ff'}); + effects.push({type:'muzzle',x,y,life:.2,maxLife:.2,color:'#dff3ff'});spawnParticles(x,y,'#dff3ff',10,160);sound('cast');return; + } + if(move.type==='attack')effects.push({type:'whip',x:actor.x,y:hy,facing:actor.facing,tip:em?.x??move.reach+25,life:.18,maxLife:.18,index:move.index,color:move.index===3?'#eaf7ff':'#bfe6ff'}); + else effects.push({type:'isolde-fx',asset:'isolde-frost',x:actor.x-actor.facing*60,y:actor.y-70,facing:actor.facing,life:.4,maxLife:.4,size:190}); + const dx=(target.x-actor.x)*actor.facing; + if(dx<=-22||dx>=move.reach+27||Math.abs(target.y-actor.y)>115){sound('step');return;} + const connected=enemy?window.__game.receiveHit(move.damage):hitDummy(move.damage,move.knockback,move.type==='skill2'?'#eaf7ff':'#bfe6ff',actor.x,hy,false,move.type==='attack'); + if(connected&&move.type==='attack') { + if(enemy)IS.refund(dummy); + else for(const name of Object.keys(cooldownMax)){if(hero.cooldowns[name]>0)rechargePulse[name]=.22;hero.cooldowns[name]=Math.max(0,hero.cooldowns[name]-cooldownMax[name]*BALANCE.basicCooldownRefund);} + } + if(connected)sound(move.type==='skill2'||move.index===3?'heavy':'hit'); + } + function rheaStrike(actor,move) { + const enemy=actor===dummy,target=enemy?hero:dummy,em=window.RHEA_METRICS?.emitters?.[move.name]; + const hy=clamp(actor.y+(em?.y??-95),actor.y-180,actor.y-40); + if(move.projectile){ + // Planet Drift: a slow little planet at chest height that crosses the arena. + const x=actor.x+actor.facing*(em?.x??80),y=actor.y-95; + projectiles.push({x,y,vx:actor.facing*move.speed,life:RH.drift.life,facing:actor.facing,owner:enemy?'enemy':'player',damage:move.damage,knockback:move.knockback,asset:'rhea-drift',size:78,color:'#f2c77a',spin:3}); + effects.push({type:'muzzle',x,y,life:.2,maxLife:.2,color:'#f2c77a'});spawnParticles(x,y,'#f2c77a',10,150);sound('cast');return; + } + if(move.well){ + // Gravity Well: a vortex opens RH.well.at px ahead and collapses after RH.well.fuse s. + const W0=RH.well,x=clamp(actor.x+actor.facing*W0.at,40,W-40); + projectiles.push({x,y:CONFIG.groundY-80,vx:0,life:W0.fuse,facing:actor.facing,owner:enemy?'enemy':'player',damage:move.damage,knockback:move.knockback,asset:'rhea-well',size:170,color:'#e8b0c0',inert:true,radius:W0.radius,spin:-6}); + sound('cast');return; + } + effects.push({type:'whip',x:actor.x,y:hy,facing:actor.facing,tip:em?.x??move.reach+20,life:.18,maxLife:.18,index:move.index,color:move.index===3?'#f7e3b0':'#f2c77a'}); + const dx=(target.x-actor.x)*actor.facing; + if(dx<=-22||dx>=move.reach+27||Math.abs(target.y-actor.y)>115){sound('step');return;} + const connected=enemy?window.__game.receiveHit(move.damage):hitDummy(move.damage,move.knockback,'#f2c77a',actor.x,hy,false,true); + if(connected) { + if(enemy)RH.refund(dummy); + else for(const name of Object.keys(cooldownMax)){if(hero.cooldowns[name]>0)rechargePulse[name]=.22;hero.cooldowns[name]=Math.max(0,hero.cooldowns[name]-cooldownMax[name]*BALANCE.basicCooldownRefund);} + } + if(connected)sound(move.index===3?'heavy':'hit'); + } + function solanStrike(actor,move) { + const enemy=actor===dummy,target=enemy?hero:dummy,em=window.SOLAN_METRICS?.emitters?.[move.name]; + const hy=clamp(actor.y+(em?.y??-95),actor.y-190,actor.y-40); + if(move.projectile){ + // Solar Crescent: a golden crescent wave from the blade edge at chest height, SO.crescent.range px long. + const x=actor.x+actor.facing*Math.round((em?.x??110)*.7),y=actor.y-95; + projectiles.push({x,y,vx:actor.facing*move.speed,life:move.range/move.speed,facing:actor.facing,owner:enemy?'enemy':'player',damage:move.damage,knockback:move.knockback,asset:'solan-crescent',size:150,color:'#ffd36a'}); + spawnParticles(x,y,'#ffd36a',10,160);sound('cast');return; + } + let hitX=null; + if(move.leap){ + // Leonine Leap lands: the blade hits the floor SO.leap.ahead px in front; everything within the radius is hit. + hitX=actor.x+actor.facing*SO.leap.ahead; + effects.push({type:'solan-fx',asset:'solan-impact',x:hitX,y:CONFIG.groundY-40,facing:actor.facing,life:.4,maxLife:.4,size:220}); + dust(hitX,18);spawnParticles(hitX,CONFIG.groundY-10,'#e8c07a',18,300,'dust');trauma=Math.min(1,trauma+.3); + } else effects.push({type:'slash',x:actor.x+actor.facing*55,y:hy+5,life:.24,maxLife:.24,facing:actor.facing,index:move.index,color:move.index===3?'#ffe7a0':'#ffd36a'}); + if(move.type==='attack'&&move.index===3){dust(actor.x+actor.facing*(em?.x??110),12);trauma=Math.min(1,trauma+.15);} + const dx=(target.x-actor.x)*actor.facing; + const inside=hitX!==null?Math.abs(target.x-hitX)CONFIG.groundY-130:dx>-22&&dx0)rechargePulse[name]=.22;hero.cooldowns[name]=Math.max(0,hero.cooldowns[name]-cooldownMax[name]*BALANCE.basicCooldownRefund);} + } + if(connected)sound(move.leap||move.index===3?'heavy':'hit'); + } + function nibStrike(actor,move) { + const enemy=actor===dummy,target=enemy?hero:dummy,em=window.NIB_METRICS?.emitters?.[move.name]; + const hy=clamp(actor.y+(em?.y??-80),actor.y-170,actor.y-40); + if(move.projectile){ + // Express Letter: a sealed letter thrown flat at chest height; the fastest projectile, NB.letter.range px long. + const x=actor.x+actor.facing*Math.round((em?.x??80)*.8),y=actor.y-90; + projectiles.push({x,y,vx:actor.facing*move.speed,life:move.range/move.speed,facing:actor.facing,owner:enemy?'enemy':'player',damage:move.damage,knockback:move.knockback,asset:'nib-letter',size:70,color:'#f4efe2'}); + spawnParticles(x,y,'#f4efe2',8,150);sound('cast');return; + } + effects.push({type:'whip',x:actor.x,y:hy,facing:actor.facing,tip:em?.x??move.reach+15,life:.14,maxLife:.14,index:move.index||3,color:move.slip?'#bfe8ff':'#8fd4ff'}); + const dx=(target.x-actor.x)*actor.facing; + if(dx<=-22||dx>=move.reach+27||Math.abs(target.y-actor.y)>115){sound('step');return;} + const connected=enemy?window.__game.receiveHit(move.damage):hitDummy(move.damage,move.knockback,move.slip?'#bfe8ff':'#8fd4ff',actor.x,hy,false,true); + if(connected&&move.slip){ + // Rooftop Slip: slip past the rival and turn around to face them (the dash stops there). + effects.push({type:'nib-fx',asset:'nib-slip',x:target.x,y:target.y-80,facing:actor.facing,life:.3,maxLife:.3,size:150}); + actor.x=clamp(target.x+actor.facing*NB.slip.behind,70,W-70);actor.facing*=-1;actor.vx=0;if(actor.action)actor.action.dash=0; + } + if(connected&&move.type==='attack') { + if(enemy)NB.refund(dummy); + else for(const name of Object.keys(cooldownMax)){if(hero.cooldowns[name]>0)rechargePulse[name]=.22;hero.cooldowns[name]=Math.max(0,hero.cooldowns[name]-cooldownMax[name]*BALANCE.basicCooldownRefund);} + } + if(connected)sound(move.slip||move.index===3?'heavy':'hit'); + } + function eddaStrike(actor,move) { + if(move.guard)return; + const enemy=actor===dummy,target=enemy?hero:dummy,em=window.EDDA_METRICS?.emitters?.[move.name]; + const hy=clamp(actor.y+(em?.y??-80),actor.y-170,actor.y-30); + if(move.projectile){ + // Stone Skip: a flat jade stone flicked low; it skips along the floor (ED.stone) and hits once. + const x=actor.x+actor.facing*Math.round((em?.x??70)*.8),y=clamp(actor.y+(em?.y??-60),actor.y-60,actor.y-40); + projectiles.push({x,y,vx:actor.facing*move.speed,vy:-ED.stone.rise,skip:true,skipG:ED.stone.gravity,bounces:ED.stone.bounces,hop:ED.stone.hop,life:3,facing:actor.facing,owner:enemy?'enemy':'player',damage:move.damage,knockback:move.knockback,asset:'edda-stone',size:54,color:'#9fe0b8',spin:14}); + spawnParticles(x,y,'#9fe0b8',8,150);sound('cast');return; + } + effects.push({type:'whip',x:actor.x,y:hy,facing:actor.facing,tip:em?.x??move.reach+15,life:.14,maxLife:.14,index:move.index||3,color:'#9fe0b8'}); + if(move.index===3)effects.push({type:'edda-fx',asset:'edda-ripple',x:actor.x+actor.facing*(em?.x??100),y:CONFIG.groundY-14,facing:actor.facing,life:.3,maxLife:.3,size:130}); + const dx=(target.x-actor.x)*actor.facing; + if(dx<=-22||dx>=move.reach+27||Math.abs(target.y-actor.y)>115){sound('step');return;} + const connected=enemy?window.__game.receiveHit(move.damage):hitDummy(move.damage,move.knockback,'#9fe0b8',actor.x,hy,false,true); + if(connected&&move.type==='attack') { + if(enemy)ED.refund(dummy); + else for(const name of Object.keys(cooldownMax)){if(hero.cooldowns[name]>0)rechargePulse[name]=.22;hero.cooldowns[name]=Math.max(0,hero.cooldowns[name]-cooldownMax[name]*BALANCE.basicCooldownRefund);} + } + if(connected)sound(move.index===3?'heavy':'hit'); + } + function startEnemyAction(name,index=1) { + if(opponentCharacter==='fenr')return F.start(dummy,name,index); + if(KITS[opponentCharacter]){if(!KITS[opponentCharacter].start(dummy,name,index))return false;if(name==='ultimate')startSummon(dummy);return true;} + if(dummy.action||dummy.cooldowns[name]>0||['hurt','down','recover'].includes(dummy.state))return false; + const stateName=name==='attack'?`attack${index}`:name,row=window.MECHA_MANIFEST.animation.rows[stateName],duration=name==='ultimate'?.5:row.frames/row.fps; + if(name!=='attack')dummy.cooldowns[name]={skill1:3,skill2:6,ultimate:18}[name]; + dummy.action={type:name,name:stateName,index,t:0,duration,fired:false,hitAt:name==='skill2'?.53:.5};dummy.state=stateName;dummy.stateTime=0; + if(name==='ultimate')startSquadron(dummy);return true; + } + function enemyStrike(a) { + if(opponentCharacter==='fenr'){fenrStrike(dummy,a);return;} + if(opponentCharacter==='mira'){miraStrike(dummy,a);return;} + if(opponentCharacter==='cora'){coraStrike(dummy,a);return;} + if(opponentCharacter==='naja'){najaStrike(dummy,a);return;} + if(opponentCharacter==='haldor'){haldorStrike(dummy,a);return;} + if(opponentCharacter==='zanni'){zanniStrike(dummy,a);return;} + if(opponentCharacter==='isolde'){isoldeStrike(dummy,a);return;} + if(opponentCharacter==='rhea'){rheaStrike(dummy,a);return;} + if(opponentCharacter==='solan'){solanStrike(dummy,a);return;} + if(opponentCharacter==='nib'){nibStrike(dummy,a);return;} + if(opponentCharacter==='edda'){eddaStrike(dummy,a);return;} + const em=window.MECHA_METRICS.emitters[a.name],x=dummy.x+dummy.facing*em.x,y=dummy.y+em.y,dx=(hero.x-dummy.x)*dummy.facing; + if(a.type==='skill1'){projectiles.push({x,y,vx:dummy.facing*920,life:1.6,facing:dummy.facing,owner:'enemy',damage:16});effects.push({type:'muzzle',x,y,life:.25,maxLife:.25});sound('cast');} + else if(a.type==='skill2'){effects.push({type:'slam',x,y:CONFIG.groundY-2,life:.8,maxLife:.8});sound('heavy');if(Math.abs(hero.x-x)<220&&Math.abs(hero.y-CONFIG.groundY)<125)window.__game.receiveHit(24);} + else if(a.type==='attack'){ + effects.push({type:'slash',x:dummy.x+dummy.facing*48,y:y+5,life:.22,maxLife:.22,facing:dummy.facing,index:a.index,color:'#cffbf0'}); + const reach=Math.max(...window.MECHA_METRICS.states[a.name].frames.map(f=>f.bounds.right)); + if(dx>-24&&dx f.bounds.right)) }; + if (name === 'skill1') return { name, type: name, duration, hitAt: .5, projectile: true, speed: 920 }; + return { name, type: name, duration, hitAt: name === 'skill2' ? .53 : .5, area: true, reach: 220 }; + } + // Horizontal distance at which a CPU move connects: projectiles go far, dashes add travel. + function cpuReach(m) { return m.projectile ? (m.range || 560) : (m.area ? m.reach : m.reach + 27) + (m.dash ? m.dash * m.duration * .4 : 0); } + // Open a chain only from where every link of it still reaches (knockback slides the player back a little). + function cpuChainReach(P) { let r = Infinity; for (let i = 1; i <= Math.max(1, P.combo); i++) r = Math.min(r, cpuReach(cpuMove('attack', i))); return Math.max(60, r - 14); } + function cpuHitTime(m) { return m.duration * (m.hitAt || .5) + (m.projectile ? Math.max(0, Math.abs(hero.x - dummy.x) - 100) / (m.speed || 800) : 0); } + // True when the hit would land on a vulnerable player: inside the current hurt window, after post-hurt immunity, + // or after a skill/ultimate the player is immune during. A fresh engagement never starts on a stunned player: + // only the confirmed chain (combo + one ender) may use the stun, so there are no infinite loops. + function cpuLands(m, fresh = false) { + const t = cpuHitTime(m), a = hero.action; + if (fresh && hero.hurtTime > 0) return false; + if (a && (a.type.startsWith('skill') || a.type === 'ultimate')) return t >= a.duration - a.t; + if (hero.hurtTime > 0) return t < hero.hurtTime - .004; + return hero.invuln < t - .03; + } + // Jump timing that puts the feet above a projectile at the moment of contact; double jump for high shots. + function cpuJumpPlan(height) { + const need = height + 12, v1 = CONFIG.jumpSpeed, v2 = CONFIG.doubleJumpSpeed, g = CONFIG.gravity, second = .16; + for (const double of [false, true]) { + let first = -1, last = -1; + for (let t = 0; t < 1.2; t += 1 / 120) { + const y = !double || t < second ? v1 * t - g * t * t / 2 : (v1 * second - g * second * second / 2) + v2 * (t - second) - g * (t - second) ** 2 / 2; + if (y < 0) break; + if (y >= need) { if (first < 0) first = t; last = t; } + } + if (first >= 0) return { double, at: (first + last) / 2 }; + } + return null; + } + function cpuJump(double) { dummy.vy = -CONFIG.jumpSpeed; dummy.y -= 1; dummy.jumps = 1; dummy.vx = 0; dummy.state = 'jump'; dummy.stateTime = 0; cpu.doubleAt = double ? time + .16 : 0; } + function cpuStart(name, index = 1) { + if (!startEnemyAction(name, index)) return false; + dummy.vx = 0; cpu.plan = null; + if (name === 'ultimate' && opponentCharacter === 'fenr') showFenrTransformation(dummy); + return true; + } + // Fastest move that reaches the player now (melee first, then area skill), or null. + // The player walking in or pressing buttons: poke from the full reach of attack1 to win the exchange. + function cpuPoking() { const toward = Math.sign(dummy.x - hero.x); return hero.vx * toward > 40 || !!hero.action; } + function cpuPick(P, dist, allowSkill) { + const options = [['attack', 1], ...(allowSkill ? [['skill2', 1], ['skill1', 1]] : [])]; + for (const [name, index] of options) { + if (name !== 'attack' && dummy.cooldowns[name] > 0) continue; + const m = cpuMove(name, index); + if (m.guard && !(hero.action?.type === 'attack' && !hero.action.fired)) continue; + const walkIn = Math.max(0, hero.vx * Math.sign(dummy.x - hero.x)) * cpuHitTime(m) * P.evade; + if (m.projectile ? dist < 180 : dist > (name === 'attack' ? (cpuPoking() ? cpuReach(m) - 6 + walkIn : cpuChainReach(P)) : cpuReach(m) + walkIn)) continue; + return { name, index, m }; + } + return null; + } + function cpuFollowUp(a, P) { + const dist = Math.abs(hero.x - dummy.x), connected = hero.hurtTime > 0; + if (a.type !== 'attack') return; + if (a.index < P.combo) { + const m = cpuMove('attack', a.index + 1), reach = cpuReach(m) - 8; + // Chain only on a confirmed hit (or by mistake on lower levels); a blind chain would hit immunity. + // Knockback slides the player back, so step in first while the stun still covers the next hit. + if (connected && cpuLands(m)) { + if (dist <= reach) { startEnemyAction('attack', a.index + 1); return; } + if ((dist - reach) / (420 * P.speed) + cpuHitTime(m) < hero.hurtTime - .02) { cpu.plan = { kind: 'chain', index: a.index + 1 }; dummy.aiThink = 0; return; } + } else if (!connected && dist <= reach && random() < P.mistakes) { startEnemyAction('attack', a.index + 1); return; } + } + if (connected && random() < P.ender) for (const name of ['skill2', 'skill1']) { + if (dummy.cooldowns[name] > 0) continue; + const m = cpuMove(name); + if (m.guard) continue; + if ((m.projectile || dist <= cpuReach(m)) && cpuLands(m) && cpuStart(name)) return; + } + } + function updateFenrAI(dt) { + const P = cpuProfile(); + dummy.aiTime += dt; dummy.aiThink = Math.max(0, dummy.aiThink - dt); + if (['hurt', 'down', 'recover'].includes(dummy.state)) { dummy.action = null; cpu.plan = null; dummy.aiThink = Math.max(dummy.aiThink, P.reaction + P.wake); return; } + if (dummy.action) { + const a = dummy.action; a.t += dt; dummy.state = a.name; + if (a.dash && a.t > a.duration * .2 && a.t < a.duration * .6) dummy.vx = dummy.facing * a.dash; + if (!a.fired && a.type !== 'ultimate' && a.t >= a.duration * a.hitAt) { a.fired = true; enemyStrike(a); } + if (a.t >= a.duration || (opponentCharacter !== 'fenr' && a.type === 'skill1' && a.fired)) { + dummy.action = null; dummy.state = 'idle'; dummy.stateTime = 0; dummy.aiThink = P.recovery; + if (hero.hp > 0) cpuFollowUp(a, P); + } + return; + } + const airborne = dummy.y < CONFIG.groundY || dummy.vy < 0; + if (!aiEnabled || hero.hp <= 0) { dummy.vx = approach(dummy.vx, 0, dt * 1500); if (!airborne) dummy.state = 'idle'; return; } + if (airborne) { + if (cpu.doubleAt && time >= cpu.doubleAt && dummy.jumps < 2) { dummy.jumps = 2; dummy.vy = -CONFIG.doubleJumpSpeed; cpu.doubleAt = 0; } + dummy.state = 'jump'; return; + } + const dx = hero.x - dummy.x, dist = Math.abs(dx); dummy.facing = dx >= 0 ? 1 : -1; + // What the player is doing, seen with a human-like delay. + if (hero.action !== cpu.heroAction) { cpu.heroAction = hero.action; cpu.heroActionAt = time; } + if (!hero.grounded !== cpu.airborne) { cpu.airborne = !hero.grounded; cpu.airAt = time; cpu.antiAirRead = false; } + const seen = since => time - since >= P.reaction; + // 1. Projectiles: jump so the shot passes under the feet at contact (decided once per shot). + for (const p of projectiles) { + if (p.gravity) { + // A lob cannot be jumped: run out of its splash by the nearer side before it lands. + if (p.owner === 'enemy' || cpu.decided.has(p)) continue; + if (!cpu.seen.has(p)) cpu.seen.set(p, time); + if (!seen(cpu.seen.get(p))) continue; + cpu.decided.add(p); + let dir = Math.sign(dummy.x - p.landX) || -dummy.facing; + if ((dir < 0 ? dummy.x - 110 : W - 100 - dummy.x) < p.splash + 20) dir = -dir; + if (Math.abs(dummy.x - p.landX) < p.splash + 30 && random() < P.evade) cpu.plan = { kind: 'sidestep', dir, until: p.landAt + .05 }; + continue; + } + if (p.owner === 'enemy' || cpu.decided.has(p) || Math.sign(p.vx) !== Math.sign(dummy.x - p.x)) continue; + if (!cpu.seen.has(p)) cpu.seen.set(p, time); + if (!seen(cpu.seen.get(p))) continue; + const tti = (Math.abs(dummy.x - p.x) - 30) / Math.abs(p.vx); + if (tti <= 0) { cpu.decided.add(p); continue; } + const plan = cpuJumpPlan(CONFIG.groundY - (p.y + (p.vy || 0) * tti)); + if (plan && tti > plan.at + 1 / 60) continue; + cpu.decided.add(p); + if (plan && random() < P.evade) { cpuJump(plan.double); return; } + } + // 1b. NAJA's locked sand ripple under the CPU: run out of it by the nearer edge (away from a wall) and keep going + // until the cobra has erupted (decided once per strike). A late read (low levels) cannot get out in time. + for (const k of serpent?.strikes || []) { + if (k.state !== 'locked' || cpu.decided.has(k) || time - k.lockedAt < P.reaction) continue; + cpu.decided.add(k); + let dir = Math.sign(dummy.x - k.x) || -dummy.facing; + if ((dir < 0 ? dummy.x - 110 : W - 100 - dummy.x) < N.serpent.radius + 20) dir = -dir; + if (Math.abs(dummy.x - k.x) < N.serpent.radius + 30 && random() < P.evade) cpu.plan = { kind: 'sidestep', dir, until: k.lockedAt + N.serpent.telegraph + .05 }; + } + // 2. A player move just started: step out of a melee startup, or plan to punish its recovery (once per move). + const ha = hero.action; + if (ha && cpu.read !== ha && seen(cpu.heroActionAt)) { + const reach = ha.type === 'attack' ? attackRange(ha.index) + 27 : ha.type === 'skill2' ? 230 : 0, room = dummy.facing > 0 ? dummy.x - 110 : W - 100 - dummy.x; + cpu.read = ha; cpu.respect = room > 90 && random() < P.evade; + const toHit = ha.duration * (ha.hitAt || (ha.type === 'skill2' ? .53 : .5)) - ha.t, backOff = (reach + 18 - dist) / (380 * P.speed); + if (!ha.fired && dist < reach + 15 && random() < P.evade) { + const hop = cpuJumpPlan(122); + if (room > 90 && backOff < toHit) cpu.plan = { kind: 'retreat', until: time + Math.min(.3, backOff + .06) }; + else if (hop && toHit > hop.at - .02) cpu.plan = { kind: 'hop', at: time + Math.max(0, toHit - hop.at), double: hop.double }; + } else if (random() < P.punish) cpu.plan = { kind: 'punish', action: ha }; + if (cpu.plan?.kind !== 'punish' && random() < P.punish) cpu.followPunish = ha; else cpu.followPunish = null; + } + if (cpu.plan?.kind === 'hop' && time >= cpu.plan.at) { const double = cpu.plan.double; cpu.plan = cpu.followPunish ? { kind: 'punish', action: cpu.followPunish } : null; cpuJump(double); return; } + if (cpu.plan?.kind === 'retreat' && time >= cpu.plan.until && cpu.followPunish) cpu.plan = { kind: 'punish', action: cpu.followPunish }; + // 3. Anti-air: meet a landing jump with a grounded hit (decided once per jump). + if (cpu.airborne && !cpu.antiAirRead && seen(cpu.airAt)) { cpu.antiAirRead = true; if (random() < P.antiAir) cpu.plan = { kind: 'antiair' }; } + if (cpu.plan?.kind === 'antiair') { + const m = cpuMove('attack', 1), t = cpuHitTime(m), landY = hero.y + hero.vy * t + CONFIG.gravity * t * t / 2; + if (hero.grounded) cpu.plan = null; + else if (dist <= cpuReach(m) && hero.vy > 0 && Math.abs(Math.min(landY, CONFIG.groundY) - dummy.y) < 105 && cpuLands(m, true) && cpuStart('attack', 1)) return; + } + if (cpu.plan?.kind === 'chain') { + // Finish a confirmed chain: close the knockback gap, then strike while the player is still stunned. + const m = cpuMove('attack', cpu.plan.index); + if (!(hero.hurtTime > 0) || !cpuLands(m)) cpu.plan = null; + else if (dist <= cpuReach(m) - 8 && cpuStart('attack', cpu.plan.index)) return; + } + if (cpu.plan?.kind === 'punish') { + const pa = cpu.plan.action, pick = cpuPick(P, dist, true); + if (hero.action !== pa) cpu.plan = hero.action || hero.hurtTime > 0 ? null : cpu.plan; + // A whiffed attack leaves the player open for its whole recovery; skills/ultimate are immune until they end. + const open = !hero.action ? hero.invuln <= 0 : pa.type === 'attack' ? pa.fired && !(dummy.state === 'hurt') : false; + if (cpu.plan && pick && (open || !(pa.type === 'attack')) && cpuLands(pick.m, true) && cpuHitTime(pick.m) - Math.max(0, hero.action ? hero.action.duration - hero.action.t : 0) < .15 && cpuStart(pick.name, pick.index)) return; + if (cpu.plan && !hero.action && !pick && hero.invuln <= 0 && time - cpu.heroActionAt > 1.2) cpu.plan = null; + } + // Movement: retreat plan, close in (run when far) or rush a punish, hold just outside a startup, or wait in reach + // while the player is protected. + const reach = (cpuPoking() ? cpuReach(cpuMove('attack', 1)) - 6 : cpuChainReach(P)) + 8, protectedNow = hero.invuln > 0 || hero.hurtTime > 0 || (ha && (ha.type.startsWith('skill') || ha.type === 'ultimate')); + const threat = ha && !ha.fired && (ha.type === 'attack' || ha.type === 'skill2') ? (ha.type === 'attack' ? attackRange(ha.index) + 27 : 230) : 0; + let wanted = 0; + if (cpu.plan?.kind === 'retreat' && time < cpu.plan.until) wanted = -dummy.facing * 380; + else if (cpu.plan?.kind === 'sidestep' && time < cpu.plan.until) wanted = cpu.plan.dir * 420; + else { + if (cpu.plan?.kind === 'retreat' || cpu.plan?.kind === 'sidestep') cpu.plan = null; + const rushing = cpu.plan?.kind === 'punish' || cpu.plan?.kind === 'chain'; + const punishWait = cpu.plan?.kind === 'punish' && threat && !ha.fired; + // An immune (not stunned) player can still hit: wait just outside their reach, then close in so the hit lands + // as the immunity ends. A stunned player is approached so the confirmed chain can continue. + const immuneFor = hero.hurtTime > 0 ? 0 : Math.max(hero.invuln, ha && (ha.type.startsWith('skill') || ha.type === 'ultimate') ? ha.duration - ha.t : 0); + const closeIn = Math.max(0, dist - reach) / (420 * P.speed) + cpuHitTime(cpuMove('attack', 1)) + .05; + const outside = attackRange(1) + 27 + 20, keepOut = (threat && (punishWait || (!rushing && cpu.read === ha && cpu.respect))) || immuneFor > closeIn; + const goal = keepOut ? Math.max(reach, threat ? threat + 18 : outside) : reach - (protectedNow && hero.hurtTime > 0 ? 20 : 12); + wanted = dist > goal ? dummy.facing * (dist > 300 || (rushing && !punishWait) || immuneFor > 0 ? 420 : 240) : dist < Math.min(55, goal - 20) ? -dummy.facing * 150 : keepOut && dist < goal - 6 ? -dummy.facing * 300 : 0; + } + dummy.vx = approach(dummy.vx, clamp(wanted * P.speed, -520, 520), dt * 2200); dummy.walkPhase += Math.abs(dummy.vx) * dt / 150; + const next = Math.abs(dummy.vx) > 5 ? (Math.abs(dummy.vx) > 300 ? 'run' : 'walk') : 'idle'; if (next !== dummy.state) { dummy.state = next; dummy.stateTime = 0; } + if (dummy.aiThink > 0 || cpu.plan?.kind === 'chain' || cpu.plan?.kind === 'sidestep') return; + // 4. Decisions on the think timer. + const allowSkill = P.skillEvery === 1 || Math.floor(dummy.aiTime / 2) % P.skillEvery === 0; + if (dummy.aiTime > P.ultimateAfter && dummy.form === 'human' && dummy.cooldowns.ultimate <= 0 && cpuStart('ultimate')) { dummy.aiThink = P.reaction; return; } + const pick = cpuPick(P, dist, allowSkill); + if (pick && random() < P.aggression && (cpuLands(pick.m, true) || (hero.hurtTime <= 0 && random() < P.mistakes))) { + if (cpuStart(pick.name, pick.index)) { if (pick.name === 'attack') dummy.aiChain++; dummy.aiThink = P.reaction; return; } + } + // Not landable yet (player protected or hesitation): check again very soon instead of idling a full beat. + dummy.aiThink = pick || dist < 260 ? Math.min(P.reaction, 1 / 60) : P.reaction * .5; + } + function opponentPose() { + const m=opponentCharacter==='arco'?window.MECHA_MANIFEST:opponentCharacter==='mira'?window.MIRA_MANIFEST:opponentCharacter==='cora'?window.CORA_MANIFEST:opponentCharacter==='naja'?window.NAJA_MANIFEST:opponentCharacter==='haldor'?window.HALDOR_MANIFEST:opponentCharacter==='zanni'?window.ZANNI_MANIFEST:opponentCharacter==='isolde'?window.ISOLDE_MANIFEST:opponentCharacter==='rhea'?window.RHEA_MANIFEST:opponentCharacter==='solan'?window.SOLAN_MANIFEST:opponentCharacter==='nib'?window.NIB_MANIFEST:opponentCharacter==='edda'?window.EDDA_MANIFEST:dummy.form==='wolf'?window.FENR_WOLF_MANIFEST:window.FENR_HUMAN_MANIFEST; + const name=m?.frame_layout?.rows?.[dummy.state]?dummy.state:'idle',list=m?.frame_layout?.rows?.[name]||[]; + let index=0; + // Projectile casts that release at 50% show wind-up -> thrust -> full extension, like the player's pose. + if(dummy.action?.type==='skill1'&&opponentCharacter!=='fenr')index=Math.min(2,list.length-1,Math.floor(dummy.action.t/(dummy.action.duration*.5)*3)); + else if(dummy.action)index=Math.floor(dummy.action.t/dummy.action.duration*list.length); + else if(name==='walk'||name==='run')index=Math.floor(dummy.walkPhase*list.length)%list.length; + else if(name==='down')index=Math.floor(dummy.stateTime/.55*list.length); + else if(name==='recover')index=Math.floor(dummy.stateTime/.4*list.length); + else index=Math.floor(dummy.stateTime*(m?.animation?.rows?.[name]?.fps||5)); + if(m?.animation?.rows?.[name]?.loop)index%=list.length; + return {state:name,frame:clamp(index,0,Math.max(0,list.length-1)),manifest:m}; + } + function updateVisuals(dt) { + for (let i = particles.length - 1; i >= 0; i--) { const p = particles[i]; p.life -= dt; p.x += p.vx * dt; p.y += p.vy * dt; p.vy += (p.kind === 'dust' || p.kind==='run-smoke' ? -40 : 680) * dt; p.vx *= Math.exp(-dt * 3); if (p.life <= 0) particles.splice(i, 1); } + for (let i = effects.length - 1; i >= 0; i--) { effects[i].life -= dt; if (effects[i].life <= 0) effects.splice(i, 1); } + for (let i = damageNumbers.length - 1; i >= 0; i--) { damageNumbers[i].life -= dt; damageNumbers[i].y -= dt * 55; if (damageNumbers[i].life <= 0) damageNumbers.splice(i, 1); } + } + + function drawBackground(shakeX, shakeY) { + const stage = images.stage; + if (stage) { + // Cover the viewport while aligning the measured stage floor with the combat floor. + // Combat remains a uniform 16:9 projection, so fighters never stretch on tall screens. + const backdropScale = Math.max(viewW / W, viewH / H); + const w = W * backdropScale, h = H * backdropScale; + const x = (viewW - w) / 2 + shakeX * scale, y = viewH * .815 - CONFIG.groundY * backdropScale + shakeY * scale; + ctx.drawImage(stage, 0, 0, stage.width, stage.height, x, y, w, h); + if (x > 0) ctx.drawImage(stage, 0, 0, 1, stage.height, 0, y, x + 1, h); + if (x + w < viewW) ctx.drawImage(stage, stage.width - 1, 0, 1, stage.height, x + w - 1, y, viewW - x - w + 1, h); + if (y > 0) ctx.drawImage(stage, 0, 0, stage.width, 1, 0, 0, viewW, y + 1); + if (y + h < viewH) ctx.drawImage(stage, 0, stage.height - 1, stage.width, 1, 0, y + h - 1, viewW, viewH - y - h + 1); + // Overscan edge strips hide the shake without zooming or cropping the artwork. + ctx.drawImage(stage, 0, 0, 1, stage.height, x - 45 * scale, y, 45 * scale + 1, h); + ctx.drawImage(stage, stage.width - 1, 0, 1, stage.height, x + w - 1, y, 45 * scale + 1, h); + } else { ctx.fillStyle = '#9fd3df'; ctx.fillRect(0, 0, viewW, viewH); } + if (cinematic > 0) { ctx.fillStyle = `rgba(9,26,39,${Math.min(.65, cinematic * 1.5)})`; ctx.fillRect(0, 0, viewW, viewH); } + } + function shadow(x, y, width, opacity = .22) { ctx.fillStyle = `rgba(29,46,45,${opacity})`; ctx.beginPath(); ctx.ellipse(Math.round(x), Math.round(y + 2), width, 10, 0, 0, Math.PI * 2); ctx.fill(); } + function heroPose() { + const list = frames(hero.state).length ? frames(hero.state) : frames('idle'); + const row = manifest?.animation?.rows?.[hero.state] || { fps: 8, loop: true }; + const result = { state: hero.state, frame: 0, phase: hero.grounded ? 'grounded' : hero.airTime < .07 ? 'takeoff' : Math.abs(hero.vy) <= 100 ? 'apex' : hero.vy < 0 ? 'rising' : 'falling', rotation: 0, pivot: null, worldPivot: null, scale: Math.max(1, Math.round(manifest?.runtime?.scale || manifest?.runtime_scale || 1)) }; + let index = 0; + if (hero.state === 'jump') { + // The flight arc comes from physics. Hold one calm airborne pose, including at the apex. + // Replaying the old takeoff/crouch strip in midair caused a visible body-size pop. + index = metrics?.playback?.jump?.airFrame ?? Math.min(2, list.length - 1); + } else if (hero.state === 'doublejump') { + const playback = metrics?.playback?.doublejump; + index = playback?.frame ?? 0; + const bounds = metrics?.states?.doublejump?.frames?.[index]?.bounds; + result.pivot = playback?.pivot || { x: 0, y: bounds ? (bounds.top + bounds.bottom) / 2 : -75 }; + result.worldPivot = metrics?.playback?.jump?.torsoPivot || { x: 0, y: -95 }; + // Positive canvas rotation flips with facing, producing a forward somersault in either direction. + result.rotation = clamp(1 - hero.doublePose / hero.rollDuration, 0, 1) * Math.PI * 2; + result.phase = 'somersault'; + } else if (hero.state === 'walk' || hero.state === 'run') index = Math.floor(hero.walkPhase * list.length) % list.length; + else if (hero.action?.type === 'skill1') index = Math.min(2,list.length-1,Math.floor(hero.action.t/(hero.action.duration*.5)*3)); + else if (hero.action) index = Math.min(list.length - 1, Math.floor(hero.action.t / hero.action.duration * list.length)); + else if (row.loop === false) index = Math.min(list.length - 1, Math.floor(hero.animTime * (row.fps || 8))); + else index = Math.floor(hero.animTime * (row.fps || 8)) % list.length; + result.frame = clamp(index, 0, Math.max(0, list.length - 1)); + return result; + } + function drawHero() { + shadow(hero.x, CONFIG.groundY, Math.max(19, (selectedCharacter === 'mira' ? 63 : selectedCharacter === 'haldor' ? 58 : 52) - (CONFIG.groundY - hero.y) * .07), .20); + if (!images.hero || !manifest) return; + let list = frames(hero.state); if (!list.length) list = frames('idle'); if (!list.length) return; + const pose = heroPose(), index = pose.frame; + const r = list[index], cw = manifest.cell?.width || manifest.cell?.w || r.w, ch = manifest.cell?.height || manifest.cell?.h || r.h; + const anchorX = manifest.cell?.anchor_x ?? cw / 2, anchorY = manifest.cell?.anchor_y ?? ch - (manifest.cell?.safe_margin_y || 0); + const spriteScale = pose.scale; + ctx.save(); ctx.translate(Math.round(hero.x), Math.round(hero.y)); ctx.scale(hero.facing * spriteScale, spriteScale); ctx.imageSmoothingEnabled = false; + if (pose.pivot) { ctx.translate(pose.worldPivot.x, pose.worldPivot.y); ctx.rotate(pose.rotation); ctx.translate(-pose.pivot.x, -pose.pivot.y); } + ctx.drawImage(images.hero, r.x, r.y, r.w, r.h, -anchorX, -anchorY, r.w, r.h); ctx.restore(); + } + function drawDummy() { + const pose=opponentPose(),m=pose.manifest,img=images[opponentCharacter==='fenr'?(dummy.form==='wolf'?'fenrWolf':'fenrHuman'):opponentCharacter]; + shadow(dummy.x,CONFIG.groundY,dummy.state==='down'?65:opponentCharacter==='mira'?61:opponentCharacter==='haldor'?55:43,.20); + if(!m||!img)return; + const r=m.frame_layout.rows[pose.state][pose.frame]; + ctx.save();ctx.translate(Math.round(dummy.x),Math.round(dummy.y));ctx.scale(dummy.facing||-1,1);ctx.imageSmoothingEnabled=false; + ctx.drawImage(img,r.x,r.y,r.w,r.h,-m.cell.width/2,-(m.cell.height-m.cell.safe_margin_y),r.w,r.h);ctx.restore(); + if(dummy.action && !dummy.action.fired && dummy.action.type!=='ultimate') { + ctx.save();ctx.strokeStyle='#f0b15e';ctx.lineWidth=2;ctx.globalAlpha=.7; + ctx.beginPath();ctx.ellipse(dummy.x,CONFIG.groundY+3,37,7,0,0,Math.PI*2);ctx.stroke();ctx.restore(); + } + } + function drawEffects() { + for (const p of projectiles) { + if(p.asset && images['fx-'+p.asset]){const w=p.size||84,h=p.size||76;ctx.save();ctx.translate(p.x,p.y);ctx.scale(p.facing,1);if(p.vy)ctx.rotate(Math.atan2(p.vy,Math.abs(p.vx)));if(p.spin)ctx.rotate(realTime*p.spin);ctx.drawImage(images['fx-'+p.asset],-w/2,-h/2,w,h);ctx.restore();continue;} + ctx.save(); ctx.translate(Math.round(p.x), Math.round(p.y)); ctx.scale(p.facing, 1); + const glow = ctx.createLinearGradient(-75, 0, 15, 0); glow.addColorStop(0, '#73f2dd00'); glow.addColorStop(1, '#affff1bb'); ctx.fillStyle = glow; ctx.beginPath(); ctx.moveTo(-80, -5); ctx.lineTo(10, -11); ctx.lineTo(25, 0); ctx.lineTo(10, 11); ctx.lineTo(-80, 5); ctx.fill(); + ctx.fillStyle = '#234f55'; ctx.fillRect(-17, -8, 31, 16); ctx.fillStyle = '#97b4ad'; ctx.fillRect(-14, -6, 29, 11); ctx.fillStyle = '#dbe4c7'; ctx.fillRect(-12, -6, 25, 3); ctx.fillStyle = '#98723f'; ctx.fillRect(-18, -9, 6, 18); ctx.fillStyle = '#efcc84'; ctx.fillRect(-17, -8, 3, 16); ctx.fillStyle = '#71d9c8'; ctx.beginPath(); ctx.moveTo(13, -6); ctx.lineTo(23, 0); ctx.lineTo(13, 7); ctx.fill(); ctx.restore(); + } + for (const e of effects) { + const t = 1 - e.life / e.maxLife; ctx.save(); ctx.translate(Math.round(e.x), Math.round(e.y)); ctx.globalAlpha = Math.min(1, e.life / e.maxLife * 2); ctx.lineCap = 'square'; + if((e.type==='fenr-fx'||e.type==='mira-fx'||e.type==='cora-fx'||e.type==='naja-fx'||e.type==='haldor-fx'||e.type==='zanni-fx'||e.type==='isolde-fx'||e.type==='rhea-fx'||e.type==='solan-fx'||e.type==='nib-fx'||e.type==='edda-fx') && images['fx-'+e.asset]){ctx.scale(e.facing||1,1);const size=e.size*(.75+t*.4);ctx.drawImage(images['fx-'+e.asset],-size/2,-size/2,size,size);} + // EDDA's tortoise spirit keeps one size; it fades in and out and lifts before each stomp. + if(e.type==='edda-spirit'&&images['fx-'+e.asset]){ctx.globalAlpha=Math.min(1,t*6,(1-t)*5)*.92;ctx.scale(e.facing||1,1);ctx.drawImage(images['fx-'+e.asset],-e.size/2,-e.size-e.lift,e.size,e.size);} + if (e.type === 'slash') { ctx.scale(e.facing, 1); ctx.rotate(e.index === 2 ? -.5 : .15); ctx.strokeStyle = e.color; ctx.lineWidth = 9 * (1 - t) + 2; ctx.beginPath(); ctx.arc(10, 4, 55 + e.index * 10, -1.2 + t * .3, 1.0 + t * .4); ctx.stroke(); ctx.strokeStyle = '#ffffffaa'; ctx.lineWidth = 3; ctx.beginPath(); ctx.arc(4, 4, 46 + e.index * 10, -.85, .7); ctx.stroke(); } + if (e.type === 'whip') { ctx.scale(e.facing, 1); const bend = (e.index === 2 ? 22 : -26) * (1 - t), end = e.tip * (.7 + .3 * Math.min(1, t * 4)); ctx.lineCap = 'round'; for (const [w, c] of [[7 * (1 - t) + 2, e.color], [2, '#ffffffcc']]) { ctx.strokeStyle = c; ctx.lineWidth = w; ctx.beginPath(); ctx.moveTo(26, 0); ctx.quadraticCurveTo(end * .55, bend, end, e.index === 2 ? 10 : -4); ctx.stroke(); } } + if (e.type === 'airpulse') { ctx.strokeStyle = '#d0fff1'; ctx.lineWidth = 5 * (1 - t); ctx.beginPath(); ctx.ellipse(0, 0, 25 + t * 60, 8 + t * 20, 0, 0, Math.PI * 2); ctx.stroke(); for (let i = 0; i < 5; i++) { ctx.fillStyle = '#7adbcf'; ctx.fillRect(-38 + i * 18, 10 + t * 35, 4, 17 * (1 - t)); } } + if (e.type === 'impact' || e.type === 'muzzle') { ctx.rotate(t * .4); ctx.fillStyle = e.color || '#b2ffef'; for (let i = 0; i < 8; i++) { ctx.rotate(Math.PI / 4); ctx.beginPath(); ctx.moveTo(5, -3); ctx.lineTo((e.heavy ? 60 : 40) * (1 - t) + 7, 0); ctx.lineTo(5, 4); ctx.fill(); } ctx.fillStyle = '#fff9e5'; ctx.fillRect(-7, -7, 14, 14); } + if (e.type === 'slam') { ctx.strokeStyle = '#ccf0ff'; ctx.lineWidth = 8 * (1 - t) + 1; ctx.beginPath(); ctx.ellipse(0, -2, 35 + t * 240, 10 + t * 35, 0, 0, Math.PI * 2); ctx.stroke(); for (let i = -3; i <= 3; i++) { const height = (95 - Math.abs(i) * 12) * Math.sin(Math.PI * Math.min(t * 1.4, 1)); ctx.fillStyle = i % 2 ? '#76b1c9dd' : '#d7eff2cc'; ctx.beginPath(); ctx.moveTo(i * 48 - 15, 0); ctx.lineTo(i * 48 - 8, -height); ctx.lineTo(i * 48 + 16, -height * .6); ctx.lineTo(i * 48 + 21, 0); ctx.closePath(); ctx.fill(); } } + if (e.type === 'charge' || e.type === 'ultimate-ring') { ctx.strokeStyle = '#e7bb79'; ctx.lineWidth = 3; const radius = e.type === 'charge' ? 100 - t * 65 : 70 + t * 150; ctx.rotate(realTime * 2); ctx.strokeRect(-radius * .7, -radius * .7, radius * 1.4, radius * 1.4); ctx.rotate(Math.PI / 4); ctx.strokeRect(-radius * .6, -radius * .6, radius * 1.2, radius * 1.2); ctx.beginPath(); ctx.arc(0, 0, radius, 0, Math.PI * 2); ctx.stroke(); } + if (e.type === 'drone-laser') { + const dx = e.endX - e.x, dy = e.endY - e.y, length = Math.hypot(dx, dy); + ctx.rotate(Math.atan2(dy, dx)); ctx.lineCap = 'round'; + const strength = Math.min(1, (1 - t) * 3), width = 9 * strength; + ctx.strokeStyle = '#50eedb35'; ctx.lineWidth = width * 3.2; ctx.beginPath(); ctx.moveTo(0,0); ctx.lineTo(length,0); ctx.stroke(); + ctx.strokeStyle = '#f9ce78d9'; ctx.lineWidth = width * 1.5; ctx.stroke(); + ctx.strokeStyle = '#fffbe7'; ctx.lineWidth = Math.max(1.4, width * .4); ctx.stroke(); + ctx.fillStyle = '#ffffe6'; ctx.beginPath(); ctx.arc(0,0,width*.85,0,Math.PI*2); ctx.fill(); + } + ctx.restore(); + } + for (const p of particles) { + ctx.save(); ctx.globalAlpha=clamp(p.life/.3,0,1); ctx.fillStyle=p.color; const size=Math.max(1,Math.round(p.size)); + if(p.kind==='run-smoke') { + const age=1-p.life/p.maxLife, radius=p.size+age*13, x=Math.round(p.x), y=Math.round(p.y); + ctx.translate(x,y); ctx.scale(1,.68); + const fog=ctx.createRadialGradient(0,0,0,0,0,radius); fog.addColorStop(0,'#edf0e0eb'); fog.addColorStop(.45,'#cbd2c2cc'); fog.addColorStop(1,'#b4c0ac00'); + ctx.globalAlpha*=.9; ctx.fillStyle=fog; ctx.beginPath(); ctx.arc(0,0,radius,0,Math.PI*2); ctx.fill(); + } else if(p.kind==='dust') {ctx.globalAlpha*=.35;ctx.fillRect(Math.round(p.x),Math.round(p.y),size*3,size*2);} + else ctx.fillRect(Math.round(p.x),Math.round(p.y),size,size); + ctx.restore(); + } + for (const n of damageNumbers) { ctx.save(); ctx.globalAlpha = Math.min(1, n.life * 2.5); ctx.font = `900 ${n.amount >= 30 ? 31 : 25}px Rajdhani`; ctx.textAlign = 'center'; ctx.lineWidth = 4; ctx.strokeStyle = '#254247'; ctx.strokeText(n.amount, Math.round(n.x), Math.round(n.y)); ctx.fillStyle = n.color; ctx.fillText(n.amount, Math.round(n.x), Math.round(n.y)); ctx.restore(); } + } + function drawSquadron(group = squad) { + if (!group || !images.drone) return; + for (const d of group.drones) { + if (!d.visible || d.alpha <= 0) continue; + const width = SQUAD.droneWidth, height = images.drone.height / images.drone.width * width; + ctx.save(); ctx.translate(Math.round(d.x),Math.round(d.y)); ctx.rotate(d.angle); ctx.scale(group.facing,1); ctx.globalAlpha = d.alpha; + const moving = group.t < d.delay + .6 || group.t > SQUAD.departure; + ctx.fillStyle = '#83ffe397'; ctx.beginPath(); ctx.moveTo(-width*.38,-5); ctx.lineTo(-width*.5-(moving?38:13),0); ctx.lineTo(-width*.38,5); ctx.fill(); + if (!d.fired && group.t > 1.1) { ctx.strokeStyle = '#7af9e2aa'; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(width*.38,0,11+(group.t% .15)*35,0,Math.PI*2); ctx.stroke(); } + ctx.imageSmoothingEnabled = false; ctx.drawImage(images.drone,-width/2,-height/2,width,height); ctx.restore(); + } + } + function drawParade(group) { + if (!group) return; + const img = images['fx-mira-rocket']; + for (const r of group.rockets) { + if (r.state !== 'flying') continue; + ctx.save(); ctx.translate(Math.round(r.x), Math.round(r.y)); ctx.rotate(r.angle); ctx.imageSmoothingEnabled = false; + if (img) ctx.drawImage(img, -26, -26, 52, 52); + else { ctx.fillStyle = '#c9a8f2'; ctx.fillRect(-14, -5, 22, 10); ctx.fillStyle = '#ffe27a'; ctx.beginPath(); ctx.moveTo(8, -5); ctx.lineTo(16, 0); ctx.lineTo(8, 5); ctx.fill(); } + ctx.restore(); + } + } + function drawMurmuration(group) { + if (!group) return; + const img = images['fx-cora-raven'], U = C.murmuration, tail = (U.ravens - 1) * U.spacing; + for (const p of group.passes) { + if (p.state !== 'flying') continue; + // A faint violet wake marks the height of the pass so the dodge window reads at a glance. + const tailX = p.front - group.facing * tail, wake = ctx.createLinearGradient(tailX, 0, p.front, 0); + wake.addColorStop(0, '#3b2a6a00'); wake.addColorStop(1, '#3b2a6a5c'); ctx.fillStyle = wake; ctx.fillRect(Math.min(tailX, p.front), p.y - 6, tail, 12); + for (let k = 0; k < U.ravens; k++) { + const x = p.front - group.facing * k * U.spacing; + if (x < -60 || x > W + 60) continue; + const y = p.y + (k % 2 ? 16 : -16) + Math.sin(group.t * 9 + k * 1.7 + p.id) * 9, flap = .55 + .45 * Math.abs(Math.cos(group.t * 16 + k * 1.3)), size = 60 - (k % 3) * 6; + ctx.save(); ctx.translate(Math.round(x), Math.round(y)); ctx.scale(group.facing, flap); ctx.imageSmoothingEnabled = false; + if (img) ctx.drawImage(img, -size / 2, -size / 2, size, size); + else { ctx.fillStyle = '#231a33'; ctx.beginPath(); ctx.moveTo(14, 0); ctx.lineTo(-12, -9); ctx.lineTo(-6, 0); ctx.lineTo(-12, 9); ctx.fill(); } + ctx.restore(); + } + } + } + function drawSerpent(group) { + if (!group) return; + const ripple = images['fx-naja-ripple'], cobra = images['fx-naja-serpent'], U = N.serpent; + for (const k of group.strikes) { + if (k.state === 'tracking' || k.state === 'locked') { + // The ripple pulses while it follows and turns bright and still once locked: that is the cue to move. + const locked = k.state === 'locked', w = U.radius * 2 + 20, pulse = locked ? 1 : .55 + .2 * Math.sin(group.t * 14 + k.id); + ctx.save(); ctx.translate(Math.round(k.x + (locked ? Math.sin(realTime * 70) * 2 : 0)), CONFIG.groundY); ctx.globalAlpha = pulse; ctx.imageSmoothingEnabled = false; + if (ripple) ctx.drawImage(ripple, -w / 2, -w / 2, w, w); + else { ctx.strokeStyle = '#e3bf78'; ctx.lineWidth = 4; ctx.beginPath(); ctx.ellipse(0, 0, U.radius, 12, 0, 0, Math.PI * 2); ctx.stroke(); } + if (locked) { ctx.globalAlpha = .5; ctx.strokeStyle = '#fff1c2'; ctx.lineWidth = 2; ctx.beginPath(); ctx.ellipse(0, 0, U.radius * (1 - (group.t - k.lockAt) / U.telegraph * .3), 11, 0, 0, Math.PI * 2); ctx.stroke(); } + ctx.restore(); + } + if (k.state !== 'erupting') continue; + // Rise fast, hold, then sink back into the sand; clipped at the floor so it grows out of the ground. + const u = (group.t - k.at) / U.rise, h = U.height + 40, size = cobra ? h * cobra.width / 496 : h; + const out = u < .25 ? 1 - Math.pow(1 - u / .25, 3) : u > .7 ? 1 - Math.pow((u - .7) / .3, 2) : 1; + ctx.save(); ctx.beginPath(); ctx.rect(k.x - size, CONFIG.groundY - h - 40, size * 2, h + 50); ctx.clip(); + ctx.translate(Math.round(k.x), Math.round(CONFIG.groundY + 12 + (1 - out) * h)); ctx.scale(group.facing, 1); ctx.imageSmoothingEnabled = false; + if (cobra) ctx.drawImage(cobra, -size / 2, -size, size, size); + else { ctx.fillStyle = '#d9b77a'; ctx.fillRect(-26, -h, 52, h); } + ctx.restore(); + } + } + function drawHitboxes() { + if (!hitboxes) return; ctx.save(); ctx.lineWidth = 2; ctx.setLineDash([5, 4]); ctx.strokeStyle = '#5affce'; ctx.strokeRect(hero.x - 28, hero.y - 128, 56, 128); ctx.strokeStyle = '#ffe186'; ctx.strokeRect(dummy.x - 30, dummy.y - 165, 60, 165); if (hero.action?.type === 'attack') { const reach = attackRange(hero.action.index); ctx.fillStyle = '#ff925c35'; ctx.strokeStyle = '#ff935d'; const x = hero.facing === 1 ? hero.x : hero.x - reach; ctx.fillRect(x, hero.y - 126, reach, 113); ctx.strokeRect(x, hero.y - 126, reach, 113); } ctx.strokeStyle = '#fff8'; ctx.beginPath(); ctx.moveTo(0, CONFIG.groundY); ctx.lineTo(W, CONFIG.groundY); ctx.stroke(); ctx.restore(); + } + function draw() { + ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0); ctx.imageSmoothingEnabled = false; ctx.fillStyle = '#9ed2dc'; ctx.fillRect(0, 0, viewW, viewH); + const shakeX = Math.sin(realTime * 93) * trauma * trauma * 13, shakeY = Math.cos(realTime * 111) * trauma * trauma * 9; + drawBackground(shakeX, shakeY); ctx.save(); ctx.translate(offsetX + shakeX * scale, offsetY + shakeY * scale); ctx.scale(scale, scale); + // Light motes provide life without moving the fixed stage or camera. + for (let i = 0; i < 14; i++) { const x = (i * 117 + realTime * (6 + i % 3)) % W, y = 210 + (i * 47) % 330 + Math.sin(realTime + i) * 9; ctx.fillStyle = i % 2 ? '#fff8cb88' : '#f0ffe94d'; ctx.fillRect(Math.round(x), Math.round(y), 2, 2); } + if (hero.x < dummy.x) { drawHero(); drawDummy(); } else { drawDummy(); drawHero(); } + drawSquadron(); drawSquadron(enemySquad); drawParade(parade); drawParade(enemyParade); drawMurmuration(flock); drawMurmuration(enemyFlock); drawSerpent(serpent); drawSerpent(enemySerpent); drawEffects(); drawHitboxes(); ctx.restore(); updateHud(); + } + function fighterInfo(id,form) { + if(id==='fenr'){const wolf=form==='wolf';return {name:'FENR',cls:wolf?'WEREWOLF':'DEMI-HUMAN',deck:wolf?'FENR · FERAL':'FENR',title:'WOLF RANGER',portrait:'assets/fenr/ui/portrait-'+(wolf?'wolf':'human')+'.webp',names:F.kits[wolf?'wolf':'human'].names,icons:['basic','skill1','skill2','ultimate'].map((s,i)=>'assets/fenr/ui/icon-'+(i===3?'ultimate':(wolf?'wolf':'human')+'-'+s)+'.webp')};} + if(id==='cora')return {name:'CORA',cls:'DEMI-HUMAN',deck:'CORA',title:'SKY DANCER',portrait:'assets/cora/ui/portrait.webp',names:C.names,icons:['basic','skill1','skill2','ultimate'].map(s=>'assets/cora/ui/icon-'+s+'.webp')}; + if(id==='edda')return {name:'EDDA',cls:'DEMI-HUMAN',deck:'EDDA',title:'SHELL SAGE',portrait:'assets/edda/ui/portrait.webp',names:ED.names,icons:['basic','skill1','skill2','ultimate'].map(s=>'assets/edda/ui/icon-'+s+'.webp')}; + if(id==='nib')return {name:'NIB',cls:'DEMI-HUMAN',deck:'NIB',title:'ROOFTOP POST',portrait:'assets/nib/ui/portrait.webp',names:NB.names,icons:['basic','skill1','skill2','ultimate'].map(s=>'assets/nib/ui/icon-'+s+'.webp')}; + if(id==='solan')return {name:'SOLAN',cls:'DEMI-HUMAN',deck:'SOLAN',title:'SUNMANE',portrait:'assets/solan/ui/portrait.webp',names:SO.names,icons:['basic','skill1','skill2','ultimate'].map(s=>'assets/solan/ui/icon-'+s+'.webp')}; + if(id==='rhea')return {name:'RHEA',cls:'MECHA',deck:'RHEA',title:'LITTLE ORRERY',portrait:'assets/rhea/ui/portrait.webp',names:RH.names,icons:['basic','skill1','skill2','ultimate'].map(s=>'assets/rhea/ui/icon-'+s+'.webp')}; + if(id==='isolde')return {name:'ISOLDE',cls:'MECHA',deck:'ISOLDE',title:'WHITE STRIDER',portrait:'assets/isolde/ui/portrait.webp',names:IS.names,icons:['basic','skill1','skill2','ultimate'].map(s=>'assets/isolde/ui/icon-'+s+'.webp')}; + if(id==='zanni')return {name:'ZANNI',cls:'MECHA',deck:'ZANNI',title:'CLOCKWORK JESTER',portrait:'assets/zanni/ui/portrait.webp',names:Z.names,icons:['basic','skill1','skill2','ultimate'].map(s=>'assets/zanni/ui/icon-'+s+'.webp')}; + if(id==='haldor')return {name:'HALDOR',cls:'MECHA',deck:'HALDOR',title:'WALKING FORGE',portrait:'assets/haldor/ui/portrait.webp',names:HD.names,icons:['basic','skill1','skill2','ultimate'].map(s=>'assets/haldor/ui/icon-'+s+'.webp')}; + if(id==='naja')return {name:'NAJA',cls:'DEMI-HUMAN',deck:'NAJA',title:'DUNE COBRA',portrait:'assets/naja/ui/portrait.webp',names:N.names,icons:['basic','skill1','skill2','ultimate'].map(s=>'assets/naja/ui/icon-'+s+'.webp')}; + if(id==='mira')return {name:'MIRA',cls:'MECHA',deck:'MIRA',title:'CANDY PILOT',portrait:'assets/mira/ui/portrait.webp',names:M.names,icons:['basic','skill1','skill2','ultimate'].map(s=>'assets/mira/ui/icon-'+s+'.webp')}; + return {name:'ARCO',cls:'MECHA',deck:'ARCO',title:'AETHER ARM',portrait:'assets/ui/arco-avatar.webp',names:['IRON CHAIN','AETHER BOLT','SEISMIC DRIVE','HELIOS SQUADRON'],icons:['attack','skill1','skill2','squadron-icon'].map(s=>'assets/ui/'+s+'.webp')}; + } + function updateFighterIdentity() { + if(!F)return; + const identity=selectedCharacter+':'+hero.form+':'+opponentCharacter+':'+dummy.form; + if(identity!==hudIdentity) { + hudIdentity=identity;const me=fighterInfo(selectedCharacter,hero.form),rival=fighterInfo(opponentCharacter,dummy.form); + $('#player-name').textContent=me.name;$('#player-class').textContent=me.cls;$('#deck-character').textContent=me.deck; + $('#player-title').textContent=me.title; + $('#player-portrait').src=me.portrait;$('#player-portrait').alt='Portrait '+me.name; + $('#enemy-portrait').src=rival.portrait;$('#enemy-portrait').alt='Portrait '+opponentCharacter.toUpperCase();$('#enemy-class').textContent=rival.cls; + $('#enemy-name').textContent=opponentCharacter.toUpperCase();$('#enemy-title').textContent=rival.title; + $('.fighter-two').setAttribute('aria-label','Status lawan '+opponentCharacter.toUpperCase()); + document.querySelectorAll('.skill-card').forEach((card,i)=>{card.querySelector('.skill-name').textContent=me.names[i];card.querySelector('img').src=me.icons[i];card.setAttribute('aria-label',me.names[i]+', '+['Spasi','I','O','P'][i]);}); + } + $('#player-status').textContent=selectedCharacter==='fenr'?(hero.form==='wolf'?'FERAL ACTIVE':'RANGER READY'):selectedCharacter==='mira'?'PILOT READY':selectedCharacter==='cora'?'WINGS READY':selectedCharacter==='naja'?'SANDS READY':selectedCharacter==='haldor'?'FURNACE HOT':selectedCharacter==='zanni'?'SHOWTIME':selectedCharacter==='isolde'?'LANCE READY':selectedCharacter==='rhea'?'ORBITS SET':selectedCharacter==='solan'?'PRIDE READY':selectedCharacter==='nib'?'ON THE ROUTE':selectedCharacter==='edda'?'STEADY AS STONE':'CORE ACTIVE'; + for(const [actor,id] of [[hero,'player-form-timer'],[dummy,'enemy-form-timer']]){const el=$('#'+id),active=actor.form==='wolf';el.hidden=!active;el.textContent=active?'FERAL '+Math.max(0,actor.formTime).toFixed(1)+' s':'';el.style.setProperty?.('--remaining',String(actor.formTime/F.balance.duration));} + } + function updateHud() { + updateFighterIdentity(); + updateMatchHud(); + if(systemAnnouncer){const a=systemAnnouncer.snapshot();canvas.dataset.announcerVoice=a.voice;canvas.dataset.announcerCue=a.cue;canvas.dataset.announcerLastCue=a.lastCue;canvas.dataset.announcerActive=String(a.active);canvas.dataset.announcerPaused=String(a.paused);canvas.dataset.announcerTime=String(a.time);canvas.dataset.announcerStarts=String(a.starts);canvas.dataset.announcerError=a.error;} + canvas.dataset.character=selectedCharacter;canvas.dataset.playerForm=hero.form||'human';canvas.dataset.opponentForm=dummy.form||'human';canvas.dataset.opponentState=dummy.state;canvas.dataset.aiEnabled=String(aiEnabled);canvas.dataset.opponentFrame=String(opponentPose().frame); + canvas.dataset.ready = String(ready); + canvas.dataset.audioState = audio?.state || 'uninitialized'; + canvas.dataset.audioSources = String(audioSources); + canvas.dataset.voiceName = voiceName; + canvas.dataset.voiceOwner = voiceActor === dummy ? 'enemy' : 'player'; + canvas.dataset.fenrVoiceReadyState = String(voiceBank.fenr?.readyState || 0); + canvas.dataset.voiceActive = String(voiceActive); + canvas.dataset.voicePaused = String(ultimateVoice?.paused ?? true); + canvas.dataset.voiceTime = String(ultimateVoice?.currentTime || 0); + canvas.dataset.voiceDuration = String(Number.isFinite(ultimateVoice?.duration) ? ultimateVoice.duration : 0); + canvas.dataset.voiceStarts = String(voiceStarts); + canvas.dataset.voiceReadyState = String(ultimateVoice?.readyState || 0); + canvas.dataset.voiceError = voiceError; + canvas.dataset.droneCount = String(squad?.drones.filter(d => d.visible).length || 0); + canvas.dataset.droneShots = String(squad?.shots || 0); + canvas.dataset.ultimatePhase = squad?.phase || 'none'; + const currentPose = heroPose(); + canvas.dataset.poseState = currentPose.state; + canvas.dataset.poseFrame = String(currentPose.frame); + canvas.dataset.posePhase = currentPose.phase; + canvas.dataset.poseRotation = currentPose.rotation.toFixed(6); + canvas.dataset.poseScale = String(currentPose.scale); + canvas.dataset.posePivot = currentPose.pivot ? `${currentPose.pivot.x},${currentPose.pivot.y}` : ''; + canvas.dataset.poseWorldPivot = currentPose.worldPivot ? `${currentPose.worldPivot.x},${currentPose.worldPivot.y}` : ''; + const h=hpLayers(hero.hp), dh=hpLayers(currentDummyHP()); + $('#health-fill').style.width = `${h.front}%`; $('#health-reserve').style.width = `${h.reserve}%`; $('#health-label').textContent = `${hero.hp} / ${BALANCE.heroMax}`; + $('#guard-fill').style.width = `${dh.front}%`; $('#guard-reserve').style.width = `${dh.reserve}%`; $('#guard-label').textContent = `HP ${currentDummyHP()} / ${BALANCE.dummyMax}`; + $('#dummy-state').textContent = dummy.ko?'K.O.':({ idle: aiEnabled?'WATCHING':'PRACTICE', hurt: 'HIT CONFIRMED', down: 'DOWN', recover: 'RECOVERING' })[dummy.state] || (dummy.action?'ATTACKING':'APPROACHING'); + $('#combo').classList.toggle('visible', comboHits > 0); $('#combo-count').textContent = comboHits; $('#combo-damage').textContent = `${comboDamage} DAMAGE`; $('#total-damage').innerHTML = `${totalDamage} DMG`; + $('#best-combo').innerHTML = `${bestCombo} HITS`; + $('#announcement').classList.toggle('visible', announceTimer > 0); + for (const card of document.querySelectorAll('.skill-card')) { + const name = card.dataset.action, cd = hero.cooldowns[name] || 0; + card.classList.toggle('active', name === 'attack' ? hero.action?.type === 'attack' : name === 'ultimate' ? (!!squad || !!parade || !!flock || !!serpent || !!quake || !!finale || !!skyfall || !!orrery || !!sunroar || !!delivery || !!tortoise || (selectedCharacter==='fenr'&&(hero.form==='wolf'||hero.transformPending>0))) : hero.action?.type === name); + card.classList.toggle('on-cooldown', cd > 0); card.setAttribute('aria-disabled', String(cd > 0 || (name==='ultimate'&&selectedCharacter==='fenr'&&(hero.form==='wolf'||hero.transformPending>0)))); + card.classList.toggle('recharged',(rechargePulse[name]||0)>0); + const wipe = card.querySelector('.cooldown-wipe'); + if (wipe) { const angle = cd > 0 ? (1 - cd / cooldownMax[name]) * 360 : 360; wipe.style.background = `conic-gradient(from 0deg, transparent 0deg ${angle.toFixed(2)}deg, rgba(0,0,0,.68) ${angle.toFixed(2)}deg 360deg)`; } + } + // One banner at a time: FENR transformation, then ARCO, then a MIRA/CORA/NAJA/HALDOR summon still inside its cut-in window. + const cutin=$('#ultimate-cutin'),live=g=>g&&g.t{cutinArt.style.visibility='';};}cutin.querySelector('.cutin-kicker').textContent={fenr:'FENR / '+cutinOwner,mira:'MIRA / '+cutinOwner,cora:'CORA / '+cutinOwner,naja:'NAJA / '+cutinOwner,haldor:'HALDOR / '+cutinOwner,zanni:'ZANNI / '+cutinOwner,isolde:'ISOLDE / '+cutinOwner,rhea:'RHEA / '+cutinOwner,solan:'SOLAN / '+cutinOwner,nib:'NIB / '+cutinOwner,edda:'EDDA / '+cutinOwner,arco:'ARCO / AETHER COMMAND'}[cutinKey];cutin.querySelector('strong').innerHTML={fenr:'FERAL
AWAKENING',mira:'ROCKET
PARADE',cora:'NIGHT
MURMURATION',naja:'DUNE
SERPENT',haldor:'FORGE
QUAKE',zanni:'GRAND
FINALE',isolde:'SKYFALL
LANCES',rhea:'GRAND
ORRERY',solan:'SUNMANE
ROAR',nib:'SPECIAL
DELIVERY',edda:'ELDER
TORTOISE',arco:'HELIOS
SQUADRON'}[cutinKey];cutin.querySelector('.cutin-detail').textContent={fenr:'THE BEAST WITHIN',mira:'TWELVE-ROCKET SALVO',cora:'THREE-PASS RAVEN STORM',naja:'THREE-STRIKE SAND COBRA',haldor:'THREE-SLAM MOLTEN SHOCKWAVE',zanni:'THREE-RING BLADE BOOMERANG',isolde:'THREE-LANCE ICE DIVE',rhea:'THREE-PLANET ORBIT',solan:'THREE-ROAR SHOCKWAVE',nib:'THREE-PARCEL HOMING RUN',edda:'THREE-STOMP SPIRIT WALK',arco:'ORBITAL LASER STRIKE'}[cutinKey];cutin.setAttribute('aria-label',{fenr:'FENR ultimate: Feral Awakening',mira:'MIRA ultimate: Rocket Parade',cora:'CORA ultimate: Night Murmuration',naja:'NAJA ultimate: Dune Serpent',haldor:'HALDOR ultimate: Forge Quake',zanni:'ZANNI ultimate: Grand Finale',isolde:'ISOLDE ultimate: Skyfall Lances',rhea:'RHEA ultimate: Grand Orrery',solan:'SOLAN ultimate: Sunmane Roar',nib:'NIB ultimate: Special Delivery',edda:'EDDA ultimate: Elder Tortoise',arco:'ARCO ultimate: Helios Squadron'}[cutinKey]);} + const visible = ct < SQUAD.cutinDuration; + cutin.classList.toggle('visible', visible); cutin.setAttribute('aria-hidden', String(!visible)); + const slide = !visible ? -110 : ct < .16 ? -110 * Math.pow(1 - ct / .16,3) : ct > .57 ? 110 * Math.pow((ct-.57)/.21,2) : 0; + cutin.style.transform = `translateX(${slide}%) rotate(-3deg)`; + cutin.style.opacity = visible ? '1' : '0'; + document.querySelectorAll('.combo-dots i').forEach((dot, i) => dot.classList.toggle('lit', hero.action?.type === 'attack' && hero.action.index > i)); + } + // Cut-in art per fighter. Only the two fighters of the current fight are warmed (at start/select), so a banner never waits + // for its image; until the right image is decoded the art stays hidden (see updateHud) instead of showing the last one. + const CUTIN_ART={fenr:'assets/fenr/ui/cutin.webp',mira:'assets/mira/ui/cutin.webp',cora:'assets/cora/ui/cutin.webp',naja:'assets/naja/ui/cutin.webp',haldor:'assets/haldor/ui/cutin.webp',zanni:'assets/zanni/ui/cutin.webp',isolde:'assets/isolde/ui/cutin.webp',rhea:'assets/rhea/ui/cutin.webp',solan:'assets/solan/ui/cutin.webp',nib:'assets/nib/ui/cutin.webp',edda:'assets/edda/ui/cutin.webp',arco:'assets/ui/ultimate-cutin.webp'}; + let cutinWarm=[]; + function warmCutins(){cutinWarm=[...new Set([selectedCharacter,opponentCharacter])].map(id=>CUTIN_ART[id]).filter(Boolean).map(src=>{const im=new Image();im.decoding='async';im.src=src;return im;});} + function setPaused(value) { paused = value; clearInput(); syncUltimateVoice(); $('#pause-btn').textContent = value ? '▶' : 'Ⅱ'; const panel = $('#pause-panel'); if (value && !panel.open) panel.showModal(); if (!value && panel.open) panel.close(); if (!value) canvas.focus({ preventScroll: true }); } + function openSettings() { settingsOpen = true; clearInput(); syncUltimateVoice(); window.FrontEnd?.syncBackground(); $('#close-settings').innerHTML=(menuOpen?'Kembali ke menu':'Kembali ke arena')+' ↗'; $('#settings-panel').showModal(); } + function closeSettings() { settingsOpen = false; $('#settings-panel').close(); syncUltimateVoice(); window.FrontEnd?.syncBackground(); canvas.focus({ preventScroll: true }); } + const handled = new Set(['w','a','s','d',' ','i','o','p','escape','r']); + window.addEventListener('keydown', e => { if (!handled.has(e.key.toLowerCase()) || /INPUT|TEXTAREA|SELECT/.test(e.target.tagName)) return; if (e.target.tagName === 'BUTTON' && (e.key === ' ' || e.key === 'Enter')) return; e.preventDefault(); if (!e.repeat) press(e.key); }); + window.addEventListener('keyup', e => { if (handled.has(e.key.toLowerCase())) { if(!/INPUT|TEXTAREA|SELECT|BUTTON/.test(e.target.tagName))e.preventDefault(); release(e.key); } }); + window.addEventListener('blur', clearInput); document.addEventListener('visibilitychange', () => { clearInput(); lastTime = 0; accumulator = 0;if(document.hidden)systemAnnouncer?.clear(); syncUltimateVoice(); syncMusic(); }); + window.addEventListener('resize', resize); canvas.addEventListener('pointerdown', () => { unlockAudio(); canvas.focus(); }); + $('#pause-btn').onclick = () => { unlockAudio(); setPaused(!paused); }; $('#resume-btn').onclick = () => setPaused(false); $('#restart-btn').onclick = reset; + $('#back-menu').onclick=()=>{closeSettings();setMenuOpen(true);window.FrontEnd?.home();}; + $('#character-select').onchange=e=>selectCharacter(e.target.value); + $('#ai-toggle').onchange=e=>{aiEnabled=e.target.checked;dummy.action=null;dummy.vx=0;dummy.state='idle';}; + $('#settings-btn').onclick = openSettings; $('#close-settings').onclick = closeSettings; + $('#settings-panel').addEventListener('cancel', e => { e.preventDefault(); closeSettings(); }); $('#pause-panel').addEventListener('cancel', e => { e.preventDefault(); setPaused(false); }); + $('#hitbox-toggle').onchange = e => { hitboxes = e.target.checked; }; $('#sound-toggle').onchange = e => { unlockAudio(); muted = !e.target.checked; applyAudioMix(); }; + $('#music-toggle').checked = musicOn; $('#music-slider').value = String(Math.round(musicVolume * 100)); $('#music-value').value = `${Math.round(musicVolume * 100)}%`; + $('#music-toggle').onchange = e => { unlockAudio(); musicOn = e.target.checked; prefs.set('aether.music', musicOn ? '1' : '0'); syncMusic(); }; + $('#music-slider').oninput = e => { unlockAudio(); musicVolume = clamp(Number(e.target.value) / 100, 0, 1); $('#music-value').value = `${e.target.value}%`; prefs.set('aether.musicVolume', e.target.value); syncMusic(); }; + $('#volume-slider').oninput = e => { unlockAudio(); volume = Number(e.target.value) / 100; $('#volume-value').value = `${e.target.value}%`; applyAudioMix(); }; + $('#sound-test').onclick = () => { unlockAudio(); sound('heavy'); }; // Inside the mobile shell the shell owns fullscreen (it also locks landscape); on its own page the game toggles it itself. + function toggleFullscreen() { if (window.parent !== window) { try { if (window.parent.aetherFullscreen) { window.parent.aetherFullscreen(); return; } } catch (_) {} window.parent.postMessage({ type: 'aether-fullscreen' }, location.origin); return; } if (!document.fullscreenElement) document.documentElement.requestFullscreen?.().catch(() => {}); else document.exitFullscreen?.(); } + $('#fullscreen-btn').onclick = toggleFullscreen; + for (const button of document.querySelectorAll('[data-action]')) button.onclick = () => { unlockAudio(); const name = button.dataset.action; if (name === 'attack') startAttack(); else cast(name); canvas.focus({ preventScroll: true }); }; + function tick(stamp) { if (lastTime) { const elapsed = Math.min((stamp - lastTime) / 1000, .1); fps += (1 / Math.max(elapsed, .001) - fps) * .05; if (!window.__game?.manual) { accumulator += elapsed; while (accumulator >= 1 / 120) { step(1 / 120); accumulator -= 1 / 120; } } } lastTime = stamp; draw(); requestAnimationFrame(tick); } + // Deterministic QA interface: set manual=true, reset(), press/release(), advance(seconds). + // Positions represent feet. No networking or persistent state is changed by this interface. + window.__game = { hero, dummy, config: CONFIG, balance:BALANCE, particles, effects, projectiles, pose: heroPose, press: key => press(key, true), release, input(key, down) { if (down) press(key); else release(key); }, reset, cast, jump, attack: startAttack, clearInput, draw, manual: false, + advance(seconds) { for (let i = 0; i < Math.round(seconds * 120); i++) step(1 / 120); draw(); return this.snapshot(); }, + snapshot() { return { ready, paused, settingsOpen, menuOpen, selectedCharacter, opponentCharacter, difficulty, stageId, match:match?{...match}:null,roundCues:[...roundCues],aiEnabled, time, stepCount, hero: JSON.parse(JSON.stringify(hero)), dummy: { ...dummy }, healthBars:{hero:hpLayers(hero.hp),dummy:hpLayers(currentDummyHP())}, squad: squad ? JSON.parse(JSON.stringify(squad)) : null, enemySquad:enemySquad?JSON.parse(JSON.stringify(enemySquad)):null, parade: parade ? JSON.parse(JSON.stringify(parade)) : null, enemyParade: enemyParade ? JSON.parse(JSON.stringify(enemyParade)) : null, flock: flock ? JSON.parse(JSON.stringify(flock)) : null, enemyFlock: enemyFlock ? JSON.parse(JSON.stringify(enemyFlock)) : null, serpent: serpent ? JSON.parse(JSON.stringify(serpent)) : null, enemySerpent: enemySerpent ? JSON.parse(JSON.stringify(enemySerpent)) : null, quake: quake ? JSON.parse(JSON.stringify(quake)) : null, enemyQuake: enemyQuake ? JSON.parse(JSON.stringify(enemyQuake)) : null, finale: finale ? { ...finale } : null, enemyFinale: enemyFinale ? { ...enemyFinale } : null, skyfall: skyfall ? { ...skyfall } : null, enemySkyfall: enemySkyfall ? { ...enemySkyfall } : null, orrery: orrery ? { ...orrery } : null, enemyOrrery: enemyOrrery ? { ...enemyOrrery } : null, sunroar: sunroar ? { ...sunroar } : null, enemySunroar: enemySunroar ? { ...enemySunroar } : null, delivery: delivery ? { ...delivery } : null, enemyDelivery: enemyDelivery ? { ...enemyDelivery } : null, tortoise: tortoise ? { ...tortoise, spirit: undefined } : null, enemyTortoise: enemyTortoise ? { ...enemyTortoise, spirit: undefined } : null, playable: playable(), cutinVisible: !!squad && squad.t < SQUAD.cutinDuration, totalDamage, comboHits, comboDamage, hitstop, cinematic, trauma, projectileCount: projectiles.length, audioState: audio?.state || 'uninitialized', audioSources, stageLoaded: !!images.stage, spriteLoaded: !!images.hero, manifestLoaded: !!manifest }; }, + startMatch,setMenuOpen,menuSound,openSettings,toggleFullscreen,music(){return music?{on:musicOn,volume:musicVolume,playing:!music.paused,src:MUSIC_PATH,element:music}:null;},onRoundCue(fn){roundAnnouncer=typeof fn==='function'?fn:null;}, + announceSelection(id){return menuOpen&&playable().includes(id)?(systemAnnouncer?.play('select_'+id)||false):false;}, + stopAnnouncer(){systemAnnouncer?.clear();},announcerState(){return systemAnnouncer?.snapshot()||null;}, + selectCharacter, setAI(value){aiEnabled=!!value;dummy.action=null;dummy.vx=0;dummy.state='idle';}, opponentPose, + setPaused, setHitboxes(v) { hitboxes = !!v; $('#hitbox-toggle').checked = hitboxes; }, + receiveHit(damage = 12,options={}) { if (menuOpen || (match&&match.phase!=='fight') || hero.hp<=0 || hero.invuln > 0) return false; if (hero.action?.guard && shellGuard(hero)) return false; if (hero.action?.type?.startsWith('skill') || hero.action?.type === 'ultimate') return false; hero.hp = Math.max(0, hero.hp - damage); hero.action = null; hero.rollBuffer = null; hero.doublePose = 0; hero.hurtTime = hero.hp>0?.42:0; hero.vx = hero.facing * -170; state(hero.hp>0?'hurt':'down'); spawnParticles(hero.x, hero.y - 75, '#ffc694', 14, 200); if(options.freeze!==false)hitstop = .05; trauma = Math.min(1, trauma + .2 * (options.shake ?? 1)); if (!hero.hp) {hero.koTime=1.7;clearInput();if(match?.mode!=='versus')announce('CORE RESTART',1.7);} return true; } + }; + if(F)reset(); + if(window.FRONTEND_ENABLED)setMenuOpen(true); + resize(); requestAnimationFrame(tick); + // Wait for the first-visit download (preload.js) so these requests are served from the cache, not fetched twice. + Promise.resolve(window.AETHER_PRELOAD).then(() => Promise.all([loadImage('hero', 'assets/mecha/run/sprite-sheet-alpha.webp', true), loadImage('stage', 'assets/stage.webp', true), ...(Rules?Object.entries(Rules.stages).filter(([id])=>id!=='bellora').map(([id,s])=>loadImage('stage-'+id,s.image,true)):[]), ...(F?[loadImage('fenrHuman','assets/fenr/human/run/sprite-sheet-alpha.webp',true),loadImage('fenrWolf','assets/fenr/wolf/run/sprite-sheet-alpha.webp',true),...['claw','gale','rush','bite','howl','transform'].map(name=>loadImage('fx-'+name,'assets/fenr/ui/fx-'+name+'.webp',true))]:[]), ...(M?[loadImage('mira','assets/mira/run/sprite-sheet-alpha.webp',true),...['star','rocket','burst','crash'].map(name=>loadImage('fx-mira-'+name,'assets/mira/ui/fx-'+name+'.webp',true))]:[]), ...(C?[loadImage('cora','assets/cora/run/sprite-sheet-alpha.webp',true),...['feather','gust','raven'].map(name=>loadImage('fx-cora-'+name,'assets/cora/ui/fx-'+name+'.webp',true))]:[]), ...(N?[loadImage('naja','assets/naja/run/sprite-sheet-alpha.webp',true),...['sandwave','cyclone','serpent','ripple'].map(name=>loadImage('fx-naja-'+name,'assets/naja/ui/fx-'+name+'.webp',true))]:[]), ...(HD?[loadImage('haldor','assets/haldor/run/sprite-sheet-alpha.webp',true),...['slag','splash','steam','quake'].map(name=>loadImage('fx-haldor-'+name,'assets/haldor/ui/fx-'+name+'.webp',true))]:[]), ...(Z?[loadImage('zanni','assets/zanni/run/sprite-sheet-alpha.webp',true),...['ring','bigring','snatch','confetti'].map(name=>loadImage('fx-zanni-'+name,'assets/zanni/ui/fx-'+name+'.webp',true))]:[]), ...(IS?[loadImage('isolde','assets/isolde/run/sprite-sheet-alpha.webp',true),...['piercer','skyfall','shatter','frost'].map(name=>loadImage('fx-isolde-'+name,'assets/isolde/ui/fx-'+name+'.webp',true))]:[]), ...(RH?[loadImage('rhea','assets/rhea/run/sprite-sheet-alpha.webp',true),...['drift','planet','well','burst'].map(name=>loadImage('fx-rhea-'+name,'assets/rhea/ui/fx-'+name+'.webp',true))]:[]), ...(SO?[loadImage('solan','assets/solan/run/sprite-sheet-alpha.webp',true),...['crescent','roar','impact','sunburst'].map(name=>loadImage('fx-solan-'+name,'assets/solan/ui/fx-'+name+'.webp',true))]:[]), ...(NB?[loadImage('nib','assets/nib/run/sprite-sheet-alpha.webp',true),...['letter','plane','slip','stamp'].map(name=>loadImage('fx-nib-'+name,'assets/nib/ui/fx-'+name+'.webp',true))]:[]), ...(ED?[loadImage('edda','assets/edda/run/sprite-sheet-alpha.webp',true),...['stone','ripple','shell','tortoise','stomp'].map(name=>loadImage('fx-edda-'+name,'assets/edda/ui/fx-'+name+'.webp',true))]:[]), loadImage('drone', 'assets/ui/drone.png', true), loadImage('cutin', 'assets/ui/ultimate-cutin.webp', true)])).then(results => { + images['stage-bellora']=images.stage;images.arco=images.hero;if(F)syncPlayerForm(); + ready = results.every(Boolean) && (!F || (!!window.FENR_HUMAN_MANIFEST && !!window.FENR_WOLF_MANIFEST)) && !!manifest && frames('idle').length > 0; + if (ready) { $('#load-state').classList.add('hidden'); announce('TRAINING READY', 1.65); } + else { $('#load-state').classList.add('error'); $('#load-message').textContent = 'Aset arena belum lengkap. Periksa atlas/manifest karakter, stage.webp, drone.png, dan ultimate-cutin.webp, lalu muat ulang.'; } + window.FrontEnd?.assetsReady(ready); + }); +})(); diff --git a/guide/README.md b/guide/README.md new file mode 100644 index 0000000..c127890 --- /dev/null +++ b/guide/README.md @@ -0,0 +1,90 @@ +# Panduan game fighting dan karakter + +> [!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. + +Dokumentasi ini menyimpan keputusan final dan cara menyiapkan karakter berikutnya dengan format yang sama. Mulai dari acuan di bawah. + +## Urutan baca + +| Keperluan | Acuan | +| --- | --- | +| Branding umum AETHER CLASH, dunia Mecha vs Demi-Human | [branding.md](branding.md) | +| Framing kepala dan arah avatar pemain/musuh, termasuk transformasi | [avatar-standard.md](avatar-standard.md) | +| Main menu, pemilihan pemain/lawan, arena, difficulty, ronde dan hasil pertandingan | [main-menu.md](main-menu.md) | +| Background main menu looping Seedance2.5 1080p | [menu-video.md](menu-video.md) | +| Mengetahui kontrol, HP, damage, cooldown, summon, HUD, dan VFX final | [gameplay-standard.md](gameplay-standard.md) | +| Membuat karakter Mecha/Demi-Human baru dari nol | [character-workflow.md](character-workflow.md) | +| Melihat contoh nyata, source terpilih, dan parameter ARCO | [arco-reference.md](arco-reference.md) | +| FENR, paket dua bentuk, AI, timer transformasi dan aset lengkap | [fenr-reference.md](fenr-reference.md) | +| MIRA, pilot anak + robot, Rocket Parade, ukuran 182 px | [mira-reference.md](mira-reference.md) | +| CORA, demi-human gagak bersayap, Night Murmuration, ukuran 184 px | [cora-reference.md](cora-reference.md) | +| NAJA, demi-human kobra dengan urumi, Dune Serpent, voice Soraya, ukuran 182 px (dari showcase ke playable) | [naja-reference.md](naja-reference.md) | +| HALDOR, tank mecha pandai besi, Slag Shot melambung, Forge Quake, voice Gideon, ukuran 169 px | [haldor-reference.md](haldor-reference.md) | +| ZANNI, badut mecha lengan gunting, Ring Toss bumerang, Spring Snatch menarik lawan, Grand Finale, voice Julian, ukuran 176 px | [zanni-reference.md](zanni-reference.md) | +| ISOLDE, ksatria mecha kaki bangau, Sky Piercer naik diagonal, Stilt Charge, Skyfall Lances dari langit, voice Vesper, ukuran 200 px | [isolde-reference.md](isolde-reference.md) | +| RHEA, zoner mecha orrery, Planet Drift lambat, Gravity Well, Grand Orrery mengorbit, voice Chloe, ukuran 170 px | [rhea-reference.md](rhea-reference.md) | +| SOLAN, singa demi-human pedang besar, Solar Crescent, Leonine Leap area, Sunmane Roar tak bisa dilompati, voice Xavier, ukuran 176 px | [solan-reference.md](solan-reference.md) | +| NIB, tikus kurir demi-human, rantai dan surat tercepat, Rooftop Slip menyelinap, Special Delivery homing, voice Evan, ukuran 150 px | [nib-reference.md](nib-reference.md) | +| EDDA, nenek kura-kura demi-human, Stone Skip memantul, Shell Counter, Elder Tortoise roh yang menghentak, voice Opal, ukuran 170 px | [edda-reference.md](edda-reference.md) | +| Menulis prompt base/row/jump/tuck | [sprite-prompts.md](sprite-prompts.md) | +| Prepare, normalize, extract, align, compose | [sprite-pipeline.md](sprite-pipeline.md) | +| Memeriksa skala dan integritas atlas | [sprite-qa.md](sprite-qa.md) | +| Memasang atlas, pivot dan emitters ke game | [sprite-runtime.md](sprite-runtime.md) | +| Mendiagnosis frame mengecil, goyang, blur, pose menempel | [sprite-known-issues.md](sprite-known-issues.md) | +| Membuat latar satu arena (5 arena aktif, termasuk hutan dan malam bulan purnama) | [stage-background.md](stage-background.md) | +| Cara CPU berpikir, knob difficulty dan benchmark | [cpu-ai.md](cpu-ai.md) | +| Mengatur hurt, KO, immunity dan hit feedback | [hit-reactions.md](hit-reactions.md) | +| Voice final Dylan, arsip kandidat, dan perilaku audio ultimate | [ultimate-voice.md](ultimate-voice.md) | +| Voice final Holden untuk FENR, arsip audisi dan perilaku pemain/AI | [fenr-voice.md](fenr-voice.md) | +| Voice ultimate Luna (MIRA) dan Anika (CORA), pemilihan dan pemotongan kalimat | [mira-cora-voice.md](mira-cora-voice.md) | +| Announcer sistem final Grady: cue, timing, integrasi dan aturan karakter baru | [announcer-system.md](announcer-system.md) | + +Jika pedoman generik bertentangan dengan keputusan proyek ini, gunakan gameplay-standard dan character-workflow. Instruksi pengguna berikutnya tetap dapat mengubah acuan; perbarui dokumen final bersamaan dengan implementasinya. + +## Template untuk karakter berikutnya + +- [Kartu karakter](templates/character-card.md): identitas, ukuran, skill, aset, dan checklist. +- [Sprite request](templates/sprite-request.json): cell 320×288, 14 state, 50 frame. +- [Selected sources](templates/selected-sources.example.json): versi sumber, model, job, dan hash. Isi semua placeholder. +- [Playback](templates/playback.example.json): struktur jump/tuck; ukur seluruh pivot sebelum dipakai. +- [Balance reference](templates/balance-reference.json): nilai final untuk pembanding, bukan config loader game. + +## Aturan yang tidak boleh terlewat + +1. Kunci satu base karakter; semua animasi memakai identitas base itu. Base baru memakai deskripsi gaya bersama tanpa gambar karakter lama, dengan pembeda siluet/rambut/wajah/palet/kostum eksplisit. +2. Gambar karakter memakai Higgsfield GPT Image 2.5 **Sunburst**; arena/prop/portrait/ikon/cut-in memakai Seedream 5.0 Pro. +3. Ukur kepala **dan badan utuh**, khususnya recovery → idle. Median rambut yang sama tidak menjamin tubuh tidak mengecil. +4. Simpan original setiap versi. Normalisasi satu kali dari original; curation hanya pergeseran integer dengan scale=1. +5. Gunakan canonical component-row extraction. Jangan mengganti row pipeline dengan generate grid sekali lalu memotong cell buta. +6. Format saat ini: jump satu pose atletis; doublejump satu pose tuck diputar 360°/0,28 s. Jangan ulang crouch di udara atau gunakan pose kick untuk salto. +7. Pakai atlas dan manifest hasil compose terbaru bersama; jangan memuat frame intermediate. Anchor di kaki, smoothing mati, skala tubuh tidak berubah per state. +8. Pose cast terpisah dari umur efek. Proyektil/summon yang sudah dilepas tidak mengunci pemain sampai efek selesai. +9. HP memakai **satu bar fisik dengan dua lapis nyata**, total 200 HP. Bukan dua baris dan bukan damage trail. +10. Bedakan pemeriksaan angka, screenshot, dan gerakan yang benar-benar dilihat. Catat batas QA secara jujur. +11. Jangan menyalin rel/pivot/muzzle ARCO ke karakter baru tanpa pengukuran ulang. +12. Pilihan pemain ARCO/FENR/MIRA/CORA/NAJA/HALDOR/ZANNI/ISOLDE/RHEA/SOLAN/NIB/EDDA sudah tersedia melalui roster dan pengaturan. Registrasinya masih eksplisit pada game.js (kit MIRA/CORA/NAJA/HALDOR/ZANNI/ISOLDE/RHEA/SOLAN/NIB/EDDA lewat peta `KITS`); penambahan folder saja tidak otomatis menambahkan karakter playable. + +## Tool yang tersedia + +| Lokasi | Fungsi | Lingkup | +| --- | --- | --- | +| guide/tools/measure_atlas.py | Ukur atlas final | Menerima path run; landmark tertentu tetap perlu adaptasi | +| guide/tools/measure_head_scale.py | Perbandingan kepala di original | Pilih template/landmark yang sesuai karakter | +| guide/tools/rebuild_raw.py | Single BOX resample, koreksi per pose | Menerima source/output/count/rel | +| guide/tools/register_frames.py + write_dx.py | Alignment badan dengan integer dx | Tinjau region dan hasilnya | +| tools/mecha_pipeline.py | Wrapper canonical pipeline dan ekspor metrics | Terikat ARCO/assets/mecha; adaptasi path sebelum reuse | +| tools/hair_landmark_audit.py | QA landmark rambut | Warna putih ARCO; bukan detector semua karakter | +| tools/transition_body_audit.py | Bandingkan body/recovery dengan idle | Terikat ARCO; adaptasi landmark | +| tools/check_hud_jump.py | Alignment/pivot jump v3 | Terikat ARCO, bukan default karakter baru | +| tools/verify_atlas_integrity.py | Frame kosong/edge/chroma/hash | Terikat path ARCO saat ini | +| tools/prepare_drone_assets.py | Cutout export, ukuran dan muzzle drone | Terikat aset drone ARCO | +| tools/serve.mjs | Server lokal demo | Jalankan dari proyek dengan Node | + +Venv sprite-gen yang dipakai ada di C:/Users/effen/.codex/skills/sprite-gen/.venv/Scripts/python.exe. Contoh perintah dan penjelasan portabilitas ada di character-workflow. Tidak perlu menjalankan generator atau membangun ulang aset hanya untuk membaca dokumentasi. + +## Istilah singkat + +Base = identitas; row = satu gerakan; original = keluaran generator yang belum di-resample; cell = kotak frame; anchor = titik acuan posisi; rel = koreksi skala terhadap original terpilih; curation = pilihan/pergeseran integer; atlas = PNG frame final; manifest = koordinat dan timing; playback/metrics = pivot, stride, hit range, dan titik efek yang terukur. + + diff --git a/guide/announcer-system.md b/guide/announcer-system.md new file mode 100644 index 0000000..cea1f56 --- /dev/null +++ b/guide/announcer-system.md @@ -0,0 +1,42 @@ +# Announcer sistem — Grady + +Status: **Grady dipilih pengguna dan dikunci sebagai voice over sistem AETHER CLASH**. Berlaku untuk seluruh roster sekarang dan karakter baru. Voice ultimate karakter tetap terpisah: Dylan untuk ARCO, Holden untuk FENR, Luna untuk MIRA, Anika untuk CORA. + +## Identitas dan sumber + +- Provider: Higgsfield; model `text2speech_v2`, variant `elevenlabs`. +- Voice: **Grady**, `voice_id=e2a2d2e6-9ed2-59cd-82af-feaa27f8a678`, `voice_type=preset`. +- Bahasa Inggris. Lock resmi: voice-lock.json. +- Generation record menyimpan request persis, job, URL, durasi, SHA256, dan hasil decode setiap MP3. Manifest memasangkan file dan durasi ke cue. +- Dua belas klip awal digenerate terpisah; `select_mira`/`mira_wins` lalu `select_cora`/`cora_wins` ditambahkan dengan pasangan voice yang sama saat MIRA dan CORA masuk roster (total 16). Sampel gabungan Grady/Arthur/Reid di folder announcer-candidates hanya arsip audisi, tidak dipakai runtime. + +## Cue aktif + +| Cue | Ucapan | Trigger | +| --- | --- | --- | +| select_arco / select_fenr / select_mira / select_cora | Arco! / Fenr! / Mira! / Cora! | Konfirmasi pemain atau lawan, bukan hover panel | +| round_1 / round_2 / round_3 | Round one! / Round two! / Round three! | Awal ronde VS Computer | +| fight | Fight! | Setelah pengumuman nomor ronde | +| ko | K.O.! | Salah satu petarung KO | +| arco_wins / fenr_wins / mira_wins / cora_wins | Arco wins! / Fenr wins! / Mira wins! / Cora wins! | Setelah panggilan hasil ronde, sesuai identitas pemenang | +| time_up | Time up! | Waktu ronde habis | +| draw | Draw! | Hasil seri, nomor ronde diulang | +| double_ko | Double K.O.! | Kedua petarung KO bersamaan | + +## Playback dan timing + +`announcer.js` menyediakan satu kanal dengan antrean. `game.js` menghubungkan event, `menu.js` memanggil nama saat konfirmasi, dan `match.js` menyesuaikan waktu intro dengan durasi manifest. Tidak ada autoplay sebelum interaksi pengguna. Training tidak memakai pengumuman ronde kompetitif. + +FIGHT dimulai setelah `max(1.05, durasi round + 0.12)` detik; fase intro berakhir setelah `max(1.85, waktu FIGHT + durasi fight + 0.10)`. Klip Round two lebih panjang (sekitar 2.194 detik), sehingga intro ronde kedua juga lebih panjang. Jangan mengembalikan durasi tetap yang memotong klip. Fase hasil menunggu paling sedikit `max(2.2, jumlah durasi panggilan hasil + 0.5)` detik. + +Announcer berprioritas di atas voice ultimate; voice karakter dihentikan saat pengumuman dimulai. SFX diturunkan selama ucapan. Master volume dan mute berlaku untuk semua klip. Pause dan Pengaturan menjeda lalu melanjutkan posisi suara. Reset, kembali menu, dan halaman tersembunyi membersihkan panggilan lama agar tidak terdengar terlambat. Penolakan playback, file gagal, dan event lama tidak boleh menghentikan pertandingan; watchdog membatasi antrean yang macet. + +## Menambah karakter + +Baca voice-lock sebelum generate. Gunakan Grady dengan pasangan ID/type yang sama untuk `!` dan ` wins!`, masing-masing satu file. Simpan prompt/job/checksum/durasi, perbarui manifest JSON dan JS, registrasi trigger runtime, serta tes pemilihan dan kemenangan. Jangan mengganti announcer dengan voice ultimate karakter atau preset baru tanpa instruksi pengguna. + +## Verifikasi + +Semua 12 file awal, dua klip MIRA (1.306 s dan 1.646 s) dan dua klip CORA (0.914 s dan 1.306 s) lolos decode ffmpeg. Sembilan suite berjumlah **142 tes lulus**, termasuk 13 tes announcer untuk gating, antrean, timing, hasil, prioritas, pause, mute, reset, visibility, dan kegagalan playback. + +Browser localhost diperiksa di tab QA terpisah: sebelum interaksi starts=0; konfirmasi ARCO dan FENR memulai cue sesuai nama; Round 1 lalu Fight menaikkan starts menjadi 4 dan selesai tanpa announcerError. Playback HTMLAudio teramati berjalan (paused=false dan currentTime positif). Pemeriksaan ini membuktikan integrasi playback, bukan penilaian timbre melalui speaker; pemilihan timbre Grady berasal dari audisi pengguna. diff --git a/guide/arco-reference.md b/guide/arco-reference.md new file mode 100644 index 0000000..d1c9f13 --- /dev/null +++ b/guide/arco-reference.md @@ -0,0 +1,64 @@ +# ARCO sebagai contoh format + +Dokumen ini mencatat sumber ARCO yang benar-benar dipakai sebagai contoh format/pipeline. Untuk base karakter baru, gunakan deskripsi gaya dan aturan pembeda di character-workflow.md tanpa melampirkan gambar ARCO; jangan menyalin job ID, rel, atau pivot sebagai data karakter baru. + +## Identitas dan paket final + +- Base B yang dipilih pengguna: base-b.png, job `196220ec-23d8-464b-bf86-87c73307d220`. +- Mecha anime chibi: rambut putih gading, kulit tan, mata teal, navy/ivory/brass, sash teal, lengan mekanis besar di sisi dekat kamera. +- Atlas sprite-sheet-alpha.png, manifest.json, [wrapper JS](../assets/mecha/manifest.js), metrics. +- 14 state / 50 frame; cell 320×288, safe margin 10/8, anchor 160/280. Posisi piksel terbawah umumnya y=279. +- Idle180 px; jump v3 sekitar 177 px; recovery action/skill sekitar 179–181 px. Tuck sekitar 130 px karena tubuh dilipat, bukan dikecilkan di runtime. +- Runtime scale sprite1; smoothing mati. Pembesaran viewport memakai proyeksi seragam. + +## Sumber revisi yang dipilih + +| Aset | Versi / job | Log prompt | +| --- | --- | --- | +| Base | B / 196220ec-23d8-464b-bf86-87c73307d220 | generation-log.json | +| Walk | v2 / 4253dd82-2a16-40da-9bed-8aa45a2f779e | walk-regeneration.json | +| Down | v2 / 7cff3fae-dc6d-4d2e-8949-ddf888550d8b | down-regeneration.json | +| Jump atletis | v3 / c327f505-b8f8-40bb-8dab-8d8352e06214 | hud-revision-requests.json, index 22 | +| Doublejump tuck | v2 / 177d4a53-00d5-4f8d-bfe8-6b0f032e5996 | motion-revision-requests.json, index 20 | +| Ultimate upright/cast | v2 / 59f55272-032f-4cf5-a05b-107e09962ff8 | motion-revision-requests.json, index 21 | +| Stage Bellora | B / d7eefba6-0006-4658-ab30-43e269426dc9 | generation-log.json | +| Portrait ARCO | fa8a1337-f486-4172-b4e6-9368e297f392 | hud-revision-requests.json | +| Portrait dummy | 1852cb5b-aa79-4e40-985f-da3a2f903a11 | hud-revision-requests.json | +| Drone | 78d149d0-8bab-4711-830c-b297749f7638 | drone-ultimate-requests.json | +| Cut-in ARCO | 749d8da3-1b94-42f4-87c7-ceb5a5209db5 | drone-ultimate-requests.json | +| Ikon summon | d8f67c29-a2f6-4795-9e22-2fe41b5f1563 | drone-ultimate-requests.json | + +Semua log di atas berada di [assets](../assets/). State lain dan ikon I/O/basic tercatat di generation-log.json, download-batch1/2/3.json, dan hud-revision-jobs.json. Prompt di run/prompts adalah salinan terpilih; catatan versi generator tetap sumber provenance. + +## Nilai kalibrasi ARCO — contoh, jangan dijadikan default universal + +Calibration target 0.4675632617, idle original frame 0 tinggi 911 px. Rel final: + +| State | rel | +| --- | --- | +| idle | 1.0 | +| walk / run | 1.05 / 1.02 | +| jump v3 / doublejump | 0.922293 / 0.98 | +| crouch | 0.905 | +| attack1 / attack2 | 1.17 / 1.105 | +| attack3 | 1.159, 1.159, 1.159, 1.177 | +| skill1 / skill2 | 1.096 / 1.178 | +| ultimate / hurt / down | 0.998 / 1.0 / 1.609 | + +Data aktual: calibration.json, rel.json, playback.json, curation.json. + +Jump v3: original tinggi 1801 px, satu BOX resample dengan faktor≈0.098279 untuk target 177 px. Integer dx−39 menyelaraskan landmark bagian atas dengan idle; tidak ada per-frame scale runtime. Landmark pale-color ini bisa mengandung bentuk tergantung pose, sehingga bukan jaminan ukuran anatomi kepala subpixel. + +Pivot relatif anchor: jump torso(5,−105); tuck local(−1,−65); putaran360° dalam 0,28 s. Titik ini mengurangi lompatan posisi kepala saat berganti pose, dan wajib diukur ulang pada karakter lain. + +Drone final112×95 dengan muzzle(50.12,5.62) relatif pusat. Sprite-gen cutout membuang chroma; pemilihan komponen utama menghindari satu piksel liar di(0,0) yang sempat membuat bbox terlalu besar. drone-config.json berisi hasil ukur. + +## Bukti dan batas + +Integrity report mencatat50 frame, tanpa frame kosong, edge alpha, chroma terlihat, dan hanya integer curation. Checksum atlas saat pendokumentasian: `5f6f4e7861b04306c9739e5dfdc4c46cc4166b13e375a9cc64380c3d44ac715c`. + +Template edge matching kepala tidak selalu valid pada pose miring; beberapa hasil lama menangkap sepatu/bahu dan ditolak setelah montage dilihat. Hair area yang seragam juga sempat menyembunyikan badan/recovery yang lebih pendek. Karena itu QA akhir menggunakan kepala, badan utuh, rentang crown→sash→foot, dan render transisi. + +Walk/run masih animasi demo empat frame. Periksa pergantian kaki dan kontinuitasnya secara nyata; angka drift atau contact sheet saja tidak membuktikan gait seamless. Tes41 yang lulus adalah tes logika, bukan sertifikasi kualitas gambar atau speaker. + +Lihat catatan QA sprite dan halaman sprite lab / preview skenario. diff --git a/guide/avatar-standard.md b/guide/avatar-standard.md new file mode 100644 index 0000000..229cd78 --- /dev/null +++ b/guide/avatar-standard.md @@ -0,0 +1,36 @@ +# Standar avatar seluruh karakter + +Keputusan pengguna: avatar harus berupa **close-up kepala yang terbaca besar seperti ARCO**, dengan orientasi sesuai slot. Berlaku untuk semua karakter dan semua bentuk transformasi. + +## Aset kanonis + +- Buat satu portrait baru dengan kepala/face dominan sekitar80–90%frame; cukup leher dan sedikit collar di bawah. Hindari torso, tangan, atau tubuh penuh yang membuat kepala kecil saat masuk HUD. +- Sumber **selalu menghadap KANAN**: hidung/moncong dan tatapan mengarah ke sisi kanan gambar. Pertahankan identitas, warna mata/rambut, telinga, dan ciri bentuk yang dipilih. +- Gaya sama dengan avatar ARCO: ilustrasi anime detail, outline rapi, shading halus bertekstur pixel, pencahayaan jelas. ARCO boleh menjadi referensi framing/style portrait untuk karakter yang identitasnya sudah dikunci; identitas tetap dari base karakter itu sendiri. Jangan mewarisi wajah/rambut/armor ARCO. +- Export384×384. Kepala tetap besar pada ikon kecil dan slot HUD vertikal. Background/ornamen tidak boleh mengalahkan wajah. +- Setiap transformasi mempunyai portrait close-up tersendiri dengan arah kanonis yang sama. Tidak mengambil crop sprite tubuh sebagai pengganti portrait. + +## Pemakaian UI + +Semua avatar menggunakan class `portrait-image` dan atribut `data-portrait-side`: + + Portrait karakter + Portrait karakter + +`style.css` memberikan `--portrait-direction:1` untuk pemain dan `-1` untuk musuh. Flip hanya gambar, bukan frame, label, atau ikon lock. Aturan ini sama untuk ARCO, FENR, werewolf, dan karakter berikutnya tanpa CSS khusus per nama karakter. + +- HUD pemain kiri: kanan. HUD musuh kanan: kiri. +- Roster tahap PLAYER: kandidat kanan. Tahap RIVAL: kandidat kiri. +- Ringkasan VS/setup arena: avatar pemain kanan, avatar CPU kiri. +- Roster mempertahankan skew/zoom bingkainya sambil menerapkan flip pada gambar. +- Arah avatar ditentukan peran/slot UI, tidak berubah ketika petarung berbalik arah selama gameplay. Sprite gameplay tetap mengikuti kontrol/facing dunia. + +## FENR versi final + +Human v2: job `d84ea4fb-c7c8-4b99-9e33-9c0fc6bc1823`. Werewolf v2: job `3a84d5ee-37fb-4436-9f43-3828b4238ed3`. Keduanya Higgsfield Seedream5.0Pro2k, base masing-masing sebagai identitas dan portrait ARCO job `fa8a1337-f486-4172-b4e6-9368e297f392` sebagai acuan framing/rendering saja. + +Prompt, job dan checksum export: portrait-revision.json. Sumber v1/v2 di `assets/fenr/ui/originals/`; alias original dan file runtime memakai v2. `fenr_assets.py` melewati arsip versi dan mengekspor portrait384px sehingga rebuild tidak mengembalikan framing lama. + +## Verifikasi browser + +Human dan werewolf diperiksa pada kedua slot HUD: file384px termuat, pemain memakai matrix(1,0,0,1,0,0), lawan matrix(-1,0,0,1,0,0). Roster PLAYER/RIVAL mempertahankan skew sambil mengubah tanda skala horizontal; ringkasanVS FENR pemain dan ARCO CPU juga mengikuti arah yang benar. Screenshot: `qa/fenr/avatar-human-sides.png` dan `qa/fenr/avatar-werewolf-sides.png`. Framing kepala dan arah kanan pada kedua sumber v2 diperiksa langsung sebelum pemasangan. diff --git a/guide/branding.md b/guide/branding.md new file mode 100644 index 0000000..0ef787c --- /dev/null +++ b/guide/branding.md @@ -0,0 +1,21 @@ +# AETHER CLASH — Mecha vs Demi-Human + +Keputusan pengguna: identitas game harus umum, berpusat pada Mecha dan Demi-Human karena roster akan bertambah. ARCO/FENR bukan pasangan protagonis yang menjadi branding seluruh game. + +- Nama game: **AETHER CLASH**. +- Tema/subjudul: **MECHA VS DEMI-HUMAN**. +- Tagline: **Two factions. One arena.** +- Dunia: fantasi Renaisans yang ditenagai aether, tetap siang cerah. Mecha menggabungkan manusia dan teknologi; Demi-Human menggabungkan manusia dan hewan. +- Main menu/About membahas faksi, dunia, arena, dan roster terbuka. Jangan mengunci headline, footer, lore, atau key art pada dua karakter. +- Nama/portrait ARCO, FENR, dan petarung berikutnya tetap digunakan dalam character select, HUD, kemampuan, cut-in, dan hasil pertandingan yang terkait dengan mereka. +- Jangan mempromosikan jumlah karakter sementara sebagai janji tetap branding. Dua karakter saat ini adalah roster awal;12panel menyediakan ruang untuk penambahan berikutnya. + +## Artwork aktif + +home-factions.png memakai gerbang turnamen dan dua banner lambang faksi, tanpa portrait tokoh. Dibuat melalui Higgsfield Seedream5.0Pro,2k,job `e0ac186a-ef20-4b0d-849c-c35d718e14c3`. Prompt/hasil/hash ada di faction-branding.json. Sumber asli `assets/menu/originals/home-factions.png`. + +Key art ARCO/FENR lama `home-art.png` disimpan sebagai arsip, tidak dimuat main menu/About. Selection art masing-masing karakter tetap dipakai pada panel roster. `tools/menu_assets.py` dapat mereproduksi export dari sumber yang disimpan. + +Main menu kini menganimasikan artwork faksi melalui video Seedance2.5 1080p; gambar tetap sebagai poster/fallback dan latar About. Lihat [menu-video.md](menu-video.md). + +Nama karakter/faksi tidak mengubah aturan mirror match, damage, AI, roster terbuka, atau urutan pemilihan pemain/lawan. diff --git a/guide/character-workflow.md b/guide/character-workflow.md new file mode 100644 index 0000000..7f64aa0 --- /dev/null +++ b/guide/character-workflow.md @@ -0,0 +1,221 @@ +# Membuat karakter berikutnya dengan format yang sama + +Gunakan bersama [acuan gameplay final](gameplay-standard.md). Dokumen ini menetapkan format proyek, sedangkan [sprite-pipeline.md](sprite-pipeline.md), [sprite-prompts.md](sprite-prompts.md), dan [sprite-qa.md](sprite-qa.md) menjelaskan teknik ekstraksi/koreksi lebih rinci. + +## 1. Kunci gaya dan identitas + +Gaya bersama: anime chibi hi-bit pixel art, outline gelap halus, cluster pixel detail, palet kaya; bukan 8-bit kotak besar. Mecha boleh menggabungkan manusia dan cyborg; Demi-Human menggabungkan manusia dan hewan. Siluet/warna karakter boleh berbeda, tetapi kepadatan pixel dan skala badan harus terasa satu game. + +1. Salin [kartu karakter](templates/character-card.md) ke folder karakter baru dan isi identitas/kelas/palet/sisi aksesori. +2. Desain base baru dari deskripsi gaya bersama, **tanpa melampirkan gambar ARCO atau karakter roster lain**. Revisi pengguna: referensi karakter lama membuat hasil terlalu mirip, bahkan dengan instruksi STYLE ONLY. Kesamaan format berasal dari pixel density, outline, skala runtime, dan pipeline; identitas visual harus berbeda. +3. Buat 2–4 opsi base, pilih/kunci satu desain yang disetujui. Base full-body, hadap kanan, pose netral, semua anggota tubuh/ekor/senjata tidak terpotong. +4. Sesudah dikunci, semua animasi baru memakai **base karakter itu sendiri** sebagai referensi identitas. Idle final boleh ditambahkan sebagai referensi proporsi. +5. Simpan prompt lengkap, parameter, reference ID, job ID, versi sumber, dan alasan pilihan. Gambar ARCO/proyek lama bukan identitas untuk karakter baru. + +### Pembeda eksplisit pada setiap prompt base + +Sebelum generate, tulis identitas karakter baru serta daftar ciri roster lama yang tidak boleh diwarisi. Wajib bedakan siluet keseluruhan, bentuk/panjang rambut, wajah/ekspresi, palet dominan, konstruksi pakaian, anatomi/alat bertarung, dan aksesori khas. Mengganti telinga atau warna saja tidak cukup. + +Untuk karakter setelah ARCO, hindari kombinasi rambut putih/perak pendek berduri, mata turquoise, jaket navy berlis emas, sash turquoise, armor ivory, dan lengan mekanis besar. Sebut larangan tersebut secara eksplisit di prompt, lalu jelaskan pilihan penggantinya secara konkret. Demi-human serigala tetap harus punya wajah/tubuh manusia dan ciri serigala yang terbaca, bukan ARCO yang ditambah telinga/ekor. + +Periksa hasil base berdampingan dengan roster lama sebelum animasi: apakah masih bisa dikenali sebagai karakter berbeda jika tanpa warna? Jika siluet, wajah, rambut, atau kostumnya terlalu serupa, revisi base. Jangan meneruskan kandidat yang ditolak ke strip animasi. Setelah base baru dipilih, gunakan base itu sendiri untuk menjaga identitas seluruh animasinya. + +## 2. Pembagian model dan aset + +| Jenis | Jalur proyek ini | Pengaturan acuan | +| --- | --- | --- | +| Base / sprite karakter | Higgsfield, `gpt_image_2_5` | `variant: sunburst`, `quality: high`, `resolution: 2k` | +| Satu pose jump/tuck | Model sama | 1:1, satu pose | +| Strip gerakan 4 frame | Model sama | 21:9, satu baris; base + guide 4 slot | +| Arena / prop / drone / portrait / cut-in / ikon | Higgsfield, `seedream_v5_pro` | Arena 16:9 2k; portrait/prop 1:1 2k; cut-in 21:9 2k; ikon 1:1 1k | +| Label, HP, timer, cooldown wipe | HTML/CSS/canvas | Tetap elemen UI, jangan dibakar ke gambar | +| Voice karakter bila diminta | Higgsfield, `text2speech_v2`, `variant: elevenlabs` | Pakai pasangan voice_id/voice_type yang valid dan pilihan pengguna; simpan audio/provenance | + +Nama/parameter di atas mencatat model yang dipakai. Saat menjalankan generasi berikutnya, periksa parameter yang didukung bila perlu. Gunakan tool Higgsfield untuk generation sesuai pilihan pengguna; sprite-gen menangani prepare, chroma removal, extraction, curation, dan compose. Jangan beralih ke generator CLI/provider lain tanpa alasan atau pilihan pengguna. + +Project/folder Higgsfield dibuat atau dipakai ulang sesuai konteks chat yang benar. ID dalam log lama adalah provenance; jangan menjadikannya folder tujuan otomatis untuk chat baru. Tunggu job yang sudah terkirim, simpan hasilnya, dan ulangi hanya item yang jelas gagal. Jangan membuat ulang satu batch penuh hanya karena satu request terkena rate limit. + +Bentuk request referensi: + + { + "params": { + "model": "gpt_image_2_5", + "variant": "sunburst", + "quality": "high", + "resolution": "2k", + "aspect_ratio": "21:9", + "folder_id": "", + "medias": [ + {"role": "image", "value": ""}, + {"role": "image", "value": ""} + ], + "prompt": "" + } + } + +Chroma harus berbeda dari material karakter: magenta #FF00FF untuk palet ARCO; hijau #00FF00 bila karakter memakai pink/ungu. Larang warna chroma di tubuh/aksesori. Background flat, tanpa floor shadow, efek, teks, garis guide, atau nomor frame. + +## 3. Paket 14 state / 50 frame + +Salin [sprite-request.json](templates/sprite-request.json) lalu sesuaikan deskripsi aksi dengan anatomi/alat karakter. Angka fps adalah metadata default; pemilihan frame jump, locomotion, dan timing skill mengikuti gameplay. + +| State | Frame | FPS metadata | Loop | Isi | +| --- | ---: | ---: | --- | --- | +| idle | 4 | 5 | ya | Napas halus, kaki tetap | +| walk | 4 | 10 | ya | Contact → passing → opposite contact → passing | +| run | 4 | 13 | ya | Sprint jelas berbeda dari walk, kaki bergantian | +| jump | 1 | 10 | tidak | Pose udara atletis, satu gambar yang ditahan | +| doublejump | 1 | 12 | tidak | Pose tuck, lutut rapat; diputar runtime | +| crouch | 4 | 8 | tidak | Menunduk/guard rendah | +| attack1 | 4 | 13 | tidak | Pembuka: wind-up, ayun, impact, recovery | +| attack2 | 4 | 13 | tidak | Serangan sambungan, siluet berbeda | +| attack3 | 4 | 11 | tidak | Finisher, recovery kembali seukuran idle | +| skill1 | 4 | 11 | tidak | Cast/aim/fire/recovery sesuai kemampuan | +| skill2 | 4 | 10 | tidak | Aksi area sesuai kemampuan | +| ultimate | 4 | 9 | tidak | Pose cast/command; efek ultimate terpisah | +| hurt | 4 | 9 | tidak | Recoil lalu kembali ke guard | +| down | 4 | 7 | tidak | Terpental, jatuh, mendarat, rebah | + +Jumlah ini baseline ARCO untuk konsistensi. Jika perlu menambah frame, ubah request, guide, manifest, dan QA bersama; jangan memotong grid dengan asumsi jumlah lama. + +### Jump yang disetujui + +Gunakan **satu pose atletis**, bukan idle melayang kaku. Torso sedikit condong, siku menekuk, lutut dekat terangkat wajar dengan tulang kering ke bawah, kaki jauh rileks ke belakang. Tidak ada tendangan lurus atau rangkaian jongkok/takeoff di udara. Lintasan naik-turun berasal dari fisika. + +### Double jump yang disetujui + +Satu pose tubuh menggulung, kedua lutut rapat dan tangan/alat dekat badan. Pertahankan ukuran kepala serta panjang anatomi saat menekuk. Runtime memutarnya 360° dalam 0,28 s dengan pivot tubuh terukur. Simpan pivot lokal pose tuck dan world-pivot untuk transisi dari jump. Jangan memakai koordinat pivot ARCO untuk karakter lain tanpa mengukur ulang. + +## 4. Struktur folder dan sumber asli + + assets// + character.md + base/base-selected.png + raw-original/-v1.png # file generator asli, tidak di-resample + raw-original/-v2.png # revisi tetap disimpan + raw-original/.png # salinan sumber terpilih untuk tool + guides/ + request.json + generation-log.json + selected-sources.json + calibration.json + rel.json + playback.json + frame-metrics.json + manifest.js + run/raw/ # hasil normalisasi sekali dari sumber asli + run/frames/ # intermediate, bukan input runtime + run/curation.json + run/sprite-sheet-alpha.png + run/manifest.json + run/qa-notes.md + qa/ + +UI/prop boleh berada di folder bersama assets/ui, tetapi nama portrait/cut-in/ikon harus jelas memiliki karakter tertentu agar tidak menimpa karakter lama. Simpan raw UI di originals. `selected-sources.json` menggunakan [template provenance](templates/selected-sources.example.json). + +## 5. Prepare dan guide + +Gunakan Python dari venv sprite-gen, bukan interpreter global acak. Jalankan dari root proyek. Contoh Windows berikut membuat run baru setelah base-selected.png tersedia; ganti ID/path sesuai karakter baru. + + $spriteRoot = 'C:\Users\effen\.codex\skills\sprite-gen' + $spritePy = Join-Path $spriteRoot '.venv\Scripts\python.exe' + $characterId = 'new-character' + $charDir = Join-Path (Get-Location) ('assets\' + $characterId) + $runDir = Join-Path $charDir 'run' + & $spritePy -X utf8 -m sprite_gen.cli prepare ` + --out-dir $runDir --character-id $characterId ` + --base-image (Join-Path $charDir 'base\base-selected.png') ` + --request (Join-Path $charDir 'request.json') ` + --chroma-key '#FF00FF' --no-fit-pixel-unfake ` + --fit-resample nearest --fit-align-x foot-centroid ` + --fit-align-y bottom --fit-ground-frames + +Template cell **320×288**, safe margin x=10/y=8, anchor (160,280). Idle manusia/chibi target sekitar 180 px. Cell boleh diperbesar bila ekor/sayap/alat tidak muat, tanpa mengecilkan frame tertentu. Semua angka ini harus dicatat bila diubah. + +Untuk row empat pose, guide generasi 1680×720 (21:9), empat slot420 px. Gunakan renderer guide sprite-gen seperti contoh implementasi mecha_pipeline.py. Guide hanya untuk spacing; extractor memisahkan komponen, bukan memotong cell generator secara buta. Jump/tuck satu pose menggunakan guide satu slot / rasio 1:1. + +## 6. Normalize → extract → ukur → koreksi + +1. Simpan original setiap job. Cek jumlah pose, arah, gap, dan kelengkapan anggota tubuh sebelum memproses. +2. Kalibrasi tinggi idle terhadap target 180 px. Pitch hanya perkiraan awal. Faktor akhir per state/pose: `target / mean(pitchX,pitchY) × rel`. +3. Ukur kepala **dan badan**: crown→sabuk, sabuk→kaki, shoulder/alat, serta recovery→idle pada baseline sama. Kesamaan luas rambut saja tidak menjamin karakter tidak mengecil. +4. Lakukan satu resample BOX langsung dari original ke run/raw. Catat target dan rel. Jangan memperbaiki skala dengan memperbesar atlas yang sudah kecil. +5. Jalankan canonical component extraction. Perbesar cell bila ada per-frame fit/cap warning. Jangan mengganti canonical extractor dengan crop grid sementara. +6. Jalankan pengukuran kepala/body untuk semua aksi. Buka montagenya. Template kepala miring bisa menangkap sepatu/bahu; hasil itu tidak valid, bukan angka untuk diterapkan. +7. Koreksi kembali dari original, lalu extract ulang state yang berubah. Curation hanya `dx/dy` integer dan `scale=1`. + +Contoh satu state (nilai harus berasal dari pengukuran karakter baru): + + $target = 0.5 # perkiraan awal, kalibrasikan dari idle + $rel = 1.0 # contoh, ganti hasil ukur state ini + & $spritePy -X utf8 guide/tools/rebuild_raw.py ` + (Join-Path $charDir 'raw-original\walk.png') ` + (Join-Path $runDir 'raw\walk.png') --poses 4 --target $target --rel $rel + & $spritePy -X utf8 -m sprite_gen.cli extract --run-dir $runDir --states walk + +Untuk jump/doublejump gunakan `--poses 1`. Angka rel ARCO bukan preset universal. Salin struktur file, lalu ukur ulang nilainya. + +## 7. Compose dan QA akhir + + & $spritePy -X utf8 -m sprite_gen.cli compose-atlas --run-dir $runDir + & $spritePy -X utf8 -m sprite_gen.cli compose-gif --run-dir $runDir --out-dir (Join-Path $runDir 'exports') + & $spritePy -X utf8 -m sprite_gen.cli inspect --run-dir $runDir + & $spritePy -X utf8 guide/tools/measure_atlas.py $runDir + +- Atlas final: tidak ada frame kosong, tepi terpotong, atau chroma terlihat. Simpan checksum/integrity report. +- Idle selisih tinggi maksimum 2 px; torso tidak bergeser lebih dari sekitar±1,5 px pada loop tegak. Recovery/action body jangan terlihat mengecil ketika kembali ke idle. +- Walk/run harus punya contact/passing dan pergantian kaki yang benar. Rasio jarak kaki kecil/besar≤0,45 hanya indikator, bukan bukti tunggal gait sudah mulus. +- Bandingkan kepala dan tubuh di original serta atlas; pisahkan toleransi target dari hasil yang benar-benar tercapai. Rotasi/occlusion yang tidak terukur harus ditandai. +- Periksa di latar gelap/terang, kanan/kiri, seluruh pose, serta transisi di game. Simpan screenshot keyframe. Jangan menyebut sudah menonton full loop bila hanya membuka contact sheet. +- Reukur jangkauan serangan dan titik emisi setelah compose. Muzzle laser/peluru harus menyambung ke alat/telapak, bukan posisi hardcoded lama. + +## 8. Integrasi runtime dan aset pendukung + +Koordinat frame dibaca dari `manifest.frame_layout.rows[state][index]`; timing default dari `manifest.animation.rows[state]`. Dimensi/anchor dari `manifest.cell`. Jump/tuck satu frame adalah pengecualian playback yang disengaja, bukan kegagalan animasi. + +Runtime saat ini membaca `window.MECHA_MANIFEST` dan `window.MECHA_METRICS` dari assets/mecha/manifest.js. Metrics memuat `states`, stride estimate, `emitters`, dan `playback`. Manifest.js adalah wrapper otomatis dari hasil compose supaya file:// tidak membutuhkan fetch JSON. Lihat [sprite-runtime.md](sprite-runtime.md). + +| Field metrics | Arti / cara mendapatkannya | +| --- | --- | +| `states..frames[i].bounds` | left/right/top/bottom alpha final relatif anchor; dihitung dari atlas setelah curation | +| `states..frames[i].height` | Tinggi silhouette final; bukan ukuran anatomi jika pose menekuk | +| `states..stride_estimate` | Estimasi siklus walk/run; beri catatan metode dan batas verifikasi | +| `emitters..x/y` | Titik efek relatif anchor, diukur pada pose impact/cast yang benar | +| `playback.jump.airFrame` | Index satu pose airborne yang dipilih | +| `playback.jump.torsoPivot` | Posisi pivot dunia relatif kaki saat beralih ke tuck | +| `playback.doublejump.pivot` | Pivot lokal tuck relatif anchor | +| `playback.doublejump.duration` | Catatan baseline 0,28 s; CONFIG.rollDuration di game.js mengendalikan timer runtime saat ini | + +Setelah frame-metrics.json lengkap dan semua pivot/emitter terukur, wrapper dapat dibuat ulang tanpa menyalin koordinat manual: + + @' + import json, sys + from pathlib import Path + char = Path(sys.argv[1]) + manifest = json.loads((char / 'run/manifest.json').read_text(encoding='utf-8')) + metrics = json.loads((char / 'frame-metrics.json').read_text(encoding='utf-8')) + (char / 'manifest.js').write_text( + 'window.MECHA_MANIFEST = ' + json.dumps(manifest) + ';\n' + + 'window.MECHA_METRICS = ' + json.dumps(metrics) + ';\n', encoding='utf-8') + '@ | & $spritePy -X utf8 - $charDir + +Contoh memakai global kompatibel slot demo sekarang, bukan registry multi-karakter. Atlas tetap menunjuk file yang sama dengan manifest yang baru diekspor. + +**Demo kini memiliki pilihan pemain ARCO/FENR/MIRA/CORA di roster dan pengaturan**, dengan integrasi eksplisit `syncPlayerForm` dalam game.js, bukan registry otomatis. Menaruh folder karakter baru saja belum membuatnya playable. Sesuaikan script index.html, loader atlas, pilihan karakter, metadata, kit, portrait dan ikon. Setiap manifest memakai global terpisah. FENR memberi contoh dua bentuk lengkap, recovery tambahan, timer transformasi, serta AI memakai kit yang sama: [fenr-reference.md](fenr-reference.md). MIRA memberi contoh pilot + kendaraan: cell 448, chroma hijau, summon roket, ukuran dikoreksi lewat calibration tanpa generate ulang ([mira-reference.md](mira-reference.md)). CORA memberi contoh karakter bersayap: cell 448 untuk sayap terbuka, proyektil kipas dengan `vy`, summon bergelombang yang dapat dihindari dengan lompatan, dan kit yang didaftarkan lewat peta `KITS` ([cora-reference.md](cora-reference.md)). + +Tool di folder proyek `tools/` seperti mecha_pipeline.py, hair_landmark_audit.py, check_hud_jump.py, dan verify_atlas_integrity.py masih terikat ke assets/mecha/ARCO. Jangan menjalankannya dengan harapan otomatis memilih karakter baru. Parameterisasikan/copy dengan path yang ditinjau, atau pakai CLI generik di atas dan tool guide/tools yang menerima path. + +Generate portrait baru, ikon setiap skill, dan cut-in tersendiri. Ekor/senjata/prop harus konsisten dengan kartu identitas. Untuk summon, buat aset terpisah dan ukur muzzle/pivot setelah cutout. Gunakan sprite-gen cutout untuk chroma removal; jika satu piksel terpisah membuat bbox terlalu besar, pilih komponen utama yang benar dan periksa hasilnya. Jangan mengambil portrait dari crop atlas lama. + +Avatar mengikuti [avatar-standard.md](avatar-standard.md): close-up kepala besar, export384×384, sumber kanonis menghadap kanan. UI pemain memakai kanan dan UI musuh mencerminkan gambar ke kiri, termasuk portrait transformasi. Gunakan class/atribut slot yang sama untuk semua karakter baru. + +## 9. Syarat penyerahan karakter berikutnya + +- Kartu identitas, base, original berversi, prompt/job log, pilihan sumber, request, rel, calibration, dan playback terisi. +- Atlas + manifest terbaru dipublikasikan bersama; semua state tersedia dengan jumlah frame yang dicatat. +- Nilai skala, pivot, emisi, hit range, dan stride tidak diwarisi tanpa pengukuran. +- HUD/avatar/ikon/cut-in mengikuti format visual yang sama; nama/kemampuan bisa berbeda sesuai kelas. +- HP, basic-hit recharge, input, pause/reset, dan pemisahan cast/efek mengikuti gameplay-standard. +- Tes relevan dan browser QA dilakukan; batas bukti ditulis. Jangan mengubah sprite/karakter lama ketika menambah yang baru. + +Contoh nyata dan jejak revisinya: [ARCO reference](arco-reference.md). diff --git a/guide/cora-reference.md b/guide/cora-reference.md new file mode 100644 index 0000000..f3e55a1 --- /dev/null +++ b/guide/cora-reference.md @@ -0,0 +1,62 @@ +# CORA — penari langit demi-human gagak + +CORA adalah petarung Demi-Human kedua dan karakter keempat di roster: wanita muda demi-human gagak dengan sepasang sayap hitam besar dan dua pisau pendek berbentuk bulu. Pemain dapat memilihnya di roster VS/Training dan Pengaturan → Karakter pemain; CPU memakai kit yang sama. Kartu identitas: assets/cora/character.md. + +## Desain yang dipilih + +Pengguna meminta rekomendasi demi-human wanita, lalu melihat tiga konsep hasil generate: LEPA (kelinci, kickboxer), MAKO (hiu, petarung pelabuhan) dan CORA (gagak, penari langit). Lineup: assets/concepts/demi-human-female/lineup-round1.png. Pengguna memilih **CORA**. Rambut hitam lurus panjang berkilau biru-violet dengan bulu kecil, mata amber, anting amber, mantel Renaisans violet gelap dengan trim bulu arang, kancing amber, legging dan sepatu bot hitam. Konsep langsung dipakai sebagai base: base-selected.png, job `80fdb0b0-1c88-4ee7-8340-ef27d375b6df`. Chroma hijau #00FF00 karena karakter memakai violet. + +Model: sama seperti MIRA, semua gambar CORA dibuat dengan **GPT Image 2.5 Sunburst**, termasuk portrait, ikon, cut-in dan VFX. Announcer tetap Grady. + +## Paket siap digunakan ulang + +| Paket | Isi | +| --- | --- | +| Sprite | 15 state / 54 frame, atlas 1792×4320, cell 448×288, anchor 224/280, idle 184 px dari kaki sampai ujung bulu rambut | +| State | idle, walk, run, crouch, attack1–3, skill1–2, ultimate, hurt, down, recover masing-masing 4; jump/tuck masing-masing 1 | +| UI | portrait 384, cut-in 1600×686, 4 ikon 256, roster art | +| VFX | 3 PNG alpha: feather (proyektil), gust (sapuan angin), raven (gagak ultimate) | +| Audio | `select_cora` “Cora!” (0.914 s), `cora_wins` “Cora wins!” (1.306 s), Grady. Voice ultimate Anika “Fly, my ravens!” — [mira-cora-voice.md](mira-cora-voice.md) | + +Cell 448 dipakai karena sayap yang terbuka membuat pose jauh lebih lebar dan tinggi daripada tubuhnya (jump 216 px, skill2 frame 1 236 px). Ukuran 184 px dipilih dari lineup 176/184/190/198 di samping ARCO, FENR dan MIRA (qa/size-candidates.png): sedikit lebih pendek dari FENR 194. Nilai ini dapat diubah hanya dengan calibration target baru dari original yang sama, tanpa generate ulang. Wrapper `CORA_MANIFEST/METRICS` ada di [assets/cora/manifest.js](../assets/cora/manifest.js). Sprite library memutar semua animasi 1×/0.5× dan kanan/kiri. + +## Kemampuan dan balance + +| Input | Gerakan | Damage / cooldown | +| --- | --- | --- | +| Space×3 | FEATHER WALTZ: tebasan pisau depan, tebasan naik pisau belakang, tebasan silang X dengan sayap terbuka (reach 90/78/115) | 6/8/12 | +| I | QUILL VOLLEY: kipas tiga bulu (5+5+6). Bulu lurus 840 px/s, dua bulu miring ±0.1 rad 790 px/s, umur 1 s; kontrol lepas saat bulu keluar | 16 / 3 s | +| O | WING GUST: dua sayap menyapu ke depan; area 30 px di belakang sampai 190 px di depan, knockback 300, tanpa dash | 24 / 6 s | +| P | NIGHT MURMURATION: pose cast 0.5 s, tiga gelombang gagak × 16 | 48 / 18 s | + +Tiap basic hit valid mengembalikan 5% cooldown maksimum semua skill (I 0.15 s, O 0.30 s, P 0.90 s). HP tetap 200 dalam dua lapis. Semua nilai ada di [cora.js](../cora.js). + +Pada jarak dekat–menengah ketiga bulu mengenai target (16). Pada jarak jauh bulu atas melewati kepala; tes mengunci contoh 760 px = 11 damage (bulu lurus 5 + bulu bawah 6). Bulu lurus sedikit lebih cepat sehingga hit terbaca sebagai tiga ketukan, bukan satu tumpukan angka. + +### Timeline Night Murmuration + +| Bagian | Waktu dari cast | +| --- | --- | +| Cut-in banner violet/amber | 0–0.78 s | +| Pose cast pemain | 0–0.50 s, lalu bebas bergerak/menyerang | +| Gelombang 1 / 2 / 3 terbang | 0.55 / 0.85 / 1.15 s pada ketinggian 70 / 112 / 150 px di atas lantai | +| Gerak gelombang | 1500 px/s searah cast, mulai 140 px di belakang CORA, 10 gagak berjarak 40 px | +| Selesai | 2.40 s | + +Setiap gelombang memberi satu hit saat gagak terdepan melewati target. Hit hanya terjadi bila target berada di depan CORA (toleransi 30 px) dan tubuhnya menutupi ketinggian gelombang. Lompatan biasa yang tepat waktu (puncak ±119 px) melewati gelombang terendah; tes mengunci contoh ini (32 damage, bukan 48). Tidak ada global hit-stop; guncangan per gelombang 0.6×. Knockback 60/60/190. Pause membekukan, reset/kembali menu/akhir ronde membatalkan. Kawanan pemain (`flock`) dan CPU (`enemyFlock`) memiliki owner terpisah. + +## Integrasi engine + +- `KITS` di game.js memetakan `mira` dan `cora` ke kontrak kit yang sama (`start`, `move`, `refund`): durasi basic, cast, reach dan aksi CPU memakai jalur itu. Setiap karakter tetap memiliki fungsi strike sendiri (`coraStrike`) dan summon sendiri (`startMurmuration`, dipilih lewat `startSummon`). +- Proyektil kini dapat membawa `vy` (lintasan miring, sprite diputar searah gerak), `knockback` dan `size`. Proyektil lama tanpa field ini tetap berperilaku sama. +- Snapshot QA berisi `flock`/`enemyFlock`; cut-in memakai kelas `.cora-cutin`, art ui/cutin.png, judul NIGHT MURMURATION dari HTML. + +## Reproduksi + +1. Prompt/job: konsep concept-requests.json; strip strip-requests.json (15 v1 + skill2/recover v2); UI ui-requests.json. +2. `tools/cora_pipeline.py measure|calibrate|normalize|extract|publish` dengan Python venv sprite-gen. `cleanup.json` kosong (tidak ada goresan yang perlu dihapus). +3. `tools/cora_measure.py` untuk edge-NCC wajah (utama) dan torso (pembanding); hasil di `assets/cora/qa/scale-measure.json` dan `scale-check.png`. Nilai terpilih di rel.json, alasannya di `qa/rel-decision.json`. +4. `tools/cora_align.py` menulis curation integer; `publish` menghasilkan atlas, metrics, playback, contact sheet dan wrapper JS. +5. `tools/cora_assets.py` mengekspor portrait/ikon/cut-in/VFX dan roster art. + +Catatan QA dan batas bukti: assets/cora/run/qa-notes.md. Tes: `node tests/cora.test.cjs` (17 tes). diff --git a/guide/cpu-ai.md b/guide/cpu-ai.md new file mode 100644 index 0000000..4a0e529 --- /dev/null +++ b/guide/cpu-ai.md @@ -0,0 +1,73 @@ +# CPU AI — cara lawan komputer berpikir + +Status: 28 September 2026. Pengguna melaporkan CPU level Excellent "biasa aja" dan level di bawahnya makin gampang. CPU lama diganti dengan "otak" baru di [game.js](../game.js) (`updateFenrAI` dan fungsi `cpu*`). Angka per level ada di `MatchRules.difficulties` ([match.js](../match.js)). + +## Kenapa CPU lama lemah + +- Bergerak berdasarkan timer. Jalannya 135–205 px/s (pemain jalan 320, lari 520), dan hanya menyerang bila jarak < 105 px. +- Tidak membaca pemain sama sekali: tidak melompati proyektil, tidak mundur dari serangan, tidak punish, tidak anti-air. +- Memakai ultimate setelah N detik tanpa melihat situasi. +- Sering memukul saat pemain sedang kebal (0,9 s setelah terkena hit), sehingga pukulannya terbuang. +- Pemain bisa "mengunci" CPU. Setelah rantai 3 hit, CPU masih ter-stun, lalu pemain memulai rantai baru sebelum CPU sempat bergerak. Pemain punya kebal 0,9 s setelah stun, sedangkan CPU tidak. + +## Otak CPU baru + +1. **Membaca dengan jeda manusia (`reaction`).** Serangan, lompatan dan proyektil pemain baru "terlihat" setelah jeda itu. Setiap gerakan pemain dibaca sekali. +2. **Menghindari proyektil.** CPU menghitung kapan tembakan sampai dan setinggi apa. Ia lalu melompat, atau double jump untuk tembakan tinggi, supaya kakinya di atas proyektil tepat saat kontak. +3. **Menghindari serangan.** Kalau serangan pemain dalam jangkauan, CPU mundur keluar jangkauan. Kalau waktunya tidak cukup atau terpojok di dinding, CPU melompat di atasnya. Sesudah itu CPU bisa langsung punish. +4. **Punish.** Serangan yang meleset membuka pemain selama masa recovery, jadi CPU langsung maju. Skill dan ultimate membuat pemain kebal, jadi CPU menunggu dan memukul tepat saat gerakan itu selesai. +5. **Anti-air.** Pemain yang turun dari lompatan disambut pukulan darat. +6. **Tidak membuang pukulan.** CPU memperhitungkan waktu kena setiap gerakannya. Saat pemain kebal (setelah stun atau selama skill), CPU menunggu di luar jangkauan pemain, lalu maju supaya pukulannya mendarat tepat saat kebal habis. +7. **Combo terkonfirmasi.** Rantai basic hanya dilanjutkan kalau hit sebelumnya kena. Kalau knockback membuat jarak terlalu jauh, CPU melangkah dulu selama stun masih ada. Rantai ditutup dengan skill (ender). CPU tidak pernah memulai serangan baru pada pemain yang sedang ter-stun, jadi CPU tidak punya combo tak terbatas. +8. **Jarak.** Kalau pemain mendekat atau menekan tombol, CPU memukul duluan dari jangkauan penuh (memperhitungkan langkah pemain). Kalau pemain diam, CPU mendekat ke jarak di mana seluruh rantai kena. CPU berlari bila jauh dan memakai proyektil atau skill yang jangkauannya lebih panjang. +9. **Ultimate.** Setelah `ultimateAfter`, CPU memakainya pada kesempatan pertama. +10. **Riak Dune Serpent.** Saat riak ultimate NAJA milik pemain terkunci di bawah CPU, CPU membacanya setelah `reaction`, lalu (peluang `evade`) berlari keluar lewat sisi terdekat yang tidak menabrak dinding sampai kobra menyembur. Riak terkunci 0.42 s sebelum gigitan, jadi Excellent biasanya lolos sedangkan Easy terlambat. +11. **Proyektil melambung.** Slag Shot HALDOR jatuh di bawah gravitasi, jadi tidak dilompati. CPU membaca titik jatuhnya setelah `reaction`, lalu (peluang `evade`) berlari keluar dari percikan lewat sisi terdekat sampai bola mendarat. Gelombang Forge Quake adalah proyektil lantai biasa, jadi aturan lompat (poin 2) berlaku. + +## Aturan adil (tanpa curang) + +- Damage dan HP **tidak pernah** diubah oleh difficulty. +- Mode Versus: stun CPU 0,5 s, jadi rantai 3 hit pemain tetap tersambung penuh. Setelah stun habis, CPU mendapat kebal (`immunity`) seperti pemain. Batas combo (`comboCap`) memberi kebal lebih awal bila satu stun sudah menerima N hit, supaya rantai yang diulang-ulang tidak mengunci CPU. +- Training tidak memakai kebal maupun batas combo, jadi dummy tetap terbuka untuk latihan combo. + +| Level | Kecepatan | Reaksi | Recovery | Rantai | Ultimate | Agresif | Hindar | Punish | Anti-air | Ender | Salah | Kebal CPU | Batas combo | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| Easy | 0.95 | 0.34 s | 0.60 s | 2 | 10 s | 60% | 25% | 30% | 25% | 15% | 22% | 0.45 s | 6 hit | +| Medium | 1.10 | 0.20 s | 0.38 s | 3 | 7 s | 80% | 55% | 60% | 55% | 50% | 8% | 0.9 s | 5 hit | +| Hard | 1.22 | 0.13 s | 0.22 s | 3 | 5 s | 90% | 78% | 82% | 78% | 85% | 3% | 0.9 s | 4 hit | +| Excellent | 1.32 | 0.08 s | 0.12 s | 3 | 3.5 s | 97% | 92% | 95% | 90% | 100% | 0% | 0.9 s | 4 hit | + +"Salah" adalah peluang CPU menyerang saat pemain kebal atau menyambung rantai secara buta. "Ender" adalah peluang menutup rantai yang kena dengan skill. + +## Hasil benchmark + +`node tools/cpu_bench.cjs []` menjalankan tiga skenario untuk setiap level dan keempat lawan: + +- **A:** pemain diam, diukur detik sampai K.O. +- **B:** pemain terus menembak proyektil, diukur persentase tembakan yang mengenai CPU. +- **C:** bot pemain sederhana yang mendekat, menekan rantai basic dan memakai skill saat siap. Bot memainkan keempat karakter, lalu dihitung ronde yang dimenangkan bot vs CPU. + +| Level | A: K.O. pemain diam (lama → baru) | B: tembakan kena CPU (lama → baru) | C: bot vs CPU (lama → baru) | +| --- | --- | --- | --- | +| Easy | 26–34 s → 21–32 s | ~95% → ~53% | 32–0 → 32–0 | +| Medium | 24–40 s → 12–29 s | 100% → ~44% | 32–0 → 29–8 | +| Hard | 21–27 s → 11–22 s | 100% → ~38% | 32–0 → 21–14 | +| Excellent | 21–29 s → 10–18 s | 100% → ~35% | 32–2 → **15–20** | + +Setelah NAJA playable (5 lawan, bot memainkan kelima karakter, 25 pasangan): bot vs CPU Easy 48–5, Medium 35–23, Hard 28–29, Excellent 17–38. Waktu K.O. pemain diam untuk CPU NAJA 23.4 / 19.9 / 16.0 / 15.7 s, sejajar MIRA dan CORA. + +Setelah HALDOR playable (6 lawan, 36 pasangan): bot vs CPU Easy 68–7, Medium 53–28, Hard 44–36, Excellent 32–46. Waktu K.O. pemain diam untuk CPU HALDOR 26.0 / 23.6 / 19.6 / 17.1 s, sedikit lebih lambat dari yang lain (tank). + +Setelah ZANNI playable (7 lawan, 49 pasangan): bot vs CPU Easy 94–7, Medium 75–31, Hard 63–47, Excellent 45–64. Waktu K.O. pemain diam untuk CPU ZANNI 21.2 / 19.7 / 16.7 / 15.7 s, sejajar MIRA/CORA/NAJA. Ring Toss dilempar dari jarak `range` 430 px (`cpuReach`), bukan 560 px seperti proyektil lurus. + +Setelah ISOLDE playable (8 lawan, 64 pasangan): bot vs CPU Easy 123–9, Medium 96–42, Hard 77–69, Excellent 55–88. Waktu K.O. pemain diam untuk CPU ISOLDE 21.3 / 15.5 / 19.4 / 18.5 s; Medium lebih cepat dari Hard/Excellent pada pemain diam (belum dianalisis). Proyektil yang mengenai CPU ISOLDE di Hard hanya 14%. + +Setelah RHEA playable (9 lawan, 81 pasangan): bot vs CPU Easy 157–9, Medium 125–52, Hard 100–87, Excellent 68–113. Waktu K.O. pemain diam untuk CPU RHEA 17.6 / 16.7 / 19.2 / 15.8 s; Hard lebih lambat dari Medium pada pemain diam (belum dianalisis). + +Setelah SOLAN playable (10 lawan, 100 pasangan): bot vs CPU Easy 194–11, Medium 158–59, Hard 129–99, Excellent 94–129. Waktu K.O. pemain diam untuk CPU SOLAN 25.6 / 22.5 / 19.9 / 18.6 s, turun rapi per level; proyektil yang mengenai CPU SOLAN 52 / 43 / 36 / 35%. + +Setelah NIB playable (11 lawan, 121 pasangan): bot vs CPU Easy 236–11, Medium 194–68, Hard 154–120, Excellent 110–158. Waktu K.O. pemain diam untuk CPU NIB 17.9 / 17.2 / 12.1 / 12.7 s, tercepat di roster pada Hard/Excellent (rantai tercepat); proyektil yang mengenai CPU NIB 52 / 42 / 34 / 31%. + +Setelah EDDA playable (12 lawan, 144 pasangan): bot vs CPU Easy 281–13, Medium 233–78, Hard 184–138, Excellent 139–177. Waktu K.O. pemain diam untuk CPU EDDA 28.0 / 27.4 / 25.8 / 18.8 s, paling lambat di roster: rantainya pelan dan Shell Counter tidak dipakai melawan pemain yang tidak menyerang. CPU hanya memilih Shell Counter saat pemain sedang memulai serangan dasar yang belum mengenai, dan tidak memakainya sebagai penutup combo. Proyektil yang mengenai CPU EDDA 52 / 43 / 42 / 33%. + +Bot ini sangat konsisten: menyerang tepat saat masuk jangkauan dan selalu mendapat kebal 0,9 s. Excellent kini menang lebih sering melawannya. Easy tetap bisa dimenangkan dengan menekan tombol terus, tetapi CPU-nya sudah menghindari sekitar separuh proyektil. Angka benchmark adalah bukti relatif antarlevel, bukan penilaian terhadap pemain manusia. Tes: `node tests/cpu-ai.test.cjs`. diff --git a/guide/edda-reference.md b/guide/edda-reference.md new file mode 100644 index 0000000..a31923b --- /dev/null +++ b/guide/edda-reference.md @@ -0,0 +1,69 @@ +# EDDA — sang petapa cangkang + +EDDA adalah petarung Demi-Human keenam dan karakter kedua belas di roster: nenek kura-kura dengan tongkat panjang dan cangkang kubah besar, satu-satunya petarung counter. Ia berasal dari 8 karakter showcase dan dibuat playable terakhir, setelah NIB. Pemain dapat memilihnya di roster VS/Training dan di Pengaturan → Karakter pemain; CPU memakai kit yang sama. Kartu identitas: assets/edda/character.md. + +## Dari showcase ke playable + +Alurnya sama dengan NIB ([nib-reference.md](nib-reference.md)). Body showcase v1 (sudah chibi) langsung menjadi base sprite (base-selected.png, job `b9ecaa00-3a50-479f-a295-6e82cd791e49`), dan avatar showcase dipakai sebagai portrait HUD. Di roster.json EDDA ditandai `promoted`, jadi **showcase kini kosong**: kedelapan karakter showcase sudah playable. Semua gambar baru dibuat dengan **GPT Image 2.5 Sunburst**, chroma magenta (palet EDDA hijau giok). Announcer tetap Grady. Alat pipeline: `tools/edda_*.py`. + +## Paket siap digunakan ulang + +| Paket | Isi | +| --- | --- | +| Sprite | 15 state / 54 frame, atlas 1792×4320, cell 448×288, anchor 224/280, idle 170 px (sepatu sampai cincin tongkat), lebar 138 px | +| UI | portrait 384 (avatar showcase), cut-in 1600×686, 4 ikon 256, roster art dari showcase | +| VFX | PNG alpha: stone (batu giok), ripple (riak pantulan), shell (kubah heksagon saat counter), stomp (hentakan batu) 256 px; tortoise (roh kura-kura) 512 px dengan kaki di tepi bawah | +| Audio | `select_edda` "Edda!" (0.993 s), `edda_wins` "Edda wins!" (1.071 s), Grady. Voice ultimate Opal "Elder Tortoise!" (1.411 s, sunyi di akhir dipotong) | + +Ukuran 170 px dipilih dari lineup 170/180/190/200 (size-candidates.png). Pada ukuran itu wajah setara RHEA/HALDOR dan kepalanya tetap di bawah petarung dewasa, cocok untuk nenek yang bungkuk. Wrapper `EDDA_MANIFEST/METRICS` ada di [assets/edda/manifest.js](../assets/edda/manifest.js). Sprite library memutar semua animasi. + +## Kemampuan dan balance + +| Input | Gerakan | Damage / cooldown | +| --- | --- | --- | +| Space×3 | STAFF FORMS: tusukan, sapuan naik, hentakan ke lantai. Reach 110/95/80 di dalam ujung tongkat (126/111/82 px); durasi 0.40/0.46/0.58 s | 6/8/12 | +| I | STONE SKIP: batu giok dilempar dari 60 px di atas lantai, memantul dua kali (hop 300 px/s) lalu tenggelam, ±630 px dari EDDA. Tidak pernah lebih tinggi dari ±65 px, jadi lompatan melewatinya | 16 / 3 s | +| O | SHELL COUNTER: jendela guard 0.55 s. Hit pertama diabaikan; penyerang dalam 170 px dibalas 24 (knockback 260). Tembakan dari jauh hanya ditahan. Satu counter per guard | 24 / 6 s | +| P | ELDER TORTOISE: pose cast 0.5 s, roh kura-kura berjalan dan menghentak tiga kali | 48 / 18 s | + +Setiap basic hit yang valid mengembalikan 5% cooldown maksimum semua skill. HP tetap 200 dalam dua lapis. Semua nilai ada di [edda.js](../edda.js). + +### Timeline Elder Tortoise + +| Bagian | Waktu dari cast | +| --- | --- | +| Cut-in banner giok | 0–0.78 s | +| Pose cast pemain | 0–0.50 s, lalu bebas bergerak/menyerang | +| Roh muncul | 0.60 s, 140 px di depan EDDA, lalu berjalan maju 120 px/s | +| Hentakan 1 / 2 / 3 | 0.80 / 1.18 / 1.56 s; roh terangkat 34 px sebelum tiap hentakan | +| Hentakan | kena lawan di lantai dalam 150 px dari roh; 16 damage, knockback 40/40/200 | +| Selesai | 2.3 s (roh memudar) | + +Hentakan 0.38 s berselang, lebih pendek dari stun 0.42 s, sehingga lawan yang diam di depan EDDA terkena 48. Melompat saat hentakan (tes: lawan di udara pada ketiga hentakan tidak kena) atau berada lebih dari 150 px dari roh menghindarinya; roh tidak berbalik, jadi lawan di belakang EDDA aman. Tidak ada global hit-stop. Pause membekukan serangan ini, sedangkan reset, kembali ke menu dan akhir ronde membatalkannya. Serangan pemain (`tortoise`) dan CPU (`enemyTortoise`) memiliki owner terpisah. + +## CPU + +- EDDA sebagai CPU memakai otak yang sama ([cpu-ai.md](cpu-ai.md)) dan kit-nya lewat `KITS`. Shell Counter hanya dipilih saat pemain sedang memulai serangan dasar yang belum mengenai, dan tidak pernah dipakai sebagai penutup combo. +- Benchmark (EDDA sebagai CPU): pemain diam K.O. dalam 28.0 / 27.4 / 25.8 / 18.8 s (Easy→Excellent), paling lambat di roster: rantainya pelan, dan counter tidak berguna melawan pemain diam. Proyektil yang mengenai CPU 52 / 43 / 42 / 33%. Detail di [cpu-ai.md](cpu-ai.md). + +## Voice + +"Elder Tortoise!" digenerate dengan empat preset wanita: Mabel, Opal, Vera dan Delia. Opal dipilih karena pitch mediannya paling rendah (±258 Hz; Vera ±299, Mabel ±314, Delia ±381), paling matang untuk nenek petapa. Ucapan selesai 1.19 s, jadi sunyi di akhir dipotong (1.411 s). Angka pitch hanya panduan; timbre belum didengar pengguna. Cara mengganti: salin kandidat lain dari `assets/edda/audio/ultimate-candidates/` ke `edda-ultimate.mp3`, lalu ganti `VOICE_NAMES.edda` di game.js. Record: generation.json. + +## Integrasi engine + +- `KITS` memetakan `edda` ke kontrak kit yang sama. EDDA memiliki `eddaStrike` (tongkat, batu) dan summon `startTortoise`/`updateTortoise` (dipilih lewat `startSummon`). +- Proyektil kini mendukung `skip` (gravitasi sendiri `skipG`, `bounces` pantulan dengan kecepatan `hop`, riak di setiap sentuhan). Proyektil lama tidak berubah. +- Aksi dengan `guard` (Shell Counter) diperiksa di awal `receiveHit` (pemain) dan `hitDummy` (CPU) lewat `shellGuard`: hit pertama di jendela `guardEnd` dibatalkan, animasi lompat ke frame serangan balik, dan penyerang dalam jangkauan terkena 24. +- Roh kura-kura adalah efek `edda-spirit` berukuran tetap (300 px) yang digambar dari lantai; x-nya diperbarui oleh summon. +- Snapshot QA berisi `tortoise`/`enemyTortoise` (tanpa referensi efek). Cut-in memakai kelas `.edda-cutin`, art ui/cutin.png, dan judul ELDER TORTOISE dari HTML. + +## Reproduksi + +1. Prompt/job: desain di roster.json; strip di strip-requests.json (15 × v1); UI di ui-requests.json. +2. `tools/edda_pipeline.py measure`, `tools/edda_measure.py`, lalu tulis `rel.json` (alasan di `qa/rel-decision.json`). +3. `tools/edda_pipeline.py calibrate`, `sprite_gen.cli prepare --out-dir assets/edda/run --character-id edda --base-image assets/edda/base/base-selected.png --request assets/edda/request.json --chroma-key #FF00FF`, lalu `normalize` dan `extract`. +4. `tools/edda_align.py`, lalu `publish`. Hitung emitter (ujung tongkat di frame 3) ke `emitters.json`, lalu jalankan `publish` sekali lagi. +5. `tools/edda_assets.py` mengekspor portrait/ikon/cut-in/VFX, lalu `tools/web_images.py` dan `node tools/build_dist.mjs`. + +Catatan QA: assets/edda/run/qa-notes.md. Tes: `node tests/edda.test.cjs` (14 tes). diff --git a/guide/fenr-reference.md b/guide/fenr-reference.md new file mode 100644 index 0000000..7c1e519 --- /dev/null +++ b/guide/fenr-reference.md @@ -0,0 +1,50 @@ +# FENR — Demi-Human dan Werewolf + +FENR menggantikan MOKU-01 sebagai lawan AI aktif. Pemain dapat memilih ARCO atau FENR melalui Pengaturan → Karakter pemain. Hanya pemain memiliki skill deck; lawan menampilkan HP, portrait, status, serta timer transformasi. Sakelar Lawan aktif memungkinkan latihan tanpa serangan AI. + +## Desain yang dipilih + +Pengguna memilih konsep pertama dari revisi terakhir: ranger laki-laki, rambut chestnut terikat, mata hijau, jerkin hijau hutan, linen krem, scarf rust, ekor serigala cokelat. Base terpilih, job `ca8a528c-cc64-4697-8d7e-409aac2d126b`. + +Ultimate berupa **werewolf bipedal berpakaian sobek**, bukan hewan empat kaki. Kepala bermoncong serigala, badan berbulu, dua tangan bercakar, dua kaki, pakaian hijau/scarf/celana tetap dikenakan. Base werewolf, job `72f241fe-234f-4844-a7cb-ff45aa842fcc`. Base empat kaki `base/wolf.png` adalah hasil yang ditolak dan tidak dimuat runtime. Konsep manusia A/B sebelumnya juga ditolak karena mirip ARCO. + +## Paket siap digunakan ulang + +| Paket | Isi | +| --- | --- | +| Human | 15 state / 54 frame, atlas1280×4320, cell320×288, anchor160/280 | +| Werewolf | 15 state / 54 frame, atlas1792×4320, cell448×288, anchor224/280 | +| State tiap bentuk | idle, walk, run, crouch, attack1–3, skill1–2, ultimate, hurt, down, recover masing-masing4; jump/tuck masing-masing1 | +| UI | 2 portrait, 7 ikon skill/kombo, 1 cut-in | +| VFX | 6 PNG alpha: claw, gale, rush, bite, howl, transform | + +Jumlah total108 frame dan16 aset UI/VFX. Werewolf memakai cell lebih lebar agar cakar pada pounce tidak terpotong; skala sprite tetap1. Jangan mengecilkan seluruh werewolf untuk memasukkan cakar ke cell320. Tinggi idle final human194px termasuk telinga; werewolf216–217px termasuk telinga. Perbedaan tinggi walk/run merupakan pose kaki/torso, bukan normalisasi seluruh bounding box per animasi. + +Revisi avatar: kedua portrait runtime sekarang memakai v2 close-up kepala,384×384, kanonis menghadap kanan. Sumber v1 yang terlalu jauh disimpan sebagai arsip. Pemain kanan, musuh kiri pada semua bentuk dan layar; lihat [avatar-standard.md](avatar-standard.md). + +Atlas/manifest berada di `assets/fenr/human/` dan `assets/fenr/wolf/`. Wrapper globals `FENR_HUMAN_MANIFEST/METRICS` dan `FENR_WOLF_MANIFEST/METRICS` berbeda dari ARCO. Sprite library menyediakan semua animasi kedua bentuk, preview0.5×/1× dan facing kanan/kiri. Paket sudah mencakup gerakan untuk pengujian sebagai pemain; tidak perlu generate ulang ketika sekadar memilih FENR. + +## Kemampuan dan balance + +| Input | Demi-Human | Werewolf | Damage / cooldown | +| --- | --- | --- | --- | +| Space×3 | Ranger Chain: claw jab, rising palm, heel kick | Savage Chain: bite, claw uppercut, cross claw | 6/8/12 versus7/9/13 | +| I | Gale Claw, proyektil angin | Fang Pounce, lompatan serang dengan cakar | 16 versus18; CD3s | +| O | Ranger Rush, lunge dua telapak | Moon Howl, gelombang area | 24 versus22; CD6s | +| P | Feral Awakening, cast0.62s lalu transformasi | Tidak bisa diaktifkan ulang selama transformasi | Durasi12s; CD24s mulai saat transformasi berakhir | + +Transformasi tidak memberi damage langsung, tidak mengisi ulang HP, dan tidak mereset cooldown I/O. HUD menunjukkan sisa durasi dalam detik dan bar menurun. Kedaluwarsa membatalkan serangan werewolf yang belum dilepas supaya tidak menghasilkan damage bentuk lama. Pause membekukan timer; reset/KO membatalkan bentuk. Tiap basic hit valid mengembalikan5% cooldown maksimum:0.15/0.30/1.20s pada FENR. Kedua HP tetap200 dengan dua lapis100 pada satu bar. + +AI memakai kit/damage yang sama dengan FENR pemain. Ia mendekat, menjaga jarak, memilih basic/skill, dan bertransformasi setelah7s saat bisa bertindak. Serangan memiliki wind-up yang dapat dilihat; ring amber di kaki menandai ancang-ancang. Ada recovery/jeda keputusan dan hit-stun; AI tidak menyerang saat KO atau lawan sedang KO. Mode latihan mengembalikan petarung setelah KO. + +## Reproduksi aset + +Generator: Higgsfield GPT Image2.5 Sunburst, high,2k untuk sprite; Seedream5.0Pro untuk UI/VFX. Referensi gerakan adalah base FENR sendiri serta guide empat slot. Semua prompt/parameter/job tersimpan pada human requests, werewolf requests, UI requests, dan koreksi. + +Human ultimate v1 hanya menghasilkan3 badan sehingga ditolak. Runtime memakai v2 lengkap4pose, job `e13394c5-6902-447a-b2ac-f00b0914f556`. Selected sources menetapkan file yang benar dan hash sumber. Original berversi tidak diubah. + +Gunakan Python venv sprite-gen. Urutan: prepare → kalibrasi dari original → normalize → canonical extract → integer curation → publish. Wrapper fenr_pipeline.py menerima bentuk human/wolf. Jangan menjalankan prepare/calibrate otomatis pada atlas final tanpa menjaga rel/curation terpilih. `rel.json` dan `calibration.json` adalah parameter final. Resample BOX hanya sekali dari original; runtime tidak mengubah skala antarframe. + +QA ukuran memakai edge-template pada salinan analisis tinggi288px, dengan konversi skala kembali ke original dicatat dalam `qa/edge-head-measurements.json`. Match kepala miring/tertutup yang menangkap sepatu/tangan **ditolak**, lalu memakai kepala tegak yang valid pada strip dan pemeriksaan badan di contact sheet. Detektor warna rambut pernah menangkap pakaian dan tidak dipakai sebagai otoritas skala final. Tidak semua kepala miring memiliki pengukuran template independen yang valid. + +`fenr_align.py` menstabilkan torso idle/walk/run serta posisi kepala jump/tuck dengan translasi integer dari frame hasil extract; tidak mengubah anatomi atau skala. Pivot dihitung ulang dari atlas sesudah curation. Titik emisi diukur pada endpoint impact final; detector uppercut yang sempat menangkap kaki ditolak dan diganti ROI cakar teratas. `fenr_assets.py` memakai canonical sprite-gen cutout untuk enam efek. Manifest, metrics dan atlas diterbitkan bersama, cek108frame kosong/terpotong=0. diff --git a/guide/fenr-voice.md b/guide/fenr-voice.md new file mode 100644 index 0000000..09c83e2 --- /dev/null +++ b/guide/fenr-voice.md @@ -0,0 +1,32 @@ +# Voice ultimate FENR — Holden + +Status: pengguna memilih **Holden**, sudah dipasang pada awal Feral Awakening/cut-in pemain maupun AI. File runtime: [fenr-ultimate-holden.mp3](../assets/fenr/audio/fenr-ultimate-holden.mp3). Archie dan Grady tetap menjadi arsip kandidat. + +Kalimat Inggris yang sama untuk Holden, Archie, dan Grady: + +> Unleash the beast! + +Provider: Higgsfield. Model `text2speech_v2`, varian `elevenlabs`, `voice_type: preset`. Ini audio hasil generasi baru untuk FENR, bukan sample preview katalog atau audio ARCO. + +| Voice | ID preset | File | +| --- | --- | --- | +| Holden | 3c9d6053-6334-592c-8997-4e325286af3f | holden.mp3 | +| Archie | bd072316-f77c-588b-b6e5-e46b9b03d008 | archie.mp3 | +| Grady | e2a2d2e6-9ed2-59cd-82af-feaa27f8a678 | grady.mp3 | + +Generation record menyimpan prompt, parameter lengkap, job ID, URL hasil, durasi, format, SHA256, dan hasil decode ffmpeg. Pemeriksaan decode memastikan file audio valid; tidak menilai timbre/akting atau memastikan suara sudah dimainkan speaker. + +## Perilaku runtime + +- Holden dan Dylan dipreload sebagai dua file lokal, dengan satu saluran percakapan aktif. Tidak ada autoplay sebelum interaksi pengguna membuka audio. AI yang sudah terlewat tidak memutar ucapan tertunda saat audio baru dibuka. +- Ultimate pemain memprioritaskan voice pemain; AI tidak menyela voice yang sedang aktif. Jika pemain memulai ultimate ketika AI sedang berbicara, ucapan AI berhenti agar tidak bertumpuk. +- Voice mengikuti volume/mute; SFX diturunkan menjadi38% selama ucapan berjalan. Pause/pengaturan/halaman tersembunyi menjeda voice; resume melanjutkan posisinya. +- Reset/pergantian karakter menghentikan voice dan mengembalikannya ke awal. Cast AI yang terputus atau pemilik KO menghentikan ucapan yang tidak lagi relevan. +- Voice tetap bisa selesai sesudah banner cut-in0.78s berlalu. Audio tidak memperpanjang cast0.62s atau transformasi12s dan tidak menahan gerakan. +- Akhir summon ARCO tidak menghentikan Holden yang berasal dari transformasi FENR. Event/rejection dari file atau pemutaran lama tidak mengambil alih voice baru. + +## Verifikasi + +File runtime identik dengan kandidat Holden terpilih: SHA256 `367de2be24809ba23d308181e7fe9a0af2b13c3ecf2e89b23e33cbe48177477e`. Durasi container1.410612s, durasi playable browser1.36s. Browser: preload readyState4, voiceHolden/ownerplayer, playback melaju dari0.016471s sebelum pause ke0.067883s setelah resume, tanpa error. + +`node tests/fenr-voice.test.cjs`:9tes meliputi pemain/AI, gating interaksi, durasi voice melewati cut-in, pause/settings/volume/mute, prioritas satu speaker, interruption/reset, pergantian karakter, visibility, playback rejection dan race antarfile. Semua74tes proyek lulus (41gameplay,15FENR,9Dylan,9Holden). Bukti playback/decoding bukan penilaian timbre atau volume speaker OS. diff --git a/guide/gameplay-standard.md b/guide/gameplay-standard.md new file mode 100644 index 0000000..0fec001 --- /dev/null +++ b/guide/gameplay-standard.md @@ -0,0 +1,131 @@ +# Acuan final game fighting + +Status: 27 September 2026, setelah seluruh revisi pengguna. Ini acuan perilaku terbaru; laporan lama mencatat riwayat, bukan pengaturan yang harus dikembalikan. Sumber implementasi: [game.js](../game.js), [index.html](../index.html), dan [style.css](../style.css). + +## Lingkup dan visual + +- Demo lokal dengan pilihan pemain ARCO (Mecha), FENR (Demi-Human) dan MIRA (Mecha, pilot anak + robot; [mira-reference.md](mira-reference.md)) serta CORA (Demi-Human gagak bersayap; [cora-reference.md](cora-reference.md)) dan NAJA (Demi-Human kobra dengan urumi; [naja-reference.md](naja-reference.md)) serta HALDOR (Mecha tank pandai besi; [haldor-reference.md](haldor-reference.md)) ZANNI (Mecha badut lengan gunting; [zanni-reference.md](zanni-reference.md)) ISOLDE (Mecha ksatria kaki bangau; [isolde-reference.md](isolde-reference.md)) RHEA (Mecha zoner orrery; [rhea-reference.md](rhea-reference.md)) SOLAN (Demi-Human singa pedang besar; [solan-reference.md](solan-reference.md)) NIB (Demi-Human tikus kurir; [nib-reference.md](nib-reference.md)) dan EDDA (Demi-Human nenek kura-kura, counter; [edda-reference.md](edda-reference.md)). FENR menggantikan MOKU-01 sebagai AI lawan aktif. Detail kit dua bentuk dan sumber aset: [fenr-reference.md](fenr-reference.md). +- Main menu kini menyediakan VS Computer, Training, Pengaturan dan About. VS memilih pemain→lawan→arena/difficulty; ARCO, FENR, MIRA, CORA, NAJA, HALDOR, ZANNI, ISOLDE, RHEA, SOLAN, NIB dan EDDA tersedia sebagai CPU, dengan otak yang membaca pemain dan knob per difficulty ([cpu-ai.md](cpu-ai.md)); pilihan arena ada lima. Pertandingan pertama menang2ronde, maksimal3nomor ronde,90detik per ronde. Training memakai pemulihan KO lama dan AI pasif default. Detail [main-menu.md](main-menu.md). +- FENR bertransformasi menjadi werewolf BIPEDAL berpakaian sobek selama12detik; countdown di HUD pemilik, cooldown ultimate24detik setelah bentuk berakhir. Basic/I/O memiliki sprite, damage, ikon, dan efek berbeda pada bentuk werewolf. Skill deck lawan tidak ditampilkan. +- Anime chibi dengan pixel art detail tinggi / hi-bit. Hindari blok 8-bit besar, perubahan proporsi antargerakan, atau frame yang bergantian tajam dan buram. +- Tema abad pertengahan/Renaisans fantasi, siang cerah. Kamera arena diam; tidak mengikuti pemain dan tidak memakai parallax. +- Dunia simulasi 1280×720. Ground ARCO saat ini y=599, diukur dari lantai stage. Ukur ulang bila latar diganti. +- Canvas memenuhi viewport. Proyeksi karakter seragam, tidak diregangkan; latar menggunakan cover yang diselaraskan dengan lantai. Shake menggeser stage dan objek bersama, dengan overscan untuk menutup tepi kosong. + +## Kontrol dan gerakan bersama + +| Input | Perilaku final | +| --- | --- | +| A / D | Jalan ke kiri/kanan | +| Dua tap A atau D, lalu tahan | Lari; batas antar-tap 260 ms. Ini klarifikasi atas tulisan awal “Double WA” | +| W | Lompat; W kedua juga dapat memakai lompatan udara | +| S | Menunduk di lantai; mempercepat jatuh ketika sedang turun | +| Dua tap S | Salto/double jump; maksimum dua lompatan per airtime | +| Space | Satu serangan per tap; tiga tap mengantre attack1→attack2→attack3 | +| I / O / P | Skill1 / skill2 / ultimate | +| Esc / R | Pause / reset latihan | + +Key repeat saat tombol ditahan tidak dianggap tap baru. Antrean kombo dibatasi tiga serangan. Lepas tombol, ganti arah, blur, dan visibility cleanup membersihkan sprint/riwayat tap. Blur tidak membuka menu pause otomatis. + +| Parameter | Nilai saat ini | +| --- | --- | +| Jalan / lari | 320 / 520 px per detik | +| Akselerasi tanah / udara | 2800 / 1600 px per detik² | +| Kecepatan awal jump / double jump | 830 / 790 px per detik ke atas | +| Gravitasi / minimum fast fall | 2900 px per detik² / 1180 px per detik | +| Putaran double jump | 360° selama 0,28 detik | +| Simulasi | Fixed step 1/120 detik, render terpisah | + +Lompat normal menahan **satu pose atletis** selama lintasan fisika: siku/kaki menekuk, bukan berdiri kaku. Jangan memutar ulang pose jongkok/takeoff ketika karakter sudah di udara. Double jump memakai satu pose menggulung, diputar mengelilingi pivot tubuh terukur. Bukan pose menendang. Sesudah putaran selesai, kembali langsung ke pose jump. Skala sprite tetap. + +Selama salto, arah visual tidak berubah mendadak dan fast fall ditunda sampai putaran selesai. Satu input attack/skill valid terakhir dapat disimpan lalu dijalankan setelah roll. Reset atau hurt membatalkan antrean itu. + +## HP: satu bar fisik, dua lapis nyata + +Pemain dan target masing-masing **200 HP**, terdiri dari dua lapis 100 HP dalam **satu area bar HUD**. Warna cadangan berada di belakang warna utama. + + front = clamp(hp - 100, 0, 100) + reserve = clamp(hp, 0, 100) + +| HP | Isi lapis depan | Isi cadangan | Tampilan | +| --- | --- | --- | --- | +| 200 | 100% | 100% | Warna depan menutupi seluruh cadangan | +| 140 | 40% | 100% | 40% warna depan, sisanya warna cadangan | +| 100 | 0% | 100% | Satu lapis cadangan penuh | +| 75 | 0% | 75% | Cadangan mulai berkurang | +| 0 | 0% | 0% | K.O. | + +Ini **bukan dua baris vertikal**, bukan shield tambahan yang regenerasi sendiri, dan bukan damage trail yang turun terlambat. Kedua fill berada di parent `.life-track` yang sama. Target tidak dipaksa KO oleh ultimate; hanya total HP nol memicu KO. HP tidak direset setelah menerima skill biasa. Di mode latihan, target pulih setelah animasi KO/recovery; pemain memakai core restart ketika benar-benar kehabisan HP. + +## Baseline power dan recharge + +Nilai berada di objek `BALANCE` pada game.js. Ini baseline demo; karakter baru boleh punya efek/identitas berbeda, tetapi perubahan power harus dicatat dan diuji. Jangan menyebut baseline ini sebagai hasil keseimbangan multiplayer. + +| Serangan | Damage | Cooldown | Knockback awal | Peran | +| --- | --- | --- | --- | --- | +| Basic 1 | 6 | Tidak ada | 75 px/s | Pembuka kombo | +| Basic 2 | 8 | Tidak ada | 100 px/s | Sambungan | +| Basic 3 | 12 | Tidak ada | 180 px/s | Finisher; total kombo 26 | +| I — Aether Bolt | 16 | 3 s | 140 px/s | Proyektil cepat, gerak bebas setelah peluru keluar | +| O — Seismic Drive | 24 | 6 s | 220 px/s | Hantaman area, radius damage 220 px | +| P — Helios Squadron | 4×12 = 48 | 18 s | 45 px/s tiap hit awal; 190 pada hit terakhir | Summon mandiri, dapat dibarengi aksi pemain | + +Satu ultimate penuh menyisakan **152 dari 200 HP** pada target segar. Tidak ada forced KO/forced knockdown dari ultimate. Satu ayunan, peluru, atau laser hanya memberi satu hit pada target; efek yang masih terlihat tidak terus memberikan damage per frame. + +Setiap basic attack yang mengenai target valid memajukan cooldown **semua skill sebesar 5% dari total cooldown masing-masing**: + + remaining = max(0, remaining - maxCooldown * 0.05) + +- I: −0,15 s; O: −0,30 s; P: −0,90 s per hit. +- Tiga pukulan kombo yang masuk memberi 15%, selain berjalannya timer biasa. +- Meleset, target kebal/KO/down, dan hit skill tidak memberi bonus. +- Skill yang sudah siap tetap nol; bonus tidak disimpan sebagai saldo. +- Bingkai ikon mendapat kilatan mint singkat 0,22 s saat memperoleh bonus. + +## Pisahkan pose cast, efek, dan cooldown + +Tiga durasi ini bukan satu timer. Jangan mengunci karakter sampai seluruh VFX selesai. + +- I melepas karakter saat proyektil diluncurkan, sekitar 0,18 s untuk atlas ARCO saat ini. Pose tembak tampil sebelum release; proyektil tetap bergerak sendiri. +- O memakai komitmen animasi sekitar 0,4 s dari empat frame pada 10 fps, impact sekitar 53% durasi. +- P hanya memakai pose summon **0,50 s**. Setelah itu karakter bebas jalan, lompat, berbalik, atau menggunakan serangan lain sementara drone masih aktif. +- Summon menyimpan arah dan posisi cast/target sendiri. Pemain berjalan atau terkena damage setelah cast tidak membatalkan drone. Cooldown dan guard terhadap summon ganda tetap berlaku. +- Hit-stop berlaku pada pukulan langsung/slam. Peluru dan laser summon tidak membekukan gerak pemiliknya. + +## Timeline ultimate ARCO + +| Bagian | Waktu simulasi dari cast | +| --- | --- | +| Banner cut-in | 0–0,78 s; masuk sampai 0,16 s, keluar mulai 0,57 s | +| Pose pemanggilan pemain | 0–0,50 s | +| Drone mulai masuk | 0,46 / 0,56 / 0,66 / 0,76 s | +| Perjalanan masuk tiap drone | 0,60 s | +| Empat laser | Mulai 1,50 s, selang 0,095 s | +| Umur visual setiap laser | 0,65 s; damage hanya sekali | +| Drone berangkat pergi / selesai | Mulai 2,24 s / seluruh rangkaian 2,85 s | + +Drone berjumlah empat, memakai posisi formasi terpisah, dibatasi agar tidak terpotong di tepi arena. Target harus berada di arah cast yang valid. Titik laser berasal dari muzzle terukur pada aset drone, bukan pusat gambar. Pause membekukan banner/drone/cooldown bersama; reset membatalkan seluruh rangkaian dan damage yang belum terjadi. + +Cut-in menggunakan ilustrasi ARCO baru, banner miring navy–cyan–emas, nama jurus besar dalam HTML, dan peredupan arena sesaat. Referensi pengguna hanya menjadi acuan pola interaksi; jangan menyalin karakter, teks, atau ornamen referensi mentah-mentah. + +## HUD, efek, audio + +- Dua status petarung saling berhadapan, portrait hasil generasi khusus, mode latihan ∞ di tengah, ikon skill bergambar. +- Portrait bukan crop sprite yang sedang dipakai. Generate ilustrasi avatar dan cut-in tersendiri memakai base sebagai referensi identitas. +- Font Rajdhani lokal di assets/fonts, dengan OFL.txt. Label/angka UI dibuat dengan HTML/canvas agar tetap tajam. +- Countdown memakai sapuan **hitam transparan radial searah jarum jam** pada ikon; tidak memakai angka countdown. Key binding statis Space/I/O/P tetap boleh ditampilkan. +- Tidak ada riwayat/chip tombol yang sedang ditekan pada layar. +- Setiap skill punya VFX berbeda. Efek tidak dibakar ke sprite karakter. +- Asap lari muncul tiap 30 px perjalanan nyata di tanah, dua puff kecil per emisi. Tidak muncul saat jalan, melompat, atau tertahan. Puff lama tetap memudar alami. +- Tidak ada kilatan brightness putih pada dummy, filter merah/blink pada hero, atau rotasi kecil terus-menerus saat dummy idle/hurt. Gunakan recoil/knockback, percikan, damage number, audio, dan stage shake yang terkendali. +- Audio diaktifkan dari input pengguna. Mute, volume, tes audio, fullscreen, dan hitbox tersedia dalam pengaturan. +- Voice ultimate final: **Dylan**, dibuat lewat Higgsfield/ElevenLabs, kalimat “Helios Squadron! Open fire!”. Diputar mulai cut-in, mengikuti mute/volume, pause/resume, dan reset. SFX direndahkan saat dialog berjalan. Lihat [ultimate-voice.md](ultimate-voice.md). +- Voice ultimate MIRA **Luna** “Rocket Parade!” dan CORA **Anika** “Fly, my ravens!” (juga NAJA **Soraya** “Dune Serpent!” HALDOR **Gideon** “Forge Quake!” ZANNI **Julian** “Grand Finale!” ISOLDE **Vesper** “Skyfall Lances!” RHEA **Chloe** “Grand Orrery!” SOLAN **Xavier** “Sunmane Roar!” NIB **Evan** “Special Delivery!” dan EDDA **Opal** “Elder Tortoise!”): kanal yang sama dengan Dylan/Holden, dimulai saat summon dibuat dan selesai sendiri walaupun summon masih berjalan. Lihat [mira-cora-voice.md](mira-cora-voice.md). + +## Verifikasi wajib setelah perubahan + +Jalankan `node tests/gameplay.test.cjs` dan `node --check game.js`. Saat acuan ini ditulis ada **41 tes gameplay lulus**. Tes memakai shim DOM/canvas/audio dan tidak membuktikan mutu render atau speaker. + +Untuk voice, jalankan juga `node tests/ultimate-voice.test.cjs` (**9 tes audio-control**) dan `node tests/summon-voice.test.cjs` (**7 tes Luna/Anika**). Periksa file audio dimuat dan playback berjalan pada browser dari input pengguna. + +Periksa browser: seluruh aset dimuat; gerak/jump; release I; berjalan saat drone menembak; kedua lapis HP pada 200/140/100/75/0; bonus cooldown; asap di kaki; cut-in dan radial cooldown; pause/reset; layout lebar dan sempit. Bedakan bukti kode, angka atlas, screenshot, dan gerakan yang benar-benar diamati. Lihat [character-workflow.md](character-workflow.md) untuk membuat karakter berikutnya. diff --git a/guide/haldor-reference.md b/guide/haldor-reference.md new file mode 100644 index 0000000..0bb9b3f --- /dev/null +++ b/guide/haldor-reference.md @@ -0,0 +1,70 @@ +# HALDOR — tank tungku berjalan + +HALDOR adalah petarung Mecha ketiga dan karakter keenam di roster: pandai besi tua dalam exoframe tungku uap tembaga, bersenjata palu tempa berkepala landasan. Ia berasal dari 8 karakter showcase dan dibuat playable setelah NAJA. Pemain dapat memilihnya di roster VS/Training dan di Pengaturan → Karakter pemain; CPU memakai kit yang sama. Kartu identitas: assets/haldor/character.md. + +## Dari showcase ke playable + +Alurnya sama dengan NAJA ([naja-reference.md](naja-reference.md)). Body showcase chibi v2 langsung menjadi base sprite (base-selected.png, job `0f826aeb-57af-4a07-ac46-30dec1e4c6bd`), dan avatar showcase dipakai sebagai portrait HUD. Di roster.json HALDOR ditandai `promoted`, jadi showcase kini berisi 6 karakter. Semua gambar baru dibuat dengan **GPT Image 2.5 Sunburst**. Announcer tetap Grady. + +## Paket siap digunakan ulang + +| Paket | Isi | +| --- | --- | +| Sprite | 15 state / 54 frame, atlas 1792×4320, cell 448×288, anchor 224/280, idle 169 px (kaki sampai bibir cerobong), lebar 164–169 px | +| UI | portrait 384 (avatar showcase), cut-in 1600×686, 4 ikon 256, roster art dari showcase | +| VFX | 4 PNG alpha 256: slag (bola terak), splash (percikan lelehan), steam (semburan uap), quake (gelombang batu lava) | +| Audio | `select_haldor` “Haldor!” (1.149 s), `haldor_wins` “Haldor wins!” (1.306 s), Grady. Voice ultimate Gideon “Forge Quake!” (1.228 s) | + +Ukuran 168 px (hasil 169) dipilih dari lineup 160/168/176/184 (size-candidates.png). Pada ukuran itu kepala dengan jenggot setara NAJA/CORA, sedangkan badannya paling lebar di roster, sesuai peran tank. Wrapper `HALDOR_MANIFEST/METRICS` ada di [assets/haldor/manifest.js](../assets/haldor/manifest.js). Sprite library memutar semua animasi. + +## Kemampuan dan balance + +| Input | Gerakan | Damage / cooldown | +| --- | --- | --- | +| Space×3 | FORGE CHAIN: hook, uppercut, smash landasan ke lantai. Reach 112/90/95 mengikuti kepala palu terukur (131/83/85 px). Rantai paling lambat (0.42/0.48/0.62 s) dengan knockback terbesar (95/125/235) | 6/8/12 | +| I | SLAG SHOT: bola terak dilempar melambung ke tempat lawan berdiri saat lemparan (180–560 px di depan), waktu terbang 0.9 s, puncak ±200 px di atas lantai. Kena langsung saat melintas atau memercik 72 px saat mendarat. Kontrol lepas saat bola keluar | 16 / 3 s | +| O | STEAM RAM: serudukan bahu, dash 340 px/s (maju ±100 px), reach 118, knockback 320 | 24 / 6 s | +| P | FORGE QUAKE: pose cast 0.5 s, tiga hantaman di kawah depan HALDOR | 48 / 18 s | + +Setiap basic hit yang valid mengembalikan 5% cooldown maksimum semua skill. HP tetap 200 dalam dua lapis. Semua nilai ada di [haldor.js](../haldor.js). + +Slag Shot berbeda dari proyektil lain: bola jatuh di bawah gravitasi, jadi tidak bisa dilompati. Cara menghindarnya adalah berpindah tempat setelah lemparan. Pada jarak dekat (<±180 px) bola bisa mengenai badan saat masih naik, seperti lemparan jarak dekat. + +### Timeline Forge Quake + +| Bagian | Waktu dari cast | +| --- | --- | +| Cut-in banner tembaga/bara | 0–0.78 s | +| Pose cast pemain | 0–0.50 s, lalu bebas bergerak/menyerang | +| Hantaman 1 / 2 / 3 | 0.80 / 1.18 / 1.56 s di kawah 70 px di depan HALDOR | +| Gelombang | sepasang per hantaman (kiri dan kanan), 760 px/s sepanjang lantai, 16 damage, satu hit per gelombang | +| Selesai | 2.20 s (gelombang terakhir terus berjalan sampai keluar layar) | + +Jarak antar hantaman 0.38 s, lebih pendek dari stun 0.42 s, sehingga lawan yang diam terkena 48. Satu lompatan (0.57 s di udara) melewati dua gelombang. Lompatan yang tepat waktu ditambah double jump melewati ketiganya; tes mengunci kedua contoh (184 dan 200 HP). Gelombang berjalan ke dua arah, jadi posisi lawan tidak menentukan. Tidak ada global hit-stop; knockback 60/60/190. Pause membekukan serangan ini, sedangkan reset, kembali ke menu dan akhir ronde membatalkannya. Serangan pemain (`quake`) dan CPU (`enemyQuake`) memiliki owner terpisah. Gelombang memakai jalur proyektil biasa, jadi CPU yang membaca proyektil otomatis mencoba melompatinya. + +## CPU + +- HALDOR sebagai CPU memakai otak yang sama ([cpu-ai.md](cpu-ai.md)) dan kit-nya lewat `KITS`. Jangkauan palu yang pendek membuatnya membuka rantai dari ±100 px. +- Aturan baru untuk semua lawan CPU: proyektil melambung (Slag Shot pemain) tidak dilompati. CPU membaca titik jatuhnya setelah jeda `reaction`, lalu (peluang `evade`) berlari keluar dari percikan lewat sisi terdekat sampai bola mendarat. Tes: Excellent minimal 4/6, Easy lebih sedikit. +- Benchmark (HALDOR sebagai CPU): pemain diam K.O. dalam 26.0 / 23.6 / 19.6 / 17.1 s (Easy→Excellent), sedikit lebih lambat dari MIRA/CORA/NAJA, sesuai tank. Proyektil yang mengenai CPU 52 / 39 / 37 / 37%. + +## Voice + +Katalog preset tidak menyertakan deskripsi suara, jadi “Forge Quake!” digenerate dengan empat preset pria: Gideon, Barrett, Bram dan Knox. Gideon dipilih karena pitch median-nya paling rendah (±81 Hz, di bawah Holden milik FENR ±92 Hz) dan sedikit frame periodik, yang menandakan suara serak cocok untuk pandai besi tua. Angka pitch berasal dari estimasi autokorelasi sederhana dan hanya menjadi panduan; isi dan timbre belum didengar pengguna. Take dipakai apa adanya (ucapan selesai di 0.88 s). Cara mengganti: salin kandidat lain dari `assets/haldor/audio/ultimate-candidates/` ke `haldor-ultimate.mp3`, lalu ganti `VOICE_NAMES.haldor` di game.js. Record: generation.json. + +## Integrasi engine + +- `KITS` memetakan `haldor` ke kontrak kit yang sama. HALDOR memiliki `haldorStrike` (lob, tebasan, smash, serudukan) dan summon `startQuake`/`updateQuake` (dipilih lewat `startSummon`). Dash Steam Ram memakai jalur dash yang sama dengan FENR/MIRA. +- Proyektil kini mendukung `gravity` (lintasan lengkung) dan mendarat lewat `slagSplash`. Proyektil lama tanpa `gravity` tidak berubah. +- `bodyGap` 62 px bila HALDOR bertarung (badan lebar), bayangan lebih lebar. +- Snapshot QA berisi `quake`/`enemyQuake`. Cut-in memakai kelas `.haldor-cutin`, art ui/cutin.png, dan judul FORGE QUAKE dari HTML. + +## Reproduksi + +1. Prompt/job: desain di roster.json; strip di strip-requests.json (15 × v1); UI di ui-requests.json. +2. `tools/haldor_pipeline.py measure`, `tools/haldor_measure.py`, lalu tulis `rel.json` (alasan di `qa/rel-decision.json`). +3. `tools/haldor_pipeline.py calibrate`, `sprite_gen.cli prepare --out-dir assets/haldor/run --character-id haldor --base-image assets/haldor/base/base-selected.png --request assets/haldor/request.json --chroma-key #00FF00`, lalu `normalize` dan `extract`. +4. `tools/haldor_align.py`, lalu `publish`. Hitung emitter (ujung terjauh di frame 2) ke `emitters.json`, lalu jalankan `publish` sekali lagi. +5. `tools/haldor_assets.py` mengekspor portrait/ikon/cut-in/VFX. + +Catatan QA: assets/haldor/run/qa-notes.md. Tes: `node tests/haldor.test.cjs` (15 tes). diff --git a/guide/hit-reactions.md b/guide/hit-reactions.md new file mode 100644 index 0000000..a578d10 --- /dev/null +++ b/guide/hit-reactions.md @@ -0,0 +1,53 @@ +# Hit feedback, hurt, dan KO + +Acuan ini mengikuti [gameplay final](gameplay-standard.md). + +## Aset karakter + +Petarung baru tetap menyiapkan hurt 4 dan down 4 dengan base yang sama. Hurt: recoil → stagger → regain balance → guard. Down: jatuh ke belakang relatif facing, lalu rebah. Hadap kanan berarti kepala jatuh ke kiri; arah lain memakai runtime flip. + +Jaga ukuran kepala, torso, dan anggota tubuh. Pose rebah tidak boleh mengecil agar muat slot. Kurangi ukuran gambar SEMUA pose dalam prompt/generator untuk memberi ruang, lalu normalisasi anatomi terhadap idle. Jika pose saling menempel, utamakan regenerasi row dengan gap yang jelas. Tidak ada efek/blood/impact star yang dibakar ke sprite. + +Demo saat ini memakai satu prop dummy, bukan petarung musuh beranimasi. Hurt dummy ditunjukkan lewat state, knockback dan VFX; tidak memakai row hurt ARCO. Jangan mengklaim musuh AI atau animasi bangkit petarung baru sudah tersedia hanya karena asetnya ada. + +## Feedback saat hit valid + +| Bagian | Perilaku saat ini | +| --- | --- | +| Hit-stop | Melee/slam boleh: ringan0,045 s, kategori berat saat damage≥30 memakai0,085 s | +| Proyektil / drone | Tidak memakai global hit-stop agar pemilik tetap dapat bergerak | +| Warna target | Tidak diberi brightness putih, filter merah, atau blink alpha | +| Pose idle/hurt dummy | Tidak diberi rotasi kecil berulang; hindari pixel shimmer/glitch | +| Percikan / angka / suara | Di titik benturan atau emitter terukur, satu event per hit | +| Knockback | Mengikuti arah benturan; parameter sumber serangan ada di BALANCE | +| Shake | Seluruh stage, singkat dan terkendali; tepi kosong ditutup overscan. Trauma selalu meluruh ke nol, termasuk saat fase intro/K.O. Akhir ronde (K.O., double K.O., time up) mengenolkan trauma sehingga layar K.O. diam, tidak bergetar | + +O saat ini damage 24 sehingga tidak otomatis masuk kategori hit-stop≥30; jangan menganggap nama “heavy” sama dengan cabang threshold kode. Sumber kebenaran adalah parameter dan kondisi engine saat ini. + +## Pemain + +- Hurt mengunci input sekitar 0,42 s, lalu immunity 0,9 s; immunity tidak berarti sprite berkedip. +- Skill memiliki perlindungan hanya selama komitmen cast yang masih aktif. Setelah pose summon0,5 s selesai, pemain dapat bergerak **dan dapat terkena damage**; drone tetap melanjutkan tugas. +- Projectile I melepas karakter ketika peluru keluar. Jangan memakai durasi visual proyektil untuk memperpanjang input lock. +- Kedua lapis HP total 200 harus habis sebelum pemain down/core restart. Latihan memulihkan pemain setelah sekitar 1,7 s; ini belum sistem ronde kompetitif. +- Pada demo, receiveHit diuji lewat hook diagnostik; dummy normal belum menyerang pemain. + +## Target latihan + +HP target juga200 dalam satu bar berlapis. Hit biasa tetap mengurangi HP, tanpa refill setelah hurt. Ultimate tidak memaksa jatuh/KO. + +Saat HP benar-benar nol: state down, recoil/jatuh, kemudian recover setelah 1,7 s. Recovery sekitar 0,4 s, setelah itu target latihan mengisi HP kembali dan kebal0,5 s. Target down/recover/kebal tidak menerima hit baru dan tidak memberi bonus recharge basic. + +Jika kelak ada mekanik knockdown nonfatal, jangan mengisi HP hanya karena target bangkit. KO dan knockdown adalah kondisi berbeda. + +## Checklist integrasi karakter lain + +- [ ] Hurt/down berasal dari identitas yang sama dan skalanya diperiksa terhadap idle. +- [ ] Satu ayunan/peluru/laser tidak memberi damage ulang setiap frame. +- [ ] Hit yang ditolak tidak memberi bonus cooldown. +- [ ] Tidak ada brightness flash, shimmer idle, atau alpha blink yang sebelumnya ditolak pengguna. +- [ ] Owner summon bebas setelah cast dan tidak dibekukan oleh hit laser. +- [ ] Target baru KO setelah kedua lapis HP habis; damage biasa tidak direset. +- [ ] Pause/reset menghentikan atau membersihkan seluruh state yang relevan. +- [ ] Jika menambahkan AI lawan, telegraph/range/recovery dan animasinya dibuat serta diuji tersendiri. + diff --git a/guide/isolde-reference.md b/guide/isolde-reference.md new file mode 100644 index 0000000..8fd1219 --- /dev/null +++ b/guide/isolde-reference.md @@ -0,0 +1,66 @@ +# ISOLDE — ksatria kaki bangau + +ISOLDE adalah petarung Mecha kelima dan karakter kedelapan di roster: ksatria muda dengan prostetik kaki bangau perak dan tombak aether ramping. Ia berasal dari 8 karakter showcase dan dibuat playable setelah ZANNI. Pemain dapat memilihnya di roster VS/Training dan di Pengaturan → Karakter pemain; CPU memakai kit yang sama. Kartu identitas: assets/isolde/character.md. + +## Dari showcase ke playable + +Alurnya sama dengan HALDOR dan ZANNI ([zanni-reference.md](zanni-reference.md)). Body showcase chibi v2 langsung menjadi base sprite (base-selected.png, job `00d58d0c-5c3a-41d7-81bd-f463ae5bca82`), dan avatar showcase dipakai sebagai portrait HUD. Di roster.json ISOLDE ditandai `promoted`, jadi showcase kini berisi 4 karakter. Semua gambar baru dibuat dengan **GPT Image 2.5 Sunburst**, chroma hijau. Announcer tetap Grady. Alat pipeline: `tools/isolde_*.py`. + +## Paket siap digunakan ulang + +| Paket | Isi | +| --- | --- | +| Sprite | 15 state / 54 frame, atlas 1792×4320, cell 448×288, anchor 224/280, idle 199 px (kaki sampai ujung tombak tegak), lebar 129–133 px | +| UI | portrait 384 (avatar showcase), cut-in 1600×686, 4 ikon 256, roster art dari showcase | +| VFX | 4 PNG alpha 256: piercer (baut es), skyfall (tombak es raksasa), shatter (pecahan es di lantai), frost (jejak serbuan) | +| Audio | `select_isolde` "Isolde!" (0.679 s), `isolde_wins` "Isolde wins!" (1.489 s), Grady. Voice ultimate Vesper "Skyfall Lances!" (1.907 s, sunyi di awal dipotong) | + +Ukuran 200 px dipilih dari lineup 200/212/224/236 (size-candidates.png). Pada ukuran itu wajah setara NAJA/ZANNI; tombak tegak membuatnya tokoh tertinggi di roster, sesuai desain "tallest, thinnest". Wrapper `ISOLDE_MANIFEST/METRICS` ada di [assets/isolde/manifest.js](../assets/isolde/manifest.js). Sprite library memutar semua animasi. + +## Kemampuan dan balance + +| Input | Gerakan | Damage / cooldown | +| --- | --- | --- | +| Space×3 | LANCE LINE: tusukan lurus, sapuan naik, tusukan lunge. Reach 140/115/165 di dalam ujung tombak terukur (157/131/184 px); finisher terjauh di roster. Knockback 80/105/210 | 6/8/12 | +| I | SKY PIERCER: baut es dari tombak yang terangkat, lepas setinggi dada (105 px) dan naik 0.25 px per piksel maju (900 px/s). Mengenai lawan di darat sampai ±240 px, di atasnya lewat; lawan yang melompat terkena jauh lebih jauh. Anti-air | 16 / 3 s | +| O | STILT CHARGE: serbuan tombak di atas kaki bangau, dash 560 px/s (maju ±160 px), reach 150, knockback 300 | 24 / 6 s | +| P | SKYFALL LANCES: pose cast 0.5 s, tiga tombak es raksasa menukik dari langit | 48 / 18 s | + +Setiap basic hit yang valid mengembalikan 5% cooldown maksimum semua skill. HP tetap 200 dalam dua lapis. Semua nilai ada di [isolde.js](../isolde.js). + +### Timeline Skyfall Lances + +| Bagian | Waktu dari cast | +| --- | --- | +| Cut-in banner baja/biru es | 0–0.78 s | +| Pose cast pemain | 0–0.50 s, lalu bebas bergerak/menyerang | +| Panggilan 1 / 2 / 3 | 0.80 / 1.18 / 1.56 s: tiap tombak muncul 520 px di atas dan 260 px di belakang kaki lawan saat itu | +| Tombak | 0.55 s menukik diagonal ke titik itu; kena saat melintas, atau pecahan es 70 px saat mendarat; 16 damage, satu hit per tombak | +| Selesai | setelah 2.4 s dan semua tombak mendarat | + +Jarak antar panggilan 0.38 s, lebih pendek dari stun 0.42 s, sehingga lawan yang diam terkena 48 di mana pun ia berdiri, termasuk di belakang ISOLDE. Lompatan tidak menolong karena tombak datang dari atas; lawan yang terus berlari membuat tombak jatuh di belakangnya. Tidak ada global hit-stop; knockback 50/50/190. Pause membekukan serangan ini, sedangkan reset, kembali ke menu dan akhir ronde membatalkannya. Serangan pemain (`skyfall`) dan CPU (`enemySkyfall`) memiliki owner terpisah. + +## CPU + +- ISOLDE sebagai CPU memakai otak yang sama ([cpu-ai.md](cpu-ai.md)) dan kit-nya lewat `KITS`. `range` 240 membuat CPU hanya memakai Sky Piercer bila lawan cukup dekat. +- Benchmark (ISOLDE sebagai CPU): pemain diam K.O. dalam 21.3 / 15.5 / 19.4 / 18.5 s (Easy→Excellent). Medium lebih cepat dari Hard dan Excellent pada pemain diam; penyebabnya belum dianalisis. Proyektil yang mengenai CPU 54 / 45 / 14 / 42%. + +## Voice + +"Skyfall Lances!" digenerate dengan empat preset wanita: Elena, Vesper, Imogen dan Sloane (Soraya, Luna dan Anika sudah dipakai). Vesper dipilih karena rentang ucapannya paling pendek (±1.77 s) dengan frame bersuara terbanyak, yaitu penyampaian jernih dan tenang untuk ksatria. Jeda 0.18 s di awal dipotong. Take Imogen 4.3 s, terlalu panjang. Angka pitch hanya panduan; timbre belum didengar pengguna. Cara mengganti: salin kandidat lain dari `assets/isolde/audio/ultimate-candidates/` ke `isolde-ultimate.mp3`, lalu ganti `VOICE_NAMES.isolde` di game.js. Record: generation.json. + +## Integrasi engine + +- `KITS` memetakan `isolde` ke kontrak kit yang sama. ISOLDE memiliki `isoldeStrike` (tusukan, baut naik, serbuan) dan summon `startSkyfall`/`updateSkyfall` (dipilih lewat `startSummon`). Stilt Charge memakai jalur dash yang sama dengan FENR/MIRA/HALDOR. +- Proyektil kini mendukung `landY`: tombak yang mencapai lantai memanggil `lanceShatter` (pecahan es 70 px). Proyektil lama tidak berubah. +- Snapshot QA berisi `skyfall`/`enemySkyfall`. Cut-in memakai kelas `.isolde-cutin`, art ui/cutin.png, dan judul SKYFALL LANCES dari HTML. + +## Reproduksi + +1. Prompt/job: desain di roster.json; strip di strip-requests.json (15 × v1); UI di ui-requests.json. +2. `tools/isolde_pipeline.py measure`, `tools/isolde_measure.py`, lalu tulis `rel.json` (alasan di `qa/rel-decision.json`). +3. `tools/isolde_pipeline.py calibrate`, `sprite_gen.cli prepare --out-dir assets/isolde/run --character-id isolde --base-image assets/isolde/base/base-selected.png --request assets/isolde/request.json --chroma-key #00FF00`, lalu `normalize` dan `extract`. +4. `tools/isolde_align.py`, lalu `publish`. Hitung emitter (ujung tombak di frame 2) ke `emitters.json`, lalu jalankan `publish` sekali lagi. +5. `tools/isolde_assets.py` mengekspor portrait/ikon/cut-in/VFX, lalu `tools/web_images.py` dan `node tools/build_dist.mjs`. + +Catatan QA: assets/isolde/run/qa-notes.md. Tes: `node tests/isolde.test.cjs` (13 tes). diff --git a/guide/main-menu.md b/guide/main-menu.md new file mode 100644 index 0000000..5f218f1 --- /dev/null +++ b/guide/main-menu.md @@ -0,0 +1,72 @@ +# Main menu, character select dan VS Computer + +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. + +Background Home memakai [video loop Seedance2.5 1080p](menu-video.md),7.5s,tanpa audio. Artwork statis menjadi poster/fallback; menu dan teks tetap elemen HTML. Pengaturan menyediakan checkbox Animasi latar menu. + +## Alur + +- **VS Computer**: pilih pemain → pilih lawan → pilih arena dan kesulitan → ROUND1 → FIGHT → pertandingan. +- **Training**: pilih pemain → partner latihan → arena. Waktu tidak terbatas, AI pasif secara default, HP kembali setelah KO. Pengaturan dapat mengaktifkan AI atau mengganti pemain selama Training. +- **Pengaturan**: audio, volume, tes suara, fullscreen, hitbox, panduan kontrol. Pilihan karakter/AI latihan disembunyikan di main menu dan VS agar pertandingan tidak berubah di tengah ronde. +- **About**: pengantar dunia/karakter dan kredit visual, suara, serta font. +- Esc menjeda pertarungan; menu pause menyediakan lanjut, mulai ulang, dan kembali ke main menu. Di menu seleksi, Esc kembali satu tahap. + +Pilih karakter dengan klik atau WASD/panah lalu Enter. Slot pemain dikunci terlebih dahulu; baru lawan dipilih. Saat karakter (pemain maupun CPU) dikonfirmasi, gambar panel dan art besarnya menjadi lebih cerah 3 kali (0,3 s tiap kedipan, tanpa glow) dengan bunyi lock-in dan dua ketukan pelan, lalu baru pindah ke langkah berikutnya; input diabaikan selama 0,9 s itu. Mirror match (ARCO vs ARCO atau FENR vs FENR) diperbolehkan. Ada **12 panel**, semuanya karakter playable: ARCO, FENR, MIRA, CORA, NAJA, HALDOR, ZANNI, ISOLDE, RHEA, SOLAN, NIB dan EDDA. Slot terkunci tetap bisa disorot tetapi tidak dapat dikonfirmasi; baik UI maupun engine menolak ID karakter yang tidak tersedia. + +Sound navigasi, konfirmasi, kembali, dan locked menggunakan nada arcade Web Audio yang berbeda. Sound mengikuti master volume/mute dan baru diaktifkan setelah interaksi pengguna. Hover/fokus hanya berbunyi saat panel berubah, dengan pembatas75ms agar tidak beruntun terlalu rapat. Ini SFX, bukan voice announcer. + +## Pertandingan: pertama menang dua ronde + +Pengguna menegaskan **menang2ronde dari maksimal3**. Setiap ronde90detik, HP200perpetarung (dua lapis100pada satu bar). Damage/cooldown karakter tetap sama di semua kesulitan. + +Urutan fase: intro ROUNDn → FIGHT → fight → KO/result ronde → ronde berikutnya atau hasil pertandingan. Durasi intro dan hasil menyesuaikan durasi klip Grady di manifest agar pengumuman tidak terpotong; batas minimum lama 1.85s/2.2s tetap berlaku. Lihat [timing announcer](announcer-system.md). Input serangan dan AI diblokir saat intro/KO/menu. Pause membekukan seluruh timer. + +- Skor2–0 selesai di ronde2;1–1 masuk ronde3; kemenangan kedua mengakhiri pertandingan. +- Timeout: HP lebih tinggi menang. HP sama atau doubleKO mengulang nomor ronde yang sama tanpa menambah skor; tidak membuat ROUND4. +- Ronde berikutnya membersihkan proyektil, drone, transformasi, voice, cooldown, posisi dan HP. Skor pertandingan tetap. +- Kemenangan/kekalahan menampilkan petarung pemenang, skor, Rematch, Character Select, dan Main Menu. +- R memulai ulang pertandingan dari ronde1 dan skor0–0; di Training tetap reset latihan. + +`__game.onRoundCue(callback)` tetap mengeluarkan event round/fight untuk ronde1/2/3. **Grady sudah dikunci dan dipasang** untuk nama karakter saat konfirmasi, ROUND1/2/3, FIGHT, KO dan hasil. Enam belas cue terpisah tersedia; [acuan announcer](announcer-system.md) menjelaskan antrean, prioritas dan sinkronisasi. Voice ultimate Dylan/Holden/Luna/Anika tetap bekerja di kanal karakter. + +## CPU + +Keenam petarung bisa dipilih sebagai CPU. HALDOR memakai Forge Chain, Slag Shot (lob), Steam Ram dan Forge Quake (gelombang lava tiga kali ke dua arah); CPU lawan keluar dari titik jatuh lob bila membacanya tepat waktu. NAJA memakai Viper Lash, Sand Fang, Urumi Cyclone dan Dune Serpent (riak pasir yang memburu, tiga semburan kobra) dengan serangan per owner; CPU lawan keluar dari riak yang terkunci bila membacanya tepat waktu. CORA memakai Feather Waltz, Quill Volley, Wing Gust dan Night Murmuration (tiga gelombang gagak) dengan kawanan per owner. MIRA memakai Mitten Chain, Star Popper, Candy Crash dan Rocket Parade (12 roket) dengan formasi roket per owner. ARCO mempunyai tiga basic, Aether Bolt, Seismic Drive, dan empat drone Helios Squadron. FENR memakai kit manusia/werewolf serta Holden. Formasi drone pemain dan CPU memiliki owner terpisah sehingga mirror match tidak mencampur target atau efek. Voice tetap satu speaker aktif, dengan prioritas pemain. + +| Level | Kecepatan | Reaksi | Recovery | Rantai | Ultimate setelah | Hindar / Punish | Kebal CPU / batas combo | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| Easy | 0.95 | 0.34 s | 0.60 s | 2 | 10 s | 25% / 30% | 0.45 s / 6 hit | +| Medium | 1.10 | 0.20 s | 0.38 s | 3 | 7 s | 55% / 60% | 0.9 s / 5 hit | +| Hard | 1.22 | 0.13 s | 0.22 s | 3 | 5 s | 78% / 82% | 0.9 s / 4 hit | +| Excellent | 1.32 | 0.08 s | 0.12 s | 3 | 3.5 s | 92% / 95% | 0.9 s / 4 hit | + +CPU kini membaca pemain: melompati proyektil, mundur atau melompat dari serangan, punish recovery, anti-air, tidak memukul saat pemain kebal, dan menyambung rantai hanya bila hit pertama kena. Di Versus, CPU memakai aturan kebal setelah stun seperti pemain, plus batas combo per stun. Tidak ada bonus HP atau multiplier damage. Penjelasan lengkap dan hasil benchmark ada di [cpu-ai.md](cpu-ai.md). + +## Arena dan aset menu + +| Arena | File | GroundY | +| --- | --- | ---: | +| Bellora Courtyard | assets/stage.png |599| +| Sunspire Terrace | assets/menu/sunspire.png |599| +| Azure Harbor | assets/menu/azure-harbor.png |599| +| Elderwood Glade (hutan, siang) | assets/menu/elderwood.png | 606 | +| Moonrise Bastion (malam, bulan purnama) | assets/menu/moonrise.png | 618 | + +Semua arena berukuran 1280×720 dengan kamera diam. Tiga arena pertama cerah siang hari; Elderwood adalah hutan bercahaya matahari, dan Moonrise adalah benteng malam di bawah bulan purnama dengan lantai tetap terang agar petarung terbaca. Elderwood/Moonrise punya log sendiri (2 varian per arena, groundY diukur dari pita lantai). Dua arena baru dan home key art digenerate melalui Higgsfield Seedream5.0Pro,2k. Log mencatat prompt, referensi, job dan checksum. Sumber immutable ada di `assets/menu/originals/`. + +Full-body selection art memakai base ARCO B/FENR C yang telah disetujui, diproses canonical sprite-gen cutout melalui menu_assets.py; identitas tidak digenerate ulang. Avatar lock adalah [SVG](../assets/menu/locked.svg) siluet generik, bukan konsep karakter baru. + +## Menambah karakter/arena berikutnya + +Tambahkan definisi karakter dan portrait/art di `menu.js`, ganti salah satu slot locked pada roster, lalu daftarkan manifest, atlas, kit, voice dan AI di engine. Jangan membuka panel tanpa aset dan kemampuan yang siap. Arena ditambahkan di `MatchRules.stages` serta loader gambar engine; ukur groundY sebelum digunakan. UI tidak memuat frame intermediate sprite. + +## QA + +`node tests/match.test.cjs`:16tes aturan/engine baru, mencakup menu freeze, pemilihan terpisah, ID terkunci, input intro,2–0/1–1/round3, timeout/draw/doubleKO, reset ronde, training, NPC ARCO, dua formasi drone bersamaan, difficulty, rematch dan hook announcer. Bersama tes karakter/audio dan 13 tes announcer,103 tes lulus. + +Browser diperiksa pada viewport desktop default: home, roster12slot, navigasi keyboard, penolakan lock, pemainFENR→lawanARCO, AzureHarbor/Excellent, pertarungan CPU ARCO, Training, Pengaturan, About, dan layar hasil. SFX diverifikasi melalui AudioContext berjalan dan sumber audio dibuat; tidak ada klaim audisi speaker OS. + +qa-match.html memberikan fixture visual deterministik untuk `scene=round1`, `fight`, `round2`, `round3`, `victory`, `defeat`. Ronde3/hasil2–1 pada screenshot berasal dari fixture ini; aturan menang/kalah juga diuji independen di unit test. Screenshot tersimpan di `qa/menu/`. Tata letak responsif tersedia di CSS; bukti browser yang dicatat adalah viewport desktop yang benar-benar diperiksa. diff --git a/guide/menu-video.md b/guide/menu-video.md new file mode 100644 index 0000000..852db46 --- /dev/null +++ b/guide/menu-video.md @@ -0,0 +1,31 @@ +# Background video main menu + +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 + +- Job video: `fc8bbce3-416c-494a-af1e-d7e4dde7ec05`. +- Model `seedance_2_5`, mode `omni_reference`, resolution `1080p`, ratio16:9, requested duration8s, `generate_audio:false`, bitrate high. +- Original: `assets/menu/originals/home-factions-seedance-1080p.mp4`, HEVC1920×1080/24fps,8.041667s. +- Runtime: [home-factions-loop.mp4](../assets/menu/home-factions-loop.mp4), H.264/yuv420p,1920×1080/24fps,180frame,**7.5s**, tanpa audio, sekitar1.42MB. +- video-generation.json menyimpan request, job, durasi, hasil probe/decode, metode loop, dan SHA256. + +build_menu_loop.ps1 membuat export kompatibel browser, fast-start MP4, serta overlap0.5s antara akhir dan awal. Urutan gerak tetap maju, bukan ping-pong/reverse. Sumber generator tetap disimpan. Ukuran gambar tetap native1080p; tidak di-upscale dari720p. + +## Runtime + +- Video dekoratif `autoplay muted loop playsinline`, tidak menangkap klik/fokus. Tombol dan teks tetap HTML di atas gradient gelap yang sama. +- Poster tampil selama video belum siap atau tidak dapat mulai. Tidak ada layar kosong ketika media loading. +- Satu video element digunakan ulang saat kembali ke Home. Berpindah ke karakter/arena/About/result menjeda dan melepaskannya dari DOM; pertempuran tidak memutar video di belakangnya. +- Video dijeda saat Pengaturan terbuka atau tab tersembunyi, lalu dilanjutkan jika Home aktif. +- Checkbox **Animasi latar menu** di Pengaturan dapat menghentikan animasi. Preferensi OS reduced-motion menonaktifkan video secara default; pengguna dapat mengubah checkbox pada sesi berjalan. +- Suara video tidak ada, sehingga SFX menu dan voice petarung tetap terpisah. About/arena pertarungan tidak diganti video. +- Server lokal memberi MIME video/mp4 dan HTTP byte ranges untuk seek/loop. HEAD dan suffix range juga didukung. + +## Verifikasi + +Decode ffmpeg berhasil, file final1920×1080,H.264,24fps,7.5s tanpa audio. Contact sheet0–7s diperiksa untuk komposisi stabil dan identitas faksi. Nilai perbedaan frame dicatat di metadata sebagai diagnostik; tidak berarti sambungan generatif identik pixel demi pixel. + +Browser nyata: videoWidth1920,videoHeight1080,readyState4,duration7.5,muted=true,loop=true,paused=false,error=null. Pause settings mempertahankan currentTime sekitar7.317s dan resume berjalan kembali. HTTPGET range0–1023 mengembalikan206/video/mp4 dan1024byte; HEAD200, suffix128byte206, range di luar file416. Semua90tes gameplay/voice/match tetap lolos. + +Checkbox animasi diuji melalui UI: unchecked membuat paused=true dan waktu berhenti sekitar4.638s; diaktifkan kembali melanjutkan playback. Saat pindah ke character select, jumlah video di DOM menjadi0. Bukti tampilan main menu: `qa/menu-video/main-menu-loop.png`. diff --git a/guide/mira-cora-voice.md b/guide/mira-cora-voice.md new file mode 100644 index 0000000..c58b807 --- /dev/null +++ b/guide/mira-cora-voice.md @@ -0,0 +1,37 @@ +# Voice ultimate MIRA dan CORA — Luna dan Anika + +Status: pengguna memilih **Luna** untuk MIRA dan **Anika** untuk CORA dari katalog preset Higgsfield, lalu meminta kalimat pendek 1–2 kata. Keduanya sudah dipasang pada ultimate pemain maupun CPU. Voice NAJA (Soraya, “Dune Serpent!”) dan HALDOR (Gideon, “Forge Quake!”) memakai kanal dan aturan yang sama; detailnya di [naja-reference.md](naja-reference.md#voice) dan [haldor-reference.md](haldor-reference.md#voice). + +| Karakter | Kalimat runtime | File | Durasi | +| --- | --- | --- | --- | +| MIRA | Rocket Parade! | [mira-ultimate-luna.mp3](../assets/mira/audio/mira-ultimate-luna.mp3) | 1.44 s container, 1.40 s di browser | +| CORA | Fly, my ravens! | [cora-ultimate-anika.mp3](../assets/cora/audio/cora-ultimate-anika.mp3) | 1.78 s container, 1.75 s di browser | + +Provider: Higgsfield. Model `text2speech_v2`, varian `elevenlabs`, `voice_type: preset`. Ini audio hasil generasi baru, bukan sample preview katalog. + +| Voice | ID preset | Take asli (arsip) | +| --- | --- | --- | +| Luna | 375a3398-e3b4-4f91-845d-42181e352899 | “Rocket Parade! Fire everything!” 3.24 s — luna.mp3 | +| Anika | 4b2dc8f3-5e8b-59a9-9a5c-85620e44c033 | “Fly, my ravens! Night Murmuration!” 3.63 s — anika.mp3; variasi nama di depan 3.87 s — anika-name-first.mp3 | + +Generation record (MIRA, CORA) menyimpan prompt, parameter, job ID, URL hasil, durasi, format, SHA256, hasil decode ffmpeg, dan cara file runtime diturunkan. + +## Pemilihan dan pemotongan + +- Katalog hanya memberi nama dan gender. Sebagai bantu, 70 preview suara wanita diukur: pitch median, rentang pitch, kecerahan dan tempo. Luna termasuk yang tertinggi (median ±281 Hz), cocok untuk pilot anak. Anika berada di tengah-rendah (±186 Hz) dengan rentang sempit, sehingga terdengar tenang. Angka ini bukan penilaian akting; pilihan akhir dari pengguna. +- Take pertama 3.2–3.9 s, lebih lama dari salvo roket (2.3 s) dan gelombang gagak (2.4 s). Pengguna meminta 1–2 kata dengan contoh “Rocket Parade!” dan “Fly, my ravens!”. +- Kedua frasa itu sudah ada di awal take. File runtime adalah frasa pertama tersebut, dipotong di tengah jeda antarkalimat (−50 dB: Luna 1.27–1.60 s, Anika 1.62–2.04 s), fade-out 80 ms, lalu encode ulang MP3 128 kbps mono. Tidak ada generate ulang, jadi intonasi sama dengan take yang sudah didengar. + +## Perilaku runtime + +- Luna dan Anika dipreload bersama Dylan dan Holden pada satu saluran suara karakter. Tidak ada autoplay sebelum interaksi pengguna. +- Voice dimulai saat summon dibuat (Rocket Parade / Night Murmuration), untuk pemain maupun CPU. Tombol P yang ditolak cooldown tidak mengulang voice. +- Ucapan pemain menggantikan ucapan CPU; CPU tidak memotong pemain. Announcer Grady tetap berprioritas di atas voice karakter. +- Kalimat tidak terikat umur summon. Jam voice sendiri hanya berjalan saat pertandingan berjalan. Kalimat berhenti saat file selesai, caster K.O., reset, kembali ke menu, atau akhir ronde. Kalimat pendek sekarang selesai sebelum roket/gagak habis; kalimat yang lebih panjang pun tidak akan terpotong ketika summon selesai. +- Pause, Pengaturan dan halaman tersembunyi menjeda lalu melanjutkan posisi yang sama. Volume/mute dan penurunan SFX menjadi 38% sama seperti Dylan/Holden. + +## Verifikasi + +- Kedua file runtime lolos decode ffmpeg; checksum ada di generation record. +- `node tests/summon-voice.test.cjs`: 7 tes untuk preload tanpa autoplay, satu kali per cast, kalimat selesai saat salvo masih terbang, kalimat panjang (simulasi 3 s) tetap utuh setelah summon selesai, reset/restart, pause/resume, K.O. caster, serta CPU CORA yang digantikan ultimate MIRA pemain. Sembilan suite proyek: 142 tes lulus. +- Browser localhost dengan klik dan tombol P nyata: Luna (1.40 s) dan Anika (1.75 s) aktif dengan waktu playback berjalan, tanpa error, dan selesai sebelum summon berakhir. Ini bukti pemutaran di browser, bukan penilaian timbre atau volume speaker. diff --git a/guide/mira-reference.md b/guide/mira-reference.md new file mode 100644 index 0000000..5166ecb --- /dev/null +++ b/guide/mira-reference.md @@ -0,0 +1,54 @@ +# MIRA — pilot anak dan robot lilac + +MIRA adalah petarung Mecha ketiga: anak perempuan sekitar 9 tahun yang duduk di kokpit terbuka robot telur lilac. Pemain dapat memilihnya di roster VS/Training dan Pengaturan → Karakter pemain; CPU memakai kit yang sama. Kartu identitas: assets/mira/character.md. + +## Desain yang dipilih + +Empat konsep base dibuat tanpa gambar roster lama (A Sunflower Brawler, B Lilac Candy Walker, C Scarlet Organ Knight, D Tangerine Twin-Stack). Pengguna memilih **B**: rambut twin tail pink dengan jepit bintang, headset berantena, jas hujan kuning lemon, robot telur lilac bersendi grafit dengan dua mata visor kuning, kepalan mitten dan dua pod roket drum. Base terpilih, job `0b3326ad-4c9b-4ff2-9a5e-ccce6ac2cebf`. Chroma hijau #00FF00 karena karakter memakai pink/ungu. + +Keputusan model: pengguna meminta MIRA dibuat dengan **GPT Image 2.5 Sunburst**, termasuk portrait, ikon, cut-in dan VFX (acuan umum proyek memakai Seedream untuk UI art). Announcer tetap Grady. + +## Paket siap digunakan ulang + +| Paket | Isi | +| --- | --- | +| Sprite | 15 state / 54 frame, atlas 1792×4320, cell 448×288, anchor 224/280, idle 182 px termasuk antena | +| State | idle, walk, run, crouch, attack1–3, skill1–2, ultimate, hurt, down, recover masing-masing 4; jump/tuck masing-masing 1 | +| UI | portrait 384, cut-in 1600 lebar, 4 ikon 256, roster art | +| VFX | 4 PNG alpha: star, rocket, burst, crash | +| Audio | `select_mira` “Mira!”, `mira_wins` “Mira wins!” (Grady). Voice ultimate Luna “Rocket Parade!” — [mira-cora-voice.md](mira-cora-voice.md) | + +Ukuran: versi awal 214 px membuat pilot terlihat seukuran orang dewasa di samping ARCO 180 dan FENR 194. Pengguna memilih **182 px** dari lineup 182/170/160; perubahan dilakukan dengan calibration target baru dari original yang sama, tanpa generate ulang. Wrapper `MIRA_MANIFEST/METRICS` ada di [assets/mira/manifest.js](../assets/mira/manifest.js). Sprite library memutar semua animasi 1×/0.5× dan kanan/kiri. + +## Kemampuan dan balance + +| Input | Gerakan | Damage / cooldown | +| --- | --- | --- | +| Space×3 | MITTEN CHAIN: jab, hook, hammer dua kepalan (reach 100/85/82) | 6/8/12 | +| I | STAR POPPER: bintang dari telapak, 760 px/s; kontrol lepas saat bintang keluar | 16 / 3 s | +| O | CANDY CRASH: tackle dengan dash 240 px/s pada 20–60% aksi 0.68 s, reach 128 | 24 / 6 s | +| P | ROCKET PARADE: pose cast 0.5 s, 12 roket × 4 | 48 / 18 s | + +Tiap basic hit valid mengembalikan 5% cooldown maksimum semua skill (I 0.15 s, O 0.30 s, P 0.90 s). HP tetap 200 dalam dua lapis. Semua nilai di [mira.js](../mira.js). + +### Timeline Rocket Parade + +| Bagian | Waktu dari cast | +| --- | --- | +| Cut-in banner lilac | 0–0.78 s | +| Pose cast pemain | 0–0.50 s, lalu bebas bergerak/menyerang | +| Peluncuran | 0.40 s + 0.05 s × n dari pod belakang/depan bergantian (posisi robot saat itu) | +| Tumbukan | 1.40 s + 0.032 s × n; semua 12 mendarat dalam satu jendela hurt | +| Selesai | 2.30 s | + +Target dikunci saat cast: roket hanya melukai bila target berada di depan. Roket mengikuti target sampai 80% lintasan lalu terkunci. Setiap roket memberi satu hit, tanpa global hit-stop; guncangan dikurangi (0.3×) agar 12 hit tidak menjadi getaran panjang. Pause membekukan, reset/kembali menu/akhir ronde membatalkan. Formasi pemain dan CPU memiliki owner terpisah. + +## Reproduksi + +1. Prompt/job strip: strip-requests.json; UI: ui-requests.json; base: base-requests.json. +2. `tools/mira_pipeline.py measure|calibrate|normalize|extract|publish` dengan Python venv sprite-gen. `normalize` juga menerapkan cleanup.json. +3. `tools/mira_measure.py` untuk edge-NCC kepala pilot + mata visor; hasil di `assets/mira/qa/scale-measure.json` dan `scale-check.png`. Nilai terpilih di rel.json, alasannya di `qa/rel-decision.json`. +4. `tools/mira_align.py` menulis curation integer; `publish` menghasilkan atlas, metrics, playback, contact sheet dan wrapper JS. +5. `tools/mira_assets.py` mengekspor portrait/ikon/cut-in/VFX dan roster art. + +Catatan QA dan batas bukti: assets/mira/run/qa-notes.md. Tes: `node tests/mira.test.cjs` (14 tes). diff --git a/guide/naja-reference.md b/guide/naja-reference.md new file mode 100644 index 0000000..9af9dbc --- /dev/null +++ b/guide/naja-reference.md @@ -0,0 +1,71 @@ +# NAJA — kobra gurun demi-human + +NAJA adalah petarung Demi-Human ketiga dan karakter kelima di roster: wanita kobra chibi dengan tudung pasir, ekor ular dan urumi (pedang-cambuk perak). Ia berasal dari 8 karakter showcase dan dibuat playable atas permintaan pengguna. Pemain dapat memilihnya di roster VS/Training dan di Pengaturan → Karakter pemain; CPU memakai kit yang sama. Kartu identitas: assets/naja/character.md. + +## Dari showcase ke playable + +Desain NAJA sudah disetujui sebagai karakter showcase chibi v2. Body showcase itu langsung dipakai sebagai base sprite (base-selected.png, job `6b2c99a8-1b5b-474a-aaed-799182c284c4`), sehingga art di roster sama dengan karakter yang bertarung. Avatar showcase dipakai sebagai portrait HUD tanpa generate ulang. Di roster.json NAJA ditandai `promoted`. `tools/showcase_assets.py` tetap mengekspor art/avatar-nya tetapi tidak lagi memasukkannya ke `showcase.js`, jadi showcase kini berisi 7 karakter. + +Model: semua gambar baru (strip, cut-in, ikon, VFX) dibuat dengan **GPT Image 2.5 Sunburst**, sama seperti MIRA dan CORA. Announcer tetap Grady. + +## Paket siap digunakan ulang + +| Paket | Isi | +| --- | --- | +| Sprite | 15 state / 54 frame, atlas 1792×4320, cell 448×288, anchor 224/280, idle 182 px dari kaki sampai puncak tudung | +| State | idle, walk, run, crouch, attack1–3, skill1–2, ultimate, hurt, down, recover masing-masing 4; jump/tuck masing-masing 1 | +| UI | portrait 384 (avatar showcase), cut-in 1600×686, 4 ikon 256, roster art dari showcase | +| VFX | 4 PNG alpha: sandwave (proyektil lantai), cyclone (cincin urumi), ripple (riak pasir), serpent (kobra pasir 512 px) | +| Audio | `select_naja` “Naja!” (1.149 s), `naja_wins` “Naja wins!” (1.411 s), Grady. Voice ultimate Soraya “Dune Serpent!” (1.306 s) | + +Wrapper `NAJA_MANIFEST/METRICS` ada di [assets/naja/manifest.js](../assets/naja/manifest.js). Sprite library memutar semua animasi dengan kecepatan 1× atau 0.5×, menghadap kanan maupun kiri. Ukuran 182 px dapat diubah hanya dengan calibration target baru dari original yang sama, tanpa generate ulang. + +## Kemampuan dan balance + +| Input | Gerakan | Damage / cooldown | +| --- | --- | --- | +| Space×3 | VIPER LASH: lecutan, sapuan rendah, lecutan panjang. Reach 135/130/160 (terpanjang di roster; ujung urumi terukur 161/161/196 px). Rantai sedikit lebih lambat (0.38/0.44/0.56 s) sebagai kompensasi | 6/8/12 | +| I | SAND FANG: tepukan telapak mengirim gelombang pasir menyusur lantai, 620 px/s, umur 1.1 s. Kontrol lepas saat gelombang keluar. Lompatan kecil apa pun melewatinya | 16 / 3 s | +| O | URUMI CYCLONE: urumi berputar penuh, mengenai target di depan **atau** belakang sampai 150 px, knockback 260, tanpa dash | 24 / 6 s | +| P | DUNE SERPENT: pose cast 0.5 s, riak pasir memburu lawan, tiga semburan kobra × 16 | 48 / 18 s | + +Setiap basic hit yang valid mengembalikan 5% cooldown maksimum semua skill (I 0.15 s, O 0.30 s, P 0.90 s). HP tetap 200 dalam dua lapis. Semua nilai ada di [naja.js](../naja.js). + +### Timeline Dune Serpent + +| Bagian | Waktu dari cast | +| --- | --- | +| Cut-in banner teal/indigo/pasir | 0–0.78 s | +| Pose cast pemain | 0–0.50 s, lalu bebas bergerak/menyerang | +| Riak 1 keluar dari depan NAJA dan memburu | 0.10 s, 900 px/s (lebih cepat dari lari 520) | +| Riak terkunci / kobra menyembur | 0.83 → 1.25 s, 1.23 → 1.65 s, 1.63 → 2.05 s | +| Kolom kobra | radius hit 70 px, tinggi 240 px, terlihat 0.45 s | +| Selesai | 2.70 s | + +Setiap riak baru mulai memburu dari posisi riak sebelumnya saat riak itu terkunci. Riak yang terkunci berhenti, menyala terang dan bergetar; itulah tanda untuk bergerak. Kolom kobra (240 px) lebih tinggi dari puncak double jump (±226 px), jadi melompat tidak cukup. Cara menghindar adalah keluar dari riak (>70 px) dalam 0.42 s setelah terkunci. Tes mengunci contoh ini: berjalan keluar menghindari gigitan pertama (32 damage, bukan 48), sedangkan lompatan tetap terkena 48. Target yang lebih jauh dari ±800 px lolos dari gigitan pertama (riak 1 menempuh ±660 px sebelum terkunci). Tidak ada global hit-stop; guncangan per gigitan 0.6×; knockback 60/60/190. Pause membekukan serangan ini, sedangkan reset, kembali ke menu dan akhir ronde membatalkannya. Serangan pemain (`serpent`) dan CPU (`enemySerpent`) memiliki owner terpisah. + +## CPU + +- NAJA sebagai CPU memakai otak yang sama ([cpu-ai.md](cpu-ai.md)) dan kit-nya lewat `KITS`. Jangkauan cambuk membuatnya membuka rantai dari jarak ±145 px. +- Aturan baru untuk semua lawan CPU: saat riak Dune Serpent pemain terkunci di bawahnya, CPU keluar lewat sisi terdekat (menjauhi dinding) sampai kobra menyembur. Aturan ini dibaca setelah jeda `reaction` dan diputuskan sekali per gigitan dengan peluang `evade`. Excellent (reaksi 0.08 s) biasanya lolos; Easy (0.34 s) terlambat. Tes: minimal 4/6 untuk Excellent, 0/6 untuk Easy. +- Benchmark `node tools/cpu_bench.cjs ` (NAJA sebagai CPU): pemain diam K.O. dalam 23.4 / 19.9 / 16.0 / 15.7 s (Easy→Excellent), sejajar MIRA/CORA. Proyektil yang mengenai CPU 54 / 42 / 41 / 43%. + +## Voice + +**Soraya** (preset Higgsfield ElevenLabs `5c1d2f7f-cdb4-5b1d-bca9-156439e3275e`) dipilih sebagai rekomendasi: suara wanita rendah, gelap dan tenang yang cocok untuk penari kobra. Kalimatnya hanya nama jurus, “Dune Serpent!”, mengikuti permintaan pengguna agar voice ultimate cukup 1–2 kata. Take pertama berdurasi 1.306 s tanpa jeda di tengah, jadi dipakai apa adanya (salinan byte-identik, tanpa potong). Voice ini memakai kanal yang sama dengan Luna/Anika: dimulai saat serangan dibuat, selesai sendiri saat kobra masih menyerang, dan hanya diputar setelah ada interaksi pengguna. Record: generation.json. Untuk mengganti voice, generate ulang satu kalimat ini dengan preset lain, lalu ganti `NAJA_VOICE_PATH` dan `VOICE_NAMES.naja` di game.js. + +## Integrasi engine + +- `KITS` di game.js kini memetakan `mira`, `cora` dan `naja` ke kontrak kit yang sama (`start`, `move`, `refund`). NAJA memiliki fungsi strike sendiri (`najaStrike`: gelombang lantai, kilat urumi, cyclone dua arah) dan summon sendiri (`startSerpent`/`updateSerpent`/`drawSerpent`, dipilih lewat `startSummon`). +- Efek baru `whip` menggambar kilat urumi dari tangan ke ujung cambuk yang terukur. Efek `naja-fx` memakai sprite cyclone. +- Snapshot QA berisi `serpent`/`enemySerpent`. Cut-in memakai kelas `.naja-cutin`, art ui/cutin.png, dan judul DUNE SERPENT dari HTML. + +## Reproduksi + +1. Prompt/job: desain di roster.json; strip di strip-requests.json (15 × v1, dibangun dari `request.json`); UI di ui-requests.json. +2. `sprite_gen.cli prepare --out-dir assets/naja/run --character-id naja --base-image assets/naja/base/base-selected.png --request assets/naja/request.json --chroma-key #00FF00`. +3. `tools/naja_measure.py` untuk edge-NCC wajah (utama) dan torso (pembanding); nilai terpilih di rel.json, alasannya di `qa/rel-decision.json`. +4. `tools/naja_pipeline.py calibrate|normalize|extract`, `tools/naja_align.py`, lalu `publish` (atlas, metrics, playback, contact sheet, wrapper JS). Emitter (ujung terjauh di frame 2, wilayah ujung 6 px) disimpan di `emitters.json`, lalu jalankan `publish` sekali lagi. +5. `tools/naja_assets.py` mengekspor portrait/ikon/cut-in/VFX. + +Catatan QA dan batas bukti: assets/naja/run/qa-notes.md. Tes: `node tests/naja.test.cjs` (18 tes). diff --git a/guide/nib-reference.md b/guide/nib-reference.md new file mode 100644 index 0000000..c082ddd --- /dev/null +++ b/guide/nib-reference.md @@ -0,0 +1,68 @@ +# NIB — kurir atap + +NIB adalah petarung Demi-Human kelima dan karakter kesebelas di roster: bocah tikus kurir dengan dua tongkat pendek dan tas surat besar, petarung terkecil dan tercepat. Ia berasal dari 8 karakter showcase dan dibuat playable setelah SOLAN. Pemain dapat memilihnya di roster VS/Training dan di Pengaturan → Karakter pemain; CPU memakai kit yang sama. Kartu identitas: assets/nib/character.md. + +## Dari showcase ke playable + +Alurnya sama dengan SOLAN ([solan-reference.md](solan-reference.md)). Body showcase v1 (sudah chibi) langsung menjadi base sprite (base-selected.png, job `ccd7d47b-e244-4c21-abc5-91cb2a68cddb`), dan avatar showcase dipakai sebagai portrait HUD. Di roster.json NIB ditandai `promoted`, jadi showcase kini tinggal EDDA. Semua gambar baru dibuat dengan **GPT Image 2.5 Sunburst**, chroma hijau. Announcer tetap Grady. Alat pipeline: `tools/nib_*.py`. + +Filter keamanan provider menolak idle dan run v1 sebagai false positive; usia numerik dihapus dari identitas dan v2 langsung lolos (qa-notes). + +## Paket siap digunakan ulang + +| Paket | Isi | +| --- | --- | +| Sprite | 15 state / 54 frame, atlas 1792×4320, cell 448×288, anchor 224/280, idle 150 px (sepatu sampai puncak telinga), lebar 120–126 px | +| UI | portrait 384 (avatar showcase), cut-in 1600×686, 4 ikon 256, roster art dari showcase | +| VFX | 4 PNG alpha 256: letter (surat bersegel), plane (pesawat kertas), slip (kilatan selinap), stamp (cap segel saat melepas pesawat) | +| Audio | `select_nib` "Nib!" (0.914 s), `nib_wins` "Nib wins!" (1.411 s), Grady. Voice ultimate Evan "Special Delivery!" (1.254 s, sunyi di akhir dipotong) | + +Ukuran 150 px dipilih dari lineup 140/150/160/170 (size-candidates.png). Pada ukuran itu wajah setara RHEA dan NIB tetap yang terkecil. Wrapper `NIB_MANIFEST/METRICS` ada di [assets/nib/manifest.js](../assets/nib/manifest.js). Sprite library memutar semua animasi. + +## Kemampuan dan balance + +| Input | Gerakan | Damage / cooldown | +| --- | --- | --- | +| Space×3 | BATON FLURRY: jab, ayunan naik, tongkat ganda. Rantai tercepat di roster (0.30/0.34/0.46 s); reach 95/75/95 di dalam ujung tongkat (107/80/101 px); knockback kecil 55/75/170 agar tetap dekat | 6/8/12 | +| I | EXPRESS LETTER: surat bersegel dilempar datar setinggi dada, 1100 px/s (tercepat di roster), hilang setelah 400 px | 16 / 3 s | +| O | ROOFTOP SLIP: dash rendah 650 px/s, reach 100. Bila mengenai, NIB menyelinap 70 px ke belakang lawan dan berbalik menghadapnya (dash berhenti) | 24 / 6 s | +| P | SPECIAL DELIVERY: pose cast 0.5 s, tiga pesawat kertas homing | 48 / 18 s | + +Setiap basic hit yang valid mengembalikan 5% cooldown maksimum semua skill. HP tetap 200 dalam dua lapis. Semua nilai ada di [nib.js](../nib.js). + +### Timeline Special Delivery + +| Bagian | Waktu dari cast | +| --- | --- | +| Cut-in banner biru langit | 0–0.78 s | +| Pose cast pemain | 0–0.50 s, lalu bebas bergerak/menyerang | +| Pesawat 1 / 2 / 3 | 0.80 / 1.18 / 1.56 s dari tas NIB, meluncur naik lalu berbelok ke dada lawan | +| Pesawat | 620 px/s, belok maks 3.2 rad/s, hilang setelah 2.2 s; 16 damage, satu hit per pesawat | +| Selesai | setelah 2.6 s dan semua pesawat habis | + +Pesawat dilepas 0.38 s berselang, lebih pendek dari stun 0.42 s, sehingga lawan yang diam terkena 48 di mana pun ia berdiri, termasuk di belakang NIB. Karena beloknya terbatas, lompatan saat pesawat tinggal ±100 px membuatnya meleset (tes: 2 dari 3 lolos); lompatan lebih awal atau berlari tetap terkejar. Tidak ada global hit-stop; knockback 40/40/180. Pause membekukan serangan ini, sedangkan reset, kembali ke menu dan akhir ronde membatalkannya. Serangan pemain (`delivery`) dan CPU (`enemyDelivery`) memiliki owner terpisah. + +## CPU + +- NIB sebagai CPU memakai otak yang sama ([cpu-ai.md](cpu-ai.md)) dan kit-nya lewat `KITS`. Express Letter memakai `range` 400 untuk jangkauan CPU. +- Benchmark (NIB sebagai CPU): pemain diam K.O. dalam 17.9 / 17.2 / 12.1 / 12.7 s (Easy→Excellent), tercepat di roster pada Hard dan Excellent karena rantainya tercepat. Proyektil yang mengenai CPU 52 / 42 / 34 / 31%. Detail di [cpu-ai.md](cpu-ai.md). + +## Voice + +"Special Delivery!" digenerate dengan empat preset pria muda: Cody (job gagal di provider), Evan, Ian dan Kevin. Evan dipilih karena pitch mediannya paling tinggi (±267 Hz; Kevin ±227, Ian ±192), paling muda untuk kurir kecil. Ucapan selesai 1.02 s, jadi sunyi di akhir dipotong (1.254 s). Angka pitch hanya panduan; timbre belum didengar pengguna. Cara mengganti: salin kandidat lain dari `assets/nib/audio/ultimate-candidates/` ke `nib-ultimate.mp3`, lalu ganti `VOICE_NAMES.nib` di game.js. Record: generation.json. + +## Integrasi engine + +- `KITS` memetakan `nib` ke kontrak kit yang sama. NIB memiliki `nibStrike` (tongkat, surat, selinap) dan summon `startDelivery`/`updateDelivery` (dipilih lewat `startSummon`). Rooftop Slip memakai jalur dash yang sama dengan FENR/MIRA/HALDOR/ISOLDE/SOLAN, lalu memindahkan NIB ke belakang lawan. +- Proyektil kini mendukung `homing` (belok ke dada lawan dengan batas `turn` rad/s, kecepatan `speedH`). Proyektil lama tidak berubah. +- Snapshot QA berisi `delivery`/`enemyDelivery`. Cut-in memakai kelas `.nib-cutin`, art ui/cutin.png, dan judul SPECIAL DELIVERY dari HTML. + +## Reproduksi + +1. Prompt/job: desain di roster.json; strip di strip-requests.json (13 × v1, 2 × v2); UI di ui-requests.json. +2. `tools/nib_pipeline.py measure`, `tools/nib_measure.py`, lalu tulis `rel.json` (alasan di `qa/rel-decision.json`). +3. `tools/nib_pipeline.py calibrate`, `sprite_gen.cli prepare --out-dir assets/nib/run --character-id nib --base-image assets/nib/base/base-selected.png --request assets/nib/request.json --chroma-key #00FF00`, lalu `normalize` dan `extract`. +4. `tools/nib_align.py`, lalu `publish`. Hitung emitter (ujung tongkat di frame 2) ke `emitters.json`, lalu jalankan `publish` sekali lagi. +5. `tools/nib_assets.py` mengekspor portrait/ikon/cut-in/VFX, lalu `tools/web_images.py` dan `node tools/build_dist.mjs`. + +Catatan QA: assets/nib/run/qa-notes.md. Tes: `node tests/nib.test.cjs` (13 tes). diff --git a/guide/rhea-reference.md b/guide/rhea-reference.md new file mode 100644 index 0000000..8607931 --- /dev/null +++ b/guide/rhea-reference.md @@ -0,0 +1,68 @@ +# RHEA — orrery kecil + +RHEA adalah petarung Mecha keenam dan karakter kesembilan di roster: anak ajaib 14 tahun dengan harness orrery kuningan, dua cincin dan tiga bola planet di lengan engsel. Ia berasal dari 8 karakter showcase dan dibuat playable setelah ISOLDE. Pemain dapat memilihnya di roster VS/Training dan di Pengaturan → Karakter pemain; CPU memakai kit yang sama. Kartu identitas: assets/rhea/character.md. + +## Dari showcase ke playable + +Alurnya sama dengan ISOLDE ([isolde-reference.md](isolde-reference.md)). Body showcase v1 (sudah chibi) langsung menjadi base sprite (base-selected.png, job `7d4aafa4-898c-4cc4-b7f3-5ada55367b5a`), dan avatar showcase dipakai sebagai portrait HUD. Di roster.json RHEA ditandai `promoted`, jadi showcase kini berisi 3 karakter (semua Demi-Human). Semua gambar baru dibuat dengan **GPT Image 2.5 Sunburst**, chroma hijau. Announcer tetap Grady. Alat pipeline: `tools/rhea_*.py`. + +Filter keamanan provider menolak tiga strip v1 (crouch, hurt, recover) sebagai false positive. Kalimat aksinya diubah ke bentuk netral dan v2 langsung lolos; detail di qa-notes. + +## Paket siap digunakan ulang + +| Paket | Isi | +| --- | --- | +| Sprite | 15 state / 54 frame, atlas 1792×4320, cell 448×288, anchor 224/280, idle 170 px (sepatu sampai puncak cepol), lebar 118–119 px | +| UI | portrait 384 (avatar showcase), cut-in 1600×686, 4 ikon 256, roster art dari showcase | +| VFX | 4 PNG alpha 256: drift (planet kecil), planet (planet raksasa bercincin), well (pusaran gravitasi), burst (ledakan bintang) | +| Audio | `select_rhea` "Rhea!" (0.993 s), `rhea_wins` "Rhea wins!" (1.306 s), Grady. Voice ultimate Chloe "Grand Orrery!" (1.306 s) | + +Ukuran 170 px dipilih dari lineup 160/170/180/190 (size-candidates.png). Pada ukuran itu wajah setara NAJA dan badannya tetap kecil sesuai umur. Wrapper `RHEA_MANIFEST/METRICS` ada di [assets/rhea/manifest.js](../assets/rhea/manifest.js). Sprite library memutar semua animasi. + +## Kemampuan dan balance + +| Input | Gerakan | Damage / cooldown | +| --- | --- | --- | +| Space×3 | ORBIT STRIKE: bola kuningan diayun maju, bola krem naik, tiga bola sejajar. Reach 100/85/115 di dalam bola terukur (109/80/128 px). Knockback 75/100/190 | 6/8/12 | +| I | PLANET DRIFT: planet kecil setinggi dada melayang 380 px/s selama 3.2 s (melintasi arena). Paling lambat di roster: RHEA bisa berjalan di belakangnya. Lompatan melewatinya | 16 / 3 s | +| O | GRAVITY WELL: pusaran terbuka 230 px di depan dan runtuh setelah 0.3 s, mengenai lawan di lantai dalam 95 px dari pusatnya (135–325 px dari RHEA). Lawan yang menempel atau jauh aman | 24 / 6 s | +| P | GRAND ORRERY: pose cast 0.5 s, tiga planet raksasa mengorbit RHEA | 48 / 18 s | + +Setiap basic hit yang valid mengembalikan 5% cooldown maksimum semua skill. HP tetap 200 dalam dua lapis. Semua nilai ada di [rhea.js](../rhea.js). + +### Timeline Grand Orrery + +| Bagian | Waktu dari cast | +| --- | --- | +| Cut-in banner burgundy/kuningan | 0–0.78 s | +| Pose cast pemain | 0–0.50 s, lalu bebas bergerak/menyerang | +| Planet 1 / 2 / 3 dilepas | 0.80 / 1.18 / 1.56 s dari titik 230 px di depan RHEA | +| Orbit | satu putaran elips 230×70 px mengelilingi RHEA dalam 0.9 s, mengikuti posisi RHEA; 16 damage, satu hit per planet | +| Selesai | setelah 2.6 s dan semua planet menyelesaikan putarannya | + +Ketiga planet berjalan dengan fase yang sama dan dilepas 0.38 s berselang, lebih pendek dari stun 0.42 s, sehingga lawan yang diam di dalam orbit (depan atau belakang) terkena 48. Lawan yang menjaga jarak lebih dari ±260 px aman: ini ultimate zoner, bukan serangan pengejar. Tidak ada global hit-stop; knockback 40/40/190. Pause membekukan serangan ini, sedangkan reset, kembali ke menu dan akhir ronde membatalkannya. Serangan pemain (`orrery`) dan CPU (`enemyOrrery`) memiliki owner terpisah. + +## CPU + +- RHEA sebagai CPU memakai otak yang sama ([cpu-ai.md](cpu-ai.md)) dan kit-nya lewat `KITS`. Planet Drift memakai jangkauan proyektil standar (560 px) karena ia melintasi arena. +- Benchmark (RHEA sebagai CPU): pemain diam K.O. dalam 17.6 / 16.7 / 19.2 / 15.8 s (Easy→Excellent); Hard lebih lambat dari Medium pada pemain diam (belum dianalisis). Proyektil yang mengenai CPU 52 / 42 / 41 / 30%. Detail di [cpu-ai.md](cpu-ai.md). + +## Voice + +"Grand Orrery!" digenerate dengan empat preset wanita muda: Pixie, Zoe, Chloe dan Gia. Chloe dipilih karena pitch mediannya paling tinggi (±390 Hz; Zoe ±299, Gia ±274, Pixie ±234) dengan take pendek (ucapan selesai 1.08 s), cocok untuk anak ajaib 14 tahun. Angka pitch hanya panduan; timbre belum didengar pengguna. Cara mengganti: salin kandidat lain dari `assets/rhea/audio/ultimate-candidates/` ke `rhea-ultimate.mp3`, lalu ganti `VOICE_NAMES.rhea` di game.js. Record: generation.json. + +## Integrasi engine + +- `KITS` memetakan `rhea` ke kontrak kit yang sama. RHEA memiliki `rheaStrike` (ayunan bola, planet melayang, pusaran) dan summon `startOrrery`/`updateOrrery` (dipilih lewat `startSummon`). +- Proyektil kini mendukung `inert` (menunggu di tempat lalu memanggil `wellBurst` saat habis) dan `orbit` (mengelilingi pemiliknya pada elips; `spent` setelah satu hit). Proyektil lama tidak berubah. +- Snapshot QA berisi `orrery`/`enemyOrrery`. Cut-in memakai kelas `.rhea-cutin`, art ui/cutin.png, dan judul GRAND ORRERY dari HTML. + +## Reproduksi + +1. Prompt/job: desain di roster.json; strip di strip-requests.json (12 × v1, 3 × v2); UI di ui-requests.json. +2. `tools/rhea_pipeline.py measure`, `tools/rhea_measure.py`, lalu tulis `rel.json` (alasan di `qa/rel-decision.json`). +3. `tools/rhea_pipeline.py calibrate`, `sprite_gen.cli prepare --out-dir assets/rhea/run --character-id rhea --base-image assets/rhea/base/base-selected.png --request assets/rhea/request.json --chroma-key #00FF00`, lalu `normalize` dan `extract`. +4. `tools/rhea_align.py`, lalu `publish`. Hitung emitter (ujung terjauh di frame 2) ke `emitters.json`, lalu jalankan `publish` sekali lagi. +5. `tools/rhea_assets.py` mengekspor portrait/ikon/cut-in/VFX, lalu `tools/web_images.py` dan `node tools/build_dist.mjs`. + +Catatan QA: assets/rhea/run/qa-notes.md. Tes: `node tests/rhea.test.cjs` (13 tes). diff --git a/guide/solan-reference.md b/guide/solan-reference.md new file mode 100644 index 0000000..43a8e1b --- /dev/null +++ b/guide/solan-reference.md @@ -0,0 +1,66 @@ +# SOLAN — surai matahari + +SOLAN adalah petarung Demi-Human keempat dan karakter kesepuluh di roster: manusia singa dengan surai saffron raksasa dan pedang besar baja. Ia berasal dari 8 karakter showcase dan dibuat playable setelah RHEA. Pemain dapat memilihnya di roster VS/Training dan di Pengaturan → Karakter pemain; CPU memakai kit yang sama. Kartu identitas: assets/solan/character.md. + +## Dari showcase ke playable + +Alurnya sama dengan RHEA ([rhea-reference.md](rhea-reference.md)). Body showcase chibi v2 langsung menjadi base sprite (base-selected.png, job `0e447e38-2109-4817-b778-06d3955118e0`), dan avatar showcase dipakai sebagai portrait HUD. Di roster.json SOLAN ditandai `promoted`, jadi showcase kini berisi 2 karakter (NIB, EDDA). Semua gambar baru dibuat dengan **GPT Image 2.5 Sunburst**, chroma hijau. Announcer tetap Grady. Alat pipeline: `tools/solan_*.py`. + +## Paket siap digunakan ulang + +| Paket | Isi | +| --- | --- | +| Sprite | 15 state / 54 frame, atlas 1792×4320, cell 448×288, anchor 224/280, idle 176 px (sepatu sampai ujung pedang di bahu), lebar 153–155 px | +| UI | portrait 384 (avatar showcase), cut-in 1600×686, 4 ikon 256, roster art dari showcase | +| VFX | 4 PNG alpha 256: crescent (gelombang bulan sabit), roar (gelombang auman tinggi), impact (hantaman lantai), sunburst (matahari saat mengaum) | +| Audio | `select_solan` "Solan!" (0.914 s), `solan_wins` "Solan wins!" (1.384 s, sunyi di akhir dipotong), Grady. Voice ultimate Xavier "Sunmane Roar!" (1.306 s) | + +Ukuran 176 px dipilih dari lineup 176/188/200/212 (size-candidates.png). Pada ukuran itu wajah setara NAJA, dan surainya membuatnya tampak besar sesuai peran powerhouse. Wrapper `SOLAN_MANIFEST/METRICS` ada di [assets/solan/manifest.js](../assets/solan/manifest.js). Sprite library memutar semua animasi. + +## Kemampuan dan balance + +| Input | Gerakan | Damage / cooldown | +| --- | --- | --- | +| Space×3 | SUNBLADE: tebasan mendatar, tebasan naik, belahan ke lantai. Reach 130/100/95 di dalam ujung pedang terukur (149/95/74 px). Knockback 90/120/230 | 6/8/12 | +| I | SOLAR CRESCENT: gelombang bulan sabit emas setinggi dada, 700 px/s, hilang setelah 480 px (jangkauan total ±590 px dari SOLAN) | 16 / 3 s | +| O | LEONINE LEAP: melompat maju (dash 420 px/s) lalu menghantam lantai 70 px di depan. Semua lawan di lantai dalam 120 px dari titik hantam terkena, tanpa perlu kontak | 24 / 6 s | +| P | SUNMANE ROAR: pose cast 0.5 s, tiga auman | 48 / 18 s | + +Setiap basic hit yang valid mengembalikan 5% cooldown maksimum semua skill. HP tetap 200 dalam dua lapis. Semua nilai ada di [solan.js](../solan.js). + +### Timeline Sunmane Roar + +| Bagian | Waktu dari cast | +| --- | --- | +| Cut-in banner saffron/umber | 0–0.78 s | +| Pose cast pemain | 0–0.50 s, lalu bebas bergerak/menyerang | +| Auman 1 / 2 / 3 | 0.80 / 1.18 / 1.56 s dari posisi SOLAN saat itu | +| Gelombang | sepasang per auman (kiri dan kanan), 520 px/s, tinggi ±190 px (`tall`), hilang setelah 480 px; 16 damage, satu hit per gelombang | +| Selesai | setelah 2.6 s dan semua gelombang hilang | + +Jarak antar auman 0.38 s, lebih pendek dari stun 0.42 s, sehingga lawan dalam ±480 px terkena 48, di depan maupun di belakang. Berbeda dari Forge Quake HALDOR, gelombang auman terlalu tinggi untuk dilompati; satu-satunya cara menghindar adalah menjauh sebelum auman. Tidak ada global hit-stop; knockback 50/50/190. Pause membekukan serangan ini, sedangkan reset, kembali ke menu dan akhir ronde membatalkannya. Serangan pemain (`sunroar`) dan CPU (`enemySunroar`) memiliki owner terpisah. + +## CPU + +- SOLAN sebagai CPU memakai otak yang sama ([cpu-ai.md](cpu-ai.md)) dan kit-nya lewat `KITS`. Solar Crescent memakai `range` 480 untuk jangkauan CPU. CPU akan mencoba melompati gelombang auman seperti proyektil lain; itu tidak menolong, dan tes menguncinya. +- Benchmark (SOLAN sebagai CPU): pemain diam K.O. dalam 25.6 / 22.5 / 19.9 / 18.6 s (Easy→Excellent), turun rapi per level. Proyektil yang mengenai CPU 52 / 43 / 36 / 35%. Detail di [cpu-ai.md](cpu-ai.md). + +## Voice + +"Sunmane Roar!" digenerate dengan empat preset pria: Brooks, Marcus, Orion dan Xavier. Xavier dipilih karena take-nya paling pendek (ucapan selesai 1.03 s, yang lain 1.6–2.1 s) dengan pitch menengah-rendah (±142 Hz). Angka pitch hanya panduan; timbre belum didengar pengguna. Cara mengganti: salin kandidat lain dari `assets/solan/audio/ultimate-candidates/` ke `solan-ultimate.mp3`, lalu ganti `VOICE_NAMES.solan` di game.js. Record: generation.json. + +## Integrasi engine + +- `KITS` memetakan `solan` ke kontrak kit yang sama. SOLAN memiliki `solanStrike` (tebasan, gelombang, lompatan area) dan summon `startSunroar`/`updateSunroar` (dipilih lewat `startSummon`). Leonine Leap memakai jalur dash yang sama dengan FENR/MIRA/HALDOR/ISOLDE. +- Proyektil kini mendukung `tall`: toleransi vertikal tabrakan yang lebih besar, sehingga gelombang tidak bisa dilompati. Proyektil lama tidak berubah. +- Snapshot QA berisi `sunroar`/`enemySunroar`. Cut-in memakai kelas `.solan-cutin`, art ui/cutin.png, dan judul SUNMANE ROAR dari HTML. + +## Reproduksi + +1. Prompt/job: desain di roster.json; strip di strip-requests.json (15 × v1); UI di ui-requests.json. +2. `tools/solan_pipeline.py measure`, `tools/solan_measure.py`, lalu tulis `rel.json` (alasan di `qa/rel-decision.json`). +3. `tools/solan_pipeline.py calibrate`, `sprite_gen.cli prepare --out-dir assets/solan/run --character-id solan --base-image assets/solan/base/base-selected.png --request assets/solan/request.json --chroma-key #00FF00`, lalu `normalize` dan `extract`. +4. `tools/solan_align.py`, lalu `publish`. Hitung emitter (ujung pedang di frame 2) ke `emitters.json`, lalu jalankan `publish` sekali lagi. +5. `tools/solan_assets.py` mengekspor portrait/ikon/cut-in/VFX, lalu `tools/web_images.py` dan `node tools/build_dist.mjs`. + +Catatan QA: assets/solan/run/qa-notes.md. Tes: `node tests/solan.test.cjs` (13 tes). diff --git a/guide/sprite-known-issues.md b/guide/sprite-known-issues.md new file mode 100644 index 0000000..d9bce61 --- /dev/null +++ b/guide/sprite-known-issues.md @@ -0,0 +1,203 @@ +# Masalah yang Pernah Terjadi & Solusinya + +Untuk keputusan gameplay dan format terbaru lihat [gameplay-standard.md](gameplay-standard.md) dan [character-workflow.md](character-workflow.md). Kasus lama tetap berguna sebagai diagnosis; angka koreksinya bukan preset karakter baru. + +Format: **gejala → penyebab → solusi → pencegahan**. Tambahkan entri baru setiap menemukan masalah baru, +lalu tambahkan pencegahannya ke checklist di [sprite-qa.md](sprite-qa.md). + +--- + +## 1. Saat diam, satu frame terlihat lebih besar + +- **Gejala:** karakter "membesar" sekali tiap siklus idle. +- **Penyebab:** model gambar menggambar satu pose ~5 % lebih besar (78×149 px vs 74×142 px). Extractor hanya + menyamakan posisi kaki, tidak menyamakan skala antar frame. +- **Solusi:** ukur dengan `measure_head_scale.py` (kepala, di gambar asli), lalu `rebuild_raw.py --rel` + (koreksi per pose di gambar asli), extract ulang. +- **Pencegahan:** prompt idle berisi "all at the same scale", "feet planted in the same place"; + checklist: skala ±1 %. + +## 2. Setelah diperbaiki, frame yang dikoreksi terlihat sedikit buram / animasi berkedip + +- **Gejala:** ukuran sudah sama tapi animasi terasa "kejut-kejut"; sebagian frame kurang tajam. +- **Penyebab:** koreksi dilakukan dengan `scale` (dan `dx` pecahan) di `curation.json`. Transform ini + me-resample frame dengan bicubic → frame yang dikoreksi lebih lembut dari yang tidak. +- **Solusi:** hapus transform skala dari curation; koreksi skala di `rebuild_raw.py` (satu resample dari + gambar asli); curation hanya berisi `dx` bulat. +- **Pencegahan:** aturan "curation hanya geser bulat" (sprite-qa.md → Yang TIDAK boleh dilakukan). + +## 3. Saat berjalan badan goyang maju-mundur (glitch) + +- **Gejala:** kepala/badan meloncat ±6 px tiap langkah. +- **Penyebab:** penyelarasan `foot-centroid` menaruh titik tengah **kaki** di tengah sel. Saat kaki melebar + vs merapat, titik itu berpindah → badan ikut berpindah. +- **Solusi:** `register_frames.py run walk --dx-only` → `write_dx.py` (sejajarkan badan atas ke idle). +- **Pencegahan:** untuk walk/run selalu jalankan registrasi dx; checklist torso_x ±1.5 px. + +## 4. Karakter mengecil saat mulai berjalan + +- **Gejala:** transisi idle → walk terlihat "zoom out". +- **Penyebab:** strip walk digambar lebih kecil oleh model; normalisasi pitch tidak sempurna (sisa ~4.5 %). +- **Solusi:** ukur dengan `measure_head_scale.py` → median skala → `rebuild_raw.py --rel`. + ⚠ Koreksi pertama memakai registrasi siluet (1.045) dan ternyata **berlebihan** — walk jadi ±6 % lebih + besar dari idle (lihat #12). Nilai yang benar setelah diukur lewat kepala: 0.990. +- **Pencegahan:** setiap gerakan diukur terhadap **idle frame 0** lewat kepala, bukan siluet. + +## 5. Ukuran karakter berbeda-beda antar gerakan + +- **Gejala:** walk jauh lebih kecil dari idle, attack lebih besar, dst. +- **Penyebab:** tiap strip hasil generate punya skala sendiri (idle 860 px, walk 675 px tinggi). +- **Solusi:** normalisasi per strip dengan pitch (`0.5 / pitch`) — sudah otomatis di `rebuild_raw.py`. + +## 6. Extractor: `pitch disagreement … detect 1.00x1.00` dan frame dikecilkan paksa + +- **Penyebab:** gambar AI adalah pixel art "palsu" (tidak ada grid asli), sementara mode `pixel-unfake` aktif; + dan sel terlalu kecil untuk resolusi asli. +- **Solusi:** `--no-fit-pixel-unfake`, perkecil strip dulu (langkah normalisasi), pakai sel yang cukup besar. + +## 7. Warning `native logical exceeds the physical cap … capped frames` + +- **Penyebab:** pose tidak muat di sel → dikecilkan **per frame** → skala tidak konsisten. +- **Solusi:** perbesar sel (`--cell-width/--cell-height`) sampai warning hilang. Jangan diabaikan. + +## 8. Model tidak bisa membuat strip selebar layout guide + +- **Gejala:** layout guide 768×160 (4.8:1), model maksimal 21:9. +- **Solusi:** gambar ulang guide di rasio yang didukung dengan jumlah slot sama. Extractor memisahkan pose + per komponen, jadi rasio strip tidak harus sama dengan sel. + +## 9. `rebuild_raw.py`: "ditemukan X pose, harusnya N" + +- **Penyebab:** dua pose saling menempel (pedang/jubah menyentuh tetangga) atau ada efek lepas. +- **Solusi:** generate ulang strip itu dengan persen tinggi lebih kecil dan "clear gap between poses". + +## 10. Makhluk terbang: posisi melompat-lompat antar frame kepak sayap + +- **Gejala:** naga/burung "meloncat" naik-turun 30–90 px saat sayap turun. +- **Penyebab:** tidak ada kaki untuk dijadikan anchor, dan registrasi siluet penuh tertipu sayap + (sayap adalah bagian terbesar dan bergerak paling jauh; IoU hanya 0.4–0.5). +- **Solusi:** sejajarkan dengan bagian yang memang diam di gerakan itu: + - kepak sayap / hover → **ujung moncong** (titik paling kanan di 45 % atas siluet) atau kepala + - serangan yang menggerakkan kepala (semburan) → **badan/perut** (kotak tengah siluet), IoU harus ≥ 0.85 + - tetap geser **bulat** saja. Goyang naik-turun (bob) ditambahkan di kode game, bukan dari sprite. +- **Pencegahan:** di prompt tulis bagian mana yang harus diam ("head and neck stay steady"). + +## 11. Sayap / ekor terpotong di tepi sel setelah digeser + +- **Penyebab:** geser (curation) memindahkan frame di dalam sel; sel yang pas-pasan jadi terlalu kecil. +- **Solusi:** perbesar sel lalu extract + compose ulang. Cek: tidak ada piksel alpha di baris/kolom tepi sel. +- **Pencegahan:** ukuran sel = pose terbesar + jangkauan geser terbesar + margin. + +## 12. Karakter mengecil setiap menyerang / memakai skill, lalu "membesar" lagi saat diam + +- **Gejala:** kepala dan badan terlihat lebih kecil selama serangan/skill; begitu kembali ke idle/jalan, + karakter seperti membesar. Paling terasa di frame pedang terangkat (dipakai berkali-kali oleh skill). +- **Penyebab:** + 1. Normalisasi skala hanya memakai **pitch** (ukuran pixel palsu). Model menggambar strip serangan dengan + pitch yang sama tapi karakter **lebih kecil** (±6–15 %) — supaya senjata yang terangkat muat di slot. + 2. Cek skala sebelumnya hanya dilakukan untuk idle & walk. Gerakan aksi tidak pernah diukur. + 3. Walk malah dikoreksi berlebihan (+4.5 %) karena registrasi siluet: hasilnya walk ±6 % lebih besar dari idle. + Transisi serang (kecil) → jalan (besar) memperparah kesan "membesar". +- **Kenapa sulit diukur:** tinggi & siluet berubah karena pose (lutut ditekuk, lengan & pedang), pencocokan + warna gagal karena tiap strip diberi pencahayaan berbeda, dan di atlas kepala hanya ±20 px. +- **Solusi:** `measure_head_scale.py` — cocokkan **tepi helm** di **gambar asli** untuk setiap frame semua + gerakan terhadap idle frame 0 → median per gerakan → `rebuild_raw.py --rel` → extract → ukur lagi → + ukur ulang hitbox & titik efek. Hasil kasus ini: walk 1.056→1.00, jump 0.97→1.00, attack1 0.94→1.00, + attack2 ~0.93→1.015, attack3 1.03→1.00. +- **Pencegahan:** checklist "Skala antar gerakan — SEMUA gerakan" di sprite-qa.md; `rel.json` per karakter; + setiap gerakan baru wajib diukur terhadap idle sebelum dipakai. + +## 13. Gerakan yang ditambah belakangan skalanya jauh berbeda + +- **Gejala:** gerakan baru (mis. "hurt" yang dibuat setelah gerakan lain selesai) tampil ±30 % lebih kecil. +- **Penyebab:** tiap generate adalah undian baru; strip baru tidak "ingat" ukuran strip lama walau base sama. +- **Solusi:** gerakan baru diperlakukan sama seperti yang pertama — `measure_head_scale.py` terhadap idle frame 0 + (pakai `rel.json` + `--head-box` dari kartu karakter) → `rebuild_raw.py --rel` → tambah state ke + `sprite-request.json` → extract → compose. +- **Pencegahan:** simpan `--head-box`, `--landmark`, `--target`, dan `rel.json` di kartu karakter supaya pengukuran + gerakan baru tinggal dijalankan ulang. + +## 14. Pose jatuh / rebahan tidak bisa diukur lewat kepala + +- **Gejala:** `head-check.png` untuk strip "down"/"knockdown" berisi lengan atau senjata, bukan kepala. +- **Penyebab:** kepala miring atau terbalik; template kepala tegak tidak cocok. +- **Solusi:** pakai `rel` dari strip lain dengan pitch sama yang terbukti pas, lalu cek visual: jajarkan frame jatuh + di samping idle (ukuran badan, lebar bahu, panjang kaki harus sebanding). + +## 15. Jalan terlihat "berselancar": kaki tidak pernah bergantian + +- **Gejala:** karakter bergerak tapi kakinya seperti meluncur; di setiap frame kaki yang sama selalu di belakang. +- **Penyebab (dua sekaligus):** + 1. **Sprite salah dari generate:** model menggambar 6 pose dengan kaki terbuka yang mirip-mirip, tanpa pose + "melintas" (kaki merapat saat satu kaki mengayun melewati yang lain). Tidak ada frame yang duplikat persis, + jadi tidak tertangkap cek duplikat. + 2. **Animasi tidak sinkron dengan gerak:** fps tetap (9) sementara badan bergerak 95 px/s → kaki "menempuh" + ±312 px/s, badan hanya 95 → kaki meluncur. +- **Deteksi:** `measure_atlas.py` sekarang mengukur **jarak kaki per frame** untuk walk/run. Siklus benar: lebar → + rapat → lebar → rapat (rasio terkecil/terbesar ≤ 0.45). Kasus ini: `[93, 62, 78, 88, 69, 83]` rasio 0.67 ✗; + setelah diperbaiki `[102, 31, 38, 106, 31, 43]` rasio 0.29 ✓. +- **Solusi sprite:** generate ulang dengan prompt yang **menyebut kaki mana di depan tiap frame** dan bahwa kaki + **bersilang di frame melintas**, plus lampirkan strip walk karakter lain yang sudah benar sebagai **referensi + gerakan saja** (lihat [sprite-prompts.md](sprite-prompts.md)). Strip baru tetap diukur skalanya (kasus ini: 7 % kecil). +- **Solusi game:** kecepatan animasi dihitung dari jarak tempuh, bukan fps tetap — lihat + [sprite-runtime.md](sprite-runtime.md#kecepatan-animasi). + +## 16. Hasil jalan masih sedikit "kejut" setelah skala & posisi benar + +- **Penyebab:** pose kaki hasil generate memang tidak sempurna berurutan (bukan masalah ukuran). +- **Solusi:** generate ulang strip walk 2–3 kali, pilih yang paling halus; atau buang/ulang frame lewat + curation (`selected`/`order`). Tidak bisa diperbaiki dengan skala/geser. + +## 17. Frame terakhir serangan/kombo lebih kecil → karakter "membesar" saat kembali ke idle + +- **Gejala:** median skala gerakan sudah lolos (1.00), tapi di game pada perpindahan frame terakhir serangan + → idle, karakter terlihat membesar sedikit dan bergeser beberapa piksel. +- **Penyebab:** koreksi `--rel` satu angka per strip memakai **median** semua frame. Model tetap menggambar tiap + pose dengan ukuran/proporsi sedikit berbeda; kebetulan frame terakhir (pose kembali siaga) lebih kecil. + Contoh nyata (kombo knight): median strip 1.000, tapi frame terakhir kepala ±0.96, dan pose berdiri tegak + 11 px lebih pendek dari idle (helm→sabuk 55 vs 58 px), badan bergeser 1–7 px. +- **Kenapa lolos QA:** median menyembunyikan satu frame yang meleset; frame putar/menunduk tidak terukur + sehingga frame terakhir tidak mendapat perhatian khusus. +- **Solusi:** ukur **frame pertama & terakhir** tiap serangan langsung terhadap idle frame 0 di atlas + (ukuran kepala + tinggi helm→sabuk untuk pose tegak) → koreksi **per pose** hanya untuk frame itu + (`rebuild_raw.py --rel 1.143,1.143,1.143,1.143,1.143,1.223`) → geser bulat supaya posisi kepala sama dengan + idle (`write_dx.py … 0,0,0,0,0,1`) → extract → compose → cek ulang berdampingan dengan idle. + Hasil kasus ini: kepala 0.96 → 0.99 (= idle), combo3 f5 helm 130 → 139 px (idle 141), posisi kepala ±0 px. +- **Catatan alat:** pose di strip kombo saling menempel (ujung pedang menyentuh jubah tetangga). + `rebuild_raw.py` sekarang memotong di kolom tersentuh-tipis, lalu **hanya** pose yang dikoreksi ditempel ulang; + strip lainnya tetap di-resize utuh (versi lama menata ulang semua pose → pedang tetangga terbelah). +- **Pencegahan:** checklist "Frame sambungan" di sprite-qa.md. + +## 18. Kepala sudah lolos tetapi badan aksi masih tampak mengecil + +- Penyebab: luas rambut/median kepala tidak menangkap pemendekan torso atau kaki, terutama recovery dan ultimate. +- Perbaikan: bandingkan tubuh utuh pada garis kaki sama; ukur crown→sabuk dan sabuk→kaki. Koreksi secukupnya dari original atau regenerate proporsi yang salah. Jangan membesarkan atlas per frame. +- ARCO membutuhkan perbaikan body presence dan ultimate upright baru; hasil rambut saja tidak cukup sebagai bukti. + +## 19. Jump ramai, kaku, atau mengecil sesudah double jump + +- Empat pose jump lama memutar crouch ketika karakter sudah airborne; setelah double jump timer berakhir, animasi mengulang crouch dari awal. +- Satu pose pengganti yang terlalu mirip berdiri juga ditolak karena kaku. Solusi final: satu pose jump atletis dengan siku/lutut menekuk, ditahan sepanjang lintasan fisika. +- Double jump menggunakan satu tuck yang benar-benar diputar360°/0,28 s, bukan pose menendang. Keluar dari roll langsung ke jump, dengan pivot terukur. + +## 20. Summon selesai menembak baru karakter bisa bergerak + +- Penyebab: durasi pose cast disamakan dengan umur seluruh efek. +- Solusi: cast ultimate ARCO0,50 s terpisah dari drone 2,85 s; I melepas karakter saat peluru keluar. Drone/peluru tidak memakai global hit-stop untuk pemiliknya. + +## 21. Cutout prop tampak kecil dan muzzle meleset + +- Satu piksel liar di(0,0) ikut memperbesar bbox drone walaupun hampir tidak tampak. Resize ke112 px lalu membuat objek utama terlalu kecil. +- Solusi: periksa komponen alpha, ambil bbox komponen utama yang benar dengan padding aman, lalu resize sekali dari cutout resolusi asli. Ukur muzzle di output final. +- Jangan memakai pusat gambar sebagai nozzle; wing/ekor dapat menggeser center bbox. + +## 22. HP dua lapis salah ditafsirkan + +- Damage trail dan dua baris vertikal bukan yang diminta pengguna. +- Solusi final: satu track, dua fill bertumpuk pada koordinat yang sama; setiap fill mewakili 100HP nyata. Warna depan terkikis lalu reserve. Total200HP, KO hanya pada 0. +- Ultimate tidak memaksa KO atau mengosongkan bar. Nilai final damage ada di gameplay-standard. + +## 23. Asap lari terus keluar saat tertahan + +- Penyebab: emisi berbasis waktu atau status tombol, tanpa memeriksa jarak yang benar-benar ditempuh. +- Solusi: emisi tiap 30 px perjalanan di tanah saat state run. Jangan memunculkan asap saat walk/jump atau ketika collision membuat posisi tidak bergerak. Soft gradient harus mengikuti bentuk puff agar tidak menjadi lingkaran keras seperti gelembung. diff --git a/guide/sprite-pipeline.md b/guide/sprite-pipeline.md new file mode 100644 index 0000000..022fd10 --- /dev/null +++ b/guide/sprite-pipeline.md @@ -0,0 +1,195 @@ +# Sprite Pipeline + +Untuk proyek fighting ini, mulai dari [character-workflow.md](character-workflow.md) dan [template request](templates/sprite-request.json): 14 state/50 frame, cell 320×288, jump/tuck masing-masing1 pose. Contoh generik di bawah harus mengikuti kontrak tersebut saat dipakai di proyek ini. + +Alur dari nol sampai atlas siap pakai. Contoh memakai skill **sprite-gen** (`sprite-gen prepare/extract/compose-atlas`) +dan model gambar yang tidak bisa membuat strip super lebar (mis. GPT Image, maksimal 21:9). Prinsipnya berlaku +juga untuk alat lain. + +``` +1 base ──► 2 prepare ──► 3 generate strip ──► 4 normalisasi skala ──► 5 extract + ▲ │ + └── 7 koreksi per pose ◄┘ 6 ukur + │ + 8 geser bulat (curation) ──► 9 compose atlas ──► 10 QA +``` + +--- + +## 1. Buat & kunci karakternya dulu — baru sprite + +**Jangan langsung minta sprite sheet.** Urutannya selalu: + +1. Buat **satu gambar karakter** (base) sampai benar-benar disetujui: desain, warna, proporsi, gaya. +2. Simpan sebagai **kartu karakter** (di bawah). +3. Baru setelah itu generate strip gerakan — **setiap** strip melampirkan base ini sebagai referensi. + +Alasannya: model gambar mengikuti referensi jauh lebih konsisten daripada mengikuti deskripsi teks. +Kalau desain belum dikunci, tiap strip akan "menafsirkan ulang" karakternya (warna, helm, senjata berubah), +dan itu tidak bisa diperbaiki di tahap mana pun setelahnya. + +- Generate 2–4 varian, pilih **satu**. Semua gerakan nanti mengacu ke gambar ini. +- Syarat base: + - full body, tidak terpotong (termasuk senjata, jubah, bulu helm) + - menghadap **kanan** (kiri didapat dengan flip di game) + - background **satu warna flat** (chroma key) yang tidak dipakai karakter — lihat bagian Chroma + - pose netral/idle, proporsi final +- Simpan: `base/base-a.png` (+ job id generator bila perlu dipakai sebagai referensi). + +**Chroma key:** pilih warna yang jauh dari warna karakter. +- Karakter biru/perak/emas/hijau → magenta `#FF00FF` +- Karakter merah/pink/ungu → hijau `#00FF00` +- Larang warna itu di prompt ("do not use magenta/pink/purple on the character"). + +### Kartu karakter + +Simpan di `/character.md` (atau json) dan **pakai ulang** setiap kali menambah gerakan baru, +walaupun berbulan-bulan kemudian. Gerakan baru yang dibuat tanpa kartu ini hampir pasti tidak cocok. + +```markdown +# Kartu karakter: knight +- Base: base/base-a.png (generator job id: b1469b8f-…, bisa dipakai langsung sebagai referensi) +- Prompt base: +- Model: gpt_image_2_5, variant sunburst, quality high, resolution 2k +- Palet: silver steel, gold trim, royal blue tabard & cape, white plume, brown leather +- Chroma key: #FF00FF Menghadap: kanan +- Skala: target/pitch diukur untuk idle ±180 px; kepala DAN badan dibandingkan; rel.json milik karakter ini +- Sel: 320×288, margin x=10/y=8, anchor kaki (160, 280) +- Gerakan: gunakan 14 state pada template; walk/run 4, jump/tuck 1, attack1-3 4 +``` + +### Karakter kedua dan seterusnya (supaya satu game satu gaya) + +Base karakter baru dibuat dari **deskripsi gaya bersama tanpa gambar karakter lama**. Sesuai revisi pengguna, label STYLE ONLY belum cukup mencegah kemiripan. Prompt wajib menentukan siluet, rambut, wajah, palet, kostum, anatomi/alat, dan aksesori baru serta menyebut ciri roster lama yang harus dihindari. Lihat [aturan pembeda](character-workflow.md#pembeda-eksplisit-pada-setiap-prompt-base). +Setelah base baru disetujui, kartu dan base miliknya sendiri menjadi referensi identitas untuk seluruh strip. + +Karakter yang jauh lebih besar (bos, naga) boleh punya `target` skala lebih besar (pixel sedikit lebih tebal) +supaya tampil besar di layar — tulis di kartunya. + +## 2. Siapkan run + +Default proyek: empat frame untuk sebagian besar gerakan, satu pose atletis untuk jump dan satu pose tuck untuk doublejump. Enam frame walk adalah opsi pengembangan yang harus mengubah request/guide/QA bersama, bukan default tersembunyi. Berikut cuplikan; template menyediakan semua 14 state. + +```json +{ + "cell": { "width": 320, "height": 288, "safe_margin_x": 10, "safe_margin_y": 8 }, + "states": { + "idle": { "frames": 4, "fps": 5, "loop": true }, + "walk": { "frames": 4, "fps": 10, "loop": true }, + "jump": { "frames": 1, "fps": 10, "loop": false }, + "doublejump": { "frames": 1, "fps": 12, "loop": false }, + "attack1": { "frames": 4, "fps": 13, "loop": false } + } +} +``` + +```bash +sprite-gen prepare --out-dir run --character-id hero --base-image base/base-a.png \ + --request request.json \ + --chroma-key "#FF00FF" --no-fit-pixel-unfake --fit-align-x foot-centroid --fit-align-y bottom +``` + +**Ukuran sel:** harus muat pose **terbesar** (pedang terangkat, tusukan maksimal) di skala final tanpa dikecilkan. +Hitung setelah langkah 4; kalau ragu, lebih besar lebih aman. Frame yang tidak muat akan dikecilkan otomatis +**per frame** → ukuran karakter jadi tidak konsisten. + +**`pixel-unfake` mati** bila gambar AI bukan pixel art asli (grid tidak terdeteksi). Tanda: extractor melaporkan +`pitch disagreement … detect 1.00x1.00`. + +## 3. Generate strip per gerakan + +- Satu strip = satu gerakan, N pose dari kiri ke kanan. +- Referensi yang dilampirkan: **(1) base**, **(2) layout guide** berisi N kotak. +- Model dengan rasio terbatas: buat ulang layout guide di rasio yang didukung (mis. 21:9) dengan jumlah kotak sama. + Extractor memisahkan pose berdasarkan bentuk, jadi strip tidak harus persis rasio sel. +- Prompt: lihat [sprite-prompts.md](sprite-prompts.md). +- Simpan hasil asli **tanpa diubah** di `raw-original/.png`. Ini sumber untuk semua koreksi berikutnya. + +Simpan pula versi immutable `-v1.png`, `-v2.png`, dan seterusnya. `.png` boleh menjadi salinan sumber terpilih untuk tool; jangan me-resample file versi aslinya. Jump/tuck satu pose memakai 1:1 dan `--poses 1`, bukan strip empat pose. + +Periksa cepat dengan mata: jumlah pose benar, tidak saling menempel, tidak ada efek lepas (garis gerak, debu). +Kalau gagal, generate ulang strip itu saja. + +## 4. Normalisasi skala antar strip + +Model menggambar tiap strip dengan ukuran berbeda (contoh nyata: idle 860 px tinggi, walk 675 px). +Langkah pertama: samakan **pitch** (ukuran pixel palsu) tiap strip. Ini hanya **perkiraan awal** — model bisa +menggambar karakter lebih kecil dengan pitch yang sama (terutama strip serangan). Skala sebenarnya dipastikan +di langkah 6 lewat ukuran kepala. + +```python +from PIL import Image +from sprite_gen.frames.extract import estimate_pixel_grid_runlen +im = Image.open("raw-original/walk.png").convert("RGB") +px, py = estimate_pixel_grid_runlen(im.convert("RGBA")) +factor = TARGET / ((px + py) / 2) # TARGET mis. 0.5 → 1 px final = 2 pixel palsu +``` + +`TARGET` menentukan seberapa "kotak-kotak" hasil akhirnya: 0.5 = detail halus (hi-bit), 0.33 = lebih chunky. + +## 5. Extract + +Tulis strip hasil normalisasi ke `run/raw/.png`, lalu: + +```bash +sprite-gen extract --run-dir run +``` + +Harus `errors: []`. Warning `native logical exceeds the physical cap` = sel kekecilan → perbesar sel (langkah 2). + +## 6. Ukur + +Jalankan pengukuran di [sprite-qa.md](sprite-qa.md) untuk **setiap** gerakan — termasuk serangan, lompat, +dan pose yang dipakai skill. Yang dicari: +- skala tiap gerakan relatif terhadap idle frame 0 lewat **ukuran kepala** (`measure_head_scale.py`, di gambar + asli). Pitch yang sama **tidak** menjamin karakter sama besar. +- posisi horizontal badan (`torso_x`) per frame +- tinggi badan/recovery, crown→sabuk dan sabuk→kaki pada baseline yang sama. Kepala/rambut seragam saja pernah menyembunyikan tubuh ARCO yang mengecil. + +Hasil pencocokan kepala yang menangkap sepatu/bahu atau terhalang alat adalah tidak valid. Buka montage, pilih landmark sesuai karakter, lalu lakukan perbandingan badan penuh. Jangan menerapkan faktor skala besar dari hasil yang salah atau menyalin faktor ARCO ke karakter lain. + +## 7. Koreksi skala per pose — di gambar asli + +Kalau ada gerakan yang median skalanya di luar 0.97–1.03 (atau satu pose yang jelas beda): **bangun ulang** +`run/raw/.png` dari `raw-original/` +dengan faktor per pose = `factor_strip × koreksi_pose`, satu kali resample (BOX). Script: lihat +[sprite-qa.md → Perbaiki skala](sprite-qa.md#perbaiki-skala-di-gambar-asli). Lalu extract ulang gerakan itu. + +Catat semua nilai `--rel` di `rel.json` karakter. Contoh nyata (knight): +`idle [1,0.95,1,0.987]`, `walk 0.990`, `jump 1.032`, `attack1 1.059`, `attack2 1.075`, `attack3 0.974`. + +## 8. Sejajarkan dengan geser bulat + +Setelah skala benar, tulis `curation.json` hanya berisi geser **integer** (`dx`, bila perlu `dy`), `scale` = 1. +Hitung dx dengan registrasi badan atas ke idle frame 0 (script di sprite-qa.md). + +## 9. Compose + +```bash +sprite-gen compose-atlas --run-dir run +sprite-gen compose-gif --run-dir run --out-dir run/previews +``` + +Output: `sprite-sheet-alpha.png` + `manifest.json` (+ GIF preview per gerakan). + +## 10. QA & kirim ke game + +- Checklist [sprite-qa.md](sprite-qa.md) harus lolos **pada atlas final**. +- Salin atlas + manifest ke folder aset game. Jangan pernah mengambil dari `run/frames/` (belum ter-curation). +- Catat di `run/qa-notes.md`: provider, faktor skala, koreksi per pose, dx, hasil ukur. + +## Struktur folder yang disarankan + +``` +/ + base/ base-a.png (identitas) + raw-original/ strip asli dari generator — tidak pernah diedit + guides/ layout guide rasio generator + request.json + run/ run sprite-gen (raw/, frames/, curation.json, atlas, manifest, qa-notes.md) +``` + +## Windows + +- Set `PYTHONUTF8=1` sebelum memanggil `sprite-gen` (help/log memakai karakter unicode). +- Venv Windows ada di `.venv\Scripts`; bila dokumen menulis `.venv/bin`, buat junction `bin → Scripts`. diff --git a/guide/sprite-prompts.md b/guide/sprite-prompts.md new file mode 100644 index 0000000..a214a16 --- /dev/null +++ b/guide/sprite-prompts.md @@ -0,0 +1,179 @@ +# Template Prompt Sprite + +Format final proyek: [character-workflow.md](character-workflow.md). Default walk/run empat frame; jump dan doublejump masing-masing satu pose. Bagian enam-frame walk di bawah adalah opsi pengembangan, bukan default template. + +Ganti bagian `<…>`. Tulis prompt dalam bahasa Inggris (model gambar paling patuh dengan bahasa Inggris). + +## Prinsip + +- **Deskripsi fisik karakter hanya ditulis lengkap di prompt base.** Di prompt strip, cukup "match reference 1 + exactly" + palet singkat. Deskripsi panjang di prompt strip bersaing dengan referensi dan mengubah identitas. +- **Prompt strip hanya tentang gerakan + aturan layout.** +- Sebut jumlah pose, arah hadap, "same scale / same ground line", dan larangan efek lepas di **setiap** strip. +- Minta tinggi karakter dalam persen tinggi gambar (mis. 60–70 %) supaya pose tidak terpotong dan ada jarak antar pose. + +## Base + +``` +Game sprite, full-body side view of , standing in a relaxed idle pose, +facing RIGHT (side view as in a 2D side-scrolling platformer). + +Character design: . +. +The whole body<, weapon, cape, hair> fully visible and uncropped, centered, occupying about 70% of the image +height, feet on an invisible ground line near the bottom. +Background: perfectly flat solid pure , no gradient, no floor, no shadow, no text, no border. +Do not use any on the character. +``` + +Setting: rasio 1:1, kualitas high. Generate 2–4 varian, pilih satu. + +## Strip gerakan + +Lampirkan: **referensi 1 = base**, **referensi 2 = layout guide (N kotak)**. Rasio: selebar yang didukung (mis. 21:9). + +``` +Create a single horizontal sprite strip for the 2D side-scrolling game character '' in the state ''. + +Reference 1 = the canonical character design (identity). Reference 2 = layout guide, used ONLY for frame +count, slot spacing, centering and safe padding; never draw the guide itself. + +Style contract: match reference 1 EXACTLY: same pixel density (logical pixel block size), same body +proportions, same outline weight, same palette (), same shading, same detail level. +Do not restyle, do not change proportions. Same props: . + +Animation action: . + +Rules: This row owns motion only; keep identity identical in every frame. No detached effects, no motion +lines, no smears, no afterimages, no glows, no floor shadows, no dust, no text, no labels, no frame numbers, +no grid, no scenery. + +Layout: exactly full-body poses, left to right, in one horizontal row, all facing RIGHT. Treat the image +as equal-width invisible slots; center exactly one complete, uncropped pose in each slot, all at the +same scale and on the same ground line, each pose about <60–70>% of the image height, with clear + gap between neighbouring poses. No pose (including ) may touch or overlap a +neighbouring pose. Background: perfectly flat pure across the whole image. +Do not use on the character. Output only the sprite strip image. +``` + +## Contoh deskripsi gerakan + +| State | Frame | Deskripsi | +|---|---|---| +| idle | 4 | subtle breathing: chest rises and falls, cape sways slightly, feet planted in the same place in every frame; frames 1 and 4 connect into a loop | +| walk | 4 | near-leg contact, far-leg passing dengan kaki rapat, far-leg contact, near-leg passing | +| run | 4 | pola contact/passing sama, torso condong dan langkah sprint; jangan duplikasi walk | +| jump | 1 | pose udara atletis dengan siku dan lutut menekuk; lintasan naik/turun dari fisika | +| doublejump | 1 | pose tuck dengan kedua lutut rapat; rotasi penuh dari runtime | +| attack ringan | 4 | 1 wind-up (weapon pulled back), 2 swing forward, 3 full extension, 4 recover to guard; feet roughly in place | +| attack berat | 4 | 1 weapon raised high, 2 swinging down, 3 impact low in front, 4 recover | +| tusukan | 4 | 1 weapon pulled back at hip, 2 step forward, 3 full lunge straight forward, 4 back to guard | + +### Opsi enam frame untuk memperbaiki walk + +Model sering menggambar 6 pose "kaki terbuka" yang mirip tanpa pergantian kaki. Sebut kaki mana di depan per +frame, dan lampirkan strip walk yang sudah benar (karakter lain boleh) sebagai **referensi 3 = gerakan saja**: + +``` +Reference 3 = a walk cycle of a different character: use it ONLY as a motion reference for how the legs +alternate (leg timing and foot contacts). Do NOT copy its character, armor, colors or weapon. + +A TRUE ALTERNATING 6-frame walk cycle moving to the RIGHT. The two legs MUST swap: the far leg (his LEFT +leg, slightly darker) and the near leg (his RIGHT leg, lighter) take turns being in front: +- frame 1: CONTACT — near RIGHT leg forward with heel down, far LEFT leg behind on its toes, legs wide apart +- frame 2: DOWN — weight on the right leg, knee bent, left leg starting to lift behind +- frame 3: PASSING — left leg swinging forward and crossing next to the right leg, knees close together +- frame 4: CONTACT — far LEFT leg now forward, near RIGHT leg behind on its toes (mirror of frame 1) +- frame 5: DOWN — weight on the left leg, right leg lifting behind +- frame 6: PASSING — right leg swinging forward crossing next to the left leg, knees close together +In frames 3 and 6 the feet must be close together. In frames 1 and 4 a DIFFERENT leg is in front. +``` + +Generate 2 varian, cek dengan `measure_atlas.py` (baris "jarak kaki": harus lebar-rapat-lebar-rapat). + +## Tips mengurangi frame beda ukuran sejak awal + +- Tulis **"all at the same scale and on the same ground line"** dan **"about X% of the image height"** di setiap strip. +- Untuk idle, tulis **"feet planted in the same place in every frame"** — model cenderung "zoom" frame napas. +- Pose dengan senjata terangkat membuat model mengecilkan badan agar muat → minta persen tinggi yang + sudah memperhitungkan senjata ("at most 70% including the raised sword"). +- Tambahkan di strip aksi: **"the character's body and head must be exactly the same size as in reference 1; + if the weapon does not fit, make the weapon overlap the slot padding — never shrink the character."** + Tetap wajib diukur (sprite-qa.md) — kalimat ini mengurangi, bukan menghilangkan, masalahnya. +- Tetap anggap hasilnya **tidak** konsisten 100 %: selalu ukur (sprite-qa.md). + +## Base karakter baru: identitas berbeda secara eksplisit + +Jangan lampirkan gambar karakter roster lama pada generasi base baru. Isi semua pembeda berikut secara konkret; jangan hanya menulis "different character": + + Create an original fighter from scratch. Shared art style: + fine hi-bit pixel clusters, crisp dark outlines, rich shading, anime chibi anatomy. + Unique silhouette: . Hair: . Face: . + Dominant palette: . Outfit construction: . + Anatomy / fighting tools: . Signature accessory: . + EXCLUDE these existing-roster identity features: . + Do not create a recolor or add animal ears to an existing design. + Keep the full body, ears, tail, weapons and mechanical parts visible, facing RIGHT. + Pure flat background; no floor, shadows, scenery, text or effects. + +Sesudah base baru dikunci, base itu menjadi referensi identitas semua row. Untuk Demi-Human, telinga/ekor menambah silhouette, bukan alasan mengecilkan badan agar tinggi totalnya sama dengan rambut ARCO. Periksa pembeda visual terhadap roster sebelum membuat animasi. + +## Jump final: satu pose atletis, bukan idle melayang + +Referensi1=base baru; referensi2=idle karakter yang sama untuk proporsi. + + ONE SINGLE full-body sprite of the same character as reference 1; reference 2 is + the idle anatomy reference. Match head-to-torso size, neck-to-belt length, limb + lengths, palette, outline weight and pixel density exactly. Facing RIGHT. + A natural athletic airborne JUMP: slight forward torso lean, bent elbows with + weapon/large arm near the ribs for balance; near knee moderately lifted with + lower leg pointing DOWN; far leg relaxed behind with a soft knee bend, boot down. + No rigid standing-hover pose, no arms hanging straight, no deep crouch or tuck, + no straight kicking leg. The game will HOLD this one pose during the flight arc. + Complete uncropped body and accessories, centered, about 70 percent canvas height. + Pure flat background. No shadow, ground, glow, particles, text, or guides. + +Rasio1:1. Jangan menambah beberapa gerakan hanya untuk membuat jump terlihat hidup. Pilih pose yang sudah atletis, lalu periksa ukuran badan/kepalanya terhadap idle pada baseline sama. + +## Double jump final: satu pose menggulung + + ONE SINGLE full-body airborne TUCK pose of the exact same character as reference 1. + Both knees pulled TOGETHER toward the chest, both arms and accessories close around + the folded legs, boots together. Compact rounded silhouette for a forward somersault. + Head initially upright and visible, facing RIGHT; the game rotates the WHOLE pose. + Keep the same head size, torso thickness and anatomical limb lengths as idle. + Limbs fold through their joints; do not shrink the character. No extended kick, + no punch, no running stride, no detached effects or duplicate limbs. + Generous padding on pure flat . No shadow, glow, scenery, text, or guides. + +Rasio1:1. Tuck boleh lebih pendek sebagai silhouette karena lutut ditekuk. Jangan membesarkannya sampai tinggi idle; ukur kepala/torso, lalu pilih pivot yang menjaga perpindahan pusat tubuh/head tetap wajar. + +## Recovery dan pose ultimate tidak mengecil + +Untuk row aksi tambahkan: + + Preserve the same anatomical scale in every pose. The final upright recovery must + match the idle reference in head size, shoulder width, crown-to-belt span and leg + length. Never compress the torso or shorten the legs to fit an extended weapon. + Maintain clear blank gaps between poses; no limb may touch a neighboring pose. + +Untuk summon gunakan empat pose command singkat, bukan empat gambar VFX: + + Four poses: upright preparation, raise/brace the casting arm, open hand or command + gesture, return to the original upright idle stance. Knees only slightly soft; + keep head and pelvis height consistent. No summoned creatures, drones, laser, + magic circle, aura or particles in the character row. + +Pose rebah/serangan memanjang sering perlu persen tinggi sumber lebih kecil (contoh35–50%) agar badan horizontal muat di slot. Minta **semua pose pada skala anatomi sama**, lalu lakukan normalisasi dari original; jangan membiarkan model mengecilkan hanya pose rebah/impact. + +## Portrait, cut-in, ikon, dan prop + +Gunakan Seedream sesuai [character-workflow.md](character-workflow.md). Referensi identitas boleh base, tetapi hasil harus ilustrasi baru, bukan crop sprite lama. + +- Portrait1:1: close-up wajah/bahu, ekspresi khas, siluet terbaca dalam frame HUD kecil, palet karakter konsisten; tanpa border/teks yang akan dibuat oleh UI. +- Cut-in21:9: wajah/gesture di kiri, ruang gelap di kanan untuk judul HTML; komposisi dinamis, tanpa tulisan. Desain baru sesuai karakter. Referensi game lain hanya pola presentasi banner, bukan materi yang disalin. +- Ikon1:1: satu simbol serangan yang terbaca pada 64 px, background navy gelap, tidak ada angka/keybind/border. Buat gambar berbeda untuk setiap skill. +- Prop summon: objek penuh, arah/muzzle jelas, background chroma, tanpa laser/trail baked. Setelah cutout, ukur titik keluarnya efek pada aset final. + +Prompt aktual ARCO tersedia pada log yang ditautkan di [arco-reference.md](arco-reference.md). Gunakan sebagai contoh struktur; ganti identitas, anatomi, gesture, dan palet untuk karakter baru. diff --git a/guide/sprite-qa.md b/guide/sprite-qa.md new file mode 100644 index 0000000..2ceb546 --- /dev/null +++ b/guide/sprite-qa.md @@ -0,0 +1,159 @@ +# Sprite QA — supaya tidak ada frame yang besar sendiri + +Mata manusia baru sadar frame "berdenyut" setelah dimainkan. Jadi sprite **diukur dulu** sebelum masuk game. +Semua pemeriksaan dilakukan pada **atlas final** (`sprite-sheet-alpha.png` + `manifest.json`), bukan `frames/`. + +Untuk proyek ini baca [character-workflow.md](character-workflow.md) terlebih dahulu. Angka toleransi di bawah adalah target untuk landmark yang valid. Hasil template yang menangkap bahu/sepatu atau gagal karena rotasi harus dicatat sebagai tidak terukur; jangan dilaporkan lolos hanya berdasarkan median lain. + +Tool ada di [tools/](tools/). Jalankan dengan Python dari venv sprite-gen (butuh Pillow, NumPy, `sprite_gen`): + +```bash +PY=/.venv/bin/python # Windows: .venv\Scripts\python.exe, set PYTHONUTF8=1 +``` + +## Checklist (wajib lolos semua) + +**Skala antar gerakan — SEMUA gerakan, termasuk serangan, lompat, skill** +- [ ] `measure_head_scale.py` dijalankan untuk **setiap** gerakan terhadap idle frame 0: + median tiap gerakan **0.97–1.03** +- [ ] `head-check.png` dilihat: semua kepala tampak sama besar dengan TEMPLATE; frame yang kotaknya bukan + kepala dicatat sebagai "tidak terukur" dan dicek dengan perbandingan badan penuh (lihat langkah 2) +- [ ] Setiap menambah gerakan baru, cek ini diulang untuk gerakan itu (jangan anggap aman karena + gerakan lain sudah lolos) + +**Frame sambungan — frame yang bertemu idle (awal & akhir serangan/kombo, akhir hurt)** +- [ ] Ukur badan penuh dan crown→sabuk→kaki, bukan kepala/rambut saja. Pada ARCO, kepala seragam pernah menyembunyikan recovery 170–176 px dibanding idle 180 px. +- [ ] Frame **pertama dan terakhir** tiap gerakan aksi diukur sendiri terhadap idle frame 0 di atlas + (bukan cuma median strip): kepala 0.97–1.03, dan untuk pose tegak tinggi helm & helm→sabuk selisih ≤ 3 px +- [ ] Posisi kepala frame terakhir sama dengan idle (± 2 px) — kalau tidak, geser bulat (`write_dx.py`) +- [ ] Dilihat berdampingan: frame terakhir | idle f0 dengan garis tinggi yang sama (known-issues #17) + +**Per gerakan tegak (idle, walk, run)** +- [ ] Tinggi frame idle selisih **≤ 2 px** satu sama lain +- [ ] Posisi badan (`torso_x`) tidak goyang lebih dari **±1.5 px** dalam satu gerakan +- [ ] Naik-turun badan saat jalan (bob) wajar, **bukan** hasil skala yang berbeda +- [ ] Walk/run: **kaki bergantian** — `measure_atlas.py` baris "jarak kaki" lebar→rapat→lebar→rapat, + rasio terkecil/terbesar ≤ 0.45 (kalau tidak: generate ulang, lihat known-issues #15) +- [ ] Walk/run di game: frame maju berdasarkan jarak tempuh (`stride`), bukan fps tetap + +**Makhluk terbang / tanpa kaki** +- [ ] Bagian yang seharusnya diam (kepala saat hover, badan saat menyerang) tidak bergeser > 2 px antar frame +- [ ] Tidak ada piksel di tepi sel (sayap/ekor terpotong) + +**Semua gerakan** +- [ ] Jump hanya memakai pose atletis yang dipilih; tidak replay crouch di udara. Tuck doublejump tidak memiliki kaki menendang. +- [ ] Normal jump → tuck → spin → kembali jump dilihat di runtime dengan pivot terukur dan skala konstan. +- [ ] Source, request, jumlah frame, manifest, wrapper JS, metrics, dan titik efek sama-sama versi terbaru. +- [ ] Gerakan baru dibuat dengan base dari kartu karakter yang sama (bukan base baru / deskripsi teks saja) +- [ ] Kaki semua frame di baris yang sama (`bottom` sama) — untuk karakter yang berdiri +- [ ] Tidak ada frame kosong / terpotong / pose menempel ke tetangga +- [ ] Tidak ada sisa warna chroma (pinggiran magenta/hijau) — lihat di atas latar gelap **dan** terang +- [ ] Ketajaman sama di semua frame (tidak ada frame yang sedikit buram) — artinya tidak ada `scale ≠ 1` + atau `dx` pecahan di `curation.json` +- [ ] Identitas konsisten: warna, senjata di tangan yang sama, aksesori tidak hilang +- [ ] GIF preview tiap gerakan dilihat sekali dengan kecepatan asli dan 0.5× + +**Terakhir** +- [ ] `qa-notes.md` diisi: faktor skala, koreksi per pose, dx, hasil `measure_atlas.py` +- [ ] Pisahkan bukti angka, contact sheet/screenshot, dan loop yang benar-benar ditonton. Jangan menyebut full motion sudah mulus bila baru keyframe yang diperiksa. +- [ ] Jika menggunakan tool landmark putih ARCO untuk karakter berambut gelap/bertanduk, ganti landmark; jangan membiarkan alat memilih armor sebagai rambut. + +## Langkah pemeriksaan + +### 1. Ukur atlas + +```bash +$PY guide/tools/measure_atlas.py run +``` + +Contoh keluaran yang **baik**: + +``` + idle 0: h=141 bottom=199 head_w=38 torso_x=112.0 ok + idle 1: h=140 bottom=199 head_w=40 torso_x=111.6 ok + ... + rentang tinggi 140–141 rentang torso_x 111.6–112.3 +SEMUA LOLOS +``` + +Contoh yang **buruk** (kasus nyata sebelum diperbaiki): idle frame 1 `h=149` sementara lainnya `142` +→ karakter terlihat membesar sekali tiap siklus napas. + +### 2. Cek skala SEMUA gerakan lewat kepala (di gambar asli) + +Aturan: **samanya ukuran pixel palsu (pitch) TIDAK berarti samanya ukuran karakter.** Model bisa menggambar +strip serangan dengan karakter lebih kecil (supaya pedang terangkat muat di slot), dengan pitch yang sama. +Jadi setiap gerakan diukur terhadap idle memakai bagian yang bentuknya tetap: **kepala/helm**. + +Simpan `--rel` yang sedang dipakai di `rel.json` (satu sumber kebenaran, juga dicatat di kartu karakter): + +```json +{ "idle": [1, 0.95, 1, 0.987], "walk": [0.990], "jump": [1.032], + "attack1": [1.059], "attack2": [1.075], "attack3": [0.974] } +``` + +```bash +$PY guide/tools/measure_head_scale.py --poses idle:4,walk:6,jump:4,attack1:4,attack2:4,attack3:4 \ + --rel-json rel.json --landmark white [--head-box x0,y0,x1,y1] +``` + +``` + attack1 0: kepala di atlas = 0.878 × idle (skor 0.45) + ... + median 0.944 → PERLU KOREKSI; --rel baru 1.059 +``` + +- Pengukuran di **gambar asli** (kepala ±120 px) dengan pencocokan **tepi** — di atlas kepala cuma ±20 px dan + model memberi pencahayaan berbeda per strip, jadi pencocokan warna/siluet di atlas tidak bisa dipercaya. +- `--head-box`: kotak kepala di pose acuan (lihat tile TEMPLATE di `head-check.png`; harus berisi kepala saja). +- `--landmark white`: untuk karakter dengan bulu helm/rambut putih, mencegah kepala tertukar dengan bahu. +- **Wajib buka `head-check.png`.** Tiap kepala dikembalikan ke ukuran template memakai skala terbaca; + kalau benar, semuanya sama besar. Kotak yang bukan kepala = "TIDAK VALID" → frame itu dinilai dengan + median gerakannya, lalu dicek manual dengan perbandingan badan penuh berjajar (kaki di garis yang sama). +- Koreksi **per gerakan** (satu angka untuk satu strip), bukan per frame, kecuali frame idle yang jelas beda. + +Jangan pakai registrasi siluet (`register_frames.py` tanpa `--dx-only`) untuk menilai skala pose aksi: +lutut ditekuk menurunkan kepala dan alat "membesarkan" karakter untuk mengejarnya; lengan/senjata juga +mengacaukan kecocokan. `register_frames.py` hanya untuk mencari **geser bulat** pada pose tegak. + +## Perbaiki skala di gambar asli + +```bash +# satu angka --rel = strip diperkecil utuh (aman walau pose di gambar asli saling menempel) +$PY guide/tools/rebuild_raw.py raw-original/attack1.png run/raw/attack1.png --poses 4 --rel 1.059 +# angka per pose (khusus frame yang beda sendiri, mis. idle) +$PY guide/tools/rebuild_raw.py raw-original/idle.png run/raw/idle.png --poses 4 --rel 1,0.95,1,0.987 +# pose saling menempel juga bisa: dipotong di titik sentuh, hanya pose dengan rel berbeda yang ditempel ulang +$PY guide/tools/rebuild_raw.py raw-original/combo3.png combo-run/raw/combo3.png --poses 6 --rel 1.143,1.143,1.143,1.143,1.143,1.223 +sprite-gen extract --run-dir run --states idle,attack1 +``` + +- `--rel` di `rel.json` selalu relatif terhadap **gambar asli** (bukan terhadap raw sebelumnya). + `measure_head_scale.py` sudah memperhitungkan rel yang sedang dipakai dan mencetak `--rel baru`. +- Setelah extract ulang → ukur lagi sampai semua median 0.97–1.03 → **ukur ulang hitbox & titik efek** di game + (lihat [sprite-runtime.md](sprite-runtime.md#hitbox-dari-data-bukan-tebakan)). + +## Perbaiki goyang dengan geser bulat + +```bash +$PY guide/tools/register_frames.py run walk --dx-only # → dx bulat: 1,-5,-4,0,0,-4 +$PY guide/tools/write_dx.py run walk 1,-5,-4,0,0,-4 +sprite-gen compose-atlas --run-dir run +$PY guide/tools/measure_atlas.py run # harus SEMUA LOLOS +``` + +Geser bulat = piksel dipindah utuh, tanpa blur (sudah diverifikasi: selisih piksel ≤ 2/255). + +## Yang TIDAK boleh dilakukan + +| Jangan | Kenapa | +|---|---| +| Koreksi ukuran dengan `scale` di `curation.json` | Di-resample bicubic → frame itu sedikit buram → berkedip tajam/buram saat animasi | +| `dx`/`dy` pecahan (0.5, 1.5) | Sama: resample → buram sebelah | +| Mengecilkan tiap frame agar muat sel | Tiap frame dapat skala berbeda → karakter berdenyut. Perbesar sel | +| Memeriksa di `frames/` | Belum termasuk curation; yang dipakai game adalah atlas | +| Menilai hanya dengan mata di gambar diam | Selisih 2 px tidak terlihat di atlas, tapi terasa saat animasi | +| Menyembunyikan pose jelek dengan mengubah fps | Masalah tetap ada; generate ulang strip itu | +| Menganggap strip "sudah sama skala" karena pitch-nya sama | Pitch sama ≠ karakter sama besar; serangan bisa 6–15 % lebih kecil | +| Hanya mengecek idle & walk | Justru serangan/skill yang paling sering meleset (pose senjata terangkat) | +| Percaya angka alat tanpa membuka `head-check.png` | Alat bisa menangkap bahu/lambang sebagai "kepala" dan tetap mencetak angka | diff --git a/guide/sprite-runtime.md b/guide/sprite-runtime.md new file mode 100644 index 0000000..f1c450d --- /dev/null +++ b/guide/sprite-runtime.md @@ -0,0 +1,101 @@ +# Memakai Atlas di Game + +Kontrak proyek terbaru: [gameplay-standard.md](gameplay-standard.md) dan [character-workflow.md](character-workflow.md). Saat ini loader masih satu karakter ARCO; format baru tidak otomatis menjadi registry multi-karakter. + +Atlas yang sudah lolos QA masih bisa terlihat salah kalau cara menggambarnya keliru. Aturan di sini +berlaku untuk canvas 2D, Phaser, PixiJS, Godot, Unity, dll. + +## File yang dipakai + +- `sprite-sheet-alpha.png` — semua frame, latar transparan +- `manifest.json` — koordinat tiap frame + +Salin keduanya dari **run** (hasil `compose-atlas`), bukan dari `frames/`. Setiap kali atlas di-compose ulang, +salin ulang **keduanya** bersamaan. + +Bila game dibuka lewat `file://` (tanpa server), `fetch` JSON diblok browser: bungkus manifest jadi file JS +(`window.SPRITE_MANIFEST = {...}`) yang dibuat otomatis dari `manifest.json` — jangan diedit manual. + +## Baca koordinat dari manifest, jangan menebak grid + +```js +const r = manifest.frame_layout.rows[state][frameIndex]; // {x, y, w, h} +const fps = manifest.animation.rows[state].fps; +const loop = manifest.animation.rows[state].loop; +``` + +Jumlah frame per gerakan bisa berubah setelah curation — selalu ambil dari manifest. + +## Anchor di kaki + +Frame sudah disejajarkan sehingga **kaki** berada di: + +``` +anchorX = cellWidth / 2 +anchorY = cellHeight - safe_margin_y // dari manifest.cell +``` + +Posisi karakter di dunia game = posisi kaki. Gambar frame dengan offset `(-anchorX, -anchorY)`. + +```js +ctx.save(); +ctx.translate(Math.round(x), Math.round(y)); // x,y = kaki +ctx.scale(facing, 1); // facing: 1 kanan, -1 kiri +ctx.drawImage(sheet, r.x, r.y, r.w, r.h, -anchorX, -anchorY, r.w, r.h); +ctx.restore(); +``` + +- **Flip kiri/kanan di sekitar anchor** (bukan di sekitar pojok sel) → karakter tidak "teleport" saat berbalik. +- Semua sprite digambar menghadap kanan; arah kiri selalu hasil flip. + +## Jangan menambah blur di runtime + +| Aturan | Canvas 2D | Lainnya | +|---|---|---| +| Matikan smoothing | `ctx.imageSmoothingEnabled = false` + CSS `image-rendering: pixelated` | Phaser `pixelArt: true`; Pixi `SCALE_MODES.NEAREST`; Godot filter *Nearest*; Unity Filter Mode *Point*, Compression *None* | +| Posisi dibulatkan | `Math.round(x)` saat menggambar (simulasi boleh float) | "pixel snap" / round di shader | +| Skala sprite lokal konstan | 1× pada ARCO; jangan mengubah skala per pose | sama | +| Proyeksi viewport seragam | Dunia dapat diproyeksikan fractional untuk responsif, tetapi x/y memakai rasio yang sama | jangan meregangkan badan | +| Efek tidak mengubah ukuran tubuh | Tanpa squash/stretch per state; VFX terpisah | sama | + +Kalau salah satu dilanggar, frame yang sudah rapi di atlas bisa terlihat berbeda ketajamannya di layar — +gejalanya persis seperti masalah ukuran frame. + +## Kecepatan animasi + +- Pakai `fps` dari manifest sebagai default untuk gerakan di tempat (idle, serang, hurt). +- **Jalan/lari: frame maju berdasarkan jarak yang ditempuh**, supaya kaki yang menapak tidak meluncur: + + ```js + // stride = jarak satu siklus penuh (2 langkah) ≈ 2 × jarak kaki saat menapak (lihat measure_atlas.py) + walkPhase += dt * (Math.abs(vx) / stride) * frameCount; + frame = Math.floor(walkPhase) % frameCount; + ``` + + Ukur `stride` dari atlas (jarak kaki terbesar × 2), jangan ditebak. Kalau hasil fps terlalu lambat/cepat, + ubah **kecepatan gerak** karakter, bukan fps-nya. Contoh: orc stride 208 px, jalan 170 px/s → ±4.9 fps. + + Nilai dari lebar siluet kaki adalah **estimate**, bukan pengukuran stride same-foot contact yang terverifikasi. Runtime ARCO memakai estimate tersebut; jangan menyebutnya gait yang sudah tersertifikasi mulus. Periksa pose contact/passing dan pergantian kaki secara visual. +- Gerakan non-loop (`loop: false`) berhenti di frame terakhir, lalu kembali ke idle. + +## Hitbox dari data, bukan tebakan + +Ukur jangkauan senjata dari atlas (bounding box alpha tiap frame relatif anchor) dan simpan sebagai angka di +kode. Setelah atlas di-compose ulang dengan skala berbeda, **ukur ulang** hitbox. + +## Checklist integrasi + +- [ ] Atlas dan manifest disalin bersamaan dari run terbaru +- [ ] Anchor = (cellWidth/2, cellHeight − safe_margin_y) +- [ ] Flip dengan `scale(-1, 1)` di anchor +- [ ] Smoothing mati, posisi sprite dibulatkan, skala tubuh konstan dan proyeksi viewport seragam +- [ ] Kecepatan walk ikut kecepatan gerak + +## Playback khusus ARCO yang menjadi baseline + +- Jump menahan satu pose atletis pada rise/apex/fall; tidak replay crouch di udara. +- Doublejump memutar satu tuck pose360° dalam 0,28 s. Baca pivot lokal dan world-pivot dari metrics, lalu ukur ulang untuk karakter baru. +- Skill I menampilkan pose persiapan/tembak sebelum projectile release, lalu melepas input saat peluru keluar; tidak menunggu frame recovery nominal habis. +- Ultimate menggunakan pose cast 0,50 s, sementara drone/cut-in memakai timeline terpisah2,85 s. Jangan mengunci owner sepanjang durasi efek. +- `MECHA_METRICS.playback`, `states`, dan `emitters` melengkapi manifest. Koordinat emitter relatif anchor, bukan koordinat layar. +- Salto, facing flip, dan transform viewport tidak boleh menyebabkan per-state scale yang berbeda. diff --git a/guide/stage-background.md b/guide/stage-background.md new file mode 100644 index 0000000..0732bbb --- /dev/null +++ b/guide/stage-background.md @@ -0,0 +1,102 @@ +# Latar Stage Game Fighting + +Arena aktif: Bellora, Sunspire, Azure Harbor (groundY 599), Elderwood Glade (606) dan Moonrise Bastion (618, malam bulan purnama). Untuk arena malam, minta lantai tetap diterangi cahaya bulan/lentera agar sprite siang tetap terbaca. Untuk proyek ARCO, acuan final ada di [gameplay-standard.md](gameplay-standard.md): dunia1280×720, stage Bellora varian B, groundY599 dari band lantai528–719. Gambar utuh dipakai pada rasio 16:9; viewport lain memakai cover yang sejajar lantai, sementara karakter diproyeksikan seragam. Ini penyesuaian responsif, bukan kamera mengikuti pemain/parallax. + +Panduan membuat latar (stage) untuk game **fighting 2D satu layar**: dua petarung di satu arena, kamera diam. +Berlaku untuk game lain juga — ganti tema (padang, dojo, pantai, reruntuhan) tapi pertahankan aturannya. + +## Gaya yang dipakai + +| Hal | Keputusan | +|---|---| +| Jumlah gambar | **Satu gambar utuh** (langit, latar jauh, dan lantai digambar bersamaan) — bukan layer terpisah | +| Kamera | **Diam.** Tidak ada scrolling, tidak ada parallax. Arena = satu layar | +| Rasio | Sama dengan resolusi game (mis. 960×540 → **16:9**) supaya bisa dipakai utuh tanpa dipotong | +| Model | **Seedream 5.0 Pro** (`seedream_v5_pro`, 16:9, 2k) — komposisi scene utuh paling rapi. Hasilnya gaya ilustrasi lukis halus | +| Lantai | Pita tanah/arena **terlihat dari samping-agak-atas**, memanjang selebar layar, di ±75–90 % tinggi gambar | +| Tengah layar | Kosong/lapang — tidak ada objek besar di depan yang menutupi petarung | + +### Kenapa bukan parallax / tanah bergeser + +Dicoba dulu: latar jauh + tanah terpisah dengan parallax dan kamera mengikuti pemain. Hasilnya **memusingkan** +untuk game fighting — pemain perlu melihat seluruh arena dan lawan sekaligus. Genre fighting = satu tempat diam. + +### Kenapa lantai bukan potongan tanah + +Dicoba dulu: strip tanah berupa penampang (rumput di atas, tanah + batu + akar di bawah). Itu gaya platformer. +Stage fighting butuh **lantai tempat berdiri** yang terlihat dari samping-atas (seperti panggung), bukan dinding tanah. + +## Prompt + +Lampirkan **gambar base karakter utama sebagai referensi gaya** (bukan identitas). Ganti bagian `<…>`. + +``` +Reference image is a STYLE reference only (a sprite from the same game): match its art style, +color richness and shading. Do NOT draw the character or any character. + +Create ONE complete, single-screen background for a 2D side-view FIGHTING GAME stage, , in one coherent image (not layers). Composition from top to bottom: ; ; ; then +. The bottom 30% of the image is the fighting floor seen from the side at a +slightly elevated angle: a wide horizontal band of +running across the entire width (centered at about 80% of the image height) where two fighters will stand, +, bordered by in +front and behind. The floor is flat and level, NOT a cross-section, no underground layers. Keep the middle +of the image open and uncluttered for gameplay, nothing large in the foreground. No characters, no +animals, no text, no UI, no border. +``` + +- Generate **2 varian** sekaligus (`count: 2`), pilih yang komposisinya paling enak. Biaya ±2.5 kredit/gambar. +- Landmark (kastil, kuil, menara) taruh di **sepertiga kiri/kanan**, jangan di tengah. +- Untuk tema lain cukup ganti isi `<…>`; kalimat soal lantai, tengah kosong, dan "NOT a cross-section" jangan dihapus. + +## Memasang di game + +1. **Skala** gambar ke resolusi game dengan resample BOX (mis. 2720×1536 → 960×540). Jangan crop kecuali rasionya beda. +2. **Cari garis kaki** — baris tengah pita lantai. Ukur, jangan tebak: + + ```python + import numpy as np + from PIL import Image + a = np.array(Image.open("stage.png").convert("RGB")).astype(int) + r, g, b = a[..., 0], a[..., 1], a[..., 2] + dirt = ((r > g) & (r > b + 40)).mean(1) # baris yang >50 % warnanya tanah/coklat + rows = [y for y in range(len(dirt)) if dirt[y] > 0.5] + print("pita lantai", rows[0], "-", rows[-1], "tengah", (rows[0] + rows[-1]) // 2) + ``` + + Set `GROUND_Y` (garis kaki petarung) di dalam pita itu, sedikit di bawah tengahnya. Untuk lantai yang + bukan coklat (batu, kayu), ganti rumus warnanya. +3. **Kamera diam**, arena selebar layar; petarung dibatasi di tepi kiri/kanan (mis. 40 px dari tepi). +4. **Screen shake** menggeser seluruh stage — gambar tepi terluar gambar direntangkan ±40 px ke luar layar + supaya getaran tidak memperlihatkan pinggiran kosong: + + ```js + ctx.drawImage(img, 0, 0, 1, H, -M, 0, M, H); // kiri + ctx.drawImage(img, W - 1, 0, 1, H, W, 0, M, H); // kanan + ctx.drawImage(img, 0, 0, W, 1, -M, -M, W + 2 * M, M); // atas + ctx.drawImage(img, 0, H - 1, W, 1, -M, H, W + 2 * M, M); // bawah + ctx.drawImage(img, 0, 0); + ``` + +5. **Efek yang menggelapkan layar** (skill besar) → overlay gelap di atas stage, di bawah karakter dan efek. +6. Simpan beberapa varian dan pilih lewat parameter (mis. `?stage=`) supaya mudah dibandingkan di game. + +## Konsistensi gaya dengan sprite + +- Seedream 5.0 Pro menghasilkan **ilustrasi lukis**, walau diberi referensi gaya pixel. Sprite pixel art di + atasnya tetap terbaca jelas (kontras tinggi, outline gelap) dan hasilnya diterima untuk proyek ini. +- Kalau proyek butuh latar **pixel art ketat**: pakai `gpt_image_2_5` (21:9 lalu crop, atau 16:9) dengan prompt + yang sama — lebih patuh pada gaya pixel, komposisi sedikit kurang rapi. +- Karakter besar (bos/naga) yang muncul di stage: generate dari deskripsi gaya bersama dan identitas baru yang eksplisit, tanpa gambar karakter roster lama + (lihat [sprite-pipeline.md](sprite-pipeline.md#karakter-kedua-dan-seterusnya-supaya-satu-game-satu-gaya)). + +## Checklist stage + +- [ ] Satu gambar, rasio = resolusi game, dipakai utuh +- [ ] Pita lantai terlihat dari samping-atas, selebar layar; bukan penampang tanah; tidak ada platform melayang +- [ ] Tengah layar lapang; landmark di sepertiga kiri/kanan +- [ ] `GROUND_Y` diukur dari pita lantai; kaki petarung terlihat berdiri di atas lantai +- [ ] Kamera diam; screen shake tidak memperlihatkan tepi kosong +- [ ] Sumber, job id, prompt, dan varian yang dipilih dicatat (mis. `background/layers.md`) diff --git a/guide/templates/balance-reference.json b/guide/templates/balance-reference.json new file mode 100644 index 0000000..2def469 --- /dev/null +++ b/guide/templates/balance-reference.json @@ -0,0 +1,38 @@ +{ + "_note": "Reference data only. The current runtime reads BALANCE from game.js; this is not an implemented external config loader.", + "combo": [ + { + "damage": 6, + "knockback": 75 + }, + { + "damage": 8, + "knockback": 100 + }, + { + "damage": 12, + "knockback": 180 + } + ], + "skill1": { + "damage": 16, + "cooldown": 3, + "knockback": 140 + }, + "skill2": { + "damage": 24, + "cooldown": 6, + "knockback": 220, + "radius": 220 + }, + "ultimate": { + "damagePerDrone": 12, + "cooldown": 18, + "castTime": 0.5, + "knockback": 190 + }, + "dummyMax": 200, + "heroMax": 200, + "hpPerBar": 100, + "basicCooldownRefund": 0.05 +} diff --git a/guide/templates/character-card.md b/guide/templates/character-card.md new file mode 100644 index 0000000..274d8dd --- /dev/null +++ b/guide/templates/character-card.md @@ -0,0 +1,82 @@ +# Kartu karakter: + +Status: TEMPLATE — isi semua placeholder. Salin ke assets//character.md. Acuan bersama berada di guide/gameplay-standard.md dan guide/character-workflow.md; angka koreksi ARCO tidak otomatis berlaku untuk karakter ini. + +## Identitas terkunci + +| Field | Isi | +| --- | --- | +| ID folder / nama tampilan | / | +| Kelas | Mecha / Demi-Human / kelas lain yang diminta | +| Ciri anatomi dan siluet | | +| Palet dan outline | | +| Tangan/sisi aksesori | | +| Base terpilih | base/base-selected.png | +| Base job ID / model | / gpt_image_2_5, sunburst, high, 2k | +| Referensi gaya | | +| Pembeda dari roster | | +| Ciri roster yang dilarang di prompt | | +| Persetujuan / tanggal | | +| Facing / chroma | RIGHT / | +| Prompt dan provenance | generation-log.json / selected-sources.json | + +## Format sprite + +- Request: request.json, disalin dari guide/templates/sprite-request.json. +- Baseline 14 state / 50 frame: idle, walk, run, crouch, attack1–3, skill1, skill2, ultimate, hurt, down masing-masing 4; jump dan doublejump masing-masing 1. +- Cell awal 320×288, safe margin (10,8), anchor (160,280). Catat bila cell perlu lebih besar. +- Tinggi badan idle target sekitar 180 px. Ukur anatomi, bukan memaksa ujung telinga/tanduk/senjata masuk target tinggi dengan mengecilkan tubuh. +- Strip 4 pose: 21:9, guide 4 slot; pose tunggal: 1:1. +- Semua state memakai base baru yang sama sebagai identitas. Efek, bayangan, teks, dan debris terpisah dari sprite. + +## Ukuran yang harus diisi dari hasil ukur + +| Ukuran / file | Hasil | +| --- | --- | +| Tinggi idle final / range frame | | +| Kepala / leher→sabuk / sabuk→kaki | | +| Aksesori yang dikecualikan dari ukuran anatomi | | +| Calibration target / pitch | | +| Rel per state/pose | | +| Integer curation shifts | | +| Pivot jump / tuck | | +| Walk / run stride | | +| Hit range / titik efek | | +| Atlas checksum | | + +## Kemampuan dan aset pendukung + +| Slot | Nama / mekanik | Damage / cooldown awal | Gerak dilepas kapan? | Aset dan titik efek | +| --- | --- | --- | --- | --- | +| Space | <3 gesture combo> | 6 / 8 / 12 | | | +| I | | 16 / 3 s | | | +| O | | 24 / 6 s | | | +| P | | | | | + +Angka tersebut adalah titik awal perbandingan, bukan izin menggandakan damage karena mengganti jumlah VFX. Untuk kemampuan baru, catat jumlah sumber damage dan validasi totalnya. + +- Portrait khusus: , hasil generasi baru; bukan crop atlas. +- Kontrak avatar: close-up kepala80–90%, export384×384, kanonis menghadapKANAN; `portrait-image` + `data-portrait-side=player/enemy` menentukan flip UI. Semua bentuk transformasi mengikuti aturan yang sama. +- Cut-in khusus: , gambar tanpa teks; judul/UI ditambahkan terpisah. +- Ikon setiap slot: . +- Prop/summon: , dimensi final, pivot/muzzle terukur. +- Voice ultimate bila diminta: . Catat hasil audisi dan integrasi mute/volume/pause/reset; jangan menganggap kandidat sudah dipasang. +- Announcer sistem wajib Grady sesuai voice lock: generate `!` dan ` wins!` terpisah, daftarkan file/durasi/trigger dan tes. Voice ultimate tidak menggantikan announcer. +- Sifat kelas yang unik: . + +## Checklist selesai + +- [ ] Base terkunci; original setiap versi dan log lengkap disimpan. +- [ ] Base jelas berbeda dari roster lama; bukan recolor atau karakter lama ditambah ciri ras. +- [ ] Jumlah pose, facing, celah, anggota tubuh, dan konsistensi aksesori diperiksa. +- [ ] Skala kepala **dan badan/recovery** dibandingkan dengan idle; invalid match ditandai. +- [ ] Normalisasi satu kali dari original; curation hanya integer translation. +- [ ] Canonical extraction/compose berhasil; tidak ada per-frame fit paksa. +- [ ] Atlas final tanpa frame kosong, tepi terpotong, atau chroma terlihat. +- [ ] Jump atletis dan tuck dapat dibedakan; putaran tidak mengganti ukuran tubuh. +- [ ] Efek keluar dari alat/telapak/muzzle yang benar setelah normalisasi. +- [ ] File atlas, manifest, wrapper JS, dan metrics sinkron. +- [ ] Tes kontrol, hit, cooldown/recharge, HP berlapis, cast release, pause/reset dijalankan. +- [ ] Browser dilihat pada keyframe penting; loop yang belum ditonton tidak diklaim mulus. +- [ ] Path loader diperbarui secara sengaja; karakter/aset lama tidak tertimpa. +- [ ] qa-notes.md memisahkan hasil angka, pengamatan render, dan batas yang belum diverifikasi. diff --git a/guide/templates/playback.example.json b/guide/templates/playback.example.json new file mode 100644 index 0000000..d79489d --- /dev/null +++ b/guide/templates/playback.example.json @@ -0,0 +1,20 @@ +{ + "_note": "EXAMPLE ONLY. Replace every null pivot coordinate with an integer measured on this character's final atlas. Do not load this file into the game before measuring.", + "jump": { + "airFrame": 0, + "torsoPivot": { + "x": null, + "y": null + } + }, + "doublejump": { + "mode": "rotate", + "frame": 0, + "duration": 0.28, + "pivot": { + "x": null, + "y": null + } + }, + "measurement": "" +} diff --git a/guide/templates/selected-sources.example.json b/guide/templates/selected-sources.example.json new file mode 100644 index 0000000..54613ee --- /dev/null +++ b/guide/templates/selected-sources.example.json @@ -0,0 +1,173 @@ +{ + "character_id": "", + "status": "TEMPLATE — fill with selected generated sources before publishing", + "base": { + "original": "base/base-selected.png", + "job_id": null, + "approved": false, + "prompt_log": "generation-log.json" + }, + "generation_defaults": { + "provider": "Higgsfield", + "model": "gpt_image_2_5", + "variant": "sunburst", + "quality": "high", + "resolution": "2k" + }, + "states": { + "idle": { + "version": 1, + "original": "raw-original/idle-v1.png", + "selected_alias": "raw-original/idle.png", + "job_id": null, + "sha256": null, + "aspect_ratio": "21:9", + "frames": 4, + "identity_reference": "", + "guide_reference": "<4-slot guide media id>" + }, + "walk": { + "version": 1, + "original": "raw-original/walk-v1.png", + "selected_alias": "raw-original/walk.png", + "job_id": null, + "sha256": null, + "aspect_ratio": "21:9", + "frames": 4, + "identity_reference": "", + "guide_reference": "<4-slot guide media id>" + }, + "run": { + "version": 1, + "original": "raw-original/run-v1.png", + "selected_alias": "raw-original/run.png", + "job_id": null, + "sha256": null, + "aspect_ratio": "21:9", + "frames": 4, + "identity_reference": "", + "guide_reference": "<4-slot guide media id>" + }, + "jump": { + "version": 1, + "original": "raw-original/jump-v1.png", + "selected_alias": "raw-original/jump.png", + "job_id": null, + "sha256": null, + "aspect_ratio": "1:1", + "frames": 1, + "identity_reference": "", + "guide_reference": null + }, + "doublejump": { + "version": 1, + "original": "raw-original/doublejump-v1.png", + "selected_alias": "raw-original/doublejump.png", + "job_id": null, + "sha256": null, + "aspect_ratio": "1:1", + "frames": 1, + "identity_reference": "", + "guide_reference": null + }, + "crouch": { + "version": 1, + "original": "raw-original/crouch-v1.png", + "selected_alias": "raw-original/crouch.png", + "job_id": null, + "sha256": null, + "aspect_ratio": "21:9", + "frames": 4, + "identity_reference": "", + "guide_reference": "<4-slot guide media id>" + }, + "attack1": { + "version": 1, + "original": "raw-original/attack1-v1.png", + "selected_alias": "raw-original/attack1.png", + "job_id": null, + "sha256": null, + "aspect_ratio": "21:9", + "frames": 4, + "identity_reference": "", + "guide_reference": "<4-slot guide media id>" + }, + "attack2": { + "version": 1, + "original": "raw-original/attack2-v1.png", + "selected_alias": "raw-original/attack2.png", + "job_id": null, + "sha256": null, + "aspect_ratio": "21:9", + "frames": 4, + "identity_reference": "", + "guide_reference": "<4-slot guide media id>" + }, + "attack3": { + "version": 1, + "original": "raw-original/attack3-v1.png", + "selected_alias": "raw-original/attack3.png", + "job_id": null, + "sha256": null, + "aspect_ratio": "21:9", + "frames": 4, + "identity_reference": "", + "guide_reference": "<4-slot guide media id>" + }, + "skill1": { + "version": 1, + "original": "raw-original/skill1-v1.png", + "selected_alias": "raw-original/skill1.png", + "job_id": null, + "sha256": null, + "aspect_ratio": "21:9", + "frames": 4, + "identity_reference": "", + "guide_reference": "<4-slot guide media id>" + }, + "skill2": { + "version": 1, + "original": "raw-original/skill2-v1.png", + "selected_alias": "raw-original/skill2.png", + "job_id": null, + "sha256": null, + "aspect_ratio": "21:9", + "frames": 4, + "identity_reference": "", + "guide_reference": "<4-slot guide media id>" + }, + "ultimate": { + "version": 1, + "original": "raw-original/ultimate-v1.png", + "selected_alias": "raw-original/ultimate.png", + "job_id": null, + "sha256": null, + "aspect_ratio": "21:9", + "frames": 4, + "identity_reference": "", + "guide_reference": "<4-slot guide media id>" + }, + "hurt": { + "version": 1, + "original": "raw-original/hurt-v1.png", + "selected_alias": "raw-original/hurt.png", + "job_id": null, + "sha256": null, + "aspect_ratio": "21:9", + "frames": 4, + "identity_reference": "", + "guide_reference": "<4-slot guide media id>" + }, + "down": { + "version": 1, + "original": "raw-original/down-v1.png", + "selected_alias": "raw-original/down.png", + "job_id": null, + "sha256": null, + "aspect_ratio": "21:9", + "frames": 4, + "identity_reference": "", + "guide_reference": "<4-slot guide media id>" + } + } +} diff --git a/guide/templates/sprite-request.json b/guide/templates/sprite-request.json new file mode 100644 index 0000000..4d912a1 --- /dev/null +++ b/guide/templates/sprite-request.json @@ -0,0 +1,95 @@ +{ + "cell": { + "width": 320, + "height": 288, + "safe_margin_x": 10, + "safe_margin_y": 8 + }, + "states": { + "idle": { + "frames": 4, + "fps": 5, + "loop": true, + "action": "Subtle breathing; head and torso steady; feet planted, seamless quiet loop." + }, + "walk": { + "frames": 4, + "fps": 10, + "loop": true, + "action": "Right-facing alternating walk: near-leg contact, far-leg passing with feet close, far-leg contact, near-leg passing." + }, + "run": { + "frames": 4, + "fps": 13, + "loop": true, + "action": "Right-facing sprint, forward torso lean; alternating wide contact and narrow passing; same anatomy and scale as idle." + }, + "jump": { + "frames": 1, + "fps": 10, + "loop": false, + "action": "One athletic airborne pose, bent elbows, one knee moderately raised with shin down, trailing leg relaxed. No rigid idle hover, no kick, no deep tuck." + }, + "doublejump": { + "frames": 1, + "fps": 12, + "loop": false, + "action": "One compact tucked airborne pose, both knees together, arms/props close. Full-size head and anatomy. Runtime supplies the 360-degree rotation." + }, + "crouch": { + "frames": 4, + "fps": 8, + "loop": false, + "action": "Lower into guard, hold crouch, small guard shift, return toward crouch-ready stance. Bend joints; do not shrink anatomy." + }, + "attack1": { + "frames": 4, + "fps": 13, + "loop": false, + "action": "Light combo opener: wind-up, strike, full impact extension, recovery toward idle guard. Adapt gesture to the character's weapon." + }, + "attack2": { + "frames": 4, + "fps": 13, + "loop": false, + "action": "Second combo gesture with a different silhouette: wind-up, strike, impact, recovery at consistent body scale." + }, + "attack3": { + "frames": 4, + "fps": 11, + "loop": false, + "action": "Heavier finisher: anticipation, strike, full impact, upright recovery matching idle size." + }, + "skill1": { + "frames": 4, + "fps": 11, + "loop": false, + "action": "Prepare, aim, fire/cast, recover. No detached projectiles in the character art; movement release belongs to runtime." + }, + "skill2": { + "frames": 4, + "fps": 10, + "loop": false, + "action": "Prepare, wind-up, area-impact pose, recover. No dust, debris, shockwaves or detached effects in the sprite." + }, + "ultimate": { + "frames": 4, + "fps": 9, + "loop": false, + "action": "Short cast/command sequence, same head and torso scale. Summoned objects and cut-in are separate assets." + }, + "hurt": { + "frames": 4, + "fps": 9, + "loop": false, + "action": "Front hit recoil, backward stagger, regain balance, return to guard. Right-facing, no blood or impact effects." + }, + "down": { + "frames": 4, + "fps": 7, + "loop": false, + "action": "Fall backward to left: recoil, fall, land on back, lie flat head left/feet right. Identical anatomy; large gaps between poses." + } + }, + "style": "Refined hi-bit pixel art, anime chibi; match the approved NEW character base exactly. Same palette, anatomy, outline weight and pixel density in all states." +} diff --git a/guide/tools/measure_atlas.py b/guide/tools/measure_atlas.py new file mode 100644 index 0000000..1f997a7 --- /dev/null +++ b/guide/tools/measure_atlas.py @@ -0,0 +1,101 @@ +"""Ukur setiap frame di atlas final dan tandai yang melenceng. + +Pakai: python measure_atlas.py [--ref idle] [--states idle,walk] + +Per frame dicetak: + h tinggi siluet (px) + bottom baris kaki (harus sama untuk semua frame di tanah) + head_w lebar kepala/helm — info saja (rambut/bulu helm yang bergoyang membuatnya berisik); + cek skala yang akurat pakai register_frames.py + torso_x posisi horizontal badan — indikator goyang kiri-kanan +Tanda '!' hanya diberikan untuk state "tegak" (--stable, default idle,walk,run): tinggi & goyang-x. +Pose aksi (jump, attack) memang berubah bentuk — untuk itu cukup lihat GIF dan pastikan bottom sama. +""" +import argparse +import json +from pathlib import Path + +import numpy as np +from PIL import Image + +HEAD_BAND = (0.13, 0.28) # bagian siluet (dari atas) yang dianggap kepala +TORSO_BAND = (0.32, 0.56) # bagian siluet yang dianggap badan +ALPHA_MIN = 40 + + +def frame_stats(cell: Image.Image) -> dict: + al = np.array(cell.getchannel("A")) > ALPHA_MIN + ys, xs = np.nonzero(al) + if len(ys) == 0: + return {"empty": True} + top, bottom = ys.min(), ys.max() + h = bottom - top + def band(a, b): + return al[top + int(h * a): top + int(h * b)] + hx = np.nonzero(band(*HEAD_BAND))[1] + tx = np.nonzero(band(*TORSO_BAND))[1] + feet = np.nonzero(al[bottom - 8: bottom + 1].any(0))[0] # lowest 9 rows ≈ the feet + return {"h": int(h), "top": int(top), "bottom": int(bottom), + "head_w": int(hx.max() - hx.min()) if len(hx) else 0, + "torso_x": round(float(tx.mean()), 1) if len(tx) else 0.0, + "feet": int(feet.max() - feet.min()) if len(feet) else 0} + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("run_dir") + ap.add_argument("--ref", default="idle", help="state acuan skala (frame 0)") + ap.add_argument("--states", default="", help="daftar state dipisah koma; kosong = semua") + ap.add_argument("--stable", default="idle,walk,run", help="state tegak yang diperiksa ketat") + ap.add_argument("--max-h-diff", type=int, default=2) + ap.add_argument("--max-torso-drift", type=float, default=1.5) + ap.add_argument("--max-feet-ratio", type=float, default=0.45, + help="walk/run: jarak kaki terkecil / terbesar harus ≤ ini (kaki merapat saat melintas)") + args = ap.parse_args() + stable = set(args.stable.split(",")) + + run = Path(args.run_dir) + manifest = json.loads((run / "manifest.json").read_text(encoding="utf-8")) + atlas = Image.open(run / manifest.get("sprite_sheet_alpha", "sprite-sheet-alpha.png")).convert("RGBA") + rows = manifest["frame_layout"]["rows"] + wanted = [s for s in args.states.split(",") if s] or list(rows) + + def cell(state, i): + r = rows[state][i] + return atlas.crop((r["x"], r["y"], r["x"] + r["w"], r["y"] + r["h"])) + + ref = frame_stats(cell(args.ref, 0)) + print(f"acuan: {args.ref}[0] h={ref['h']} head_w={ref['head_w']} torso_x={ref['torso_x']} bottom={ref['bottom']}") + problems = 0 + for state in wanted: + stats = [frame_stats(cell(state, i)) for i in range(len(rows[state]))] + hs = [s["h"] for s in stats] + tx = [s["torso_x"] for s in stats] + for i, s in enumerate(stats): + flags = [] + if s.get("empty"): + flags.append("KOSONG") + elif state in stable: + if state == args.ref and abs(s["h"] - ref["h"]) > args.max_h_diff: + flags.append("tinggi") + if abs(s["torso_x"] - np.median(tx)) > args.max_torso_drift: + flags.append("goyang-x") + if not s.get("empty") and s["bottom"] != ref["bottom"]: + flags.append("kaki-tidak-di-garis") + problems += bool(flags) + print(f"{state:>10} {i}: h={s.get('h')} bottom={s.get('bottom')} head_w={s.get('head_w')} " + f"torso_x={s.get('torso_x')} {'! ' + ','.join(flags) if flags else 'ok'}") + print(f"{'':>10} rentang tinggi {min(hs)}–{max(hs)} rentang torso_x {min(tx)}–{max(tx)}") + if state in ("walk", "run"): + # a real walk cycle closes the legs when one passes the other: min feet spread << max + fs = [s["feet"] for s in stats if not s.get("empty")] + ratio = min(fs) / max(fs) if max(fs) else 1 + ok = ratio <= args.max_feet_ratio + problems += not ok + print(f"{'':>10} jarak kaki per frame {fs} → rapat/lebar {ratio:.2f} " + f"{'ok (kaki bergantian)' if ok else '! kaki-tidak-bergantian: kaki tidak pernah merapat — generate ulang strip ini'}") + print("\nSEMUA LOLOS" if problems == 0 else f"\n{problems} frame perlu dicek (lihat sprite-qa.md)") + + +if __name__ == "__main__": + main() diff --git a/guide/tools/measure_head_scale.py b/guide/tools/measure_head_scale.py new file mode 100644 index 0000000..e73775e --- /dev/null +++ b/guide/tools/measure_head_scale.py @@ -0,0 +1,191 @@ +"""Cek skala SEMUA gerakan terhadap idle lewat ukuran kepala/helm — dijalankan di gambar ASLI generator. + +Kenapa kepala: pose aksi (serang, lompat, skill) mengubah tinggi & siluet (lutut ditekuk, lengan & senjata +terangkat), jadi tinggi/siluet tidak bisa menilai skala. Kepala/helm bentuknya tetap. +Kenapa di gambar asli: di atlas kepala hanya ~20 px (selisih 1 px = 5 %); di gambar asli ~6× lebih besar. +Kenapa tepi (edge), bukan warna: model menggambar tiap strip dengan pencahayaan berbeda → pencocokan warna +gagal; garis kontur helm tetap sama. + +Pakai: + python measure_head_scale.py --poses idle:4,walk:6,jump:4,attack1:4 \ + [--ref idle:0] [--head-box x0,y0,x1,y1] [--rel-json rel.json] [--target 0.5] [--key ff00ff] [--landmark white] + + char_dir folder karakter berisi raw-original/.png + --head-box kotak kepala di pose acuan, koordinat relatif crop pose (kolom pose, baris dari atas gambar). + Kosongkan untuk otomatis (pita 10–30 % teratas siluet) — lalu CEK tile TEMPLATE di head-check.png. + --rel-json {"walk": [1.045], "idle": [1,0.95,1,0.987]} = --rel yang SEKARANG dipakai rebuild_raw.py + --landmark white: cari kepala di dekat gumpalan putih (bulu helm / rambut putih) — mencegah tertukar dengan bahu + +Keluaran: rasio ukuran kepala di atlas vs idle per frame, median per gerakan, saran --rel baru, dan +/head-check.png: setiap kepala yang ditemukan DIKEMBALIKAN ke ukuran template memakai skala terbaca. +Kalau skalanya benar, semua kepala di lembar itu tampak sama besar dengan TEMPLATE. Kotak yang tidak berisi +kepala (tertutup lengan / menghadap depan) = angka frame itu tidak valid → abaikan, pakai median gerakannya. +""" +import argparse +import json +from pathlib import Path + +import numpy as np +from PIL import Image, ImageDraw +from sprite_gen.frames.extract import estimate_pixel_grid_runlen + +WHITE_MIN = 215 +MIN_SCORE = 0.30 # skor NCC tepi di bawah ini = kepala tidak tertangkap dengan baik + + +def corr(a, b): + H, W = a.shape; h, w = b.shape + fa = np.fft.rfft2(a, (H + h, W + w)); fb = np.fft.rfft2(b[::-1, ::-1], (H + h, W + w)) + return np.fft.irfft2(fa * fb, (H + h, W + w))[h - 1:H, w - 1:W] + + +def edges(img, key): + a = np.asarray(img).astype(float) + fg = np.sqrt(((a - key) ** 2).sum(-1)) > 110 + g = a.mean(2) * fg + gy, gx = np.gradient(g) + return np.hypot(gx, gy), fg, a + + +def white_center(a, fg): + ys, xs = np.nonzero(fg & (a.min(2) > WHITE_MIN)) + if len(xs) < 50: + return None + rows = np.nonzero(fg.any(1))[0] + sel = ys < rows.min() + 0.45 * (rows.max() - rows.min()) + return (float(np.median(xs[sel])), float(np.median(ys[sel]))) if sel.any() else None + + +def split_poses(img, n, key): + a = np.array(img).astype(int) + cols = (np.sqrt(((a - key) ** 2).sum(-1)) > 110).sum(0) > 2 + runs, x = [], 0 + while x < len(cols): + if cols[x]: + s = x + while x < len(cols) and cols[x]: + x += 1 + runs.append([s, x]) + else: + x += 1 + merged = [runs[0]] if runs else [] + for r in runs[1:]: + if r[0] - merged[-1][1] < 12: + merged[-1][1] = r[1] + else: + merged.append(r) + merged = [r for r in merged if r[1] - r[0] > 20] + if len(merged) != n: # pose saling menempel → pakai slot sama lebar + merged = [[round(k * img.width / n), round((k + 1) * img.width / n)] for k in range(n)] + return merged + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("char_dir") + ap.add_argument("--poses", required=True, help="state:jumlah,... mis. idle:4,walk:6,attack1:4") + ap.add_argument("--ref", default="idle:0") + ap.add_argument("--head-box", default="") + ap.add_argument("--rel-json", default="") + ap.add_argument("--target", type=float, default=0.5) + ap.add_argument("--key", default="ff00ff") + ap.add_argument("--landmark", default="", choices=["", "white"]) + args = ap.parse_args() + + root = Path(args.char_dir) + key = np.array([int(args.key[i:i + 2], 16) for i in (0, 2, 4)]) + poses = [(p.split(":")[0], int(p.split(":")[1])) for p in args.poses.split(",")] + rel = json.loads(Path(args.rel_json).read_text()) if args.rel_json else {} + + def load(state, n): + im = Image.open(root / "raw-original" / f"{state}.png").convert("RGB") + px, py = estimate_pixel_grid_runlen(im.convert("RGBA")) + return im, split_poses(im, n, key), args.target / ((px + py) / 2) + + def rel_of(state, i, n): + r = rel.get(state, [1.0]) + return r[0] if len(r) == 1 else r[i] + + rs, ri = args.ref.split(":"); ri = int(ri) + nref = dict(poses)[rs] + im, runs, base_ref = load(rs, nref) + x0, x1 = runs[ri] + pose = im.crop((x0, 0, x1, im.height)) + if args.head_box: + hb = [int(v) for v in args.head_box.split(",")] + else: + _, fg, _ = edges(pose, key) + rows = np.nonzero(fg.any(1))[0]; top, h = rows.min(), rows.max() - rows.min() + y0, y1 = top + int(h * 0.10), top + int(h * 0.30) + cols = np.nonzero(fg[y0:y1].any(0))[0] + hb = [int(cols.min()), int(y0), int(cols.max() + 1), int(y1)] + tmpl = pose.crop(hb) + tE, tfg, ta = edges(tmpl, key) + tmask = (tfg & ~(ta.min(2) > WHITE_MIN)).astype(np.float32) + ref_scale_atlas = base_ref * rel_of(rs, ri, nref) + + offset = None + if args.landmark == "white": + _, pfg, pa = edges(pose, key) + c = white_center(pa, pfg) + if c: + offset = ((hb[0] + hb[2]) / 2 - c[0], (hb[1] + hb[3]) / 2 - c[1]) + + def find(img): + E, fg, a = edges(img, key) + c = white_center(a, fg) if offset else None + best = (-9.0, 1.0, 0, 0) + for s in np.arange(0.70, 1.401, 0.01): + tw, th = round(tmpl.width * s), round(tmpl.height * s) + if tw >= img.width or th >= img.height: + continue + T = np.array(Image.fromarray(tE.astype(np.float32)).resize((tw, th), Image.BILINEAR)).astype(float) + M = (np.array(Image.fromarray((tmask * 255).astype(np.uint8)).resize((tw, th), Image.BILINEAR)) > 127).astype(float) + n = M.sum() + tz = (T - (T * M).sum() / n) * M + sc = corr(E, tz) / np.sqrt(np.maximum(corr(E * E, M) - corr(E, M) ** 2 / n, 1e-9) * (tz * tz).sum()) + if c is not None: + yy, xx = np.mgrid[0:sc.shape[0], 0:sc.shape[1]] + ex, ey = c[0] + offset[0] * s, c[1] + offset[1] * s + sc = np.where((abs(xx + tw / 2 - ex) < 90) & (abs(yy + th / 2 - ey) < 90), sc, -9) + k = np.unravel_index(np.argmax(sc), sc.shape) + if sc[k] > best[0]: + best = (float(sc[k]), float(s), int(k[1]), int(k[0])) + return best + + tiles = [("TEMPLATE", tmpl)] + print(f"template kepala: {tmpl.width}x{tmpl.height}px dari {args.ref} (box {hb})") + suggestions = {} + for state, n in poses: + im, runs, base = load(state, n) + ratios = [] + for i, (a0, a1) in enumerate(runs): + p = im.crop((a0, 0, a1, im.height)) + score, s, bx, by = find(p) + ratio = s * base * rel_of(state, i, n) / ref_scale_atlas + ok = score >= MIN_SCORE + if ok: + ratios.append(ratio) + tw, th = round(tmpl.width * s), round(tmpl.height * s) + tiles.append((f"{state}{i} {ratio:.2f}{'' if ok else ' ?'}", + p.crop((bx, by, bx + tw, by + th)).resize(tmpl.size, Image.LANCZOS))) + print(f"{state:>10} {i}: kepala di atlas = {ratio:.3f} × idle (skor {score:.2f}){'' if ok else ' TIDAK VALID'}") + if ratios: + med = float(np.median(ratios)) + cur = rel.get(state, [1.0]) + new = [round(v / med, 3) for v in cur] + suggestions[state] = new + flag = "OK" if abs(med - 1) <= 0.03 else "PERLU KOREKSI" + print(f"{'':>10} median {med:.3f} → {flag}; --rel baru {','.join(map(str, new))}") + W, H = tmpl.size; cols = 9 + sheet = Image.new("RGB", (W * cols, (H + 14) * ((len(tiles) + cols - 1) // cols)), (0, 0, 0)) + for k, (name, t) in enumerate(tiles): + x, y = (k % cols) * W, (k // cols) * (H + 14) + sheet.paste(t, (x, y + 14)); ImageDraw.Draw(sheet).text((x + 2, y + 1), name, fill=(255, 255, 0)) + sheet.save(root / "head-check.png") + print(f"\nLIHAT {root / 'head-check.png'}: semua kepala harus sama besar dengan TEMPLATE.") + print("saran --rel (salin ke rel.json bila setuju):", json.dumps(suggestions)) + + +if __name__ == "__main__": + main() diff --git a/guide/tools/rebuild_raw.py b/guide/tools/rebuild_raw.py new file mode 100644 index 0000000..eb1c2ba --- /dev/null +++ b/guide/tools/rebuild_raw.py @@ -0,0 +1,122 @@ +"""Bangun ulang run/raw/.png dari strip asli generator dengan faktor skala per pose. + +Satu kali resample (BOX) per pose → semua frame sama tajam. Ini SATU-SATUNYA tempat skala dikoreksi. + +Pakai: + python rebuild_raw.py --poses N [--target 0.5] [--rel 1,0.95,1,0.987] + + src_strip strip asli generator (raw-original/.png), latar chroma flat + out_strip run/raw/.png + --target ukuran 1 px final dalam "pixel palsu" (0.5 = 1 px final per 2 pixel palsu) + --rel koreksi per pose (hasil register_frames.py); satu angka = berlaku untuk semua pose + --key warna chroma (default ff00ff) + +Butuh sprite_gen (untuk mengukur pitch). Setelah ini: sprite-gen extract --run-dir run --states +""" +import argparse + +import numpy as np +from PIL import Image +from sprite_gen.frames.extract import estimate_pixel_grid_runlen + + +def find_poses(fg: np.ndarray, n: int, min_gap: int = 12, min_width: int = 20): + cols = fg.sum(0) > 2 + runs, x, w = [], 0, len(cols) + while x < w: + if cols[x]: + s = x + while x < w and cols[x]: + x += 1 + runs.append([s, x]) + else: + x += 1 + merged = [runs[0]] + for r in runs[1:]: + if r[0] - merged[-1][1] < min_gap: # potongan kecil (ujung pedang, jubah) digabung + merged[-1][1] = r[1] + else: + merged.append(r) + merged = [r for r in merged if r[1] - r[0] > min_width] + counts = fg.sum(0) + while len(merged) < n: + # pose bersentuhan (ujung pedang menyentuh jubah tetangga): potong pose terlebar di kolom + # dengan piksel paling sedikit (bagian tengah 70 %) — biasanya titik sentuh yang tipis + i = max(range(len(merged)), key=lambda j: merged[j][1] - merged[j][0]) + s, e = merged[i] + m = int((e - s) * 0.15) + cut = s + m + int(np.argmin(counts[s + m:e - m])) + print(f"pose menempel: potong di kolom {cut} (piksel {int(counts[cut])})") + merged[i:i + 1] = [[s, cut], [cut, e]] + if len(merged) != n: + raise SystemExit(f"ditemukan {len(merged)} pose, harusnya {n}: {merged} — cek strip / generate ulang") + return merged + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("src") + ap.add_argument("out") + ap.add_argument("--poses", type=int, required=True) + ap.add_argument("--target", type=float, default=0.5) + ap.add_argument("--rel", default="1") + ap.add_argument("--key", default="ff00ff") + args = ap.parse_args() + + key = np.array([int(args.key[i:i + 2], 16) for i in (0, 2, 4)]) + im = Image.open(args.src).convert("RGB") + px, py = estimate_pixel_grid_runlen(im.convert("RGBA")) + base = args.target / ((px + py) / 2) + rel = [float(v) for v in args.rel.split(",")] + rel = rel * args.poses if len(rel) == 1 else rel + assert len(rel) == args.poses, "--rel harus 1 angka atau sebanyak --poses" + + if len(set(rel)) == 1: + # satu faktor untuk semua pose → perkecil strip utuh; tidak perlu memisahkan pose + # (juga aman bila pose di gambar asli saling bersentuhan) + f = base * rel[0] + out = im.resize((round(im.width * f), round(im.height * f)), Image.Resampling.BOX) + out.save(args.out) + print(f"strip utuh: faktor {f:.4f} (pitch {px:.2f}x{py:.2f}, rel {rel[0]})") + print("tersimpan:", args.out, out.size) + return + + # rel mayoritas = skala strip utuh (tata letak & pose yang bersentuhan tetap utuh); + # hanya pose dengan rel berbeda yang dipotong dari gambar asli, di-resample sekali, lalu ditempel ulang + common = max(set(rel), key=rel.count) + fc = base * common + out = im.resize((round(im.width * fc), round(im.height * fc)), Image.Resampling.BOX) + print(f"strip utuh: faktor {fc:.4f} (pitch {px:.2f}x{py:.2f}, rel {common})") + a = np.array(im).astype(int) + fg = np.sqrt(((a - key) ** 2).sum(-1)) > 110 + runs = find_poses(fg, args.poses) + pad = 6 + for i, (x0, x1) in enumerate(runs): + if rel[i] == common: + continue + rows = np.nonzero(fg[:, x0:x1].any(1))[0] + y0, y1 = rows.min(), rows.max() + 1 + # pad hanya ke sisi yang kosong — jangan ikut mengambil piksel pose tetangga yang menempel + px0 = x0 - pad if i == 0 or runs[i - 1][1] < x0 else x0 + px1 = x1 + pad if i == len(runs) - 1 or runs[i + 1][0] > x1 else x1 + box = (max(0, px0), max(0, y0 - pad), min(im.width, px1), min(im.height, y1 + pad)) + crop = im.crop(box) + cmask = Image.fromarray((fg[box[1]:box[3], box[0]:box[2]] * 255).astype(np.uint8)) + # hapus pose lama (versi skala strip) dari kanvas + sbox = tuple(round(v * fc) for v in box) + old = cmask.resize((sbox[2] - sbox[0], sbox[3] - sbox[1]), Image.Resampling.BOX).point(lambda v: 255 if v > 0 else 0) + out.paste(Image.new("RGB", old.size, tuple(int(v) for v in key)), sbox[:2], old) + # tempel versi baru, kaki (tengah-bawah) tetap di tempat yang sama + f = base * rel[i] + size = (max(1, round(crop.width * f)), max(1, round(crop.height * f))) + small = crop.resize(size, Image.Resampling.BOX) + smask = cmask.resize(size, Image.Resampling.BOX).point(lambda v: 255 if v > 0 else 0) + cx, bottom = (box[0] + box[2]) / 2 * fc, box[3] * fc + out.paste(small, (round(cx - small.width / 2), round(bottom - small.height)), smask) + print(f"pose {i}: faktor {f:.4f} (rel {rel[i]}) ditempel ulang") + out.save(args.out) + print("tersimpan:", args.out, out.size) + + +if __name__ == "__main__": + main() diff --git a/guide/tools/register_frames.py b/guide/tools/register_frames.py new file mode 100644 index 0000000..7544e61 --- /dev/null +++ b/guide/tools/register_frames.py @@ -0,0 +1,80 @@ +"""Cari skala dan geser tiap frame relatif ke frame acuan (default idle frame 0). + +Membandingkan bagian badan atas yang kaku (kepala → sabuk), jadi gerakan kaki/jubah tidak mengganggu. +Kaki dijaga tetap di garis tanah saat skala dicoba. + +Pakai: + python register_frames.py # cari skala + dx (diagnosis) + python register_frames.py --dx-only # skala dikunci 1.0, cari dx bulat (untuk curation) + +Baca frame dari run/frames// (sebelum curation). Butuh paket sprite_gen (venv skill sprite-gen). +""" +import argparse +import json +from pathlib import Path + +import numpy as np +from PIL import Image +from sprite_gen.curate import curation as C + +UPPER_BAND = (0.10, 0.50) # bagian siluet acuan yang dipakai untuk mencocokkan (dari atas) + + +def alpha(img): + return np.array(img.getchannel("A")) > 40 + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("run_dir") + ap.add_argument("state") + ap.add_argument("--ref", default="idle:0", help="state:index acuan") + ap.add_argument("--dx-only", action="store_true") + ap.add_argument("--bob", type=int, default=None, help="toleransi naik-turun (px); default 4 untuk walk/run, 0 lainnya") + args = ap.parse_args() + + run = Path(args.run_dir) + req = json.loads((run / "sprite-request.json").read_text(encoding="utf-8")) + cw, ch = req["cell"]["width"], req["cell"]["height"] + foot_y = ch - req["cell"]["safe_margin_y"] + cy = ch / 2 + ref_state, ref_i = args.ref.split(":") + ref = alpha(Image.open(run / "frames" / ref_state / f"frame-{ref_i}.png")) + ys = np.nonzero(ref.any(1))[0] + top, h = ys.min(), ys.max() - ys.min() + y0, y1 = top + int(h * UPPER_BAND[0]), top + int(h * UPPER_BAND[1]) + bob = args.bob if args.bob is not None else (4 if args.state in ("walk", "run") else 0) + + scales = [1.0] if args.dx_only else np.arange(0.90, 1.101, 0.005) + dxs = range(-10, 11) if args.dx_only else np.arange(-10, 10.5, 0.5) + frames = sorted((run / "frames" / args.state).glob("frame-*.png"), + key=lambda p: int(p.stem.split("-")[1].split(".")[0])) + frames = [p for p in frames if p.stem.count(".") == 0] + result = [] + for p in frames: + src = Image.open(p) + best = (-1.0, 1.0, 0.0) + for s in scales: + dy = (foot_y - cy) * (1 - s) # kaki tetap di garis tanah + for dx in dxs: + t = {"scale": float(s), "dx": float(dx), "dy": float(dy)} + out = alpha(C.apply_transform(src, t, (cw, ch))) + for sh in range(-bob, bob + 1): + o = np.roll(out, sh, axis=0)[y0:y1] + r = ref[y0:y1] + iou = (o & r).sum() / max(1, (o | r).sum()) + if iou > best[0]: + best = (iou, float(s), float(dx)) + iou, s, dx = best + result.append({"frame": p.stem, "scale": round(s, 3), "dx": int(dx) if args.dx_only else dx, "iou": round(iou, 3)}) + note = "" if abs(s - 1) <= 0.01 else " <- skala meleset >1%, koreksi di gambar asli" + print(f"{p.stem}: scale={s:.3f} dx={dx:+.1f} iou={iou:.3f}{note}") + if args.dx_only: + print("dx bulat:", ",".join(str(r["dx"]) for r in result)) + else: + # 'scale' = pengali yang membuat frame cocok dengan acuan → langsung jadi --rel untuk rebuild_raw.py + print("--rel untuk rebuild_raw.py:", ",".join(f"{r['scale']:.3f}" for r in result)) + + +if __name__ == "__main__": + main() diff --git a/guide/tools/write_dx.py b/guide/tools/write_dx.py new file mode 100644 index 0000000..71bb191 --- /dev/null +++ b/guide/tools/write_dx.py @@ -0,0 +1,31 @@ +"""Tulis geser horizontal BULAT per frame ke curation.json (tanpa resample → tanpa blur). + +Pakai: python write_dx.py + (dx dari: python register_frames.py --dx-only) + +Transform lain di state itu diganti. scale selalu 1, dx dibulatkan. Lalu: sprite-gen compose-atlas --run-dir +""" +import json +import sys +from pathlib import Path + +from sprite_gen.curate import curation as C + + +def main(): + run, state, raw = Path(sys.argv[1]), sys.argv[2], sys.argv[3] + dxs = [int(round(float(v))) for v in raw.split(",")] + path = run / "curation.json" + cur = json.loads(path.read_text(encoding="utf-8")) if path.exists() else C.empty_curation() + transforms = {str(i): {"scale": 1.0, "dx": dx, "dy": 0} for i, dx in enumerate(dxs) if dx} + cur.setdefault("states", {}) + if transforms: + cur["states"][state] = {"transforms": transforms} + else: + cur["states"].pop(state, None) + C.write_curation_atomic(run, C.stamp_curation(run, cur)) + print(f"{state}: dx = {dxs}") + + +if __name__ == "__main__": + main() diff --git a/guide/ultimate-voice.md b/guide/ultimate-voice.md new file mode 100644 index 0000000..a04e4a0 --- /dev/null +++ b/guide/ultimate-voice.md @@ -0,0 +1,34 @@ +# Voice ultimate ARCO — Dylan + +Status: pengguna memilih **Dylan**. File final [arco-ultimate-dylan.mp3](../assets/audio/arco-ultimate-dylan.mp3) sudah dipasang pada awal ultimate/cut-in ARCO. Holden dan Archie tetap disimpan sebagai kandidat arsip, tidak dimainkan oleh runtime. + +Script Inggris yang sama untuk setiap kandidat: + +> Helios Squadron! Open fire! + +Provider Higgsfield, model `text2speech_v2`, varian `elevenlabs`, `voice_type: preset`. Voice ID berasal dari daftar resmi Higgsfield dan ketiga voice dipilih pengguna untuk percobaan. Ini hasil generasi kalimat ultimate baru, bukan sampel preview bawaan voice. + +| Kandidat | Voice ID | File | +| --- | --- | --- | +| Dylan | b847bc29-f184-583a-8ad9-d1f1e16d1a60 | dylan.mp3 | +| Holden | 3c9d6053-6334-592c-8997-4e325286af3f | holden.mp3 | +| Archie | bd072316-f77c-588b-b6e5-e46b9b03d008 | archie.mp3 | + +generation.json menyimpan pilihan final, prompt, parameter, job ID, URL sumber, file lokal, durasi hasil probe, checksum, dan hasil verifikasi decode. File runtime identik dengan kandidat Dylan: SHA-256 `25ef0d218a87f53186f73363438dcea65dd5a065c103bee15fb9dd7e9d8aa249`. Durasi container hasil ffprobe sekitar 2,273 s; browser melaporkan durasi playable 2,24 s. + +## Perilaku runtime + +- Dylan dan Holden FENR dipreload pada HTMLAudioElement lokal terpisah tanpa autoplay, dengan satu voice aktif agar tidak bertumpuk. Tidak membutuhkan stream Higgsfield saat game dimainkan. Aturan prioritas voice pemain/AI: [fenr-voice.md](fenr-voice.md). +- Voice dimulai pada startSquadron setelah input pengguna yang valid. Tombol P yang ditolak cooldown tidak mengulang voice. +- Volume voice mengikuti slider game dengan pengali 1,25 (dibatasi 1); mute berlaku pada voice dan SFX. Gain SFX menjadi 38% selama voice sedang berjalan, lalu pulih setelah voice berakhir/berhenti. +- Pause dan pengaturan menjeda voice; resume melanjutkan posisi yang sama. Reset menghentikan dan mengembalikannya ke awal. +- Saat tab disembunyikan voice dijeda tanpa mengubah preferensi pause game. Voice yang sudah tidak relevan tidak diputar terlambat setelah summon selesai. +- Tidak ada perubahan pada cast 0,50 s, damage, cooldown, atau kebebasan bergerak. Audio tidak menahan karakter. +- Rejection autoplay/load ditangani tanpa merusak gameplay. Token tiap percobaan mencegah Promise play lama membatalkan voice baru setelah reset/pause/resume. +- Preview visual yang dipicu programmatically tanpa user gesture tidak mengeluarkan voice otomatis. + +## Verifikasi + +41 tes gameplay tetap lulus, ditambah 9 tes pada `node tests/ultimate-voice.test.cjs` untuk preload/start, duplikasi cast, pause/settings, reset, volume/mute/ducking, visibility, dan race/error Promise play. + +Browser nyata: readyState4, durasi2,24 s, voiceActive=true, paused=false, playback time bergerak, start counter1, dan error kosong. Pause cepat saat awal voice mempertahankan active state; resume melanjutkan playback dengan start counter tetap1. Bukti ini memverifikasi pemutaran browser dan perilaku kontrol, bukan volume speaker/OS atau penilaian timbre melalui tool. diff --git a/guide/zanni-reference.md b/guide/zanni-reference.md new file mode 100644 index 0000000..9c813ae --- /dev/null +++ b/guide/zanni-reference.md @@ -0,0 +1,71 @@ +# ZANNI — badut jam kuningan + +ZANNI adalah petarung Mecha keempat dan karakter ketujuh di roster: badut commedia dell'arte dengan lengan bawah berupa kisi gunting kuningan yang bisa memanjang, bersenjata tiga cincin juggling bermata pisau. Ia berasal dari 8 karakter showcase dan dibuat playable setelah HALDOR. Pemain dapat memilihnya di roster VS/Training dan di Pengaturan → Karakter pemain; CPU memakai kit yang sama. Kartu identitas: assets/zanni/character.md. + +## Dari showcase ke playable + +Alurnya sama dengan NAJA dan HALDOR ([haldor-reference.md](haldor-reference.md)). Body showcase chibi v2 langsung menjadi base sprite (base-selected.png, job `c90ac556-95c7-41db-a3ce-14ba5b331656`), dan avatar showcase dipakai sebagai portrait HUD. Di roster.json ZANNI ditandai `promoted`, jadi showcase kini berisi 5 karakter. Semua gambar baru dibuat dengan **GPT Image 2.5 Sunburst**. Announcer tetap Grady. + +Berbeda dari HALDOR, semua strip dan FX ZANNI memakai chroma **magenta** (#FF00FF), karena paletnya chartreuse. Alat pipeline HALDOR disalin menjadi `tools/zanni_*.py` dengan kunci magenta. + +## Paket siap digunakan ulang + +| Paket | Isi | +| --- | --- | +| Sprite | 15 state / 54 frame, atlas 1792×4320, cell 448×288, anchor 224/280, idle 176 px (kaki sampai puncak topi), lebar 134–143 px | +| UI | portrait 384 (avatar showcase), cut-in 1600×686, 4 ikon 256, roster art dari showcase | +| VFX | 4 PNG alpha 256: ring (cincin Ring Toss), bigring (cincin raksasa Grand Finale), snatch (ledakan tarikan), confetti (tebasan confetti saat melempar) | +| Audio | `select_zanni` "Zanni!" (1.149 s), `zanni_wins` "Zanni wins!" (1.489 s, sunyi di akhir dipotong), Grady. Voice ultimate Julian "Grand Finale!" (1.071 s) | + +Ukuran 176 px dipilih dari lineup 176/188/200/212 (size-candidates.png). Pada ukuran itu wajah setara NAJA/CORA, sedangkan topi tiga cabang membuatnya tampak setinggi roster lain. Wrapper `ZANNI_MANIFEST/METRICS` ada di [assets/zanni/manifest.js](../assets/zanni/manifest.js). Sprite library memutar semua animasi. + +## Kemampuan dan balance + +| Input | Gerakan | Damage / cooldown | +| --- | --- | --- | +| Space×3 | SCISSOR JAB: jab, uppercut, jab ganda dengan lengan gunting yang memanjang. Reach 125/100/140 mengikuti ujung lengan terukur (144/110/161 px). Rantai cepat (0.36/0.42/0.54 s), knockback 70/95/190 | 6/8/12 | +| I | RING TOSS: cincin bermata terbang lurus setinggi tangan (820 px/s), berbalik setelah 430 px dan kembali ke tangan ZANNI. Kena sekali, saat pergi atau saat pulang. Kontrol lepas saat cincin keluar | 16 / 3 s | +| O | SPRING SNATCH: lengan gunting memanjang sampai 200 px dan menarik lawan ke 95 px di depan ZANNI, siap untuk jab | 24 / 6 s | +| P | GRAND FINALE: pose cast 0.5 s, tiga cincin raksasa dilempar ke depan | 48 / 18 s | + +Setiap basic hit yang valid mengembalikan 5% cooldown maksimum semua skill. HP tetap 200 dalam dua lapis. Semua nilai ada di [zanni.js](../zanni.js). + +Ring Toss adalah proyektil bumerang pertama. Lompatan melewati cincin yang pergi, tetapi cincin yang pulang masih bisa mengenai lawan yang mendarat di jalurnya. Cincin yang sudah mengenai (`spent`) tetap terbang pulang tanpa bisa mengenai lagi. Spring Snatch tidak mendorong lawan yang sudah lebih dekat dari 95 px. + +### Timeline Grand Finale + +| Bagian | Waktu dari cast | +| --- | --- | +| Cut-in banner chartreuse/arang | 0–0.78 s | +| Pose cast pemain | 0–0.50 s, lalu bebas bergerak/menyerang | +| Lemparan 1 / 2 / 3 | 0.80 / 1.18 / 1.56 s dari tangan ZANNI, setinggi dada | +| Cincin | 980 px/s ke tepi arena, lalu pulang ke ZANNI; 16 damage, satu hit per cincin | +| Selesai | setelah 2.3 s dan semua cincin kembali | + +Jarak antar lemparan 0.38 s, lebih pendek dari stun 0.42 s, sehingga lawan yang diam di depan terkena 48. Lawan di belakang ZANNI saat lemparan aman. Tidak ada global hit-stop; knockback 50/50/190. Pause membekukan serangan ini, sedangkan reset, kembali ke menu dan akhir ronde membatalkannya. Serangan pemain (`finale`) dan CPU (`enemyFinale`) memiliki owner terpisah. Cincin memakai jalur proyektil biasa, jadi CPU yang membaca proyektil otomatis mencoba melompatinya. + +## CPU + +- ZANNI sebagai CPU memakai otak yang sama ([cpu-ai.md](cpu-ai.md)) dan kit-nya lewat `KITS`. +- `cpuReach` kini memakai `range` sebuah proyektil bila ada, jadi CPU ZANNI melempar Ring Toss dari jarak 430 px, bukan 560. +- Benchmark (ZANNI sebagai CPU): pemain diam K.O. dalam 21.2 / 19.7 / 16.7 / 15.7 s (Easy→Excellent), sejajar MIRA/CORA/NAJA. Proyektil yang mengenai CPU 52 / 42 / 42 / 33%. Detail di [cpu-ai.md](cpu-ai.md). + +## Voice + +"Grand Finale!" digenerate dengan empat preset pria: Jasper, Benji, Caspian dan Julian. Julian dipilih karena pitch median bersihnya paling tinggi (±200 Hz, Jasper ±163, Benji ±178; estimasi Caspian tidak andal), cocok untuk badut yang ringan dan jahil, dengan take pendek 1.07 s. Angka pitch berasal dari estimasi autokorelasi sederhana dan hanya menjadi panduan; timbre belum didengar pengguna. Cara mengganti: salin kandidat lain dari `assets/zanni/audio/ultimate-candidates/` ke `zanni-ultimate.mp3`, lalu ganti `VOICE_NAMES.zanni` di game.js. Record: generation.json. + +## Integrasi engine + +- `KITS` memetakan `zanni` ke kontrak kit yang sama. ZANNI memiliki `zanniStrike` (jab, bumerang, tarikan) dan summon `startFinale`/`updateFinale` (dipilih lewat `startSummon`). +- Proyektil kini mendukung `boomerang` (berbalik setelah `range` px dan pulang ke pemiliknya), `spent` (satu hit) dan `spin` (rotasi gambar). Proyektil lama tidak berubah. +- Snapshot QA berisi `finale`/`enemyFinale`. Cut-in memakai kelas `.zanni-cutin`, art ui/cutin.png, dan judul GRAND FINALE dari HTML. + +## Reproduksi + +1. Prompt/job: desain di roster.json; strip di strip-requests.json (15 × v1); UI di ui-requests.json. +2. `tools/zanni_pipeline.py measure`, `tools/zanni_measure.py`, lalu tulis `rel.json` (alasan di `qa/rel-decision.json`). +3. `tools/zanni_pipeline.py calibrate`, `sprite_gen.cli prepare --out-dir assets/zanni/run --character-id zanni --base-image assets/zanni/base/base-selected.png --request assets/zanni/request.json --chroma-key #FF00FF`, lalu `normalize` dan `extract`. +4. `tools/zanni_align.py`, lalu `publish`. Hitung emitter (ujung terjauh di frame 2) ke `emitters.json`, lalu jalankan `publish` sekali lagi. +5. `tools/zanni_assets.py` mengekspor portrait/ikon/cut-in/VFX, lalu `tools/web_images.py` dan `node tools/build_dist.mjs`. + +Catatan QA: assets/zanni/run/qa-notes.md. Tes: `node tests/zanni.test.cjs` (14 tes). diff --git a/haldor.js b/haldor.js new file mode 100644 index 0000000..ce06566 --- /dev/null +++ b/haldor.js @@ -0,0 +1,35 @@ +/* Shared HALDOR moves (forge-master mecha, anvil hammer): player and AI use the same rules. */ +(() => { + 'use strict'; + const balance = { cooldowns:{skill1:3,skill2:6,ultimate:18}, castTime:.5, quakeDamage:16, basicRefund:.05 }; + const names = ['FORGE CHAIN','SLAG SHOT','STEAM RAM','FORGE QUAKE']; + // The heavy tank: the slowest chain in the roster, with the biggest knockback on every link. Reach follows the measured + // hammer head (131 px hook, 83 px uppercut, 85 px floor smash in emitters.json): short range, heavy hits. + const combo = [ + {damage:6,knockback:95,reach:112,duration:.42,hitAt:.5}, + {damage:8,knockback:125,reach:90,duration:.48,hitAt:.5}, + {damage:12,knockback:235,reach:95,duration:.62,hitAt:.58} + ]; + // Slag Shot: a molten slag ball lobbed in an arc that lands where the rival stood at the throw (180-560 px), + // hitting on contact in flight or with a small splash where it lands. Stepping away after the throw dodges it. + const slag = { flight:.9, rise:520, minRange:180, maxRange:560, splash:72 }; + // Forge Quake: three hammer slams; each sends a molten shockwave along the floor both ways from the crater. + // One 16-damage hit per wave (48). Slams are 0.38 s apart, inside the 0.42 s hurt stun, so a standing rival takes all + // three. One jump (0.57 s airtime) clears two waves; jump + double jump clears all three. + const quake = { cutinDuration:.78, slams:[.8,1.18,1.56], speed:760, duration:2.2 }; + function move(name,index=1) { + if(name==='attack')return {name:`attack${index}`,type:'attack',index,...combo[index-1]}; + if(name==='skill1')return {name,type:name,damage:16,duration:.56,knockback:150,projectile:true,lob:true,speed:(slag.minRange+slag.maxRange)/2/slag.flight,hitAt:.55}; + // Steam Ram: a piston-boosted shoulder charge; the biggest knockback in his kit. + if(name==='skill2')return {name,type:name,damage:24,duration:.7,knockback:320,reach:118,dash:340,hitAt:.6}; + return {name:'ultimate',type:'ultimate',duration:balance.castTime,damage:0,hitAt:1}; + } + function start(a,name,index=1) { + if(a.action||a.state==='hurt'||a.state==='down'||a.state==='recover')return false; + if(name!=='attack'&&a.cooldowns[name]>0)return false; + if(name!=='attack')a.cooldowns[name]=balance.cooldowns[name]; + a.action={...move(name,index),t:0,fired:false,queued:0};a.state=a.action.name;a.stateTime=0;return true; + } + function refund(a) {for(const name of Object.keys(balance.cooldowns))a.cooldowns[name]=Math.max(0,a.cooldowns[name]-balance.cooldowns[name]*balance.basicRefund);} + window.Haldor={balance,names,combo,slag,quake,move,start,refund}; +})(); diff --git a/index.html b/index.html new file mode 100644 index 0000000..930a81d --- /dev/null +++ b/index.html @@ -0,0 +1,28 @@ + + +AETHER CLASH — Mecha vs Demi-Human +
Menyiapkan…
+ +
+
+
Portrait ARCOP1
+

ARCO

MECHA
AETHER ARM200 / 200
CORE ACTIVE
+
FREE TRAINING
∞
VS
+
DEMI-HUMAN

FENR

HP 200 / 200WOLF RANGER
READY
Portrait FENRCPU
+
+ +
0
HITS0 DAMAGE
+ +
ARCOMOVE SET
+ + + + +
BELLORACOURTYARD
TOTAL DAMAGE0 DMG
BEST CHAIN0 HITS
R · RESETESC · PAUSE
+ +
✦AETHER CLASHMenyiapkan arena…
+
AETHER CLASH / SETTINGS

Pengaturan arena

Atur audio, tampilan, dan kontrol permainan.

Kontrol keyboard

A / D · Gerak   2× A / D · Lari
W · Lompat   S · Menunduk / turun cepat
2× S · Salto   SPACE · Kombo 3 pukulan
I / O · Skill   P · Ultimate

+
AETHER CLASH / PAUSE

PAUSED

Pertarungan dijeda. Siap melanjutkan?

+
+ + diff --git a/isolde.js b/isolde.js new file mode 100644 index 0000000..1989aa6 --- /dev/null +++ b/isolde.js @@ -0,0 +1,35 @@ +/* Shared ISOLDE moves (heron-leg lancer knight mecha, aether lance): player and AI use the same rules. */ +(() => { + 'use strict'; + const balance = { cooldowns:{skill1:3,skill2:6,ultimate:18}, castTime:.5, lanceDamage:16, basicRefund:.05 }; + const names = ['LANCE LINE','SKY PIERCER','STILT CHARGE','SKYFALL LANCES']; + // The lance gives a long, straight chain; reach sits inside the measured lance point (157/131/184 px in emitters.json). + const combo = [ + {damage:6,knockback:80,reach:140,duration:.38,hitAt:.5}, + {damage:8,knockback:105,reach:115,duration:.44,hitAt:.5}, + {damage:12,knockback:210,reach:165,duration:.56,hitAt:.55} + ]; + // Sky Piercer: an ice bolt fired from the raised lance on a rising line (`rise` = vertical/horizontal speed ratio), + // leaving at chest height (`y` px above the feet). It still catches a grounded rival within about 240 px and a + // jumping rival much farther away: an anti-air poke. + const piercer = { speed:900, rise:.25, y:105 }; + // Skyfall Lances: three giant ice lances dive from the sky 0.38 s apart (inside the 0.42 s hurt stun). Each one is + // aimed at the rival's feet when it is called and takes `flight` s to land, with a small shatter where it lands. + // A standing rival takes all three (48); keep moving to make them land behind you. + const skyfall = { cutinDuration:.78, calls:[.8,1.18,1.56], flight:.55, height:520, back:260, shatter:70, size:170, duration:2.4 }; + function move(name,index=1) { + if(name==='attack')return {name:`attack${index}`,type:'attack',index,...combo[index-1]}; + if(name==='skill1')return {name,type:name,damage:16,duration:.5,knockback:130,projectile:true,speed:piercer.speed,range:240,hitAt:.55}; + // Stilt Charge: a long, fast lance-first charge on the springy heron legs. + if(name==='skill2')return {name,type:name,damage:24,duration:.72,knockback:300,reach:150,dash:560,hitAt:.58}; + return {name:'ultimate',type:'ultimate',duration:balance.castTime,damage:0,hitAt:1}; + } + function start(a,name,index=1) { + if(a.action||a.state==='hurt'||a.state==='down'||a.state==='recover')return false; + if(name!=='attack'&&a.cooldowns[name]>0)return false; + if(name!=='attack')a.cooldowns[name]=balance.cooldowns[name]; + a.action={...move(name,index),t:0,fired:false,queued:0};a.state=a.action.name;a.stateTime=0;return true; + } + function refund(a) {for(const name of Object.keys(balance.cooldowns))a.cooldowns[name]=Math.max(0,a.cooldowns[name]-balance.cooldowns[name]*balance.basicRefund);} + window.Isolde={balance,names,combo,piercer,skyfall,move,start,refund}; +})(); diff --git a/match.js b/match.js new file mode 100644 index 0000000..3384ef4 --- /dev/null +++ b/match.js @@ -0,0 +1,45 @@ +/* Deterministic first-to-two round rules; announcer cue names are ready for audio. */ +(() => { + const difficulties={ + // CPU brain knobs (game.js): reaction = delay before it reads the player; evade/punish/antiAir/ender = odds of + // dodging a shot or startup, punishing recovery, anti-airing a jump, finishing a confirmed chain with a skill; + // mistakes = odds of attacking into immunity or chaining blindly; wake = extra delay after being hit; + // immunity = CPU post-hurt immunity in versus (the player always gets 0.9 s; Hard/Excellent use the same rule); + // comboCap = hits in one continuous stun before that immunity starts early (stops re-started chain loops). + easy:{label:'Easy',speed:.95,recovery:.6,reaction:.34,combo:2,skillEvery:2,ultimateAfter:10,aggression:.6,evade:.25,punish:.3,antiAir:.25,ender:.15,mistakes:.22,wake:.22,immunity:.45,comboCap:6}, + medium:{label:'Medium',speed:1.1,recovery:.38,reaction:.2,combo:3,skillEvery:1,ultimateAfter:7,aggression:.8,evade:.55,punish:.6,antiAir:.55,ender:.5,mistakes:.08,wake:.12,immunity:.9,comboCap:5}, + hard:{label:'Hard',speed:1.22,recovery:.22,reaction:.13,combo:3,skillEvery:1,ultimateAfter:5,aggression:.9,evade:.78,punish:.82,antiAir:.78,ender:.85,mistakes:.03,wake:.06,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={ + bellora:{name:'Bellora Courtyard',short:'BELLORA',subtitle:'COURTYARD',image:'assets/stage.webp',groundY:599,tag:'The city of aether',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'}, + harbor:{name:'Azure Harbor',short:'AZURE',subtitle:'HARBOR',image:'assets/menu/azure-harbor.webp',groundY:599,tag:'Where the tides meet',time:'SUNNY DAYLIGHT'}, + // 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'}, + moonrise:{name:'Moonrise Bastion',short:'MOONRISE',subtitle:'BASTION',image:'assets/menu/moonrise.webp',groundY:618,tag:'Under the full moon',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 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 tick(m,dt,pHp,eHp) { + const events=[];if(m.mode!=='versus'||m.phase==='complete')return events; + m.phaseTime+=dt; + if(m.phase==='intro') { + if(!m.fightCue && m.phaseTime>=m.fightAt){m.fightCue=true;events.push({type:'cue',cue:'fight',round:m.round});} + if(m.phaseTime>=m.introDuration){m.phase='fight';m.phaseTime=0;events.push({type:'fight'});} + } else if(m.phase==='fight') { + m.seconds=Math.max(0,m.seconds-dt); + if(pHp<=0||eHp<=0||m.seconds===0){ + const winner=pHp===eHp?'draw':pHp>eHp?'player':'enemy'; + m.lastWinner=winner;if(winner==='player')m.playerWins++;if(winner==='enemy')m.enemyWins++; + m.winner=m.playerWins===2?'player':m.enemyWins===2?'enemy':null; + m.phase='ko';m.phaseTime=0;events.push({type:'round-end',winner,timeout:m.seconds===0,doubleKO:pHp<=0&&eHp<=0}); + } + } else if(m.phase==='ko'&&m.phaseTime>=m.koDuration) { + if(m.winner){m.phase='complete';events.push({type:'complete',winner:m.winner});} + else {m.round=m.playerWins+m.enemyWins+1;m.phase='intro';m.phaseTime=0;m.seconds=90;m.fightCue=false;m.koDuration=2.2;introTiming(m);events.push({type:'next-round',round:m.round});} + } + return events; + } + window.MatchRules={create,tick,difficulties,stages}; +})(); diff --git a/menu.css b/menu.css new file mode 100644 index 0000000..b73697f --- /dev/null +++ b/menu.css @@ -0,0 +1,73 @@ +/* Aether Arm front-end: arcade typography, opposing fighter art and roster panels. */ +#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} +.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} +#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}} +.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-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-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-title h1 i{display:none} +.home-title h1 em{letter-spacing:3px}.home-caption{font-size:24px;letter-spacing:3px}.home-caption i{font-size:17px;margin:0 12px}.wordmark>span{letter-spacing:3px} +.home-caption{position:absolute;right:8%;bottom:13%;font-weight:700;font-size:33px;letter-spacing:5px;text-shadow:2px 3px #102c37}.home-caption i{font-style:normal;color:#dcb783;margin:0 20px;font-size:22px}.home-caption small{display:block;font-size:10px;letter-spacing:4px;color:#cfdfd6;text-align:center;margin-top:5px}.home-footer{position:absolute;left:4.5%;right:4.5%;bottom:22px;display:flex;justify-content:space-between;font-size:11px;letter-spacing:2px;color:#a6c0c6}.home-footer b{color:#e7c88d;margin:0 11px}.home-footer>span:last-child{color:#cbe0bc} +.selection-backdrop{background:radial-gradient(ellipse at 20% 60%,#2b514e77,transparent 40%),radial-gradient(ellipse at 82% 60%,#79534355,transparent 40%),linear-gradient(160deg,#142e3d,#0b1926 70%)}.selection-backdrop:after{content:'';position:absolute;inset:0;opacity:.13;background:repeating-linear-gradient(125deg,transparent 0 60px,#a6dbd32a 61px 62px)} +.select-header{height:107px;display:flex;align-items:center;gap:25px;padding:18px 3.5%;border-bottom:1px solid #a4c7c022;background:#0b1d2980}.menu-back{display:flex;align-items:center;gap:10px;background:transparent;border:1px solid #8cadb14d;color:#e9e6cd;padding:8px 13px;font-size:24px}.menu-back span{font-size:11px;font-weight:700;letter-spacing:1px}.select-header h1{font-size:clamp(25px,3.1vw,43px);font-weight:700;font-style:italic;line-height:1;margin:6px 0 0;letter-spacing:1.3px}.wordmark{margin-left:auto;font-weight:700;font-style:italic;font-size:22px;letter-spacing:1px;line-height:.82;position:relative;padding-right:22px;color:#f3e5bd}.wordmark>span{display:block;letter-spacing:9px;color:#a1d5c9}.wordmark i{position:absolute;right:0;top:4px;font-size:20px;color:#e8bf7c} +.selection-steps{display:flex;justify-content:center;align-items:center;gap:13px;padding-top:18px;font-size:12px;letter-spacing:1px;color:#58757d}.selection-steps span{display:flex;gap:7px;align-items:center}.selection-steps b{font-size:10px;letter-spacing:2px}.selection-steps .current{color:#f4d8a3}.selection-steps .done{color:#8bd4c3}.selection-steps>i{height:1px;width:35px;background:#55707a77} +.select-stage{display:grid;grid-template-columns:minmax(0,1fr) minmax(300px,380px) minmax(0,1fr);height:calc(100vh - 188px);max-width:1540px;margin:0 auto;padding:0 24px;align-items:center;gap:5px} +#player-preview,#enemy-preview{height:100%;min-height:410px;min-width:0;position:relative}.fighter-plinth{position:relative;height:100%;overflow:visible}.side-tag{position:absolute;top:7%;left:12%;font-size:11px;letter-spacing:2px;font-weight:700;color:#8cdacc}.fighter-plinth.enemy .side-tag{left:auto;right:12%;color:#eeb281}.plinth-light{position:absolute;left:10%;right:0;bottom:100px;top:10%;background:linear-gradient(145deg,#79d7c214,transparent);clip-path:polygon(30% 0,100% 0,75% 100%,0 100%)}.enemy .plinth-light{background:linear-gradient(220deg,#f4b77817,transparent);transform:scaleX(-1)} +.selection-art{position:absolute;left:50%;bottom:116px;height:clamp(220px,49vh,525px);max-width:130%;object-fit:contain;transform:translateX(-50%);filter:drop-shadow(0 16px 16px #020d18a6);animation:fighter-in .2s ease-out both}.enemy .selection-art{transform:translateX(-50%) scaleX(-1)}.unknown .selection-art{height:clamp(210px,39vh,370px);opacity:.45;filter:drop-shadow(0 0 12px #718c8b22)} +@keyframes fighter-in{from{opacity:.3;filter:drop-shadow(0 16px 16px #020d18a6) brightness(1.4)}to{opacity:1;filter:drop-shadow(0 16px 16px #020d18a6) brightness(1)}} +.fighter-copy{position:absolute;left:11%;right:0;bottom:9px}.enemy .fighter-copy{left:0;right:11%;text-align:right}.fighter-copy>span{font-size:10px;font-weight:700;letter-spacing:2px;color:#a0d5c9}.enemy .fighter-copy>span{color:#edbd95}.fighter-copy h2{font-size:clamp(42px,5vw,72px);font-style:italic;margin:3px 0 0;line-height:.88;letter-spacing:2px}.fighter-copy p{font-size:12px;letter-spacing:3px;font-weight:700;margin:9px 0;color:#b4c8c6}.fighter-copy small{font-size:12px;color:#839fa8} +.roster-center{padding-top:8px;position:relative;z-index:2}.roster-caption{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}.roster-caption strong{font-size:12px;letter-spacing:2px;color:#f1cf95}.roster-caption>span{font-size:9px;letter-spacing:1.5px;color:#9cb2b7}.roster-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:7px}.roster-tile{aspect-ratio:.83;min-width:0;padding:0;position:relative;overflow:hidden;border:1px solid #58787d88;background:#172b35;transform:skewX(-4deg);transition:border-color .1s,box-shadow .1s,filter .1s}.roster-tile img{width:100%;height:100%;object-fit:cover;object-position:center 20%;transform:skewX(4deg) scale(1.08);pointer-events:none}.roster-tile>span{position:absolute;bottom:0;left:0;right:0;padding:8px 2px 4px;background:linear-gradient(transparent,#081720);font-size:12px;letter-spacing:1px;font-weight:700;color:#e7e8d5}.roster-tile>b{position:absolute;top:4px;left:4px;background:#86d9c4;color:#09202b;font-size:10px;padding:2px 4px}.roster-tile>b:empty{display:none}.roster-tile.locked{filter:saturate(.35);border-color:#334953}.roster-tile.locked span{font-size:8px;color:#7b969d;letter-spacing:2px}.roster-tile.locked img{opacity:.65}.roster-tile.soon img{filter:saturate(.85) brightness(.88)}.roster-tile.highlight{border:2px solid #efce91;box-shadow:0 0 0 2px #c9954b44,0 0 18px #d7aa6524;filter:none}.roster-tile.highlight:after{content:'';position:absolute;inset:3px;border:1px solid #fff2bc66;pointer-events:none} +.fighter-moves{height:53px;text-align:center;padding-top:12px}.fighter-moves>span{display:block;font-size:10px;color:#93afa9;letter-spacing:1px;text-transform:uppercase}.fighter-moves b{display:block;font-size:14px;letter-spacing:2px;margin-top:3px;color:#eddfbc}.menu-primary{display:flex;justify-content:space-between;align-items:center;gap:20px;border:1px solid #ffe8b4;background:#e0bf85;color:#10252f;font-size:17px;font-weight:700;letter-spacing:1.5px;padding:13px 21px;cursor:pointer;transition:background .15s,transform .15s}.menu-primary:hover,.menu-primary:focus-visible{background:#ffe4b0;transform:translateY(-2px)}.menu-primary b{font-size:23px;line-height:.7}.confirm-fighter{width:100%;margin-top:11px}.roster-notice{height:16px;text-align:center;font-size:10px;color:#839fa8;letter-spacing:1px;margin:9px 0 0}.menu-primary[aria-disabled=true]{background:#536265;border-color:#718486;color:#bac2b8}.menu-primary:disabled{cursor:wait;filter:grayscale(1);opacity:.6} +.menu-footer{position:absolute;bottom:0;left:0;right:0;display:flex;justify-content:space-between;align-items:center;padding:16px 3.5%;font-size:10px;color:#8ea8ae;letter-spacing:1px;border-top:1px solid #8fbaaa22;background:#0b1c269c}.menu-footer kbd{font:700 10px Rajdhani;border:1px solid #647c8188;padding:2px 4px;color:#d6decb}.menu-footer i{display:inline-block;width:5px;height:5px;margin:0 10px;background:#91c9ae;transform:rotate(45deg)} +.arena-screen-bg{filter:brightness(.5) saturate(.85)}.arena-screen-bg:after{content:'';position:absolute;inset:0;background:linear-gradient(0deg,#0a1d2bf5,transparent 60%,#0b243388)}.arena-content{width:min(980px,88%);margin:20px auto 80px}.versus-strip{display:flex;align-items:center;justify-content:center;gap:34px;height:57px}.versus-strip>div{display:flex;align-items:center;gap:12px;min-width:175px}.versus-strip>div:last-child{justify-content:flex-end;text-align:right}.versus-strip img{width:48px;height:48px;object-fit:cover;border:1px solid #dec89c}.versus-strip span{font-size:9px;letter-spacing:2px;color:#99d2c4}.versus-strip strong{display:block;font-size:25px;font-style:italic;color:#ffedd0;line-height:1}.versus-strip>b{font-size:25px;font-style:italic;color:#e8bf7b}.arena-window{height:clamp(115px,23vh,265px);margin:17px 0 12px;border:1px solid #cde0be55;position:relative;background:linear-gradient(0deg,#07131dd6,transparent 66%);box-shadow:0 8px 35px #05172233}.arena-window>div{position:absolute;left:22px;bottom:17px}.arena-window small{font-size:11px;letter-spacing:2px;text-transform:uppercase;color:#d5e4ca}.arena-window h2{font-size:36px;font-style:italic;font-weight:700;line-height:1;margin:4px 0 0;text-transform:uppercase}.arena-number{position:absolute;top:13px;left:16px;font-size:11px;letter-spacing:3px}.arena-day{position:absolute;right:17px;bottom:18px;font-size:10px;letter-spacing:1.5px;color:#f5dfb3}.arena-tiles{display:grid;grid-template-columns:repeat(5,1fr);gap:12px}.arena-tile{height:90px;border:1px solid #a6c6ba66;padding:0;background:#152e38;position:relative;overflow:hidden}.arena-tile img{width:100%;height:100%;object-fit:cover;opacity:.7}.arena-tile>span{position:absolute;bottom:0;left:0;right:0;padding:15px 8px 6px;background:linear-gradient(transparent,#071720);font-size:14px;text-transform:uppercase;font-weight:700;letter-spacing:1px}.arena-tile.selected{border:2px solid #ebc487;box-shadow:0 0 0 2px #e6b97544}.arena-tile.selected img{opacity:1}.match-options{display:flex;align-items:center;justify-content:space-between;gap:20px;padding:18px 0}.difficulty-buttons{display:flex;gap:6px;margin-top:9px}.difficulty-buttons button{min-width:85px;background:#142d38;color:#c4d3cf;border:1px solid #789a984d;padding:7px 11px;font-size:14px;font-weight:700;text-transform:uppercase}.difficulty-buttons i{display:block;font-style:normal;font-size:9px;letter-spacing:2px;color:#718f97}.difficulty-buttons button.selected{background:#c19b60;color:#0c242e;border-color:#e8cd91}.difficulty-buttons .selected i{color:#193b3d}.match-rules{text-align:right;font-size:13px;letter-spacing:1.2px;color:#f1dab2}.match-rules span{display:block;font-size:9px;letter-spacing:1px;color:#a9c2c3;margin-top:5px}.training-note{font-size:15px;color:#d2e4d3;margin-bottom:0}.start-battle{margin-left:auto;min-width:280px}.arena-content #setup-error{font-size:12px;color:#ffcd9e;text-align:right;margin:6px 0 0} +.home-art.dim{filter:brightness(.32)}.about-panel{width:min(660px,87%);margin:35px 0 70px 12%;padding:27px 35px;background:#112935dc;border-left:3px solid #d7b87d}.about-panel h2{font-size:50px;line-height:.95;letter-spacing:1px;margin:15px 0}.about-panel em{color:#dbbd85;font-style:italic}.about-panel>p{font-size:17px;line-height:1.45;color:#b9d0d0;max-width:530px}.about-fighters{display:flex;gap:28px;border-top:1px solid #678c8655;padding-top:12px}.about-fighters p{flex:1;font-size:12px;color:#d1c497}.about-fighters span{display:block;font-size:13px;color:#9fb8bc;margin-top:6px;line-height:1.4}.about-stats{display:flex;gap:40px;margin:18px 0;color:#98b3b8;font-size:10px;letter-spacing:1px}.about-stats b{display:block;font-size:34px;line-height:1;color:#f0d49e}.about-panel>small{display:block;font-size:11px;line-height:1.7;color:#7b9ca7}.about-panel .menu-primary{margin-top:23px;width:100%} +.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(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: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(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:520px){.home-top{padding:14px 4.5%}.home-content{margin-top:0}.home-title h1{font-size:62px;margin:13px 0 18px}.home-title>span{font-size:9px}.home-title p,.home-nav small{display:none}.home-nav button{padding:7px 13px}.home-nav button>span:nth-child(2){font-size:20px}.home-caption{bottom:18%}.home-footer{bottom:12px;font-size:9px}} + +/* 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} +.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} +/* 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){.select-header{height:50px;padding:6px 3%;gap:14px}.select-header h1{font-size:21px;margin-top:2px}.selection-steps{padding-top:5px;font-size:9px;gap:9px}.selection-steps b{font-size:8px}.select-stage{height:calc(100vh - 104px);grid-template-columns:minmax(0,1fr) minmax(250px,290px) minmax(0,1fr);padding:0 12px}.roster-center{padding-top:0}.roster-caption{margin-bottom:4px}.roster-caption strong{font-size:10px}.roster-caption>span{font-size:8px}.roster-grid{gap:4px}.roster-tile{aspect-ratio:1.3}.roster-tile>span{font-size:9px;padding:5px 2px 2px}.roster-tile.locked span{font-size:7px}.fighter-moves{height:30px;padding-top:3px}.fighter-moves>span{font-size:8px}.fighter-moves b{font-size:11px;margin-top:1px}.confirm-fighter{margin-top:3px;padding:6px 12px;font-size:12px}.roster-notice{font-size:9px;margin:2px 0 0}.selection-art{height:56vh;bottom:62px}.unknown .selection-art{height:44vh}.side-tag{top:1%;font-size:9px}.fighter-copy{bottom:2px}.fighter-copy>span{font-size:8px}.fighter-copy h2{font-size:30px}.fighter-copy p{font-size:8px;margin:3px 0;letter-spacing:2px}.fighter-copy small{font-size:8px}.menu-footer{padding:5px 3%;font-size:8px}.menu-footer kbd{font-size:8px;padding:1px 3px}.arena-content{margin:4px auto 34px}.arena-window{height:24vh;margin:5px 0}.arena-window>div{left:14px;bottom:8px}.arena-window h2{font-size:22px}.arena-window small{font-size:8px}.arena-tiles{gap:6px}.arena-tiles .arena-tile{height:44px}.match-options{padding:6px 0;gap:10px}.difficulty-buttons{margin-top:4px}.difficulty-buttons button{min-width:62px;padding:4px 7px;font-size:12px}.about-panel{margin:8px 0 36px 7%;padding:12px 20px}.about-panel h2{font-size:28px;margin:6px 0}.about-panel>p{font-size:12px;margin:6px 0}.about-panel .menu-primary{margin-top:10px}.result-layout{height:calc(100vh - 50px);padding:8px 5%}} +@media(max-height:420px){.select-stage{grid-template-rows:minmax(0,1fr);align-items:stretch}.roster-center{align-self:center}.fighter-copy small{display:none}.selection-art{bottom:58px;height:54vh}.roster-notice{height:12px}} +@media(max-height:420px){#player-preview,#enemy-preview{min-height:0;height:100%}} +@media(max-height:420px){.menu-footer{display:none}.versus-strip{height:36px;gap:22px}.versus-strip img{width:30px;height:30px}.versus-strip strong{font-size:18px}.versus-strip>b{font-size:18px}.arena-window{height:17vh}.arena-tile>span{font-size:10px;padding:8px 4px 3px}.match-rules{font-size:11px}.match-rules span{margin-top:2px}.arena-content .menu-primary{padding:7px 16px;font-size:14px}.arena-content .menu-primary b{font-size:18px}.arena-content{margin-bottom:10px}.select-stage{height:calc(100vh - 76px)}} +@media(max-height:420px){.arena-number{display:none}} +/* Phone landscape result screen: art, title, score and the three buttons all above the fold. */ +@media(max-height:420px){.result-layout{height:calc(100vh - 50px);padding:6px 6%;gap:5%}.result-fighter{height:min(78vh,300px)}.result-copy{width:330px}.result-copy .eyebrow{font-size:9px}.result-copy h1{font-size:40px;margin:4px 0}.result-copy>p{font-size:13px;letter-spacing:3px;margin:2px 0}.final-score{font-size:40px;gap:16px;line-height:1.05}.final-score i{font-size:18px}.result-copy>small{font-size:10px;margin:2px 0 8px}.result-copy>.menu-primary{padding:7px 16px;font-size:14px}.result-copy>.menu-primary b{font-size:18px}.result-secondary{margin-top:5px;padding:6px;font-size:11px}} +.home-top-right{display:flex;align-items:center} +/* Out of the flow so the header keeps its height; the build tag makes room for it. */ +html.touch .home-top{padding-right:calc(4.5% + 50px)} +.menu-fullscreen{position:absolute;top:8px;right:4.5%;z-index:3;width:38px;height:38px;padding:0;display:grid;place-items:center;border:1px solid #e8d4a399;background:#0b1d29c7;color:#f5e6c2;cursor:pointer;touch-action:manipulation} +.menu-fullscreen svg{width:22px;height:22px;fill:none;stroke:currentColor;stroke-width:2.4;stroke-linecap:square} +.menu-fullscreen:active{transform:scale(.92);background:#1a3342} +.menu-fullscreen.hinted{border-color:#ffd66b;box-shadow:0 0 0 3px #ffd66b66;animation:fullscreen-glow 1.4s ease-in-out infinite} +@keyframes fullscreen-glow{0%,100%{box-shadow:0 0 0 0 #ffd66b00}50%{box-shadow:0 0 0 5px #ffd66b88}} +@media (prefers-reduced-motion:reduce){.menu-fullscreen.hinted{animation:none}} + +/* Confirmed fighter: the picture brightens 3 times (0.3 s each, within the 3-flashes-per-second limit) before the next step; no glow. */ +.roster-tile.picked{animation:pick-tile .3s steps(1,end) 3} +#player-preview.picked .selection-art,#enemy-preview.picked .selection-art{animation:pick-art .3s steps(1,end) 3} +#front-menu[data-picking] .roster-tile,#front-menu[data-picking] button{pointer-events: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)}} +@media (prefers-reduced-motion:reduce){.roster-tile.picked,#player-preview.picked .selection-art,#enemy-preview.picked .selection-art{animation:none;filter:brightness(1.35)}} diff --git a/menu.js b/menu.js new file mode 100644 index 0000000..9e2b496 --- /dev/null +++ b/menu.js @@ -0,0 +1,138 @@ +/* Front door: player -> rival -> arena/difficulty, with future locked roster slots. */ +(() => { + 'use strict'; + const root=document.querySelector('#front-menu'),game=window.__game; + const fighters={ + arco:{name:'ARCO',tag:'THE AETHER ARM',race:'MECHA',portrait:'assets/ui/arco-avatar.webp',art:'assets/menu/arco-select.webp',detail:'Steel resolve. Unbreakable spirit.',basic:'IRON CHAIN',ultimate:'HELIOS SQUADRON',style:'Brawler / drone summon',color:'#8ae3d3'}, + fenr:{name:'FENR',tag:'THE WOLF RANGER',race:'DEMI-HUMAN',portrait:'assets/fenr/ui/portrait-human.webp',art:'assets/menu/fenr-select.webp',detail:'The wild never bows.',basic:'RANGER CHAIN',ultimate:'FERAL AWAKENING',style:'Agile / werewolf transformation',color:'#eab27a'}, + ...(window.Mira&&window.MIRA_MANIFEST?{mira:{name:'MIRA',tag:'THE CANDY PILOT',race:'MECHA',portrait:'assets/mira/ui/portrait.webp',art:'assets/menu/mira-select.webp',detail:'Small pilot. Big robot. Bigger fireworks.',basic:'MITTEN CHAIN',ultimate:'ROCKET PARADE',style:'Heavy mech / rocket barrage',color:'#d9b4ff'}}:{}), + ...(window.Cora&&window.CORA_MANIFEST?{cora:{name:'CORA',tag:'THE RAVEN DANCER',race:'DEMI-HUMAN',portrait:'assets/cora/ui/portrait.webp',art:'assets/menu/cora-select.webp',detail:'Every feather is a blade.',basic:'FEATHER WALTZ',ultimate:'NIGHT MURMURATION',style:'Agile / raven swarm',color:'#c4a6ff'}}:{}), + ...(window.Naja&&window.NAJA_MANIFEST?{naja:{name:'NAJA',tag:'THE DUNE COBRA',race:'DEMI-HUMAN',portrait:'assets/naja/ui/portrait.webp',art:'assets/menu/naja-select.webp',detail:'Sways like sand. Strikes like venom.',basic:'VIPER LASH',ultimate:'DUNE SERPENT',style:'Mid-range / urumi whip & sand serpent',color:'#d9b77a'}}:{}), + ...(window.Haldor&&window.HALDOR_MANIFEST?{haldor:{name:'HALDOR',tag:'THE WALKING FORGE',race:'MECHA',portrait:'assets/haldor/ui/portrait.webp',art:'assets/menu/haldor-select.webp',detail:'Old forge master. Walking furnace. Heavy hammer.',basic:'FORGE CHAIN',ultimate:'FORGE QUAKE',style:'Heavy tank / steam forge hammer',color:'#d9803f'}}:{}), + ...(window.Zanni&&window.ZANNI_MANIFEST?{zanni:{name:'ZANNI',tag:'THE CLOCKWORK JESTER',race:'MECHA',portrait:'assets/zanni/ui/portrait.webp',art:'assets/menu/zanni-select.webp',detail:'Arms that stretch. Jokes that sting.',basic:'SCISSOR JAB',ultimate:'GRAND FINALE',style:'Trickster / extending scissor-arm strikes and bladed rings',color:'#c9d64a'}}:{}), + ...(window.Isolde&&window.ISOLDE_MANIFEST?{isolde:{name:'ISOLDE',tag:'THE WHITE STRIDER',race:'MECHA',portrait:'assets/isolde/ui/portrait.webp',art:'assets/menu/isolde-select.webp',detail:'Long legs, longer lance, zero mercy.',basic:'LANCE LINE',ultimate:'SKYFALL LANCES',style:'Long-range lancer / stilt-leg charges',color:'#9fd4ff'}}:{}), + ...(window.Rhea&&window.RHEA_MANIFEST?{rhea:{name:'RHEA',tag:'THE LITTLE ORRERY',race:'MECHA',portrait:'assets/rhea/ui/portrait.webp',art:'assets/menu/rhea-select.webp',detail:'Homework done. Planets armed. Class dismissed.',basic:'ORBIT STRIKE',ultimate:'GRAND ORRERY',style:'Zoner / orbiting orrery spheres',color:'#c7485e'}}:{}), + ...(window.Solan&&window.SOLAN_MANIFEST?{solan:{name:'SOLAN',tag:'THE SUNMANE',race:'DEMI-HUMAN',portrait:'assets/solan/ui/portrait.webp',art:'assets/menu/solan-select.webp',detail:'Golden mane, heavy blade, louder roar.',basic:'SUNBLADE',ultimate:'SUNMANE ROAR',style:'Powerhouse / greatsword and roar',color:'#f2b632'}}:{}), + ...(window.Nib&&window.NIB_MANIFEST?{nib:{name:'NIB',tag:'THE ROOFTOP POST',race:'DEMI-HUMAN',portrait:'assets/nib/ui/portrait.webp',art:'assets/menu/nib-select.webp',detail:'Every letter delivered. Every punch, too.',basic:'BATON FLURRY',ultimate:'SPECIAL DELIVERY',style:'Rushdown / twin courier batons',color:'#8fd4ff'}}:{}), + ...(window.Edda&&window.EDDA_MANIFEST?{edda:{name:'EDDA',tag:'THE SHELL SAGE',race:'DEMI-HUMAN',portrait:'assets/edda/ui/portrait.webp',art:'assets/menu/edda-select.webp',detail:'Slow to anger, impossible to knock down.',basic:'STAFF FORMS',ultimate:'ELDER TORTOISE',style:'Counter defender / staff and shell guard',color:'#8fe3b4'}}:{}) + }; + // Showcase fighters (assets/showcase/showcase.js) fill roster slots with avatar and art; they cannot be confirmed yet. + const showcase=Object.fromEntries(Object.entries(window.SHOWCASE_FIGHTERS||{}).filter(([id])=>!fighters[id])); + const info=id=>fighters[id]||showcase[id],soon=id=>!!showcase[id]; + const opened=[...Object.keys(fighters),...Object.keys(showcase)]; + // Touch screens have no hover: a tap only previews a fighter; the yellow CONFIRM button picks it. + const touch=document.documentElement.classList.contains('touch'); + // Phones get a fullscreen button on the home screen (no trip to Settings). Hidden where the page cannot go fullscreen + // (iPhone Safari); inside the mobile shell the shell's document is the one that goes fullscreen. + const canFullscreen=touch&&(()=>{try{const d=window.parent!==window?window.parent.document:document;return !!(d.fullscreenEnabled||d.webkitFullscreenEnabled);}catch(_){return false;}})(); + // Until the phone has been fullscreen once (button or the shell's first-touch fullscreen), the button glows. + const hintKey='aether.fullscreenHint'; + let fullscreenHint=canFullscreen&&(()=>{try{return localStorage.getItem(hintKey)!=='seen';}catch(_){return true;}})(); + function dismissFullscreenHint(){if(!fullscreenHint)return;fullscreenHint=false;try{localStorage.setItem(hintKey,'seen');}catch(_){}root.querySelector('.menu-fullscreen')?.classList.remove('hinted');} + if(fullscreenHint)try{const d=window.parent!==window?window.parent.document:document;d.addEventListener('fullscreenchange',()=>{if(d.fullscreenElement)dismissFullscreenHint();});}catch(_){} + // Warm the small roster portraits so the grid appears at once. The large select art and arena pictures are fetched when + // shown and then come from the browser cache; holding all of them decoded would waste a phone's image memory. + const warm=opened.map(id=>info(id).portrait).filter(Boolean).map(src=>{const im=new Image();im.decoding='async';im.src=src;return im;}); + const roster=[...opened,...Array.from({length:Math.max(0,12-opened.length)},(_,i)=>'locked-'+i)]; + // lockIn: the pending step change while a confirmed fighter flashes 3 times (PICK_FLASH s each); input waits for it. + const PICK_FLASH=.3;let lockIn=null,lockTicks=[]; + let screen='home',step=0,mode='versus',player='arco',enemy='fenr',hover='arco',stage='bellora',level='medium',result=null,interacted=false,lastSound=0; + const reducedMotion=window.matchMedia?.('(prefers-reduced-motion: reduce)'); + let backgroundEnabled=!reducedMotion?.matches,backgroundVideo=null,videoPlayToken=0; + const backgroundToggle=document.querySelector('#menu-video-toggle'); + function shouldAnimate(){return backgroundEnabled && screen==='home' && !root.hidden && !document.hidden && !game.snapshot().settingsOpen;} + function syncBackground(){ + if(!backgroundVideo)return;const token=++videoPlayToken; + if(!shouldAnimate()){backgroundVideo.pause();return;} + if(backgroundVideo.paused){const p=backgroundVideo.play();p?.catch(()=>{if(token===videoPlayToken)backgroundVideo.dataset.state='autoplay-blocked';});} + } + function attachBackground(){ + if(screen!=='home')return; + 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); + 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); + const brand='AETHERCLASH✦'; + const footer=()=>'
W A S D / ↑ ↓ ← → SELECT   ENTER CONFIRM   ESC BACKLOCAL PLAY BUILD 02
'; + function navHeader(title,subtitle){return `
${subtitle}

${title}

${brand}
`;} + function fighterPanel(id,side){const f=info(id);return `
${side==='player'?'P1 / YOUR FIGHTER':'CPU / YOUR RIVAL'}${f?.name||'Karakter belum dipilih'}
${f?.tag||'AWAITING CHALLENGER'}

${f?.name||'???'}

${f?.race||'ROSTER EXPANDING'}

${f?.detail||'A new challenger is on the way.'}
`;} + function render(){ + 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; + if(screen==='home'){ + root.innerHTML=`
FIGHTING DEMO / VOL. 02${canFullscreen?``:''}

AETHER
CLASH✦

Two factions. One arena.

MECHAVSDEMI-HUMANTECHNOLOGY MEETS INSTINCT
${game.snapshot().ready?'READY TO FIGHT':'PREPARING ARENA…'}
`; + } else if(screen==='select'){ + root.innerHTML=`
${navHeader(step===0?'SELECT YOUR FIGHTER':'SELECT YOUR RIVAL',mode==='versus'?'VERSUS COMPUTER':'TRAINING ROOM')}
01 PLAYER02 RIVAL03 ARENA
${fighterPanel(step===0?hover:player,'player')}
${step===0?'PLAYER 1':'COMPUTER'}CHOOSE A FIGHTER
${roster.map((id,i)=>{const f=info(id);return ``;}).join('')}

${roster.length>opened.length?(roster.length-opened.length)+' challenger slots reserved.':''}

${fighterPanel(step===1?hover:null,'enemy')}
${footer()}`; + updatePreview(false); + } else if(screen==='arena'){ + const st=MatchRules.stages[stage];root.innerHTML=`
${navHeader('SET THE STAGE',mode==='versus'?'VERSUS COMPUTER / MATCH SETUP':'TRAINING / ARENA SETUP')}
P1${fighters[player].name}
VS
CPU${fighters[enemy].name}
${String(Object.keys(MatchRules.stages).indexOf(stage)+1).padStart(2,'0')} / ${String(Object.keys(MatchRules.stages).length).padStart(2,'0')}
${st.tag}

${st.name}

✦ ${st.time||'SUNNY DAYLIGHT'}
${Object.entries(MatchRules.stages).map(([id,a])=>``).join('')}
${mode==='versus'?'CPU DIFFICULTY':'PRACTICE MODE'}${mode==='versus'?`
${Object.entries(MatchRules.difficulties).map(([id,d],i)=>``).join('')}
`:'

Unlimited time · Passive rival · Reset anytime

'}
${mode==='versus'?'FIRST TO 2 WINSBEST OF 3 ROUNDS · 90 SECONDS':'MAKE EVERY HIT COUNTCOMBO TRACKER · FULL MOVE SET'}

${footer()}`; + } else if(screen==='about'){ + root.innerHTML=`
${navHeader('ABOUT AETHER CLASH','MECHA VS DEMI-HUMAN')}
CHRONICLES OF AETHER

Steel meets
the untamed.

Di dunia Renaisans yang ditenagai aether, dua faksi bertemu di arena: Mecha dengan rekayasa tubuh dan mesin, serta Demi-Human dengan kekuatan manusia dan hewan. Para petarung membawa asal-usul, kemampuan, dan tujuan mereka sendiri.

MECHAPerpaduan manusia dan teknologi. Rekayasa mekanis, persenjataan aether, dan teknik bertarung yang beragam.

DEMI-HUMANPerpaduan manusia dan hewan. Setiap ras memiliki anatomi, naluri, dan kekuatan khasnya sendiri.

02FACTIONS12ROSTER SLOTS∞RIVALRIES
Visuals · Higgsfield   Voices · ElevenLabs   Typography · Rajdhani
Local fighting demo · Single player
`; + } else if(screen==='result'){ + const won=result.winner==='player',winner=won?result.player:result.enemy;root.innerHTML=`
${navHeader('MATCH COMPLETE',MatchRules.stages[result.stage].name.toUpperCase())}
${fighters[winner].name}
${won?'PLAYER 1':'COMPUTER'} TAKES THE MATCH

${won?'VICTORY':'DEFEAT'}

${fighters[winner].name} WINS

${result.playerWins}—${result.enemyWins}
${fighters[result.player].name} vs ${fighters[result.enemy].name} · ${MatchRules.difficulties[result.level].label}
`; + } + bind();attachBackground(); + } + function updatePreview(play=true){ + root.querySelectorAll('[data-fighter]').forEach(b=>b.classList.toggle('highlight',b.dataset.fighter===hover)); + const panel=root.querySelector(step===0?'#player-preview':'#enemy-preview');if(panel)panel.innerHTML=fighterPanel(hover,step===0?'player':'enemy'); + const f=info(hover),moves=root.querySelector('#fighter-moves');if(moves)moves.innerHTML=f?`${f.style}${soon(hover)?'':`${f.ultimate}`}`:'NEW CHALLENGERCOMING LATER'; + const confirm=root.querySelector('.confirm-fighter');if(confirm)confirm.setAttribute('aria-disabled',String(!fighters[hover]));if(play)sfx('move'); + } + function focusFighter(id){if(hover===id||lockIn)return;hover=id;updatePreview();} + function confirm(){if(lockIn)return;if(!fighters[hover]){sfx('locked',true);root.querySelector('#roster-notice').textContent=soon(hover)?showcase[hover].name+' belum bisa dimainkan.':'Karakter ini belum terbuka.';return;} + // The pick flashes 3 times (tile and big art) with a lock-in chime before the next step, so the screen does not jump. + sfx('pick',true);game.announceSelection(hover);root.dataset.picking='true'; + root.querySelector(`[data-fighter="${hover}"]`)?.classList.add('picked');root.querySelector(step===0?'#player-preview':'#enemy-preview')?.classList.add('picked'); + lockTicks=[1,2].map(i=>setTimeout(()=>sfx('blink',true),i*PICK_FLASH*1000)); + lockIn=setTimeout(()=>{lockIn=null;delete root.dataset.picking;if(step===0){player=hover;step=1;hover=player==='arco'?'fenr':'arco';}else{enemy=hover;screen='arena';step=2;}render();},3*PICK_FLASH*1000); + } + function back(){if(lockIn)return;game.stopAnnouncer();sfx('back',true);if(screen==='arena'){screen='select';step=1;hover=enemy;}else if(screen==='select'&&step===1){step=0;hover=player;}else screen='home';render();} + function start(){if(game.startMatch({player,enemy,stage,level,mode})){document.querySelector('#arena').focus();}else {const error=root.querySelector('#setup-error');if(error)error.textContent='Aset belum siap. Tunggu sebentar lalu coba lagi.';sfx('locked',true);}} + function command(cmd){ + if(lockIn)return; + if(cmd==='fullscreen'){sfx('move',true);dismissFullscreenHint();game.toggleFullscreen?.();return;} + if(cmd==='confirm'){confirm();return;}if(cmd==='back'){back();return;}sfx('confirm',true); + if(cmd==='versus'||cmd==='training'){mode=cmd;screen='select';step=0;hover=player;render();} + if(cmd==='settings')game.openSettings();if(cmd==='about'){screen='about';render();} + if(cmd==='home'){game.stopAnnouncer();screen='home';render();}if(cmd==='start')start(); + if(cmd==='rematch'){({player,enemy,stage,level,mode}=result);start();} + if(cmd==='reselect'){screen='select';step=0;hover=player;render();} + } + function bind(){ + root.querySelectorAll('[data-cmd]').forEach(b=>{b.onclick=()=>command(b.dataset.cmd);b.onpointerenter=()=>sfx('move');b.onfocus=()=>sfx('move');}); + root.querySelectorAll('[data-fighter]').forEach(b=>{b.onpointerenter=()=>focusFighter(b.dataset.fighter);b.onfocus=()=>focusFighter(b.dataset.fighter);b.onclick=()=>{focusFighter(b.dataset.fighter);if(!touch)confirm();};}); + root.querySelectorAll('[data-stage]').forEach(b=>b.onclick=()=>{stage=b.dataset.stage;sfx('move',true);render();root.querySelector(`[data-stage="${stage}"]`).focus();}); + root.querySelectorAll('[data-level]').forEach(b=>b.onclick=()=>{level=b.dataset.level;sfx('move',true);render();root.querySelector(`[data-level="${level}"]`).focus();}); + } + document.addEventListener('keydown',e=>{ + if(root.hidden||document.querySelector('dialog[open]')||e.repeat)return;interacted=true; + if(lockIn){e.preventDefault();return;} + if(e.key==='Escape'){e.preventDefault();back();return;} + if(screen==='select'&&['ArrowLeft','ArrowRight','ArrowUp','ArrowDown','a','d','w','s'].includes(e.key)){e.preventDefault();const offset={ArrowLeft:-1,a:-1,ArrowRight:1,d:1,ArrowUp:-4,w:-4,ArrowDown:4,s:4}[e.key],i=(roster.indexOf(hover)+offset+roster.length)%roster.length;focusFighter(roster[i]);root.querySelector(`[data-fighter="${hover}"]`).focus();return;} + if(screen==='select'&&(e.key==='Enter'||e.key===' ')){const cmd=e.target.closest?.('[data-cmd]')?.dataset.cmd;if(cmd&&cmd!=='confirm')return;e.preventDefault();confirm();return;} + if(screen==='arena'&&['ArrowLeft','ArrowRight','ArrowUp','ArrowDown','a','d','w','s'].includes(e.key)){ + e.preventDefault();const levels=Object.keys(MatchRules.difficulties),stages=Object.keys(MatchRules.stages),vertical=['ArrowUp','ArrowDown','w','s'].includes(e.key),backward=['ArrowLeft','ArrowUp','a','w'].includes(e.key); + if(vertical&&mode==='versus'){level=levels[(levels.indexOf(level)+(backward?-1:1)+levels.length)%levels.length];}else{stage=stages[(stages.indexOf(stage)+(backward?-1:1)+stages.length)%stages.length];}sfx('move');render();return; + } + if(screen==='arena'&&e.key==='Enter'&&!e.target.closest?.('button')){e.preventDefault();command('start');return;} + if(screen==='home'&&e.key==='Enter'&&!e.target.closest?.('button')){e.preventDefault();command('versus');return;} + if(screen==='home'&&['ArrowUp','ArrowDown','w','s'].includes(e.key)){e.preventDefault();const list=[...root.querySelectorAll('.home-nav button')],current=list.indexOf(document.activeElement),next=(current+(['ArrowUp','w'].includes(e.key)?-1:1)+list.length)%list.length;list[next].focus();} + }); + window.FrontEnd={syncBackground,home(){screen='home';game.setMenuOpen(true);render();},showResult(data){result=data;({player,enemy,stage,level,mode}=data);screen='result';game.setMenuOpen(true);render();},assetsReady(ok=true){if(game.snapshot().menuOpen){render();if(!ok){const status=root.querySelector('#menu-ready');if(status)status.textContent='ASSET ERROR · RELOAD REQUIRED';}}}}; + // First paint after the loading screen's download, so menu pictures come straight from the cache. + Promise.resolve(window.AETHER_PRELOAD).then(render); +})(); diff --git a/mira.js b/mira.js new file mode 100644 index 0000000..885e6ef --- /dev/null +++ b/mira.js @@ -0,0 +1,29 @@ +/* Shared MIRA moves (pilot girl + lilac robot): player and AI use the same rules. */ +(() => { + 'use strict'; + const balance = { cooldowns:{skill1:3,skill2:6,ultimate:18}, castTime:.5, rockets:12, rocketDamage:4, basicRefund:.05 }; + const names = ['MITTEN CHAIN','STAR POPPER','CANDY CRASH','ROCKET PARADE']; + // Reach follows the measured fist emitters of the 182 px atlas (assets/mira/emitters.json): jab 98, hook 80, hammer 56 px. + const combo = [ + {damage:6,knockback:75,reach:100,duration:.36,hitAt:.5}, + {damage:8,knockback:100,reach:85,duration:.42,hitAt:.5}, + {damage:12,knockback:180,reach:82,duration:.52,hitAt:.55} + ]; + // Rocket Parade timeline (seconds from cast). Launches stream from alternating pods; impacts ripple 0.032 s + // apart so all 12 land inside one hurt window (player invulnerability only starts after hurt ends). + const parade = { cutinDuration:.78, launchStart:.40, launchGap:.05, impactStart:1.40, impactGap:.032, duration:2.3 }; + function move(name,index=1) { + if(name==='attack')return {name:`attack${index}`,type:'attack',index,...combo[index-1]}; + if(name==='skill1')return {name,type:name,damage:16,duration:.48,knockback:140,projectile:true,speed:760,hitAt:.5}; + if(name==='skill2')return {name,type:name,damage:24,duration:.68,knockback:220,reach:128,dash:240,hitAt:.56}; + return {name:'ultimate',type:'ultimate',duration:balance.castTime,damage:0,hitAt:1}; + } + function start(a,name,index=1) { + if(a.action||a.state==='hurt'||a.state==='down'||a.state==='recover')return false; + if(name!=='attack'&&a.cooldowns[name]>0)return false; + if(name!=='attack')a.cooldowns[name]=balance.cooldowns[name]; + a.action={...move(name,index),t:0,fired:false,queued:0};a.state=a.action.name;a.stateTime=0;return true; + } + function refund(a) {for(const name of Object.keys(balance.cooldowns))a.cooldowns[name]=Math.max(0,a.cooldowns[name]-balance.cooldowns[name]*balance.basicRefund);} + window.Mira={balance,names,combo,parade,move,start,refund}; +})(); diff --git a/mobile.html b/mobile.html new file mode 100644 index 0000000..7372572 --- /dev/null +++ b/mobile.html @@ -0,0 +1,66 @@ + + + + + + + + +AETHER CLASH — Mecha vs Demi-Human + + + + + + + + diff --git a/naja.js b/naja.js new file mode 100644 index 0000000..45fdf50 --- /dev/null +++ b/naja.js @@ -0,0 +1,33 @@ +/* Shared NAJA moves (cobra demi-human, urumi whip-sword): player and AI use the same rules. */ +(() => { + 'use strict'; + const balance = { cooldowns:{skill1:3,skill2:6,ultimate:18}, castTime:.5, biteDamage:16, basicRefund:.05 }; + const names = ['VIPER LASH','SAND FANG','URUMI CYCLONE','DUNE SERPENT']; + // The urumi gives NAJA the longest basic reach in the roster (tips measured at 161/161/196 px; the hit reach sits + // inside the tip); the chain is a touch slower to compensate. + const combo = [ + {damage:6,knockback:75,reach:135,duration:.38,hitAt:.5}, + {damage:8,knockback:100,reach:130,duration:.44,hitAt:.5}, + {damage:12,knockback:180,reach:160,duration:.56,hitAt:.55} + ]; + // Dune Serpent: a sand ripple leaves her palm at `follow` s, chases the rival at `speed` px/s (faster than a run) and + // locks `telegraph` s before each strike, then a giant sand cobra erupts there. Three strikes x 16 (48). The column + // is too tall to jump; stepping out of the locked ripple dodges it. A rival more than ~800 px away escapes the first bite. + const serpent = { cutinDuration:.78, strikes:[1.25,1.65,2.05], follow:.1, speed:900, telegraph:.42, radius:70, height:240, rise:.45, duration:2.7 }; + function move(name,index=1) { + if(name==='attack')return {name:`attack${index}`,type:'attack',index,...combo[index-1]}; + // Sand Fang: a low sand wave along the floor, 16 like other I skills. Any small jump clears it. + if(name==='skill1')return {name,type:name,damage:16,duration:.5,knockback:120,projectile:true,ground:true,speed:620,hitAt:.55}; + // Urumi Cyclone: the blade spins all the way around her and hits in front and behind. + if(name==='skill2')return {name,type:name,damage:24,duration:.66,knockback:260,reach:150,area:true,around:true,hitAt:.55}; + return {name:'ultimate',type:'ultimate',duration:balance.castTime,damage:0,hitAt:1}; + } + function start(a,name,index=1) { + if(a.action||a.state==='hurt'||a.state==='down'||a.state==='recover')return false; + if(name!=='attack'&&a.cooldowns[name]>0)return false; + if(name!=='attack')a.cooldowns[name]=balance.cooldowns[name]; + a.action={...move(name,index),t:0,fired:false,queued:0};a.state=a.action.name;a.stateTime=0;return true; + } + function refund(a) {for(const name of Object.keys(balance.cooldowns))a.cooldowns[name]=Math.max(0,a.cooldowns[name]-balance.cooldowns[name]*balance.basicRefund);} + window.Naja={balance,names,combo,serpent,move,start,refund}; +})(); diff --git a/nib.js b/nib.js new file mode 100644 index 0000000..569b31a --- /dev/null +++ b/nib.js @@ -0,0 +1,35 @@ +/* Shared NIB moves (mouse demi-human courier, twin batons): player and AI use the same rules. */ +(() => { + 'use strict'; + const balance = { cooldowns:{skill1:3,skill2:6,ultimate:18}, castTime:.5, planeDamage:16, basicRefund:.05 }; + const names = ['BATON FLURRY','EXPRESS LETTER','ROOFTOP SLIP','SPECIAL DELIVERY']; + // The fastest chain in the roster with the shortest reach (baton tips measured at 107/80/101 px in emitters.json); + // small knockback keeps him close. + const combo = [ + {damage:6,knockback:55,reach:95,duration:.3,hitAt:.5}, + {damage:8,knockback:75,reach:75,duration:.34,hitAt:.5}, + {damage:12,knockback:170,reach:95,duration:.46,hitAt:.55} + ]; + // Express Letter: the fastest projectile in the roster, but it only flies `range` px. + const letter = { speed:1100, range:400 }; + // Rooftop Slip: a very fast low dash strike; when it connects NIB slips past the rival and turns to face them. + const slip = { dash:650, behind:70 }; + // Special Delivery: three paper-plane parcels leave the satchel 0.38 s apart (inside the 0.42 s hurt stun) and home + // in on the rival at `speed` px/s, turning at most `turn` rad/s. Each hits once for 16 (48). A late sidestep or jump + // makes a plane overshoot, and it gives up after `life` s. + const delivery = { cutinDuration:.78, releases:[.8,1.18,1.56], speed:620, turn:3.2, life:2.2, size:96, duration:2.6 }; + function move(name,index=1) { + if(name==='attack')return {name:`attack${index}`,type:'attack',index,...combo[index-1]}; + if(name==='skill1')return {name,type:name,damage:16,duration:.45,knockback:110,projectile:true,speed:letter.speed,range:letter.range,hitAt:.55}; + if(name==='skill2')return {name,type:name,damage:24,duration:.6,knockback:160,reach:100,dash:slip.dash,slip:true,hitAt:.45}; + return {name:'ultimate',type:'ultimate',duration:balance.castTime,damage:0,hitAt:1}; + } + function start(a,name,index=1) { + if(a.action||a.state==='hurt'||a.state==='down'||a.state==='recover')return false; + if(name!=='attack'&&a.cooldowns[name]>0)return false; + if(name!=='attack')a.cooldowns[name]=balance.cooldowns[name]; + a.action={...move(name,index),t:0,fired:false,queued:0};a.state=a.action.name;a.stateTime=0;return true; + } + function refund(a) {for(const name of Object.keys(balance.cooldowns))a.cooldowns[name]=Math.max(0,a.cooldowns[name]-balance.cooldowns[name]*balance.basicRefund);} + window.Nib={balance,names,combo,letter,slip,delivery,move,start,refund}; +})(); diff --git a/precache.js b/precache.js new file mode 100644 index 0000000..cc19ccf --- /dev/null +++ b/precache.js @@ -0,0 +1,2 @@ +/* Generated by tools/build_dist.mjs: runtime files [path, bytes, sha1] for preload.js and sw.js. Do not edit. */ +self.AETHER_PRECACHE = {"version":"7cd7de58ae91","total":33861851,"files":[["index.html",12682,"d65f84207532"],["mobile.html",3704,"225e36d7d946"],["style.css",25732,"f7b4911d63c1"],["menu.css",28728,"45a1fea806f6"],["preload.js",4166,"a69988b1fe61"],["game.js",170044,"641abb004541"],["menu.js",24312,"a1326a3f5c1c"],["match.js",4262,"4968339c22d4"],["announcer.js",3135,"12d9e83e83d1"],["touch.js",4896,"843a3f092171"],["fenr.js",2941,"6dd8746d0c8f"],["mira.js",2052,"6f4414b0fd75"],["cora.js",2302,"74444cce4384"],["naja.js",2509,"2b8de54cfe73"],["haldor.js",2677,"720ee4de987e"],["zanni.js",2799,"06141ea78ce2"],["isolde.js",2635,"acfbce8a5f82"],["rhea.js",2656,"c792766df1cd"],["solan.js",2701,"65c9c0b3be4b"],["nib.js",2524,"97eb63f05fcf"],["edda.js",2832,"4feab9b48cda"],["assets/ui/drone.png",18724,"474894562b64"],["assets/menu/locked.svg",697,"acb96b08bdc2"],["assets/audio/announcer/arco_wins.mp3",23031,"4e3734fb863a"],["assets/audio/announcer/cora_wins.mp3",21359,"ed104161e99f"],["assets/audio/announcer/double_ko.mp3",24285,"5b0ba10cfa89"],["assets/audio/announcer/draw.mp3",17598,"eb92b764316c"],["assets/audio/announcer/edda_wins.mp3",17598,"325542899ab8"],["assets/audio/announcer/fenr_wins.mp3",24285,"9fe9cb3a833b"],["assets/audio/announcer/fight.mp3",17598,"8a64a7e3d192"],["assets/audio/announcer/haldor_wins.mp3",21359,"797be497cccb"],["assets/audio/announcer/isolde_wins.mp3",24285,"d50703c1287a"],["assets/audio/announcer/ko.mp3",11328,"f3106c569e34"],["assets/audio/announcer/manifest.js",3383,"b7101298c2b0"],["assets/audio/announcer/mira_wins.mp3",26793,"33d1e0f8ded3"],["assets/audio/announcer/naja_wins.mp3",23031,"fda44264a05b"],["assets/audio/announcer/nib_wins.mp3",23031,"0790031653fd"],["assets/audio/announcer/rhea_wins.mp3",21359,"c62a5d27ce5b"],["assets/audio/announcer/round_1.mp3",17598,"fdcce639d88d"],["assets/audio/announcer/round_2.mp3",35570,"2598ef54b8c4"],["assets/audio/announcer/round_3.mp3",21359,"83ec7e4ec306"],["assets/audio/announcer/select_arco.mp3",15090,"b0856ea865dc"],["assets/audio/announcer/select_cora.mp3",15090,"3669ff4be89c"],["assets/audio/announcer/select_edda.mp3",16344,"c7b6a9a9175f"],["assets/audio/announcer/select_fenr.mp3",17598,"a7770529a39c"],["assets/audio/announcer/select_haldor.mp3",18852,"ae21c9d5c3d0"],["assets/audio/announcer/select_isolde.mp3",11328,"2baee6922fc2"],["assets/audio/announcer/select_mira.mp3",21359,"21bb3ca4e22f"],["assets/audio/announcer/select_naja.mp3",18852,"1bd57158cba1"],["assets/audio/announcer/select_nib.mp3",15090,"5a8ff37811c4"],["assets/audio/announcer/select_rhea.mp3",16344,"1a098bdd4489"],["assets/audio/announcer/select_solan.mp3",15090,"00cd3d422862"],["assets/audio/announcer/select_zanni.mp3",18852,"af46d57d9cb8"],["assets/audio/announcer/solan_wins.mp3",22612,"9b605ba42f1d"],["assets/audio/announcer/time_up.mp3",20106,"e59e9a60345e"],["assets/audio/announcer/zanni_wins.mp3",24284,"438a81d03242"],["assets/audio/arco-ultimate-dylan.mp3",36824,"0d6136cbf82a"],["assets/audio/music/midday-showdown.mp3",1571832,"65d22abb18d9"],["assets/cora/audio/cora-ultimate-anika.mp3",28882,"497a73251c21"],["assets/cora/audio/cora_wins.mp3",21359,"ed104161e99f"],["assets/cora/audio/select_cora.mp3",15090,"3669ff4be89c"],["assets/cora/manifest.js",11440,"8b4660138027"],["assets/cora/run/sprite-sheet-alpha.webp",1500730,"f3a41842ac41"],["assets/cora/ui/cutin.webp",189114,"7e272f03bad6"],["assets/cora/ui/fx-feather.webp",5044,"e7eb665a13f2"],["assets/cora/ui/fx-gust.webp",14152,"c28b6d68f27d"],["assets/cora/ui/fx-raven.webp",10750,"dd8dd61b3440"],["assets/cora/ui/icon-basic.webp",21044,"0e4033475768"],["assets/cora/ui/icon-skill1.webp",17310,"30c0d7b1ab06"],["assets/cora/ui/icon-skill2.webp",19240,"242a5f1b6f75"],["assets/cora/ui/icon-ultimate.webp",28518,"ad50e2965363"],["assets/cora/ui/portrait.webp",37954,"99888687708d"],["assets/edda/audio/edda-ultimate.mp3",23030,"fa1d125a2477"],["assets/edda/audio/edda_wins.mp3",17598,"325542899ab8"],["assets/edda/audio/select_edda.mp3",16344,"c7b6a9a9175f"],["assets/edda/manifest.js",12097,"09198409baae"],["assets/edda/run/sprite-sheet-alpha.webp",1595536,"65a5f15d5b63"],["assets/edda/ui/cutin.webp",264670,"b7ae1481b87e"],["assets/edda/ui/fx-ripple.webp",6940,"aec084176256"],["assets/edda/ui/fx-shell.webp",17640,"04805c01ce8b"],["assets/edda/ui/fx-stomp.webp",13468,"0e850f78f460"],["assets/edda/ui/fx-stone.webp",7698,"d7aef6ed78ec"],["assets/edda/ui/fx-tortoise.webp",60852,"38d379cc8edc"],["assets/edda/ui/icon-basic.webp",26148,"7b6c2d5812a4"],["assets/edda/ui/icon-skill1.webp",20836,"425f5004fc1b"],["assets/edda/ui/icon-skill2.webp",23314,"b4bcd5746cf2"],["assets/edda/ui/icon-ultimate.webp",31242,"29c09a7392b7"],["assets/edda/ui/portrait.webp",34176,"eef0b3e18d5b"],["assets/fenr/audio/fenr-ultimate-holden.mp3",23031,"4d15d464b495"],["assets/fenr/human/manifest.js",11411,"70446f69a35c"],["assets/fenr/human/run/sprite-sheet-alpha.webp",1486412,"0d6f47bb4cb5"],["assets/fenr/ui/cutin.webp",119558,"538658b309df"],["assets/fenr/ui/fx-bite.webp",8948,"b257d8dad484"],["assets/fenr/ui/fx-claw.webp",7178,"780d656a1837"],["assets/fenr/ui/fx-gale.webp",4970,"54bbac66f730"],["assets/fenr/ui/fx-howl.webp",10784,"3837b651a7a6"],["assets/fenr/ui/fx-rush.webp",10026,"ee5f015089dd"],["assets/fenr/ui/fx-transform.webp",15202,"aa9e6402f320"],["assets/fenr/ui/icon-human-basic.webp",11026,"a51720ccc2a1"],["assets/fenr/ui/icon-human-skill1.webp",6122,"028f0e547941"],["assets/fenr/ui/icon-human-skill2.webp",9938,"a04cc8d9767b"],["assets/fenr/ui/icon-ultimate.webp",14998,"bc755f2f7468"],["assets/fenr/ui/icon-wolf-basic.webp",15864,"54aeb1d66b05"],["assets/fenr/ui/icon-wolf-skill1.webp",10442,"0d5ada7ae5d2"],["assets/fenr/ui/icon-wolf-skill2.webp",8652,"a83241200084"],["assets/fenr/ui/portrait-human.webp",24324,"db4f7dbaea08"],["assets/fenr/ui/portrait-wolf.webp",27618,"3593d886c6e6"],["assets/fenr/wolf/manifest.js",11436,"1cebdd992920"],["assets/fenr/wolf/run/sprite-sheet-alpha.webp",2360632,"c884ad55b290"],["assets/fonts/Rajdhani-Bold.ttf",400680,"fb82c1992ff1"],["assets/fonts/Rajdhani-Medium.ttf",384348,"7837ff8a9aea"],["assets/haldor/audio/haldor-ultimate.mp3",20106,"3be059e79b78"],["assets/haldor/audio/haldor_wins.mp3",21359,"797be497cccb"],["assets/haldor/audio/select_haldor.mp3",18852,"ae21c9d5c3d0"],["assets/haldor/manifest.js",11455,"3f37b108ff62"],["assets/haldor/run/sprite-sheet-alpha.webp",1950620,"058f28bc666d"],["assets/haldor/ui/cutin.webp",226036,"63414893dd24"],["assets/haldor/ui/fx-quake.webp",12184,"c948acf2f3d3"],["assets/haldor/ui/fx-slag.webp",11300,"ead5ca7fc990"],["assets/haldor/ui/fx-splash.webp",9562,"657a5895d023"],["assets/haldor/ui/fx-steam.webp",6432,"3139a435db74"],["assets/haldor/ui/icon-basic.webp",28334,"582f2db1f96c"],["assets/haldor/ui/icon-skill1.webp",16502,"13259e085c92"],["assets/haldor/ui/icon-skill2.webp",23904,"a0e53a6758ce"],["assets/haldor/ui/icon-ultimate.webp",31006,"ae9317a6b879"],["assets/haldor/ui/portrait.webp",46308,"cbd54572adfa"],["assets/isolde/audio/isolde-ultimate.mp3",30972,"dbda1e78418a"],["assets/isolde/audio/isolde_wins.mp3",24285,"d50703c1287a"],["assets/isolde/audio/select_isolde.mp3",11328,"2baee6922fc2"],["assets/isolde/manifest.js",11797,"994e9e8cd364"],["assets/isolde/run/sprite-sheet-alpha.webp",1550448,"ca6ff6b4838a"],["assets/isolde/ui/cutin.webp",226498,"e9685c940967"],["assets/isolde/ui/fx-frost.webp",8318,"e07ad936ebcb"],["assets/isolde/ui/fx-piercer.webp",6690,"5a43bb26d4af"],["assets/isolde/ui/fx-shatter.webp",15232,"fe5605238ccc"],["assets/isolde/ui/fx-skyfall.webp",7412,"511cf5f5a9e7"],["assets/isolde/ui/icon-basic.webp",14166,"a2511738024f"],["assets/isolde/ui/icon-skill1.webp",20634,"c2045c76e0fc"],["assets/isolde/ui/icon-skill2.webp",23448,"f2b7d4ddbb03"],["assets/isolde/ui/icon-ultimate.webp",26876,"2febf3ff9891"],["assets/isolde/ui/portrait.webp",41656,"1eb9e10cc34c"],["assets/mecha/manifest.js",13266,"b0a07e14b7de"],["assets/mecha/run/sprite-sheet-alpha.webp",1140340,"02dfd7fc3f38"],["assets/menu/arco-select.webp",83804,"e70d678cc558"],["assets/menu/azure-harbor.webp",116560,"e9b00d01e676"],["assets/menu/cora-select.webp",85066,"8f496a9ae04f"],["assets/menu/edda-select.webp",98000,"f39253607637"],["assets/menu/elderwood.webp",130386,"ec28da7499fb"],["assets/menu/fenr-select.webp",84330,"df00d7b0f6d8"],["assets/menu/haldor-select.webp",110328,"4d4fada3a51d"],["assets/menu/home-art.webp",188424,"3cccd1c9793b"],["assets/menu/home-factions-loop.mp4",1417101,"d48036ae3888"],["assets/menu/home-factions.webp",148524,"2068721bc197"],["assets/menu/isolde-select.webp",119740,"832756d46ab1"],["assets/menu/mira-select.webp",89058,"02d78848bbc2"],["assets/menu/moonrise.webp",68134,"750461b81b3b"],["assets/menu/naja-select.webp",130418,"2644be3a0d1a"],["assets/menu/nib-select.webp",77832,"4a7fa17c0260"],["assets/menu/rhea-select.webp",107980,"e32b08a6c5ef"],["assets/menu/solan-select.webp",112452,"07dbf6341e75"],["assets/menu/sunspire.webp",49268,"54b11853a23a"],["assets/menu/zanni-select.webp",117976,"cf94f9dfdf69"],["assets/mira/audio/mira-ultimate-luna.mp3",23448,"a3b8a3c98a55"],["assets/mira/audio/mira_wins.mp3",26793,"33d1e0f8ded3"],["assets/mira/audio/select_mira.mp3",21359,"21bb3ca4e22f"],["assets/mira/manifest.js",11852,"949fbc2bd69b"],["assets/mira/run/sprite-sheet-alpha.webp",2237726,"5b0fc6c20310"],["assets/mira/ui/cutin.webp",209146,"dc5267bcdaa2"],["assets/mira/ui/fx-burst.webp",17352,"c037a0dd7366"],["assets/mira/ui/fx-crash.webp",13328,"2dea28410a25"],["assets/mira/ui/fx-rocket.webp",6158,"16d752cb026a"],["assets/mira/ui/fx-star.webp",6094,"33cba4f3ff5d"],["assets/mira/ui/icon-basic.webp",17856,"7dde5f96605a"],["assets/mira/ui/icon-skill1.webp",15306,"56f108fe44e1"],["assets/mira/ui/icon-skill2.webp",25404,"8aee74746fca"],["assets/mira/ui/icon-ultimate.webp",25152,"0ce76bda51e8"],["assets/mira/ui/portrait.webp",38620,"47f194e28e65"],["assets/naja/audio/naja-ultimate-soraya.mp3",21359,"17e4fa97f21f"],["assets/naja/audio/naja_wins.mp3",23031,"fda44264a05b"],["assets/naja/audio/select_naja.mp3",18852,"1bd57158cba1"],["assets/naja/manifest.js",11430,"a64c68965bdd"],["assets/naja/run/sprite-sheet-alpha.webp",1621460,"e33344d6ec66"],["assets/naja/ui/cutin.webp",304792,"a428e2dea484"],["assets/naja/ui/fx-cyclone.webp",14950,"8c9904e41ca3"],["assets/naja/ui/fx-ripple.webp",8958,"616e8df82f51"],["assets/naja/ui/fx-sandwave.webp",9302,"950297d2078b"],["assets/naja/ui/fx-serpent.webp",65082,"a4961a5a6d83"],["assets/naja/ui/icon-basic.webp",27428,"b09d339b7902"],["assets/naja/ui/icon-skill1.webp",24914,"1eff7bf3b7ac"],["assets/naja/ui/icon-skill2.webp",28412,"2f7b77ad45ab"],["assets/naja/ui/icon-ultimate.webp",29388,"7f65e2beeeb1"],["assets/naja/ui/portrait.webp",38558,"267273db6455"],["assets/nib/audio/nib-ultimate.mp3",20523,"c6739b0ebbf4"],["assets/nib/audio/nib_wins.mp3",23031,"0790031653fd"],["assets/nib/audio/select_nib.mp3",15090,"5a8ff37811c4"],["assets/nib/manifest.js",11785,"7a0a8737e951"],["assets/nib/run/sprite-sheet-alpha.webp",1145842,"5f86108718a0"],["assets/nib/ui/cutin.webp",185052,"3e8cbacd4ee0"],["assets/nib/ui/fx-letter.webp",4924,"4d4b9beaffad"],["assets/nib/ui/fx-plane.webp",4572,"6980a21f4c9e"],["assets/nib/ui/fx-slip.webp",22866,"f4958eca0680"],["assets/nib/ui/fx-stamp.webp",19112,"f6c29056da94"],["assets/nib/ui/icon-basic.webp",22936,"5c92b6e449ac"],["assets/nib/ui/icon-skill1.webp",12434,"ab66b1ea938f"],["assets/nib/ui/icon-skill2.webp",19034,"6c0d868dcb91"],["assets/nib/ui/icon-ultimate.webp",18786,"27e0aed42406"],["assets/nib/ui/portrait.webp",28462,"70ce54866552"],["assets/rhea/audio/rhea-ultimate.mp3",21359,"01cf69da4373"],["assets/rhea/audio/rhea_wins.mp3",21359,"c62a5d27ce5b"],["assets/rhea/audio/select_rhea.mp3",16344,"1a098bdd4489"],["assets/rhea/manifest.js",11682,"e63b96027b54"],["assets/rhea/run/sprite-sheet-alpha.webp",1274018,"6e9f0d3688ef"],["assets/rhea/ui/cutin.webp",209154,"9a0bcc505ec6"],["assets/rhea/ui/fx-burst.webp",19276,"03174ca5e988"],["assets/rhea/ui/fx-drift.webp",9482,"a5710703920f"],["assets/rhea/ui/fx-planet.webp",17248,"dce69d012b10"],["assets/rhea/ui/fx-well.webp",21730,"ceb6bbe12c2f"],["assets/rhea/ui/icon-basic.webp",17416,"a6742865805c"],["assets/rhea/ui/icon-skill1.webp",12188,"9cf259e64ebb"],["assets/rhea/ui/icon-skill2.webp",24152,"67af3820a177"],["assets/rhea/ui/icon-ultimate.webp",26232,"8eff1ad0166d"],["assets/rhea/ui/portrait.webp",38584,"fd26b4900737"],["assets/showcase/edda/portrait.webp",34176,"eef0b3e18d5b"],["assets/showcase/haldor/portrait.webp",46308,"cbd54572adfa"],["assets/showcase/isolde/portrait.webp",41656,"1eb9e10cc34c"],["assets/showcase/naja/portrait.webp",38558,"267273db6455"],["assets/showcase/nib/portrait.webp",28462,"70ce54866552"],["assets/showcase/rhea/portrait.webp",38584,"fd26b4900737"],["assets/showcase/showcase.js",150,"a475b33f051e"],["assets/showcase/solan/portrait.webp",42714,"910bf43ac688"],["assets/showcase/zanni/portrait.webp",33856,"64b5171fa23b"],["assets/solan/audio/select_solan.mp3",15090,"00cd3d422862"],["assets/solan/audio/solan-ultimate.mp3",21359,"d7297cacb9b1"],["assets/solan/audio/solan_wins-raw.mp3",26793,"40b664047993"],["assets/solan/audio/solan_wins.mp3",22612,"9b605ba42f1d"],["assets/solan/manifest.js",11759,"79e98e0a6fa4"],["assets/solan/run/sprite-sheet-alpha.webp",1812930,"78c268a484ea"],["assets/solan/ui/cutin.webp",221154,"6d3c03c440aa"],["assets/solan/ui/fx-crescent.webp",10074,"47ae65f41c30"],["assets/solan/ui/fx-impact.webp",12828,"18039e218472"],["assets/solan/ui/fx-roar.webp",16002,"e7bde0da0471"],["assets/solan/ui/fx-sunburst.webp",17286,"f46b512520da"],["assets/solan/ui/icon-basic.webp",21426,"abfecbd48e41"],["assets/solan/ui/icon-skill1.webp",16616,"c49e42b3e5f3"],["assets/solan/ui/icon-skill2.webp",24528,"922918cf670e"],["assets/solan/ui/icon-ultimate.webp",26478,"6d53f01bbd42"],["assets/solan/ui/portrait.webp",42714,"910bf43ac688"],["assets/stage-config.js",330,"5f4c562a5196"],["assets/stage.webp",84514,"35061ed6fae8"],["assets/ui/arco-avatar.webp",28238,"4c06b6cad40a"],["assets/ui/attack.webp",6744,"bd2b590d635e"],["assets/ui/drone-config.js",196,"8eb1127440e6"],["assets/ui/dummy-avatar.webp",22410,"d3fbe1234b69"],["assets/ui/icons-check.webp",32114,"e27fecadcda7"],["assets/ui/skill1.webp",8908,"3228c3690fb8"],["assets/ui/skill2.webp",9788,"dceb3d2b71cd"],["assets/ui/squadron-icon.webp",6466,"ddedba88f295"],["assets/ui/ultimate-cutin.webp",126218,"599145541b00"],["assets/ui/ultimate.webp",7504,"770631fda059"],["assets/zanni/audio/select_zanni.mp3",18852,"af46d57d9cb8"],["assets/zanni/audio/zanni-ultimate.mp3",17598,"28ec07653ad7"],["assets/zanni/audio/zanni_wins-raw.mp3",34316,"f8b324451dae"],["assets/zanni/audio/zanni_wins.mp3",24284,"438a81d03242"],["assets/zanni/manifest.js",11595,"ffa0d66d7bfd"],["assets/zanni/run/sprite-sheet-alpha.webp",1555686,"a60752c47112"],["assets/zanni/ui/cutin.webp",206256,"338aaa7bf671"],["assets/zanni/ui/fx-bigring.webp",24056,"f8be338393bf"],["assets/zanni/ui/fx-confetti.webp",16344,"3b85c104a23c"],["assets/zanni/ui/fx-ring.webp",14902,"bd879ed06ac4"],["assets/zanni/ui/fx-snatch.webp",19366,"18e9773549a2"],["assets/zanni/ui/icon-basic.webp",25846,"099711ea0e34"],["assets/zanni/ui/icon-skill1.webp",17230,"37d8ade539b7"],["assets/zanni/ui/icon-skill2.webp",19002,"07dc5d0b85fd"],["assets/zanni/ui/icon-ultimate.webp",28306,"ef268c6e6011"],["assets/zanni/ui/portrait.webp",33856,"64b5171fa23b"]]}; diff --git a/preload.js b/preload.js new file mode 100644 index 0000000..d91d507 --- /dev/null +++ b/preload.js @@ -0,0 +1,66 @@ +/* First-visit loader. Before the menu opens, every runtime file in precache.js (built by tools/build_dist.mjs) is + downloaded once behind a progress bar, so later screens never wait for pictures. + - Secure pages (https, localhost): files go into Cache Storage ('aether-assets') with their content hash. A later visit + only downloads files whose hash changed, and sw.js serves the pictures straight from that cache. + - Plain http on a LAN IP (no Cache Storage / service worker there): the download still fills the browser's HTTP cache. + - file:// or no list: nothing to do, the game boots as before. + The game and the menu wait for window.AETHER_PRELOAD before they request their own images (no double download). */ +(() => { + 'use strict'; + const list = self.AETHER_PRECACHE, boot = document.getElementById('boot'); + const finish = () => { if (!boot) return; boot.classList.add('done'); setTimeout(() => boot.remove(), 500); }; + if (!list || !boot || location.protocol === 'file:' || typeof fetch !== 'function') { window.AETHER_PRELOAD = Promise.resolve(); finish(); return; } + const bar = boot.querySelector('.boot-bar i'), status = boot.querySelector('.boot-status'); + const mb = n => (n / 1e6).toFixed(1).replace('.', ','); + let got = 0, downloading = false; + function paint() { + const p = Math.min(1, got / Math.max(1, list.total)); + bar.style.transform = `scaleX(${p})`; + status.textContent = downloading ? `Mengunduh aset game · ${Math.floor(p * 100)}% · ${mb(got)} / ${mb(list.total)} MB` : 'Memuat…'; + } + const secure = window.isSecureContext && 'caches' in window; + if (secure && 'serviceWorker' in navigator) navigator.serviceWorker.register('sw.js').catch(() => {}); + const INDEX = '__aether-index'; + + async function run() { + const cache = secure ? await caches.open('aether-assets').catch(() => null) : null; + let index = {}; + if (cache) { try { const hit = await cache.match(INDEX); if (hit) index = await hit.json(); } catch (_) { index = {}; } } + const todo = []; + for (const [url, bytes, hash] of list.files) { + if (cache && index[url] === hash && await cache.match(url)) got += bytes; else todo.push([url, bytes, hash]); + } + downloading = todo.length > 0; paint(); + let next = 0; + async function worker() { + while (next < todo.length) { + const [url, bytes, hash] = todo[next++]; + let seen = 0; + try { + const res = await fetch(url, { cache: 'no-cache' }); + if (!res.ok) throw new Error(String(res.status)); + const chunks = [], reader = res.body && res.body.getReader ? res.body.getReader() : null; + if (reader) for (;;) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); seen += value.byteLength; got += value.byteLength; paint(); } + else { const buf = new Uint8Array(await res.arrayBuffer()); chunks.push(buf); seen = buf.byteLength; got += seen; } + if (cache) { + const type = res.headers.get('Content-Type') || ''; + await cache.put(url, new Response(new Blob(chunks, { type }), { headers: { 'Content-Type': type } })); + index[url] = hash; + } + } catch (_) { /* a missing file must not block the game; it simply loads normally later */ } + got += Math.max(0, bytes - seen); paint(); + } + } + await Promise.all(Array.from({ length: 6 }, worker)); + if (cache) { + // Forget files from older deploys, then store the hash index for the next visit. + const keep = new Set(list.files.map(f => new URL(f[0], location.href).href)); + keep.add(new URL(INDEX, location.href).href); + for (const req of await cache.keys()) if (!keep.has(req.url)) await cache.delete(req); + for (const url of Object.keys(index)) if (!list.files.some(f => f[0] === url)) delete index[url]; + await cache.put(INDEX, new Response(JSON.stringify(index), { headers: { 'Content-Type': 'application/json' } })); + } + downloading = false; got = list.total; paint(); + } + window.AETHER_PRELOAD = run().catch(() => {}).then(finish); +})(); diff --git a/rhea.js b/rhea.js new file mode 100644 index 0000000..24a7839 --- /dev/null +++ b/rhea.js @@ -0,0 +1,36 @@ +/* Shared RHEA moves (teen orrery scholar mecha, spheres on jointed arms): player and AI use the same rules. */ +(() => { + 'use strict'; + const balance = { cooldowns:{skill1:3,skill2:6,ultimate:18}, castTime:.5, planetDamage:16, basicRefund:.05 }; + const names = ['ORBIT STRIKE','PLANET DRIFT','GRAVITY WELL','GRAND ORRERY']; + // The spheres swing out on their jointed arms; reach sits inside the measured spheres (109/80/128 px in emitters.json). + const combo = [ + {damage:6,knockback:75,reach:100,duration:.38,hitAt:.5}, + {damage:8,knockback:100,reach:85,duration:.44,hitAt:.5}, + {damage:12,knockback:190,reach:115,duration:.56,hitAt:.55} + ]; + // Planet Drift: a slow little planet that crosses the whole arena at chest height. Slow enough to walk behind and + // control space with (a zoner's tool); a jump clears it. + const drift = { speed:380, life:3.2 }; + // Gravity Well: a vortex opens `at` px ahead and collapses after `fuse` s, hitting everything within `radius` px + // of its centre. It is a mid-range tool: a rival hugging RHEA is outside it. + const well = { at:230, fuse:.3, radius:95 }; + // Grand Orrery: three giant planets are released 0.38 s apart (inside the 0.42 s hurt stun). Each makes one lap + // around RHEA on an ellipse (rx x ry px) in `lap` s and hits once. A rival inside the orbit takes all three (48), + // front or back; stepping out past the orbit (about 260 px) dodges them. + const orrery = { cutinDuration:.78, releases:[.8,1.18,1.56], lap:.9, rx:230, ry:70, size:120, duration:2.6 }; + function move(name,index=1) { + if(name==='attack')return {name:`attack${index}`,type:'attack',index,...combo[index-1]}; + if(name==='skill1')return {name,type:name,damage:16,duration:.5,knockback:130,projectile:true,speed:drift.speed,hitAt:.55}; + if(name==='skill2')return {name,type:name,damage:24,duration:.62,knockback:220,reach:well.at+well.radius,well:true,hitAt:.55}; + return {name:'ultimate',type:'ultimate',duration:balance.castTime,damage:0,hitAt:1}; + } + function start(a,name,index=1) { + if(a.action||a.state==='hurt'||a.state==='down'||a.state==='recover')return false; + if(name!=='attack'&&a.cooldowns[name]>0)return false; + if(name!=='attack')a.cooldowns[name]=balance.cooldowns[name]; + a.action={...move(name,index),t:0,fired:false,queued:0};a.state=a.action.name;a.stateTime=0;return true; + } + function refund(a) {for(const name of Object.keys(balance.cooldowns))a.cooldowns[name]=Math.max(0,a.cooldowns[name]-balance.cooldowns[name]*balance.basicRefund);} + window.Rhea={balance,names,combo,drift,well,orrery,move,start,refund}; +})(); diff --git a/solan.js b/solan.js new file mode 100644 index 0000000..d2b2908 --- /dev/null +++ b/solan.js @@ -0,0 +1,36 @@ +/* Shared SOLAN moves (lion demi-human powerhouse, greatsword and roar): player and AI use the same rules. */ +(() => { + 'use strict'; + const balance = { cooldowns:{skill1:3,skill2:6,ultimate:18}, castTime:.5, roarDamage:16, basicRefund:.05 }; + const names = ['SUNBLADE','SOLAR CRESCENT','LEONINE LEAP','SUNMANE ROAR']; + // Heavy greatsword chain: a little quicker than HALDOR's hammer, with big knockback. Reach sits inside the measured + // blade tip (149/95/74 px in emitters.json; the cleave ends on the floor close in front of him). + const combo = [ + {damage:6,knockback:90,reach:130,duration:.4,hitAt:.5}, + {damage:8,knockback:120,reach:100,duration:.46,hitAt:.5}, + {damage:12,knockback:230,reach:95,duration:.6,hitAt:.58} + ]; + // Solar Crescent: a golden crescent slash wave at chest height, `range` px long. + const crescent = { speed:700, range:480 }; + // Leonine Leap: SOLAN springs forward (`dash` px/s) and slams the blade down; everything within `radius` px of the + // impact point on the floor is hit. He does not need to touch the rival. + const leap = { dash:420, radius:120, ahead:70 }; + // Sunmane Roar: three roars 0.38 s apart (inside the 0.42 s hurt stun); each sends a tall sound wave both ways along + // the floor that cannot be jumped (`tall`) and fades after `range` px. A rival inside 480 px takes all three (48); + // getting farther away than that before the roars dodges them. + const roar = { cutinDuration:.78, roars:[.8,1.18,1.56], speed:520, range:480, tall:190, size:210, duration:2.6 }; + function move(name,index=1) { + if(name==='attack')return {name:`attack${index}`,type:'attack',index,...combo[index-1]}; + if(name==='skill1')return {name,type:name,damage:16,duration:.52,knockback:140,projectile:true,speed:crescent.speed,range:crescent.range,hitAt:.55}; + if(name==='skill2')return {name,type:name,damage:24,duration:.74,knockback:280,reach:leap.ahead+leap.radius,dash:leap.dash,leap:true,hitAt:.6}; + return {name:'ultimate',type:'ultimate',duration:balance.castTime,damage:0,hitAt:1}; + } + function start(a,name,index=1) { + if(a.action||a.state==='hurt'||a.state==='down'||a.state==='recover')return false; + if(name!=='attack'&&a.cooldowns[name]>0)return false; + if(name!=='attack')a.cooldowns[name]=balance.cooldowns[name]; + a.action={...move(name,index),t:0,fired:false,queued:0};a.state=a.action.name;a.stateTime=0;return true; + } + function refund(a) {for(const name of Object.keys(balance.cooldowns))a.cooldowns[name]=Math.max(0,a.cooldowns[name]-balance.cooldowns[name]*balance.basicRefund);} + window.Solan={balance,names,combo,crescent,leap,roar,move,start,refund}; +})(); diff --git a/style.css b/style.css new file mode 100644 index 0000000..aed43eb --- /dev/null +++ b/style.css @@ -0,0 +1,101 @@ +@font-face{font-family:Rajdhani;src:url('assets/fonts/Rajdhani-Medium.ttf') format('truetype');font-weight:400 600;font-display:swap} +@font-face{font-family:Rajdhani;src:url('assets/fonts/Rajdhani-Bold.ttf') format('truetype');font-weight:700 900;font-display:swap} +:root{font-family:Rajdhani,'Arial Narrow',sans-serif;color:#fff8e8;background:#111e27;--gold:#e6c78f;--teal:#67e4dd;--orange:#ed9879} +*{box-sizing:border-box}html,body{margin:0;width:100%;height:100%;overflow:hidden}button,input{font:inherit}button{color:inherit;cursor:pointer}button:focus-visible,input:focus-visible,summary:focus-visible{outline:2px solid #fff3d4;outline-offset:4px}#game-shell{width:100%;height:100%;position:relative;isolation:isolate;overflow:hidden}canvas{position:absolute;inset:0;width:100%;height:100%;display:block;image-rendering:pixelated;outline:none}.vignette{position:absolute;inset:0;pointer-events:none;z-index:1;background:linear-gradient(180deg,#051724a6,transparent 28%,transparent 70%,#081924d9)} +.combat-header{position:absolute;top:25px;left:30px;right:30px;display:grid;grid-template-columns:minmax(0,1fr) 134px minmax(0,1fr);align-items:start;gap:12px;z-index:2;pointer-events:none;filter:drop-shadow(0 3px 3px #05132066)} +.fighter{display:flex;align-items:flex-start;min-width:0;gap:12px}.fighter-fields{flex:1;min-width:0;padding-top:3px}.portrait-frame{width:92px;height:102px;flex-shrink:0;position:relative;background:#11232b;clip-path:polygon(0 0,84% 0,100% 16%,100% 100%,0 100%);border:2px solid var(--gold)}.portrait-frame:after{content:'';position:absolute;inset:0;background:linear-gradient(0deg,#0b1a22b0,transparent 45%);border-bottom:4px solid var(--teal)}.portrait-frame img{width:100%;height:100%;object-fit:cover;object-position:center 33%;display:block}.player-tag{position:absolute;bottom:4px;left:7px;z-index:1;font-size:14px;letter-spacing:2px;text-shadow:1px 1px 2px #000}.name-row{display:flex;align-items:center;gap:12px;height:38px;margin-bottom:5px}.name-row h1,.name-row h2{font-size:34px;font-weight:700;font-style:italic;letter-spacing:1px;line-height:1;margin:0;text-shadow:2px 2px 0 #13232c}.class-tag{font-size:10px;font-weight:700;letter-spacing:2px;color:#d7e8e8;margin-top:6px} +.life-frame{height:24px;padding:3px;background:#0d1b25;border:1px solid #c0bbb18c;transform:skewX(-15deg);box-shadow:0 3px 0 #07162199,inset 0 1px 0 #fff4}.life-track{height:100%;width:100%;background:#3e4141;position:relative;overflow:hidden}.life-track:after{content:'';position:absolute;inset:0;background:repeating-linear-gradient(90deg,transparent 0,transparent calc(10% - 1px),#061a2336 calc(10% - 1px),#061a2336 10%)}.life-fill{height:100%;width:100%;background:linear-gradient(180deg,#dbfff0 0,#b3f2d4 25%,#49c6b9 28%,#51bdaa);box-shadow:inset 0 2px 0 #fff7;transition:width .16s ease-out}.bar-caption{display:flex;justify-content:space-between;font-size:10px;font-weight:700;letter-spacing:1.4px;margin:7px 2px 0;color:#e6ece5;text-shadow:1px 1px 2px #051923}.fighter-mark{display:flex;align-items:center;gap:6px;margin:8px 3px 0;color:#bbd1cf;font-size:9px;letter-spacing:1.7px;font-weight:700}.fighter-mark i{width:7px;height:7px;background:#a7e4d0;transform:rotate(45deg);border:1px solid #fff8}.fighter-mark span{margin-left:5px}.fighter-two .name-row,.fighter-two .fighter-mark{justify-content:flex-end}.fighter-two .fighter-mark span{margin:0 6px 0 0}.fighter-two .portrait-frame{clip-path:polygon(16% 0,100% 0,100% 100%,0 100%,0 16%)}.fighter-two .portrait-frame:after{border-color:var(--orange)}.fighter-two .player-tag{left:auto;right:7px}.fighter-two .life-fill{background:linear-gradient(180deg,#ffe6b2 0,#ffce93 25%,#eaa070 28%,#d87c59);margin-left:auto}.fighter-two .life-frame{transform:skewX(15deg)}.fighter-two .fighter-mark i{background:#dfae87} +.match-center{text-align:center;position:relative;padding-top:1px}.mode-kicker{font-size:10px;letter-spacing:2.2px;font-weight:700;color:#f5dec0;white-space:nowrap}.match-clock{height:57px;font-size:80px;font-weight:700;line-height:.77;letter-spacing:-6px;padding-right:6px;text-shadow:2px 3px 0 #0e2734,0 0 20px #091a30}.match-rule{display:flex;align-items:center;justify-content:center;gap:10px;font-size:11px;color:var(--gold);letter-spacing:2px;font-weight:700;margin-top:6px}.match-rule i{width:28px;height:1px;background:linear-gradient(90deg,transparent,#e6c78f)}.match-rule i:last-child{transform:scaleX(-1)}.match-actions{position:absolute;top:123px;left:50%;transform:translateX(-50%);display:flex;gap:6px;z-index:3}.match-actions button{width:29px;height:27px;border:1px solid #a0b5bd59;background:#1026339c;font-size:17px;line-height:1;padding:4px;display:grid;place-items:center;clip-path:polygon(5px 0,100% 0,100% calc(100% - 5px),calc(100% - 5px) 100%,0 100%,0 5px)}.match-actions button:hover{background:#315263}.match-actions svg{width:16px;height:16px;stroke:#e9dcc6;fill:none;stroke-width:1.5} +.bottom-hud{position:absolute;bottom:25px;left:32px;right:32px;display:flex;justify-content:space-between;align-items:flex-end;z-index:2;gap:25px}.skill-deck{width:350px}.deck-heading{display:flex;align-items:center;gap:10px;margin-bottom:8px;font-size:11px;letter-spacing:1.8px;font-weight:700;color:var(--gold)}.deck-heading i{height:1px;background:#a4c6cc55;flex:1}.deck-heading b{font-size:9px;color:#becfd3;font-weight:500}.skill-bar{display:flex;gap:10px}.skill-card{border:0;background:none;padding:0;display:flex;align-items:center;flex-direction:column;width:80px;flex-shrink:0;transition:transform .12s}.skill-art{position:relative;display:block;width:67px;height:67px;background:#12212b;clip-path:polygon(8px 0,100% 0,100% calc(100% - 8px),calc(100% - 8px) 100%,0 100%,0 8px);border:1px solid #89adb0;overflow:hidden}.skill-art:after{content:'';position:absolute;inset:3px;border:1px solid #c2dbdb36;pointer-events:none}.skill-art img{width:100%;height:100%;object-fit:cover;display:block;transition:filter .15s,opacity .15s}.skill-card kbd{position:absolute;right:0;top:0;min-width:19px;padding:1px 4px;font-family:inherit;font-size:12px;line-height:15px;font-weight:700;background:#ecddbb;color:#102731}.skill-card.basic kbd{font-size:9px}.skill-name{font-size:10px;letter-spacing:.6px;font-weight:700;margin-top:6px;line-height:1.1;text-shadow:1px 2px 2px #061a28;white-space:nowrap}.skill-card:hover .skill-art{border-color:#fff3d3}.skill-card.active{transform:translateY(-3px)}.skill-card.active .skill-art{border-color:#ffeab7}.skill-card.on-cooldown img{filter:grayscale(.9);opacity:.38}.skill-card.on-cooldown .skill-art{border-color:#5e747b}.skill-card.on-cooldown .skill-name{color:#84979d}.skill-card.ultimate .skill-art{border-color:#e6bd7b}.skill-card.ultimate kbd{background:#eac188}.skill-card.ultimate .skill-name{color:#f2c883}.combo-dots{position:absolute;bottom:4px;left:50%;transform:translateX(-50%);display:flex;gap:4px}.combo-dots i{display:block;width:10px;height:3px;background:#365961}.combo-dots i.lit{background:#fff0c9} +.stage-sign{position:absolute;left:50%;bottom:1px;transform:translateX(-50%);text-align:center;color:#ccddd7;text-shadow:0 2px 3px #122b34;pointer-events:none}.stage-sign>span{font-size:17px;font-weight:700;letter-spacing:5px}.stage-sign small{display:block;font-size:9px;letter-spacing:4px;margin-top:1px}.stage-sign i{display:block;width:90px;height:1px;background:linear-gradient(90deg,transparent,#cbb68b,transparent);margin:9px auto 0}.training-data{display:grid;grid-template-columns:1fr 1fr;gap:0 25px;text-align:right;min-width:205px;border-top:1px solid #c9d3c85c;padding-top:9px}.training-data>div>span{display:block;font-size:10px;letter-spacing:1.6px;color:#c5d5d4;font-weight:700}.training-data strong{display:block;font-size:39px;line-height:1.1;font-weight:700}.training-data strong small{font-size:10px;font-weight:500;color:#bacece}.reset-hint{grid-column:1/-1;margin-top:8px;color:#bccbc8;font-size:10px;letter-spacing:1px}.reset-hint span{margin-left:15px} +.combo-counter{position:absolute;right:42px;top:34%;display:flex;align-items:center;gap:9px;z-index:2;opacity:0;transform:translateX(20px) skewX(-10deg);transition:opacity .15s,transform .15s;pointer-events:none;text-shadow:2px 3px 0 #142430,0 3px 15px #122a42}.combo-counter.visible{opacity:1;transform:skewX(-10deg)}.combo-counter>span{font-size:104px;font-weight:700;line-height:.85}.combo-counter strong{font-size:29px;line-height:1;font-weight:700}.combo-counter small{display:block;font-size:13px;font-weight:700;letter-spacing:1px;color:#f0d6a8}.announcement{position:absolute;left:50%;top:26%;transform:translate(-50%,-8px) skewX(-8deg);z-index:3;font-size:39px;font-weight:700;letter-spacing:5px;text-align:center;white-space:nowrap;opacity:0;transition:opacity .15s,transform .15s;text-shadow:2px 3px 0 #112634,0 3px 12px #0d2039;pointer-events:none}.announcement.visible{opacity:1;transform:translate(-50%,0) skewX(-8deg)} +.load-state{position:absolute;inset:0;z-index:5;background:#0e202c;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:14px}.load-state.hidden{display:none}.loading-glyph{font-size:50px;color:var(--gold)}.load-state strong{font-size:36px;font-weight:700;letter-spacing:6px}.load-state>span:last-child{font-size:16px;color:#b5c9ce}.load-state.error>span:last-child{max-width:430px;text-align:center;padding:15px;color:#f3bdaa} +dialog{margin:auto;background:linear-gradient(140deg,#1c303d,#101e2b);color:#f9f0dc;border:1px solid #b9c2c44f;border-top:3px solid var(--gold);padding:30px;width:440px;max-width:calc(100% - 24px);max-height:calc(100% - 24px);overflow:auto;box-shadow:0 20px 90px #07131cbb}dialog::backdrop{background:#031423ac;backdrop-filter:blur(5px)}.dialog-kicker{color:var(--gold);font-size:12px;letter-spacing:2px;font-weight:700}dialog h2{font-size:34px;font-weight:700;margin:8px 0;line-height:1}dialog p{font-size:16px;line-height:1.5;color:#b9cbd0;margin:10px 0 16px}.setting-row{display:flex;align-items:center;justify-content:space-between;padding:12px 0;border-bottom:1px solid #98abb72e;font-size:17px;font-weight:700}.setting-row small{display:block;font-size:13px;font-weight:500;color:#91a7b0}.setting-row input{accent-color:#8fe3d0;width:18px;height:18px}.volume-row{display:flex;align-items:center;gap:12px;font-size:15px;padding:16px 0}.volume-row input{flex:1;min-width:0;accent-color:#84d9c6}.volume-row output{min-width:35px}.dialog-buttons{display:flex;gap:8px}.dialog-buttons button{flex:1;border:1px solid #8da8b153;background:#274352;padding:8px;font-size:15px;font-weight:700}.controls-help{font-size:15px;margin:13px 0;color:#bdd0d3}.controls-help summary{cursor:pointer}.controls-help p{font-size:14px}.primary-button{display:flex;align-items:center;justify-content:space-between;width:100%;border:0;background:var(--gold);color:#132b37;padding:12px 15px;font-size:18px;font-weight:700}.primary-button:hover{background:#fae0b4}.text-button{display:block;margin:15px auto 0;border:0;background:none;color:#bccecf;font-size:15px;padding:5px} +@media(min-width:1600px){.combat-header{top:32px;left:45px;right:45px;grid-template-columns:1fr 160px 1fr}.portrait-frame{width:112px;height:120px}.name-row{height:42px}.name-row h1,.name-row h2{font-size:40px}.life-frame{height:27px}.match-actions{top:137px}.bottom-hud{left:45px;right:45px;bottom:32px}.skill-art{width:75px;height:75px}.skill-deck{width:382px}.skill-card{width:88px}} +@media(max-width:950px){.combat-header{left:20px;right:20px;top:20px;gap:8px;grid-template-columns:1fr 100px 1fr}.portrait-frame{width:66px;height:80px}.fighter{gap:8px}.fighter-fields{padding-top:0}.name-row{height:28px;margin-bottom:4px;gap:7px}.name-row h1,.name-row h2{font-size:26px}.class-tag{font-size:8px;letter-spacing:1px}.life-frame{height:19px}.bar-caption{font-size:8px;letter-spacing:.6px;margin-top:5px}.fighter-mark{font-size:8px;letter-spacing:.7px;margin-top:6px;gap:4px}.fighter-mark i{width:5px;height:5px}.mode-kicker{font-size:8px;letter-spacing:1px}.match-clock{font-size:65px;height:45px}.match-rule{margin-top:3px;font-size:10px}.match-actions{top:104px}.bottom-hud{left:23px;right:23px;bottom:20px}.skill-deck{width:298px}.skill-bar{gap:8px}.skill-card{width:68px}.skill-art{width:57px;height:57px}.skill-name{font-size:8px}.training-data{min-width:175px;gap:0 15px}.stage-sign{display:none}.combo-counter>span{font-size:85px}.announcement{font-size:30px}} +@media(max-width:620px){.combat-header{top:14px;left:12px;right:12px;grid-template-columns:1fr 66px 1fr;gap:4px}.fighter{gap:5px}.portrait-frame{position:absolute;top:60px;width:48px;height:54px}.fighter-two .portrait-frame{right:0}.fighter-fields{width:100%}.name-row h1,.name-row h2{font-size:23px}.class-tag{display:none}.name-row{height:26px}.life-frame{height:16px}.bar-caption{font-size:7px;letter-spacing:.1px}.bar-caption span:first-child{display:none}.fighter-two .bar-caption span:first-child{display:inline}.fighter-two .bar-caption span:last-child{display:none}.fighter-mark{margin-left:54px;justify-content:flex-end}.fighter-two .fighter-mark{margin-left:0;margin-right:54px;justify-content:flex-start}.fighter-mark i{display:none}.fighter-mark span{font-size:7px;letter-spacing:0;white-space:nowrap;margin:0}.mode-kicker{font-size:6px;letter-spacing:.6px}.match-clock{font-size:50px;height:34px}.match-rule{font-size:8px;gap:4px}.match-rule i{width:13px}.match-actions{top:85px}.match-actions button{width:25px;height:24px}.bottom-hud{left:16px;right:16px;bottom:19px;justify-content:center}.skill-deck{width:320px}.skill-bar{gap:8px;justify-content:center}.skill-card{width:73px}.skill-art{width:60px;height:60px}.skill-name{font-size:9px}.training-data{display:none}.combo-counter{right:19px;top:27%}.combo-counter>span{font-size:68px}.combo-counter strong{font-size:22px}.combo-counter small{font-size:11px}.announcement{font-size:25px;letter-spacing:2px;top:27%}} +@media(max-height:500px) and (min-width:620px){.combat-header{top:12px;left:19px;right:19px;grid-template-columns:1fr 95px 1fr}.portrait-frame{width:56px;height:66px}.name-row{height:25px}.name-row h1,.name-row h2{font-size:24px}.fighter-mark{display:none}.life-frame{height:16px}.bar-caption{font-size:8px}.match-clock{font-size:57px;height:40px}.match-actions{top:85px}.bottom-hud{bottom:12px;left:20px;right:20px}.skill-art{width:46px;height:46px}.skill-card{width:65px}.skill-deck{width:284px}.deck-heading{margin-bottom:5px;font-size:10px}.skill-name{font-size:8px}.training-data strong{font-size:29px}.combo-counter{top:30%}.announcement{font-size:29px}dialog{padding:20px}} + + +/* Cooldown is a clockwise transparent-black pie sweep over the actual skill art. */ +.cooldown-wipe{position:absolute;inset:0;z-index:2;pointer-events:none;opacity:0} +.on-cooldown .cooldown-wipe{opacity:1} +.skill-card.on-cooldown img{filter:saturate(.9);opacity:1} +.skill-card kbd{z-index:3}.skill-art:after{z-index:4} +.skill-card.ultimate .skill-name{font-size:9px;letter-spacing:.2px} +/* Original ARCO cut-in: tilted cyan/gold command strip, art and live HTML title. */ +.ultimate-cutin{position:absolute;left:-4%;top:26%;width:108%;height:clamp(145px,27vh,220px);z-index:5;pointer-events:none;visibility:hidden;overflow:hidden;isolation:isolate;background:linear-gradient(100deg,#0b2230,#163e49 42%,#0c1b2d 74%);border-top:3px solid #f2d296;border-bottom:3px solid #f2d296;box-shadow:0 -3px 0 #173e4c,0 -5px 0 #8debd8,0 4px 0 #50b7b2,0 17px 45px #000b} +.ultimate-cutin.visible{visibility:visible} +.cutin-circuit{position:absolute;inset:0;background:linear-gradient(115deg,transparent 44%,#66ead323 44.2%,transparent 44.5%),repeating-linear-gradient(0deg,transparent 0 18px,#65d9d50d 18px 19px),repeating-linear-gradient(90deg,transparent 0 45px,#65d9d50a 45px 46px)} +.cutin-art{position:absolute;left:0;top:0;width:73%;height:100%;object-fit:cover;object-position:27% 42%;mask-image:linear-gradient(90deg,#000 0%,#000 60%,transparent 98%)} +.cutin-caption{position:absolute;left:51%;top:12%;right:6%;bottom:8%;display:flex;flex-direction:column;justify-content:center;text-shadow:3px 4px 0 #071323} +.cutin-kicker{font-size:clamp(8px,1vw,12px);font-weight:700;letter-spacing:3px;color:#78f2db;margin-bottom:5px} +.cutin-caption strong{font-size:clamp(32px,5.1vw,68px);font-weight:700;font-style:italic;line-height:.84;letter-spacing:1px;color:#fff5d7} +.cutin-caption em{font-style:italic;color:#f1c580} +.cutin-detail{display:flex;gap:12px;align-items:center;margin-top:12px;font-size:clamp(8px,.9vw,11px);letter-spacing:2px;font-weight:700;color:#d3f5ed} +.cutin-units{display:flex;gap:5px}.cutin-units i{display:block;width:6px;height:6px;background:#8ef1de;transform:rotate(45deg)} +.cutin-edge{position:absolute;inset:5px 0;border-top:1px solid #7bdedc66;border-bottom:1px solid #7bdedc55;pointer-events:none} +@media(max-width:620px){.ultimate-cutin{top:28%;height:155px}.cutin-art{width:88%;object-position:28% 45%}.cutin-caption{left:48%;right:4%}.cutin-caption strong{font-size:33px;letter-spacing:0}.cutin-kicker{font-size:7px;letter-spacing:1px}.cutin-detail{font-size:7px;letter-spacing:.4px;gap:6px}.cutin-units{gap:3px}.cutin-units i{width:4px;height:4px}.skill-card.ultimate .skill-name{font-size:8px}} + +.skill-card.recharged .skill-art{border-color:#b0fff0;box-shadow:inset 0 0 0 2px #88efd1} +.skill-card.recharged .skill-name{color:#bbfff0} +/* One physical bar, two real HP layers; reserve is revealed beneath the first. */ +.life-fill{position:absolute;left:0;top:0;z-index:2;transition:width .08s linear} +.life-track:after{z-index:3} +.life-fill.reserve-fill{z-index:1;background:linear-gradient(180deg,#a2dfed,#69b8d2 35%,#3884a3)} +.fighter-two .life-fill{left:auto;right:0;margin:0} +.fighter-two .life-fill.reserve-fill{background:linear-gradient(180deg,#ffd1b2,#d58969 35%,#a65446)} +/* Avatar contract: every source faces RIGHT. Mirror image only for the enemy slot. */ +.portrait-image{--portrait-direction:1;transform:scaleX(var(--portrait-direction));transform-origin:center} +.portrait-image[data-portrait-side="enemy"]{--portrait-direction:-1} +.form-timers{position:absolute;top:140px;left:4%;right:4%;display:flex;justify-content:space-between;pointer-events:none;z-index:3} +.form-timer{position:relative;min-width:120px;padding:7px 13px 10px;background:#17291de8;color:#ffe0a5;border:1px solid #a67e43;font:700 16px Rajdhani;letter-spacing:1.4px;box-shadow:0 3px 14px #0004} +.form-timer:after{content:'';position:absolute;left:0;bottom:0;width:calc(var(--remaining,1)*100%);height:3px;background:#e7bd76}.form-timer[hidden]{visibility:hidden;display:block} +#enemy-form-timer{margin-left:auto}.fenr-cutin{background:linear-gradient(100deg,#202e1b,#584326 60%,#202b1d);border-color:#e6c186}.fenr-cutin .cutin-kicker{color:#d5e7a5}.fenr-cutin .cutin-caption em{color:#e9aa71}.mira-cutin{background:linear-gradient(100deg,#2a1640,#5a2f6d 58%,#24153a);border-color:#ffd978}.mira-cutin .cutin-kicker{color:#ffc4ec}.mira-cutin .cutin-caption em{color:#ffd978}.cora-cutin{background:linear-gradient(100deg,#150f26,#3b2758 58%,#120d20);border-color:#f0b35a}.cora-cutin .cutin-kicker{color:#cdb6ff}.cora-cutin .cutin-caption em{color:#f0b35a}.naja-cutin{background:linear-gradient(100deg,#0d2a33,#1f4652 55%,#2b2350);border-color:#e3bf78}.naja-cutin .cutin-kicker{color:#f2d9a0}.naja-cutin .cutin-caption em{color:#e3bf78}.nib-cutin{background:linear-gradient(100deg,#0e1e2a,#2a5674 58%,#0b1720);border-color:#8fd4ff}.nib-cutin .cutin-kicker{color:#e8f6ff}.nib-cutin .cutin-caption em{color:#e8c48a}.edda-cutin{background:linear-gradient(100deg,#15231a,#3f6a4c 58%,#1a2016);border-color:#8fe3b4}.edda-cutin .cutin-kicker{color:#e9f7df}.edda-cutin .cutin-caption em{color:#e0c07a}.solan-cutin{background:linear-gradient(100deg,#261707,#684210 58%,#1a1005);border-color:#f2b632}.solan-cutin .cutin-kicker{color:#ffe3a0}.solan-cutin .cutin-caption em{color:#f2b632}.rhea-cutin{background:linear-gradient(100deg,#231015,#562231 58%,#1a0b0f);border-color:#e8c07a}.rhea-cutin .cutin-kicker{color:#f5e6c8}.rhea-cutin .cutin-caption em{color:#e8c07a}.isolde-cutin{background:linear-gradient(100deg,#171d25,#34465a 58%,#12171e);border-color:#bfe3ff}.isolde-cutin .cutin-kicker{color:#e6f4ff}.isolde-cutin .cutin-caption em{color:#8fd0ff}.zanni-cutin{background:linear-gradient(100deg,#141611,#2e3318 58%,#11120d);border-color:#d8e24a}.zanni-cutin .cutin-kicker{color:#eef39a}.zanni-cutin .cutin-caption em{color:#d8e24a}.haldor-cutin{background:linear-gradient(100deg,#1c1411,#4a2616 58%,#16100d);border-color:#ff9a3c}.haldor-cutin .cutin-kicker{color:#ffc58a}.haldor-cutin .cutin-caption em{color:#ff9a3c} +.setting-row select{max-width:180px;background:#172b30;color:#f5edd9;border:1px solid #778a7c;padding:8px;font:700 15px Rajdhani} +@media(max-width:620px){.form-timers{top:137px}.form-timer{font-size:12px;min-width:86px;padding:4px 7px 7px}.setting-row select{max-width:145px}} +@media(max-height:500px) and (min-width:620px){.form-timers{top:97px}.form-timer{font-size:12px;padding:4px 8px 7px}} + +/* Touch controls (html.touch, set by the detector in index.html; see touch.js). */ +#touch-controls{display:none} +html.touch body{touch-action:manipulation;-webkit-user-select:none;user-select:none;-webkit-touch-callout:none;-webkit-tap-highlight-color:transparent} +html.touch body:not(.in-menu) #touch-controls{display:block;position:absolute;inset:0;z-index:4;pointer-events:none} +html.touch .skill-deck,html.touch .training-data,html.touch .reset-hint{display:none} +html.touch .bottom-hud{justify-content:center} +html.touch .vignette{background:linear-gradient(180deg,#051724a6,transparent 28%)} +.dpad{position:absolute;left:calc(22px + env(safe-area-inset-left));bottom:calc(16px + env(safe-area-inset-bottom));display:grid;grid-template-columns:repeat(3,var(--key));grid-template-rows:repeat(2,var(--key));gap:7px;pointer-events:auto;touch-action:none;--key:54px} +.dbtn{position:relative;padding:0;border-radius:12px;pointer-events:auto;touch-action:none;cursor:pointer;border:2px solid #d8efe96b;background:#0e1d26c7;box-shadow:0 5px 14px #0007,inset 0 0 0 3px #ffffff0d;transition:transform .06s,filter .06s,background .06s} +.dbtn-up{grid-column:2;grid-row:1}.dbtn-left{grid-column:1;grid-row:2}.dbtn-down{grid-column:2;grid-row:2}.dbtn-right{grid-column:3;grid-row:2} +.dbtn i{position:absolute;left:50%;top:50%;width:0;height:0;border:11px solid transparent;pointer-events:none} +.dbtn-up i{border-bottom:15px solid #e9fbf6;transform:translate(-50%,-72%)} +.dbtn-down i{border-top:15px solid #e9fbf6;transform:translate(-50%,-28%)} +.dbtn-left i{border-right:15px solid #e9fbf6;transform:translate(-72%,-50%)} +.dbtn-right i{border-left:15px solid #e9fbf6;transform:translate(-28%,-50%)} +.dbtn.pressed{transform:scale(.92);background:#1c3a45e6;filter:drop-shadow(0 0 7px #7dfbe0)} +.action-pad{position:absolute;right:calc(22px + env(safe-area-inset-right));bottom:calc(16px + env(safe-area-inset-bottom));width:190px;height:190px;pointer-events:none;--big:96px;--small:58px;--r:100px} +.tbtn{position:absolute;padding:0;border-radius:50%;overflow:hidden;pointer-events:auto;touch-action:none;cursor:pointer;border:2px solid #f2e2b9b0;background:#0e1d26d0;box-shadow:0 5px 14px #0008,inset 0 0 0 3px #00000040;transition:transform .06s,filter .06s} +.tbtn img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;opacity:.95;pointer-events:none} +.tbtn .cooldown-wipe{position:absolute;inset:0;z-index:2;pointer-events:none} +.tbtn .cooldown-num{position:absolute;inset:0;z-index:3;display:grid;place-items:center;font:700 22px/1 Rajdhani,system-ui,sans-serif;font-style:normal;color:#fff;text-shadow:0 2px 3px #000;pointer-events:none} +.tbtn.pressed{transform:scale(.9);filter:brightness(1.25)} +.tbtn.active{border-color:#ffe39a;box-shadow:0 0 16px #ffd76acc} +.tbtn.recharged{box-shadow:0 0 18px #9dffe8} +.tbtn.on-cooldown img{filter:grayscale(.55) brightness(.7)} +/* Basic attack in the corner, the three skills on a quarter circle around it (left, up-left, up). */ +.tbtn-attack{width:var(--big);height:var(--big);right:0;bottom:0;border-width:3px;border-color:#fff1c9d9} +.tbtn-skill{width:var(--small);height:var(--small)} +.tbtn-s1{right:calc(var(--big)/2 + var(--r) - var(--small)/2);bottom:calc(var(--big)/2 - var(--small)/2)} +.tbtn-s2{right:calc(var(--big)/2 + var(--r)*.7071 - var(--small)/2);bottom:calc(var(--big)/2 + var(--r)*.7071 - var(--small)/2)} +.tbtn-ult{right:calc(var(--big)/2 - var(--small)/2);bottom:calc(var(--big)/2 + var(--r) - var(--small)/2);border-color:#ffc86be6} +@media(max-height:360px){.action-pad{--big:82px;--small:50px;--r:86px}.dpad{--key:48px}} +@media(min-height:560px){.action-pad{--big:112px;--small:66px;--r:118px;width:230px;height:230px}.dpad{--key:64px}} + +/* First-visit loading screen (preload.js): covers menu and arena until every runtime file is cached. */ +#boot{position:fixed;inset:0;z-index:100;display:grid;place-items:center;background:radial-gradient(ellipse at 50% 42%,#173547,#0b1b25 68%);color:#f6eedb;font-family:Rajdhani,Arial,sans-serif;transition:opacity .45s ease} +#boot.done{opacity:0;pointer-events:none} +.boot-card{width:min(420px,78vw);text-align:center} +.boot-logo{display:block;font-size:clamp(44px,9vmin,76px);font-style:italic;font-weight:700;line-height:.8;letter-spacing:-2px;text-shadow:4px 5px 0 #05151e7a} +.boot-logo em{font-style:italic;color:#ecd7a6;letter-spacing:6px} +.boot-bar{height:6px;margin:clamp(18px,5vmin,34px) 0 12px;background:#ffffff1a;border:1px solid #e8d4a340;overflow:hidden} +.boot-bar i{display:block;height:100%;background:linear-gradient(90deg,#86d9c4,#e0bf85);transform:scaleX(0);transform-origin:0 50%;transition:transform .15s linear} +.boot-status{font-size:13px;letter-spacing:1.5px;color:#a9c7c9} diff --git a/sw.js b/sw.js new file mode 100644 index 0000000..6d66001 --- /dev/null +++ b/sw.js @@ -0,0 +1,21 @@ +/* AETHER CLASH service worker. preload.js fills the 'aether-assets' cache (with content hashes) on each visit, so this + worker only has to answer from it: + - 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; + - audio/video and range requests: left to the network and HTTP cache (Safari needs real 206 range answers). */ +const CACHE = 'aether-assets'; +self.addEventListener('install', () => self.skipWaiting()); +self.addEventListener('activate', event => event.waitUntil(self.clients.claim())); +self.addEventListener('fetch', event => { + const req = event.request; + if (req.method !== 'GET') return; + const url = new URL(req.url); + if (url.origin !== location.origin || req.headers.has('range') || /\.(mp3|mp4)$/i.test(url.pathname)) return; + const fromCache = () => caches.open(CACHE).then(cache => cache.match(url.href, { ignoreSearch: true }) + .then(hit => hit || (url.pathname.endsWith('/') ? cache.match(new URL('index.html', url).href) : undefined))); + if (/\.(webp|png|svg|ttf|woff2)$/i.test(url.pathname)) { + event.respondWith(fromCache().then(hit => hit || fetch(req))); + return; + } + event.respondWith(fetch(req).catch(() => fromCache().then(hit => hit || Response.error()))); +}); diff --git a/touch.js b/touch.js new file mode 100644 index 0000000..dae4e74 --- /dev/null +++ b/touch.js @@ -0,0 +1,80 @@ +/* Touch controls for phones and tablets: an arrow pad on the left, a MOBA-style action cluster on the right. + They feed the same key handlers as the keyboard (window.__game.input), so every rule (double-tap run, double jump, + combo queue, cooldowns) behaves exactly like WASD + Space/I/O/P. Only active when has the .touch class. */ +(() => { + 'use strict'; + if (!document.documentElement.classList.contains('touch')) return; + const root = document.getElementById('touch-controls'); + if (!root) return; + const game = () => window.__game; + const capture = (el, id) => { try { el.setPointerCapture(id); } catch (_) { /* capture is a nicety; input still works without it */ } }; + const input = (key, down) => game()?.input?.(key, down); + + // Inside the mobile shell the first touch asks the shell to go fullscreen and lock landscape (Android). + if (window.parent !== window) { + let sent = false; + addEventListener('pointerdown', () => { if (sent) return; sent = true; try { if (window.parent.aetherActivate) { window.parent.aetherActivate(); return; } } catch (_) {} window.parent.postMessage({ type: 'aether-activate' }, location.origin); }, { capture: true }); + } + // No long-press menus, selection or double-tap zoom anywhere on the game. + addEventListener('contextmenu', e => e.preventDefault()); + + // Arrow pad: left / down / right in a row, up above down. Each finger holds the arrow under it and can slide to a + // neighbour without lifting (left <-> right to turn, down -> up to jump). Up is W (jump; again in the air = double jump). + const pad = root.querySelector('.dpad'); + const fingers = new Map(); // pointerId -> key + function refresh() { + const down = new Set(fingers.values()); + for (const arrow of pad.querySelectorAll('.dbtn')) arrow.classList.toggle('pressed', down.has(arrow.dataset.key)); + } + function setFinger(id, key) { + const old = fingers.get(id); + if (old === key) return; + if (old) { fingers.delete(id); if (![...fingers.values()].includes(old)) input(old, false); } + if (key) { const already = [...fingers.values()].includes(key); fingers.set(id, key); if (!already) input(key, true); } + refresh(); + } + pad.addEventListener('pointerdown', e => { + const arrow = e.target.closest?.('.dbtn'); + if (!arrow) return; + e.preventDefault(); capture(pad, e.pointerId); setFinger(e.pointerId, arrow.dataset.key); + }); + pad.addEventListener('pointermove', e => { + if (!fingers.has(e.pointerId)) return; + const arrow = document.elementFromPoint(e.clientX, e.clientY)?.closest?.('.dbtn'); + if (arrow && pad.contains(arrow)) setFinger(e.pointerId, arrow.dataset.key); + }); + const lift = e => { if (fingers.has(e.pointerId)) setFinger(e.pointerId, null); }; + for (const type of ['pointerup', 'pointercancel', 'lostpointercapture']) pad.addEventListener(type, lift); + + // Action buttons: big basic attack, three small skills around it. Each finger holds its own button. + const buttons = [...root.querySelectorAll('.tbtn')].map(el => ({ el, key: el.dataset.key, slot: el.dataset.slot, img: el.querySelector('img'), wipe: el.querySelector('.cooldown-wipe'), num: el.querySelector('.cooldown-num'), pointer: null })); + for (const b of buttons) { + b.el.addEventListener('pointerdown', e => { + e.preventDefault(); + if (b.pointer !== null) return; + b.pointer = e.pointerId; capture(b.el, e.pointerId); b.el.classList.add('pressed'); input(b.key, true); + }); + const end = e => { if (e.pointerId !== b.pointer) return; b.pointer = null; b.el.classList.remove('pressed'); input(b.key, false); }; + for (const type of ['pointerup', 'pointercancel', 'lostpointercapture']) b.el.addEventListener(type, end); + } + + // Mirror the HUD skill cards (icon, cooldown wipe, active/recharged state) onto the round buttons every frame. + function sync() { + const g = game(); + // The game clears its keys when a menu opens; forget the held arrows too. + if (document.body.classList.contains('in-menu') && fingers.size) { fingers.clear(); refresh(); } + for (const b of buttons) { + const card = document.querySelector(`.skill-card[data-action="${b.slot}"]`); + if (card) { + const src = card.querySelector('img')?.getAttribute('src'); + if (src && b.img.getAttribute('src') !== src) b.img.setAttribute('src', src); + for (const cls of ['on-cooldown', 'active', 'recharged']) b.el.classList.toggle(cls, card.classList.contains(cls)); + const wipe = card.querySelector('.cooldown-wipe'); + if (wipe && b.wipe) b.wipe.style.background = wipe.style.background; + } + if (b.num) { const cd = g?.hero?.cooldowns?.[b.slot] || 0; b.num.textContent = cd > 0 ? String(Math.ceil(cd)) : ''; } + } + requestAnimationFrame(sync); + } + requestAnimationFrame(sync); +})(); diff --git a/zanni.js b/zanni.js new file mode 100644 index 0000000..caa5656 --- /dev/null +++ b/zanni.js @@ -0,0 +1,36 @@ +/* Shared ZANNI moves (clockwork jester mecha, scissor-lattice arms and bladed rings): player and AI use the same rules. */ +(() => { + 'use strict'; + const balance = { cooldowns:{skill1:3,skill2:6,ultimate:18}, castTime:.5, finaleDamage:16, basicRefund:.05 }; + const names = ['SCISSOR JAB','RING TOSS','SPRING SNATCH','GRAND FINALE']; + // The telescoping arms give a long, quick chain (tips measured at 144/110/161 px in emitters.json); the hits are the + // standard 6/8/12, with a little less knockback than a sword so the long reach does not push rivals out of the chain. + const combo = [ + {damage:6,knockback:70,reach:125,duration:.36,hitAt:.5}, + {damage:8,knockback:95,reach:100,duration:.42,hitAt:.5}, + {damage:12,knockback:190,reach:140,duration:.54,hitAt:.55} + ]; + // Ring Toss: a bladed ring flies out `range` px, turns and comes back to ZANNI's hand. It hits once, on the way out or + // on the way back, so a rival who jumps the outgoing ring still has to watch the return. + const ring = { speed:820, range:430 }; + // Grand Finale: three giant rings flung forward 0.38 s apart (inside the 0.42 s hurt stun), each flies to the arena edge + // and boomerangs back to ZANNI. One 16-damage hit per ring (48). A rival behind ZANNI at the throw is safe; a jump + // clears a ring (they fly at chest height), but a jumped ring can still hit on its way back. + const finale = { cutinDuration:.78, throws:[.8,1.18,1.56], speed:980, size:150, duration:2.3 }; + function move(name,index=1) { + if(name==='attack')return {name:`attack${index}`,type:'attack',index,...combo[index-1]}; + if(name==='skill1')return {name,type:name,damage:16,duration:.5,knockback:120,projectile:true,boomerang:true,speed:ring.speed,range:ring.range,hitAt:.5}; + // Spring Snatch: the scissor arm shoots out 200 px (the hand is measured at 207 px) and yanks the rival back in front + // of ZANNI (`pull` px), ready for a jab. Close rivals are grabbed as well. + if(name==='skill2')return {name,type:name,damage:24,duration:.7,knockback:0,reach:200,pull:95,hitAt:.5}; + return {name:'ultimate',type:'ultimate',duration:balance.castTime,damage:0,hitAt:1}; + } + function start(a,name,index=1) { + if(a.action||a.state==='hurt'||a.state==='down'||a.state==='recover')return false; + if(name!=='attack'&&a.cooldowns[name]>0)return false; + if(name!=='attack')a.cooldowns[name]=balance.cooldowns[name]; + a.action={...move(name,index),t:0,fired:false,queued:0};a.state=a.action.name;a.stateTime=0;return true; + } + function refund(a) {for(const name of Object.keys(balance.cooldowns))a.cooldowns[name]=Math.max(0,a.cooldowns[name]-balance.cooldowns[name]*balance.basicRefund);} + window.Zanni={balance,names,combo,ring,finale,move,start,refund}; +})();