Spaces:
Running
Add local ACE-Step music sidecar + cinematic title sequence
Browse filesACE-Step (music + sung vocals) as a local sidecar, mirroring the aya/klein
pattern so the main app generates audio without HF quota:
- spaces/acestep-zerogpu/ (own .venv-acestep — ACE-Step pins transformers==4.50/
spacy==3.8 that clash with the other sidecars; needs torchcodec for WAV I/O)
- run_sidecars.sh `acestep` case (port 7866, on-demand), .env TINY_ACESTEP_SPACE
- app.py: _acestep_generate helper + POST /api/music route
Cinematic hero-select title sequence (web/titleSequence.js, web/tiny.js,
comboBattler startCinematic/stopCinematic/getCineAnchors):
- ambient camera tour over decorative warbands (no-combat actors, ~15/group,
phyllotaxis packing) behind the always-usable picker
- TINY ARMY wordmark + letterbox/vignette overlay
- ACE-Step title theme (web/audio/*, LFS) — tavern active, orchestral kept for
reuse; music ducks when the game starts; mute button beside the char-sheet button
- hero speaks their saved voice (IndexedDB) on the info page + on select
Also: klein sidecar serializes LoRA-swap + generation with a lock (adapter-leak fix).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- .gitattributes +3 -0
- .gitignore +2 -0
- app.py +55 -0
- docs/local-sidecars.md +16 -3
- run_sidecars.sh +13 -5
- spaces/acestep-zerogpu/.gitignore +3 -0
- spaces/acestep-zerogpu/README.md +37 -0
- spaces/acestep-zerogpu/app.py +132 -0
- spaces/acestep-zerogpu/requirements.txt +8 -0
- spaces/klein-zerogpu/app.py +23 -16
- web/audio/theme-orchestral.mp3 +3 -0
- web/audio/theme-tavern.mp3 +3 -0
- web/audio/title-theme.mp3 +3 -0
- web/comboBattler.js +140 -3
- web/tiny.js +93 -4
- web/titleSequence.js +96 -0
|
@@ -46,3 +46,6 @@ web/assets/minifantasy/Minifantasy_Temple_Of_The_Snake_God_v1.0_Commercial_Versi
|
|
| 46 |
web/assets/minifantasy/Minifantasy_Orc_Kingdom_v1.0/Minifantasy_Orc_Kingdom_Assets/Props/Prop_Shadows.png filter=lfs diff=lfs merge=lfs -text
|
| 47 |
web/assets/minifantasy/Minifantasy_Orc_Kingdom_v1.0/Minifantasy_Orc_Kingdom_Assets/Tileset/Tile_Shadows.png filter=lfs diff=lfs merge=lfs -text
|
| 48 |
web/assets/minifantasy/Minifantasy_Necropolis_v1.0/Minifantasy_Necropolis_Assets/Tileset/Buildings/TileasetAndPremadeShadows.png filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
web/assets/minifantasy/Minifantasy_Orc_Kingdom_v1.0/Minifantasy_Orc_Kingdom_Assets/Props/Prop_Shadows.png filter=lfs diff=lfs merge=lfs -text
|
| 47 |
web/assets/minifantasy/Minifantasy_Orc_Kingdom_v1.0/Minifantasy_Orc_Kingdom_Assets/Tileset/Tile_Shadows.png filter=lfs diff=lfs merge=lfs -text
|
| 48 |
web/assets/minifantasy/Minifantasy_Necropolis_v1.0/Minifantasy_Necropolis_Assets/Tileset/Buildings/TileasetAndPremadeShadows.png filter=lfs diff=lfs merge=lfs -text
|
| 49 |
+
|
| 50 |
+
# Generated audio themes (ACE-Step) — binary, required LFS by the HF hub Xet check.
|
| 51 |
+
*.mp3 filter=lfs diff=lfs merge=lfs -text
|
|
@@ -2,4 +2,6 @@
|
|
| 2 |
__pycache__/
|
| 3 |
*.pyc
|
| 4 |
.venv/
|
|
|
|
| 5 |
logs/
|
|
|
|
|
|
| 2 |
__pycache__/
|
| 3 |
*.pyc
|
| 4 |
.venv/
|
| 5 |
+
.venv-acestep/
|
| 6 |
logs/
|
| 7 |
+
outputs/
|
|
@@ -352,6 +352,9 @@ _DASHSCOPE_URL = _DASHSCOPE_BASE + "/api/v1/services/audio/tts/customization"
|
|
| 352 |
# soundfile`. Lazy-loaded; the Space (cpu-basic) leaves this unset and uses DashScope.
|
| 353 |
TTS_MODE = os.environ.get("TINY_TTS_MODE", "").strip().lower()
|
| 354 |
VOXCPM_SPACE = os.environ.get("TINY_VOXCPM_SPACE", "").strip()
|
|
|
|
|
|
|
|
|
|
| 355 |
TINY_AYA_SPACE = os.environ.get("TINY_AYA_SPACE", "").strip()
|
| 356 |
MINICPM5_SPACE = os.environ.get("TINY_MINICPM5_SPACE", "").strip()
|
| 357 |
# Coding model (Skill Forge): Mellum2 is a ZeroGPU sidecar (same /generate contract as
|
|
@@ -550,6 +553,35 @@ def _voxcpm_clone(text, ref_audio_b64, ref_text, instruct):
|
|
| 550 |
return f.read()
|
| 551 |
|
| 552 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 553 |
def _tiny_aya_generate(system, user, max_tokens, temperature):
|
| 554 |
from gradio_client import Client
|
| 555 |
client = Client(TINY_AYA_SPACE, token=HF_TOKEN or None)
|
|
@@ -742,6 +774,29 @@ async def voxcpm_clone(request: Request):
|
|
| 742 |
return Response(wav, media_type="audio/wav", headers={"Cache-Control": "no-store"})
|
| 743 |
|
| 744 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 745 |
# ── Persona portraits (image generation) ─────────────────────────────────────
|
| 746 |
# Mirrors the voice path: TINY_IMAGE_MODE=local runs the OPEN WEIGHTS on your GPU
|
| 747 |
# (Z-Image-Turbo, 6B, ~12 GB bf16 — coexists with the TTS model on a 24 GB card);
|
|
|
|
| 352 |
# soundfile`. Lazy-loaded; the Space (cpu-basic) leaves this unset and uses DashScope.
|
| 353 |
TTS_MODE = os.environ.get("TINY_TTS_MODE", "").strip().lower()
|
| 354 |
VOXCPM_SPACE = os.environ.get("TINY_VOXCPM_SPACE", "").strip()
|
| 355 |
+
# ACE-Step music + sung-vocal generation (local sidecar; see spaces/acestep-zerogpu). NB this is a
|
| 356 |
+
# MUSIC model — its "vocals" are sung lyrics, not speech; VoxCPM stays the path for dialogue/TTS.
|
| 357 |
+
ACESTEP_SPACE = os.environ.get("TINY_ACESTEP_SPACE", "").strip()
|
| 358 |
TINY_AYA_SPACE = os.environ.get("TINY_AYA_SPACE", "").strip()
|
| 359 |
MINICPM5_SPACE = os.environ.get("TINY_MINICPM5_SPACE", "").strip()
|
| 360 |
# Coding model (Skill Forge): Mellum2 is a ZeroGPU sidecar (same /generate contract as
|
|
|
|
| 553 |
return f.read()
|
| 554 |
|
| 555 |
|
| 556 |
+
def _acestep_generate(prompt, lyrics, duration, steps, guidance, seed):
|
| 557 |
+
"""Call the ACE-Step sidecar's /generate and return the rendered WAV bytes. Same retry shape
|
| 558 |
+
as _voxcpm_predict (the sidecar shares one GPU pipeline, so transient queue/GPU hiccups retry)."""
|
| 559 |
+
from gradio_client import Client
|
| 560 |
+
client = Client(ACESTEP_SPACE, token=HF_TOKEN or None)
|
| 561 |
+
last_err = None
|
| 562 |
+
for attempt in range(3):
|
| 563 |
+
try:
|
| 564 |
+
result = client.predict(
|
| 565 |
+
prompt or "",
|
| 566 |
+
lyrics or "",
|
| 567 |
+
float(duration or 30.0),
|
| 568 |
+
int(steps or 60),
|
| 569 |
+
float(guidance if guidance is not None else 15.0),
|
| 570 |
+
int(seed if seed is not None else -1),
|
| 571 |
+
api_name="/generate",
|
| 572 |
+
)
|
| 573 |
+
path = result[0] if isinstance(result, (tuple, list)) else result
|
| 574 |
+
with open(os.fspath(path), "rb") as f:
|
| 575 |
+
return f.read()
|
| 576 |
+
except Exception as e: # noqa: BLE001
|
| 577 |
+
last_err = e
|
| 578 |
+
msg = str(e).lower()
|
| 579 |
+
if attempt == 2 or not any(s in msg for s in ("accelerator", "queue", "gpu", "timeout", "temporarily")):
|
| 580 |
+
raise
|
| 581 |
+
time.sleep(1.5 * (attempt + 1))
|
| 582 |
+
raise last_err
|
| 583 |
+
|
| 584 |
+
|
| 585 |
def _tiny_aya_generate(system, user, max_tokens, temperature):
|
| 586 |
from gradio_client import Client
|
| 587 |
client = Client(TINY_AYA_SPACE, token=HF_TOKEN or None)
|
|
|
|
| 774 |
return Response(wav, media_type="audio/wav", headers={"Cache-Control": "no-store"})
|
| 775 |
|
| 776 |
|
| 777 |
+
@fastapi_app.post("/api/music")
|
| 778 |
+
async def api_music(request: Request):
|
| 779 |
+
"""Generate music (and optional sung vocals) with the ACE-Step sidecar. Returns a WAV.
|
| 780 |
+
Body: {prompt, lyrics?, duration?, steps?, guidance?, seed?}. prompt = tags/style;
|
| 781 |
+
lyrics blank or "[inst]" = instrumental."""
|
| 782 |
+
body = await request.json()
|
| 783 |
+
prompt = (body.get("prompt") or "").strip()
|
| 784 |
+
if not prompt:
|
| 785 |
+
return Response("prompt required", status_code=400)
|
| 786 |
+
if not ACESTEP_SPACE:
|
| 787 |
+
return Response("TINY_ACESTEP_SPACE not set", status_code=503)
|
| 788 |
+
lyrics = body.get("lyrics") or ""
|
| 789 |
+
duration = body.get("duration", 30.0)
|
| 790 |
+
steps = body.get("steps", 60)
|
| 791 |
+
guidance = body.get("guidance", 15.0)
|
| 792 |
+
seed = body.get("seed", -1)
|
| 793 |
+
try:
|
| 794 |
+
wav = await asyncio.to_thread(_acestep_generate, prompt, lyrics, duration, steps, guidance, seed)
|
| 795 |
+
except Exception as e: # noqa: BLE001
|
| 796 |
+
return Response(f"ACE-Step error: {e}", status_code=502)
|
| 797 |
+
return Response(wav, media_type="audio/wav", headers={"Cache-Control": "no-store"})
|
| 798 |
+
|
| 799 |
+
|
| 800 |
# ── Persona portraits (image generation) ─────────────────────────────────────
|
| 801 |
# Mirrors the voice path: TINY_IMAGE_MODE=local runs the OPEN WEIGHTS on your GPU
|
| 802 |
# (Z-Image-Turbo, 6B, ~12 GB bf16 — coexists with the TTS model on a 24 GB card);
|
|
@@ -8,6 +8,18 @@ talks to them unchanged — only the `*_SPACE` env var changes from a Space ID t
|
|
| 8 |
|------------|-----------------------------|------|--------------------------------|-------------------|
|
| 9 |
| Tiny Aya | `spaces/tiny-aya-zerogpu/` | 7864 | `CohereLabs/tiny-aya-global` | `TINY_AYA_SPACE` |
|
| 10 |
| Klein | `spaces/klein-zerogpu/` | 7865 | `black-forest-labs/FLUX.2-klein-4B` + `polats/weiner-klein-lora` | `TINY_KLEIN_SPACE` |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
Mirrors of `polats/tiny-army-tiny-aya-zerogpu` and `polats/tiny-army-klein-zerogpu`, adapted for
|
| 13 |
local hardware (no ZeroGPU). The hosted `app.py`s are unchanged in spirit; the local diffs are
|
|
@@ -16,13 +28,14 @@ documented in each file's header.
|
|
| 16 |
## Run
|
| 17 |
|
| 18 |
```bash
|
| 19 |
-
./run_sidecars.sh # start
|
| 20 |
./run_sidecars.sh aya # just tiny-aya
|
| 21 |
./run_sidecars.sh klein # just klein
|
| 22 |
-
./run_sidecars.sh
|
|
|
|
| 23 |
```
|
| 24 |
|
| 25 |
-
`.env` already points `TINY_AYA_SPACE` / `TINY_KLEIN_SPACE`
|
| 26 |
|
| 27 |
### Network access
|
| 28 |
|
|
|
|
| 8 |
|------------|-----------------------------|------|--------------------------------|-------------------|
|
| 9 |
| Tiny Aya | `spaces/tiny-aya-zerogpu/` | 7864 | `CohereLabs/tiny-aya-global` | `TINY_AYA_SPACE` |
|
| 10 |
| Klein | `spaces/klein-zerogpu/` | 7865 | `black-forest-labs/FLUX.2-klein-4B` + `polats/weiner-klein-lora` | `TINY_KLEIN_SPACE` |
|
| 11 |
+
| ACE-Step | `spaces/acestep-zerogpu/` | 7866 | [ACE-Step](https://github.com/ace-step/ACE-Step) — music + sung vocals | `TINY_ACESTEP_SPACE` |
|
| 12 |
+
|
| 13 |
+
**ACE-Step is the odd one out:** it's a *music* model (not text/image), and it pins
|
| 14 |
+
`transformers==4.50`/`spacy==3.8.4` which clash with the aya/klein venv — so it runs in its **own**
|
| 15 |
+
`.venv-acestep` (Python 3.12; spacy has no 3.13 wheel) and also needs `torchcodec` for audio I/O.
|
| 16 |
+
Start it on demand with `./run_sidecars.sh acestep` (it's intentionally **not** in the `all`
|
| 17 |
+
default — heavier on the shared card). API:
|
| 18 |
+
`/generate(prompt, lyrics, duration, infer_step, guidance_scale, seed) -> wav`; the main app
|
| 19 |
+
exposes it at `POST /api/music` (`{prompt, lyrics?, duration?, steps?, guidance?, seed?}` → WAV).
|
| 20 |
+
"Vocals" = lyrics SUNG inside a track (blank or `[inst]` = instrumental); **VoxCPM** remains the
|
| 21 |
+
path for spoken dialogue/TTS. `cpu_offload` keeps it ~0.7 GB idle / ~8 GB peak; the first call
|
| 22 |
+
auto-downloads checkpoints to `~/.cache/ace-step`.
|
| 23 |
|
| 24 |
Mirrors of `polats/tiny-army-tiny-aya-zerogpu` and `polats/tiny-army-klein-zerogpu`, adapted for
|
| 25 |
local hardware (no ZeroGPU). The hosted `app.py`s are unchanged in spirit; the local diffs are
|
|
|
|
| 28 |
## Run
|
| 29 |
|
| 30 |
```bash
|
| 31 |
+
./run_sidecars.sh # start aya + klein, wait until ready (logs in logs/)
|
| 32 |
./run_sidecars.sh aya # just tiny-aya
|
| 33 |
./run_sidecars.sh klein # just klein
|
| 34 |
+
./run_sidecars.sh acestep # just ACE-Step (music; own venv, on demand)
|
| 35 |
+
./run_sidecars.sh stop # stop all three
|
| 36 |
```
|
| 37 |
|
| 38 |
+
`.env` already points `TINY_AYA_SPACE` / `TINY_KLEIN_SPACE` / `TINY_ACESTEP_SPACE` at local URLs.
|
| 39 |
|
| 40 |
### Network access
|
| 41 |
|
|
@@ -9,29 +9,34 @@
|
|
| 9 |
# TINY_KLEIN_SPACE=http://127.0.0.1:7865
|
| 10 |
#
|
| 11 |
# Usage:
|
| 12 |
-
# ./run_sidecars.sh # start
|
| 13 |
# ./run_sidecars.sh aya # just tiny-aya
|
| 14 |
# ./run_sidecars.sh klein # just klein
|
| 15 |
-
# ./run_sidecars.sh
|
|
|
|
| 16 |
set -euo pipefail
|
| 17 |
cd "$(dirname "$0")"
|
| 18 |
|
| 19 |
PY="${PY:-.venv/bin/python}"
|
|
|
|
|
|
|
|
|
|
| 20 |
AYA_PORT="${AYA_PORT:-7864}"
|
| 21 |
KLEIN_PORT="${KLEIN_PORT:-7865}"
|
|
|
|
| 22 |
# Bind address. 0.0.0.0 = reachable from the LAN (other machines on the network) at this host's
|
| 23 |
# IP:PORT. Set BIND=127.0.0.1 to restrict to localhost (main app on the same box only).
|
| 24 |
BIND="${BIND:-0.0.0.0}"
|
| 25 |
mkdir -p logs
|
| 26 |
|
| 27 |
start_one() {
|
| 28 |
-
local name="$1" dir="$2" port="$3"
|
| 29 |
if [ -f "logs/$name.pid" ] && kill -0 "$(cat "logs/$name.pid")" 2>/dev/null; then
|
| 30 |
echo "[$name] already running (pid $(cat "logs/$name.pid")) on :$port"
|
| 31 |
return
|
| 32 |
fi
|
| 33 |
echo "[$name] starting on $BIND:$port (logs/$name.log)"
|
| 34 |
-
PORT="$port" GRADIO_SERVER_NAME="$BIND" nohup "$
|
| 35 |
> "logs/$name.log" 2>&1 &
|
| 36 |
echo $! > "logs/$name.pid"
|
| 37 |
}
|
|
@@ -62,9 +67,12 @@ stop_one() {
|
|
| 62 |
}
|
| 63 |
|
| 64 |
case "${1:-all}" in
|
| 65 |
-
stop) stop_one aya; stop_one klein ;;
|
| 66 |
aya) start_one aya tiny-aya-zerogpu "$AYA_PORT"; wait_ready aya "$AYA_PORT" ;;
|
| 67 |
klein) start_one klein klein-zerogpu "$KLEIN_PORT"; wait_ready klein "$KLEIN_PORT" ;;
|
|
|
|
|
|
|
|
|
|
| 68 |
all|*)
|
| 69 |
start_one aya tiny-aya-zerogpu "$AYA_PORT"
|
| 70 |
start_one klein klein-zerogpu "$KLEIN_PORT"
|
|
|
|
| 9 |
# TINY_KLEIN_SPACE=http://127.0.0.1:7865
|
| 10 |
#
|
| 11 |
# Usage:
|
| 12 |
+
# ./run_sidecars.sh # start aya + klein, stream logs to logs/, wait until ready
|
| 13 |
# ./run_sidecars.sh aya # just tiny-aya
|
| 14 |
# ./run_sidecars.sh klein # just klein
|
| 15 |
+
# ./run_sidecars.sh acestep # just ACE-Step music sidecar (own .venv-acestep, started on demand)
|
| 16 |
+
# ./run_sidecars.sh stop # stop all three
|
| 17 |
set -euo pipefail
|
| 18 |
cd "$(dirname "$0")"
|
| 19 |
|
| 20 |
PY="${PY:-.venv/bin/python}"
|
| 21 |
+
# ACE-Step pins transformers==4.50 / spacy==3.8 that conflict with the aya/klein venv, so it runs
|
| 22 |
+
# in its own Python 3.12 venv. Override with ACESTEP_PY if you put it elsewhere.
|
| 23 |
+
ACESTEP_PY="${ACESTEP_PY:-.venv-acestep/bin/python}"
|
| 24 |
AYA_PORT="${AYA_PORT:-7864}"
|
| 25 |
KLEIN_PORT="${KLEIN_PORT:-7865}"
|
| 26 |
+
ACESTEP_PORT="${ACESTEP_PORT:-7866}"
|
| 27 |
# Bind address. 0.0.0.0 = reachable from the LAN (other machines on the network) at this host's
|
| 28 |
# IP:PORT. Set BIND=127.0.0.1 to restrict to localhost (main app on the same box only).
|
| 29 |
BIND="${BIND:-0.0.0.0}"
|
| 30 |
mkdir -p logs
|
| 31 |
|
| 32 |
start_one() {
|
| 33 |
+
local name="$1" dir="$2" port="$3" py="${4:-$PY}"
|
| 34 |
if [ -f "logs/$name.pid" ] && kill -0 "$(cat "logs/$name.pid")" 2>/dev/null; then
|
| 35 |
echo "[$name] already running (pid $(cat "logs/$name.pid")) on :$port"
|
| 36 |
return
|
| 37 |
fi
|
| 38 |
echo "[$name] starting on $BIND:$port (logs/$name.log)"
|
| 39 |
+
PORT="$port" GRADIO_SERVER_NAME="$BIND" nohup "$py" "spaces/$dir/app.py" \
|
| 40 |
> "logs/$name.log" 2>&1 &
|
| 41 |
echo $! > "logs/$name.pid"
|
| 42 |
}
|
|
|
|
| 67 |
}
|
| 68 |
|
| 69 |
case "${1:-all}" in
|
| 70 |
+
stop) stop_one aya; stop_one klein; stop_one acestep ;;
|
| 71 |
aya) start_one aya tiny-aya-zerogpu "$AYA_PORT"; wait_ready aya "$AYA_PORT" ;;
|
| 72 |
klein) start_one klein klein-zerogpu "$KLEIN_PORT"; wait_ready klein "$KLEIN_PORT" ;;
|
| 73 |
+
# ACE-Step (music + sung vocals) — its own venv; not in `all` since it's heavier on the
|
| 74 |
+
# shared card. Start it on demand: ./run_sidecars.sh acestep
|
| 75 |
+
acestep) start_one acestep acestep-zerogpu "$ACESTEP_PORT" "$ACESTEP_PY"; wait_ready acestep "$ACESTEP_PORT" ;;
|
| 76 |
all|*)
|
| 77 |
start_one aya tiny-aya-zerogpu "$AYA_PORT"
|
| 78 |
start_one klein klein-zerogpu "$KLEIN_PORT"
|
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
outputs/
|
| 3 |
+
*.wav
|
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Tiny Army ACE-Step
|
| 3 |
+
emoji: 🎵
|
| 4 |
+
sdk: gradio
|
| 5 |
+
app_file: app.py
|
| 6 |
+
---
|
| 7 |
+
|
| 8 |
+
# Tiny Army ACE-Step — local sidecar
|
| 9 |
+
|
| 10 |
+
Local sidecar wrapping [ACE-Step](https://github.com/ace-step/ACE-Step) for **music + sung-vocal**
|
| 11 |
+
generation, so the main app makes audio without burning HF quota. Mirrors the other sidecars'
|
| 12 |
+
Gradio API so `gradio_client` talks to it unchanged:
|
| 13 |
+
|
| 14 |
+
```
|
| 15 |
+
/generate(prompt, lyrics, audio_duration, infer_step, guidance_scale, seed) -> wav file
|
| 16 |
+
```
|
| 17 |
+
|
| 18 |
+
- **prompt** — tags / style, e.g. `epic orchestral, cinematic, heroic brass`
|
| 19 |
+
- **lyrics** — `[verse]/[chorus]` markers for sung vocals; blank or `[inst]` for an instrumental
|
| 20 |
+
- ACE-Step is a *music* model — its "voice" is singing, not speech. Keep **VoxCPM** for dialogue.
|
| 21 |
+
|
| 22 |
+
## Run
|
| 23 |
+
|
| 24 |
+
Runs in a **dedicated venv** (`.venv-acestep`, Python 3.12) because ACE-Step pins
|
| 25 |
+
`transformers==4.50` / `spacy==3.8.4`, which conflict with the aya/klein sidecars. Launch via:
|
| 26 |
+
|
| 27 |
+
```bash
|
| 28 |
+
./run_sidecars.sh acestep # port 7866
|
| 29 |
+
```
|
| 30 |
+
|
| 31 |
+
Then point the main app at it (already in `.env`): `TINY_ACESTEP_SPACE=http://127.0.0.1:7866`.
|
| 32 |
+
|
| 33 |
+
### Env knobs
|
| 34 |
+
- `TINY_ACESTEP_OFFLOAD=0` — keep the model GPU-resident (faster) when the card is free; default
|
| 35 |
+
`1` enables `cpu_offload` (~8 GB peak) to coexist with the shared text-encoder server.
|
| 36 |
+
- `TINY_ACESTEP_CKPT` — checkpoint dir (default `~/.cache/ace-step`, auto-downloaded first run).
|
| 37 |
+
- `TINY_ACESTEP_STEPS` / `_GUIDANCE` / `_DURATION` / `_MAX_DURATION` / `_DTYPE`.
|
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
# LOCAL SIDECAR for ACE-Step (https://github.com/ace-step/ACE-Step) — music + sung-vocal
|
| 4 |
+
# generation, run on the local 3090 instead of a hosted Space so the main app burns no HF quota.
|
| 5 |
+
#
|
| 6 |
+
# Gradio API contract (so the main app's gradio_client talks to it the same way it talks to the
|
| 7 |
+
# other sidecars):
|
| 8 |
+
# /generate(prompt, lyrics, audio_duration:float, infer_step:int, guidance_scale:float, seed:int)
|
| 9 |
+
# -> audio file path (wav)
|
| 10 |
+
#
|
| 11 |
+
# Notes:
|
| 12 |
+
# * ACE-Step is a *music* model. "Vocals" = lyrics SUNG inside a track (give it tags + lyrics
|
| 13 |
+
# with [verse]/[chorus] markers). It is NOT speech TTS — VoxCPM stays the tool for dialogue.
|
| 14 |
+
# Leave `lyrics` blank (or "[inst]") for an instrumental.
|
| 15 |
+
# * ACE-Step pins transformers==4.50 / accelerate==1.6 which conflict with the aya/klein
|
| 16 |
+
# sidecars' transformers>=5.4 — so this sidecar runs in its OWN venv (.venv-acestep). The
|
| 17 |
+
# run_sidecars.sh `acestep` case launches it with that interpreter.
|
| 18 |
+
# * cpu_offload=True keeps peak VRAM ~8 GB so it coexists with the shared text-encoder server
|
| 19 |
+
# (same philosophy as klein's sequential offload). Set TINY_ACESTEP_OFFLOAD=0 when the card
|
| 20 |
+
# is free for full-speed GPU-resident inference.
|
| 21 |
+
# * First run downloads ~3.5B-param checkpoints to ~/.cache/ace-step (a few GB); subsequent
|
| 22 |
+
# starts are fast. Override the location with TINY_ACESTEP_CKPT.
|
| 23 |
+
import os
|
| 24 |
+
import random
|
| 25 |
+
import threading
|
| 26 |
+
import time
|
| 27 |
+
|
| 28 |
+
# Reduce CUDA fragmentation under shared-card memory pressure. Must precede CUDA init.
|
| 29 |
+
os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
|
| 30 |
+
|
| 31 |
+
try:
|
| 32 |
+
from dotenv import load_dotenv
|
| 33 |
+
load_dotenv(os.path.join(os.path.dirname(__file__), "..", "..", ".env"))
|
| 34 |
+
load_dotenv()
|
| 35 |
+
except Exception: # noqa: BLE001
|
| 36 |
+
pass
|
| 37 |
+
|
| 38 |
+
import gradio as gr
|
| 39 |
+
import torch
|
| 40 |
+
from acestep.pipeline_ace_step import ACEStepPipeline
|
| 41 |
+
|
| 42 |
+
CKPT_DIR = os.environ.get("TINY_ACESTEP_CKPT") or None # None -> default ~/.cache/ace-step
|
| 43 |
+
DTYPE = os.environ.get("TINY_ACESTEP_DTYPE", "bfloat16").strip()
|
| 44 |
+
# Offload on by default: the 3090 is shared (desktop + text-encoder server). "0"/"false" = keep
|
| 45 |
+
# the model GPU-resident for full speed when the card is free.
|
| 46 |
+
OFFLOAD = os.environ.get("TINY_ACESTEP_OFFLOAD", "1").strip().lower() not in ("0", "false", "no")
|
| 47 |
+
DEFAULT_STEPS = int(os.environ.get("TINY_ACESTEP_STEPS", "60"))
|
| 48 |
+
DEFAULT_GUIDANCE = float(os.environ.get("TINY_ACESTEP_GUIDANCE", "15.0"))
|
| 49 |
+
DEFAULT_DURATION = float(os.environ.get("TINY_ACESTEP_DURATION", "30.0"))
|
| 50 |
+
MAX_DURATION = float(os.environ.get("TINY_ACESTEP_MAX_DURATION", "240.0"))
|
| 51 |
+
MAX_SEED = 2_147_483_647
|
| 52 |
+
|
| 53 |
+
print(f"[acestep] loading pipeline (dtype={DTYPE}, cpu_offload={OFFLOAD and torch.cuda.is_available()})", flush=True)
|
| 54 |
+
_t0 = time.time()
|
| 55 |
+
pipe = ACEStepPipeline(
|
| 56 |
+
checkpoint_dir=CKPT_DIR,
|
| 57 |
+
dtype=DTYPE,
|
| 58 |
+
cpu_offload=OFFLOAD and torch.cuda.is_available(),
|
| 59 |
+
overlapped_decode=True,
|
| 60 |
+
torch_compile=False,
|
| 61 |
+
)
|
| 62 |
+
print(f"[acestep] pipeline constructed in {time.time() - _t0:.1f}s (weights load lazily on first call)", flush=True)
|
| 63 |
+
|
| 64 |
+
# The pipeline is process-global and shared by every caller; serialize generations so concurrent
|
| 65 |
+
# requests don't trample each other's state (same guard as the other sidecars).
|
| 66 |
+
_gen_lock = threading.Lock()
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _first_audio(out):
|
| 70 |
+
"""ACE-Step's __call__ returns a list of audio paths, sometimes paired with a params dict
|
| 71 |
+
((paths, params)). Dig out the first audio file path robustly."""
|
| 72 |
+
seen = out
|
| 73 |
+
# Unwrap a (result, params) tuple/list whose 2nd element is the metadata dict.
|
| 74 |
+
if isinstance(seen, (list, tuple)) and len(seen) == 2 and isinstance(seen[1], dict):
|
| 75 |
+
seen = seen[0]
|
| 76 |
+
if isinstance(seen, (list, tuple)):
|
| 77 |
+
seen = seen[0] if seen else None
|
| 78 |
+
if seen is None:
|
| 79 |
+
raise gr.Error("ACE-Step returned no audio")
|
| 80 |
+
return os.fspath(seen)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def generate(prompt: str, lyrics: str = "", audio_duration: float = DEFAULT_DURATION,
|
| 84 |
+
infer_step: int = DEFAULT_STEPS, guidance_scale: float = DEFAULT_GUIDANCE,
|
| 85 |
+
seed: int = -1):
|
| 86 |
+
if not prompt or not prompt.strip():
|
| 87 |
+
raise gr.Error("prompt (tags / style) required, e.g. 'lofi hip hop, mellow, rainy'")
|
| 88 |
+
dur = float(audio_duration or DEFAULT_DURATION)
|
| 89 |
+
dur = max(1.0, min(dur, MAX_DURATION))
|
| 90 |
+
steps = max(1, min(int(infer_step or DEFAULT_STEPS), 200))
|
| 91 |
+
guidance = float(guidance_scale if guidance_scale is not None else DEFAULT_GUIDANCE)
|
| 92 |
+
if seed is None or int(seed) < 0:
|
| 93 |
+
seed = random.randint(0, MAX_SEED)
|
| 94 |
+
with _gen_lock:
|
| 95 |
+
out = pipe(
|
| 96 |
+
format="wav",
|
| 97 |
+
audio_duration=dur,
|
| 98 |
+
prompt=prompt.strip(),
|
| 99 |
+
lyrics=(lyrics or "").strip(),
|
| 100 |
+
infer_step=steps,
|
| 101 |
+
guidance_scale=guidance,
|
| 102 |
+
manual_seeds=[int(seed)],
|
| 103 |
+
)
|
| 104 |
+
if torch.cuda.is_available():
|
| 105 |
+
torch.cuda.empty_cache()
|
| 106 |
+
return _first_audio(out)
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
demo = gr.Interface(
|
| 110 |
+
fn=generate,
|
| 111 |
+
inputs=[
|
| 112 |
+
gr.Textbox(label="Prompt (tags / style)", lines=2,
|
| 113 |
+
placeholder="e.g. epic orchestral, cinematic, heroic brass"),
|
| 114 |
+
gr.Textbox(label="Lyrics (blank or [inst] = instrumental)", lines=6,
|
| 115 |
+
placeholder="[verse]\n...\n[chorus]\n..."),
|
| 116 |
+
gr.Number(label="Duration (s)", value=DEFAULT_DURATION),
|
| 117 |
+
gr.Number(label="Inference steps", value=DEFAULT_STEPS, precision=0),
|
| 118 |
+
gr.Number(label="Guidance scale", value=DEFAULT_GUIDANCE),
|
| 119 |
+
gr.Number(label="Seed (-1 = random)", value=-1, precision=0),
|
| 120 |
+
],
|
| 121 |
+
outputs=gr.Audio(type="filepath", label="Generated audio"),
|
| 122 |
+
api_name="generate",
|
| 123 |
+
title="Tiny Army ACE-Step — local sidecar",
|
| 124 |
+
description="Text-to-music + sung vocals. Leave lyrics blank for instrumental.",
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
if __name__ == "__main__":
|
| 128 |
+
demo.queue().launch(
|
| 129 |
+
server_name=os.environ.get("GRADIO_SERVER_NAME", "127.0.0.1"),
|
| 130 |
+
server_port=int(os.environ.get("PORT", "7866")),
|
| 131 |
+
show_error=True,
|
| 132 |
+
)
|
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ACE-Step pulls its own pinned deps (transformers==4.50, accelerate==1.6, spacy==3.8, torch, ...).
|
| 2 |
+
# Those conflict with the aya/klein sidecars' transformers>=5.4, so this sidecar runs in its OWN
|
| 3 |
+
# venv (.venv-acestep, Python 3.12 — spacy 3.8.4 has no 3.13 wheel). Install with:
|
| 4 |
+
# python3.12 -m venv .venv-acestep
|
| 5 |
+
# .venv-acestep/bin/pip install -r spaces/acestep-zerogpu/requirements.txt
|
| 6 |
+
git+https://github.com/ace-step/ACE-Step.git
|
| 7 |
+
torchcodec # torchaudio>=2.9 writes audio via torchcodec; ACE-Step's save step needs it
|
| 8 |
+
python-dotenv
|
|
@@ -20,6 +20,7 @@ from __future__ import annotations
|
|
| 20 |
# * Loads the repo .env for HF_TOKEN.
|
| 21 |
import os
|
| 22 |
import random
|
|
|
|
| 23 |
import time
|
| 24 |
|
| 25 |
# Reduce CUDA fragmentation under the shared-card memory pressure (the offload churn allocates
|
|
@@ -78,6 +79,10 @@ print(f"[klein] loaded in {time.time() - _t0:.1f}s", flush=True)
|
|
| 78 |
|
| 79 |
_lora_active: str | None = None
|
| 80 |
_offload_enabled = False
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
|
| 82 |
|
| 83 |
def _load_lora(name: str) -> None:
|
|
@@ -130,31 +135,33 @@ def _to_img(x):
|
|
| 130 |
def generate(prompt: str, seed: int = 42, lora: str = DEFAULT_LORA, ref_image=None, ref_image2=None):
|
| 131 |
if not prompt or not prompt.strip():
|
| 132 |
raise gr.Error("prompt required")
|
| 133 |
-
_ensure_lora(lora)
|
| 134 |
# With offload enabled the pipeline places its own modules; the generator must live on the
|
| 135 |
# device the latents end up on (cuda when offloading, else cpu).
|
| 136 |
dev = "cuda" if torch.cuda.is_available() else "cpu"
|
| 137 |
if seed is None or int(seed) < 0:
|
| 138 |
seed = random.randint(0, MAX_SEED)
|
| 139 |
-
kwargs = dict(
|
| 140 |
-
prompt=prompt.strip(),
|
| 141 |
-
width=1024,
|
| 142 |
-
height=1024,
|
| 143 |
-
num_inference_steps=STEPS,
|
| 144 |
-
guidance_scale=GUIDANCE,
|
| 145 |
-
generator=torch.Generator(device=dev).manual_seed(int(seed)),
|
| 146 |
-
)
|
| 147 |
# FLUX.2 native multi-image conditioning: pass [pose anchor, identity ref] as a LIST so the
|
| 148 |
# composition follows one reference (e.g. a mannequin/depth render of a kimodo pose) while
|
| 149 |
# character identity comes from the other. One image -> passed alone (proven single-ref path).
|
| 150 |
refs = [r for r in (_to_img(ref_image), _to_img(ref_image2)) if r is not None]
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
kwargs
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
return img
|
| 159 |
|
| 160 |
|
|
|
|
| 20 |
# * Loads the repo .env for HF_TOKEN.
|
| 21 |
import os
|
| 22 |
import random
|
| 23 |
+
import threading
|
| 24 |
import time
|
| 25 |
|
| 26 |
# Reduce CUDA fragmentation under the shared-card memory pressure (the offload churn allocates
|
|
|
|
| 79 |
|
| 80 |
_lora_active: str | None = None
|
| 81 |
_offload_enabled = False
|
| 82 |
+
# Serialize LoRA-state + generation: the pipe and its loaded LoRA are process-global and shared
|
| 83 |
+
# by every caller, so without this a concurrent no-LoRA request could render while another has
|
| 84 |
+
# weiner attached (and vice-versa). See the hosted Space fix for the same leak.
|
| 85 |
+
_gen_lock = threading.Lock()
|
| 86 |
|
| 87 |
|
| 88 |
def _load_lora(name: str) -> None:
|
|
|
|
| 135 |
def generate(prompt: str, seed: int = 42, lora: str = DEFAULT_LORA, ref_image=None, ref_image2=None):
|
| 136 |
if not prompt or not prompt.strip():
|
| 137 |
raise gr.Error("prompt required")
|
|
|
|
| 138 |
# With offload enabled the pipeline places its own modules; the generator must live on the
|
| 139 |
# device the latents end up on (cuda when offloading, else cpu).
|
| 140 |
dev = "cuda" if torch.cuda.is_available() else "cpu"
|
| 141 |
if seed is None or int(seed) < 0:
|
| 142 |
seed = random.randint(0, MAX_SEED)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
# FLUX.2 native multi-image conditioning: pass [pose anchor, identity ref] as a LIST so the
|
| 144 |
# composition follows one reference (e.g. a mannequin/depth render of a kimodo pose) while
|
| 145 |
# character identity comes from the other. One image -> passed alone (proven single-ref path).
|
| 146 |
refs = [r for r in (_to_img(ref_image), _to_img(ref_image2)) if r is not None]
|
| 147 |
+
# Exclusive ownership of the shared pipe for the whole LoRA-set + generate (no adapter leak).
|
| 148 |
+
with _gen_lock:
|
| 149 |
+
_ensure_lora(lora)
|
| 150 |
+
kwargs = dict(
|
| 151 |
+
prompt=prompt.strip(),
|
| 152 |
+
width=1024,
|
| 153 |
+
height=1024,
|
| 154 |
+
num_inference_steps=STEPS,
|
| 155 |
+
guidance_scale=GUIDANCE,
|
| 156 |
+
generator=torch.Generator(device=dev).manual_seed(int(seed)),
|
| 157 |
+
)
|
| 158 |
+
if len(refs) == 1:
|
| 159 |
+
kwargs["image"] = refs[0]
|
| 160 |
+
elif refs:
|
| 161 |
+
kwargs["image"] = refs
|
| 162 |
+
img = pipe(**kwargs).images[0]
|
| 163 |
+
if dev == "cuda":
|
| 164 |
+
torch.cuda.empty_cache()
|
| 165 |
return img
|
| 166 |
|
| 167 |
|
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:4682604e975e339eb8f40f47fc2351a61b79786aacb87c630b8f7152705960d5
|
| 3 |
+
size 600884
|
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:85781da5bf224d995ad8964846396a588ef953ad2a63f3c82c8bd267f3f0b5ad
|
| 3 |
+
size 648620
|
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:85781da5bf224d995ad8964846396a588ef953ad2a63f3c82c8bd267f3f0b5ad
|
| 3 |
+
size 648620
|
|
@@ -4887,8 +4887,10 @@ async function createCombatRenderer({ pixi, defsById = {}, layers, coords, getBa
|
|
| 4887 |
v.sprite.scale.set(1);
|
| 4888 |
v.sprite.alpha = a.alive ? 1 : 0.32;
|
| 4889 |
}
|
| 4890 |
-
|
| 4891 |
-
|
|
|
|
|
|
|
| 4892 |
}
|
| 4893 |
}
|
| 4894 |
function updateFloats(dtMS) {
|
|
@@ -6182,6 +6184,8 @@ function mountComboBattler(pixi, host, opts = {}) {
|
|
| 6182 |
const ARENA = { ox: 0, oy: 0 };
|
| 6183 |
const fieldToWorld = (fx, fy) => ({ x: ARENA.ox + fx * G, y: ARENA.oy + fy * G });
|
| 6184 |
const worldToField = (wx, wy) => ({ x: (wx - ARENA.ox) / G, y: (wy - ARENA.oy) / G });
|
|
|
|
|
|
|
| 6185 |
const map = createGameWorld(pixi, host, { seed, keyboardPan: false });
|
| 6186 |
const roamWalkable = gameWorldWalkable(seed);
|
| 6187 |
const world = { walkable: (fx, fy) => {
|
|
@@ -6194,6 +6198,7 @@ function mountComboBattler(pixi, host, opts = {}) {
|
|
| 6194 |
return { forgottenPlains: e, orc: e, necropolis: e };
|
| 6195 |
})();
|
| 6196 |
let battle = null, R = null, combatRoot = null, rings = null, markers = null, navDbg = null, spawnFn = null, dead = false;
|
|
|
|
| 6197 |
let emoteLayer = null;
|
| 6198 |
let skillVfxLayer = null;
|
| 6199 |
const emoPrev = /* @__PURE__ */ new Map();
|
|
@@ -6861,8 +6866,140 @@ function mountComboBattler(pixi, host, opts = {}) {
|
|
| 6861 |
routePath = null;
|
| 6862 |
routeIdx = 0;
|
| 6863 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6864 |
async function spawnHero(player) {
|
| 6865 |
if (!alive || !player) return;
|
|
|
|
|
|
|
| 6866 |
const bnds = map.getBounds();
|
| 6867 |
ARENA.ox = bnds.x0;
|
| 6868 |
ARENA.oy = bnds.y0;
|
|
@@ -7093,7 +7230,7 @@ function mountComboBattler(pixi, host, opts = {}) {
|
|
| 7093 |
const p = pa();
|
| 7094 |
return p ? { name: p.name, profession: p.profession, hp: Math.round(p.hp), maxHp: Math.round(p.maxHp), skills: (p.bar || []).map((s) => s.name) } : null;
|
| 7095 |
}
|
| 7096 |
-
const ctrl = { ready, selectHero, getSpawnWorld, getSnapshot, getHero, heroEmote, buffHero, setPaused, setHeroSkills, getThreat: () => ({ ...runState }), resize, onChange, destroy, map, walkable: (wx, wy) => roamWalkable(wx, wy) };
|
| 7097 |
if (typeof window !== "undefined") {
|
| 7098 |
window.__comboSnap = () => ctrl.getSnapshot();
|
| 7099 |
window.__combo = ctrl;
|
|
|
|
| 4887 |
v.sprite.scale.set(1);
|
| 4888 |
v.sprite.alpha = a.alive ? 1 : 0.32;
|
| 4889 |
}
|
| 4890 |
+
if (!a.decor) {
|
| 4891 |
+
drawBars(a.id, a, dtMS, b.t);
|
| 4892 |
+
drawStatus(a.id, a, b.t, depth);
|
| 4893 |
+
}
|
| 4894 |
}
|
| 4895 |
}
|
| 4896 |
function updateFloats(dtMS) {
|
|
|
|
| 6184 |
const ARENA = { ox: 0, oy: 0 };
|
| 6185 |
const fieldToWorld = (fx, fy) => ({ x: ARENA.ox + fx * G, y: ARENA.oy + fy * G });
|
| 6186 |
const worldToField = (wx, wy) => ({ x: (wx - ARENA.ox) / G, y: (wy - ARENA.oy) / G });
|
| 6187 |
+
const FSTEP_C = TILE8 / G;
|
| 6188 |
+
const CINE_SPRITE_MUL = 4;
|
| 6189 |
const map = createGameWorld(pixi, host, { seed, keyboardPan: false });
|
| 6190 |
const roamWalkable = gameWorldWalkable(seed);
|
| 6191 |
const world = { walkable: (fx, fy) => {
|
|
|
|
| 6198 |
return { forgottenPlains: e, orc: e, necropolis: e };
|
| 6199 |
})();
|
| 6200 |
let battle = null, R = null, combatRoot = null, rings = null, markers = null, navDbg = null, spawnFn = null, dead = false;
|
| 6201 |
+
let cinematic = false, cineActors = null;
|
| 6202 |
let emoteLayer = null;
|
| 6203 |
let skillVfxLayer = null;
|
| 6204 |
const emoPrev = /* @__PURE__ */ new Map();
|
|
|
|
| 6866 |
routePath = null;
|
| 6867 |
routeIdx = 0;
|
| 6868 |
}
|
| 6869 |
+
function cineSnapWorld(wx, wy) {
|
| 6870 |
+
const f = worldToField(wx, wy);
|
| 6871 |
+
for (let r = 0; r <= 18; r++) for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) {
|
| 6872 |
+
if (Math.max(Math.abs(dx), Math.abs(dy)) !== r) continue;
|
| 6873 |
+
const x = f.x + dx * FSTEP_C, y = f.y + dy * FSTEP_C;
|
| 6874 |
+
if (world.walkable(x, y)) return fieldToWorld(x, y);
|
| 6875 |
+
}
|
| 6876 |
+
return { x: wx, y: wy };
|
| 6877 |
+
}
|
| 6878 |
+
async function startCinematic({ groups = [] } = {}) {
|
| 6879 |
+
if (!alive) return [];
|
| 6880 |
+
if (battle || combatRoot) teardownCombat();
|
| 6881 |
+
const bnds = map.getBounds();
|
| 6882 |
+
ARENA.ox = bnds.x0;
|
| 6883 |
+
ARENA.oy = bnds.y0;
|
| 6884 |
+
const field = { w: (bnds.x1 - bnds.x0) / G, h: (bnds.y1 - bnds.y0) / G };
|
| 6885 |
+
battle = makeTeamBattle({ seed, players: [], enemies: [], sandbox: true, freeCast: false, world, field });
|
| 6886 |
+
cinematic = true;
|
| 6887 |
+
cineActors = [];
|
| 6888 |
+
combatRoot = new Container();
|
| 6889 |
+
combatRoot.scale.set(G);
|
| 6890 |
+
combatRoot.position.set(ARENA.ox, ARENA.oy);
|
| 6891 |
+
const units = new Container();
|
| 6892 |
+
units.sortableChildren = true;
|
| 6893 |
+
const fx = new Container();
|
| 6894 |
+
const proj = new Graphics();
|
| 6895 |
+
combatRoot.addChild(units, proj, fx);
|
| 6896 |
+
map.getEntityLayer().addChild(combatRoot);
|
| 6897 |
+
const firstSheet = groups[0]?.units?.[0]?.sheets;
|
| 6898 |
+
const ch = await contentHeight(firstSheet?.idle || firstSheet?.walk);
|
| 6899 |
+
if (!alive || !cinematic) return [];
|
| 6900 |
+
depthScale.v = SPRITE_TILES * TILE8 / ((ch || 24) * G) * CINE_SPRITE_MUL;
|
| 6901 |
+
R = await createCombatRenderer({ pixi, defsById: {}, layers: { units, fx, projLayer: proj }, coords: { mapX: (x) => x, mapY: (y) => y, depthOf: () => depthScale.v }, getBattle: () => battle });
|
| 6902 |
+
if (!alive || !cinematic) return [];
|
| 6903 |
+
let n = 0;
|
| 6904 |
+
const anchors = [];
|
| 6905 |
+
for (const g of groups) {
|
| 6906 |
+
const anchor = cineSnapWorld(g.world.x, g.world.y);
|
| 6907 |
+
anchors.push(anchor);
|
| 6908 |
+
const center = worldToField(anchor.x, anchor.y);
|
| 6909 |
+
const list = g.units || [];
|
| 6910 |
+
const GOLDEN = Math.PI * (3 - Math.sqrt(5));
|
| 6911 |
+
for (let i = 0; i < list.length; i++) {
|
| 6912 |
+
const entry = list[i];
|
| 6913 |
+
const ang = i * GOLDEN;
|
| 6914 |
+
const rad = (0.6 + Math.sqrt(i) * 0.85) * FSTEP_C * CINE_SPRITE_MUL;
|
| 6915 |
+
let pos = { x: center.x + Math.cos(ang) * rad, y: center.y + Math.sin(ang) * rad };
|
| 6916 |
+
if (!world.walkable(pos.x, pos.y)) pos = center;
|
| 6917 |
+
const id = "C" + n++;
|
| 6918 |
+
const a = spawnActor(battle, { ...entry.unit || {}, name: entry.name }, "enemy", id);
|
| 6919 |
+
a.x = pos.x;
|
| 6920 |
+
a.y = pos.y;
|
| 6921 |
+
a.decor = true;
|
| 6922 |
+
a.faceX = Math.cos(ang) < 0 ? -1 : 1;
|
| 6923 |
+
a.faceY = 0;
|
| 6924 |
+
a.home = { x: pos.x, y: pos.y };
|
| 6925 |
+
a.wander = { tx: pos.x, ty: pos.y, wait: Math.random() * 2 };
|
| 6926 |
+
if (alive && cinematic) await R.addActor(id, { name: entry.name, ...sd(entry.sheets) });
|
| 6927 |
+
cineActors.push(a);
|
| 6928 |
+
}
|
| 6929 |
+
}
|
| 6930 |
+
if (!alive || !cinematic) {
|
| 6931 |
+
stopCinematic();
|
| 6932 |
+
return [];
|
| 6933 |
+
}
|
| 6934 |
+
offTick = map.onTick(cineTick);
|
| 6935 |
+
return anchors;
|
| 6936 |
+
}
|
| 6937 |
+
function cineTick(ticker) {
|
| 6938 |
+
if (!cinematic || !battle || !R || paused) return;
|
| 6939 |
+
const dtMS = ticker.deltaMS, dt = Math.min(dtMS / 1e3, 0.1);
|
| 6940 |
+
battle.t += dt;
|
| 6941 |
+
const SPEED = 8 * FSTEP_C * CINE_SPRITE_MUL;
|
| 6942 |
+
for (const a of cineActors || []) {
|
| 6943 |
+
const w = a.wander;
|
| 6944 |
+
const dx = w.tx - a.x, dy = w.ty - a.y, d = Math.hypot(dx, dy);
|
| 6945 |
+
if (d < 1 || w.wait > 0) {
|
| 6946 |
+
a.moving = false;
|
| 6947 |
+
w.wait -= dt;
|
| 6948 |
+
if (w.wait <= 0) {
|
| 6949 |
+
const ang = Math.random() * Math.PI * 2, rr = (0.5 + Math.random() * 1.6) * FSTEP_C * CINE_SPRITE_MUL;
|
| 6950 |
+
const nx = a.home.x + Math.cos(ang) * rr, ny = a.home.y + Math.sin(ang) * rr;
|
| 6951 |
+
if (world.walkable(nx, ny)) {
|
| 6952 |
+
w.tx = nx;
|
| 6953 |
+
w.ty = ny;
|
| 6954 |
+
}
|
| 6955 |
+
w.wait = 0.8 + Math.random() * 2.5;
|
| 6956 |
+
}
|
| 6957 |
+
} else {
|
| 6958 |
+
const s = Math.min(d, SPEED * dt);
|
| 6959 |
+
a.x += dx / d * s;
|
| 6960 |
+
a.y += dy / d * s;
|
| 6961 |
+
a.faceX = dx < 0 ? -1 : 1;
|
| 6962 |
+
a.faceY = dy < 0 ? -1 : 1;
|
| 6963 |
+
a.moving = true;
|
| 6964 |
+
}
|
| 6965 |
+
}
|
| 6966 |
+
R.syncActors(battle, dtMS, battle.t);
|
| 6967 |
+
}
|
| 6968 |
+
function stopCinematic() {
|
| 6969 |
+
if (!cinematic && !cineActors) return;
|
| 6970 |
+
cinematic = false;
|
| 6971 |
+
cineActors = null;
|
| 6972 |
+
teardownCombat();
|
| 6973 |
+
}
|
| 6974 |
+
function getCineAnchors(count = 6) {
|
| 6975 |
+
const out = [];
|
| 6976 |
+
const push = (tx, ty) => {
|
| 6977 |
+
if (!roamWalkable(tx, ty)) return false;
|
| 6978 |
+
const x = (tx + 0.5) * TILE8, y = (ty + 0.5) * TILE8;
|
| 6979 |
+
if (out.some((a) => Math.hypot(a.x - x, a.y - y) < 60 * TILE8)) return false;
|
| 6980 |
+
out.push({ x, y, biome: map.biomeAt(tx, ty) || "forgottenPlains" });
|
| 6981 |
+
return true;
|
| 6982 |
+
};
|
| 6983 |
+
try {
|
| 6984 |
+
const sw = gameWorldSpawn(seed);
|
| 6985 |
+
push(Math.round(sw.x / TILE8), Math.round(sw.y / TILE8));
|
| 6986 |
+
} catch {
|
| 6987 |
+
}
|
| 6988 |
+
const bnds = map.getBounds();
|
| 6989 |
+
const tx0 = Math.round(bnds.x0 / TILE8), ty0 = Math.round(bnds.y0 / TILE8);
|
| 6990 |
+
const tw = Math.round((bnds.x1 - bnds.x0) / TILE8), th = Math.round((bnds.y1 - bnds.y0) / TILE8);
|
| 6991 |
+
let guard = 0;
|
| 6992 |
+
while (out.length < count && guard++ < 4e3) push(tx0 + (8 + Math.random() * (tw - 16) | 0), ty0 + (8 + Math.random() * (th - 16) | 0));
|
| 6993 |
+
for (let i = out.length - 1; i > 0; i--) {
|
| 6994 |
+
const j = Math.random() * (i + 1) | 0;
|
| 6995 |
+
[out[i], out[j]] = [out[j], out[i]];
|
| 6996 |
+
}
|
| 6997 |
+
return out;
|
| 6998 |
+
}
|
| 6999 |
async function spawnHero(player) {
|
| 7000 |
if (!alive || !player) return;
|
| 7001 |
+
cinematic = false;
|
| 7002 |
+
cineActors = null;
|
| 7003 |
const bnds = map.getBounds();
|
| 7004 |
ARENA.ox = bnds.x0;
|
| 7005 |
ARENA.oy = bnds.y0;
|
|
|
|
| 7230 |
const p = pa();
|
| 7231 |
return p ? { name: p.name, profession: p.profession, hp: Math.round(p.hp), maxHp: Math.round(p.maxHp), skills: (p.bar || []).map((s) => s.name) } : null;
|
| 7232 |
}
|
| 7233 |
+
const ctrl = { ready, selectHero, startCinematic, stopCinematic, getCineAnchors, getSpawnWorld, getSnapshot, getHero, heroEmote, buffHero, setPaused, setHeroSkills, getThreat: () => ({ ...runState }), resize, onChange, destroy, map, walkable: (wx, wy) => roamWalkable(wx, wy) };
|
| 7234 |
if (typeof window !== "undefined") {
|
| 7235 |
window.__comboSnap = () => ctrl.getSnapshot();
|
| 7236 |
window.__combo = ctrl;
|
|
@@ -15,7 +15,7 @@ import { mountMapSandbox } from '/web/mapSandbox.js'
|
|
| 15 |
import { mountComboBattler } from '/web/comboBattler.js'
|
| 16 |
import { mountPersonaPanel, CLASS_SLUG } from '/web/personaPanel.js'
|
| 17 |
import { mountHeroCreator, animateIdleIcon } from '/web/heroCreator.js'
|
| 18 |
-
import { listPersonas, onRosterChange, getPortrait, getPersona, patchPersona, setActiveHeroId } from '/web/personaStore.js'
|
| 19 |
import { applyXp, enemyXpValue, levelProgress, xpToNext, appendEvent, statBonuses } from '/web/progression.js'
|
| 20 |
import { getSkillIcon } from '/web/personaStore.js'
|
| 21 |
import { effectSummary, resolveEquipped } from '/web/skillSchema.js'
|
|
@@ -24,6 +24,8 @@ import { forgeSkillForHero } from '/web/skillForge.js'
|
|
| 24 |
import { mountAfterAction } from '/web/afterAction.js'
|
| 25 |
import { mountLevelUp, applyPerk } from '/web/levelUpCards.js'
|
| 26 |
import { createTutorial } from '/web/tutorial.js'
|
|
|
|
|
|
|
| 27 |
|
| 28 |
// First-run guided tour (in-world copy). Steps advance as the player acts; always skippable.
|
| 29 |
const TUTORIAL_STEPS = [
|
|
@@ -302,6 +304,17 @@ async function fillAvatar(box, persona, chars, sizePx) {
|
|
| 302 |
if (pc?.idle) animateIdleIcon(box, spriteUrl(pc.idle), sizePx)
|
| 303 |
}
|
| 304 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 305 |
// Hero detail page (before spawning): big portrait/idle + name/class/about/quote and a Select button.
|
| 306 |
// onSelect fires when confirmed; Back / backdrop just close (returns to the picker).
|
| 307 |
function openHeroDetail(host, persona, chars, onSelect) {
|
|
@@ -320,7 +333,8 @@ function openHeroDetail(host, persona, chars, onSelect) {
|
|
| 320 |
const select = document.createElement('button'); select.className = 'hero-detail-select'; select.type = 'button'; select.textContent = 'Select ▶'
|
| 321 |
foot.append(back, select)
|
| 322 |
card.append(portrait, info, foot); backdrop.append(card); host.appendChild(backdrop)
|
| 323 |
-
|
|
|
|
| 324 |
back.addEventListener('click', close)
|
| 325 |
backdrop.addEventListener('pointerdown', (e) => { if (e.target === backdrop) close() })
|
| 326 |
select.addEventListener('click', () => { close(); onSelect() })
|
|
@@ -425,10 +439,76 @@ whenEl('battle-stage', async (el) => {
|
|
| 425 |
// Cinematic: the picker presents a zoomed-out overview; confirming a hero flies the camera DOWN to
|
| 426 |
// the spawn point and drops them in there.
|
| 427 |
const flyToOverview = () => { const b = comboCtrl.map.getBounds(); if (b) comboCtrl.map.flyTo((b.x0 + b.x1) / 2, (b.y0 + b.y1) / 2, OVERVIEW_ZOOM, 700).catch(() => {}) }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 428 |
const spawnWithFly = (p) => {
|
|
|
|
|
|
|
| 429 |
picker?.remove(); picker = null
|
| 430 |
// Re-read from the store so we carry live progression (level/xp/skills), not a stale picker copy.
|
| 431 |
currentHero = (p && p.id && getPersona(p.id)) || p
|
|
|
|
| 432 |
setActiveHeroId(currentHero?.id || null)
|
| 433 |
runStart = { level: currentHero?.progression?.level || 1 } // for the after-action "levels gained"
|
| 434 |
deathShown = false
|
|
@@ -497,7 +577,9 @@ whenEl('battle-stage', async (el) => {
|
|
| 497 |
return buildHeroPicker(el, personas.length ? personas : [FALLBACK], chars, onPick,
|
| 498 |
() => openCreateModal(el, spawnWithFly))
|
| 499 |
}
|
| 500 |
-
|
|
|
|
|
|
|
| 501 |
// Rebuild the open picker whenever the roster changes — so a hero just created (or removed) shows
|
| 502 |
// up in the "Choose your hero" bar without waiting for the next time it reopens.
|
| 503 |
const refreshPicker = () => { if (picker) { picker.remove(); picker = buildPicker() } }
|
|
@@ -507,6 +589,13 @@ whenEl('battle-stage', async (el) => {
|
|
| 507 |
const sheetBtn = document.createElement('button'); sheetBtn.type = 'button'; sheetBtn.title = 'Character (C)'; sheetBtn.dataset.tut = 'sheet'
|
| 508 |
sheetBtn.innerHTML = '☰<span style="font:700 9px var(--tac-font,system-ui);display:block;margin-top:-2px;color:#9aa4b2;letter-spacing:.04em">C</span>'
|
| 509 |
sheetBtn.style.cssText = 'position:absolute;top:14px;right:14px;z-index:7;width:44px;height:46px;border-radius:10px;border:1px solid #2a3340;background:rgba(20,24,33,.85);color:#e8e8e8;font:600 17px var(--tac-font,system-ui);cursor:pointer;display:flex;flex-direction:column;align-items:center;justify-content:center;line-height:1'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 510 |
const sheet = document.createElement('aside')
|
| 511 |
sheet.style.cssText = 'position:absolute;top:0;right:0;bottom:0;width:min(300px,82vw);z-index:8;background:rgba(16,20,27,.97);border-left:1px solid #2a3340;box-shadow:-8px 0 24px rgba(0,0,0,.45);transform:translateX(100%);transition:transform .22s ease;overflow-y:auto;color:#e8e8e8;font:13px var(--tac-font,system-ui);box-sizing:border-box;padding:18px 16px'
|
| 512 |
// A forged-skill detail bottom-sheet: big action illustration (lazy Klein render, cached),
|
|
@@ -761,7 +850,7 @@ whenEl('battle-stage', async (el) => {
|
|
| 761 |
const t = e.target; if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return
|
| 762 |
if (e.key === 'c' || e.key === 'C') { setSheet(!sheetOpen); e.preventDefault() }
|
| 763 |
})
|
| 764 |
-
el.append(sheetBtn, sheet)
|
| 765 |
|
| 766 |
// ── XP / level HUD chip (top-left) — always visible while a hero is in play ──
|
| 767 |
const xpHud = document.createElement('div')
|
|
|
|
| 15 |
import { mountComboBattler } from '/web/comboBattler.js'
|
| 16 |
import { mountPersonaPanel, CLASS_SLUG } from '/web/personaPanel.js'
|
| 17 |
import { mountHeroCreator, animateIdleIcon } from '/web/heroCreator.js'
|
| 18 |
+
import { listPersonas, onRosterChange, getPortrait, getPersona, patchPersona, setActiveHeroId, getAudio } from '/web/personaStore.js'
|
| 19 |
import { applyXp, enemyXpValue, levelProgress, xpToNext, appendEvent, statBonuses } from '/web/progression.js'
|
| 20 |
import { getSkillIcon } from '/web/personaStore.js'
|
| 21 |
import { effectSummary, resolveEquipped } from '/web/skillSchema.js'
|
|
|
|
| 24 |
import { mountAfterAction } from '/web/afterAction.js'
|
| 25 |
import { mountLevelUp, applyPerk } from '/web/levelUpCards.js'
|
| 26 |
import { createTutorial } from '/web/tutorial.js'
|
| 27 |
+
import { mountTitleSequence } from '/web/titleSequence.js'
|
| 28 |
+
import { playWav, stopPreview } from '/web/tts.js'
|
| 29 |
|
| 30 |
// First-run guided tour (in-world copy). Steps advance as the player acts; always skippable.
|
| 31 |
const TUTORIAL_STEPS = [
|
|
|
|
| 304 |
if (pc?.idle) animateIdleIcon(box, spriteUrl(pc.idle), sizePx)
|
| 305 |
}
|
| 306 |
}
|
| 307 |
+
// Play a hero's SAVED voice — the cached quote WAV (IndexedDB) made when the hero was created.
|
| 308 |
+
// Never regenerates; silent no-op for heroes that have no saved voice yet (e.g. the Fighter fallback).
|
| 309 |
+
async function playHeroVoice(hero) {
|
| 310 |
+
if (!hero?.id) return
|
| 311 |
+
try {
|
| 312 |
+
const blob = await getAudio(hero.id)
|
| 313 |
+
if (!blob) return
|
| 314 |
+
try { stopPreview() } catch { /* ignore */ }
|
| 315 |
+
await playWav(await blob.arrayBuffer())
|
| 316 |
+
} catch { /* ignore */ }
|
| 317 |
+
}
|
| 318 |
// Hero detail page (before spawning): big portrait/idle + name/class/about/quote and a Select button.
|
| 319 |
// onSelect fires when confirmed; Back / backdrop just close (returns to the picker).
|
| 320 |
function openHeroDetail(host, persona, chars, onSelect) {
|
|
|
|
| 333 |
const select = document.createElement('button'); select.className = 'hero-detail-select'; select.type = 'button'; select.textContent = 'Select ▶'
|
| 334 |
foot.append(back, select)
|
| 335 |
card.append(portrait, info, foot); backdrop.append(card); host.appendChild(backdrop)
|
| 336 |
+
playHeroVoice(persona) // greet the player in the hero's own saved voice when their page opens
|
| 337 |
+
const close = () => { try { stopPreview() } catch { /* ignore */ } backdrop.remove() }
|
| 338 |
back.addEventListener('click', close)
|
| 339 |
backdrop.addEventListener('pointerdown', (e) => { if (e.target === backdrop) close() })
|
| 340 |
select.addEventListener('click', () => { close(); onSelect() })
|
|
|
|
| 439 |
// Cinematic: the picker presents a zoomed-out overview; confirming a hero flies the camera DOWN to
|
| 440 |
// the spawn point and drops them in there.
|
| 441 |
const flyToOverview = () => { const b = comboCtrl.map.getBounds(); if (b) comboCtrl.map.flyTo((b.x0 + b.x1) / 2, (b.y0 + b.y1) / 2, OVERVIEW_ZOOM, 700).catch(() => {}) }
|
| 442 |
+
// ── Music: one looping theme that plays at full on the title screen and DUCKS (weaker) once the
|
| 443 |
+
// game starts. Owned here (not in the title sequence) so it survives the hero-pick handoff and can
|
| 444 |
+
// be muted. Autoplay is gated until a user gesture, so we retry on the first interaction. ──
|
| 445 |
+
const music = (() => {
|
| 446 |
+
const MUTE_KEY = 'tinyarmy.musicMuted'
|
| 447 |
+
const FULL = 0.55, DUCK = 0.16
|
| 448 |
+
const a = new Audio('/web/audio/title-theme.mp3'); a.loop = true; a.volume = 0
|
| 449 |
+
let target = FULL, muted = false, fade = null
|
| 450 |
+
try { muted = localStorage.getItem(MUTE_KEY) === '1' } catch { /* ignore */ }
|
| 451 |
+
const fadeTo = (want) => {
|
| 452 |
+
clearInterval(fade)
|
| 453 |
+
fade = setInterval(() => {
|
| 454 |
+
if (Math.abs(a.volume - want) < 0.02) { a.volume = want; clearInterval(fade); return }
|
| 455 |
+
a.volume += (want - a.volume) * 0.18
|
| 456 |
+
}, 60)
|
| 457 |
+
}
|
| 458 |
+
// Apply current state: muted → hard pause (truly stops it); else play + fade to target volume.
|
| 459 |
+
const apply = () => {
|
| 460 |
+
if (muted) { clearInterval(fade); try { a.pause() } catch { /* ignore */ } a.volume = 0 }
|
| 461 |
+
else { a.play().catch(() => {}); fadeTo(target) }
|
| 462 |
+
}
|
| 463 |
+
// Autoplay is gated until a user gesture — start (if not muted) on the first interaction.
|
| 464 |
+
const onGesture = () => { apply(); window.removeEventListener('pointerdown', onGesture); window.removeEventListener('keydown', onGesture) }
|
| 465 |
+
window.addEventListener('pointerdown', onGesture); window.addEventListener('keydown', onGesture)
|
| 466 |
+
const mutedListeners = new Set()
|
| 467 |
+
return {
|
| 468 |
+
full() { target = FULL; apply() },
|
| 469 |
+
duck() { target = DUCK; apply() },
|
| 470 |
+
isMuted: () => muted,
|
| 471 |
+
toggleMute() { muted = !muted; try { localStorage.setItem(MUTE_KEY, muted ? '1' : '0') } catch { /* ignore */ } apply(); for (const fn of mutedListeners) { try { fn(muted) } catch { /* ignore */ } } return muted },
|
| 472 |
+
onMuteChange(fn) { mutedListeners.add(fn) },
|
| 473 |
+
}
|
| 474 |
+
})()
|
| 475 |
+
// ── Cinematic title sequence (ambient camera tour over decorative warbands behind the picker) ──
|
| 476 |
+
let titleSeq = null
|
| 477 |
+
const ROSTER_POOL = Object.values(rosters).flat().filter(Boolean)
|
| 478 |
+
// Build "little armies" at scenic anchors: mostly the anchor-biome roster, with the odd wildcard
|
| 479 |
+
// pulled from the whole pool so groups read as a varied mix of characters and monsters.
|
| 480 |
+
const buildCineGroups = () => {
|
| 481 |
+
if (!ROSTER_POOL.length) return []
|
| 482 |
+
const anchors = comboCtrl.getCineAnchors?.(6) || []
|
| 483 |
+
return anchors.map((an) => {
|
| 484 |
+
const br = (rosters[an.biome] && rosters[an.biome].length) ? rosters[an.biome] : ROSTER_POOL
|
| 485 |
+
const n = 13 + (Math.random() * 5 | 0) // ~15 per group (13–17)
|
| 486 |
+
const units = []
|
| 487 |
+
for (let i = 0; i < n; i++) {
|
| 488 |
+
const src = Math.random() < 0.8 ? br : ROSTER_POOL
|
| 489 |
+
const e = src[Math.random() * src.length | 0]
|
| 490 |
+
if (e) units.push(e)
|
| 491 |
+
}
|
| 492 |
+
return { world: { x: an.x, y: an.y }, units }
|
| 493 |
+
}).filter((g) => g.units.length)
|
| 494 |
+
}
|
| 495 |
+
const startTitle = () => {
|
| 496 |
+
music.full() // theme at full on the title screen
|
| 497 |
+
if (titleSeq) return
|
| 498 |
+
titleSeq = mountTitleSequence(el, comboCtrl, {
|
| 499 |
+
groups: buildCineGroups(),
|
| 500 |
+
title: 'TINY ARMY',
|
| 501 |
+
tagline: 'Pick a hero. Hold the line.',
|
| 502 |
+
})
|
| 503 |
+
}
|
| 504 |
+
const stopTitle = () => { titleSeq?.stop(); titleSeq = null }
|
| 505 |
const spawnWithFly = (p) => {
|
| 506 |
+
stopTitle()
|
| 507 |
+
music.duck() // theme keeps playing but weaker now the game has started
|
| 508 |
picker?.remove(); picker = null
|
| 509 |
// Re-read from the store so we carry live progression (level/xp/skills), not a stale picker copy.
|
| 510 |
currentHero = (p && p.id && getPersona(p.id)) || p
|
| 511 |
+
playHeroVoice(currentHero) // hero's saved voice as they deploy (no regeneration)
|
| 512 |
setActiveHeroId(currentHero?.id || null)
|
| 513 |
runStart = { level: currentHero?.progression?.level || 1 } // for the after-action "levels gained"
|
| 514 |
deathShown = false
|
|
|
|
| 577 |
return buildHeroPicker(el, personas.length ? personas : [FALLBACK], chars, onPick,
|
| 578 |
() => openCreateModal(el, spawnWithFly))
|
| 579 |
}
|
| 580 |
+
// Picker is usable immediately; the title sequence runs the ambient camera tour behind it (so we
|
| 581 |
+
// start that instead of the old static fly-to-overview). flyToOverview stays as a fallback.
|
| 582 |
+
const showPicker = () => { xpHud.style.display = 'none'; if (!picker) { picker = buildPicker(); if (ROSTER_POOL.length) startTitle(); else flyToOverview() } }
|
| 583 |
// Rebuild the open picker whenever the roster changes — so a hero just created (or removed) shows
|
| 584 |
// up in the "Choose your hero" bar without waiting for the next time it reopens.
|
| 585 |
const refreshPicker = () => { if (picker) { picker.remove(); picker = buildPicker() } }
|
|
|
|
| 589 |
const sheetBtn = document.createElement('button'); sheetBtn.type = 'button'; sheetBtn.title = 'Character (C)'; sheetBtn.dataset.tut = 'sheet'
|
| 590 |
sheetBtn.innerHTML = '☰<span style="font:700 9px var(--tac-font,system-ui);display:block;margin-top:-2px;color:#9aa4b2;letter-spacing:.04em">C</span>'
|
| 591 |
sheetBtn.style.cssText = 'position:absolute;top:14px;right:14px;z-index:7;width:44px;height:46px;border-radius:10px;border:1px solid #2a3340;background:rgba(20,24,33,.85);color:#e8e8e8;font:600 17px var(--tac-font,system-ui);cursor:pointer;display:flex;flex-direction:column;align-items:center;justify-content:center;line-height:1'
|
| 592 |
+
// Mute toggle for the music, sitting just left of the character-sheet button.
|
| 593 |
+
const muteBtn = document.createElement('button'); muteBtn.type = 'button'
|
| 594 |
+
muteBtn.style.cssText = 'position:absolute;top:14px;right:66px;z-index:7;width:44px;height:46px;border-radius:10px;border:1px solid #2a3340;background:rgba(20,24,33,.85);color:#e8e8e8;font:600 18px var(--tac-font,system-ui);cursor:pointer;display:flex;align-items:center;justify-content:center;line-height:1'
|
| 595 |
+
const renderMute = (m) => { muteBtn.textContent = m ? '🔇' : '🔊'; muteBtn.title = m ? 'Unmute music' : 'Mute music' }
|
| 596 |
+
renderMute(music.isMuted())
|
| 597 |
+
music.onMuteChange(renderMute)
|
| 598 |
+
muteBtn.addEventListener('click', () => renderMute(music.toggleMute()))
|
| 599 |
const sheet = document.createElement('aside')
|
| 600 |
sheet.style.cssText = 'position:absolute;top:0;right:0;bottom:0;width:min(300px,82vw);z-index:8;background:rgba(16,20,27,.97);border-left:1px solid #2a3340;box-shadow:-8px 0 24px rgba(0,0,0,.45);transform:translateX(100%);transition:transform .22s ease;overflow-y:auto;color:#e8e8e8;font:13px var(--tac-font,system-ui);box-sizing:border-box;padding:18px 16px'
|
| 601 |
// A forged-skill detail bottom-sheet: big action illustration (lazy Klein render, cached),
|
|
|
|
| 850 |
const t = e.target; if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return
|
| 851 |
if (e.key === 'c' || e.key === 'C') { setSheet(!sheetOpen); e.preventDefault() }
|
| 852 |
})
|
| 853 |
+
el.append(sheetBtn, muteBtn, sheet)
|
| 854 |
|
| 855 |
// ── XP / level HUD chip (top-left) — always visible while a hero is in play ──
|
| 856 |
const xpHud = document.createElement('div')
|
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Cinematic title sequence for the hero-select screen. The hero picker is usable immediately;
|
| 2 |
+
// this just runs an ambient camera tour over decorative "warbands" (spawned via
|
| 3 |
+
// comboCtrl.startCinematic) and lays a TINY ARMY wordmark + letterbox/vignette over the map,
|
| 4 |
+
// with an optional looping theme. Everything here is non-blocking (pointer-events:none) and below
|
| 5 |
+
// the picker (z-index 6), so it never gets in the player's way.
|
| 6 |
+
//
|
| 7 |
+
// const seq = mountTitleSequence(host, comboCtrl, { groups, audioUrl, title, tagline })
|
| 8 |
+
// ...later, on hero pick/teardown: seq.stop()
|
| 9 |
+
|
| 10 |
+
export function mountTitleSequence(host, comboCtrl, opts = {}) {
|
| 11 |
+
// Music is owned by the caller (tiny.js) so it can persist (ducked) into gameplay and be muted —
|
| 12 |
+
// this module only drives the camera tour + wordmark overlay.
|
| 13 |
+
const { groups = [], title = 'TINY ARMY', tagline = 'Pick a hero. Hold the line.' } = opts
|
| 14 |
+
let stopped = false
|
| 15 |
+
const timers = new Set()
|
| 16 |
+
const sleep = (ms) => new Promise((res) => { const t = setTimeout(() => { timers.delete(t); res() }, ms); timers.add(t) })
|
| 17 |
+
|
| 18 |
+
// ── Overlay: letterbox bars + vignette + wordmark (all non-interactive) ──
|
| 19 |
+
const layer = document.createElement('div')
|
| 20 |
+
layer.className = 'title-seq'
|
| 21 |
+
layer.style.cssText = 'position:absolute;inset:0;z-index:5;pointer-events:none;overflow:hidden;opacity:0;transition:opacity .8s ease'
|
| 22 |
+
const bar = (edge) => {
|
| 23 |
+
const b = document.createElement('div')
|
| 24 |
+
b.style.cssText = `position:absolute;left:0;right:0;${edge}:0;height:7vh;background:linear-gradient(${edge === 'top' ? '180deg' : '0deg'},rgba(0,0,0,.75),rgba(0,0,0,0));`
|
| 25 |
+
return b
|
| 26 |
+
}
|
| 27 |
+
const vignette = document.createElement('div')
|
| 28 |
+
vignette.style.cssText = 'position:absolute;inset:0;background:radial-gradient(120% 90% at 50% 42%,transparent 55%,rgba(4,6,10,.55) 100%)'
|
| 29 |
+
const wrap = document.createElement('div')
|
| 30 |
+
wrap.style.cssText = 'position:absolute;left:0;right:0;top:13%;text-align:center;padding:0 16px;transition:opacity 1s ease,transform 1s ease'
|
| 31 |
+
const h = document.createElement('div')
|
| 32 |
+
h.textContent = title
|
| 33 |
+
h.style.cssText = 'font:800 clamp(34px,8vw,76px) var(--tac-font,Georgia,serif);letter-spacing:.06em;color:#f4ecd8;text-shadow:0 3px 0 rgba(0,0,0,.35),0 0 26px rgba(255,196,92,.35);line-height:1'
|
| 34 |
+
const sub = document.createElement('div')
|
| 35 |
+
sub.textContent = tagline
|
| 36 |
+
sub.style.cssText = 'margin-top:10px;font:600 clamp(12px,2.4vw,17px) var(--tac-font,system-ui);color:#cdb98e;letter-spacing:.18em;text-transform:uppercase;opacity:.9'
|
| 37 |
+
wrap.append(h, sub)
|
| 38 |
+
layer.append(bar('top'), bar('bottom'), vignette, wrap)
|
| 39 |
+
host.appendChild(layer)
|
| 40 |
+
requestAnimationFrame(() => { layer.style.opacity = '1' })
|
| 41 |
+
// The big wordmark holds, then settles smaller/translucent so it reads as ambient branding
|
| 42 |
+
// rather than a blocking splash (the picker is live the whole time).
|
| 43 |
+
const settle = setTimeout(() => {
|
| 44 |
+
if (stopped) return
|
| 45 |
+
wrap.style.opacity = '.5'
|
| 46 |
+
wrap.style.transform = 'translateY(-4%) scale(.7)'
|
| 47 |
+
}, 4200)
|
| 48 |
+
timers.add(settle)
|
| 49 |
+
|
| 50 |
+
// ── Camera director: tour the warband anchors with push-in / pull-out, looping forever. ──
|
| 51 |
+
const rand = (a, b) => a + Math.random() * (b - a)
|
| 52 |
+
async function fly(x, y, z, ms) {
|
| 53 |
+
if (stopped) return
|
| 54 |
+
try { await comboCtrl.map.flyTo(x, y, z, ms) } catch { /* interrupted */ }
|
| 55 |
+
}
|
| 56 |
+
async function director(anchors) {
|
| 57 |
+
if (!anchors.length) { // nothing spawned — just drift the overview so it's not dead-still
|
| 58 |
+
const b = comboCtrl.map.getBounds()
|
| 59 |
+
while (!stopped) { await fly((b.x0 + b.x1) / 2, (b.y0 + b.y1) / 2, 0.45, 4000); await sleep(2500) }
|
| 60 |
+
return
|
| 61 |
+
}
|
| 62 |
+
const b = comboCtrl.map.getBounds()
|
| 63 |
+
const mid = { x: (b.x0 + b.x1) / 2, y: (b.y0 + b.y1) / 2 }
|
| 64 |
+
let i = 0
|
| 65 |
+
while (!stopped) {
|
| 66 |
+
const a = anchors[i % anchors.length]
|
| 67 |
+
// Push in on the warband and dwell. Wide enough to show lots of map; the decor sprites are
|
| 68 |
+
// scaled up (CINE_SPRITE_MUL in startCinematic) to compensate, so characters keep their size.
|
| 69 |
+
await fly(a.x, a.y, rand(1.15, 1.4), 2600)
|
| 70 |
+
await sleep(1900)
|
| 71 |
+
// Every third beat, pull out to a wide establishing shot before the next group.
|
| 72 |
+
if (i % 3 === 2) { await fly(mid.x + rand(-400, 400), mid.y + rand(-300, 300), rand(0.4, 0.55), 2300); await sleep(900) }
|
| 73 |
+
i++
|
| 74 |
+
}
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
// Spawn the warbands, then start the tour over wherever they actually landed.
|
| 78 |
+
;(async () => {
|
| 79 |
+
let anchors = []
|
| 80 |
+
try { anchors = (await comboCtrl.startCinematic({ groups })) || [] } catch { anchors = [] }
|
| 81 |
+
if (stopped) { try { comboCtrl.stopCinematic() } catch {} ; return }
|
| 82 |
+
director(anchors)
|
| 83 |
+
})()
|
| 84 |
+
|
| 85 |
+
function stop() {
|
| 86 |
+
if (stopped) return
|
| 87 |
+
stopped = true
|
| 88 |
+
for (const t of timers) clearTimeout(t)
|
| 89 |
+
timers.clear()
|
| 90 |
+
try { comboCtrl.stopCinematic() } catch { /* ignore */ }
|
| 91 |
+
layer.style.opacity = '0'
|
| 92 |
+
setTimeout(() => { try { layer.remove() } catch {} }, 800)
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
return { stop }
|
| 96 |
+
}
|