Spaces:
Running
Skill forge: usable immediately, character-in-skill art painted in background
Browse filesForging a skill no longer blocks on image generation. The coding model
defines the skill, it's persisted + learned at once, and a single scene of
THE CHARACTER using the skill is painted in the background (one image now,
used as both icon and detail art — no separate icon + action art).
- skillForge.js: persist with iconPending=true and return immediately;
generateSkillImage() runs fire-and-forget, passing the hero's portrait to
Klein as an identity ref so the actual character appears in the scene.
Status names the coding model (like image/voice backends do).
- app.py: /portrait accepts ref_image; both the sidecar path
(_remote_klein_portrait) and in-process ZeroGPU path (_klein_portrait, via
new _decode_ref_image) feed it as FLUX.2 identity conditioning.
- imagen.js / imagenServer.js: thread refImage through to local + Klein engines.
- tiny.js / skillForgePanel.js: gold spinner loading indicator while an icon
is still painting; swaps to the art when iconPending clears (onRosterChange).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- app.py +57 -8
- docs/skill-upgrade-plan.md +124 -0
- web/imagen.js +5 -2
- web/imagenServer.js +2 -2
- web/skillForge.js +35 -19
- web/skillForgePanel.js +29 -4
- web/tiny.js +19 -18
|
@@ -823,34 +823,81 @@ def _load_klein_pipe():
|
|
| 823 |
return Flux2KleinPipeline.from_pretrained(_KLEIN_MODEL_ID, torch_dtype=torch.bfloat16)
|
| 824 |
|
| 825 |
|
| 826 |
-
def _remote_klein_portrait(prompt, seed=None):
|
| 827 |
import os as _os
|
|
|
|
|
|
|
| 828 |
from gradio_client import Client
|
| 829 |
client = Client(_KLEIN_SPACE, token=HF_TOKEN or None)
|
| 830 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 831 |
path = result[0] if isinstance(result, (tuple, list)) else result
|
| 832 |
with open(_os.fspath(path), "rb") as f:
|
| 833 |
return f.read()
|
| 834 |
|
| 835 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 836 |
@GPU(duration=60)
|
| 837 |
-
def _klein_portrait(prompt, seed=None, width=1024, height=1024):
|
| 838 |
-
"""FLUX.2 [klein] 4B for ZeroGPU-backed portrait generation.
|
|
|
|
|
|
|
| 839 |
global _klein_pipe
|
| 840 |
import io
|
| 841 |
import random
|
| 842 |
import torch
|
|
|
|
| 843 |
with _klein_lock:
|
| 844 |
if _klein_pipe is None:
|
| 845 |
_klein_pipe = _load_klein_pipe()
|
| 846 |
dev = "cuda" if torch.cuda.is_available() else "cpu"
|
| 847 |
_klein_pipe.to(dev)
|
| 848 |
s = int(seed if seed is not None else random.randint(0, 2_147_483_647))
|
| 849 |
-
|
| 850 |
prompt=prompt, width=width, height=height,
|
| 851 |
num_inference_steps=_KLEIN_STEPS, guidance_scale=_KLEIN_GUIDANCE,
|
| 852 |
generator=torch.Generator(device=dev).manual_seed(s),
|
| 853 |
-
)
|
|
|
|
|
|
|
|
|
|
| 854 |
if dev == "cuda":
|
| 855 |
_klein_pipe.to("cpu")
|
| 856 |
try:
|
|
@@ -925,6 +972,8 @@ async def portrait(request: Request):
|
|
| 925 |
seed = body.get("seed")
|
| 926 |
provider = body.get("provider") or "" # cloud sub-provider hint (e.g. flux-dev)
|
| 927 |
engine = (body.get("engine") or "").strip().lower() # 'local' | 'klein' | 'cloud' | '' = auto
|
|
|
|
|
|
|
| 928 |
if not prompt:
|
| 929 |
return Response("prompt required", status_code=400)
|
| 930 |
want_local = engine == "local" or (not engine and IMAGE_MODE == "local")
|
|
@@ -944,9 +993,9 @@ async def portrait(request: Request):
|
|
| 944 |
if want_klein:
|
| 945 |
try:
|
| 946 |
if _KLEIN_SPACE:
|
| 947 |
-
png = await asyncio.to_thread(_remote_klein_portrait, prompt, seed)
|
| 948 |
else:
|
| 949 |
-
png = await asyncio.to_thread(_klein_portrait, prompt, seed)
|
| 950 |
except Exception as e: # noqa: BLE001
|
| 951 |
return Response(f"klein image error: {e}", status_code=500)
|
| 952 |
return Response(png, media_type="image/png", headers={"Cache-Control": "no-store"})
|
|
|
|
| 823 |
return Flux2KleinPipeline.from_pretrained(_KLEIN_MODEL_ID, torch_dtype=torch.bfloat16)
|
| 824 |
|
| 825 |
|
| 826 |
+
def _remote_klein_portrait(prompt, seed=None, ref_image=None):
|
| 827 |
import os as _os
|
| 828 |
+
import base64 as _b64
|
| 829 |
+
import tempfile as _tmp
|
| 830 |
from gradio_client import Client
|
| 831 |
client = Client(_KLEIN_SPACE, token=HF_TOKEN or None)
|
| 832 |
+
s = int(seed if seed is not None else 42)
|
| 833 |
+
ref_path = None
|
| 834 |
+
if ref_image:
|
| 835 |
+
try:
|
| 836 |
+
raw = ref_image.split(",", 1)[1] if "," in ref_image else ref_image # strip a data: URL prefix
|
| 837 |
+
fd, ref_path = _tmp.mkstemp(suffix=".png")
|
| 838 |
+
with _os.fdopen(fd, "wb") as f:
|
| 839 |
+
f.write(_b64.b64decode(raw))
|
| 840 |
+
except Exception: # noqa: BLE001 — a bad ref just falls back to text-only
|
| 841 |
+
ref_path = None
|
| 842 |
+
try:
|
| 843 |
+
if ref_path:
|
| 844 |
+
from gradio_client import handle_file
|
| 845 |
+
# generate(prompt, seed, lora, ref_image[pose], ref_image2[identity]). No LoRA so the
|
| 846 |
+
# identity comes from the reference; pass the portrait as the identity ref.
|
| 847 |
+
result = client.predict(prompt, s, "", None, handle_file(ref_path), api_name="/generate")
|
| 848 |
+
else:
|
| 849 |
+
result = client.predict(prompt, s, api_name="/generate")
|
| 850 |
+
except Exception: # noqa: BLE001 — remote may not accept the extra args; retry text-only
|
| 851 |
+
result = client.predict(prompt, s, api_name="/generate")
|
| 852 |
+
finally:
|
| 853 |
+
if ref_path:
|
| 854 |
+
try:
|
| 855 |
+
_os.unlink(ref_path)
|
| 856 |
+
except Exception: # noqa: BLE001
|
| 857 |
+
pass
|
| 858 |
path = result[0] if isinstance(result, (tuple, list)) else result
|
| 859 |
with open(_os.fspath(path), "rb") as f:
|
| 860 |
return f.read()
|
| 861 |
|
| 862 |
|
| 863 |
+
def _decode_ref_image(ref_image):
|
| 864 |
+
"""base64 PNG / data-URL string -> PIL.Image (RGB), or None. Used as a FLUX.2 identity ref."""
|
| 865 |
+
if not ref_image:
|
| 866 |
+
return None
|
| 867 |
+
try:
|
| 868 |
+
import io
|
| 869 |
+
import base64 as _b64
|
| 870 |
+
from PIL import Image
|
| 871 |
+
raw = ref_image.split(",", 1)[1] if "," in ref_image else ref_image # strip a data: URL prefix
|
| 872 |
+
return Image.open(io.BytesIO(_b64.b64decode(raw))).convert("RGB")
|
| 873 |
+
except Exception: # noqa: BLE001 — a bad ref just falls back to text-only
|
| 874 |
+
return None
|
| 875 |
+
|
| 876 |
+
|
| 877 |
@GPU(duration=60)
|
| 878 |
+
def _klein_portrait(prompt, seed=None, width=1024, height=1024, ref_image=None):
|
| 879 |
+
"""FLUX.2 [klein] 4B for ZeroGPU-backed portrait generation. `ref_image` (base64) is an identity
|
| 880 |
+
reference (e.g. a hero's portrait) so a generated scene depicts THAT character — native multi-image
|
| 881 |
+
conditioning, same as the klein-zerogpu sidecar's generate()."""
|
| 882 |
global _klein_pipe
|
| 883 |
import io
|
| 884 |
import random
|
| 885 |
import torch
|
| 886 |
+
ref = _decode_ref_image(ref_image)
|
| 887 |
with _klein_lock:
|
| 888 |
if _klein_pipe is None:
|
| 889 |
_klein_pipe = _load_klein_pipe()
|
| 890 |
dev = "cuda" if torch.cuda.is_available() else "cpu"
|
| 891 |
_klein_pipe.to(dev)
|
| 892 |
s = int(seed if seed is not None else random.randint(0, 2_147_483_647))
|
| 893 |
+
kwargs = dict(
|
| 894 |
prompt=prompt, width=width, height=height,
|
| 895 |
num_inference_steps=_KLEIN_STEPS, guidance_scale=_KLEIN_GUIDANCE,
|
| 896 |
generator=torch.Generator(device=dev).manual_seed(s),
|
| 897 |
+
)
|
| 898 |
+
if ref is not None:
|
| 899 |
+
kwargs["image"] = ref
|
| 900 |
+
img = _klein_pipe(**kwargs).images[0]
|
| 901 |
if dev == "cuda":
|
| 902 |
_klein_pipe.to("cpu")
|
| 903 |
try:
|
|
|
|
| 972 |
seed = body.get("seed")
|
| 973 |
provider = body.get("provider") or "" # cloud sub-provider hint (e.g. flux-dev)
|
| 974 |
engine = (body.get("engine") or "").strip().lower() # 'local' | 'klein' | 'cloud' | '' = auto
|
| 975 |
+
ref_image = body.get("ref_image") # optional base64 PNG — an identity reference for Klein (e.g. the
|
| 976 |
+
# character's portrait), so a generated skill image shows THAT character.
|
| 977 |
if not prompt:
|
| 978 |
return Response("prompt required", status_code=400)
|
| 979 |
want_local = engine == "local" or (not engine and IMAGE_MODE == "local")
|
|
|
|
| 993 |
if want_klein:
|
| 994 |
try:
|
| 995 |
if _KLEIN_SPACE:
|
| 996 |
+
png = await asyncio.to_thread(_remote_klein_portrait, prompt, seed, ref_image)
|
| 997 |
else:
|
| 998 |
+
png = await asyncio.to_thread(_klein_portrait, prompt, seed, 1024, 1024, ref_image)
|
| 999 |
except Exception as e: # noqa: BLE001
|
| 1000 |
return Response(f"klein image error: {e}", status_code=500)
|
| 1001 |
return Response(png, media_type="image/png", headers={"Cache-Control": "no-store"})
|
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Skill Upgrade Plan — impressive unique VFX + powerful AoE forged skills
|
| 2 |
+
|
| 3 |
+
**Goals (from the ask):**
|
| 4 |
+
1. Forged skills get **impressive graphics when used** in combat (today they look plain).
|
| 5 |
+
2. The **coding model makes each skill visually unique** (not just mechanically).
|
| 6 |
+
3. **Most skills are powerful and hit all units in their range** (AoE).
|
| 7 |
+
|
| 8 |
+
## Why forged skills look plain today (grounded findings)
|
| 9 |
+
- VFX is config-driven: `combatRenderer.buildSkillPlay(id, def)` reads `def.skillFx[skillId] =
|
| 10 |
+
{ animUrl, effects:[{url,cell}] }`. **Canon** skills get this from the sandbox's skill configs;
|
| 11 |
+
**forged** skills (id 9000+) have **no `skillFx` entry**, so on `cast` the renderer falls through
|
| 12 |
+
to `playAttack()` — a **basic sword swing** — and `spawnEffects(undefined)` is a no-op.
|
| 13 |
+
- AoE is **target-centric**: `resolveScope()` supports `self/party/nearby/area/target_and_adjacent/
|
| 14 |
+
adjacent_to_target`, but `area`/`nearby` = **target + units within `ADJACENT_GW=140` of the
|
| 15 |
+
TARGET**. There is **no "all enemies within the caster's range" scope.** Forged skills also don't
|
| 16 |
+
set any `scope`, so each effect hits the single auto-target.
|
| 17 |
+
- Asset palette is rich: `web/assets/effects.json` (228 entries) has element sheets — **fire, ice,
|
| 18 |
+
nature, poison, shock, petrification**, plus `attack` arcs (slash/pierce/shot) and `hit` pops —
|
| 19 |
+
all minifantasy strips with `{cell, rows, cols}`. Great raw material for element-themed VFX.
|
| 20 |
+
- The Game wires VFX per actor: `comboBattler.spawnHero` builds `defsById.P0 = {name, profession,
|
| 21 |
+
...sheets}` and passes it to `createCombatRenderer`. **Adding `defsById.P0.skillFx` is the hook**
|
| 22 |
+
for the hero's forged-skill VFX. `spawnEffects` positions effects at the **caster's body centre**.
|
| 23 |
+
|
| 24 |
+
---
|
| 25 |
+
|
| 26 |
+
## Phase A — Visual identity in the forge IR (model makes each unique)
|
| 27 |
+
|
| 28 |
+
Extend the Skill-Forge output so the coding model emits a **`visual` block** alongside the mechanics.
|
| 29 |
+
Cosmetic, so a bad value just falls back (never crashes the resolver).
|
| 30 |
+
|
| 31 |
+
**New IR fields** (`skillSchema.js` → `validateSkill`):
|
| 32 |
+
```js
|
| 33 |
+
visual: {
|
| 34 |
+
element: 'fire'|'ice'|'lightning'|'nature'|'poison'|'shadow'|'holy'|'arcane', // → tint + sprite sheet
|
| 35 |
+
shape: 'slash'|'nova'|'ring'|'beam'|'pillar'|'projectile'|'shockwave', // procedural motion
|
| 36 |
+
color: '#rrggbb', // accent (validated hex)
|
| 37 |
+
intensity: 1..3, // particle count / scale / flash
|
| 38 |
+
}
|
| 39 |
+
```
|
| 40 |
+
All whitelisted/clamped. `element` maps to an `effects.json` sheet; `shape`+`color`+`intensity` drive
|
| 41 |
+
a procedural layer (Phase B). The model picks them to match the flavor → **every skill is visually
|
| 42 |
+
distinct** (element × shape × color × intensity is a huge space), and consistent with its name/lore.
|
| 43 |
+
|
| 44 |
+
**Prompt (`FORGE_SYSTEM`)** gains: "Also choose a `visual` that fits the skill's theme: an element,
|
| 45 |
+
a shape (how it manifests), an accent color, and intensity 1-3." Keep the JSON-only contract.
|
| 46 |
+
|
| 47 |
+
## Phase B — Render impressive, unique VFX
|
| 48 |
+
|
| 49 |
+
Two composited layers, wired by giving the hero's forged skills a `skillFx` entry at spawn.
|
| 50 |
+
|
| 51 |
+
**B1 — Element sprite layer (reuse existing pipeline).**
|
| 52 |
+
- Build `ELEMENT_FX` map: element → an `effects.json` URL (fire→fire sheet, lightning→shock, etc.).
|
| 53 |
+
- In `comboBattler.spawnHero`, construct `defsById.P0.skillFx` from the equipped forged skills:
|
| 54 |
+
`skillFx[skill.id] = { animUrl: null, effects: [ ELEMENT_FX[skill.visual.element] ] }`.
|
| 55 |
+
`buildSkillPlay` already loads these; `processLog` 'cast' already calls `spawnEffects`. So forged
|
| 56 |
+
skills immediately stop looking like a basic swing.
|
| 57 |
+
|
| 58 |
+
**B2 — Procedural Pixi VFX layer (new `src/render/skillVfx.js`).**
|
| 59 |
+
A small, asset-free effect generator drawn into the combat root (world space), parameterised by
|
| 60 |
+
`visual`: an **expanding tinted ring/nova** (shape `nova/ring/shockwave`, radius = the skill's AoE
|
| 61 |
+
range → reads as "everything in here is hit"), a **beam/line** to the farthest target, a **pillar**,
|
| 62 |
+
or a **projectile** burst. Color = `visual.color`; particle count/scale/duration scale with
|
| 63 |
+
`intensity`. Big skills also trigger a brief **screen flash + shake** (reuse the existing
|
| 64 |
+
`addShake`/juice hooks). This guarantees uniqueness + scale even when no sprite matches.
|
| 65 |
+
|
| 66 |
+
**B3 — Hit feedback on every struck unit.**
|
| 67 |
+
When an AoE skill resolves, pop a small element `hit` sprite (from `effects.json` `hit` category) +
|
| 68 |
+
the yellow skill-name float on each affected enemy — so the player sees the whole area get hit.
|
| 69 |
+
|
| 70 |
+
**Wiring:** comboBattler's `processLog` 'cast' hook (it already runs per cast) calls
|
| 71 |
+
`skillVfx.play(caster, skill.visual, rangeRadius, targets)`. The renderer change is additive.
|
| 72 |
+
|
| 73 |
+
## Phase C — Powerful + hit all units in range (AoE)
|
| 74 |
+
|
| 75 |
+
**C1 — New engine scope (`teamBattle.resolveScope`):**
|
| 76 |
+
```js
|
| 77 |
+
case 'area_around_caster': {
|
| 78 |
+
const r = e.range || DEFAULT_AOE // effect-supplied radius (field units)
|
| 79 |
+
return b.actors.filter(x => x.alive && x.team !== src.team && dist(src, x) <= r)
|
| 80 |
+
}
|
| 81 |
+
```
|
| 82 |
+
Allow effects to carry a clamped `range`. (Also extend `damage/heal/...` op validators to accept
|
| 83 |
+
`scope` + `range`, not just `apply_condition`.)
|
| 84 |
+
|
| 85 |
+
**C2 — Forge defaults to powerful AoE:**
|
| 86 |
+
- Schema: when the model omits scope, **default most forged skills to `area_around_caster`** (a
|
| 87 |
+
config flag, e.g. attack/spell categories get AoE by default; `self`/`ally` targets don't).
|
| 88 |
+
- Prompt: "Most skills should be POWERFUL and hit ALL foes in range — prefer an area scope and
|
| 89 |
+
meaningful damage/conditions."
|
| 90 |
+
- Numbers: raise the forged damage clamp (these are a hero's signature ultimate-ish skills), with a
|
| 91 |
+
balance ceiling. AoE radius derived from the skill's reach/element (clamped to ~the on-screen
|
| 92 |
+
ranged reach so it's readable, not whole-map).
|
| 93 |
+
|
| 94 |
+
**C3 — Readability tie-in:** the AoE radius from C1 drives the Phase-B nova scale, so the visual
|
| 95 |
+
ring exactly covers the hit area — the player *sees* who's caught.
|
| 96 |
+
|
| 97 |
+
---
|
| 98 |
+
|
| 99 |
+
## Files to touch
|
| 100 |
+
| File | Change |
|
| 101 |
+
|---|---|
|
| 102 |
+
| `tiny-army/web/skillSchema.js` | `visual` block + validation; `scope`/`range` on more ops; AoE-by-default; higher damage clamp |
|
| 103 |
+
| `tiny-army/web/skillForge.js` | `FORGE_SYSTEM` prompt: emit `visual` + prefer powerful AoE |
|
| 104 |
+
| `auto-battler/src/engine/teamBattle.js` | `area_around_caster` scope + `range` on effects |
|
| 105 |
+
| `auto-battler/src/render/skillVfx.js` (new) | procedural element VFX (nova/ring/beam/pillar/projectile) |
|
| 106 |
+
| `auto-battler/src/render/comboBattler.js` | build `defsById.P0.skillFx` from forged visuals; call `skillVfx.play` on cast; AoE-scaled nova + flash |
|
| 107 |
+
| `auto-battler/src/render/combatRenderer.js` | (light) expose a per-target hit-effect helper for AoE pops |
|
| 108 |
+
|
| 109 |
+
## Sequencing
|
| 110 |
+
1. **C (engine AoE + power)** first — biggest gameplay impact, smallest surface; verify in-engine
|
| 111 |
+
that an `area_around_caster` skill damages all foes in radius.
|
| 112 |
+
2. **A (visual schema + prompt)** — model emits `visual`; validate; re-forge produces visual params.
|
| 113 |
+
3. **B1 (element sprites)** — forged skills stop looking plain (quick win, reuses pipeline).
|
| 114 |
+
4. **B2/B3 (procedural VFX + AoE nova + hit pops)** — the "impressive + unique" payoff.
|
| 115 |
+
|
| 116 |
+
## Decisions (locked 2026-06-13)
|
| 117 |
+
- **Power/AoE:** signature-strong, **capped** — most forged attacks/spells are AoE + high damage, ceiling so they can't trivially one-shot a Champion boss.
|
| 118 |
+
- **VFX:** **both** element sprites + procedural nova/beam scaled to the AoE.
|
| 119 |
+
|
| 120 |
+
## Open decisions
|
| 121 |
+
1. **VFX approach** — element sprites only, procedural only, or **both composited** (recommended: both — sprites give theme, procedural gives unique scale/motion + guaranteed coverage).
|
| 122 |
+
2. **AoE default** — make *most* forged attacks/spells AoE (recommended), or only when the model opts in.
|
| 123 |
+
3. **Power level** — how strong? Recommend "signature strong": higher damage + AoE, but capped so a forged skill can't trivially one-shot a Champion boss.
|
| 124 |
+
4. **Should canon (non-forged) skills also get the new procedural VFX?** Optional follow-up — they already have authored sprites.
|
|
@@ -38,6 +38,9 @@ export async function ensureImage(onProgress) { return eng().ensure(onProgress)
|
|
| 38 |
|
| 39 |
// Generate a portrait → PNG Blob. `prompt` is the appearance description; `seed` keeps it
|
| 40 |
// reproducible where the engine supports it.
|
| 41 |
-
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
| 43 |
}
|
|
|
|
| 38 |
|
| 39 |
// Generate a portrait → PNG Blob. `prompt` is the appearance description; `seed` keeps it
|
| 40 |
// reproducible where the engine supports it.
|
| 41 |
+
// `refImage` (optional, base64 PNG / data URL) is an identity reference passed to Klein, so a
|
| 42 |
+
// generated image can depict a specific character (e.g. a skill scene of the hero). Engines that
|
| 43 |
+
// don't support references (cloud FLUX, in-browser) simply ignore it.
|
| 44 |
+
export async function generatePortrait(prompt, { seed, refImage } = {}) {
|
| 45 |
+
return eng().generate(prompt, { seed, refImage })
|
| 46 |
}
|
|
@@ -28,7 +28,7 @@ export const engineLocal = {
|
|
| 28 |
label: 'Z-Image-Turbo · local (your GPU)',
|
| 29 |
available: () => isLocalhost(),
|
| 30 |
note: 'run the project locally',
|
| 31 |
-
generate: (prompt, { seed } = {}) => postPortrait({ prompt, seed, engine: 'local' }),
|
| 32 |
backendLabel: () => '🖥 local model',
|
| 33 |
}
|
| 34 |
|
|
@@ -37,7 +37,7 @@ export const engineKleinZeroGpu = {
|
|
| 37 |
id: 'klein-zerogpu',
|
| 38 |
label: 'FLUX.2 klein 4B · ZeroGPU',
|
| 39 |
available: () => true,
|
| 40 |
-
generate: (prompt, { seed } = {}) => postPortrait({ prompt, seed, engine: 'klein', provider: 'flux-klein-4b' }),
|
| 41 |
backendLabel: () => 'ZeroGPU FLUX.2 klein 4B',
|
| 42 |
}
|
| 43 |
|
|
|
|
| 28 |
label: 'Z-Image-Turbo · local (your GPU)',
|
| 29 |
available: () => isLocalhost(),
|
| 30 |
note: 'run the project locally',
|
| 31 |
+
generate: (prompt, { seed, refImage } = {}) => postPortrait({ prompt, seed, engine: 'local', ref_image: refImage }),
|
| 32 |
backendLabel: () => '🖥 local model',
|
| 33 |
}
|
| 34 |
|
|
|
|
| 37 |
id: 'klein-zerogpu',
|
| 38 |
label: 'FLUX.2 klein 4B · ZeroGPU',
|
| 39 |
available: () => true,
|
| 40 |
+
generate: (prompt, { seed, refImage } = {}) => postPortrait({ prompt, seed, engine: 'klein', provider: 'flux-klein-4b', ref_image: refImage }),
|
| 41 |
backendLabel: () => 'ZeroGPU FLUX.2 klein 4B',
|
| 42 |
}
|
| 43 |
|
|
@@ -3,13 +3,12 @@
|
|
| 3 |
// validates/clamps it into an engine-runnable CB skill (skillSchema.js), paints a Klein icon,
|
| 4 |
// and persists it onto the hero (customSkills + learnedSkills + event). See skillForgePanel.js
|
| 5 |
// and tiny.js (character sheet).
|
| 6 |
-
import { streamCoding } from '/web/codingModel.js'
|
| 7 |
import { stripThinkFinal } from '/web/personaPrompts.js'
|
| 8 |
import { validateSkill, SAFE_OPS, SAFE_CONDITIONS, ELEMENTS, SHAPES } from '/web/skillSchema.js'
|
| 9 |
import { generatePortrait } from '/web/imagen.js'
|
| 10 |
-
import { getPersona, patchPersona, putSkillIcon } from '/web/personaStore.js'
|
| 11 |
import { appendEvent } from '/web/progression.js'
|
| 12 |
-
import { withElapsed } from '/web/elapsed.js'
|
| 13 |
|
| 14 |
export const FORGE_SYSTEM = [
|
| 15 |
'You are the Skill Forge for a fantasy auto-battler. Author ONE POWERFUL signature skill for a',
|
|
@@ -60,40 +59,57 @@ async function generateOnce(p, ask, { think = false, onToken, extra = '' } = {})
|
|
| 60 |
return { v: validateSkill(parseSkillJson(clean), { persona: p }), raw, clean }
|
| 61 |
}
|
| 62 |
|
| 63 |
-
|
| 64 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
}
|
| 66 |
|
| 67 |
-
//
|
| 68 |
-
//
|
| 69 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
export async function forgeSkillForHero(personaId, request, { onStatus, onToken, think = false } = {}) {
|
| 71 |
const p = getPersona(personaId)
|
| 72 |
if (!p) return { ok: false, error: 'no hero' }
|
| 73 |
const ask = (request || '').trim()
|
| 74 |
if (!ask) return { ok: false, error: 'describe the skill you want' }
|
| 75 |
|
| 76 |
-
onStatus && onStatus(
|
| 77 |
let { v } = await generateOnce(p, ask, { think, onToken })
|
| 78 |
if (!v.ok) {
|
| 79 |
-
onStatus && onStatus(
|
| 80 |
;({ v } = await generateOnce(p, ask, { think, onToken, extra: `\n\nYour previous attempt was invalid: ${v.errors.join('; ')}. Return ONLY a corrected json block.` }))
|
| 81 |
}
|
| 82 |
if (!v.ok) return { ok: false, error: v.errors.join('; ') }
|
| 83 |
|
| 84 |
-
const skill = v.skill
|
| 85 |
-
let iconBlob = null
|
| 86 |
-
try {
|
| 87 |
-
iconBlob = await withElapsed((t) => onStatus && onStatus(t), 'painting icon…', generatePortrait(iconPrompt(p, skill), { seed: skill.id }))
|
| 88 |
-
if (iconBlob) await putSkillIcon(skill.id, iconBlob)
|
| 89 |
-
} catch (e) { console.warn('[tinyarmy:skill] icon generation failed (skill still learned):', (e && e.message) || e) } // logged via diag; art is optional
|
| 90 |
-
|
| 91 |
const fresh = getPersona(p.id) || p
|
| 92 |
patchPersona(p.id, {
|
| 93 |
customSkills: { ...(fresh.customSkills || {}), [skill.id]: skill },
|
| 94 |
learnedSkills: [...(fresh.learnedSkills || []), skill.id],
|
| 95 |
events: appendEvent(fresh.events, 'skill_learned', { skill: skill.name, id: skill.id, ts: Date.now() }),
|
| 96 |
})
|
| 97 |
-
onStatus && onStatus('
|
| 98 |
-
|
|
|
|
| 99 |
}
|
|
|
|
| 3 |
// validates/clamps it into an engine-runnable CB skill (skillSchema.js), paints a Klein icon,
|
| 4 |
// and persists it onto the hero (customSkills + learnedSkills + event). See skillForgePanel.js
|
| 5 |
// and tiny.js (character sheet).
|
| 6 |
+
import { streamCoding, currentCodingModel } from '/web/codingModel.js'
|
| 7 |
import { stripThinkFinal } from '/web/personaPrompts.js'
|
| 8 |
import { validateSkill, SAFE_OPS, SAFE_CONDITIONS, ELEMENTS, SHAPES } from '/web/skillSchema.js'
|
| 9 |
import { generatePortrait } from '/web/imagen.js'
|
| 10 |
+
import { getPersona, patchPersona, putSkillIcon, getPortrait } from '/web/personaStore.js'
|
| 11 |
import { appendEvent } from '/web/progression.js'
|
|
|
|
| 12 |
|
| 13 |
export const FORGE_SYSTEM = [
|
| 14 |
'You are the Skill Forge for a fantasy auto-battler. Author ONE POWERFUL signature skill for a',
|
|
|
|
| 59 |
return { v: validateSkill(parseSkillJson(clean), { persona: p }), raw, clean }
|
| 60 |
}
|
| 61 |
|
| 62 |
+
const blobToDataURL = (blob) => new Promise((res) => { const r = new FileReader(); r.onload = () => res(r.result); r.onerror = () => res(null); r.readAsDataURL(blob) })
|
| 63 |
+
|
| 64 |
+
// The skill's image: a scene of THE CHARACTER performing the skill. Klein gets the hero's portrait
|
| 65 |
+
// as an identity reference (refImage) so the actual character appears. This single image is the
|
| 66 |
+
// skill's icon (and the detail-sheet art) — no separate icon + art any more.
|
| 67 |
+
export function skillImagePrompt(p, skill) {
|
| 68 |
+
return `${p.name || 'A warrior'}, a ${p.unitClass || 'fighter'}, unleashing the skill "${skill.name}": ${skill.flavor || ''}. dynamic fantasy action scene, the character mid-cast, motion and magical impact, dramatic lighting, painterly`
|
| 69 |
}
|
| 70 |
|
| 71 |
+
// Generate the skill image in the BACKGROUND (the skill is already usable). Stores it as the icon
|
| 72 |
+
// and clears iconPending → the UI swaps its spinner for the image (via onRosterChange).
|
| 73 |
+
async function generateSkillImage(personaId, skill) {
|
| 74 |
+
let refImage = null
|
| 75 |
+
try { const portrait = await getPortrait(personaId); if (portrait) refImage = await blobToDataURL(portrait) } catch { /* no portrait → text-only */ }
|
| 76 |
+
try {
|
| 77 |
+
const blob = await generatePortrait(skillImagePrompt(getPersona(personaId) || {}, skill), { seed: skill.id, refImage })
|
| 78 |
+
if (blob) await putSkillIcon(skill.id, blob)
|
| 79 |
+
} catch (e) { console.warn('[tinyarmy:skill] image generation failed (skill still usable):', (e && e.message) || e) }
|
| 80 |
+
finally {
|
| 81 |
+
const cur = getPersona(personaId)
|
| 82 |
+
if (cur && cur.customSkills && cur.customSkills[skill.id]) {
|
| 83 |
+
patchPersona(personaId, { customSkills: { ...cur.customSkills, [skill.id]: { ...cur.customSkills[skill.id], iconPending: false } } })
|
| 84 |
+
}
|
| 85 |
+
}
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
// Forge a skill for `personaId`. The skill is defined by the coding model, persisted IMMEDIATELY
|
| 89 |
+
// (so it's usable at once), and its image is painted in the BACKGROUND. Returns { ok, skill } as
|
| 90 |
+
// soon as the skill is defined; the image arrives later (iconPending → spinner until then).
|
| 91 |
export async function forgeSkillForHero(personaId, request, { onStatus, onToken, think = false } = {}) {
|
| 92 |
const p = getPersona(personaId)
|
| 93 |
if (!p) return { ok: false, error: 'no hero' }
|
| 94 |
const ask = (request || '').trim()
|
| 95 |
if (!ask) return { ok: false, error: 'describe the skill you want' }
|
| 96 |
|
| 97 |
+
onStatus && onStatus(`forging via ${currentCodingModel().label}…`) // name the model (like image/voice)
|
| 98 |
let { v } = await generateOnce(p, ask, { think, onToken })
|
| 99 |
if (!v.ok) {
|
| 100 |
+
onStatus && onStatus(`refining via ${currentCodingModel().label} (retrying)…`)
|
| 101 |
;({ v } = await generateOnce(p, ask, { think, onToken, extra: `\n\nYour previous attempt was invalid: ${v.errors.join('; ')}. Return ONLY a corrected json block.` }))
|
| 102 |
}
|
| 103 |
if (!v.ok) return { ok: false, error: v.errors.join('; ') }
|
| 104 |
|
| 105 |
+
const skill = { ...v.skill, iconPending: true } // image paints in the background
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
const fresh = getPersona(p.id) || p
|
| 107 |
patchPersona(p.id, {
|
| 108 |
customSkills: { ...(fresh.customSkills || {}), [skill.id]: skill },
|
| 109 |
learnedSkills: [...(fresh.learnedSkills || []), skill.id],
|
| 110 |
events: appendEvent(fresh.events, 'skill_learned', { skill: skill.name, id: skill.id, ts: Date.now() }),
|
| 111 |
})
|
| 112 |
+
onStatus && onStatus('learned ✓ (painting its scene…)')
|
| 113 |
+
generateSkillImage(p.id, skill) // fire-and-forget — don't block use of the skill
|
| 114 |
+
return { ok: true, skill }
|
| 115 |
}
|
|
@@ -6,6 +6,17 @@ import { listPersonas, getPersona, onRosterChange, getSkillIcon } from '/web/per
|
|
| 6 |
import { effectSummary } from '/web/skillSchema.js'
|
| 7 |
import { forgeSkillForHero } from '/web/skillForge.js'
|
| 8 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
function el(tag, props = {}, kids = []) {
|
| 10 |
const n = document.createElement(tag)
|
| 11 |
for (const [k, v] of Object.entries(props)) {
|
|
@@ -51,22 +62,36 @@ export function mountSkillForgePanel(host) {
|
|
| 51 |
}
|
| 52 |
function refreshStatus() { if (!status.dataset.busy) status.textContent = `Coding model: ${currentCodingModel().label}` }
|
| 53 |
|
|
|
|
| 54 |
async function renderCard(skill) {
|
|
|
|
| 55 |
card.style.display = ''
|
| 56 |
card.replaceChildren()
|
| 57 |
const head = el('div', { style: 'display:flex;gap:12px;align-items:center' })
|
| 58 |
-
const icon = el('div', { style: 'width:64px;height:64px;border-radius:10px;border:1px solid #2a3340;background:#0d1015;flex:none;background-size:cover;background-position:center' })
|
| 59 |
-
|
|
|
|
|
|
|
| 60 |
const meta = el('div', {}, [
|
| 61 |
el('div', { style: 'font:700 16px var(--tac-font,system-ui);color:#e8e8e8' }, skill.name),
|
| 62 |
el('div', { style: 'font-size:11px;color:#9aa4b2;text-transform:uppercase;letter-spacing:.06em;margin:2px 0' }, `${skill.category.replace('_', ' ')} · ${effectSummary(skill)}`),
|
| 63 |
])
|
| 64 |
head.append(icon, meta)
|
| 65 |
const flav = el('div', { style: 'color:#c2c8d2;font-style:italic;margin:10px 0 0;line-height:1.45' }, skill.flavor || '')
|
| 66 |
-
const
|
|
|
|
| 67 |
card.append(head, flav, saved)
|
| 68 |
}
|
| 69 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
let running = false
|
| 71 |
async function forge() {
|
| 72 |
if (running) return
|
|
@@ -87,7 +112,7 @@ export function mountSkillForgePanel(host) {
|
|
| 87 |
}
|
| 88 |
|
| 89 |
btn.addEventListener('click', forge)
|
| 90 |
-
onRosterChange(refreshHeroes)
|
| 91 |
onCodingModelChange(refreshStatus)
|
| 92 |
refreshHeroes(); refreshStatus()
|
| 93 |
return { refresh: refreshHeroes }
|
|
|
|
| 6 |
import { effectSummary } from '/web/skillSchema.js'
|
| 7 |
import { forgeSkillForHero } from '/web/skillForge.js'
|
| 8 |
|
| 9 |
+
// Loading spinner for a skill whose scene is still painting. Idempotent — tiny.js injects the same
|
| 10 |
+
// keyframes/class; we re-inject (id-guarded) so the Forge tab works even before the game module loads.
|
| 11 |
+
;(function injectSkillSpin() {
|
| 12 |
+
try {
|
| 13 |
+
if (document.getElementById('ta-skill-spin-css')) return
|
| 14 |
+
const s = document.createElement('style'); s.id = 'ta-skill-spin-css'
|
| 15 |
+
s.textContent = '@keyframes ta-spin{to{transform:translate(-50%,-50%) rotate(360deg)}}.ta-skill-spin{position:absolute;top:50%;left:50%;width:18px;height:18px;border:2px solid #2a3340;border-top-color:#c9a227;border-radius:50%;animation:ta-spin .8s linear infinite}'
|
| 16 |
+
document.head.appendChild(s)
|
| 17 |
+
} catch { /* ignore */ }
|
| 18 |
+
})()
|
| 19 |
+
|
| 20 |
function el(tag, props = {}, kids = []) {
|
| 21 |
const n = document.createElement(tag)
|
| 22 |
for (const [k, v] of Object.entries(props)) {
|
|
|
|
| 62 |
}
|
| 63 |
function refreshStatus() { if (!status.dataset.busy) status.textContent = `Coding model: ${currentCodingModel().label}` }
|
| 64 |
|
| 65 |
+
let shownSkillId = null // skill currently in the card — so we can repaint its icon when it finishes
|
| 66 |
async function renderCard(skill) {
|
| 67 |
+
shownSkillId = skill.id
|
| 68 |
card.style.display = ''
|
| 69 |
card.replaceChildren()
|
| 70 |
const head = el('div', { style: 'display:flex;gap:12px;align-items:center' })
|
| 71 |
+
const icon = el('div', { style: 'position:relative;width:64px;height:64px;border-radius:10px;border:1px solid #2a3340;background:#0d1015;flex:none;background-size:cover;background-position:center' })
|
| 72 |
+
const b = await getSkillIcon(skill.id)
|
| 73 |
+
if (b) icon.style.backgroundImage = `url(${URL.createObjectURL(b)})`
|
| 74 |
+
else if (skill.iconPending) icon.append(el('div', { class: 'ta-skill-spin', title: 'painting its scene…' })) // loading indicator (CSS injected by tiny.js)
|
| 75 |
const meta = el('div', {}, [
|
| 76 |
el('div', { style: 'font:700 16px var(--tac-font,system-ui);color:#e8e8e8' }, skill.name),
|
| 77 |
el('div', { style: 'font-size:11px;color:#9aa4b2;text-transform:uppercase;letter-spacing:.06em;margin:2px 0' }, `${skill.category.replace('_', ' ')} · ${effectSummary(skill)}`),
|
| 78 |
])
|
| 79 |
head.append(icon, meta)
|
| 80 |
const flav = el('div', { style: 'color:#c2c8d2;font-style:italic;margin:10px 0 0;line-height:1.45' }, skill.flavor || '')
|
| 81 |
+
const note = skill.iconPending ? 'painting its scene… (already usable on the character sheet)' : '✓ Learned — view it on the hero’s character sheet.'
|
| 82 |
+
const saved = el('div', { style: 'color:#34d058;font-size:12px;margin-top:8px' }, note)
|
| 83 |
card.append(head, flav, saved)
|
| 84 |
}
|
| 85 |
|
| 86 |
+
// When the background image finishes, the hero's skill flips iconPending → false (onRosterChange).
|
| 87 |
+
// If that skill is the one in the card, repaint it so the spinner swaps for the finished art.
|
| 88 |
+
function repaintShownCard() {
|
| 89 |
+
if (!shownSkillId || card.style.display === 'none') return
|
| 90 |
+
const owner = listPersonas().find((p) => p.customSkills && p.customSkills[shownSkillId])
|
| 91 |
+
const sk = owner && owner.customSkills[shownSkillId]
|
| 92 |
+
if (sk && !sk.iconPending) renderCard(sk)
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
let running = false
|
| 96 |
async function forge() {
|
| 97 |
if (running) return
|
|
|
|
| 112 |
}
|
| 113 |
|
| 114 |
btn.addEventListener('click', forge)
|
| 115 |
+
onRosterChange(() => { refreshHeroes(); repaintShownCard() })
|
| 116 |
onCodingModelChange(refreshStatus)
|
| 117 |
refreshHeroes(); refreshStatus()
|
| 118 |
return { refresh: refreshHeroes }
|
|
@@ -17,9 +17,8 @@ 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
|
| 21 |
import { effectSummary, resolveEquipped } from '/web/skillSchema.js'
|
| 22 |
-
import { generatePortrait } from '/web/imagen.js'
|
| 23 |
import { mountCharacterChat } from '/web/characterChat.js'
|
| 24 |
import { forgeSkillForHero } from '/web/skillForge.js'
|
| 25 |
import { mountAfterAction } from '/web/afterAction.js'
|
|
@@ -95,6 +94,16 @@ function whenEl(id, cb) {
|
|
| 95 |
})
|
| 96 |
})()
|
| 97 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
// Gradio wraps each gr.HTML block in a `<div class="prose gradio-style …">` whose versioned rules
|
| 99 |
// (.gradio-container-X .prose h2 / .prose * ; .gradio-container-X .gradio-style button) outrank the
|
| 100 |
// shared component CSS — recolouring/resizing our headings and stripping pill/button borders +
|
|
@@ -501,7 +510,7 @@ whenEl('battle-stage', async (el) => {
|
|
| 501 |
const closeDetail = () => { back.remove(); skillSheet = null }
|
| 502 |
x.addEventListener('click', closeDetail); sh.append(x)
|
| 503 |
const art = document.createElement('div'); art.style.cssText = 'width:100%;aspect-ratio:1/1;border-radius:12px;border:1px solid #2a3340;background:#0d1015;background-size:cover;background-position:center;margin:6px 0 12px;display:flex;align-items:center;justify-content:center;color:#5a6573;font-size:12px'
|
| 504 |
-
art.textContent = '
|
| 505 |
const nm = document.createElement('div'); nm.textContent = sk.name; nm.style.cssText = 'font:700 19px var(--tac-font,system-ui)'; sh.append(nm)
|
| 506 |
const cat = document.createElement('div'); cat.textContent = `${(sk.category || '').replace('_', ' ')} · ${effectSummary(sk)}`; cat.style.cssText = 'font-size:11px;color:#9aa4b2;text-transform:uppercase;letter-spacing:.06em;margin:3px 0 10px'; sh.append(cat)
|
| 507 |
if (sk.flavor) { const fl = document.createElement('div'); fl.textContent = sk.flavor; fl.style.cssText = 'font-style:italic;color:#c2c8d2;line-height:1.5;margin-bottom:14px'; sh.append(fl) }
|
|
@@ -518,16 +527,8 @@ whenEl('battle-stage', async (el) => {
|
|
| 518 |
paintEq(); sh.append(eqBtn)
|
| 519 |
back.addEventListener('pointerdown', (e) => { if (e.target === back) closeDetail() })
|
| 520 |
back.append(sh); el.append(back); skillSheet = back
|
| 521 |
-
//
|
| 522 |
-
|
| 523 |
-
if (b) { art.textContent = ''; art.style.backgroundImage = `url(${URL.createObjectURL(b)})`; return }
|
| 524 |
-
try {
|
| 525 |
-
const prompt = `${currentHero.name || 'A fighter'}, a ${currentHero.unitClass || ''}, performing "${sk.name}": ${sk.flavor || ''}. dynamic action scene, motion and impact, fantasy game art, dramatic lighting`
|
| 526 |
-
const blob = await generatePortrait(prompt, { seed: sk.id + 1 })
|
| 527 |
-
if (blob) { await putSkillArt(sk.id, blob); art.textContent = ''; art.style.backgroundImage = `url(${URL.createObjectURL(blob)})` }
|
| 528 |
-
else art.textContent = 'no art'
|
| 529 |
-
} catch { art.textContent = 'art unavailable' }
|
| 530 |
-
})
|
| 531 |
}
|
| 532 |
// Chat with the current hero in a bottom-sheet (woid-context chat + per-message voice).
|
| 533 |
let chatSheet = null
|
|
@@ -598,7 +599,7 @@ whenEl('battle-stage', async (el) => {
|
|
| 598 |
const row = document.createElement('button'); row.type = 'button'
|
| 599 |
row.style.cssText = `display:flex;gap:10px;align-items:center;width:100%;text-align:left;padding:9px;border:1px solid ${on ? '#c9a227' : '#232b36'};border-radius:10px;margin-bottom:7px;background:rgba(20,24,33,.6);color:#e8e8e8;cursor:pointer`
|
| 600 |
const ic = document.createElement('div'); ic.style.cssText = 'width:36px;height:36px;border-radius:8px;border:1px solid #2a3340;background:#0d1015;background-size:cover;background-position:center;flex:none'
|
| 601 |
-
|
| 602 |
const t = document.createElement('div')
|
| 603 |
const nm = document.createElement('div'); nm.textContent = sk.name + (on ? ' ★' : elsewhere ? ' (in another slot)' : ''); nm.style.cssText = 'font:600 13px var(--tac-font,system-ui)'
|
| 604 |
const ef = document.createElement('div'); ef.textContent = effectSummary(sk); ef.style.cssText = 'font-size:11px;color:#9aa4b2'
|
|
@@ -694,7 +695,7 @@ whenEl('battle-stage', async (el) => {
|
|
| 694 |
slot.style.cssText = `flex:1;aspect-ratio:1/1;border-radius:10px;border:1px ${sk ? 'solid #c9a227' : 'dashed #2a3340'};background:#0d1015;background-size:cover;background-position:center;cursor:pointer;position:relative;padding:0;overflow:hidden`
|
| 695 |
const num = document.createElement('span'); num.textContent = String(i + 1); num.style.cssText = 'position:absolute;top:3px;left:5px;font-size:10px;color:#9aa4b2;text-shadow:0 1px 2px #000;z-index:1'; slot.append(num)
|
| 696 |
if (sk) {
|
| 697 |
-
|
| 698 |
const cap = document.createElement('span'); cap.textContent = sk.name; cap.style.cssText = 'position:absolute;left:0;right:0;bottom:0;background:rgba(0,0,0,.62);padding:2px 3px;font-size:9px;color:#e8e8e8;white-space:nowrap;overflow:hidden;text-overflow:ellipsis'; slot.append(cap)
|
| 699 |
} else { const plus = document.createElement('span'); plus.textContent = '+'; plus.style.cssText = 'position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:22px;color:#3a4250'; slot.append(plus) }
|
| 700 |
slot.addEventListener('click', () => openBarPicker(i)); bar.append(slot)
|
|
@@ -706,8 +707,8 @@ whenEl('battle-stage', async (el) => {
|
|
| 706 |
learned.forEach((sk) => {
|
| 707 |
const onBar = eq.includes(sk.id)
|
| 708 |
const row = document.createElement('div'); row.style.cssText = `display:flex;gap:10px;align-items:center;width:100%;padding:8px;border:1px solid ${onBar ? '#c9a227' : '#232b36'};border-radius:10px;margin-bottom:7px;background:rgba(20,24,33,.6)`
|
| 709 |
-
const ic = document.createElement('button'); ic.type = 'button'; ic.title = 'Details'; ic.style.cssText = 'width:40px;height:40px;border-radius:8px;border:1px solid #2a3340;background:#0d1015;background-size:cover;background-position:center;flex:none;cursor:pointer;padding:0'
|
| 710 |
-
|
| 711 |
ic.addEventListener('click', () => openSkillDetail(sk))
|
| 712 |
const txt = document.createElement('button'); txt.type = 'button'; txt.style.cssText = 'flex:1;text-align:left;background:none;border:none;color:#e8e8e8;cursor:pointer;padding:0'
|
| 713 |
const nm = document.createElement('div'); nm.textContent = sk.name; nm.style.cssText = 'font:600 13px var(--tac-font,system-ui)'
|
|
@@ -771,7 +772,7 @@ whenEl('battle-stage', async (el) => {
|
|
| 771 |
if (sheetOpen) updateHp(comboCtrl.getHero?.())
|
| 772 |
if (s.player) { if (!moveAnchor) moveAnchor = { x: s.player.x, y: s.player.y }; else if (Math.hypot(s.player.x - moveAnchor.x, s.player.y - moveAnchor.y) > 40) { moveAnchor = { x: s.player.x, y: s.player.y }; try { tut.emit('move') } catch { /* ignore */ } } }
|
| 773 |
})
|
| 774 |
-
onRosterChange(refreshPicker)
|
| 775 |
// First-run guided tour (skippable, persisted). Kicks off shortly after the Game mounts.
|
| 776 |
if (tut.shouldAutoStart()) setTimeout(() => tut.startOrResume(), 900)
|
| 777 |
})
|
|
|
|
| 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'
|
|
|
|
| 22 |
import { mountCharacterChat } from '/web/characterChat.js'
|
| 23 |
import { forgeSkillForHero } from '/web/skillForge.js'
|
| 24 |
import { mountAfterAction } from '/web/afterAction.js'
|
|
|
|
| 94 |
})
|
| 95 |
})()
|
| 96 |
|
| 97 |
+
// Paint a forged skill's icon into a square box, or a spinner if its image is still generating in
|
| 98 |
+
// the background (iconPending). Shared by the bar slots + skill cards.
|
| 99 |
+
;(function injectSkillSpin() { try { const s = document.createElement('style'); s.textContent = '@keyframes ta-spin{to{transform:translate(-50%,-50%) rotate(360deg)}}.ta-skill-spin{position:absolute;top:50%;left:50%;width:18px;height:18px;border:2px solid #2a3340;border-top-color:#c9a227;border-radius:50%;animation:ta-spin .8s linear infinite}'; document.head.appendChild(s) } catch { /* ignore */ } })()
|
| 100 |
+
function paintSkillIcon(box, sk) {
|
| 101 |
+
getSkillIcon(sk.id).then((b) => {
|
| 102 |
+
if (b) { box.style.backgroundImage = `url(${URL.createObjectURL(b)})` }
|
| 103 |
+
else if (sk.iconPending) { box.style.position = box.style.position || 'relative'; const sp = document.createElement('div'); sp.className = 'ta-skill-spin'; box.appendChild(sp) }
|
| 104 |
+
})
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
// Gradio wraps each gr.HTML block in a `<div class="prose gradio-style …">` whose versioned rules
|
| 108 |
// (.gradio-container-X .prose h2 / .prose * ; .gradio-container-X .gradio-style button) outrank the
|
| 109 |
// shared component CSS — recolouring/resizing our headings and stripping pill/button borders +
|
|
|
|
| 510 |
const closeDetail = () => { back.remove(); skillSheet = null }
|
| 511 |
x.addEventListener('click', closeDetail); sh.append(x)
|
| 512 |
const art = document.createElement('div'); art.style.cssText = 'width:100%;aspect-ratio:1/1;border-radius:12px;border:1px solid #2a3340;background:#0d1015;background-size:cover;background-position:center;margin:6px 0 12px;display:flex;align-items:center;justify-content:center;color:#5a6573;font-size:12px'
|
| 513 |
+
art.textContent = sk.iconPending ? '✦ painting the scene…' : ''; sh.append(art)
|
| 514 |
const nm = document.createElement('div'); nm.textContent = sk.name; nm.style.cssText = 'font:700 19px var(--tac-font,system-ui)'; sh.append(nm)
|
| 515 |
const cat = document.createElement('div'); cat.textContent = `${(sk.category || '').replace('_', ' ')} · ${effectSummary(sk)}`; cat.style.cssText = 'font-size:11px;color:#9aa4b2;text-transform:uppercase;letter-spacing:.06em;margin:3px 0 10px'; sh.append(cat)
|
| 516 |
if (sk.flavor) { const fl = document.createElement('div'); fl.textContent = sk.flavor; fl.style.cssText = 'font-style:italic;color:#c2c8d2;line-height:1.5;margin-bottom:14px'; sh.append(fl) }
|
|
|
|
| 527 |
paintEq(); sh.append(eqBtn)
|
| 528 |
back.addEventListener('pointerdown', (e) => { if (e.target === back) closeDetail() })
|
| 529 |
back.append(sh); el.append(back); skillSheet = back
|
| 530 |
+
// The skill's single image (the character performing it) — generated in the background by the forge.
|
| 531 |
+
getSkillIcon(sk.id).then((b) => { if (b) { art.textContent = ''; art.style.backgroundImage = `url(${URL.createObjectURL(b)})` } else if (!sk.iconPending) art.textContent = 'no image' })
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 532 |
}
|
| 533 |
// Chat with the current hero in a bottom-sheet (woid-context chat + per-message voice).
|
| 534 |
let chatSheet = null
|
|
|
|
| 599 |
const row = document.createElement('button'); row.type = 'button'
|
| 600 |
row.style.cssText = `display:flex;gap:10px;align-items:center;width:100%;text-align:left;padding:9px;border:1px solid ${on ? '#c9a227' : '#232b36'};border-radius:10px;margin-bottom:7px;background:rgba(20,24,33,.6);color:#e8e8e8;cursor:pointer`
|
| 601 |
const ic = document.createElement('div'); ic.style.cssText = 'width:36px;height:36px;border-radius:8px;border:1px solid #2a3340;background:#0d1015;background-size:cover;background-position:center;flex:none'
|
| 602 |
+
paintSkillIcon(ic, sk)
|
| 603 |
const t = document.createElement('div')
|
| 604 |
const nm = document.createElement('div'); nm.textContent = sk.name + (on ? ' ★' : elsewhere ? ' (in another slot)' : ''); nm.style.cssText = 'font:600 13px var(--tac-font,system-ui)'
|
| 605 |
const ef = document.createElement('div'); ef.textContent = effectSummary(sk); ef.style.cssText = 'font-size:11px;color:#9aa4b2'
|
|
|
|
| 695 |
slot.style.cssText = `flex:1;aspect-ratio:1/1;border-radius:10px;border:1px ${sk ? 'solid #c9a227' : 'dashed #2a3340'};background:#0d1015;background-size:cover;background-position:center;cursor:pointer;position:relative;padding:0;overflow:hidden`
|
| 696 |
const num = document.createElement('span'); num.textContent = String(i + 1); num.style.cssText = 'position:absolute;top:3px;left:5px;font-size:10px;color:#9aa4b2;text-shadow:0 1px 2px #000;z-index:1'; slot.append(num)
|
| 697 |
if (sk) {
|
| 698 |
+
paintSkillIcon(slot, sk)
|
| 699 |
const cap = document.createElement('span'); cap.textContent = sk.name; cap.style.cssText = 'position:absolute;left:0;right:0;bottom:0;background:rgba(0,0,0,.62);padding:2px 3px;font-size:9px;color:#e8e8e8;white-space:nowrap;overflow:hidden;text-overflow:ellipsis'; slot.append(cap)
|
| 700 |
} else { const plus = document.createElement('span'); plus.textContent = '+'; plus.style.cssText = 'position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:22px;color:#3a4250'; slot.append(plus) }
|
| 701 |
slot.addEventListener('click', () => openBarPicker(i)); bar.append(slot)
|
|
|
|
| 707 |
learned.forEach((sk) => {
|
| 708 |
const onBar = eq.includes(sk.id)
|
| 709 |
const row = document.createElement('div'); row.style.cssText = `display:flex;gap:10px;align-items:center;width:100%;padding:8px;border:1px solid ${onBar ? '#c9a227' : '#232b36'};border-radius:10px;margin-bottom:7px;background:rgba(20,24,33,.6)`
|
| 710 |
+
const ic = document.createElement('button'); ic.type = 'button'; ic.title = 'Details'; ic.style.cssText = 'position:relative;width:40px;height:40px;border-radius:8px;border:1px solid #2a3340;background:#0d1015;background-size:cover;background-position:center;flex:none;cursor:pointer;padding:0'
|
| 711 |
+
paintSkillIcon(ic, sk)
|
| 712 |
ic.addEventListener('click', () => openSkillDetail(sk))
|
| 713 |
const txt = document.createElement('button'); txt.type = 'button'; txt.style.cssText = 'flex:1;text-align:left;background:none;border:none;color:#e8e8e8;cursor:pointer;padding:0'
|
| 714 |
const nm = document.createElement('div'); nm.textContent = sk.name; nm.style.cssText = 'font:600 13px var(--tac-font,system-ui)'
|
|
|
|
| 772 |
if (sheetOpen) updateHp(comboCtrl.getHero?.())
|
| 773 |
if (s.player) { if (!moveAnchor) moveAnchor = { x: s.player.x, y: s.player.y }; else if (Math.hypot(s.player.x - moveAnchor.x, s.player.y - moveAnchor.y) > 40) { moveAnchor = { x: s.player.x, y: s.player.y }; try { tut.emit('move') } catch { /* ignore */ } } }
|
| 774 |
})
|
| 775 |
+
onRosterChange(() => { refreshPicker(); if (sheetOpen && currentHero?.id) { currentHero = getPersona(currentHero.id) || currentHero; renderSheet() } })
|
| 776 |
// First-run guided tour (skippable, persisted). Kicks off shortly after the Game mounts.
|
| 777 |
if (tut.shouldAutoStart()) setTimeout(() => tut.startOrResume(), 900)
|
| 778 |
})
|