diff --git a/app.py b/app.py index 661a502f1f6ab7610045239e2eefc6022291245d..17a2359555587c597489e5172daca2e2df9fe9da 100644 --- a/app.py +++ b/app.py @@ -110,17 +110,18 @@ THEME = ('') HEAD = ('' + HIDE_TABS + FONTS + THEME + '' '' '' + '' '' '') STAGE = "height:56vh;border:1px solid #20262e;border-radius:12px;overflow:hidden;background:#0b0e12" @@ -207,6 +208,14 @@ with gr.Blocks(title="Tiny Army") as ui: # Sandbox: the Coding Model (Settings → Coding Model) authors a combat skill # for a chosen hero. Filled by web/skillForgePanel.js. gr.HTML('
') + with gr.Tab("Classes"): + # Sandbox: the shared Classes playground (web/classesSandbox.js, synced from + # auto-battler) — class picker + WASD combat + customize panel. + gr.HTML('') + with gr.Tab("Enemies"): + # Sandbox: the shared Enemies playground (web/enemiesSandbox.js) — enemy + # roster + WASD combat + stats/skill customize panel. + gr.HTML('') # Pixi canvases start hidden (0×0); re-measure them when a tab is shown. battle_tab.select(None, None, None, js="()=>window.tinyResize&&window.tinyResize()") sprite_tab.select(None, None, None, js="()=>window.tinyResize&&window.tinyResize()") @@ -246,6 +255,8 @@ fastapi_app.mount("/web", StaticFiles(directory=WEB), name="web") # NOTE: serve sprite assets at /sprites, NOT /assets — Gradio serves its own UI # bundle from /assets, and mounting there shadows it (breaks the whole UI). fastapi_app.mount("/sprites", StaticFiles(directory=os.path.join(WEB, "assets")), name="sprites") +# Skill + condition icons for the Classes sandbox (curated subset under web/gw). +fastapi_app.mount("/gw", StaticFiles(directory=os.path.join(WEB, "gw")), name="gw") def _sse(event, data): diff --git a/build.sh b/build.sh index 0c0eed17447c7072b3feea220174b9d975f94c95..3ce6f2e8c6eaac38c14028562dca72ad73f3eb88 100755 --- a/build.sh +++ b/build.sh @@ -14,16 +14,34 @@ npx --yes esbuild "$AB/src/engine/teamBattle.js" --bundle --format=esm --ou npx --yes esbuild "$AB/src/render/spriteSheet.js" --bundle --format=esm --outfile=web/sheet.js npx --yes esbuild "$AB/src/render/spriteScene.js" --bundle --format=esm --outfile=web/scene.js npx --yes esbuild "$AB/src/render/spritePlayground.js" --bundle --format=esm --outfile=web/playground.js +# Classes sandbox — the full page (picker + WASD combat + customize) as one bundle; +# pulls in the shared combatRenderer + engine. Pixi injected by web/tiny.js. +npx --yes esbuild "$AB/src/render/classesSandbox.js" --bundle --format=esm --outfile=web/classesSandbox.js +npx --yes esbuild "$AB/src/render/enemiesSandbox.js" --bundle --format=esm --outfile=web/enemiesSandbox.js # 2. App shell (nav IR + sidebar CSS/JS) + the playground chrome CSS → copied # verbatim, so they can't drift from the React app, which renders the same files. mkdir -p web/shell cp "$AB/src/shell/nav.json" "$AB/src/shell/sidebar.css" "$AB/src/shell/sidebar.js" web/shell/ cp "$AB/src/render/spriteScene.css" web/shell/spriteScene.css +cp "$AB/src/views/classes.css" web/shell/classes.css # 3. Assets → use auto-battler's FULL character manifest (so the Space lists every # character, like the app) and curate every sheet it references (~1 MB). cp "$AB/public/assets/characters.json" web/assets/characters.json +# Classes sandbox data: the effects catalogue + the class config (sprite/anim/skill +# choices). Served at /sprites/* (web/assets is mounted there); tiny.js fetches them. +cp "$AB/public/assets/effects.json" web/assets/effects.json +cp "$AB/public/classes.json" web/assets/classes.json +cp "$AB/public/enemies.json" web/assets/enemies.json AB="$AB" python3 curate_assets.py +# 4. /gw icons the Classes sandbox shows — just the ~44 CB skill icons (NOT all +# 1484 GW icons) + the condition pips. Served at /gw (mounted in app.py). +AB_ABS="$(cd "$AB" && pwd)" +mkdir -p web/gw/skills web/gw/icons +cp "$AB"/public/gw/icons/*.jpg web/gw/icons/ 2>/dev/null || true +node --input-type=module -e "import {CB_SKILLS} from 'file://$AB_ABS/src/engine/skills.js'; for (const s of CB_SKILLS) console.log(s.id)" \ + | while IFS= read -r id; do cp "$AB/public/gw/skills/$id.jpg" web/gw/skills/ 2>/dev/null || true; done + echo "synced web/{engine,sheet,scene}.js + web/shell/* + assets from $AB" diff --git a/web/assets/classes.json b/web/assets/classes.json new file mode 100644 index 0000000000000000000000000000000000000000..a8e880392a810ab545298ff15d35f499fb0c6576 --- /dev/null +++ b/web/assets/classes.json @@ -0,0 +1,359 @@ +{ + "classes": { + "Warrior": { + "sprite": "true-heroes-iii-fighter", + "bar": [], + "attackAnim": "figther-cataclysm", + "attackEffect": "none", + "attackType": "melee", + "projectile": "none" + }, + "Ranger": { + "sprite": "true-heroes-iii-ranger", + "bar": [], + "attackType": "ranged", + "attackAnim": "attack", + "attackEffects": [], + "projectile": "auto" + }, + "Monk": { + "sprite": "true-heroes-ii-cleric", + "bar": [], + "attackAnim": "attack" + }, + "Necromancer": { + "sprite": "true-heroes-iv-blood-mage", + "bar": [], + "attackAnim": "attack", + "attackType": "melee" + }, + "Mesmer": { + "sprite": "true-heroes-ii-bard", + "bar": [], + "attackType": "ranged", + "attackAnim": "jump" + }, + "Elementalist": { + "sprite": "true-heroes-iii-wizard", + "bar": [], + "attackType": "ranged", + "attackAnim": "jump" + }, + "Assassin": { + "sprite": "true-heroes-iv-ninja-assassin", + "bar": [], + "attackAnim": "thousand-blades-start" + }, + "Ritualist": { + "sprite": "true-heroes-iv-tech-augmented-gunslinger", + "bar": [], + "attackAnim": "attack", + "attackType": "ranged" + }, + "Paragon": { + "sprite": "true-heroes-ii-paladin", + "bar": [] + }, + "Dervish": { + "sprite": "dark-brotherhood-devoted-blade", + "bar": [] + } + }, + "skills": { + "1": { + "effects": [ + "auto", + "special-apotheosis" + ] + }, + "101": { + "anim": "blood-spikes", + "effects": [ + "special-blood-spikes" + ] + }, + "106": { + "anim": "blood-shards-diagonal", + "effects": [ + "flail-sickness" + ] + }, + "109": { + "anim": "extract-blood", + "effects": [ + "special-drain" + ] + }, + "115": { + "anim": "consume-blood", + "effects": [ + "special-drain" + ] + }, + "118": { + "anim": "blood-shards-orthogonal", + "effects": [ + "special-blood-shards-orthogonal" + ] + }, + "121": { + "anim": "extract-power", + "effects": [ + "special-drain" + ] + }, + "135": { + "anim": "extract-power", + "effects": [ + "special-drain" + ] + }, + "150": { + "anim": "blood-slam", + "effects": [ + "special-blood-slam" + ] + }, + "153": { + "anim": "extract-blood", + "effects": [ + "special-drain" + ] + }, + "240": { + "anim": "cleric-divine-fire-orthogonal", + "effects": [ + "special-divine-fire-projectile", + "special-divine-fire-impact" + ] + }, + "245": { + "effects": [ + "auto", + "special-dome-base-dictum", + "special-dome-dictum" + ] + }, + "252": { + "anim": "cleric-divine-fire-diagonal", + "effects": [ + "special-divine-fire-impact" + ] + }, + "281": { + "anim": "cleric-idle-start", + "effects": [ + "special-healing-words-pray" + ] + }, + "282": { + "anim": "cleric-idle-start", + "effects": [ + "special-healing-words-pray" + ] + }, + "283": { + "anim": "cleric-idle-start", + "effects": [ + "special-healing-words-pray" + ] + }, + "307": { + "effects": [ + "auto", + "special-spirit-guardian-pray" + ] + }, + "312": { + "anim": "cleric-divine-fire-orthogonal", + "effects": [ + "special-divine-fire-impact" + ] + }, + "331": { + "effects": [ + "auto", + "flail-stun", + "special-single-melee-attack", + "special-shard-impact" + ], + "anim": "figther-cataclysm" + }, + "332": { + "effects": [ + "auto", + "special-shield-bash", + "flail-sleep" + ] + }, + "348": { + "anim": "figther-idle-special", + "effects": [ + "auto", + "special-dissonant-chord-impact" + ] + }, + "352": { + "anim": "figther-tempest", + "effects": [ + "auto", + "slashm-bleeding", + "shot-poison" + ] + }, + "372": { + "anim": "figther-swirl", + "effects": [ + "special-swirl" + ] + }, + "382": { + "anim": "figther-uppercut", + "effect": "slashl-bleeding", + "effects": [ + "slashl-bleeding", + "slashm-fire", + "special-blood-slam", + "weapon-sword-fire" + ] + }, + "384": { + "effects": [ + "lash-bleeding", + "slashm-fire" + ], + "anim": "jump" + }, + "385": { + "anim": "figther-cataclysm", + "effects": [ + "flail-fire", + "flail-ice", + "slashl-petrification" + ] + }, + "391": { + "anim": "ranger-double-shot-orthogonal", + "effects": [ + "shot-bleeding", + "weapon-bow-bleeding" + ] + }, + "393": { + "anim": "ranger-double-shot-orthogonal", + "effects": [ + "shot-ice", + "weapon-bow-ice" + ] + }, + "409": { + "anim": "ranger-double-shot-orthogonal", + "effects": [ + "shot-shock", + "weapon-bow-shock" + ] + }, + "426": { + "anim": "ranger-triple-shot", + "effects": [ + "shot-shock", + "weapon-bow-shock" + ] + }, + "435": { + "anim": "ranger-double-shot-orthogonal", + "effects": [ + "flail-poison", + "lash-poison", + "weapon-viper-scimitar", + "flail-stun" + ] + }, + "446": { + "anim": "ranger-idle-special", + "effects": [ + "special-healing-words-pray" + ] + }, + "775": { + "anim": "thousand-blades-end", + "effects": [ + "special-thousand-blades" + ] + }, + "780": { + "anim": "thousand-blades-start", + "effects": [ + "weapon-dagger-bleeding", + "slashs-bleeding" + ] + }, + "782": { + "anim": "thousand-blades-start", + "effects": [ + "weapon-dagger-bleeding", + "slashs-bleeding" + ] + }, + "784": { + "anim": "thousand-blades-start", + "effects": [ + "weapon-dagger-poison", + "slashs-poison" + ] + }, + "858": { + "anim": "thousand-blades-start", + "effects": [ + "weapon-dagger-petrification", + "slashs-petrification" + ] + }, + "952": { + "anim": "deadly-dash-start", + "effects": [ + "special-drain" + ] + }, + "988": { + "anim": "thousand-blades-end", + "effects": [ + "weapon-dagger-shock", + "slashs-shock" + ] + }, + "1024": { + "anim": "thousand-blades-start", + "effects": [ + "weapon-dagger-ice", + "slashs-ice" + ] + }, + "1114": { + "effects": [ + "auto", + "special-holy-hammer-impact" + ] + }, + "1466": { + "anim": "ranger-double-shot-orthogonal", + "effects": [ + "shot-fire", + "weapon-bow-fire" + ] + }, + "1470": { + "anim": "ranger-triple-shot", + "effects": [ + "shot-bleeding", + "weapon-bow-bleeding" + ] + }, + "1727": { + "anim": "ranger-idle-special", + "effects": [ + "special-swirl" + ] + } + } +} diff --git a/web/assets/effects.json b/web/assets/effects.json new file mode 100644 index 0000000000000000000000000000000000000000..e07c061032860f61dcfece53698801580400cd5e --- /dev/null +++ b/web/assets/effects.json @@ -0,0 +1,2417 @@ +{ + "generatedFrom": "Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets", + "effects": [ + { + "category": "status", + "key": "bleeding", + "label": "Bleeding", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Status%20Effects/Status_bleeding.png", + "cell": 32, + "frames": 8 + }, + { + "category": "status", + "key": "fear", + "label": "Fear", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Status%20Effects/Status_fear.png", + "cell": 32, + "frames": 8 + }, + { + "category": "status", + "key": "fire", + "label": "Fire", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Status%20Effects/Status_fire.png", + "cell": 32, + "frames": 8 + }, + { + "category": "status", + "key": "ice", + "label": "Ice", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Status%20Effects/Status_ice.png", + "cell": 32, + "frames": 8 + }, + { + "category": "status", + "key": "nature", + "label": "Nature", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Status%20Effects/Status_nature.png", + "cell": 32, + "frames": 8 + }, + { + "category": "status", + "key": "petrification", + "label": "Petrification", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Status%20Effects/Status_petrification.png", + "cell": 32, + "frames": 8 + }, + { + "category": "status", + "key": "poison", + "label": "Poison", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Status%20Effects/Status_poisson.png", + "cell": 32, + "frames": 8 + }, + { + "category": "status", + "key": "shock", + "label": "Shock", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Status%20Effects/Status_shock.png", + "cell": 32, + "frames": 8 + }, + { + "category": "status", + "key": "sickness", + "label": "Sickness", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Status%20Effects/Status_sickness.png", + "cell": 32, + "frames": 8 + }, + { + "category": "status", + "key": "sleep", + "label": "Sleep", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Status%20Effects/Status_sleep.png", + "cell": 32, + "frames": 8 + }, + { + "category": "status", + "key": "stun", + "label": "Stun", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Status%20Effects/Status_stun.png", + "cell": 32, + "frames": 8 + }, + { + "category": "hit", + "key": "bleeding", + "label": "Bleeding", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Hit%20Effects/Hit_bleeding.png", + "cell": 32, + "frames": 4 + }, + { + "category": "hit", + "key": "fear", + "label": "Fear", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Hit%20Effects/Hit_fear.png", + "cell": 32, + "frames": 4 + }, + { + "category": "hit", + "key": "fire", + "label": "Fire", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Hit%20Effects/Hit_fire.png", + "cell": 32, + "frames": 4 + }, + { + "category": "hit", + "key": "ice", + "label": "Ice", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Hit%20Effects/Hit_ice.png", + "cell": 32, + "frames": 4 + }, + { + "category": "hit", + "key": "nature", + "label": "Nature", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Hit%20Effects/Hit_nature.png", + "cell": 32, + "frames": 4 + }, + { + "category": "hit", + "key": "petrification", + "label": "Petrification", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Hit%20Effects/Hit_petrification.png", + "cell": 32, + "frames": 4 + }, + { + "category": "hit", + "key": "poison", + "label": "Poison", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Hit%20Effects/Hit_poisson.png", + "cell": 32, + "frames": 4 + }, + { + "category": "hit", + "key": "shock", + "label": "Shock", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Hit%20Effects/Hit_shock.png", + "cell": 32, + "frames": 4 + }, + { + "category": "hit", + "key": "sickness", + "label": "Sickness", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Hit%20Effects/Hit_sickness.png", + "cell": 32, + "frames": 4 + }, + { + "category": "hit", + "key": "sleep", + "label": "Sleep", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Hit%20Effects/Hit_sleep.png", + "cell": 32, + "frames": 4 + }, + { + "category": "hit", + "key": "stun", + "label": "Stun", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Hit%20Effects/Hit_stun.png", + "cell": 32, + "frames": 4 + }, + { + "category": "projectile", + "key": "arrow-bleeding", + "label": "Arrow · bleeding", + "type": "arrow", + "element": "bleeding", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/Shot/Arrow/Arrow_bleeding.png", + "cell": 32, + "rows": 4, + "cols": 1, + "frames": 1 + }, + { + "category": "projectile", + "key": "arrow-fear", + "label": "Arrow · fear", + "type": "arrow", + "element": "fear", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/Shot/Arrow/Arrow_fear.png", + "cell": 32, + "rows": 4, + "cols": 1, + "frames": 1 + }, + { + "category": "projectile", + "key": "arrow-fire", + "label": "Arrow · fire", + "type": "arrow", + "element": "fire", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/Shot/Arrow/Arrow_fire.png", + "cell": 32, + "rows": 4, + "cols": 1, + "frames": 1 + }, + { + "category": "projectile", + "key": "arrow-ice", + "label": "Arrow · ice", + "type": "arrow", + "element": "ice", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/Shot/Arrow/Arrow_ice.png", + "cell": 32, + "rows": 4, + "cols": 1, + "frames": 1 + }, + { + "category": "projectile", + "key": "arrow-nature", + "label": "Arrow · nature", + "type": "arrow", + "element": "nature", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/Shot/Arrow/Arrow_nature.png", + "cell": 32, + "rows": 4, + "cols": 1, + "frames": 1 + }, + { + "category": "projectile", + "key": "arrow-petrification", + "label": "Arrow · petrification", + "type": "arrow", + "element": "petrification", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/Shot/Arrow/Arrow_petrification.png", + "cell": 32, + "rows": 4, + "cols": 1, + "frames": 1 + }, + { + "category": "projectile", + "key": "arrow-poison", + "label": "Arrow · poison", + "type": "arrow", + "element": "poison", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/Shot/Arrow/Arrow_poisson.png", + "cell": 32, + "rows": 4, + "cols": 1, + "frames": 1 + }, + { + "category": "projectile", + "key": "arrow-shock", + "label": "Arrow · shock", + "type": "arrow", + "element": "shock", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/Shot/Arrow/Arrow_shock.png", + "cell": 32, + "rows": 4, + "cols": 1, + "frames": 1 + }, + { + "category": "projectile", + "key": "arrow-sickness", + "label": "Arrow · sickness", + "type": "arrow", + "element": "sickness", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/Shot/Arrow/Arrow_sickness.png", + "cell": 32, + "rows": 4, + "cols": 1, + "frames": 1 + }, + { + "category": "projectile", + "key": "arrow-sleep", + "label": "Arrow · sleep", + "type": "arrow", + "element": "sleep", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/Shot/Arrow/Arrow_sleep.png", + "cell": 32, + "rows": 4, + "cols": 1, + "frames": 1 + }, + { + "category": "projectile", + "key": "arrow-stun", + "label": "Arrow · stun", + "type": "arrow", + "element": "stun", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/Shot/Arrow/Arrow_stun.png", + "cell": 32, + "rows": 4, + "cols": 1, + "frames": 1 + }, + { + "category": "attack", + "key": "flail-bleeding", + "label": "Flail · bleeding", + "type": "flail", + "element": "bleeding", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/Flail/Flail_bleeding.png", + "cell": 32, + "rows": 4, + "cols": 3, + "frames": 3 + }, + { + "category": "attack", + "key": "flail-fear", + "label": "Flail · fear", + "type": "flail", + "element": "fear", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/Flail/Flail_fear.png", + "cell": 32, + "rows": 4, + "cols": 3, + "frames": 3 + }, + { + "category": "attack", + "key": "flail-fire", + "label": "Flail · fire", + "type": "flail", + "element": "fire", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/Flail/Flail_fire.png", + "cell": 32, + "rows": 4, + "cols": 3, + "frames": 3 + }, + { + "category": "attack", + "key": "flail-ice", + "label": "Flail · ice", + "type": "flail", + "element": "ice", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/Flail/Flail_ice.png", + "cell": 32, + "rows": 4, + "cols": 3, + "frames": 3 + }, + { + "category": "attack", + "key": "flail-nature", + "label": "Flail · nature", + "type": "flail", + "element": "nature", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/Flail/Flail_nature.png", + "cell": 32, + "rows": 4, + "cols": 3, + "frames": 3 + }, + { + "category": "attack", + "key": "flail-petrification", + "label": "Flail · petrification", + "type": "flail", + "element": "petrification", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/Flail/Flail_petrification.png", + "cell": 32, + "rows": 4, + "cols": 3, + "frames": 3 + }, + { + "category": "attack", + "key": "flail-poison", + "label": "Flail · poison", + "type": "flail", + "element": "poison", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/Flail/Flail_poisson.png", + "cell": 32, + "rows": 4, + "cols": 3, + "frames": 3 + }, + { + "category": "attack", + "key": "flail-shock", + "label": "Flail · shock", + "type": "flail", + "element": "shock", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/Flail/Flail_shock.png", + "cell": 32, + "rows": 4, + "cols": 3, + "frames": 3 + }, + { + "category": "attack", + "key": "flail-sickness", + "label": "Flail · sickness", + "type": "flail", + "element": "sickness", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/Flail/Flail_sickness.png", + "cell": 32, + "rows": 4, + "cols": 3, + "frames": 3 + }, + { + "category": "attack", + "key": "flail-sleep", + "label": "Flail · sleep", + "type": "flail", + "element": "sleep", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/Flail/Flail_sleep.png", + "cell": 32, + "rows": 4, + "cols": 3, + "frames": 3 + }, + { + "category": "attack", + "key": "flail-stun", + "label": "Flail · stun", + "type": "flail", + "element": "stun", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/Flail/Flail_stun.png", + "cell": 32, + "rows": 4, + "cols": 3, + "frames": 3 + }, + { + "category": "attack", + "key": "lash-bleeding", + "label": "Lash · bleeding", + "type": "lash", + "element": "bleeding", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/Lash/Lash_bleeding.png", + "cell": 32, + "rows": 4, + "cols": 3, + "frames": 3 + }, + { + "category": "attack", + "key": "lash-fear", + "label": "Lash · fear", + "type": "lash", + "element": "fear", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/Lash/Lash_fear.png", + "cell": 32, + "rows": 4, + "cols": 3, + "frames": 3 + }, + { + "category": "attack", + "key": "lash-fire", + "label": "Lash · fire", + "type": "lash", + "element": "fire", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/Lash/Lash_fire.png", + "cell": 32, + "rows": 4, + "cols": 3, + "frames": 3 + }, + { + "category": "attack", + "key": "lash-ice", + "label": "Lash · ice", + "type": "lash", + "element": "ice", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/Lash/Lash_ice.png", + "cell": 32, + "rows": 4, + "cols": 3, + "frames": 3 + }, + { + "category": "attack", + "key": "lash-nature", + "label": "Lash · nature", + "type": "lash", + "element": "nature", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/Lash/Lash_nature.png", + "cell": 32, + "rows": 4, + "cols": 3, + "frames": 3 + }, + { + "category": "attack", + "key": "lash-petrification", + "label": "Lash · petrification", + "type": "lash", + "element": "petrification", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/Lash/Lash_petrification.png", + "cell": 32, + "rows": 4, + "cols": 3, + "frames": 3 + }, + { + "category": "attack", + "key": "lash-poison", + "label": "Lash · poison", + "type": "lash", + "element": "poison", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/Lash/Lash_poisson.png", + "cell": 32, + "rows": 4, + "cols": 3, + "frames": 3 + }, + { + "category": "attack", + "key": "lash-shock", + "label": "Lash · shock", + "type": "lash", + "element": "shock", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/Lash/Lash_shock.png", + "cell": 32, + "rows": 4, + "cols": 3, + "frames": 3 + }, + { + "category": "attack", + "key": "lash-sickness", + "label": "Lash · sickness", + "type": "lash", + "element": "sickness", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/Lash/Lash_sickness.png", + "cell": 32, + "rows": 4, + "cols": 3, + "frames": 3 + }, + { + "category": "attack", + "key": "lash-sleep", + "label": "Lash · sleep", + "type": "lash", + "element": "sleep", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/Lash/Lash_sleep.png", + "cell": 32, + "rows": 4, + "cols": 3, + "frames": 3 + }, + { + "category": "attack", + "key": "lash-stun", + "label": "Lash · stun", + "type": "lash", + "element": "stun", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/Lash/Lash_stun.png", + "cell": 32, + "rows": 4, + "cols": 3, + "frames": 3 + }, + { + "category": "attack", + "key": "pierce-bleeding", + "label": "Pierce · bleeding", + "type": "pierce", + "element": "bleeding", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/Pierce/Pierce_bleeding.png", + "cell": 32, + "rows": 4, + "cols": 3, + "frames": 3 + }, + { + "category": "attack", + "key": "pierce-fear", + "label": "Pierce · fear", + "type": "pierce", + "element": "fear", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/Pierce/Pierce_fear.png", + "cell": 32, + "rows": 4, + "cols": 3, + "frames": 3 + }, + { + "category": "attack", + "key": "pierce-fire", + "label": "Pierce · fire", + "type": "pierce", + "element": "fire", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/Pierce/Pierce_fire.png", + "cell": 32, + "rows": 4, + "cols": 3, + "frames": 3 + }, + { + "category": "attack", + "key": "pierce-ice", + "label": "Pierce · ice", + "type": "pierce", + "element": "ice", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/Pierce/Pierce_ice.png", + "cell": 32, + "rows": 4, + "cols": 3, + "frames": 3 + }, + { + "category": "attack", + "key": "pierce-nature", + "label": "Pierce · nature", + "type": "pierce", + "element": "nature", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/Pierce/Pierce_nature.png", + "cell": 32, + "rows": 4, + "cols": 3, + "frames": 3 + }, + { + "category": "attack", + "key": "pierce-petrification", + "label": "Pierce · petrification", + "type": "pierce", + "element": "petrification", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/Pierce/Pierce_petrification.png", + "cell": 32, + "rows": 4, + "cols": 3, + "frames": 3 + }, + { + "category": "attack", + "key": "pierce-poison", + "label": "Pierce · poison", + "type": "pierce", + "element": "poison", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/Pierce/Pierce_poisson.png", + "cell": 32, + "rows": 4, + "cols": 3, + "frames": 3 + }, + { + "category": "attack", + "key": "pierce-shock", + "label": "Pierce · shock", + "type": "pierce", + "element": "shock", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/Pierce/Pierce_shock.png", + "cell": 32, + "rows": 4, + "cols": 3, + "frames": 3 + }, + { + "category": "attack", + "key": "pierce-sickness", + "label": "Pierce · sickness", + "type": "pierce", + "element": "sickness", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/Pierce/Pierce_sickness.png", + "cell": 32, + "rows": 4, + "cols": 3, + "frames": 3 + }, + { + "category": "attack", + "key": "pierce-sleep", + "label": "Pierce · sleep", + "type": "pierce", + "element": "sleep", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/Pierce/Pierce_sleep.png", + "cell": 32, + "rows": 4, + "cols": 3, + "frames": 3 + }, + { + "category": "attack", + "key": "pierce-stun", + "label": "Pierce · stun", + "type": "pierce", + "element": "stun", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/Pierce/Pierce_stun.png", + "cell": 32, + "rows": 4, + "cols": 3, + "frames": 3 + }, + { + "category": "attack", + "key": "shot-bleeding", + "label": "Shot · bleeding", + "type": "shot", + "element": "bleeding", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/Shot/Front%20Layer/Shot_bleeding_f.png", + "cell": 32, + "rows": 4, + "cols": 8, + "frames": 8 + }, + { + "category": "attack", + "key": "shot-fear", + "label": "Shot · fear", + "type": "shot", + "element": "fear", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/Shot/Front%20Layer/Shot_fear_f.png", + "cell": 32, + "rows": 4, + "cols": 8, + "frames": 8 + }, + { + "category": "attack", + "key": "shot-fire", + "label": "Shot · fire", + "type": "shot", + "element": "fire", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/Shot/Front%20Layer/Shot_fire_f.png", + "cell": 32, + "rows": 4, + "cols": 8, + "frames": 8 + }, + { + "category": "attack", + "key": "shot-ice", + "label": "Shot · ice", + "type": "shot", + "element": "ice", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/Shot/Front%20Layer/Shot_ice_f.png", + "cell": 32, + "rows": 4, + "cols": 8, + "frames": 8 + }, + { + "category": "attack", + "key": "shot-nature", + "label": "Shot · nature", + "type": "shot", + "element": "nature", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/Shot/Front%20Layer/Shot_nature_f.png", + "cell": 32, + "rows": 4, + "cols": 8, + "frames": 8 + }, + { + "category": "attack", + "key": "shot-petrification", + "label": "Shot · petrification", + "type": "shot", + "element": "petrification", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/Shot/Front%20Layer/Shot_petrification_f.png", + "cell": 32, + "rows": 4, + "cols": 8, + "frames": 8 + }, + { + "category": "attack", + "key": "shot-poison", + "label": "Shot · poison", + "type": "shot", + "element": "poison", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/Shot/Front%20Layer/Shot_poisson_f.png", + "cell": 32, + "rows": 4, + "cols": 8, + "frames": 8 + }, + { + "category": "attack", + "key": "shot-shock", + "label": "Shot · shock", + "type": "shot", + "element": "shock", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/Shot/Front%20Layer/Shot_shock_f.png", + "cell": 32, + "rows": 4, + "cols": 8, + "frames": 8 + }, + { + "category": "attack", + "key": "shot-sickness", + "label": "Shot · sickness", + "type": "shot", + "element": "sickness", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/Shot/Front%20Layer/Shot_sickness_f.png", + "cell": 32, + "rows": 4, + "cols": 8, + "frames": 8 + }, + { + "category": "attack", + "key": "shot-sleep", + "label": "Shot · sleep", + "type": "shot", + "element": "sleep", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/Shot/Front%20Layer/Shot_sleep_f.png", + "cell": 32, + "rows": 4, + "cols": 8, + "frames": 8 + }, + { + "category": "attack", + "key": "shot-stun", + "label": "Shot · stun", + "type": "shot", + "element": "stun", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/Shot/Front%20Layer/Shot_stun_f.png", + "cell": 32, + "rows": 4, + "cols": 8, + "frames": 8 + }, + { + "category": "attack", + "key": "slashl-bleeding", + "label": "SlashL · bleeding", + "type": "slashl", + "element": "bleeding", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/SlashL/Front%20Layer/SlashL_bleeding_f.png", + "cell": 32, + "rows": 4, + "cols": 6, + "frames": 6 + }, + { + "category": "attack", + "key": "slashl-fear", + "label": "SlashL · fear", + "type": "slashl", + "element": "fear", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/SlashL/Front%20Layer/SlashL_fear_f.png", + "cell": 32, + "rows": 4, + "cols": 6, + "frames": 6 + }, + { + "category": "attack", + "key": "slashl-fire", + "label": "SlashL · fire", + "type": "slashl", + "element": "fire", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/SlashL/Front%20Layer/SlashL_fire_f.png", + "cell": 32, + "rows": 4, + "cols": 6, + "frames": 6 + }, + { + "category": "attack", + "key": "slashl-ice", + "label": "SlashL · ice", + "type": "slashl", + "element": "ice", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/SlashL/Front%20Layer/SlashL_ice_f.png", + "cell": 32, + "rows": 4, + "cols": 6, + "frames": 6 + }, + { + "category": "attack", + "key": "slashl-nature", + "label": "SlashL · nature", + "type": "slashl", + "element": "nature", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/SlashL/Front%20Layer/SlashL_nature_f.png", + "cell": 32, + "rows": 4, + "cols": 6, + "frames": 6 + }, + { + "category": "attack", + "key": "slashl-petrification", + "label": "SlashL · petrification", + "type": "slashl", + "element": "petrification", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/SlashL/Front%20Layer/SlashL_petrification_f.png", + "cell": 32, + "rows": 4, + "cols": 6, + "frames": 6 + }, + { + "category": "attack", + "key": "slashl-poison", + "label": "SlashL · poison", + "type": "slashl", + "element": "poison", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/SlashL/Front%20Layer/SlashL_poisson_f.png", + "cell": 32, + "rows": 4, + "cols": 6, + "frames": 6 + }, + { + "category": "attack", + "key": "slashl-shock", + "label": "SlashL · shock", + "type": "slashl", + "element": "shock", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/SlashL/Front%20Layer/SlashL_shock_f.png", + "cell": 32, + "rows": 4, + "cols": 6, + "frames": 6 + }, + { + "category": "attack", + "key": "slashl-sickness", + "label": "SlashL · sickness", + "type": "slashl", + "element": "sickness", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/SlashL/Front%20Layer/SlashL_sickness_f.png", + "cell": 32, + "rows": 4, + "cols": 6, + "frames": 6 + }, + { + "category": "attack", + "key": "slashl-sleep", + "label": "SlashL · sleep", + "type": "slashl", + "element": "sleep", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/SlashL/Front%20Layer/SlashL_sleep_f.png", + "cell": 32, + "rows": 4, + "cols": 6, + "frames": 6 + }, + { + "category": "attack", + "key": "slashl-stun", + "label": "SlashL · stun", + "type": "slashl", + "element": "stun", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/SlashL/Front%20Layer/SlashL_stun_f.png", + "cell": 32, + "rows": 4, + "cols": 6, + "frames": 6 + }, + { + "category": "attack", + "key": "slashm-bleeding", + "label": "SlashM · bleeding", + "type": "slashm", + "element": "bleeding", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/SlashM/Front%20Layer/SlashM_bleeding_f.png", + "cell": 32, + "rows": 4, + "cols": 4, + "frames": 4 + }, + { + "category": "attack", + "key": "slashm-fear", + "label": "SlashM · fear", + "type": "slashm", + "element": "fear", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/SlashM/Front%20Layer/SlashM_fear_f.png", + "cell": 32, + "rows": 4, + "cols": 4, + "frames": 4 + }, + { + "category": "attack", + "key": "slashm-fire", + "label": "SlashM · fire", + "type": "slashm", + "element": "fire", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/SlashM/Front%20Layer/SlashM_fire_f.png", + "cell": 32, + "rows": 4, + "cols": 4, + "frames": 4 + }, + { + "category": "attack", + "key": "slashm-ice", + "label": "SlashM · ice", + "type": "slashm", + "element": "ice", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/SlashM/Front%20Layer/SlashM_ice_f.png", + "cell": 32, + "rows": 4, + "cols": 4, + "frames": 4 + }, + { + "category": "attack", + "key": "slashm-nature", + "label": "SlashM · nature", + "type": "slashm", + "element": "nature", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/SlashM/Front%20Layer/SlashM_nature_f.png", + "cell": 32, + "rows": 4, + "cols": 4, + "frames": 4 + }, + { + "category": "attack", + "key": "slashm-petrification", + "label": "SlashM · petrification", + "type": "slashm", + "element": "petrification", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/SlashM/Front%20Layer/SlashM_petrification_f.png", + "cell": 32, + "rows": 4, + "cols": 4, + "frames": 4 + }, + { + "category": "attack", + "key": "slashm-poison", + "label": "SlashM · poison", + "type": "slashm", + "element": "poison", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/SlashM/Front%20Layer/SlashM_poisson_f.png", + "cell": 32, + "rows": 4, + "cols": 4, + "frames": 4 + }, + { + "category": "attack", + "key": "slashm-shock", + "label": "SlashM · shock", + "type": "slashm", + "element": "shock", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/SlashM/Front%20Layer/SlashM_shock_f.png", + "cell": 32, + "rows": 4, + "cols": 4, + "frames": 4 + }, + { + "category": "attack", + "key": "slashm-sickness", + "label": "SlashM · sickness", + "type": "slashm", + "element": "sickness", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/SlashM/Front%20Layer/SlashM_sickness_f.png", + "cell": 32, + "rows": 4, + "cols": 4, + "frames": 4 + }, + { + "category": "attack", + "key": "slashm-sleep", + "label": "SlashM · sleep", + "type": "slashm", + "element": "sleep", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/SlashM/Front%20Layer/SlashM_sleep_f.png", + "cell": 32, + "rows": 4, + "cols": 4, + "frames": 4 + }, + { + "category": "attack", + "key": "slashm-stun", + "label": "SlashM · stun", + "type": "slashm", + "element": "stun", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/SlashM/Front%20Layer/SlashM_stun_f%20.png", + "cell": 32, + "rows": 4, + "cols": 4, + "frames": 4 + }, + { + "category": "attack", + "key": "slashs-bleeding", + "label": "SlashS · bleeding", + "type": "slashs", + "element": "bleeding", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/SlashS/Front%20Layer/SlashS_bleeding_f.png", + "cell": 32, + "rows": 4, + "cols": 4, + "frames": 4 + }, + { + "category": "attack", + "key": "slashs-fear", + "label": "SlashS · fear", + "type": "slashs", + "element": "fear", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/SlashS/Front%20Layer/SlashS_fear_f.png", + "cell": 32, + "rows": 4, + "cols": 4, + "frames": 4 + }, + { + "category": "attack", + "key": "slashs-fire", + "label": "SlashS · fire", + "type": "slashs", + "element": "fire", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/SlashS/Front%20Layer/SlashS_fire_f.png", + "cell": 32, + "rows": 4, + "cols": 4, + "frames": 4 + }, + { + "category": "attack", + "key": "slashs-ice", + "label": "SlashS · ice", + "type": "slashs", + "element": "ice", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/SlashS/Front%20Layer/SlashS_ice_f.png", + "cell": 32, + "rows": 4, + "cols": 4, + "frames": 4 + }, + { + "category": "attack", + "key": "slashs-nature", + "label": "SlashS · nature", + "type": "slashs", + "element": "nature", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/SlashS/Front%20Layer/SlashS_nature_f.png", + "cell": 32, + "rows": 4, + "cols": 4, + "frames": 4 + }, + { + "category": "attack", + "key": "slashs-petrification", + "label": "SlashS · petrification", + "type": "slashs", + "element": "petrification", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/SlashS/Front%20Layer/SlashS_petrification_f.png", + "cell": 32, + "rows": 4, + "cols": 4, + "frames": 4 + }, + { + "category": "attack", + "key": "slashs-poison", + "label": "SlashS · poison", + "type": "slashs", + "element": "poison", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/SlashS/Front%20Layer/SlashS_poisson_f.png", + "cell": 32, + "rows": 4, + "cols": 4, + "frames": 4 + }, + { + "category": "attack", + "key": "slashs-shock", + "label": "SlashS · shock", + "type": "slashs", + "element": "shock", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/SlashS/Front%20Layer/SlashS_shock_f.png", + "cell": 32, + "rows": 4, + "cols": 4, + "frames": 4 + }, + { + "category": "attack", + "key": "slashs-sickness", + "label": "SlashS · sickness", + "type": "slashs", + "element": "sickness", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/SlashS/Front%20Layer/SlashS_sickness_f.png", + "cell": 32, + "rows": 4, + "cols": 4, + "frames": 4 + }, + { + "category": "attack", + "key": "slashs-sleep", + "label": "SlashS · sleep", + "type": "slashs", + "element": "sleep", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/SlashS/Front%20Layer/SlashS_sleep_f.png", + "cell": 32, + "rows": 4, + "cols": 4, + "frames": 4 + }, + { + "category": "attack", + "key": "slashs-stun", + "label": "SlashS · stun", + "type": "slashs", + "element": "stun", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Standalone%20Effects/Attack%20Effects/SlashS/Front%20Layer/SlashS_stun_f.png", + "cell": 32, + "rows": 4, + "cols": 4, + "frames": 4 + }, + { + "category": "weapon", + "key": "weapon-icerberg-blade", + "label": "Icerberg Blade", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Legendary%20Weapons/Icerberg%20Blade/Icerberg_blade_f.png", + "cell": 32, + "rows": 4, + "cols": 6, + "frames": 6 + }, + { + "category": "weapon", + "key": "weapon-thunderbolt-sword", + "label": "Thunderbolt Sword", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Legendary%20Weapons/Thunderbolt%20Sword/Thunderbolt_sword_f.png", + "cell": 32, + "rows": 4, + "cols": 6, + "frames": 6 + }, + { + "category": "weapon", + "key": "weapon-viper-scimitar", + "label": "Viper Scimitar", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Legendary%20Weapons/Viper%20Scimitar/Viper_scimitar_f.png", + "cell": 32, + "rows": 4, + "cols": 6, + "frames": 6 + }, + { + "category": "weapon", + "key": "weapon-volcano-mace", + "label": "Volcano Mace", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Legendary%20Weapons/Volcano%20Mace/Volcano_mace_f.png", + "cell": 32, + "rows": 4, + "cols": 6, + "frames": 6 + }, + { + "category": "weapon", + "key": "weapon-bow-bleeding", + "label": "Bow · bleeding", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/Bow/Front%20Layer/Bow_bleeding_f.png", + "cell": 32, + "rows": 4, + "cols": 8, + "frames": 8 + }, + { + "category": "weapon", + "key": "weapon-bow-fear", + "label": "Bow · fear", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/Bow/Front%20Layer/Bow_fear_f.png", + "cell": 32, + "rows": 4, + "cols": 8, + "frames": 8 + }, + { + "category": "weapon", + "key": "weapon-bow-fire", + "label": "Bow · fire", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/Bow/Front%20Layer/Bow_fire_f.png", + "cell": 32, + "rows": 4, + "cols": 8, + "frames": 8 + }, + { + "category": "weapon", + "key": "weapon-bow-ice", + "label": "Bow · ice", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/Bow/Front%20Layer/Bow_ice_f.png", + "cell": 32, + "rows": 4, + "cols": 8, + "frames": 8 + }, + { + "category": "weapon", + "key": "weapon-bow-nature", + "label": "Bow · nature", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/Bow/Front%20Layer/Bow_nature_f.png", + "cell": 32, + "rows": 4, + "cols": 8, + "frames": 8 + }, + { + "category": "weapon", + "key": "weapon-bow-petrification", + "label": "Bow · petrification", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/Bow/Front%20Layer/Bow_petrification_f.png", + "cell": 32, + "rows": 4, + "cols": 8, + "frames": 8 + }, + { + "category": "weapon", + "key": "weapon-bow-poison", + "label": "Bow · poison", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/Bow/Front%20Layer/Bow_poisson_f.png", + "cell": 32, + "rows": 4, + "cols": 8, + "frames": 8 + }, + { + "category": "weapon", + "key": "weapon-bow-shock", + "label": "Bow · shock", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/Bow/Front%20Layer/Bow_shock_f.png", + "cell": 32, + "rows": 4, + "cols": 8, + "frames": 8 + }, + { + "category": "weapon", + "key": "weapon-bow-sickness", + "label": "Bow · sickness", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/Bow/Front%20Layer/Bow_sickness_f.png", + "cell": 32, + "rows": 4, + "cols": 8, + "frames": 8 + }, + { + "category": "weapon", + "key": "weapon-bow-sleep", + "label": "Bow · sleep", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/Bow/Front%20Layer/Bow_sleep_f.png", + "cell": 32, + "rows": 4, + "cols": 8, + "frames": 8 + }, + { + "category": "weapon", + "key": "weapon-bow-stun", + "label": "Bow · stun", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/Bow/Front%20Layer/Bow_stun_f.png", + "cell": 32, + "rows": 4, + "cols": 8, + "frames": 8 + }, + { + "category": "weapon", + "key": "weapon-dagger-bleeding", + "label": "Dagger · bleeding", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/Dagger/Front%20Layer/Dagger_bleeding_f.png", + "cell": 32, + "rows": 4, + "cols": 4, + "frames": 4 + }, + { + "category": "weapon", + "key": "weapon-dagger-fear", + "label": "Dagger · fear", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/Dagger/Front%20Layer/Dagger_fear_f.png", + "cell": 32, + "rows": 4, + "cols": 4, + "frames": 4 + }, + { + "category": "weapon", + "key": "weapon-dagger-fire", + "label": "Dagger · fire", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/Dagger/Front%20Layer/Dagger_fire_f.png", + "cell": 32, + "rows": 4, + "cols": 4, + "frames": 4 + }, + { + "category": "weapon", + "key": "weapon-dagger-ice", + "label": "Dagger · ice", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/Dagger/Front%20Layer/Dagger_ice_f.png", + "cell": 32, + "rows": 4, + "cols": 4, + "frames": 4 + }, + { + "category": "weapon", + "key": "weapon-dagger-nature", + "label": "Dagger · nature", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/Dagger/Front%20Layer/Dagger_nature_f.png", + "cell": 32, + "rows": 4, + "cols": 4, + "frames": 4 + }, + { + "category": "weapon", + "key": "weapon-dagger-petrification", + "label": "Dagger · petrification", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/Dagger/Front%20Layer/Dagger_petrification_f.png", + "cell": 32, + "rows": 4, + "cols": 4, + "frames": 4 + }, + { + "category": "weapon", + "key": "weapon-dagger-poison", + "label": "Dagger · poison", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/Dagger/Front%20Layer/Dagger_poisson_f.png", + "cell": 32, + "rows": 4, + "cols": 4, + "frames": 4 + }, + { + "category": "weapon", + "key": "weapon-dagger-shock", + "label": "Dagger · shock", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/Dagger/Front%20Layer/Dagger_shock_f.png", + "cell": 32, + "rows": 4, + "cols": 4, + "frames": 4 + }, + { + "category": "weapon", + "key": "weapon-dagger-sickness", + "label": "Dagger · sickness", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/Dagger/Front%20Layer/Dagger_sickness_f.png", + "cell": 32, + "rows": 4, + "cols": 4, + "frames": 4 + }, + { + "category": "weapon", + "key": "weapon-dagger-sleep", + "label": "Dagger · sleep", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/Dagger/Front%20Layer/Dagger_sleep_f.png", + "cell": 32, + "rows": 4, + "cols": 4, + "frames": 4 + }, + { + "category": "weapon", + "key": "weapon-dagger-stun", + "label": "Dagger · stun", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/Dagger/Front%20Layer/Dagger_stun_f.png", + "cell": 32, + "rows": 4, + "cols": 4, + "frames": 4 + }, + { + "category": "weapon", + "key": "weapon-flail-bleeding", + "label": "Flail · bleeding", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/Flail/Flail_bleeding.png", + "cell": 32, + "rows": 4, + "cols": 3, + "frames": 3 + }, + { + "category": "weapon", + "key": "weapon-flail-fear", + "label": "Flail · fear", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/Flail/Flail_fear.png", + "cell": 32, + "rows": 4, + "cols": 3, + "frames": 3 + }, + { + "category": "weapon", + "key": "weapon-flail-fire", + "label": "Flail · fire", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/Flail/Flail_fire.png", + "cell": 32, + "rows": 4, + "cols": 3, + "frames": 3 + }, + { + "category": "weapon", + "key": "weapon-flail-ice", + "label": "Flail · ice", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/Flail/Flail_ice.png", + "cell": 32, + "rows": 4, + "cols": 3, + "frames": 3 + }, + { + "category": "weapon", + "key": "weapon-flail-nature", + "label": "Flail · nature", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/Flail/Flail_nature.png", + "cell": 32, + "rows": 4, + "cols": 3, + "frames": 3 + }, + { + "category": "weapon", + "key": "weapon-flail-petrification", + "label": "Flail · petrification", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/Flail/Flail_petrification.png", + "cell": 32, + "rows": 4, + "cols": 3, + "frames": 3 + }, + { + "category": "weapon", + "key": "weapon-flail-poison", + "label": "Flail · poison", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/Flail/Flail_poisson.png", + "cell": 32, + "rows": 4, + "cols": 3, + "frames": 3 + }, + { + "category": "weapon", + "key": "weapon-flail-shock", + "label": "Flail · shock", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/Flail/Flail_shock.png", + "cell": 32, + "rows": 4, + "cols": 3, + "frames": 3 + }, + { + "category": "weapon", + "key": "weapon-flail-sickness", + "label": "Flail · sickness", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/Flail/Flail_sickness.png", + "cell": 32, + "rows": 4, + "cols": 3, + "frames": 3 + }, + { + "category": "weapon", + "key": "weapon-flail-sleep", + "label": "Flail · sleep", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/Flail/Flail_sleep.png", + "cell": 32, + "rows": 4, + "cols": 3, + "frames": 3 + }, + { + "category": "weapon", + "key": "weapon-flail-stun", + "label": "Flail · stun", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/Flail/Flail_stun.png", + "cell": 32, + "rows": 4, + "cols": 3, + "frames": 3 + }, + { + "category": "weapon", + "key": "weapon-longsword-bleeding", + "label": "LongSword · bleeding", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/LongSword/Front%20layer/LongSword_bleeding_f.png", + "cell": 32, + "rows": 4, + "cols": 6, + "frames": 6 + }, + { + "category": "weapon", + "key": "weapon-longsword-fear", + "label": "LongSword · fear", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/LongSword/Front%20layer/LongSword_fear_f.png", + "cell": 32, + "rows": 4, + "cols": 6, + "frames": 6 + }, + { + "category": "weapon", + "key": "weapon-longsword-fire", + "label": "LongSword · fire", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/LongSword/Front%20layer/LongSword_fire_f.png", + "cell": 32, + "rows": 4, + "cols": 6, + "frames": 6 + }, + { + "category": "weapon", + "key": "weapon-longsword-ice", + "label": "LongSword · ice", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/LongSword/Front%20layer/LongSword_ice_f.png", + "cell": 32, + "rows": 4, + "cols": 6, + "frames": 6 + }, + { + "category": "weapon", + "key": "weapon-longsword-nature", + "label": "LongSword · nature", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/LongSword/Front%20layer/LongSword_nature_f.png", + "cell": 32, + "rows": 4, + "cols": 6, + "frames": 6 + }, + { + "category": "weapon", + "key": "weapon-longsword-petrification", + "label": "LongSword · petrification", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/LongSword/Front%20layer/LongSword_petrification_f.png", + "cell": 32, + "rows": 4, + "cols": 6, + "frames": 6 + }, + { + "category": "weapon", + "key": "weapon-longsword-poison", + "label": "LongSword · poison", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/LongSword/Front%20layer/LongSword_poisson_f.png", + "cell": 32, + "rows": 4, + "cols": 6, + "frames": 6 + }, + { + "category": "weapon", + "key": "weapon-longsword-shock", + "label": "LongSword · shock", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/LongSword/Front%20layer/LongSword_shock_f.png", + "cell": 32, + "rows": 4, + "cols": 6, + "frames": 6 + }, + { + "category": "weapon", + "key": "weapon-longsword-sickness", + "label": "LongSword · sickness", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/LongSword/Front%20layer/LongSword_sickness_f.png", + "cell": 32, + "rows": 4, + "cols": 6, + "frames": 6 + }, + { + "category": "weapon", + "key": "weapon-longsword-sleep", + "label": "LongSword · sleep", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/LongSword/Front%20layer/LongSword_sleep_f.png", + "cell": 32, + "rows": 4, + "cols": 6, + "frames": 6 + }, + { + "category": "weapon", + "key": "weapon-longsword-stun", + "label": "LongSword · stun", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/LongSword/Front%20layer/LongSword_stun_f.png", + "cell": 32, + "rows": 4, + "cols": 6, + "frames": 6 + }, + { + "category": "weapon", + "key": "weapon-spear-bleeding", + "label": "Spear · bleeding", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/Spear/Spear_bleeding.png", + "cell": 32, + "rows": 4, + "cols": 3, + "frames": 3 + }, + { + "category": "weapon", + "key": "weapon-spear-fear", + "label": "Spear · fear", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/Spear/Spear_fear.png", + "cell": 32, + "rows": 4, + "cols": 3, + "frames": 3 + }, + { + "category": "weapon", + "key": "weapon-spear-fire", + "label": "Spear · fire", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/Spear/Spear_fire.png", + "cell": 32, + "rows": 4, + "cols": 3, + "frames": 3 + }, + { + "category": "weapon", + "key": "weapon-spear-ice", + "label": "Spear · ice", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/Spear/Spear_ice.png", + "cell": 32, + "rows": 4, + "cols": 3, + "frames": 3 + }, + { + "category": "weapon", + "key": "weapon-spear-nature", + "label": "Spear · nature", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/Spear/Spear_nature.png", + "cell": 32, + "rows": 4, + "cols": 3, + "frames": 3 + }, + { + "category": "weapon", + "key": "weapon-spear-petrification", + "label": "Spear · petrification", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/Spear/Spear_petrification.png", + "cell": 32, + "rows": 4, + "cols": 3, + "frames": 3 + }, + { + "category": "weapon", + "key": "weapon-spear-poison", + "label": "Spear · poison", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/Spear/Spear_poisson.png", + "cell": 32, + "rows": 4, + "cols": 3, + "frames": 3 + }, + { + "category": "weapon", + "key": "weapon-spear-shock", + "label": "Spear · shock", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/Spear/Spear_shock.png", + "cell": 32, + "rows": 4, + "cols": 3, + "frames": 3 + }, + { + "category": "weapon", + "key": "weapon-spear-sickness", + "label": "Spear · sickness", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/Spear/Spear_sickness.png", + "cell": 32, + "rows": 4, + "cols": 3, + "frames": 3 + }, + { + "category": "weapon", + "key": "weapon-spear-sleep", + "label": "Spear · sleep", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/Spear/Spear_sleep.png", + "cell": 32, + "rows": 4, + "cols": 3, + "frames": 3 + }, + { + "category": "weapon", + "key": "weapon-spear-stun", + "label": "Spear · stun", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/Spear/Spear_stun.png", + "cell": 32, + "rows": 4, + "cols": 3, + "frames": 3 + }, + { + "category": "weapon", + "key": "weapon-sword-bleeding", + "label": "Sword · bleeding", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/Sword/Front%20Layer/Sword_bleeding_f.png", + "cell": 32, + "rows": 4, + "cols": 4, + "frames": 4 + }, + { + "category": "weapon", + "key": "weapon-sword-fear", + "label": "Sword · fear", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/Sword/Front%20Layer/Sword_fear_f.png", + "cell": 32, + "rows": 4, + "cols": 4, + "frames": 4 + }, + { + "category": "weapon", + "key": "weapon-sword-fire", + "label": "Sword · fire", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/Sword/Front%20Layer/Sword_fire_f.png", + "cell": 32, + "rows": 4, + "cols": 4, + "frames": 4 + }, + { + "category": "weapon", + "key": "weapon-sword-ice", + "label": "Sword · ice", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/Sword/Front%20Layer/Sword_ice_f.png", + "cell": 32, + "rows": 4, + "cols": 4, + "frames": 4 + }, + { + "category": "weapon", + "key": "weapon-sword-nature", + "label": "Sword · nature", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/Sword/Front%20Layer/Sword_nature_f.png", + "cell": 32, + "rows": 4, + "cols": 4, + "frames": 4 + }, + { + "category": "weapon", + "key": "weapon-sword-petrification", + "label": "Sword · petrification", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/Sword/Front%20Layer/Sword_petrification_f.png", + "cell": 32, + "rows": 4, + "cols": 4, + "frames": 4 + }, + { + "category": "weapon", + "key": "weapon-sword-poison", + "label": "Sword · poison", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/Sword/Front%20Layer/Sword_poisson_f.png", + "cell": 32, + "rows": 4, + "cols": 4, + "frames": 4 + }, + { + "category": "weapon", + "key": "weapon-sword-shock", + "label": "Sword · shock", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/Sword/Front%20Layer/Sword_shock_f.png", + "cell": 32, + "rows": 4, + "cols": 4, + "frames": 4 + }, + { + "category": "weapon", + "key": "weapon-sword-sickness", + "label": "Sword · sickness", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/Sword/Front%20Layer/Sword_sickness_f.png", + "cell": 32, + "rows": 4, + "cols": 4, + "frames": 4 + }, + { + "category": "weapon", + "key": "weapon-sword-sleep", + "label": "Sword · sleep", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/Sword/Front%20Layer/Sword_sleep_f.png", + "cell": 32, + "rows": 4, + "cols": 4, + "frames": 4 + }, + { + "category": "weapon", + "key": "weapon-sword-stun", + "label": "Sword · stun", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/Sword/Front%20Layer/Sword_stun_f%20.png", + "cell": 32, + "rows": 4, + "cols": 4, + "frames": 4 + }, + { + "category": "weapon", + "key": "weapon-whip-bleeding", + "label": "Whip · bleeding", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/Whip/Whip_bleeding.png", + "cell": 32, + "rows": 4, + "cols": 3, + "frames": 3 + }, + { + "category": "weapon", + "key": "weapon-whip-fear", + "label": "Whip · fear", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/Whip/Whip_fear.png", + "cell": 32, + "rows": 4, + "cols": 3, + "frames": 3 + }, + { + "category": "weapon", + "key": "weapon-whip-fire", + "label": "Whip · fire", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/Whip/Whip_fire.png", + "cell": 32, + "rows": 4, + "cols": 3, + "frames": 3 + }, + { + "category": "weapon", + "key": "weapon-whip-ice", + "label": "Whip · ice", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/Whip/Whip_ice.png", + "cell": 32, + "rows": 4, + "cols": 3, + "frames": 3 + }, + { + "category": "weapon", + "key": "weapon-whip-nature", + "label": "Whip · nature", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/Whip/Whip_nature.png", + "cell": 32, + "rows": 4, + "cols": 3, + "frames": 3 + }, + { + "category": "weapon", + "key": "weapon-whip-petrification", + "label": "Whip · petrification", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/Whip/Whip_petrification.png", + "cell": 32, + "rows": 4, + "cols": 3, + "frames": 3 + }, + { + "category": "weapon", + "key": "weapon-whip-poison", + "label": "Whip · poison", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/Whip/Whip_poisson.png", + "cell": 32, + "rows": 4, + "cols": 3, + "frames": 3 + }, + { + "category": "weapon", + "key": "weapon-whip-shock", + "label": "Whip · shock", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/Whip/Whip_shock.png", + "cell": 32, + "rows": 4, + "cols": 3, + "frames": 3 + }, + { + "category": "weapon", + "key": "weapon-whip-sickness", + "label": "Whip · sickness", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/Whip/Whip_sickness.png", + "cell": 32, + "rows": 4, + "cols": 3, + "frames": 3 + }, + { + "category": "weapon", + "key": "weapon-whip-sleep", + "label": "Whip · sleep", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/Whip/Whip_sleep.png", + "cell": 32, + "rows": 4, + "cols": 3, + "frames": 3 + }, + { + "category": "weapon", + "key": "weapon-whip-stun", + "label": "Whip · stun", + "url": "/assets/minifantasy/Minifantasy_Magic_Weapons_And_Effects_v1.0/Minifantasy_Magic_Weapons_And_Effects_Assets/Addon%20Effects%20(Minifantasy%20-%20Weapons)/Magic%20Weapons/Whip/Whip_stun.png", + "cell": 32, + "rows": 4, + "cols": 3, + "frames": 3 + }, + { + "category": "special", + "key": "special-apotheosis", + "label": "Apotheosis", + "url": "/assets/minifantasy/Minifantasy_True_Heroes_II_v1.0/Minifantasy_True_Heroes_II_Assets/Bard/Special_Animations/Apotheosis/ApotheosisEffect.png", + "cell": 32, + "rows": 4, + "cols": 13, + "frames": 13 + }, + { + "category": "special", + "key": "special-blades-dictum", + "label": "Blades Dictum", + "url": "/assets/minifantasy/Minifantasy_True_Heroes_II_v1.0/Minifantasy_True_Heroes_II_Assets/Paladin/Special_Animations/Dictums/BladesDictumEffect.png", + "cell": 32, + "rows": 4, + "cols": 15, + "frames": 15 + }, + { + "category": "special", + "key": "special-blood-shards-diagonal", + "label": "Blood Shards Diagonal", + "url": "/assets/minifantasy/Minifantasy_True_Heroes_IV_v1.1/Minifantasy_True_Heroes_IV_Assets/Blood_Mage/Special_Animations/Blood_Shard/Blood_Shards_Diagonal_Effect.png", + "cell": 32, + "rows": 4, + "cols": 10, + "frames": 10 + }, + { + "category": "special", + "key": "special-blood-shards-orthogonal", + "label": "Blood Shards Orthogonal", + "url": "/assets/minifantasy/Minifantasy_True_Heroes_IV_v1.1/Minifantasy_True_Heroes_IV_Assets/Blood_Mage/Special_Animations/Blood_Shard/Blood_Shards_Orthogonal_Effect.png", + "cell": 32, + "rows": 4, + "cols": 10, + "frames": 10 + }, + { + "category": "special", + "key": "special-blood-slam", + "label": "Blood Slam", + "url": "/assets/minifantasy/Minifantasy_True_Heroes_IV_v1.1/Minifantasy_True_Heroes_IV_Assets/Blood_Mage/Special_Animations/Blood_Slam/Blood_Slam_Effect.png", + "cell": 32, + "rows": 4, + "cols": 6, + "frames": 6 + }, + { + "category": "special", + "key": "special-blood-spikes", + "label": "Blood Spikes", + "url": "/assets/minifantasy/Minifantasy_True_Heroes_IV_v1.1/Minifantasy_True_Heroes_IV_Assets/Blood_Mage/Special_Animations/Blood_Spikes/Blood_Spikes_Effect.png", + "cell": 32, + "rows": 4, + "cols": 8, + "frames": 8 + }, + { + "category": "special", + "key": "special-cataclysm", + "label": "Cataclysm", + "url": "/assets/minifantasy/Minifantasy_True_Heroes_III_v1.1/Minifantasy_True_Heroes_III_Assets/Fighter/Special_Animations/Cataclysm/Cataclysm_Effect.png", + "cell": 32, + "rows": 4, + "cols": 12, + "frames": 12 + }, + { + "category": "special", + "key": "special-deflagration-only", + "label": "Deflagration Only", + "url": "/assets/minifantasy/Minifantasy_True_Heroes_III_v1.1/Minifantasy_True_Heroes_III_Assets/Wizard/Special_Animations/Fireball/Deflagration_Only_Effect.png", + "cell": 64, + "rows": 1, + "cols": 24, + "frames": 24 + }, + { + "category": "special", + "key": "special-dissonant-chord-impact", + "label": "Dissonant Chord (impact)", + "url": "/assets/minifantasy/Minifantasy_True_Heroes_II_v1.0/Minifantasy_True_Heroes_II_Assets/Bard/Special_Animations/Dissonant_Chord/DissonantChordImpact.png", + "cell": 32, + "rows": 1, + "cols": 4, + "frames": 4 + }, + { + "category": "special", + "key": "special-dissonant-chord-projectile", + "label": "Dissonant Chord (projectile)", + "url": "/assets/minifantasy/Minifantasy_True_Heroes_II_v1.0/Minifantasy_True_Heroes_II_Assets/Bard/Special_Animations/Dissonant_Chord/DissonantChordProjectile.png", + "cell": 32, + "rows": 3, + "cols": 3, + "frames": 3 + }, + { + "category": "special", + "key": "special-divine-fire-impact", + "label": "Divine Fire (impact)", + "url": "/assets/minifantasy/Minifantasy_True_Heroes_II_v1.0/Minifantasy_True_Heroes_II_Assets/Cleric/Special_Animations/Divine%20Fire/DivineFireImpact.png", + "cell": 32, + "rows": 1, + "cols": 4, + "frames": 4 + }, + { + "category": "special", + "key": "special-divine-fire-projectile", + "label": "Divine Fire (projectile)", + "url": "/assets/minifantasy/Minifantasy_True_Heroes_II_v1.0/Minifantasy_True_Heroes_II_Assets/Cleric/Special_Animations/Divine%20Fire/DivineFireProjectile.png", + "cell": 32, + "rows": 3, + "cols": 3, + "frames": 3 + }, + { + "category": "special", + "key": "special-dome-base-dictum", + "label": "Dome Base Dictum", + "url": "/assets/minifantasy/Minifantasy_True_Heroes_II_v1.0/Minifantasy_True_Heroes_II_Assets/Paladin/Special_Animations/Dictums/DomeBaseDictumEffect.png", + "cell": 32, + "rows": 4, + "cols": 15, + "frames": 15 + }, + { + "category": "special", + "key": "special-dome-dictum", + "label": "Dome Dictum", + "url": "/assets/minifantasy/Minifantasy_True_Heroes_II_v1.0/Minifantasy_True_Heroes_II_Assets/Paladin/Special_Animations/Dictums/DomeDictumEffect.png", + "cell": 32, + "rows": 4, + "cols": 15, + "frames": 15 + }, + { + "category": "special", + "key": "special-double-arrow-projectile", + "label": "Double Arrow (projectile)", + "url": "/assets/minifantasy/Minifantasy_True_Heroes_III_v1.1/Minifantasy_True_Heroes_III_Assets/Ranger/Special_Animations/Double_Shot/Double_Arrow_Projectile.png", + "cell": 32, + "rows": 3, + "cols": 3, + "frames": 3 + }, + { + "category": "special", + "key": "special-double-melee-attack", + "label": "Double Melee Attack", + "url": "/assets/minifantasy/Minifantasy_True_Heroes_III_v1.1/Minifantasy_True_Heroes_III_Assets/Ranger/Special_Animations/Double_Melee_Attack/Double_Melee_Attack_Effect.png", + "cell": 32, + "rows": 4, + "cols": 5, + "frames": 5 + }, + { + "category": "special", + "key": "special-drain", + "label": "Drain", + "url": "/assets/minifantasy/Minifantasy_True_Heroes_IV_v1.1/Minifantasy_True_Heroes_IV_Assets/Blood_Mage/Special_Animations/Vampirize/Drain_Effect.png", + "cell": 32, + "rows": 1, + "cols": 7, + "frames": 7 + }, + { + "category": "special", + "key": "special-explosion-full", + "label": "Explosion Full", + "url": "/assets/minifantasy/Minifantasy_True_Heroes_III_v1.1/Minifantasy_True_Heroes_III_Assets/Wizard/Special_Animations/Fireball/Explossion_Full_Effect.png", + "cell": 64, + "rows": 1, + "cols": 26, + "frames": 26 + }, + { + "category": "special", + "key": "special-explosion-only", + "label": "Explosion Only", + "url": "/assets/minifantasy/Minifantasy_True_Heroes_III_v1.1/Minifantasy_True_Heroes_III_Assets/Wizard/Special_Animations/Fireball/Explossion_Only_Effect.png", + "cell": 64, + "rows": 1, + "cols": 5, + "frames": 5 + }, + { + "category": "special", + "key": "special-fire-familiar-attack", + "label": "Fire Familiar Attack", + "url": "/assets/minifantasy/Minifantasy_True_Heroes_III_v1.1/Minifantasy_True_Heroes_III_Assets/Wizard/Special_Animations/Fire_Familiar/Fire_Familiar_Attack_Effect.png", + "cell": 32, + "rows": 4, + "cols": 8, + "frames": 8 + }, + { + "category": "special", + "key": "special-fire-torrent", + "label": "Fire Torrent", + "url": "/assets/minifantasy/Minifantasy_True_Heroes_III_v1.1/Minifantasy_True_Heroes_III_Assets/Wizard/Special_Animations/Fire_Torrent/Fire_Torrent_Effect.png", + "cell": 64, + "rows": 4, + "cols": 20, + "frames": 20 + }, + { + "category": "special", + "key": "special-fireball-projectile", + "label": "Fireball (projectile)", + "url": "/assets/minifantasy/Minifantasy_True_Heroes_III_v1.1/Minifantasy_True_Heroes_III_Assets/Wizard/Special_Animations/Fireball/Fireball_Projectile.png", + "cell": 32, + "rows": 3, + "cols": 3, + "frames": 3 + }, + { + "category": "special", + "key": "special-fth-projectile-impact", + "label": "FTH Projectile (impact)", + "url": "/assets/minifantasy/Minifantasy_True_Heroes_IV_v1.1/Minifantasy_True_Heroes_IV_Assets/Tech-Augmented_Gunslinger/Special_Animations/Fan_The_Hammer/FTH_Projectile_Impact.png", + "cell": 32, + "rows": 1, + "cols": 13, + "frames": 13 + }, + { + "category": "special", + "key": "special-healing-words-pray", + "label": "Healing Words Pray", + "url": "/assets/minifantasy/Minifantasy_True_Heroes_II_v1.0/Minifantasy_True_Heroes_II_Assets/Cleric/Special_Animations/Prayers/HealingWordsPrayEffect.png", + "cell": 32, + "rows": 4, + "cols": 22, + "frames": 22 + }, + { + "category": "special", + "key": "special-holy-hammer-impact", + "label": "Holy Hammer (impact)", + "url": "/assets/minifantasy/Minifantasy_True_Heroes_II_v1.0/Minifantasy_True_Heroes_II_Assets/Paladin/Special_Animations/Holy_Hammer/HolyHammerImpact.png", + "cell": 32, + "rows": 1, + "cols": 6, + "frames": 6 + }, + { + "category": "special", + "key": "special-shard-impact", + "label": "Shard (impact)", + "url": "/assets/minifantasy/Minifantasy_True_Heroes_IV_v1.1/Minifantasy_True_Heroes_IV_Assets/Blood_Mage/Special_Animations/Blood_Shard/Shard_Impact.png", + "cell": 32, + "rows": 1, + "cols": 10, + "frames": 10 + }, + { + "category": "special", + "key": "special-shard-projectile", + "label": "Shard (projectile)", + "url": "/assets/minifantasy/Minifantasy_True_Heroes_IV_v1.1/Minifantasy_True_Heroes_IV_Assets/Blood_Mage/Special_Animations/Blood_Shard/Shard_Projectiles.png", + "cell": 32, + "rows": 3, + "cols": 3, + "frames": 3 + }, + { + "category": "special", + "key": "special-shield-bash", + "label": "Shield Bash", + "url": "/assets/minifantasy/Minifantasy_True_Heroes_II_v1.0/Minifantasy_True_Heroes_II_Assets/Paladin/Special_Animations/Shield_Bash/ShieldBashEffect.png", + "cell": 32, + "rows": 4, + "cols": 8, + "frames": 8 + }, + { + "category": "special", + "key": "special-single-arrow-projectile", + "label": "Single Arrow (projectile)", + "url": "/assets/minifantasy/Minifantasy_True_Heroes_III_v1.1/Minifantasy_True_Heroes_III_Assets/Ranger/Special_Animations/Triple_Shot/Single_Arrow_Projectile.png", + "cell": 32, + "rows": 3, + "cols": 3, + "frames": 3 + }, + { + "category": "special", + "key": "special-single-melee-attack", + "label": "Single Melee Attack", + "url": "/assets/minifantasy/Minifantasy_True_Heroes_III_v1.1/Minifantasy_True_Heroes_III_Assets/Ranger/Special_Animations/Single_Melee_Attack/Single_Melee_Attack_Effect.png", + "cell": 32, + "rows": 4, + "cols": 5, + "frames": 5 + }, + { + "category": "special", + "key": "special-spirit-guardian-pray", + "label": "Spirit Guardian Pray", + "url": "/assets/minifantasy/Minifantasy_True_Heroes_II_v1.0/Minifantasy_True_Heroes_II_Assets/Cleric/Special_Animations/Prayers/SpiritGuardianPrayEffect.png", + "cell": 32, + "rows": 4, + "cols": 22, + "frames": 22 + }, + { + "category": "special", + "key": "special-swirl", + "label": "Swirl", + "url": "/assets/minifantasy/Minifantasy_True_Heroes_III_v1.1/Minifantasy_True_Heroes_III_Assets/Fighter/Special_Animations/Swirl/Figther_Swirl_Effect.png", + "cell": 32, + "rows": 4, + "cols": 4, + "frames": 4 + }, + { + "category": "special", + "key": "special-tempest", + "label": "Tempest", + "url": "/assets/minifantasy/Minifantasy_True_Heroes_III_v1.1/Minifantasy_True_Heroes_III_Assets/Fighter/Special_Animations/Tempest/Figther_Tempest_Effect.png", + "cell": 32, + "rows": 4, + "cols": 7, + "frames": 7 + }, + { + "category": "special", + "key": "special-thousand-blades", + "label": "Thousand Blades", + "url": "/assets/minifantasy/Minifantasy_True_Heroes_IV_v1.1/Minifantasy_True_Heroes_IV_Assets/Ninja_Assassin/Special_Animations/Thousand_Blades/Thousand_Blades_Effect.png", + "cell": 32, + "rows": 4, + "cols": 12, + "frames": 12 + }, + { + "category": "special", + "key": "special-uppercut", + "label": "Uppercut", + "url": "/assets/minifantasy/Minifantasy_True_Heroes_III_v1.1/Minifantasy_True_Heroes_III_Assets/Fighter/Special_Animations/Uppercut/Figther_Uppercut_Effect.png", + "cell": 32, + "rows": 4, + "cols": 4, + "frames": 4 + }, + { + "category": "special", + "key": "special-whip-attack", + "label": "Whip Attack", + "url": "/assets/minifantasy/Minifantasy_True_Heroes_IV_v1.1/Minifantasy_True_Heroes_IV_Assets/Tech-Augmented_Gunslinger/Special_Animations/Whip_Attack/Whip_Attack_Effect.png", + "cell": 32, + "rows": 4, + "cols": 6, + "frames": 6 + }, + { + "category": "special", + "key": "special-word-of-pain-pray", + "label": "Word Of Pain Pray", + "url": "/assets/minifantasy/Minifantasy_True_Heroes_II_v1.0/Minifantasy_True_Heroes_II_Assets/Cleric/Special_Animations/Prayers/WordOfPainPrayEffect.png", + "cell": 32, + "rows": 4, + "cols": 22, + "frames": 22 + } + ] +} diff --git a/web/assets/enemies.json b/web/assets/enemies.json new file mode 100644 index 0000000000000000000000000000000000000000..6ca3da36892fadfef519610322c322e77debb4cc --- /dev/null +++ b/web/assets/enemies.json @@ -0,0 +1,161 @@ +{ + "enemies": { + "feral-blade": { + "name": "Feral Blade", + "sprite": "dark-orc-army-feral-blade", + "attackType": "melee", + "attackAnim": "attack", + "attackEffects": [ + "auto" + ], + "projectile": "auto", + "stats": { + "hp": 70, + "armor": 40, + "basicDamage": 14, + "skillDamage": 22 + }, + "skills": [ + 385, + 384 + ], + "skillCfg": {} + }, + "orc-scout": { + "name": "Orc Scout", + "sprite": "dark-orc-army-orc-scout", + "attackType": "melee", + "attackAnim": "attack", + "attackEffects": [ + "auto" + ], + "projectile": "auto", + "stats": { + "hp": 60, + "armor": 35, + "basicDamage": 12, + "skillDamage": 20 + }, + "skills": [], + "skillCfg": {} + }, + "orc-raider": { + "name": "Orc Raider", + "sprite": "dark-orc-army-orc-raider", + "attackType": "melee", + "attackAnim": "attack", + "attackEffects": [ + "auto" + ], + "projectile": "auto", + "stats": { + "hp": 90, + "armor": 50, + "basicDamage": 18, + "skillDamage": 28 + }, + "skills": [ + 382 + ], + "skillCfg": {} + }, + "feral-berserker": { + "name": "Feral Berserker", + "sprite": "dark-orc-army-feral-berserker", + "attackType": "melee", + "attackAnim": "attack", + "attackEffects": [ + "auto" + ], + "projectile": "auto", + "stats": { + "hp": 120, + "armor": 60, + "basicDamage": 22, + "skillDamage": 36 + }, + "skills": [ + 384 + ], + "skillCfg": {} + }, + "feral-arbalist": { + "name": "Feral Arbalist", + "sprite": "dark-orc-army-feral-arbalist", + "attackType": "ranged", + "attackAnim": "attack", + "attackEffects": [], + "projectile": "auto", + "stats": { + "hp": 80, + "armor": 50, + "basicDamage": 16, + "skillDamage": 28 + }, + "skills": [ + 435 + ], + "skillCfg": {} + }, + "feral-phalanx": { + "name": "Feral Phalanx", + "sprite": "dark-orc-army-feral-phalanx", + "attackType": "melee", + "attackAnim": "attack", + "attackEffects": [ + "auto" + ], + "projectile": "auto", + "stats": { + "hp": 160, + "armor": 80, + "basicDamage": 16, + "skillDamage": 24 + }, + "skills": [ + 384 + ], + "skillCfg": {} + }, + "warbreed-berserker": { + "name": "Warbreed Berserker", + "sprite": "dark-orc-army-warbreed-berserker", + "attackType": "melee", + "attackAnim": "attack", + "attackEffects": [ + "auto" + ], + "projectile": "auto", + "stats": { + "hp": 180, + "armor": 70, + "basicDamage": 28, + "skillDamage": 44 + }, + "skills": [ + 385 + ], + "skillCfg": {} + }, + "cave-troll": { + "name": "Cave Troll", + "sprite": "dark-orc-army-cave-troll", + "attackType": "melee", + "attackAnim": "attack", + "attackEffects": [ + "auto" + ], + "projectile": "auto", + "stats": { + "hp": 400, + "armor": 90, + "basicDamage": 40, + "skillDamage": 70 + }, + "skills": [ + 385 + ], + "skillCfg": {} + } + } +} diff --git a/web/classesSandbox.js b/web/classesSandbox.js new file mode 100644 index 0000000000000000000000000000000000000000..eea820d0e8749c6c74f915f6e78bc3efef2a4328 --- /dev/null +++ b/web/classesSandbox.js @@ -0,0 +1,3207 @@ +// ../auto-battler/src/gw.js +var PROFESSION_BY_ID = [ + "Common", + "Warrior", + "Ranger", + "Monk", + "Necromancer", + "Mesmer", + "Elementalist", + "Assassin", + "Ritualist", + "Paragon", + "Dervish" +]; +var PROFESSION_COLOR = { + Common: "#6d6a5f", + Warrior: "#b8862a", + Ranger: "#4f7a32", + Monk: "#3a7ca5", + Necromancer: "#3f7a4e", + Mesmer: "#8a4aa8", + Elementalist: "#c0492b", + Assassin: "#9a2f4a", + Ritualist: "#2f8a7a", + Paragon: "#c79a2e", + Dervish: "#566a8c" +}; +var PROFESSION_PALETTES = { + Common: [{ name: "Default", swatch: "#6d6a5f", target: null }, { name: "Slate", swatch: "#4f6f9c", target: "#4f6f9c" }, { name: "Rust", swatch: "#b05a32", target: "#b05a32" }], + Warrior: [{ name: "Default", swatch: "#b8862a", target: null }, { name: "Steel", swatch: "#3f63c8", target: "#3f63c8" }, { name: "Pink", swatch: "#d96a9e", target: "#d96a9e" }], + Ranger: [{ name: "Default", swatch: "#4f7a32", target: null }, { name: "Crimson", swatch: "#bf3b3b", target: "#bf3b3b" }, { name: "Royal", swatch: "#7a3fbf", target: "#7a3fbf" }], + Monk: [{ name: "Default", swatch: "#3a7ca5", target: null }, { name: "Orange", swatch: "#e07a22", target: "#e07a22" }, { name: "Rose", swatch: "#cc4f7a", target: "#cc4f7a" }], + Necromancer: [{ name: "Default", swatch: "#3f7a4e", target: null }, { name: "Violet", swatch: "#7a3fbf", target: "#7a3fbf" }, { name: "Ash", swatch: "#9a7d82", target: "#9a7d82" }], + Mesmer: [{ name: "Default", swatch: "#8a4aa8", target: null }, { name: "Teal", swatch: "#2f9a8a", target: "#2f9a8a" }, { name: "Rose", swatch: "#cc4f8a", target: "#cc4f8a" }], + Elementalist: [{ name: "Default", swatch: "#c0492b", target: null }, { name: "Frost", swatch: "#3f8fd0", target: "#3f8fd0" }, { name: "Verdant", swatch: "#4f9a3a", target: "#4f9a3a" }], + Assassin: [{ name: "Default", swatch: "#9a2f4a", target: null }, { name: "Shadow", swatch: "#4a4f8a", target: "#4a4f8a" }, { name: "Jade", swatch: "#2f9a6a", target: "#2f9a6a" }], + Ritualist: [{ name: "Default", swatch: "#2f8a7a", target: null }, { name: "Amber", swatch: "#c9892a", target: "#c9892a" }, { name: "Violet", swatch: "#7a4abf", target: "#7a4abf" }], + Paragon: [{ name: "Default", swatch: "#c79a2e", target: null }, { name: "Azure", swatch: "#3f7fd0", target: "#3f7fd0" }, { name: "Crimson", swatch: "#cc3a3a", target: "#cc3a3a" }], + Dervish: [{ name: "Default", swatch: "#566a8c", target: null }, { name: "Moss", swatch: "#6fa353", target: "#6fa353" }, { name: "Crimson", swatch: "#bf3b3b", target: "#bf3b3b" }] +}; +var paletteTarget = (prof, i) => PROFESSION_PALETTES[prof]?.[i]?.target ?? null; +var iconUrl = (id) => `/gw/skills/${id}.jpg`; +var COST_GLYPH = { + energy: "/gw/icons/tango-energy.png", + adrenaline: "/gw/icons/tango-adrenaline.png", + activation: "/gw/icons/tango-activation.png", + recharge: "/gw/icons/tango-recharge.png", + sacrifice: "/gw/icons/tango-sacrifice.png", + overcast: "/gw/icons/tango-overcast.png", + upkeep: "/gw/icons/tango-upkeep.png" +}; + +// ../auto-battler/src/lib/recolor.js +var QUANT = 24; +var SAT_MIN = 0.2; +var L_MIN = 0.1; +var L_MAX = 0.95; +var SWATCH_N = 5; +function rgbToHsl(r, g, b) { + r /= 255; + g /= 255; + b /= 255; + const max = Math.max(r, g, b), min = Math.min(r, g, b); + const l = (max + min) / 2; + let h = 0, s = 0; + if (max !== min) { + const d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + if (max === r) h = (g - b) / d + (g < b ? 6 : 0); + else if (max === g) h = (b - r) / d + 2; + else h = (r - g) / d + 4; + h *= 60; + } + return [h, s, l]; +} +function hslToRgb(h, s, l) { + h /= 360; + if (s === 0) { + const v = Math.round(l * 255); + return [v, v, v]; + } + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + const hue = (t) => { + if (t < 0) t += 1; + if (t > 1) t -= 1; + if (t < 1 / 6) return p + (q - p) * 6 * t; + if (t < 1 / 2) return q; + if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; + return p; + }; + return [Math.round(hue(h + 1 / 3) * 255), Math.round(hue(h) * 255), Math.round(hue(h - 1 / 3) * 255)]; +} +var hexToRgb = (hex) => { + const n = parseInt(hex.replace("#", ""), 16); + return [n >> 16 & 255, n >> 8 & 255, n & 255]; +}; +var rgbToHex = (r, g, b) => "#" + [r, g, b].map((v) => v.toString(16).padStart(2, "0")).join(""); +var isMaterial = (s, l) => s >= SAT_MIN && l >= L_MIN && l <= L_MAX; +var _imgCache = /* @__PURE__ */ new Map(); +function loadImage(url) { + if (_imgCache.has(url)) return _imgCache.get(url); + const p = new Promise((res, rej) => { + const img = new Image(); + img.crossOrigin = "anonymous"; + img.onload = () => res(img); + img.onerror = rej; + img.src = url; + }); + _imgCache.set(url, p); + return p; +} +function pixels(img) { + const cv = document.createElement("canvas"); + cv.width = img.naturalWidth; + cv.height = img.naturalHeight; + const ctx = cv.getContext("2d", { willReadFrequently: true }); + ctx.drawImage(img, 0, 0); + return { cv, ctx, data: ctx.getImageData(0, 0, cv.width, cv.height) }; +} +var _analysis = /* @__PURE__ */ new Map(); +function analyze(idleUrl) { + if (_analysis.has(idleUrl)) return _analysis.get(idleUrl); + const p = (async () => { + const { data } = pixels(await loadImage(idleUrl)); + const d = data.data; + const counts = /* @__PURE__ */ new Map(); + for (let i = 0; i < d.length; i += 4) { + if (d[i + 3] < 8) continue; + const r = d[i] - d[i] % QUANT, g = d[i + 1] - d[i + 1] % QUANT, b = d[i + 2] - d[i + 2] % QUANT; + const [h, s, l] = rgbToHsl(r, g, b); + if (!isMaterial(s, l)) continue; + const k = r << 16 | g << 8 | b; + counts.set(k, (counts.get(k) || 0) + 1); + } + if (!counts.size) return { anchor: null, swatches: [] }; + const entries = [...counts.entries()].map(([k, c]) => ({ k, c, hsl: rgbToHsl(k >> 16 & 255, k >> 8 & 255, k & 255) })).sort((a, b) => b.c - a.c); + const anchor = entries[0].hsl; + const swatches = entries.slice(0, SWATCH_N).sort((a, b) => a.hsl[0] - b.hsl[0] || a.hsl[2] - b.hsl[2]).map(({ k }) => rgbToHex(k >> 16 & 255, k >> 8 & 255, k & 255)); + return { anchor, swatches }; + })(); + _analysis.set(idleUrl, p); + return p; +} +function xform([h, s, l], anchor, tH, tS) { + const nh = ((h + (tH - anchor[0])) % 360 + 360) % 360; + const ns = Math.min(1, Math.max(0, s + (tS - anchor[1]))); + return hslToRgb(nh, ns, l); +} +async function spritePalette(idleUrl) { + if (!idleUrl) return { anchor: null, swatches: [] }; + return analyze(idleUrl); +} +function recolorHex(hex, targetHex, anchor) { + if (!targetHex || !anchor) return hex; + const [tH, tS] = rgbToHsl(...hexToRgb(targetHex)); + const [r, g, b] = xform(rgbToHsl(...hexToRgb(hex)), anchor, tH, tS); + return rgbToHex(r, g, b); +} +var _canvasCache = /* @__PURE__ */ new Map(); +function recoloredCanvas(url, idleUrl, targetHex) { + const key = `${url}#${idleUrl}#${targetHex}`; + if (_canvasCache.has(key)) return _canvasCache.get(key); + const p = (async () => { + const [img, { anchor }] = await Promise.all([loadImage(url), analyze(idleUrl)]); + const { cv, ctx, data } = pixels(img); + if (anchor) { + const [tH, tS] = rgbToHsl(...hexToRgb(targetHex)); + const d = data.data; + for (let i = 0; i < d.length; i += 4) { + if (d[i + 3] < 8) continue; + const [h, s, l] = rgbToHsl(d[i], d[i + 1], d[i + 2]); + if (!isMaterial(s, l)) continue; + const [r, g, b] = xform([h, s, l], anchor, tH, tS); + d[i] = r; + d[i + 1] = g; + d[i + 2] = b; + } + ctx.putImageData(data, 0, 0); + } + return cv; + })(); + _canvasCache.set(key, p); + return p; +} +async function recoloredTexture(Texture, url, idleUrl, targetHex) { + const cv = targetHex ? await recoloredCanvas(url, idleUrl, targetHex) : await loadImage(url); + const t = Texture.from(cv); + t.source.scaleMode = "nearest"; + return t; +} + +// ../auto-battler/src/engine/skills.js +var FIRST_15 = [ + // ── Warrior: adrenaline-fuelled condition → spike (Swordsmanship line) ── + { + id: 382, + name: "Sever Artery", + profession: "Warrior", + attribute: "Swordsmanship", + category: "melee_attack", + target: "foe", + cost: { adrenaline: 4 }, + cast: 0, + recharge: 0, + requires: ["on_hit"], + effects: [{ op: "apply_condition", condition: "bleeding", duration: { scale: [5, 25] } }] + }, + { + id: 384, + name: "Gash", + profession: "Warrior", + attribute: "Swordsmanship", + category: "melee_attack", + target: "foe", + cost: { adrenaline: 6 }, + cast: 0, + recharge: 0, + // The payoff: bonus damage + Deep Wound, but only on an already-Bleeding foe. + requires: ["on_hit", { target: "bleeding" }], + effects: [ + { op: "bonus_damage", amount: { scale: [5, 20] } }, + { op: "apply_condition", condition: "deepWound", duration: { scale: [5, 20] } } + ] + }, + { + id: 385, + name: "Final Thrust", + profession: "Warrior", + attribute: "Swordsmanship", + category: "melee_attack", + target: "foe", + cost: { adrenaline: 10 }, + cast: 0, + recharge: 0, + requires: ["on_hit"], + effects: [ + { op: "lose_all_adrenaline" }, + { op: "bonus_damage", amount: { scale: [1, 40] } }, + // "doubled if below 50%" — applying the same bonus a second time, gated. + { op: "bonus_damage", amount: { scale: [1, 40] }, if: { target_below_health: 0.5 } } + ] + }, + // ── Ranger: preparations + ranged conditions/interrupt ── + { + id: 435, + name: "Apply Poison", + profession: "Ranger", + attribute: "Wilderness Survival", + category: "preparation", + target: "self", + cost: { energy: 15 }, + cast: 2, + recharge: 12, + // The differentiator: a self rider — future physical attacks inflict Poison. + effects: [{ + op: "preparation", + duration: { fixed: 24 }, + on_attack: [{ op: "apply_condition", condition: "poison", duration: { scale: [3, 15] } }] + }] + }, + { + id: 391, + name: "Hunter's Shot", + profession: "Ranger", + attribute: "Marksmanship", + category: "bow_attack", + target: "foe", + cost: { energy: 5 }, + cast: 1, + recharge: 10, + requires: ["on_hit"], + effects: [{ op: "apply_condition", condition: "bleeding", duration: { scale: [3, 25] } }] + }, + { + id: 426, + name: "Savage Shot", + profession: "Ranger", + attribute: "Marksmanship", + category: "bow_attack", + target: "foe", + cost: { energy: 10 }, + cast: 0.5, + recharge: 5, + requires: ["on_hit"], + effects: [ + { op: "interrupt" }, + // Bonus only if the interrupted action was a spell. + { op: "bonus_damage", amount: { scale: [13, 28] }, if: { target: "casting_spell" } } + ] + }, + // ── Necromancer: trigger-hexes (the event bus / physway) ── + { + id: 121, + name: "Spiteful Spirit", + profession: "Necromancer", + attribute: "Curses", + category: "hex", + target: "foe", + cost: { energy: 15 }, + cast: 2, + recharge: 10, + elite: true, + effects: [{ + op: "hex", + duration: { scale: [8, 20] }, + trigger: "on_action", + payload: [{ op: "damage", damageType: "shadow", amount: { scale: [5, 35] }, scope: "target_and_adjacent" }] + }] + }, + { + id: 101, + name: "Barbs", + profession: "Necromancer", + attribute: "Curses", + category: "hex", + target: "foe", + cost: { energy: 10 }, + cast: 2, + recharge: 5, + effects: [{ + op: "hex", + duration: { fixed: 30 }, + // Passive amplifier — no discrete trigger; the damage pipeline reads it. + payload: [{ op: "amplify_damage", amount: { scale: [1, 15] }, vs: "physical" }] + }] + }, + { + id: 150, + name: "Mark of Pain", + profession: "Necromancer", + attribute: "Curses", + category: "hex", + target: "foe", + cost: { energy: 10 }, + cast: 1, + recharge: 20, + effects: [{ + op: "hex", + duration: { fixed: 30 }, + trigger: "on_physical_hit", + payload: [{ op: "damage", damageType: "shadow", amount: { scale: [10, 40] }, scope: "adjacent_to_target" }] + }] + }, + // ── Monk: the damage-interception pipeline ── + { + id: 245, + name: "Protective Spirit", + profession: "Monk", + attribute: "Protection Prayers", + category: "enchantment", + target: "ally", + cost: { energy: 10 }, + cast: 0.25, + recharge: 5, + effects: [{ + op: "enchant", + duration: { scale: [5, 23] }, + // Cap: a single hit can't remove more than 10% of max Health. + payload: [{ op: "cap_damage", maxFraction: 0.1 }] + }] + }, + { + id: 307, + name: "Reversal of Fortune", + profession: "Monk", + attribute: "Protection Prayers", + category: "enchantment", + target: "ally", + cost: { energy: 5 }, + cast: 0.25, + recharge: 2, + effects: [{ + op: "enchant", + duration: { fixed: 8 }, + charges: 1, + trigger: "on_incoming_damage", + payload: [{ op: "convert_damage_to_heal", cap: { scale: [15, 80] } }] + }] + }, + { + id: 1114, + name: "Spirit Bond", + profession: "Monk", + attribute: "Protection Prayers", + category: "enchantment", + target: "ally", + cost: { energy: 10 }, + cast: 0.25, + recharge: 2, + effects: [{ + op: "enchant", + duration: { fixed: 8 }, + charges: 10, + trigger: "on_incoming_damage", + threshold: { perHitDamageOver: 50 }, + payload: [{ op: "heal", amount: { scale: [30, 90] }, scope: "target" }] + }] + }, + // ── Assassin: the combo chain (lead → off-hand → dual) ── + { + id: 782, + name: "Jagged Strike", + profession: "Assassin", + attribute: "Dagger Mastery", + category: "lead_attack", + target: "foe", + cost: { energy: 5 }, + cast: 0.5, + recharge: 1, + requires: ["on_hit"], + effects: [ + { op: "apply_condition", condition: "bleeding", duration: { scale: [5, 20] } }, + { op: "set_combo_mark", stage: "lead" } + ] + }, + { + id: 780, + name: "Fox Fangs", + profession: "Assassin", + attribute: "Dagger Mastery", + category: "offhand_attack", + target: "foe", + cost: { energy: 5 }, + cast: 0.5, + recharge: 3, + requires: ["on_hit", { combo_follows: "lead" }], + effects: [ + { op: "bonus_damage", amount: { scale: [10, 35] }, unblockable: true }, + { op: "set_combo_mark", stage: "offhand" } + ] + }, + { + id: 775, + name: "Death Blossom", + profession: "Assassin", + attribute: "Dagger Mastery", + category: "dual_attack", + target: "foe", + cost: { energy: 5 }, + cast: 0, + recharge: 2, + requires: ["on_hit", { combo_follows: "offhand" }], + effects: [ + { op: "bonus_damage", amount: { scale: [20, 45] } }, + { op: "damage", amount: { scale: [20, 45] }, scope: "adjacent_to_target" } + ] + } +]; +var VARIANT_EXTRA = [ + // ── Warrior · Sentinel (soak / protect) ── + { + id: 348, + name: '"Watch Yourself!"', + profession: "Warrior", + attribute: "Tactics", + category: "shout", + target: "party", + cost: { adrenaline: 4 }, + cast: 0, + recharge: 4, + // Party armor for 10s, but the buff also ends after 10 incoming attacks. + effects: [{ op: "armor_mod", amount: { scale: [5, 25] }, duration: { fixed: 10 }, attacksLeft: 10, scope: "party" }] + }, + { + id: 372, + name: "Gladiator's Defense", + profession: "Warrior", + attribute: "Tactics", + category: "stance", + target: "self", + cost: { energy: 5 }, + cast: 0, + recharge: 30, + elite: true, + // 75% block; whoever you block in melee takes 5…35 back. + effects: [{ op: "block", chance: 0.75, vs: "melee", reflect: { scale: [5, 35] }, duration: { scale: [5, 11] } }] + }, + { + id: 1, + name: "Healing Signet", + profession: "Warrior", + attribute: "Tactics", + category: "signet", + target: "self", + cost: {}, + cast: 2, + recharge: 4, + effects: [{ op: "heal", amount: { scale: [82, 172] }, scope: "self" }], + whileActivating: [{ op: "armor_mod", amount: -40, duration: { fixed: 0 }, scope: "self" }] + // −40 armor while using + }, + // ── Warrior · Breaker (knockdown control) ── + { + id: 332, + name: "Bull's Strike", + profession: "Warrior", + attribute: "Strength", + category: "melee_attack", + target: "foe", + cost: { energy: 5 }, + cast: 0, + recharge: 10, + requires: ["on_hit", { target: "moving" }], + effects: [ + { op: "bonus_damage", amount: { scale: [5, 30] } }, + { op: "knockdown", duration: { fixed: 2 } } + ] + }, + { + id: 331, + name: "Hammer Bash", + profession: "Warrior", + attribute: "Hammer Mastery", + category: "melee_attack", + target: "foe", + cost: { adrenaline: 6 }, + cast: 0, + recharge: 0, + requires: ["on_hit"], + effects: [ + { op: "knockdown", duration: { fixed: 2 } }, + { op: "lose_all_adrenaline" } + ] + }, + { + id: 352, + name: "Crushing Blow", + profession: "Warrior", + attribute: "Hammer Mastery", + category: "melee_attack", + target: "foe", + cost: { energy: 5 }, + cast: 0, + recharge: 10, + requires: ["on_hit"], + effects: [ + { op: "bonus_damage", amount: { scale: [1, 20] } }, + { op: "apply_condition", condition: "deepWound", duration: { scale: [5, 20] }, if: { target: "knocked_down" } } + ] + }, + // ── Ranger · Sharpshooter (Punishing Shot completes the interrupt bar) ── + { + id: 409, + name: "Punishing Shot", + profession: "Ranger", + attribute: "Marksmanship", + category: "bow_attack", + target: "foe", + cost: { energy: 10 }, + cast: 0.5, + recharge: 5, + elite: true, + requires: ["on_hit"], + effects: [ + { op: "bonus_damage", amount: { scale: [10, 20] } }, + { op: "interrupt" } + ] + }, + // ── Ranger · Toxicologist (stacked degen) ── + { + id: 1470, + name: "Barbed Arrows", + profession: "Ranger", + attribute: "Wilderness Survival", + category: "preparation", + target: "self", + cost: { energy: 10 }, + cast: 2, + recharge: 12, + effects: [{ + op: "preparation", + duration: { fixed: 24 }, + on_attack: [{ op: "apply_condition", condition: "bleeding", duration: { scale: [3, 15] } }] + }], + whileActivating: [{ op: "armor_mod", amount: -40, duration: { fixed: 0 }, scope: "self" }] + // −40 armor while activating + }, + { + id: 1466, + name: "Burning Arrow", + profession: "Ranger", + attribute: "Marksmanship", + category: "bow_attack", + target: "foe", + cost: { energy: 10 }, + cast: 0, + recharge: 5, + elite: true, + requires: ["on_hit"], + effects: [ + { op: "bonus_damage", amount: { scale: [10, 30] } }, + { op: "apply_condition", condition: "burning", duration: { scale: [1, 7] } } + ] + }, + // ── Ranger · Survivalist (sustain / kite) ── + { + id: 446, + name: "Troll Unguent", + profession: "Ranger", + attribute: "Wilderness Survival", + category: "skill", + target: "self", + cost: { energy: 5 }, + cast: 3, + recharge: 10, + effects: [{ op: "regen_mod", pips: { scale: [3, 10] }, duration: { fixed: 13 }, scope: "self" }] + }, + { + id: 1727, + name: "Natural Stride", + profession: "Ranger", + attribute: "Wilderness Survival", + category: "stance", + target: "self", + cost: { energy: 5 }, + cast: 0, + recharge: 12, + // Move 33% faster + 50% block; the stance ends if you become hexed/enchanted. + effects: [ + { op: "move_speed", mult: 1.33, duration: { scale: [1, 8] }, endsOnHexEnchant: true }, + { op: "block", chance: 0.5, vs: "all", duration: { scale: [1, 8] }, endsOnHexEnchant: true } + ] + }, + { + id: 393, + name: "Crippling Shot", + profession: "Ranger", + attribute: "Marksmanship", + category: "bow_attack", + target: "foe", + cost: { energy: 10 }, + cast: 0, + recharge: 2, + elite: true, + requires: ["on_hit"], + effects: [{ op: "apply_condition", condition: "crippled", duration: { scale: [1, 12] }, unblockable: true }] + }, + // ── Necromancer · Vampire (life-steal sustain) ── + { + id: 153, + name: "Vampiric Gaze", + profession: "Necromancer", + attribute: "Blood Magic", + category: "spell", + target: "foe", + cost: { energy: 10 }, + cast: 1, + recharge: 8, + effects: [{ op: "life_steal", amount: { scale: [18, 60] } }] + }, + { + id: 109, + name: "Life Siphon", + profession: "Necromancer", + attribute: "Blood Magic", + category: "hex", + target: "foe", + cost: { energy: 10 }, + cast: 1, + recharge: 5, + effects: [ + { op: "regen_mod", pips: { scale: [-1, -3] }, duration: { scale: [12, 24] }, scope: "target" }, + { op: "regen_mod", pips: { scale: [1, 3] }, duration: { scale: [12, 24] }, scope: "self" } + ] + }, + { + id: 115, + name: "Blood Renewal", + profession: "Necromancer", + attribute: "Blood Magic", + category: "enchantment", + target: "self", + cost: { energy: 1, sacrifice: 15 }, + cast: 1, + recharge: 7, + // +3…6 regen for 7s, then a burst heal of 40…190 when the enchant ends. + effects: [ + { op: "regen_mod", pips: { scale: [3, 6] }, duration: { fixed: 7 }, scope: "self" }, + { op: "enchant", duration: { fixed: 7 }, trigger: "on_end", payload: [{ op: "heal", amount: { scale: [40, 190] }, scope: "self" }] } + ] + }, + // ── Necromancer · Plaguebearer (condition spread) ── + { + id: 118, + name: "Enfeebling Blood", + profession: "Necromancer", + attribute: "Curses", + category: "spell", + target: "foe", + cost: { energy: 1, sacrifice: 10 }, + cast: 1, + recharge: 8, + effects: [{ op: "apply_condition", condition: "weakness", duration: { scale: [5, 20] }, scope: "target_and_adjacent" }] + }, + { + id: 106, + name: "Rotting Flesh", + profession: "Necromancer", + attribute: "Death Magic", + category: "spell", + target: "foe", + cost: { energy: 15 }, + cast: 3, + recharge: 3, + effects: [{ op: "apply_condition", condition: "disease", duration: { scale: [10, 25] } }] + }, + { + id: 135, + name: "Faintheartedness", + profession: "Necromancer", + attribute: "Curses", + category: "hex", + target: "foe", + cost: { energy: 10 }, + cast: 1, + recharge: 8, + effects: [ + { op: "regen_mod", pips: { scale: [0, -3] }, duration: { scale: [3, 16] }, scope: "target" }, + { op: "attack_speed", mult: 2, duration: { scale: [3, 16] }, scope: "target" } + ] + }, + // ── Monk · Healer (raw healing) ── + { + id: 281, + name: "Orison of Healing", + profession: "Monk", + attribute: "Healing Prayers", + category: "spell", + target: "ally", + cost: { energy: 5 }, + cast: 1, + recharge: 2, + effects: [{ op: "heal", amount: { scale: [20, 70] }, scope: "target" }] + }, + { + id: 283, + name: "Dwayna's Kiss", + profession: "Monk", + attribute: "Healing Prayers", + category: "spell", + target: "other_ally", + cost: { energy: 5 }, + cast: 1, + recharge: 3, + // Heal, +10…35 more for each enchantment and hex on the target ally. + effects: [{ op: "heal", amount: { scale: [15, 60] }, scope: "target", plusPerMod: { kinds: ["enchant", "hex"], amount: { scale: [10, 35] } } }] + }, + { + id: 282, + name: "Word of Healing", + profession: "Monk", + attribute: "Healing Prayers", + category: "spell", + target: "ally", + cost: { energy: 5 }, + cast: 0.75, + recharge: 3, + elite: true, + // Conditional bonus first so the "<50% Health" check reads the pre-heal HP + // (the base heal below would otherwise lift the ally over the threshold). + effects: [ + { op: "heal", amount: { scale: [30, 115] }, scope: "target", if: { target_below_health: 0.5 } }, + { op: "heal", amount: { scale: [5, 100] }, scope: "target" } + ] + }, + // ── Monk · Smiter (holy offense) ── + { + id: 312, + name: "Holy Strike", + profession: "Monk", + attribute: "Smiting Prayers", + category: "spell", + target: "foe", + cost: { energy: 5 }, + cast: 0.75, + recharge: 8, + effects: [ + { op: "damage", damageType: "holy", amount: { scale: [10, 55] }, scope: "target" }, + { op: "damage", damageType: "holy", amount: { scale: [10, 55] }, scope: "target", if: { target: "knocked_down" } } + ] + }, + { + id: 240, + name: "Smite", + profession: "Monk", + attribute: "Smiting Prayers", + category: "spell", + target: "foe", + cost: { energy: 10 }, + cast: 1, + recharge: 10, + effects: [ + { op: "damage", damageType: "holy", amount: { scale: [10, 55] }, scope: "target_and_adjacent" }, + { op: "damage", damageType: "holy", amount: { scale: [10, 35] }, scope: "target_and_adjacent", if: { target: "attacking" } } + ] + }, + { + id: 252, + name: "Banish", + profession: "Monk", + attribute: "Smiting Prayers", + category: "spell", + target: "foe", + cost: { energy: 5 }, + cast: 1, + recharge: 10, + // Double vs summoned creatures — a no-op until summons exist, but recorded. + effects: [{ op: "damage", damageType: "holy", amount: { scale: [20, 56] }, scope: "target", vsSummoned: 2 }] + }, + // ── Assassin · Nightstalker (shadow-step burst) ── + { + id: 952, + name: "Death's Charge", + profession: "Assassin", + attribute: "Shadow Arts", + category: "skill", + target: "foe", + cost: { energy: 5 }, + cast: 0.25, + recharge: 30, + // Shadow-step to the foe; heal only if that foe has more Health than you. + effects: [ + { op: "shadow_step", to: "foe" }, + { op: "heal", amount: { scale: [65, 200] }, scope: "self", if: { target_health_above_self: true } } + ] + }, + { + id: 1024, + name: "Black Mantis Thrust", + profession: "Assassin", + attribute: "Deadly Arts", + category: "lead_attack", + target: "foe", + cost: { energy: 5 }, + cast: 1, + recharge: 6, + requires: ["on_hit"], + effects: [ + { op: "bonus_damage", amount: { scale: [8, 20] } }, + { op: "apply_condition", condition: "crippled", duration: { scale: [3, 15] }, if: { target: "hexed" } }, + { op: "set_combo_mark", stage: "lead" } + ] + }, + // ── Assassin · Saboteur (control / Deadly Arts) ── + { + id: 858, + name: "Dancing Daggers", + profession: "Assassin", + attribute: "Deadly Arts", + category: "spell", + target: "foe", + cost: { energy: 5 }, + cast: 1, + recharge: 5, + // Three earth projectiles, each 5…35; counts as a lead attack. + effects: [ + { op: "damage", damageType: "earth", amount: { scale: [5, 35] }, projectiles: 3, delivery: "projectile_spell", scope: "target" }, + { op: "set_combo_mark", stage: "lead" } + ] + }, + { + id: 784, + name: "Entangling Asp", + profession: "Assassin", + attribute: "Deadly Arts", + category: "spell", + target: "foe", + cost: { energy: 10 }, + cast: 1, + recharge: 20, + requires: [{ combo_follows: "lead" }], + effects: [ + { op: "knockdown", duration: { fixed: 2 } }, + { op: "apply_condition", condition: "poison", duration: { scale: [5, 20] } } + ] + }, + { + id: 988, + name: "Temple Strike", + profession: "Assassin", + attribute: "Dagger Mastery", + category: "offhand_attack", + target: "foe", + cost: { energy: 15 }, + cast: 0, + recharge: 20, + elite: true, + requires: ["on_hit", { combo_follows: "lead" }], + effects: [ + { op: "interrupt", if: { target: "casting_spell" } }, + // interrupts a spell + { op: "apply_condition", condition: "dazed", duration: { scale: [1, 10] } }, + { op: "apply_condition", condition: "blind", duration: { scale: [1, 10] } } + ] + } +]; +var CB_SKILLS = [...FIRST_15, ...VARIANT_EXTRA]; + +// ../auto-battler/src/engine/rng.js +function makeRng(seed) { + let a = seed >>> 0; + return function rng() { + a |= 0; + a = a + 1831565813 | 0; + let t = Math.imul(a ^ a >>> 15, 1 | a); + t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; + return ((t ^ t >>> 14) >>> 0) / 4294967296; + }; +} + +// ../auto-battler/src/engine/range.js +var MELEE_GW = 144; +var BASIC_MELEE_GW = MELEE_GW / 2; +var BOW_GW = 1e3; +var SPELL_GW = 1010; + +// ../auto-battler/src/engine/teamBattle.js +var byId = Object.fromEntries(CB_SKILLS.map((s) => [s.id, s])); +var skillById = (id) => byId[id] || null; +var FIELD = { w: 1e3, h: 600 }; +var FORMATION = [ + { x: 0.31, y: 0.66 }, + { x: 0.47, y: 0.74 }, + { x: 0.13, y: 0.73 }, + { x: 0.28, y: 0.82 }, + { x: 0.44, y: 0.91 } +]; +var HIT_TOLERANCE = 130; +function val(v, rank = 12) { + if (typeof v === "number") return v; + if (v == null) return 0; + if (v.fixed != null) return v.fixed; + if (v.scale) { + const [a, b] = v.scale; + return Math.round(a + (b - a) * rank / 15); + } + return 0; +} +var DEGEN = { bleeding: 4, poison: 4, burning: 8, disease: 4 }; +var ATTACK_CATEGORIES = ["melee_attack", "bow_attack", "lead_attack", "offhand_attack", "dual_attack", "scythe_attack", "spear_attack"]; +var isAttack = (s) => ATTACK_CATEGORIES.includes(s.category); +var CLASS_TEMPLATES = { + Warrior: { maxHp: 520, role: "melee", weapon: { min: 15, max: 22, interval: 1.5, range: BASIC_MELEE_GW }, moveSpeed: 130, maxEnergy: 25, energyRegen: 0.5, armor: 80 }, + Assassin: { maxHp: 480, role: "melee", weapon: { min: 7, max: 17, interval: 1.33, range: BASIC_MELEE_GW }, moveSpeed: 175, maxEnergy: 30, energyRegen: 1.2, armor: 55 }, + Ranger: { maxHp: 430, role: "ranged", weapon: { min: 12, max: 28, interval: 1.9, range: BOW_GW, projSpeed: 850 }, moveSpeed: 155, preferredRange: 620, maxEnergy: 35, energyRegen: 1, armor: 45 }, + Monk: { maxHp: 470, role: "melee", weapon: { min: 8, max: 14, interval: 1.6, range: BASIC_MELEE_GW }, moveSpeed: 140, maxEnergy: 40, energyRegen: 1.4, armor: 60 }, + Necromancer: { maxHp: 450, role: "ranged", weapon: { min: 10, max: 20, interval: 1.8, range: BOW_GW, projSpeed: 720 }, moveSpeed: 140, preferredRange: 520, maxEnergy: 35, energyRegen: 1, armor: 45 } +}; +var DEFAULT_TEMPLATE = { maxHp: 300, role: "melee", weapon: { min: 10, max: 16, interval: 1.5, range: BASIC_MELEE_GW }, moveSpeed: 150, maxEnergy: 30, energyRegen: 1, armor: 50 }; +function templateFor(unit) { + if (unit.template) return unit.template; + if (unit.profession && CLASS_TEMPLATES[unit.profession]) return CLASS_TEMPLATES[unit.profession]; + if (unit.stats) { + const s = unit.stats; + const basic = s.basicDamage ?? 12; + const ranged = unit.attackType === "ranged"; + return { + maxHp: s.hp ?? 100, + role: ranged ? "ranged" : "melee", + armor: s.armor ?? 40, + moveSpeed: 150, + maxEnergy: 30, + energyRegen: 1, + preferredRange: ranged ? 600 : void 0, + weapon: { + min: Math.max(1, Math.round(basic * 0.8)), + max: Math.round(basic * 1.3), + interval: 1.4, + range: ranged ? BOW_GW : BASIC_MELEE_GW, + ...ranged ? { projSpeed: 800 } : {} + } + }; + } + return DEFAULT_TEMPLATE; +} +function makeActor(unit, team, id, slot) { + const tpl = templateFor(unit); + const p = FORMATION[slot % FORMATION.length]; + const pt = team === "player" ? { x: p.x, y: p.y } : { x: 1 - p.x, y: 1 - p.y }; + const bar = (unit.skills || []).map(skillById).filter(Boolean); + return { + id, + team, + name: unit.name || id, + profession: unit.profession || null, + // control: 'ai' (autonomous), 'player' (driven by b.input via setInput) or + // 'dummy' (passive target — takes damage, never acts). Sandboxes use the + // latter two so the Classes/Enemies hero fights real engine dummies. + control: unit.control || "ai", + role: tpl.role, + rank: unit.rank ?? 12, + armor: tpl.armor ?? 0, + weapon: { ...tpl.weapon }, + moveSpeed: tpl.moveSpeed, + preferredRange: tpl.preferredRange, + radius: radiusOf(unit, tpl), + maxEnergy: tpl.maxEnergy, + energyRegen: tpl.energyRegen, + baseMaxHp: tpl.maxHp, + maxHp: tpl.maxHp, + hp: tpl.maxHp, + energy: tpl.maxEnergy, + adrenaline: 0, + bar, + x: pt.x * FIELD.w, + y: pt.y * FIELD.h, + facing: team === "player" ? 1 : -1, + faceX: team === "player" ? 1 : -1, + faceY: team === "player" ? -1 : 1, + // players look up-right, enemies down-left + attackTimer: tpl.weapon.interval, + casting: null, + recharge: {}, + conds: [], + marks: {}, + prep: null, + alive: true, + mods: [], + kd: 0 + }; +} +function makeTeamBattle({ seed = 1, players = [], enemies = [], sandbox = false, respawnDummies = 0, freeCast = false } = {}) { + const actors = []; + players.filter(Boolean).slice(0, 5).forEach((u, i) => actors.push(makeActor(u, "player", `P${i}`, i))); + enemies.filter(Boolean).slice(0, 5).forEach((u, i) => actors.push(makeActor(u, "enemy", `E${i}`, i))); + return { t: 0, rng: makeRng(seed), actors, projectiles: [], log: [], over: false, winner: null, sandbox, respawnDummies, freeCast, input: {} }; +} +function setInput(b, id, cmd) { + if (!b.input) b.input = {}; + b.input[id] = { ...b.input[id] || {}, ...cmd }; +} +var ADJACENT_GW = 140; +var BODY_RADIUS = { melee: 35, ranged: 32 }; +var DEFAULT_RADIUS = 32; +var DEOVERLAP_ITERS = 3; +var DEOVERLAP_FRACTION = 0.5; +var CONTACT_SLOP = 2; +var MAX_BATTLE_T = 90; +var COLLISION_Y_WEIGHT = 3.2; +var radiusOf = (unit, tpl) => unit.template?.radius ?? unit.stats?.radius ?? tpl.radius ?? BODY_RADIUS[tpl.role] ?? DEFAULT_RADIUS; +var edgeGap = (a, t) => dist(a, t) - (a.radius || 0) - (t.radius || 0); +var MELEE_REACH = 2; +var reachOf = (a) => a.role === "ranged" ? a.weapon.range : MELEE_REACH; +var SPELL_RANGE = 900; +var ARMOR_IGNORING = /* @__PURE__ */ new Set(["shadow", "holy", "dark", "chaos"]); +var dist = (a, e) => Math.hypot(a.x - e.x, a.y - e.y); +var hasCond = (a, type) => a.conds.some((c) => c.type === type); +var isKd = (b, a) => a.kd > b.t; +var gainAdr = (a, n) => { + a.adrenaline = Math.min(25, a.adrenaline + n); +}; +var livingFoes = (b, a) => b.actors.filter((x) => x.alive && x.team !== a.team); +var alliesOf = (b, a) => b.actors.filter((x) => x.alive && x.team === a.team); +function nearestFoe(b, a) { + let best = null, bd = Infinity; + for (const x of livingFoes(b, a)) { + const d = dist(a, x); + if (d < bd) { + bd = d; + best = x; + } + } + return best; +} +function mostWoundedAlly(b, a, includeSelf = true) { + let best = null, bf = Infinity; + for (const x of alliesOf(b, a)) { + if (!includeSelf && x === a) continue; + const f = x.hp / x.maxHp; + if (f < bf) { + bf = f; + best = x; + } + } + return best; +} +var adjacentTo = (b, tgt) => b.actors.filter((x) => x.alive && x.team === tgt.team && x !== tgt && dist(x, tgt) <= ADJACENT_GW); +var addMod = (b, a, m) => a.mods.push({ until: b.t + (m.dur ?? 0), ...m }); +var activeMods = (b, a, kind) => a.mods.filter((m) => m.kind === kind && m.until > b.t); +var hasModKind = (b, a, kind) => a.mods.some((m) => m.kind === kind && m.until > b.t); +var sumRegenPips = (b, a) => activeMods(b, a, "regen").reduce((n, m) => n + m.pips, 0); +var bonusArmor = (b, a) => activeMods(b, a, "armor").reduce((n, m) => n + m.amount, 0); +var attackSpeedMult = (b, a) => activeMods(b, a, "attackSpeed").reduce((n, m) => n * m.mult, 1); +var moveSpeedMult = (b, a) => (hasCond(a, "crippled") ? 0.5 : 1) * activeMods(b, a, "moveSpeed").reduce((n, m) => n * m.mult, 1); +var hasHex = (b, a) => hasModKind(b, a, "hex"); +var countCat = (b, a, kinds) => a.mods.filter((m) => m.until > b.t && kinds.includes(m.cat)).length; +var mitigate = (dmg, armor) => dmg * (100 / (100 + (armor || 0))); +function log(b, kind, who, extra = {}) { + b.log.push({ t: Math.round(b.t * 100) / 100, kind, who: who?.id, ...extra }); +} +function applyCondition(b, tgt, type, dur, empowered) { + if (!tgt.alive) return; + const ex = tgt.conds.find((c) => c.type === type); + if (ex) { + ex.until = Math.max(ex.until, b.t + dur); + return; + } + tgt.conds.push({ type, until: b.t + dur }); + if (type === "deepWound") { + tgt.maxHp = Math.round(tgt.baseMaxHp * 0.8); + if (tgt.hp > tgt.maxHp) tgt.hp = tgt.maxHp; + } + log(b, "cond", tgt, { cond: type, amount: Math.round(dur), ...empowered ? { empowered: true } : {} }); +} +function expireConds(b, a) { + for (const c of a.conds) if (c.until <= b.t && c.type === "deepWound") a.maxHp = a.baseMaxHp; + a.conds = a.conds.filter((c) => c.until > b.t); + for (const m of a.mods) if (m.kind === "onEnd" && m.until <= b.t && !m.fired) { + m.fired = true; + const src = b.actors.find((x) => x.id === m.srcId) || a; + for (const e of m.payload || []) applyEffect(b, src, a, e, "spell"); + } + a.mods = a.mods.filter((m) => m.until > b.t); +} +function healActor(b, a, amount, empowered) { + if (!a.alive || amount <= 0) return; + a.hp = Math.min(a.maxHp, a.hp + Math.round(amount)); + log(b, "heal", a, { amount: Math.round(amount), ...empowered ? { empowered: true } : {} }); +} +function dealDamage(b, src, tgt, amount, label, opts = {}) { + if (!tgt.alive) return 0; + const { damageType = "physical", delivery = "spell", armorIgnoring = false, empowered = false } = opts; + const physical = delivery === "melee" || delivery === "projectile"; + if (physical) { + const blk = blockRoll(b, tgt, delivery); + if (blk) { + if (blk.reflect && delivery === "melee") dealDamage(b, tgt, src, blk.reflect, "reflect", { delivery: "spell", armorIgnoring: true }); + log(b, "miss", tgt, { name: label }); + return 0; + } + } + let dmg = amount; + if (physical) { + for (const m of activeMods(b, tgt, "amplify")) if (m.vs === "physical") dmg += m.amount; + } + if (!(armorIgnoring || ARMOR_IGNORING.has(damageType))) dmg = mitigate(dmg, tgt.armor + bonusArmor(b, tgt)); + const cap = activeMods(b, tgt, "cap")[0]; + if (cap) dmg = Math.min(dmg, cap.fraction * tgt.maxHp); + const conv = activeMods(b, tgt, "convert").find((m) => m.charges > 0); + if (conv) { + healActor(b, tgt, Math.min(dmg, conv.cap)); + conv.charges--; + dmg = 0; + } + for (const m of activeMods(b, tgt, "onIncomingHeal")) { + if (m.charges > 0 && dmg > m.threshold) { + healActor(b, tgt, m.amount); + m.charges--; + } + } + dmg = Math.max(0, Math.round(dmg)); + tgt.hp -= dmg; + log(b, "hit", tgt, { src: src.id, amount: dmg, name: label, ...empowered ? { empowered: true } : {} }); + gainAdr(tgt, 1); + if (physical) { + for (const m of activeMods(b, tgt, "armor")) if (m.attacksLeft != null && --m.attacksLeft <= 0) m.until = b.t; + fireTrigger(b, tgt, "onPhysicalHit"); + } + if (tgt.hp <= 0) kill(b, tgt); + return dmg; +} +function blockRoll(b, tgt, delivery) { + for (const m of activeMods(b, tgt, "block")) { + if (m.vs === "all" || m.vs === delivery) { + if (b.rng() < m.chance) return m; + } + } + return null; +} +function kill(b, a) { + if (!a.alive) return; + a.alive = false; + a.hp = 0; + a.deadAt = b.t; + log(b, "death", a); + if (hasCond(a, "disease")) for (const x of adjacentTo(b, a)) applyCondition(b, x, "disease", 10); +} +function applyContainer(b, src, tgt, e) { + const dur = val(e.duration, src.rank); + const cat = e.op; + const p = e.payload?.[0] || {}; + if (e.op === "hex") addMod(b, tgt, { kind: "hex", cat, dur }); + for (const m of tgt.mods) if (m.endsOnHexEnchant && m.until > b.t) m.until = b.t; + if (p.op === "amplify_damage") { + addMod(b, tgt, { kind: "amplify", cat, vs: p.vs || "physical", amount: val(p.amount, src.rank), dur }); + return; + } + if (p.op === "cap_damage") { + addMod(b, tgt, { kind: "cap", cat, fraction: p.maxFraction, dur }); + return; + } + if (p.op === "convert_damage_to_heal") { + addMod(b, tgt, { kind: "convert", cat, cap: val(p.cap, src.rank), charges: e.charges ?? 1, dur }); + return; + } + if (e.trigger === "on_end") { + addMod(b, tgt, { kind: "onEnd", cat, payload: e.payload, srcId: src.id, srcRank: src.rank, dur }); + return; + } + if (e.trigger === "on_incoming_damage" && p.op === "heal") { + addMod(b, tgt, { kind: "onIncomingHeal", cat, amount: val(p.amount, src.rank), threshold: e.threshold?.perHitDamageOver ?? 0, charges: e.charges ?? 1, dur }); + return; + } + if (e.trigger === "on_action") { + addMod(b, tgt, { kind: "onAction", cat, payload: e.payload, srcId: src.id, srcRank: src.rank, dur }); + return; + } + if (e.trigger === "on_physical_hit") { + addMod(b, tgt, { kind: "onPhysicalHit", cat, payload: e.payload, srcId: src.id, srcRank: src.rank, dur }); + return; + } + addMod(b, tgt, { kind: "enchant", cat, dur }); +} +function fireTrigger(b, a, kind) { + for (const m of activeMods(b, a, kind)) { + const fakeSrc = b.actors.find((x) => x.id === m.srcId) || { id: m.srcId, rank: m.srcRank }; + for (const e of m.payload || []) applyEffect(b, fakeSrc, a, e, "spell"); + } +} +function resolveScope(b, src, tgt, scope) { + switch (scope) { + case "self": + return [src]; + case "party": + return alliesOf(b, src); + case "target_and_adjacent": + return [tgt, ...adjacentTo(b, tgt)]; + case "adjacent_to_target": + return adjacentTo(b, tgt); + case "nearby": + case "area": + return [tgt, ...adjacentTo(b, tgt)]; + default: + return [tgt]; + } +} +function applyEffect(b, src, tgt, e, delivery = "spell", s = null) { + if (e.if && !branchOk(b, e.if, src, tgt)) return; + if (e.if && s) logEmpower(b, src, tgt, s, empowerLabel(e, src.rank), reasonOf(e.if)); + const emp = !!e.if; + const targets = resolveScope(b, src, tgt, e.scope); + for (const t of targets) { + if (!t || !t.alive) continue; + const dur = e.duration != null ? val(e.duration, src.rank) : 0; + switch (e.op) { + case "damage": { + const amt = val(e.amount, src.rank); + const n = e.projectiles || 0; + if (n > 1 || e.delivery === "projectile_spell") fireSpellProjectiles(b, src, t, amt, e, n || 1); + else dealDamage(b, src, t, amt, src.name || "spell", { damageType: e.damageType, delivery, empowered: emp }); + break; + } + case "life_steal": { + const dealt = dealDamage(b, src, t, val(e.amount, src.rank), src.name || "steal", { delivery, armorIgnoring: true }); + healActor(b, src, dealt); + break; + } + case "heal": { + let amt = val(e.amount, src.rank); + let scaled = 0; + if (e.plusPerMod) { + scaled = countCat(b, t, e.plusPerMod.kinds) * val(e.plusPerMod.amount, src.rank); + amt += scaled; + } + if (s && scaled > 0) logEmpower(b, src, t, s, `+${scaled} heal`, `${countCat(b, t, e.plusPerMod.kinds)} effects`); + healActor(b, t, amt, emp || scaled > 0); + break; + } + case "apply_condition": + applyCondition(b, t, e.condition, dur, emp); + break; + case "knockdown": + t.kd = Math.max(t.kd, b.t + dur); + t.casting = null; + break; + case "interrupt": + if (t.casting) { + t.casting = null; + log(b, "cond", t, { cond: "interrupted", amount: 0, ...emp ? { empowered: true } : {} }); + } + break; + case "regen_mod": + addMod(b, t, { kind: "regen", pips: val(e.pips, src.rank), dur }); + break; + case "attack_speed": + addMod(b, t, { kind: "attackSpeed", mult: e.mult, dur }); + break; + case "armor_mod": + addMod(b, t, { kind: "armor", amount: val(e.amount, src.rank), attacksLeft: e.attacksLeft, dur }); + break; + case "move_speed": + addMod(b, src, { kind: "moveSpeed", mult: e.mult, endsOnHexEnchant: e.endsOnHexEnchant, dur }); + break; + case "block": + addMod(b, src, { kind: "block", chance: e.chance, vs: e.vs || "all", reflect: e.reflect ? val(e.reflect, src.rank) : 0, endsOnHexEnchant: e.endsOnHexEnchant, dur }); + break; + case "shadow_step": + shadowStep(b, src, t); + break; + case "set_combo_mark": + src.marks[t.id] = { stage: e.stage, until: b.t + 20 }; + break; + case "lose_all_adrenaline": + src.adrenaline = 0; + break; + case "preparation": + src.prep = { on_attack: e.on_attack, until: b.t + dur }; + break; + case "hex": + case "enchant": + applyContainer(b, src, t, e); + break; + default: + break; + } + } +} +function shadowStep(b, a, tgt) { + const dx = a.x - tgt.x, dy = a.y - tgt.y, len = Math.hypot(dx, dy) || 1; + a.x = Math.max(0, Math.min(FIELD.w, tgt.x + dx / len * (BASIC_MELEE_GW * 0.8))); + a.y = Math.max(0, Math.min(FIELD.h, tgt.y + dy / len * (BASIC_MELEE_GW * 0.8))); +} +function fireSpellProjectiles(b, src, tgt, amt, e, n) { + const base = dist(src, tgt) / 900; + for (let i = 0; i < n; i++) { + b.projectiles.push({ + srcId: src.id, + tgtId: tgt.id, + aimX: tgt.x, + aimY: tgt.y, + bornT: b.t, + hitT: b.t + base + i * 0.1, + spell: true, + amount: amt, + damageType: e.damageType, + label: src.name || "spell" + }); + } + log(b, "shoot", src, { name: src.name }); +} +function branchOk(b, req2, a, tgt) { + if (req2.target_below_health != null) return tgt.hp / tgt.maxHp < req2.target_below_health; + if (req2.target_health_above_self) return tgt.hp > a.hp; + if (req2.target === "bleeding") return hasCond(tgt, "bleeding"); + if (req2.target === "casting_spell") return !!tgt.casting; + if (req2.target === "moving") return !!tgt.moving; + if (req2.target === "knocked_down") return isKd(b, tgt); + if (req2.target === "hexed") return hasHex(b, tgt); + if (req2.target === "attacking") return tgt.attackedAt != null && b.t - tgt.attackedAt < 1.2; + if (req2.self === "enchanted") return hasModKind(b, a, "cap") || hasModKind(b, a, "convert"); + return true; +} +function reasonOf(req2) { + if (!req2) return ""; + if (req2.target_below_health != null) return `foe <${req2.target_below_health * 100}%`; + if (req2.target_health_above_self) return "foe has more HP"; + if (req2.target === "bleeding") return "foe Bleeding"; + if (req2.target === "casting_spell") return "foe casting"; + if (req2.target === "moving") return "foe moving"; + if (req2.target === "knocked_down") return "knocked down"; + if (req2.target === "hexed") return "foe hexed"; + if (req2.target === "attacking") return "foe attacking"; + return ""; +} +function empowerLabel(e, rank) { + switch (e.op) { + case "bonus_damage": + case "damage": + return `+${val(e.amount, rank)} dmg`; + case "apply_condition": + return `+${e.condition}`; + case "heal": + return `+${val(e.amount, rank)} heal`; + case "interrupt": + return "INTERRUPT"; + default: + return "bonus"; + } +} +function logEmpower(b, src, tgt, s, label, reason) { + log(b, "empower", src, { tgt: tgt?.id, skillId: s?.id, name: s?.name, label, reason }); +} +function strike(b, a, enemy, s) { + a.attackTimer = a.weapon.interval * attackSpeedMult(b, a); + a.attackedAt = b.t; + if (hasCond(a, "blind") && b.rng() < 0.9) { + if (a.role !== "ranged") log(b, "swing", a, { name: s ? s.name : "attack" }); + log(b, "miss", enemy, { name: s ? s.name : "attack" }); + return; + } + let weaponDmg = a.weapon.min + b.rng() * (a.weapon.max - a.weapon.min); + if (hasCond(a, "weakness")) weaponDmg *= 0.75; + let bonus = 0, empEffect = null; + if (s) { + for (const e of s.effects) if (e.op === "bonus_damage" && (!e.if || branchOk(b, e.if, a, enemy))) { + bonus += val(e.amount, a.rank); + if (e.if) empEffect = e; + } + } + if (a.role === "ranged") { + const flight = dist(a, enemy) / (a.weapon.projSpeed || 800); + b.projectiles.push({ srcId: a.id, tgtId: enemy.id, fromX: a.x, fromY: a.y, aimX: enemy.x, aimY: enemy.y, bornT: b.t, hitT: b.t + flight, weaponDmg, bonus, s, empEffect }); + log(b, "shoot", a, { name: s ? s.name : "shot", skillId: s?.id }); + } else { + log(b, "swing", a, { name: s ? s.name : "attack" }); + applyHit(b, a, enemy, s, weaponDmg, bonus, empEffect); + } +} +function applyHit(b, a, enemy, s, weaponDmg, bonus, empEffect = null) { + if (!enemy.alive) return; + const delivery = a.role === "ranged" ? "projectile" : "melee"; + if (empEffect) logEmpower(b, a, enemy, s, empowerLabel(empEffect, a.rank), reasonOf(empEffect.if)); + dealDamage(b, a, enemy, weaponDmg + bonus, s ? s.name : "attack", { delivery, empowered: !!empEffect }); + if (s) { + for (const e of s.effects) { + if (e.op === "bonus_damage") continue; + if (e.if && !branchOk(b, e.if, a, enemy)) continue; + const emp = !!e.if; + if (emp && e.op !== "damage") logEmpower(b, a, enemy, s, empowerLabel(e, a.rank), reasonOf(e.if)); + switch (e.op) { + case "apply_condition": + for (const t of resolveScope(b, a, enemy, e.scope)) applyCondition(b, t, e.condition, val(e.duration, a.rank), emp); + break; + case "set_combo_mark": + a.marks[enemy.id] = { stage: e.stage, until: b.t + 20 }; + break; + case "lose_all_adrenaline": + a.adrenaline = 0; + break; + case "knockdown": + enemy.kd = Math.max(enemy.kd, b.t + val(e.duration, a.rank)); + enemy.casting = null; + break; + case "interrupt": + if (enemy.casting) { + enemy.casting = null; + log(b, "cond", enemy, { cond: "interrupted", amount: 0, ...emp ? { empowered: true } : {} }); + } + break; + case "damage": + applyEffect(b, a, enemy, e, "melee", s); + break; + default: + break; + } + } + if (s.category === "dual_attack") delete a.marks[enemy.id]; + } + if (a.prep && a.prep.until > b.t) for (const e of a.prep.on_attack) { + if (e.op === "apply_condition") applyCondition(b, enemy, e.condition, val(e.duration, a.rank)); + } + gainAdr(a, 1); +} +function advanceProjectiles(b) { + const live = []; + for (const p of b.projectiles) { + if (b.t < p.hitT) { + live.push(p); + continue; + } + const src = b.actors.find((x) => x.id === p.srcId); + const tgt = b.actors.find((x) => x.id === p.tgtId); + if (!src || !tgt || !tgt.alive) continue; + if (Math.hypot(tgt.x - p.aimX, tgt.y - p.aimY) > HIT_TOLERANCE) { + log(b, "miss", tgt, { name: p.label || p.s?.name || "shot" }); + continue; + } + if (p.spell) dealDamage(b, src, tgt, p.amount, p.label, { damageType: p.damageType, delivery: "spell" }); + else applyHit(b, src, tgt, p.s, p.weaponDmg, p.bonus, p.empEffect); + } + b.projectiles = live; +} +var fireOnAction = (b, a) => fireTrigger(b, a, "onAction"); +function applyActivationPenalty(b, a, s, cast) { + for (const e of s.whileActivating || []) { + if (e.op === "armor_mod") addMod(b, a, { kind: "armor", amount: val(e.amount, a.rank), dur: cast }); + } +} +var COMBO_STAGE = { lead_attack: "lead", offhand_attack: "offhand", dual_attack: "dual" }; +function performSkill(b, a, tgt, s) { + if (s.cost?.energy) a.energy -= s.cost.energy; + if (s.cost?.adrenaline) a.adrenaline -= s.cost.adrenaline; + if (s.cost?.sacrifice) a.hp = Math.max(1, a.hp - Math.round(a.maxHp * s.cost.sacrifice / 100)); + log(b, "cast", a, { name: s.name, elite: !!s.elite, skillId: s.id, tgt: tgt.id, ...COMBO_STAGE[s.category] ? { combo: COMBO_STAGE[s.category] } : {} }); + a.recharge[s.name] = b.t + (s.recharge || 0); + fireOnAction(b, a); + if (isAttack(s)) { + strike(b, a, tgt, s); + return; + } + for (const e of s.effects) applyEffect(b, a, tgt, e, "spell", s); +} +var isSupport = (s) => !!s && ["self", "ally", "other_ally", "party"].includes(s.target); +var enchantModKind = (e) => { + const p = e.payload?.[0] || {}; + if (p.op === "cap_damage") return "cap"; + if (p.op === "convert_damage_to_heal") return "convert"; + if (e.trigger === "on_incoming_damage") return "onIncomingHeal"; + return null; +}; +function skillTarget(b, a, s, foe) { + if (s.target === "self" || s.target === "party") return a; + if (s.target === "ally") return mostWoundedAlly(b, a, true); + if (s.target === "other_ally") return mostWoundedAlly(b, a, false); + return foe; +} +function usable(b, a, s, tgt, foe, free = false) { + if (!tgt) return false; + if (free) return true; + if (b.t < (a.recharge[s.name] || 0)) return false; + if (s.cost?.energy && a.energy < s.cost.energy) return false; + if (s.cost?.adrenaline && a.adrenaline < s.cost.adrenaline) return false; + if (isAttack(s) && edgeGap(a, foe) > reachOf(a)) return false; + if (!isAttack(s) && !isSupport(s) && dist(a, foe) > SPELL_RANGE) return false; + for (const r of s.requires || []) { + if (r === "on_hit") continue; + if (r.combo_follows && a.marks[foe.id]?.stage !== r.combo_follows) return false; + if (r.target === "bleeding" && !hasCond(foe, "bleeding")) return false; + if (r.target === "casting_spell" && !foe.casting) return false; + if (r.target === "moving" && !foe.moving) return false; + if (r.target === "knocked_down" && !isKd(b, foe)) return false; + } + if (s.effects.some((e) => e.op === "preparation") && a.prep && a.prep.until > b.t) return false; + if (isSupport(s)) { + if (s.effects.some((e) => e.op === "heal") && tgt.hp / tgt.maxHp >= 0.7 && s.effects.every((e) => e.op === "heal" || e.op === "shadow_step")) return false; + for (const e of s.effects) { + if (e.op === "enchant") { + const k = enchantModKind(e); + if (k && hasModKind(b, tgt, k)) return false; + } + const selfBuffKind = { block: "block", armor_mod: "armor", regen_mod: "regen", attack_speed: "attackSpeed", move_speed: "moveSpeed" }[e.op]; + if (selfBuffKind && hasModKind(b, a, selfBuffKind)) return false; + } + } else { + const meaningful = s.effects.filter((e) => e.op !== "set_combo_mark" && e.op !== "preparation"); + if (meaningful.length && meaningful.every((e) => e.op === "apply_condition" && hasCond(foe, e.condition))) return false; + } + return true; +} +function chooseAction(b, a, foe) { + for (const s of a.bar) { + const tgt = skillTarget(b, a, s, foe); + if (usable(b, a, s, tgt, foe)) return { skill: s, target: tgt }; + } + return null; +} +function moveActor(b, a, enemy, dt) { + const d = dist(a, enemy); + let toward = 0; + if (a.role === "ranged") { + if (d < (a.preferredRange ?? a.weapon.range * 0.7)) toward = -1; + else if (d > a.weapon.range) toward = 1; + } else if (edgeGap(a, enemy) > reachOf(a)) { + toward = 1; + } + if (!toward) { + a.vx = 0; + a.vy = 0; + return; + } + const speed = a.moveSpeed * moveSpeedMult(b, a); + const dx = enemy.x - a.x, dy = enemy.y - a.y, len = Math.hypot(dx, dy) || 1; + const desVx = dx / len * speed * toward, desVy = dy / len * speed * toward; + const [vx, vy] = avoidVelocity(b, a, enemy, desVx, desVy, speed); + a.x = clampField(a.x + vx * dt, a.radius, FIELD.w); + a.y = clampField(a.y + vy * dt, a.radius, FIELD.h); + a.vx = vx; + a.vy = vy; + a.moving = true; +} +var RVO_TAU = 1.6; +var RVO_RANGE = 280; +var RVO_W = 240; +var RVO_ANGLES = [0, 0.3, -0.3, 0.62, -0.62, 0.98, -0.98, 1.4, -1.4]; +var RVO_SPEEDS = [1, 0.6]; +function avoidVelocity(b, a, enemy, desVx, desVy, speed) { + const KY = COLLISION_Y_WEIGHT; + const obs = []; + for (const o of b.actors) { + if (o === a || !o.alive || o === enemy) continue; + const rpx = o.x - a.x, rpy = (o.y - a.y) * KY; + if (rpx * rpx + rpy * rpy > RVO_RANGE * RVO_RANGE) continue; + obs.push({ rpx, rpy, ovx: o.vx || 0, ovy: (o.vy || 0) * KY, R: a.radius + o.radius }); + } + if (!obs.length) return [desVx, desVy]; + const baseAng = Math.atan2(desVy, desVx); + let best = [desVx, desVy], bestPen = Infinity; + for (const da of RVO_ANGLES) { + const ang = baseAng + da, cs = Math.cos(ang), sn = Math.sin(ang); + for (const sf of RVO_SPEEDS) { + const cvx = cs * speed * sf, cvy = sn * speed * sf; + const cvxw = cvx, cvyw = cvy * KY; + let minTtc = Infinity; + for (const o of obs) { + const t = timeToHit(o.rpx, o.rpy, o.ovx - cvxw, o.ovy - cvyw, o.R); + if (t < minTtc) minTtc = t; + } + const collPen = minTtc <= RVO_TAU ? RVO_W / Math.max(minTtc, 0.05) : 0; + const dev = Math.hypot(cvx - desVx, cvy - desVy); + const pen = collPen + dev; + if (pen < bestPen) { + bestPen = pen; + best = [cvx, cvy]; + } + } + } + return best; +} +function timeToHit(px, py, rvx, rvy, R) { + const c = px * px + py * py - R * R; + if (c <= 0) return 0; + const a2 = rvx * rvx + rvy * rvy; + if (a2 < 1e-6) return Infinity; + const b2 = px * rvx + py * rvy; + if (b2 >= 0) return Infinity; + const disc = b2 * b2 - a2 * c; + if (disc <= 0) return Infinity; + return (-b2 - Math.sqrt(disc)) / a2; +} +var clampField = (v, r, max) => Math.max(r, Math.min(max - r, v)); +function resolveOverlaps(b) { + const live = b.actors.filter((a) => a.alive); + for (let it = 0; it < DEOVERLAP_ITERS; it++) { + for (let i = 0; i < live.length; i++) { + for (let j = i + 1; j < live.length; j++) { + const a = live[i], o = live[j]; + const dx = o.x - a.x, dy = (o.y - a.y) * COLLISION_Y_WEIGHT; + const d = Math.hypot(dx, dy) || 0.01; + const overlap = a.radius + o.radius - d; + if (overlap <= CONTACT_SLOP) continue; + const ux = dx / d, uy = dy / d; + const aFix = isImmovable(b, a), oFix = isImmovable(b, o); + const push = overlap * DEOVERLAP_FRACTION; + const aShare = aFix ? 0 : oFix ? 1 : 0.5; + const oShare = oFix ? 0 : aFix ? 1 : 0.5; + const yPush = uy / COLLISION_Y_WEIGHT; + a.x = clampField(a.x - ux * push * aShare, a.radius, FIELD.w); + a.y = clampField(a.y - yPush * push * aShare, a.radius, FIELD.h); + o.x = clampField(o.x + ux * push * oShare, o.radius, FIELD.w); + o.y = clampField(o.y + yPush * push * oShare, o.radius, FIELD.h); + } + } + } +} +var isImmovable = (b, a) => !!a.casting || isKd(b, a); +function stepPlayer(b, a, foe, dt) { + const cmd = b.input && b.input[a.id] || {}; + const mx = cmd.moveX || 0, my = cmd.moveY || 0; + if (mx || my) { + const len = Math.hypot(mx, my) || 1; + const speed = a.moveSpeed * moveSpeedMult(b, a); + a.x = clampField(a.x + mx / len * speed * dt, a.radius, FIELD.w); + a.y = clampField(a.y + my / len * speed * dt, a.radius, FIELD.h); + a.moving = true; + a.faceX = mx < 0 ? -1 : mx > 0 ? 1 : a.faceX; + a.faceY = my < 0 ? -1 : my > 0 ? 1 : a.faceY; + a.facing = a.faceX; + } + if (a.casting) { + a.casting.left -= dt; + if (a.casting.left <= 0) { + const { skill, target } = a.casting; + a.casting = null; + performSkill(b, a, target?.alive ? target : foe, skill); + } + return; + } + const action = cmd.action; + if (!action) return; + const free = !!b.freeCast; + if (free) { + a.energy = a.maxEnergy; + a.adrenaline = 25; + } + const clear = () => { + if (b.input) b.input[a.id] = { ...cmd, action: null }; + }; + if (action === "basic") { + if (!free && a.role !== "ranged" && edgeGap(a, foe) > reachOf(a)) { + clear(); + return; + } + if (!free && a.attackTimer > 0) return; + fireOnAction(b, a); + strike(b, a, foe, null); + clear(); + return; + } + const s = a.bar.find((x) => x.id === action); + if (!s) { + clear(); + return; + } + const tgt = skillTarget(b, a, s, foe); + if (!usable(b, a, s, tgt, foe, free)) return; + const cast = (s.cast || 0) * (hasCond(a, "dazed") ? 2 : 1); + if (cast <= 0) performSkill(b, a, tgt, s); + else { + applyActivationPenalty(b, a, s, cast); + a.casting = { skill: s, target: tgt, left: cast }; + } + clear(); +} +function reviveDummy(b, a) { + a.alive = true; + a.hp = a.maxHp = a.baseMaxHp; + a.energy = a.maxEnergy; + a.adrenaline = 0; + a.conds = []; + a.marks = {}; + a.mods = []; + a.casting = null; + a.kd = 0; + a.deadAt = null; +} +function step(b, dt) { + if (b.over) return; + b.t += dt; + if (b.sandbox && b.respawnDummies) { + for (const a of b.actors) if (!a.alive && a.control === "dummy" && a.deadAt != null && b.t - a.deadAt >= b.respawnDummies) reviveDummy(b, a); + } + for (const a of b.actors) { + if (!a.alive) continue; + a.energy = Math.min(a.maxEnergy, a.energy + a.energyRegen * dt); + let degen = 0; + for (const c of a.conds) if (c.until > b.t) degen += DEGEN[c.type] || 0; + const rate = degen - sumRegenPips(b, a) * 2; + if (rate) { + a.hp = Math.min(a.maxHp, a.hp - rate * dt); + if (a.hp <= 0) kill(b, a); + } + expireConds(b, a); + a.attackTimer -= dt; + for (const id of Object.keys(a.marks)) if (a.marks[id]?.until <= b.t) delete a.marks[id]; + } + advanceProjectiles(b); + for (const a of b.actors) { + if (!a.alive || b.over) continue; + const enemy = nearestFoe(b, a); + if (!enemy) continue; + a.facing = enemy.x < a.x ? -1 : 1; + a.faceX = a.facing; + a.faceY = enemy.y < a.y ? -1 : 1; + a.moving = false; + if (isKd(b, a)) { + a.casting = null; + continue; + } + if (a.control === "dummy") continue; + if (a.control === "player") { + stepPlayer(b, a, enemy, dt); + continue; + } + if (a.casting) { + a.casting.left -= dt; + if (a.casting.left <= 0) { + const { skill, target } = a.casting; + a.casting = null; + performSkill(b, a, target?.alive ? target : enemy, skill); + } + continue; + } + const action = chooseAction(b, a, enemy); + if (action) { + const cast = (action.skill.cast || 0) * (hasCond(a, "dazed") ? 2 : 1); + if (cast <= 0) performSkill(b, a, action.target, action.skill); + else { + applyActivationPenalty(b, a, action.skill, cast); + a.casting = { skill: action.skill, target: action.target, left: cast }; + } + } else if (edgeGap(a, enemy) <= reachOf(a) && a.attackTimer <= 0) { + fireOnAction(b, a); + strike(b, a, enemy, null); + } else { + moveActor(b, a, enemy, dt); + } + } + resolveOverlaps(b); + if (b.sandbox) return; + const playerAlive = b.actors.some((a) => a.alive && a.team === "player"); + const enemyAlive = b.actors.some((a) => a.alive && a.team === "enemy"); + if (!playerAlive || !enemyAlive) { + b.over = true; + b.winner = playerAlive ? "player" : enemyAlive ? "enemy" : null; + } else if (b.t >= MAX_BATTLE_T) { + const hp = (team) => b.actors.filter((a) => a.alive && a.team === team).reduce((n, a) => n + a.hp, 0); + const ph = hp("player"), eh = hp("enemy"); + b.over = true; + b.winner = ph === eh ? null : ph > eh ? "player" : "enemy"; + } +} + +// ../auto-battler/src/engine/describe.js +function val2(v) { + if (typeof v === "number") return String(v); + if (v && v.fixed !== void 0) return String(v.fixed); + if (v && v.scale) return `${v.scale[0]}\u2026${v.scale[1]}`; + return "?"; +} +function req(r) { + if (r === "on_hit") return "on hit"; + if (r.combo_follows) return `follows a ${r.combo_follows} attack`; + if (r.target) return `target is ${r.target}`; + if (r.target_below_health !== void 0) return `target < ${r.target_below_health * 100}% HP`; + if (r.target_health_above_self) return "target has more HP than you"; + if (r.self) return `you are ${r.self}`; + return JSON.stringify(r); +} +function effect(e) { + const ifc = e.if ? ` (if ${req(e.if)})` : ""; + const sc = e.scope && e.scope !== "target" ? ` [${e.scope.replace(/_/g, " ")}]` : ""; + let s; + switch (e.op) { + case "bonus_damage": + s = `+${val2(e.amount)} damage${e.unblockable ? " (unblockable)" : ""}`; + break; + case "damage": + s = `${val2(e.amount)}${e.projectiles ? `\xD7${e.projectiles}` : ""} ${e.damageType || ""} damage`.trim(); + break; + case "amplify_damage": + s = `+${val2(e.amount)} damage taken from ${e.vs}`; + break; + case "apply_condition": + s = `${e.condition} ${val2(e.duration)}s`; + break; + case "heal": + s = `heal ${val2(e.amount)}${e.plusPerMod ? ` (+${val2(e.plusPerMod.amount)} per ${e.plusPerMod.kinds.join("/")})` : ""}`; + break; + case "life_steal": + s = `steal ${val2(e.amount)} Health`; + break; + case "knockdown": + s = `knock down ${val2(e.duration)}s`; + break; + case "block": + s = `block ${Math.round(e.chance * 100)}% vs ${e.vs || "all"} for ${val2(e.duration)}s${e.reflect ? ` (reflect ${val2(e.reflect)})` : ""}`; + break; + case "armor_mod": + s = `+${val2(e.amount)} armor for ${val2(e.duration)}s${e.attacksLeft ? ` or ${e.attacksLeft} hits` : ""}`; + break; + case "regen_mod": + s = `${val2(e.pips)} health regen for ${val2(e.duration)}s`; + break; + case "attack_speed": + s = `attack ${e.mult}\xD7 speed for ${val2(e.duration)}s`; + break; + case "move_speed": + s = `move ${e.mult}\xD7 speed for ${val2(e.duration)}s`; + break; + case "shadow_step": + s = `shadow step to ${e.to || "foe"}`; + break; + case "convert_damage_to_heal": + s = `convert next damage \u2192 heal (max ${val2(e.cap)})`; + break; + case "cap_damage": + s = `cap each hit at ${e.maxFraction * 100}% max HP`; + break; + case "interrupt": + s = "interrupt"; + break; + case "set_combo_mark": + s = `mark: ${e.stage}`; + break; + case "lose_all_adrenaline": + s = "lose all adrenaline"; + break; + case "hex": + case "enchant": { + const trig = e.trigger ? ` ${e.trigger.replace(/_/g, " ")}` : " (passive)"; + const ch = e.charges ? `, ${e.charges}\xD7` : ""; + const th = e.threshold ? `, when hit >${e.threshold.perHitDamageOver}` : ""; + s = `${e.op} ${val2(e.duration)}s${ch}${th}${trig}: [${e.payload.map(effect).join("; ")}]`; + break; + } + case "preparation": + s = `prep ${val2(e.duration)}s: each attack \u2192 [${e.on_attack.map(effect).join("; ")}]`; + break; + default: + s = e.op; + } + return s + sc + ifc; +} + +// ../auto-battler/src/lib/skillVisuals.js +var CONDITION_COLOR = { + bleeding: "#e0584a", + deepWound: "#b3402f", + poison: "#6fae3f", + disease: "#9aa83a", + burning: "#e0822a", + dazed: "#e0c64f", + blind: "#9aa0a8", + crippled: "#5bb6c0", + weakness: "#b08a5a", + crackedArmor: "#c9a36a" +}; +var KIND = { + damage: { color: "#e0584a", label: "Damage" }, + condition: { color: "#e0584a", label: "Condition" }, + // overridden per-condition + heal: { color: "#6fbf73", label: "Heal" }, + defense: { color: "#5b9fd6", label: "Defense" }, + haste: { color: "#4fc0c0", label: "Speed" }, + vuln: { color: "#e0905a", label: "Weaken" }, + control: { color: "#b06fd8", label: "Control" }, + hex: { color: "#a05fd0", label: "Hex" }, + enchant: { color: "#e0c64f", label: "Enchant" }, + prep: { color: "#4fb0a0", label: "Prep" }, + utility: { color: "#9aa0a8", label: "Effect" } +}; +var OP_KIND = { + damage: "damage", + bonus_damage: "damage", + life_steal: "damage", + apply_condition: "condition", + heal: "heal", + regen_mod: "heal", + convert_damage_to_heal: "heal", + armor_mod: "defense", + block: "defense", + cap_damage: "defense", + attack_speed: "haste", + move_speed: "haste", + amplify_damage: "vuln", + knockdown: "control", + interrupt: "control", + hex: "hex", + enchant: "enchant", + preparation: "prep" +}; +var effectKind = (e) => OP_KIND[e?.op] ?? "utility"; +function effectStyle(e) { + const kind = effectKind(e); + if (kind === "condition") { + const color = CONDITION_COLOR[e.condition] ?? KIND.condition.color; + const label = e.condition ? e.condition.replace(/([A-Z])/g, " $1").replace(/^./, (c) => c.toUpperCase()) : "Condition"; + return { color, label }; + } + return KIND[kind]; +} +var conditionIcon = (condition) => `/gw/icons/condition-${condition.replace(/([A-Z])/g, "-$1").toLowerCase()}.jpg`; + +// ../auto-battler/src/render/spriteSheet.js +var SHEET_ROWS = 4; +var cellOf = (height) => Math.round(height / SHEET_ROWS); +function sliceGridWith(pixi, texture, cell = cellOf(texture.source.height)) { + const { Texture, Rectangle } = pixi; + const src = texture.source; + const rows = Math.max(1, Math.round(src.height / cell)); + const cols = Math.max(1, Math.round(src.width / cell)); + return Array.from({ length: rows }, (_, r) => Array.from({ length: cols }, (_2, c) => new Texture({ source: src, frame: new Rectangle(c * cell, r * cell, cell, cell) }))); +} +var ANIM = { idle: 0.12, walk: 0.18, attack: 0.3, dmg: 0.25, die: 0.28 }; +var ROW_FOR = { "front-right": 0, "front-left": 1, "back-right": 2, "back-left": 3 }; +var rowFor = (grid, facing) => grid[ROW_FOR[facing]] ?? grid[0]; +var facingFor = (faceX, faceY) => (faceY < 0 ? "back" : "front") + "-" + (faceX < 0 ? "left" : "right"); + +// ../auto-battler/src/render/combatRenderer.js +var COND_ICON = { + bleeding: "bleeding", + poison: "poison", + burning: "burning", + deepWound: "deep-wound", + disease: "disease", + dazed: "dazed", + crippled: "crippled", + weakness: "weakness", + blind: "blind", + crackedArmor: "cracked-armor" +}; +var COND_STATUS = { + bleeding: "bleeding", + poison: "poison", + burning: "fire", + disease: "sickness", + deepWound: "petrification", + dazed: "stun", + crippled: "ice", + weakness: "nature", + blind: "fear", + crackedArmor: "shock" +}; +var COND_PRIORITY = ["deepWound", "burning", "poison", "bleeding", "disease", "dazed", "crippled", "blind", "crackedArmor", "weakness"]; +var OUT_OFF = [[-1, 0], [1, 0], [0, -1], [0, 1], [-1, -1], [1, -1], [-1, 1], [1, 1]]; +var MAT_RED = [0, 0, 0, 0, 0.86, 0, 0, 0, 0, 0.12, 0, 0, 0, 0, 0.12, 0, 0, 0, 1, 0]; +var MAT_YELLOW = [0, 0, 0, 0, 1, 0, 0, 0, 0, 0.84, 0, 0, 0, 0, 0.1, 0, 0, 0, 1, 0]; +var GOLD = 16767050; +var BAR_TOP = 3; +var GHOST_HOLD = 220; +var GHOST_TAU = 150; +var GHOST_FADE = 400; +var ICON_SIZE = 18; +var _footCache = /* @__PURE__ */ new Map(); +async function footFracOf(url) { + if (!url) return 1; + if (_footCache.has(url)) return _footCache.get(url); + try { + const img = await new Promise((ok, no) => { + const i = new Image(); + i.onload = () => ok(i); + i.onerror = no; + i.src = url; + }); + const cell = Math.round(img.naturalHeight / 4); + const cv = document.createElement("canvas"); + cv.width = cell; + cv.height = cell; + const ctx = cv.getContext("2d", { willReadFrequently: true }); + ctx.drawImage(img, 0, 0, cell, cell, 0, 0, cell, cell); + const d = ctx.getImageData(0, 0, cell, cell).data; + let bottom = cell - 1; + for (let y = cell - 1; y >= 0; y--) { + let any = false; + for (let x = 0; x < cell; x++) if (d[(y * cell + x) * 4 + 3] > 16) { + any = true; + break; + } + if (any) { + bottom = y; + break; + } + } + const frac = Math.min(1, (bottom + 1.5) / cell); + _footCache.set(url, frac); + return frac; + } catch { + return 1; + } +} +var facingOf = (a) => facingFor(a.faceX, a.faceY); +async function createCombatRenderer({ pixi, defsById = {}, layers, coords, getBattle }) { + const { Assets, Texture, Rectangle, AnimatedSprite, Sprite, Container, Graphics, Text, ColorMatrixFilter } = pixi; + const sliceGrid = (texture, cell) => sliceGridWith({ Texture, Rectangle }, texture, cell); + const rowFramesH = (texture, cell) => { + const src = texture.source; + const cols = Math.max(1, Math.round(src.width / cell)); + const h = Math.min(cell, src.height); + return Array.from({ length: cols }, (_, c) => new Texture({ source: src, frame: new Rectangle(c * cell, 0, cell, h) })); + }; + const { units, fx, projLayer } = layers; + const { mapX, mapY, depthOf } = coords; + const actorOf = (id) => getBattle()?.actors.find((x) => x.id === id); + const floats = []; + const sheetsById = {}; + for (const [id, def] of Object.entries(defsById)) { + if (!def?.idle) { + sheetsById[id] = null; + continue; + } + try { + const load = (url) => url ? recoloredTexture(Texture, url, def.idle, def.recolor) : null; + const [idle, walk, attack, dmg, die] = await Promise.all([ + load(def.idle), + load(def.walk), + load(def.attack), + load(def.dmg), + load(def.die) + ]); + for (const tx of [idle, walk, attack, dmg, die]) if (tx) tx.source.scaleMode = "nearest"; + const cell = Math.round(idle.source.height / 4); + sheetsById[id] = { + idle: sliceGrid(idle, cell), + walk: walk ? sliceGrid(walk, cell) : sliceGrid(idle, cell), + attack: attack ? sliceGrid(attack, cell) : sliceGrid(idle, cell), + dmg: dmg ? sliceGrid(dmg, cell) : null, + die: die ? sliceGrid(die, cell) : null, + cell, + footFrac: await footFracOf(def.idle) + }; + } catch { + sheetsById[id] = null; + } + } + const skillIcons = {}; + for (const s of CB_SKILLS) { + try { + const t = await Assets.load(iconUrl(s.id)); + t.source.scaleMode = "linear"; + skillIcons[s.id] = t; + } catch { + } + } + const condIcons = {}; + for (const [type, file] of Object.entries(COND_ICON)) { + try { + const t = await Assets.load(`/gw/icons/condition-${file}.jpg`); + t.source.scaleMode = "linear"; + condIcons[type] = t; + } catch { + } + } + const condFrames = {}; + try { + const catalogue = await fetch("/assets/effects.json").then((r) => r.json()).then((d) => d.effects || []); + const byKey = Object.fromEntries(catalogue.filter((e) => e.category === "status").map((e) => [e.key, e])); + for (const [type, statusKey] of Object.entries(COND_STATUS)) { + const e = byKey[statusKey]; + if (!e) continue; + try { + const t = await Assets.load(e.url); + t.source.scaleMode = "nearest"; + condFrames[type] = rowFramesH(t, e.cell || t.source.height); + } catch { + } + } + } catch { + } + const skillPlay = {}; + for (const [id, def] of Object.entries(defsById)) { + const cell = sheetsById[id]?.cell; + const map = {}; + for (const [skillId, fxCfg] of Object.entries(def.skillFx || {})) { + let animGrid = null; + if (fxCfg.animUrl && cell) { + try { + const t = await recoloredTexture(Texture, fxCfg.animUrl, def.idle, def.recolor); + animGrid = sliceGrid(t, cell); + } catch { + } + } + const effects = []; + for (const ef of fxCfg.effects || []) { + try { + const t = await Assets.load(ef.url); + t.source.scaleMode = "nearest"; + effects.push(sliceGrid(t, ef.cell || 32)[0]); + } catch { + } + } + map[skillId] = { animGrid, effects }; + } + skillPlay[id] = map; + } + const view = {}; + for (const [id, def] of Object.entries(defsById)) { + const sh = sheetsById[id]; + const c = new Container(); + let sp; + if (sh) { + sp = new AnimatedSprite(rowFor(sh.idle, "front-right")); + sp.anchor.set(0.5, sh.footFrac ?? 1); + sp.animationSpeed = ANIM.idle; + sp.play(); + } else { + sp = new Container(); + const g = new Graphics(); + g.circle(0, -16, 16).fill(def?.color || 8947848); + const t = new Text({ text: (def?.name || "?")[0], style: { fill: 16777215, fontSize: 15, fontWeight: "700" } }); + t.anchor.set(0.5); + t.y = -16; + sp.addChild(g, t); + } + const bars = new Graphics(); + const status = new Container(); + const overlay = new AnimatedSprite([Texture.EMPTY]); + overlay.anchor.set(0.5, sh?.footFrac ?? 1); + overlay.visible = false; + overlay.loop = true; + overlay.animationSpeed = 0.15; + const makeOutline = (matrix) => { + if (!sh) return null; + const o = { container: new Container(), copies: [] }; + const filter = new ColorMatrixFilter(); + filter.matrix = matrix; + o.container.filters = [filter]; + o.container.visible = false; + o.container.alpha = 0.75; + for (let i = 0; i < OUT_OFF.length; i++) { + const s = new Sprite(Texture.EMPTY); + s.anchor.set(0.5, sh.footFrac ?? 1); + o.container.addChild(s); + o.copies.push(s); + } + return o; + }; + const castOutline = makeOutline(MAT_YELLOW); + const outline = makeOutline(MAT_RED); + if (castOutline) c.addChild(castOutline.container); + if (outline) c.addChild(outline.container); + c.addChild(sp, overlay, bars, status); + units.addChild(c); + view[id] = { def, sheets: sh, container: c, sprite: sp, overlay, overlayKey: null, bars, status, statusSprites: {}, outline, castOutline, mode: null, facing: null, flash: 0, dead: false }; + } + function setLoop(v, mode, facing) { + if (v.mode === mode && v.facing === facing) return; + v.mode = mode; + v.facing = facing; + v.sprite.textures = rowFor(v.sheets[mode], facing); + v.sprite.loop = true; + v.sprite.animationSpeed = ANIM[mode]; + v.sprite.play(); + } + function playOnce(v, mode, facing, onDone, speedMul = 1) { + v.mode = mode; + v.facing = facing; + v.sprite.onComplete = null; + v.sprite.textures = rowFor(v.sheets[mode], facing); + v.sprite.loop = false; + v.sprite.animationSpeed = ANIM[mode] * speedMul; + v.sprite.onComplete = () => { + v.sprite.onComplete = null; + onDone && onDone(); + }; + v.sprite.gotoAndPlay(0); + } + const resume = (v) => { + v.mode = null; + }; + function playAttack(id, a, onDone = null, speedMul = 1) { + const v = view[id]; + if (!v?.sheets || v.dead) return; + playOnce(v, "attack", facingOf(a), () => { + resume(v); + onDone && onDone(); + }, speedMul); + } + function playHurt(id, a) { + const v = view[id]; + if (!v?.sheets?.dmg || v.dead || v.skillAnim || v.mode !== "idle") return; + playOnce(v, "dmg", facingOf(a), () => resume(v)); + } + function playDie(id, a) { + const v = view[id]; + if (!v || v.dead) return; + v.dead = true; + v.mode = "die"; + const facing = facingOf(a); + v.facing = facing; + if (!v.sheets) return; + const crumple = () => { + if (!v.sheets.die) return; + const frames = rowFor(v.sheets.die, facing); + v.sprite.onComplete = null; + v.sprite.textures = frames; + v.sprite.loop = false; + v.sprite.animationSpeed = ANIM.die * 0.75; + v.sprite.onComplete = () => { + v.sprite.onComplete = null; + v.sprite.gotoAndStop(frames.length - 1); + }; + v.sprite.gotoAndPlay(0); + }; + if (v.sheets.dmg) { + const hit = rowFor(v.sheets.dmg, facing); + v.sprite.onComplete = null; + v.sprite.textures = hit; + v.sprite.loop = false; + v.sprite.animationSpeed = ANIM.dmg; + v.sprite.onComplete = () => { + v.sprite.onComplete = null; + crumple(); + }; + v.sprite.gotoAndPlay(0); + } else crumple(); + } + function playGridOnce(v, grid, { speedMul = 1, hold = false, onDone = null } = {}) { + if (!grid || !v || v.dead) return false; + v.mode = "attack"; + v.skillAnim = true; + v.sprite.onComplete = null; + v.sprite.textures = grid[0]; + v.sprite.loop = false; + v.sprite.animationSpeed = ANIM.attack * speedMul; + v.sprite.onComplete = () => { + v.sprite.onComplete = null; + if (hold) v.sprite.gotoAndStop(grid[0].length - 1); + else { + v.skillAnim = false; + resume(v); + } + onDone && onDone(); + }; + v.sprite.gotoAndPlay(0); + return true; + } + function spawnEffects(casterId, framesList) { + const a = actorOf(casterId); + const v = view[casterId]; + if (!a || !v || !framesList?.length) return; + const cell = v.sheets?.cell ?? 24, ff = v.sheets?.footFrac ?? 1, depth = depthOf(a); + const cx = mapX(a.x), cy = mapY(a.y) - (ff - 0.5) * cell * depth; + for (const frames of framesList) { + if (!frames?.length) continue; + const s = new AnimatedSprite(frames); + s.anchor.set(0.5); + s.loop = false; + s.animationSpeed = 0.3; + s.scale.set(depth); + s.position.set(cx, cy); + s.onComplete = () => fx.removeChild(s); + fx.addChild(s); + s.gotoAndPlay(0); + } + } + const headY = (id, a) => { + const v = view[id], cell = v?.sheets?.cell ?? 24, ff = v?.sheets?.footFrac ?? 1; + return mapY(a.y) - ff * cell * depthOf(a) - 2; + }; + function floatText(id, text, color, opts = {}) { + const a = actorOf(id); + if (!a) return; + const t = new Text({ text, style: { fill: color, fontSize: opts.big ? 20 : 14, fontFamily: "monospace", fontWeight: "700", stroke: opts.big ? { color: 2760448, width: 3 } : void 0 } }); + t.anchor.set(0.5); + t.x = mapX(a.x) + (Math.random() * 20 - 10); + t.y = headY(id, a) - (opts.big ? 6 : 0); + fx.addChild(t); + floats.push({ t, life: opts.big ? 1.25 : 1, max: opts.big ? 1.25 : 1 }); + } + function floatIcon(id, skillId) { + const a = actorOf(id); + const tex = skillId && skillIcons[skillId]; + if (!a || !tex) return false; + const s = new Sprite(tex); + s.anchor.set(0.5); + s.width = ICON_SIZE; + s.height = ICON_SIZE; + s.x = mapX(a.x); + s.y = headY(id, a) - 8; + fx.addChild(s); + floats.push({ t: s, life: 1.2, max: 1.2, icon: true, size: ICON_SIZE, who: id }); + return true; + } + function flagEmpower(id) { + for (const f of floats) if (f.icon && f.who === id) { + f.t.tint = GOLD; + f.empower = true; + f.size = ICON_SIZE + 6; + } + floatText(id, "\u26A1", GOLD); + } + function spawnEcho(id) { + const a = actorOf(id), v = view[id]; + if (!a || !v?.sheets) return; + const depth = depthOf(a); + for (const cfg of [{ life: 0.55, scaleTo: 2.2, a0: 0.85 }, { life: 0.8, scaleTo: 3.1, a0: 0.55 }]) { + const s = new Sprite(v.sprite.texture); + s.anchor.set(0.5, v.sheets.footFrac ?? 1); + s.tint = 10473727; + s.x = mapX(a.x); + s.y = mapY(a.y); + fx.addChild(s); + floats.push({ t: s, life: cfg.life, max: cfg.life, echo: true, baseScale: depth, scaleTo: cfg.scaleTo, alpha0: cfg.a0, dir: a.facing || 1 }); + } + } + function drawBars(id, a, dt, now) { + const v = view[id]; + const g = v.bars; + g.clear(); + const w = 40; + const cur = Math.max(0, a.hp / a.baseMaxHp); + if (v.hpGhost == null || cur >= v.hpGhost) { + v.hpGhost = cur; + v.hpLast = cur; + v.hpGhostT = GHOST_HOLD; + } + if (cur < (v.hpLast ?? cur) - 8e-3) v.hpGhostT = 0; + v.hpLast = cur; + v.hpGhostT = (v.hpGhostT ?? GHOST_HOLD) + dt; + if (v.hpGhost > cur && v.hpGhostT > GHOST_HOLD) { + v.hpGhost = cur + (v.hpGhost - cur) * Math.max(0, 1 - dt / GHOST_TAU); + if (v.hpGhost - cur < 4e-3) v.hpGhost = cur; + } + g.rect(-w / 2, BAR_TOP, w, 5).fill(1316897); + if (v.hpGhost > cur + 1e-4) { + const fade = v.hpGhostT <= GHOST_HOLD ? 1 : Math.max(0, 1 - (v.hpGhostT - GHOST_HOLD) / GHOST_FADE); + g.rect(-w / 2 + 1 + (w - 2) * cur, BAR_TOP + 1, (w - 2) * (v.hpGhost - cur), 3).fill({ color: 16777215, alpha: 0.85 * fade }); + } + g.rect(-w / 2 + 1, BAR_TOP + 1, (w - 2) * cur, 3).fill(cur > 0.4 ? 12113482 : 14165786); + const res = a.profession === "Warrior" ? a.adrenaline / 25 : a.energy / a.maxEnergy; + g.rect(-w / 2, BAR_TOP + 6, w, 3).fill(1316897); + g.rect(-w / 2 + 1, BAR_TOP + 7, (w - 2) * Math.max(0, Math.min(1, res)), 1.5).fill(a.profession === "Warrior" ? 15247146 : 3832997); + const mark = Object.values(a.marks || {}).find((m) => m && m.until > now); + if (mark) for (let i = 0; i < (mark.stage === "offhand" ? 2 : 1); i++) g.circle(w / 2 - 2 - i * 3, BAR_TOP - 2, 1.3).fill(16767050); + const headRel = -((v.sheets?.footFrac ?? 1) * (v.sheets?.cell ?? 24) * depthOf(a)) - 4; + if (a.kd > now) { + const cy = headRel + 2; + for (let i = 0; i < 3; i++) { + const ang = now * 6 + i * 2 * Math.PI / 3; + g.circle(Math.cos(ang) * 6, cy + Math.sin(ang) * 2.2, 1.5).fill(16110658); + } + } + if (a.casting && a.casting.skill) { + const total = a.casting.skill.cast || 1; + const prog = Math.max(0, Math.min(1, 1 - (a.casting.left ?? 0) / total)); + g.rect(-11, headRel, 22, 3).fill(1316897); + g.rect(-10, headRel + 0.75, 20 * prog, 1.5).fill(7325664); + } + } + function drawStatus(id, a, t, depth) { + const v = view[id]; + const active = a.conds.filter((c) => c.until > t && condIcons[c.type]); + const sz = 11, gap = 2, totalW = active.length * sz + Math.max(0, active.length - 1) * gap; + active.forEach((c, i) => { + let s = v.statusSprites[c.type]; + if (!s) { + s = new Sprite(condIcons[c.type]); + s.anchor.set(0.5); + s.width = sz; + s.height = sz; + v.status.addChild(s); + v.statusSprites[c.type] = s; + } + s.x = -totalW / 2 + sz / 2 + i * (sz + gap); + s.y = BAR_TOP + 15; + const left = c.until - t; + s.visible = left >= 1.2 || Math.floor(t * 6) % 2 === 0; + }); + for (const type in v.statusSprites) if (!active.find((c) => c.type === type)) v.statusSprites[type].visible = false; + let key = null; + if (!v.dead) { + for (const type of COND_PRIORITY) if (condFrames[type] && active.some((c) => c.type === type)) { + key = type; + break; + } + } + if (key !== v.overlayKey) { + v.overlayKey = key; + if (key) { + v.overlay.textures = condFrames[key]; + v.overlay.visible = true; + v.overlay.gotoAndPlay(0); + } else { + v.overlay.visible = false; + } + } + if (key) v.overlay.scale.set(depth); + } + function resetForNewBattle() { + for (const id in view) { + const v = view[id]; + v.dead = false; + v.mode = null; + v.facing = null; + v.flash = 0; + v.skillAnim = false; + for (const k in v.statusSprites) v.statusSprites[k].visible = false; + v.overlay.visible = false; + v.overlayKey = null; + if (v.sheets) { + v.sprite.onComplete = null; + v.sprite.tint = 16777215; + v.sprite.alpha = 1; + } + } + } + function syncActors(b, dtMS, now, { cine = null, cineDim = 0, cineCfg = {} } = {}) { + for (const a of b.actors) { + const v = view[a.id]; + if (!v) continue; + v.container.x = mapX(a.x); + v.container.y = mapY(a.y); + if (cine?.hit && a.id === cine.targetId && !v.dead) { + v.container.x += (Math.random() * 2 - 1) * 2.5; + v.container.y += (Math.random() * 2 - 1) * 2.5; + } + v.container.zIndex = a.y; + const depth = depthOf(a); + const dimmed = cine && cine.freeze && cineCfg.dim !== false && a.id !== cine.casterId && a.id !== cine.targetId; + const dimTint = () => { + const g = Math.round(255 * (1 - cineDim * 0.5)); + return g << 16 | g << 8 | g; + }; + if (v.sheets) { + v.sprite.scale.set(depth); + if (v.dead) { + v.sprite.tint = dimmed ? dimTint() : 16777215; + v.sprite.alpha = 0.9; + } else { + if (v.mode !== "attack" && v.mode !== "dmg") { + setLoop(v, a.moving ? "walk" : "idle", facingOf(a)); + } + v.flash = Math.max(0, v.flash - dtMS); + v.sprite.tint = dimmed ? dimTint() : v.flash > 0 && !v.skillAnim ? 16738922 : 16777215; + v.sprite.alpha = 1; + } + } else { + v.sprite.scale.set(1); + v.sprite.alpha = a.alive ? 1 : 0.32; + } + drawBars(a.id, a, dtMS, b.t); + drawStatus(a.id, a, b.t, depth); + } + } + function updateFloats(dtMS) { + for (let i = floats.length - 1; i >= 0; i--) { + const f = floats[i]; + f.life -= dtMS / 1e3; + const age = f.max - f.life; + if (f.echo) { + const p = Math.min(1, age / f.max); + const sc = f.baseScale * (1 + (f.scaleTo - 1) * p); + f.t.scale.set(sc * (f.dir < 0 ? -1 : 1), sc); + f.t.alpha = f.alpha0 * (1 - p); + } else { + f.t.y -= dtMS * 0.022; + const fin = Math.min(1, age / 0.18), fout = Math.min(1, f.life / 0.35); + f.t.alpha = Math.max(0, Math.min(fin, fout)); + if (f.icon) { + const sz = f.size * (0.6 + 0.4 * fin); + f.t.width = sz; + f.t.height = sz; + } + } + if (f.life <= 0) { + fx.removeChild(f.t); + floats.splice(i, 1); + } + } + } + function drawProjectiles(b) { + projLayer.clear(); + for (const p of b.projectiles) { + const frac = Math.max(0, Math.min(1, (b.t - p.bornT) / (p.hitT - p.bornT))); + const px = mapX(p.fromX + (p.aimX - p.fromX) * frac); + const py = mapY(p.fromY + (p.aimY - p.fromY) * frac) - 26; + projLayer.circle(px, py, 4).fill(15786176).stroke({ width: 1, color: 2764602 }); + } + } + function processLog(b, r, hooks = {}) { + const log2 = b.log; + for (; r.logIdx < log2.length; r.logIdx++) { + const e = log2[r.logIdx]; + if (e.kind === "cast") { + const h = hooks.onCast?.(e) || {}; + if (h.break) { + r.logIdx++; + return; + } + const v = view[e.who], a = actorOf(e.who), play = skillPlay[e.who]?.[e.skillId]; + if (!h.skipInline) { + if (v && a) { + if (!playGridOnce(v, play?.animGrid)) playAttack(e.who, a); + spawnEffects(e.who, play?.effects); + } + if (!floatIcon(e.who, e.skillId)) floatText(e.who, e.name + (e.elite ? " \u2605" : ""), 15787730); + if (e.combo === "dual") floatText(e.who, "\u2726", GOLD); + } + } else if (e.kind === "swing") { + if (!e.skillId) playAttack(e.who, actorOf(e.who)); + } else if (e.kind === "shoot") { + if (!e.skillId) playAttack(e.who, actorOf(e.who)); + } else if (e.kind === "hit" && e.amount > 0) { + const v = view[e.who]; + if (v) v.flash = 130; + playHurt(e.who, actorOf(e.who)); + e.empowered ? floatText(e.who, "-" + e.amount + "!", GOLD, { big: true }) : floatText(e.who, "-" + e.amount, 16743018); + } else if (e.kind === "heal" && e.amount > 0) e.empowered ? floatText(e.who, "+" + e.amount + "!", GOLD, { big: true }) : floatText(e.who, "+" + e.amount, 9429896); + else if (e.kind === "cond" && e.empowered) floatText(e.who, (e.cond === "interrupted" ? "INTERRUPT" : String(e.cond).toUpperCase()) + "!", GOLD, { big: true }); + else if (e.kind === "empower") flagEmpower(e.who); + else if (e.kind === "miss") floatText(e.who, "dodge", 10405352); + else if (e.kind === "death") playDie(e.who, actorOf(e.who)); + } + } + return { + view, + floats, + actorOf, + skillPlay, + setLoop, + playOnce, + resume, + playAttack, + playHurt, + playDie, + playGridOnce, + spawnEffects, + floatText, + floatIcon, + flagEmpower, + spawnEcho, + drawBars, + drawStatus, + resetForNewBattle, + syncActors, + updateFloats, + drawProjectiles, + processLog + }; +} + +// ../auto-battler/src/render/sandboxStage.js +var C_BASIC = 16777215; +var C_SKILL = 15774761; +var STEP = 0.05; +var MAT_WHITE = [0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0]; +var MAT_AMBER = [0, 0, 0, 0, 0.94, 0, 0, 0, 0, 0.71, 0, 0, 0, 0, 0.16, 0, 0, 0, 1, 0]; +var OUT_OFF2 = [[-3, 0], [3, 0], [0, -3], [0, 3], [-3, -3], [3, -3], [-3, 3], [3, 3]]; +function mountSandboxStage(pixi, stageEl, ctx) { + const { Application, Sprite, Graphics, Text, Container, ColorMatrixFilter, Texture } = pixi; + const { input, state } = ctx; + let currentApp = null; + let token = 0; + const killApp = () => { + if (currentApp) { + try { + currentApp.destroy(true, { children: true }); + } catch { + } + currentApp = null; + } + }; + function rebuild(buildBattle) { + const myToken = ++token; + killApp(); + stageEl.replaceChildren(); + const built = buildBattle(); + if (!built) return; + const { battle, defsById } = built; + const app = new Application(); + app.init({ background: 15524556, antialias: false, resizeTo: stageEl }).then(async () => { + if (myToken !== token) { + app.destroy(true, { children: true }); + return; + } + stageEl.appendChild(app.canvas); + if (ctx.canvasTestId) app.canvas.setAttribute("data-testid", ctx.canvasTestId); + currentApp = app; + const W = app.screen.width, H = app.screen.height; + const sx = W / FIELD.w, sy = H / FIELD.h; + const mapX = (x) => x * sx, mapY = (y) => y * sy; + const depthOf = (a) => Math.min(Math.max(W / 190, 1.6), 3) * (0.85 + 0.4 * (a.y / FIELD.h)); + const ranges = new Graphics(); + app.stage.addChild(ranges); + const outlineLayer = new Container(); + app.stage.addChild(outlineLayer); + const units = new Container(); + units.sortableChildren = true; + app.stage.addChild(units); + const projLayer = new Graphics(); + app.stage.addChild(projLayer); + const fxLayer = new Container(); + app.stage.addChild(fxLayer); + const chromeLayer = new Container(); + app.stage.addChild(chromeLayer); + const p0 = battle.actors.find((a) => a.id === "P0"); + if (p0) { + p0.attackTimer = 0; + if (state.pos) { + p0.x = state.pos.x; + p0.y = state.pos.y; + } + } + const R = await createCombatRenderer({ pixi, defsById, layers: { units, fx: fxLayer, projLayer }, coords: { mapX, mapY, depthOf }, getBattle: () => battle }); + if (myToken !== token) { + app.destroy(true, { children: true }); + return; + } + const outlines = ["E0", "E1", "E2"].map(() => { + const c = new Container(); + const filter = new ColorMatrixFilter(); + filter.matrix = MAT_WHITE; + c.filters = [filter]; + c.visible = false; + const copies = OUT_OFF2.map(() => { + const s = new Sprite(Texture.EMPTY); + s.anchor.set(0.5, R.view.E0?.sheets?.footFrac ?? 0.5); + return s; + }); + copies.forEach((s) => c.addChild(s)); + outlineLayer.addChild(c); + return { c, filter, copies }; + }); + const reticles = new Graphics(); + chromeLayer.addChild(reticles); + const hud = new Text({ text: "", style: { fill: 2765632, fontSize: 13, fontFamily: "monospace", lineHeight: 18 } }); + hud.position.set(12, 10); + chromeLayer.addChild(hud); + const cursor = { logIdx: 0 }; + let acc = 0; + const foeActor = (i) => battle.actors.find((a) => a.id === "E" + i); + const NAME = ctx.targetName || "foe"; + app.ticker.add((t) => { + if (myToken !== token) return; + const dtMS = t.deltaMS, dt = dtMS / 1e3; + const player = battle.actors.find((a) => a.id === "P0"); + const tg = ctx.targeting(); + const selectedSkillId = ctx.getSelectedSkillId(); + const nearestFoeGw = [0, 1, 2].map(foeActor).filter((a) => a && a.alive).reduce((m, a) => Math.min(m, Math.hypot(a.x - (player?.x ?? 0), a.y - (player?.y ?? 0))), Infinity); + setInput(battle, "P0", { moveX: input.keys.x, moveY: input.keys.y }); + if (input.req.attack) { + input.req.attack = false; + if (nearestFoeGw <= tg.basicGw) setInput(battle, "P0", { action: "basic" }); + } + if (input.req.skill) { + input.req.skill = false; + if (selectedSkillId && (tg.skillSupport || tg.skillGw != null && nearestFoeGw <= tg.skillGw)) setInput(battle, "P0", { action: selectedSkillId }); + } + if (input.req.reset) { + input.req.reset = false; + for (const a of battle.actors) if (a.control === "dummy") { + a.alive = true; + a.hp = a.maxHp = a.baseMaxHp; + a.conds = []; + a.marks = {}; + a.mods = []; + a.casting = null; + a.kd = 0; + a.deadAt = null; + } + battle.log.length = 0; + cursor.logIdx = 0; + R.resetForNewBattle(); + state.targetId = null; + } + acc += Math.min(dt, 0.1); + while (acc >= STEP) { + step(battle, STEP); + acc -= STEP; + } + if (player) state.pos = { x: player.x, y: player.y }; + R.syncActors(battle, dtMS, battle.t); + R.updateFloats(dtMS); + R.drawProjectiles(battle); + R.processLog(battle, cursor); + const { basicGw, skillGw } = tg; + const px = mapX(player?.x ?? 0), py = mapY(player?.y ?? 0); + ranges.clear(); + ranges.ellipse(px, py, basicGw * sx, basicGw * sy).fill({ color: C_BASIC, alpha: 0.05 }); + if (skillGw != null) ranges.ellipse(px, py, skillGw * sx, skillGw * sy).stroke({ width: 2, color: C_SKILL, alpha: 0.6 }); + ranges.ellipse(px, py, basicGw * sx, basicGw * sy).stroke({ width: 2, color: C_BASIC, alpha: 0.55 }); + const targetable = []; + const infoArr = [0, 1, 2].map((i) => { + const a = foeActor(i); + if (!a) return null; + const d = a.alive ? Math.hypot(a.x - (player?.x ?? 0), a.y - (player?.y ?? 0)) : Infinity; + const inBasic = a.alive && d <= basicGw, inSkill = a.alive && skillGw != null && d <= skillGw; + if (inBasic || inSkill) targetable.push(i); + return { a, d, inBasic, inSkill }; + }); + if (input.req.f) { + input.req.f = false; + if (targetable.length) { + const at = targetable.indexOf(state.targetId); + state.targetId = targetable[(at + 1) % targetable.length]; + } + } + let cur = state.targetId; + if (cur == null || !targetable.includes(cur)) cur = targetable.length ? targetable.reduce((b, i) => infoArr[i].d < infoArr[b].d ? i : b, targetable[0]) : null; + state.targetId = cur; + reticles.clear(); + infoArr.forEach((it, i) => { + const o = outlines[i]; + if (!it) { + o.c.visible = false; + return; + } + const v = R.view["E" + i]; + const show = it.inBasic || it.inSkill; + o.c.visible = show; + if (show && v?.sprite) { + o.filter.matrix = it.inSkill ? MAT_AMBER : MAT_WHITE; + o.c.alpha = i === cur ? 1 : 0.55; + const ax = mapX(it.a.x), ay = mapY(it.a.y); + o.copies.forEach((s, k) => { + s.texture = v.sprite.texture; + s.scale.copyFrom(v.sprite.scale); + s.position.set(ax + OUT_OFF2[k][0], ay + OUT_OFF2[k][1]); + }); + } + if (i === cur && it.a.alive) { + const ax = mapX(it.a.x), top = mapY(it.a.y) - (v?.sheets ? v.sheets.footFrac * v.sheets.cell * depthOf(it.a) : 40) - 8; + reticles.moveTo(ax, top + 6).lineTo(ax - 6, top - 3).lineTo(ax + 6, top - 3).fill({ color: it.inSkill ? C_SKILL : C_BASIC }); + } + }); + const aliveN = [0, 1, 2].filter((i) => foeActor(i)?.alive).length; + const tInfo = cur != null ? infoArr[cur] : null; + hud.text = tInfo ? `Target: ${NAME} ${cur + 1} \xB7 ${Math.round(tInfo.d)} gw \xB7 ${Math.max(0, Math.round(tInfo.a.hp))}/${Math.round(tInfo.a.baseMaxHp)} hp +${tInfo.inSkill ? "\u25C6 in skill range" : "\u25C7 basic-attack range"} +F cycle target (${targetable.length} in range) \xB7 \` reset` : `${aliveN ? `No ${NAME.toLowerCase()} in range \u2014 move closer (WASD)` : `All ${NAME.toLowerCase()}s down \u2014 \` to reset`} +F cycle target \xB7 \` reset`; + }); + }); + } + return { rebuild, destroy() { + token++; + killApp(); + } }; +} + +// ../auto-battler/src/render/classesSandbox.js +var CLASSES = PROFESSION_BY_ID.filter((p) => p !== "Common"); +var ATTACK_TYPES = ["melee", "ranged"]; +var ACOLYTE_SLUG = "dark-brotherhood-acolyte"; +var ACO_MAXHP = 80; +var MELEE_CATS = /* @__PURE__ */ new Set(["melee_attack", "lead_attack", "offhand_attack", "dual_attack", "scythe_attack"]); +var RANGED_CATS = /* @__PURE__ */ new Set(["bow_attack", "spear_attack"]); +function skillRangeGw(skill) { + if (!skill) return null; + if (MELEE_CATS.has(skill.category)) return MELEE_GW; + if (RANGED_CATS.has(skill.category)) return BOW_GW; + return skill.target == null || /foe/.test(skill.target) ? SPELL_GW : null; +} +var flatChars = (packs) => (packs || []).flatMap((p) => p.characters || []).filter((c) => c.idle && c.walk); +function el(tag, props = {}, kids = []) { + const n = document.createElement(tag); + for (const [k, v] of Object.entries(props)) { + if (k === "class") n.className = v; + else if (k === "html") n.innerHTML = v; + else if (k === "style" && typeof v === "object") Object.assign(n.style, v); + else if (k.startsWith("on") && typeof v === "function") n.addEventListener(k.slice(2), v); + else if (v != null) n.setAttribute(k, v); + } + for (const kid of [].concat(kids)) if (kid != null) n.append(kid); + return n; +} +function renderSkillDetail(skill) { + if (!skill) return null; + const c = skill.cost || {}; + const chips = []; + if (c.energy) chips.push({ glyph: COST_GLYPH.energy, text: c.energy, tone: "energy" }); + if (c.adrenaline) chips.push({ glyph: COST_GLYPH.adrenaline, text: c.adrenaline, tone: "adrenaline" }); + if (c.sacrifice) chips.push({ glyph: COST_GLYPH.sacrifice, text: `${c.sacrifice}%`, tone: "sacrifice" }); + if (skill.cast) chips.push({ glyph: COST_GLYPH.activation, text: `${skill.cast}s`, tone: "time" }); + if (skill.recharge) chips.push({ glyph: COST_GLYPH.recharge, text: `${skill.recharge}s`, tone: "time" }); + const reqs = skill.requires || []; + const onHit = reqs.includes("on_hit"); + const gates = reqs.filter((r) => r !== "on_hit"); + return el("div", { class: "cbgame-skill-detail", "data-testid": "cbgame-skill-detail" }, [ + el("div", { class: "cbgame-skill-detail-head" }, [ + el("img", { src: iconUrl(skill.id), alt: "", class: `cbgame-skill-detail-icon${skill.elite ? " elite" : ""}`, width: 38, height: 38 }), + el("div", { class: "cbgame-skill-detail-title" }, [ + el("div", { class: "cbgame-skill-detail-name" }, skill.name + (skill.elite ? " \u2605" : "")), + el("div", { class: "cbgame-skill-detail-meta" }, [ + el("span", {}, skill.category.replace(/_/g, " ")), + el("span", { class: "dot" }, "\xB7"), + el("span", {}, skill.attribute), + skill.target ? el("span", { class: "cbgame-skill-target" }, skill.target) : null + ]) + ]) + ]), + chips.length ? el("div", { class: "cbgame-skill-stats" }, chips.map((ch) => el("span", { class: `cbgame-skill-stat tone-${ch.tone}` }, [el("img", { src: ch.glyph, alt: "", width: 13, height: 13 }), String(ch.text)]))) : null, + onHit || gates.length ? el("div", { class: "cbgame-skill-triggers" }, [ + onHit ? el("span", { class: "cbgame-skill-trigger" }, "\u2694 On hit") : null, + ...gates.map((g) => el("span", { class: "cbgame-skill-trigger" }, "\u21B3 " + req(g))) + ]) : null, + el("div", { class: "cbgame-skill-fx" }, (skill.effects || []).map((e) => { + const { color, label } = effectStyle(e); + const isCond = effectKind(e) === "condition"; + return el("div", { class: "cbgame-skill-fx-row", style: { "--fx": color } }, [ + isCond ? el("img", { class: "cbgame-skill-fx-cond", src: conditionIcon(e.condition), alt: "", width: 16, height: 16 }) : el("span", { class: "cbgame-skill-fx-tag" }, label), + el("span", { class: "cbgame-skill-fx-text" }, effect(e)) + ]); + })) + ]); +} +function mountClassesSandbox(pixi, host, opts = {}) { + const packs = opts.packs || []; + const fx = opts.fx || []; + const editable = !!opts.editable; + let config = opts.config && opts.config.classes ? opts.config : { classes: {}, skills: {} }; + let active = CLASSES[0]; + let selectedSkillId = null; + let previewIndex = 0; + let palette = { anchor: null, swatches: [] }; + const keys = { x: 0, y: 0 }; + const req2 = { attack: false, skill: false, f: false, reset: false }; + const stageState = { pos: null, targetId: null }; + let targeting = { basicGw: BASIC_MELEE_GW, skillGw: null }; + const chars = flatChars(packs); + const classCfgOf = () => config.classes?.[active] || {}; + const playerTargetOf = () => paletteTarget(active, previewIndex); + const activeCharOf = () => chars.find((c) => c.slug === classCfgOf().sprite) || chars[0] || null; + const attackTypeOf = () => classCfgOf().attackType || "melee"; + const classSkillsOf = () => CB_SKILLS.filter((s) => s.profession === active); + const selectedSkillOf = () => classSkillsOf().find((s) => s.id === selectedSkillId) || null; + const animsOf = (ch) => { + if (!ch) return []; + const list = []; + if (ch.attack) list.push({ key: "attack", name: "Attack", url: ch.attack, effect: ch.attackEffect || null }); + if (ch.jump) list.push({ key: "jump", name: "Jump", url: ch.jump, effect: null }); + for (const e of ch.extras || []) list.push({ key: e.key, name: e.name, url: e.url, effect: e.effect || null }); + return list; + }; + const attackArcs = fx.filter((e) => e.category === "attack"); + const weaponArcs = fx.filter((e) => e.category === "weapon"); + const specialFx = fx.filter((e) => e.category === "special"); + const arrowVariants = fx.filter((e) => e.category === "projectile"); + const arcTypes = [...new Set(attackArcs.map((e) => e.type))]; + const weaponFamilies = [...new Set(weaponArcs.map((e) => e.label.includes(" \xB7 ") ? e.label.split(" \xB7 ")[0] : "legendary"))]; + const effectByKey = /* @__PURE__ */ new Map(); + for (const e of fx) if (e.category === "attack" || e.category === "weapon" || e.category === "special") effectByKey.set(e.key, e); + const resolveAnim = (anims, key) => anims.find((a) => a.key === key) || anims[0] || null; + const resolveEffectUrls = (animObj, list) => [...new Set((list || []).map((k) => k === "auto" ? animObj?.effect || null : effectByKey.get(k)?.url || null).filter(Boolean))]; + const stackOf = (cfg, key) => { + if (Array.isArray(cfg?.[key])) return cfg[key].filter((k) => k && k !== "none"); + const single = cfg?.[key.replace(/s$/, "")]; + if (single === void 0 || single === null) return ["auto"]; + return single === "none" ? [] : [single]; + }; + function persist(next) { + config = next; + opts.onSave?.(next); + } + function save(next) { + persist(next); + syncTargeting(); + renderCustomize(); + renderList(); + rebuildScene(); + } + const updateClass = (patch) => save({ ...config, classes: { ...config.classes, [active]: { ...config.classes?.[active] || {}, ...patch } } }); + const updateSkill = (id, patch) => save({ ...config, skills: { ...config.skills, [id]: { ...config.skills?.[id] || {}, ...patch } } }); + const listAside = el("aside", { class: "classes-list" }); + const stageEl = el("div", { class: "classes-stage" }); + const controlsEl = el("div", { class: "classes-controls" }); + const skillsEl = el("div", { class: "classes-skills" }); + const customizeEl = el("aside", { class: "classes-customize" }); + const root = el("div", { class: "classes" }, [ + listAside, + el("div", { class: "classes-main" }, [stageEl, controlsEl, skillsEl]), + customizeEl + ]); + host.append(root); + const labelFor = (k) => k === "auto" ? "animation effect" : effectByKey.get(k)?.label || k; + const familyOf = (e) => e.label.includes(" \xB7 ") ? e.label.split(" \xB7 ")[0] : "legendary"; + function effectStack(list, onChange, tid) { + const chips = el( + "div", + { class: "classes-fx-chips" }, + list.length === 0 ? el("span", { class: "muted" }, "none") : list.map((k, i) => el("span", { class: "classes-fx-chip" }, [ + labelFor(k), + editable ? el("button", { onclick: () => onChange(list.filter((_, j) => j !== i)) }, "\xD7") : null + ])) + ); + const kids = [chips]; + if (editable) { + const sel = el("select", { onchange: (e) => { + const v = e.target.value; + if (v && !list.includes(v)) onChange([...list, v]); + e.target.value = ""; + } }, [ + el("option", { value: "" }, "+ add effect\u2026"), + el("option", { value: "auto" }, "animation effect"), + ...arcTypes.map((t) => el("optgroup", { label: `attack: ${t}` }, attackArcs.filter((e) => e.type === t).map((e) => el("option", { value: e.key }, e.element)))), + ...weaponFamilies.map((f) => el("optgroup", { label: `weapon: ${f}` }, weaponArcs.filter((e) => familyOf(e) === f).map((e) => el("option", { value: e.key }, e.label.includes(" \xB7 ") ? e.label.split(" \xB7 ")[1] : e.label)))), + specialFx.length ? el("optgroup", { label: "special (True Heroes)" }, specialFx.map((e) => el("option", { value: e.key }, e.label))) : null + ]); + kids.push(sel); + } + return el("div", { class: "classes-fx-stack", "data-testid": tid }, kids); + } + const selectField = (value, options, onChange, tid) => { + const s = el( + "select", + { "data-testid": tid, onchange: (e) => onChange(e.target.value) }, + options.map(([v, label]) => el("option", { value: v }, label)) + ); + s.value = value; + if (!editable) s.disabled = true; + return s; + }; + function renderList() { + listAside.replaceChildren( + el("h2", {}, "Classes"), + el("ul", {}, CLASSES.map((c) => el("li", {}, el("button", { + class: `classes-link${active === c ? " active" : ""}`, + style: { "--c": PROFESSION_COLOR[c] }, + onclick: () => setActive(c) + }, c)))), + renderPalette() + ); + } + function renderPalette() { + return el("div", { class: "classes-palette", "data-testid": "classes-palette" }, [ + el("div", { class: "classes-palette-head" }, ["Palette ", el("span", { class: "muted" }, "\xB7 per instance")]), + el("div", { class: "classes-swatches" }, (PROFESSION_PALETTES[active] || []).map((p, i) => { + const cols = palette.swatches.length ? palette.swatches.map((c) => recolorHex(c, p.target, palette.anchor)) : [p.swatch]; + return el("button", { + class: `classes-swatch${previewIndex === i ? " active" : ""}`, + title: `${i + 1}${["st", "nd", "rd"][i] || "th"} of this class \u2014 ${p.name}`, + "data-testid": `classes-swatch-${i}`, + onclick: () => setPreviewIndex(i) + }, [ + el("span", { class: "classes-swatch-num" }, String(i + 1)), + el("span", { class: "classes-swatch-ramp" }, cols.map((c) => el("i", { style: { background: c } }))), + el("span", { class: "classes-swatch-name" }, p.name) + ]); + })) + ]); + } + function renderControls() { + const atkBtn = el("button", { class: "classes-attack-btn", "data-testid": "classes-attack", onclick: () => { + req2.attack = true; + } }, "\u2694 Attack (Space)"); + const skBtn = el("button", { class: "classes-attack-btn", "data-testid": "classes-play-skill", onclick: () => { + req2.skill = true; + } }, "\u25B6 Skill (E)"); + if (!selectedSkillOf()) skBtn.disabled = true; + controlsEl.replaceChildren(atkBtn, skBtn, el("span", { class: "classes-taskbar-hint muted" }, "WASD move \xB7 Space attack \xB7 E skill \xB7 F target \xB7 ` reset")); + } + function renderSkills() { + const classSkills = classSkillsOf(); + skillsEl.replaceChildren( + el("div", { class: "classes-skills-head" }, [ + el("strong", { style: { color: PROFESSION_COLOR[active] } }, active), + " skills", + el("span", { class: "muted" }, ` \xB7 ${classSkills.length} \xB7 click to select`) + ]), + el("div", { class: "classes-skill-grid" }, classSkills.length ? classSkills.map((s) => el("button", { + class: `classes-skill${s.elite ? " elite" : ""}${selectedSkillId === s.id ? " selected" : ""}`, + title: s.name, + onclick: () => setSelectedSkill(s.id) + }, el("img", { src: iconUrl(s.id), alt: s.name, loading: "lazy", width: 36, height: 36 }))) : el("span", { class: "muted" }, "No CB skills authored for this class yet.")) + ); + } + function renderCustomize() { + const cfg = classCfgOf(); + const activeChar = activeCharOf(); + const attackType = attackTypeOf(); + const anims = animsOf(activeChar); + const basicAnim = resolveAnim(anims, cfg.attackAnim); + const selectedSkill = selectedSkillOf(); + customizeEl.style.setProperty("--c", PROFESSION_COLOR[active]); + const kids = [el("h2", {}, "Customize")]; + if (!editable) kids.push(el("p", { class: "classes-readonly" }, "read-only \xB7 edit locally and push")); + kids.push( + el("label", { class: "classes-field" }, ["Sprite", selectField(cfg.sprite || "", chars.map((c) => [c.slug, c.name]), (v) => updateClass({ sprite: v }), "classes-sprite")]), + el("label", { class: "classes-field" }, ["Attack type", selectField(attackType, ATTACK_TYPES.map((t) => [t, t]), (v) => updateClass({ attackType: v }), "classes-attack-type")]), + el("label", { class: "classes-field" }, ["Attack animation", selectField(basicAnim?.key || "", anims.length ? anims.map((a) => [a.key, a.name]) : [["", "(none)"]], (v) => updateClass({ attackAnim: v }), "classes-attack-anim")]), + el("div", { class: "classes-field" }, ["Attack effects ", el("small", { class: "muted" }, "(stack)"), effectStack(stackOf(cfg, "attackEffects"), (v) => updateClass({ attackEffects: v }), "classes-attack-effect")]) + ); + if (attackType === "ranged") { + kids.push(el("label", { class: "classes-field" }, ["Projectile", selectField( + cfg.projectile || "auto", + [["auto", "auto (character's / arrow)"], ["none", "none"], ...arrowVariants.map((e) => [e.key, `arrow \xB7 ${e.element}`])], + (v) => updateClass({ projectile: v }), + "classes-projectile" + )])); + } + kids.push(el("div", { class: "classes-preview-name" }, `${active}: ${activeChar?.name || "\u2014"}`)); + if (selectedSkill) { + const skillCfg = config.skills?.[selectedSkill.id] || {}; + const skillAnim = resolveAnim(anims, skillCfg.anim); + kids.push(el("div", { class: "classes-skill-detail", "data-testid": "classes-skill-detail" }, [ + el("h2", {}, "Skill"), + renderSkillDetail(selectedSkill), + el("label", { class: "classes-field" }, ["Skill animation", selectField(skillAnim?.key || "", anims.map((a) => [a.key, a.name]), (v) => updateSkill(selectedSkill.id, { anim: v }), "classes-skill-anim")]), + el("div", { class: "classes-field" }, ["Skill effects ", el("small", { class: "muted" }, "(stack)"), effectStack(stackOf(skillCfg, "effects"), (v) => updateSkill(selectedSkill.id, { effects: v }), "classes-skill-effect")]) + ])); + } + customizeEl.replaceChildren(...kids); + } + function syncTargeting() { + targeting = { basicGw: attackTypeOf() === "ranged" ? BOW_GW : BASIC_MELEE_GW, skillGw: skillRangeGw(selectedSkillOf()), skillSupport: isSupport(selectedSkillOf()) }; + } + function setActive(c) { + if (c === active) return; + active = c; + selectedSkillId = null; + previewIndex = 0; + syncTargeting(); + renderList(); + renderControls(); + renderSkills(); + renderCustomize(); + loadPalette(); + rebuildScene(); + } + function setSelectedSkill(id) { + selectedSkillId = id; + syncTargeting(); + renderSkills(); + renderControls(); + renderCustomize(); + rebuildScene(); + } + function setPreviewIndex(i) { + previewIndex = i; + renderList(); + rebuildScene(); + } + function loadPalette() { + const ch = activeCharOf(); + if (!ch?.idle) { + palette = { anchor: null, swatches: [] }; + renderList(); + return; + } + spritePalette(ch.idle).then((p) => { + palette = p; + renderList(); + }).catch(() => { + palette = { anchor: null, swatches: [] }; + renderList(); + }); + } + const stage = mountSandboxStage(pixi, stageEl, { + input: { keys, req: req2 }, + targeting: () => targeting, + getSelectedSkillId: () => selectedSkillId, + targetName: "Acolyte", + state: stageState, + canvasTestId: "classes-canvas" + }); + function buildClassesBattle() { + const activeChar = activeCharOf(); + if (!activeChar) return null; + const attackType = attackTypeOf(); + const acolyte = chars.find((c) => c.slug === ACOLYTE_SLUG) || null; + const playerTarget = playerTargetOf(); + const anims = animsOf(activeChar); + const basicAnim = resolveAnim(anims, classCfgOf().attackAnim); + const skillCfg = config.skills?.[selectedSkillId] || {}; + const skillAnim = selectedSkillId ? resolveAnim(anims, skillCfg.anim) : null; + const skillEffectUrls = selectedSkillId ? resolveEffectUrls(skillAnim, stackOf(skillCfg, "effects")) : []; + const fxCellOf = (url) => fx.find((e) => e.url === url)?.cell || 32; + const ranged = attackType === "ranged"; + const baseTpl = CLASS_TEMPLATES[active]; + const tpl = baseTpl && { + ...baseTpl, + role: ranged ? "ranged" : "melee", + weapon: { ...baseTpl.weapon, range: ranged ? BOW_GW : BASIC_MELEE_GW, ...ranged ? { projSpeed: baseTpl.weapon.projSpeed || 800 } : {} }, + preferredRange: ranged ? baseTpl.preferredRange || 620 : void 0 + }; + const playerUnit = { name: active, profession: active, control: "player", template: tpl, skills: selectedSkillId ? [selectedSkillId] : [] }; + const acoUnit = () => ({ name: "Acolyte", control: "dummy", stats: { hp: ACO_MAXHP, armor: 0, basicDamage: 0 }, attackType: "melee" }); + const battle = makeTeamBattle({ players: [playerUnit], enemies: [acoUnit(), acoUnit(), acoUnit()], sandbox: true, freeCast: true }); + const defsById = { + P0: { + name: active, + profession: active, + idle: activeChar.idle, + walk: activeChar.walk, + attack: activeChar.attack || activeChar.idle, + dmg: activeChar.dmg, + die: activeChar.die, + recolor: playerTarget, + skillFx: selectedSkillId ? { [selectedSkillId]: { animUrl: skillAnim?.url || basicAnim?.url, effects: skillEffectUrls.map((url) => ({ url, cell: fxCellOf(url) })) } } : {} + } + }; + ["E0", "E1", "E2"].forEach((id) => { + defsById[id] = { name: "Acolyte", idle: acolyte?.idle, walk: acolyte?.walk || acolyte?.idle, attack: acolyte?.attack || acolyte?.idle, dmg: acolyte?.dmg, die: acolyte?.die, recolor: null }; + }); + return { battle, defsById }; + } + const rebuildScene = () => stage.rebuild(buildClassesBattle); + const keyMap = { w: [0, -1], s: [0, 1], a: [-1, 0], d: [1, 0], arrowup: [0, -1], arrowdown: [0, 1], arrowleft: [-1, 0], arrowright: [1, 0] }; + const keyset = {}; + const applyKeys = () => { + let x = 0, y = 0; + for (const k in keyset) if (keyset[k]) { + x += keyMap[k][0]; + y += keyMap[k][1]; + } + keys.x = Math.sign(x); + keys.y = Math.sign(y); + }; + const onDown = (e) => { + const tag = e.target?.tagName; + if (tag === "INPUT" || tag === "SELECT" || tag === "TEXTAREA") return; + if (e.code === "Space" || e.key === " ") { + req2.attack = true; + e.preventDefault(); + return; + } + if (e.key.toLowerCase() === "e") { + req2.skill = true; + e.preventDefault(); + return; + } + if (e.key.toLowerCase() === "f") { + req2.f = true; + e.preventDefault(); + return; + } + if (e.key === "`" || e.key === "~" || e.code === "Backquote") { + req2.reset = true; + e.preventDefault(); + return; + } + const k = e.key.toLowerCase(); + if (keyMap[k]) { + keyset[k] = true; + applyKeys(); + e.preventDefault(); + } + }; + const onUp = (e) => { + const k = e.key.toLowerCase(); + if (keyMap[k]) { + keyset[k] = false; + applyKeys(); + } + }; + window.addEventListener("keydown", onDown); + window.addEventListener("keyup", onUp); + syncTargeting(); + renderList(); + renderControls(); + renderSkills(); + renderCustomize(); + loadPalette(); + rebuildScene(); + return { + destroy() { + stage.destroy(); + window.removeEventListener("keydown", onDown); + window.removeEventListener("keyup", onUp); + host.replaceChildren(); + } + }; +} +export { + mountClassesSandbox +}; diff --git a/web/enemiesSandbox.js b/web/enemiesSandbox.js new file mode 100644 index 0000000000000000000000000000000000000000..bbefd1d1d7e47fd597bae9b95f329f12276ac24f --- /dev/null +++ b/web/enemiesSandbox.js @@ -0,0 +1,3181 @@ +// ../auto-battler/src/gw.js +var PROFESSION_BY_ID = [ + "Common", + "Warrior", + "Ranger", + "Monk", + "Necromancer", + "Mesmer", + "Elementalist", + "Assassin", + "Ritualist", + "Paragon", + "Dervish" +]; +var iconUrl = (id) => `/gw/skills/${id}.jpg`; +var COST_GLYPH = { + energy: "/gw/icons/tango-energy.png", + adrenaline: "/gw/icons/tango-adrenaline.png", + activation: "/gw/icons/tango-activation.png", + recharge: "/gw/icons/tango-recharge.png", + sacrifice: "/gw/icons/tango-sacrifice.png", + overcast: "/gw/icons/tango-overcast.png", + upkeep: "/gw/icons/tango-upkeep.png" +}; + +// ../auto-battler/src/engine/skills.js +var FIRST_15 = [ + // ── Warrior: adrenaline-fuelled condition → spike (Swordsmanship line) ── + { + id: 382, + name: "Sever Artery", + profession: "Warrior", + attribute: "Swordsmanship", + category: "melee_attack", + target: "foe", + cost: { adrenaline: 4 }, + cast: 0, + recharge: 0, + requires: ["on_hit"], + effects: [{ op: "apply_condition", condition: "bleeding", duration: { scale: [5, 25] } }] + }, + { + id: 384, + name: "Gash", + profession: "Warrior", + attribute: "Swordsmanship", + category: "melee_attack", + target: "foe", + cost: { adrenaline: 6 }, + cast: 0, + recharge: 0, + // The payoff: bonus damage + Deep Wound, but only on an already-Bleeding foe. + requires: ["on_hit", { target: "bleeding" }], + effects: [ + { op: "bonus_damage", amount: { scale: [5, 20] } }, + { op: "apply_condition", condition: "deepWound", duration: { scale: [5, 20] } } + ] + }, + { + id: 385, + name: "Final Thrust", + profession: "Warrior", + attribute: "Swordsmanship", + category: "melee_attack", + target: "foe", + cost: { adrenaline: 10 }, + cast: 0, + recharge: 0, + requires: ["on_hit"], + effects: [ + { op: "lose_all_adrenaline" }, + { op: "bonus_damage", amount: { scale: [1, 40] } }, + // "doubled if below 50%" — applying the same bonus a second time, gated. + { op: "bonus_damage", amount: { scale: [1, 40] }, if: { target_below_health: 0.5 } } + ] + }, + // ── Ranger: preparations + ranged conditions/interrupt ── + { + id: 435, + name: "Apply Poison", + profession: "Ranger", + attribute: "Wilderness Survival", + category: "preparation", + target: "self", + cost: { energy: 15 }, + cast: 2, + recharge: 12, + // The differentiator: a self rider — future physical attacks inflict Poison. + effects: [{ + op: "preparation", + duration: { fixed: 24 }, + on_attack: [{ op: "apply_condition", condition: "poison", duration: { scale: [3, 15] } }] + }] + }, + { + id: 391, + name: "Hunter's Shot", + profession: "Ranger", + attribute: "Marksmanship", + category: "bow_attack", + target: "foe", + cost: { energy: 5 }, + cast: 1, + recharge: 10, + requires: ["on_hit"], + effects: [{ op: "apply_condition", condition: "bleeding", duration: { scale: [3, 25] } }] + }, + { + id: 426, + name: "Savage Shot", + profession: "Ranger", + attribute: "Marksmanship", + category: "bow_attack", + target: "foe", + cost: { energy: 10 }, + cast: 0.5, + recharge: 5, + requires: ["on_hit"], + effects: [ + { op: "interrupt" }, + // Bonus only if the interrupted action was a spell. + { op: "bonus_damage", amount: { scale: [13, 28] }, if: { target: "casting_spell" } } + ] + }, + // ── Necromancer: trigger-hexes (the event bus / physway) ── + { + id: 121, + name: "Spiteful Spirit", + profession: "Necromancer", + attribute: "Curses", + category: "hex", + target: "foe", + cost: { energy: 15 }, + cast: 2, + recharge: 10, + elite: true, + effects: [{ + op: "hex", + duration: { scale: [8, 20] }, + trigger: "on_action", + payload: [{ op: "damage", damageType: "shadow", amount: { scale: [5, 35] }, scope: "target_and_adjacent" }] + }] + }, + { + id: 101, + name: "Barbs", + profession: "Necromancer", + attribute: "Curses", + category: "hex", + target: "foe", + cost: { energy: 10 }, + cast: 2, + recharge: 5, + effects: [{ + op: "hex", + duration: { fixed: 30 }, + // Passive amplifier — no discrete trigger; the damage pipeline reads it. + payload: [{ op: "amplify_damage", amount: { scale: [1, 15] }, vs: "physical" }] + }] + }, + { + id: 150, + name: "Mark of Pain", + profession: "Necromancer", + attribute: "Curses", + category: "hex", + target: "foe", + cost: { energy: 10 }, + cast: 1, + recharge: 20, + effects: [{ + op: "hex", + duration: { fixed: 30 }, + trigger: "on_physical_hit", + payload: [{ op: "damage", damageType: "shadow", amount: { scale: [10, 40] }, scope: "adjacent_to_target" }] + }] + }, + // ── Monk: the damage-interception pipeline ── + { + id: 245, + name: "Protective Spirit", + profession: "Monk", + attribute: "Protection Prayers", + category: "enchantment", + target: "ally", + cost: { energy: 10 }, + cast: 0.25, + recharge: 5, + effects: [{ + op: "enchant", + duration: { scale: [5, 23] }, + // Cap: a single hit can't remove more than 10% of max Health. + payload: [{ op: "cap_damage", maxFraction: 0.1 }] + }] + }, + { + id: 307, + name: "Reversal of Fortune", + profession: "Monk", + attribute: "Protection Prayers", + category: "enchantment", + target: "ally", + cost: { energy: 5 }, + cast: 0.25, + recharge: 2, + effects: [{ + op: "enchant", + duration: { fixed: 8 }, + charges: 1, + trigger: "on_incoming_damage", + payload: [{ op: "convert_damage_to_heal", cap: { scale: [15, 80] } }] + }] + }, + { + id: 1114, + name: "Spirit Bond", + profession: "Monk", + attribute: "Protection Prayers", + category: "enchantment", + target: "ally", + cost: { energy: 10 }, + cast: 0.25, + recharge: 2, + effects: [{ + op: "enchant", + duration: { fixed: 8 }, + charges: 10, + trigger: "on_incoming_damage", + threshold: { perHitDamageOver: 50 }, + payload: [{ op: "heal", amount: { scale: [30, 90] }, scope: "target" }] + }] + }, + // ── Assassin: the combo chain (lead → off-hand → dual) ── + { + id: 782, + name: "Jagged Strike", + profession: "Assassin", + attribute: "Dagger Mastery", + category: "lead_attack", + target: "foe", + cost: { energy: 5 }, + cast: 0.5, + recharge: 1, + requires: ["on_hit"], + effects: [ + { op: "apply_condition", condition: "bleeding", duration: { scale: [5, 20] } }, + { op: "set_combo_mark", stage: "lead" } + ] + }, + { + id: 780, + name: "Fox Fangs", + profession: "Assassin", + attribute: "Dagger Mastery", + category: "offhand_attack", + target: "foe", + cost: { energy: 5 }, + cast: 0.5, + recharge: 3, + requires: ["on_hit", { combo_follows: "lead" }], + effects: [ + { op: "bonus_damage", amount: { scale: [10, 35] }, unblockable: true }, + { op: "set_combo_mark", stage: "offhand" } + ] + }, + { + id: 775, + name: "Death Blossom", + profession: "Assassin", + attribute: "Dagger Mastery", + category: "dual_attack", + target: "foe", + cost: { energy: 5 }, + cast: 0, + recharge: 2, + requires: ["on_hit", { combo_follows: "offhand" }], + effects: [ + { op: "bonus_damage", amount: { scale: [20, 45] } }, + { op: "damage", amount: { scale: [20, 45] }, scope: "adjacent_to_target" } + ] + } +]; +var VARIANT_EXTRA = [ + // ── Warrior · Sentinel (soak / protect) ── + { + id: 348, + name: '"Watch Yourself!"', + profession: "Warrior", + attribute: "Tactics", + category: "shout", + target: "party", + cost: { adrenaline: 4 }, + cast: 0, + recharge: 4, + // Party armor for 10s, but the buff also ends after 10 incoming attacks. + effects: [{ op: "armor_mod", amount: { scale: [5, 25] }, duration: { fixed: 10 }, attacksLeft: 10, scope: "party" }] + }, + { + id: 372, + name: "Gladiator's Defense", + profession: "Warrior", + attribute: "Tactics", + category: "stance", + target: "self", + cost: { energy: 5 }, + cast: 0, + recharge: 30, + elite: true, + // 75% block; whoever you block in melee takes 5…35 back. + effects: [{ op: "block", chance: 0.75, vs: "melee", reflect: { scale: [5, 35] }, duration: { scale: [5, 11] } }] + }, + { + id: 1, + name: "Healing Signet", + profession: "Warrior", + attribute: "Tactics", + category: "signet", + target: "self", + cost: {}, + cast: 2, + recharge: 4, + effects: [{ op: "heal", amount: { scale: [82, 172] }, scope: "self" }], + whileActivating: [{ op: "armor_mod", amount: -40, duration: { fixed: 0 }, scope: "self" }] + // −40 armor while using + }, + // ── Warrior · Breaker (knockdown control) ── + { + id: 332, + name: "Bull's Strike", + profession: "Warrior", + attribute: "Strength", + category: "melee_attack", + target: "foe", + cost: { energy: 5 }, + cast: 0, + recharge: 10, + requires: ["on_hit", { target: "moving" }], + effects: [ + { op: "bonus_damage", amount: { scale: [5, 30] } }, + { op: "knockdown", duration: { fixed: 2 } } + ] + }, + { + id: 331, + name: "Hammer Bash", + profession: "Warrior", + attribute: "Hammer Mastery", + category: "melee_attack", + target: "foe", + cost: { adrenaline: 6 }, + cast: 0, + recharge: 0, + requires: ["on_hit"], + effects: [ + { op: "knockdown", duration: { fixed: 2 } }, + { op: "lose_all_adrenaline" } + ] + }, + { + id: 352, + name: "Crushing Blow", + profession: "Warrior", + attribute: "Hammer Mastery", + category: "melee_attack", + target: "foe", + cost: { energy: 5 }, + cast: 0, + recharge: 10, + requires: ["on_hit"], + effects: [ + { op: "bonus_damage", amount: { scale: [1, 20] } }, + { op: "apply_condition", condition: "deepWound", duration: { scale: [5, 20] }, if: { target: "knocked_down" } } + ] + }, + // ── Ranger · Sharpshooter (Punishing Shot completes the interrupt bar) ── + { + id: 409, + name: "Punishing Shot", + profession: "Ranger", + attribute: "Marksmanship", + category: "bow_attack", + target: "foe", + cost: { energy: 10 }, + cast: 0.5, + recharge: 5, + elite: true, + requires: ["on_hit"], + effects: [ + { op: "bonus_damage", amount: { scale: [10, 20] } }, + { op: "interrupt" } + ] + }, + // ── Ranger · Toxicologist (stacked degen) ── + { + id: 1470, + name: "Barbed Arrows", + profession: "Ranger", + attribute: "Wilderness Survival", + category: "preparation", + target: "self", + cost: { energy: 10 }, + cast: 2, + recharge: 12, + effects: [{ + op: "preparation", + duration: { fixed: 24 }, + on_attack: [{ op: "apply_condition", condition: "bleeding", duration: { scale: [3, 15] } }] + }], + whileActivating: [{ op: "armor_mod", amount: -40, duration: { fixed: 0 }, scope: "self" }] + // −40 armor while activating + }, + { + id: 1466, + name: "Burning Arrow", + profession: "Ranger", + attribute: "Marksmanship", + category: "bow_attack", + target: "foe", + cost: { energy: 10 }, + cast: 0, + recharge: 5, + elite: true, + requires: ["on_hit"], + effects: [ + { op: "bonus_damage", amount: { scale: [10, 30] } }, + { op: "apply_condition", condition: "burning", duration: { scale: [1, 7] } } + ] + }, + // ── Ranger · Survivalist (sustain / kite) ── + { + id: 446, + name: "Troll Unguent", + profession: "Ranger", + attribute: "Wilderness Survival", + category: "skill", + target: "self", + cost: { energy: 5 }, + cast: 3, + recharge: 10, + effects: [{ op: "regen_mod", pips: { scale: [3, 10] }, duration: { fixed: 13 }, scope: "self" }] + }, + { + id: 1727, + name: "Natural Stride", + profession: "Ranger", + attribute: "Wilderness Survival", + category: "stance", + target: "self", + cost: { energy: 5 }, + cast: 0, + recharge: 12, + // Move 33% faster + 50% block; the stance ends if you become hexed/enchanted. + effects: [ + { op: "move_speed", mult: 1.33, duration: { scale: [1, 8] }, endsOnHexEnchant: true }, + { op: "block", chance: 0.5, vs: "all", duration: { scale: [1, 8] }, endsOnHexEnchant: true } + ] + }, + { + id: 393, + name: "Crippling Shot", + profession: "Ranger", + attribute: "Marksmanship", + category: "bow_attack", + target: "foe", + cost: { energy: 10 }, + cast: 0, + recharge: 2, + elite: true, + requires: ["on_hit"], + effects: [{ op: "apply_condition", condition: "crippled", duration: { scale: [1, 12] }, unblockable: true }] + }, + // ── Necromancer · Vampire (life-steal sustain) ── + { + id: 153, + name: "Vampiric Gaze", + profession: "Necromancer", + attribute: "Blood Magic", + category: "spell", + target: "foe", + cost: { energy: 10 }, + cast: 1, + recharge: 8, + effects: [{ op: "life_steal", amount: { scale: [18, 60] } }] + }, + { + id: 109, + name: "Life Siphon", + profession: "Necromancer", + attribute: "Blood Magic", + category: "hex", + target: "foe", + cost: { energy: 10 }, + cast: 1, + recharge: 5, + effects: [ + { op: "regen_mod", pips: { scale: [-1, -3] }, duration: { scale: [12, 24] }, scope: "target" }, + { op: "regen_mod", pips: { scale: [1, 3] }, duration: { scale: [12, 24] }, scope: "self" } + ] + }, + { + id: 115, + name: "Blood Renewal", + profession: "Necromancer", + attribute: "Blood Magic", + category: "enchantment", + target: "self", + cost: { energy: 1, sacrifice: 15 }, + cast: 1, + recharge: 7, + // +3…6 regen for 7s, then a burst heal of 40…190 when the enchant ends. + effects: [ + { op: "regen_mod", pips: { scale: [3, 6] }, duration: { fixed: 7 }, scope: "self" }, + { op: "enchant", duration: { fixed: 7 }, trigger: "on_end", payload: [{ op: "heal", amount: { scale: [40, 190] }, scope: "self" }] } + ] + }, + // ── Necromancer · Plaguebearer (condition spread) ── + { + id: 118, + name: "Enfeebling Blood", + profession: "Necromancer", + attribute: "Curses", + category: "spell", + target: "foe", + cost: { energy: 1, sacrifice: 10 }, + cast: 1, + recharge: 8, + effects: [{ op: "apply_condition", condition: "weakness", duration: { scale: [5, 20] }, scope: "target_and_adjacent" }] + }, + { + id: 106, + name: "Rotting Flesh", + profession: "Necromancer", + attribute: "Death Magic", + category: "spell", + target: "foe", + cost: { energy: 15 }, + cast: 3, + recharge: 3, + effects: [{ op: "apply_condition", condition: "disease", duration: { scale: [10, 25] } }] + }, + { + id: 135, + name: "Faintheartedness", + profession: "Necromancer", + attribute: "Curses", + category: "hex", + target: "foe", + cost: { energy: 10 }, + cast: 1, + recharge: 8, + effects: [ + { op: "regen_mod", pips: { scale: [0, -3] }, duration: { scale: [3, 16] }, scope: "target" }, + { op: "attack_speed", mult: 2, duration: { scale: [3, 16] }, scope: "target" } + ] + }, + // ── Monk · Healer (raw healing) ── + { + id: 281, + name: "Orison of Healing", + profession: "Monk", + attribute: "Healing Prayers", + category: "spell", + target: "ally", + cost: { energy: 5 }, + cast: 1, + recharge: 2, + effects: [{ op: "heal", amount: { scale: [20, 70] }, scope: "target" }] + }, + { + id: 283, + name: "Dwayna's Kiss", + profession: "Monk", + attribute: "Healing Prayers", + category: "spell", + target: "other_ally", + cost: { energy: 5 }, + cast: 1, + recharge: 3, + // Heal, +10…35 more for each enchantment and hex on the target ally. + effects: [{ op: "heal", amount: { scale: [15, 60] }, scope: "target", plusPerMod: { kinds: ["enchant", "hex"], amount: { scale: [10, 35] } } }] + }, + { + id: 282, + name: "Word of Healing", + profession: "Monk", + attribute: "Healing Prayers", + category: "spell", + target: "ally", + cost: { energy: 5 }, + cast: 0.75, + recharge: 3, + elite: true, + // Conditional bonus first so the "<50% Health" check reads the pre-heal HP + // (the base heal below would otherwise lift the ally over the threshold). + effects: [ + { op: "heal", amount: { scale: [30, 115] }, scope: "target", if: { target_below_health: 0.5 } }, + { op: "heal", amount: { scale: [5, 100] }, scope: "target" } + ] + }, + // ── Monk · Smiter (holy offense) ── + { + id: 312, + name: "Holy Strike", + profession: "Monk", + attribute: "Smiting Prayers", + category: "spell", + target: "foe", + cost: { energy: 5 }, + cast: 0.75, + recharge: 8, + effects: [ + { op: "damage", damageType: "holy", amount: { scale: [10, 55] }, scope: "target" }, + { op: "damage", damageType: "holy", amount: { scale: [10, 55] }, scope: "target", if: { target: "knocked_down" } } + ] + }, + { + id: 240, + name: "Smite", + profession: "Monk", + attribute: "Smiting Prayers", + category: "spell", + target: "foe", + cost: { energy: 10 }, + cast: 1, + recharge: 10, + effects: [ + { op: "damage", damageType: "holy", amount: { scale: [10, 55] }, scope: "target_and_adjacent" }, + { op: "damage", damageType: "holy", amount: { scale: [10, 35] }, scope: "target_and_adjacent", if: { target: "attacking" } } + ] + }, + { + id: 252, + name: "Banish", + profession: "Monk", + attribute: "Smiting Prayers", + category: "spell", + target: "foe", + cost: { energy: 5 }, + cast: 1, + recharge: 10, + // Double vs summoned creatures — a no-op until summons exist, but recorded. + effects: [{ op: "damage", damageType: "holy", amount: { scale: [20, 56] }, scope: "target", vsSummoned: 2 }] + }, + // ── Assassin · Nightstalker (shadow-step burst) ── + { + id: 952, + name: "Death's Charge", + profession: "Assassin", + attribute: "Shadow Arts", + category: "skill", + target: "foe", + cost: { energy: 5 }, + cast: 0.25, + recharge: 30, + // Shadow-step to the foe; heal only if that foe has more Health than you. + effects: [ + { op: "shadow_step", to: "foe" }, + { op: "heal", amount: { scale: [65, 200] }, scope: "self", if: { target_health_above_self: true } } + ] + }, + { + id: 1024, + name: "Black Mantis Thrust", + profession: "Assassin", + attribute: "Deadly Arts", + category: "lead_attack", + target: "foe", + cost: { energy: 5 }, + cast: 1, + recharge: 6, + requires: ["on_hit"], + effects: [ + { op: "bonus_damage", amount: { scale: [8, 20] } }, + { op: "apply_condition", condition: "crippled", duration: { scale: [3, 15] }, if: { target: "hexed" } }, + { op: "set_combo_mark", stage: "lead" } + ] + }, + // ── Assassin · Saboteur (control / Deadly Arts) ── + { + id: 858, + name: "Dancing Daggers", + profession: "Assassin", + attribute: "Deadly Arts", + category: "spell", + target: "foe", + cost: { energy: 5 }, + cast: 1, + recharge: 5, + // Three earth projectiles, each 5…35; counts as a lead attack. + effects: [ + { op: "damage", damageType: "earth", amount: { scale: [5, 35] }, projectiles: 3, delivery: "projectile_spell", scope: "target" }, + { op: "set_combo_mark", stage: "lead" } + ] + }, + { + id: 784, + name: "Entangling Asp", + profession: "Assassin", + attribute: "Deadly Arts", + category: "spell", + target: "foe", + cost: { energy: 10 }, + cast: 1, + recharge: 20, + requires: [{ combo_follows: "lead" }], + effects: [ + { op: "knockdown", duration: { fixed: 2 } }, + { op: "apply_condition", condition: "poison", duration: { scale: [5, 20] } } + ] + }, + { + id: 988, + name: "Temple Strike", + profession: "Assassin", + attribute: "Dagger Mastery", + category: "offhand_attack", + target: "foe", + cost: { energy: 15 }, + cast: 0, + recharge: 20, + elite: true, + requires: ["on_hit", { combo_follows: "lead" }], + effects: [ + { op: "interrupt", if: { target: "casting_spell" } }, + // interrupts a spell + { op: "apply_condition", condition: "dazed", duration: { scale: [1, 10] } }, + { op: "apply_condition", condition: "blind", duration: { scale: [1, 10] } } + ] + } +]; +var CB_SKILLS = [...FIRST_15, ...VARIANT_EXTRA]; + +// ../auto-battler/src/engine/rng.js +function makeRng(seed) { + let a = seed >>> 0; + return function rng() { + a |= 0; + a = a + 1831565813 | 0; + let t = Math.imul(a ^ a >>> 15, 1 | a); + t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; + return ((t ^ t >>> 14) >>> 0) / 4294967296; + }; +} + +// ../auto-battler/src/engine/range.js +var MELEE_GW = 144; +var BASIC_MELEE_GW = MELEE_GW / 2; +var BOW_GW = 1e3; +var SPELL_GW = 1010; + +// ../auto-battler/src/engine/teamBattle.js +var byId = Object.fromEntries(CB_SKILLS.map((s) => [s.id, s])); +var skillById = (id) => byId[id] || null; +var FIELD = { w: 1e3, h: 600 }; +var FORMATION = [ + { x: 0.31, y: 0.66 }, + { x: 0.47, y: 0.74 }, + { x: 0.13, y: 0.73 }, + { x: 0.28, y: 0.82 }, + { x: 0.44, y: 0.91 } +]; +var HIT_TOLERANCE = 130; +function val(v, rank = 12) { + if (typeof v === "number") return v; + if (v == null) return 0; + if (v.fixed != null) return v.fixed; + if (v.scale) { + const [a, b] = v.scale; + return Math.round(a + (b - a) * rank / 15); + } + return 0; +} +var DEGEN = { bleeding: 4, poison: 4, burning: 8, disease: 4 }; +var ATTACK_CATEGORIES = ["melee_attack", "bow_attack", "lead_attack", "offhand_attack", "dual_attack", "scythe_attack", "spear_attack"]; +var isAttack = (s) => ATTACK_CATEGORIES.includes(s.category); +var CLASS_TEMPLATES = { + Warrior: { maxHp: 520, role: "melee", weapon: { min: 15, max: 22, interval: 1.5, range: BASIC_MELEE_GW }, moveSpeed: 130, maxEnergy: 25, energyRegen: 0.5, armor: 80 }, + Assassin: { maxHp: 480, role: "melee", weapon: { min: 7, max: 17, interval: 1.33, range: BASIC_MELEE_GW }, moveSpeed: 175, maxEnergy: 30, energyRegen: 1.2, armor: 55 }, + Ranger: { maxHp: 430, role: "ranged", weapon: { min: 12, max: 28, interval: 1.9, range: BOW_GW, projSpeed: 850 }, moveSpeed: 155, preferredRange: 620, maxEnergy: 35, energyRegen: 1, armor: 45 }, + Monk: { maxHp: 470, role: "melee", weapon: { min: 8, max: 14, interval: 1.6, range: BASIC_MELEE_GW }, moveSpeed: 140, maxEnergy: 40, energyRegen: 1.4, armor: 60 }, + Necromancer: { maxHp: 450, role: "ranged", weapon: { min: 10, max: 20, interval: 1.8, range: BOW_GW, projSpeed: 720 }, moveSpeed: 140, preferredRange: 520, maxEnergy: 35, energyRegen: 1, armor: 45 } +}; +var DEFAULT_TEMPLATE = { maxHp: 300, role: "melee", weapon: { min: 10, max: 16, interval: 1.5, range: BASIC_MELEE_GW }, moveSpeed: 150, maxEnergy: 30, energyRegen: 1, armor: 50 }; +function templateFor(unit) { + if (unit.template) return unit.template; + if (unit.profession && CLASS_TEMPLATES[unit.profession]) return CLASS_TEMPLATES[unit.profession]; + if (unit.stats) { + const s = unit.stats; + const basic = s.basicDamage ?? 12; + const ranged = unit.attackType === "ranged"; + return { + maxHp: s.hp ?? 100, + role: ranged ? "ranged" : "melee", + armor: s.armor ?? 40, + moveSpeed: 150, + maxEnergy: 30, + energyRegen: 1, + preferredRange: ranged ? 600 : void 0, + weapon: { + min: Math.max(1, Math.round(basic * 0.8)), + max: Math.round(basic * 1.3), + interval: 1.4, + range: ranged ? BOW_GW : BASIC_MELEE_GW, + ...ranged ? { projSpeed: 800 } : {} + } + }; + } + return DEFAULT_TEMPLATE; +} +function makeActor(unit, team, id, slot) { + const tpl = templateFor(unit); + const p = FORMATION[slot % FORMATION.length]; + const pt = team === "player" ? { x: p.x, y: p.y } : { x: 1 - p.x, y: 1 - p.y }; + const bar = (unit.skills || []).map(skillById).filter(Boolean); + return { + id, + team, + name: unit.name || id, + profession: unit.profession || null, + // control: 'ai' (autonomous), 'player' (driven by b.input via setInput) or + // 'dummy' (passive target — takes damage, never acts). Sandboxes use the + // latter two so the Classes/Enemies hero fights real engine dummies. + control: unit.control || "ai", + role: tpl.role, + rank: unit.rank ?? 12, + armor: tpl.armor ?? 0, + weapon: { ...tpl.weapon }, + moveSpeed: tpl.moveSpeed, + preferredRange: tpl.preferredRange, + radius: radiusOf(unit, tpl), + maxEnergy: tpl.maxEnergy, + energyRegen: tpl.energyRegen, + baseMaxHp: tpl.maxHp, + maxHp: tpl.maxHp, + hp: tpl.maxHp, + energy: tpl.maxEnergy, + adrenaline: 0, + bar, + x: pt.x * FIELD.w, + y: pt.y * FIELD.h, + facing: team === "player" ? 1 : -1, + faceX: team === "player" ? 1 : -1, + faceY: team === "player" ? -1 : 1, + // players look up-right, enemies down-left + attackTimer: tpl.weapon.interval, + casting: null, + recharge: {}, + conds: [], + marks: {}, + prep: null, + alive: true, + mods: [], + kd: 0 + }; +} +function makeTeamBattle({ seed = 1, players = [], enemies = [], sandbox = false, respawnDummies = 0, freeCast = false } = {}) { + const actors = []; + players.filter(Boolean).slice(0, 5).forEach((u, i) => actors.push(makeActor(u, "player", `P${i}`, i))); + enemies.filter(Boolean).slice(0, 5).forEach((u, i) => actors.push(makeActor(u, "enemy", `E${i}`, i))); + return { t: 0, rng: makeRng(seed), actors, projectiles: [], log: [], over: false, winner: null, sandbox, respawnDummies, freeCast, input: {} }; +} +function setInput(b, id, cmd) { + if (!b.input) b.input = {}; + b.input[id] = { ...b.input[id] || {}, ...cmd }; +} +var ADJACENT_GW = 140; +var BODY_RADIUS = { melee: 35, ranged: 32 }; +var DEFAULT_RADIUS = 32; +var DEOVERLAP_ITERS = 3; +var DEOVERLAP_FRACTION = 0.5; +var CONTACT_SLOP = 2; +var MAX_BATTLE_T = 90; +var COLLISION_Y_WEIGHT = 3.2; +var radiusOf = (unit, tpl) => unit.template?.radius ?? unit.stats?.radius ?? tpl.radius ?? BODY_RADIUS[tpl.role] ?? DEFAULT_RADIUS; +var edgeGap = (a, t) => dist(a, t) - (a.radius || 0) - (t.radius || 0); +var MELEE_REACH = 2; +var reachOf = (a) => a.role === "ranged" ? a.weapon.range : MELEE_REACH; +var SPELL_RANGE = 900; +var ARMOR_IGNORING = /* @__PURE__ */ new Set(["shadow", "holy", "dark", "chaos"]); +var dist = (a, e) => Math.hypot(a.x - e.x, a.y - e.y); +var hasCond = (a, type) => a.conds.some((c) => c.type === type); +var isKd = (b, a) => a.kd > b.t; +var gainAdr = (a, n) => { + a.adrenaline = Math.min(25, a.adrenaline + n); +}; +var livingFoes = (b, a) => b.actors.filter((x) => x.alive && x.team !== a.team); +var alliesOf = (b, a) => b.actors.filter((x) => x.alive && x.team === a.team); +function nearestFoe(b, a) { + let best = null, bd = Infinity; + for (const x of livingFoes(b, a)) { + const d = dist(a, x); + if (d < bd) { + bd = d; + best = x; + } + } + return best; +} +function mostWoundedAlly(b, a, includeSelf = true) { + let best = null, bf = Infinity; + for (const x of alliesOf(b, a)) { + if (!includeSelf && x === a) continue; + const f = x.hp / x.maxHp; + if (f < bf) { + bf = f; + best = x; + } + } + return best; +} +var adjacentTo = (b, tgt) => b.actors.filter((x) => x.alive && x.team === tgt.team && x !== tgt && dist(x, tgt) <= ADJACENT_GW); +var addMod = (b, a, m) => a.mods.push({ until: b.t + (m.dur ?? 0), ...m }); +var activeMods = (b, a, kind) => a.mods.filter((m) => m.kind === kind && m.until > b.t); +var hasModKind = (b, a, kind) => a.mods.some((m) => m.kind === kind && m.until > b.t); +var sumRegenPips = (b, a) => activeMods(b, a, "regen").reduce((n, m) => n + m.pips, 0); +var bonusArmor = (b, a) => activeMods(b, a, "armor").reduce((n, m) => n + m.amount, 0); +var attackSpeedMult = (b, a) => activeMods(b, a, "attackSpeed").reduce((n, m) => n * m.mult, 1); +var moveSpeedMult = (b, a) => (hasCond(a, "crippled") ? 0.5 : 1) * activeMods(b, a, "moveSpeed").reduce((n, m) => n * m.mult, 1); +var hasHex = (b, a) => hasModKind(b, a, "hex"); +var countCat = (b, a, kinds) => a.mods.filter((m) => m.until > b.t && kinds.includes(m.cat)).length; +var mitigate = (dmg, armor) => dmg * (100 / (100 + (armor || 0))); +function log(b, kind, who, extra = {}) { + b.log.push({ t: Math.round(b.t * 100) / 100, kind, who: who?.id, ...extra }); +} +function applyCondition(b, tgt, type, dur, empowered) { + if (!tgt.alive) return; + const ex = tgt.conds.find((c) => c.type === type); + if (ex) { + ex.until = Math.max(ex.until, b.t + dur); + return; + } + tgt.conds.push({ type, until: b.t + dur }); + if (type === "deepWound") { + tgt.maxHp = Math.round(tgt.baseMaxHp * 0.8); + if (tgt.hp > tgt.maxHp) tgt.hp = tgt.maxHp; + } + log(b, "cond", tgt, { cond: type, amount: Math.round(dur), ...empowered ? { empowered: true } : {} }); +} +function expireConds(b, a) { + for (const c of a.conds) if (c.until <= b.t && c.type === "deepWound") a.maxHp = a.baseMaxHp; + a.conds = a.conds.filter((c) => c.until > b.t); + for (const m of a.mods) if (m.kind === "onEnd" && m.until <= b.t && !m.fired) { + m.fired = true; + const src = b.actors.find((x) => x.id === m.srcId) || a; + for (const e of m.payload || []) applyEffect(b, src, a, e, "spell"); + } + a.mods = a.mods.filter((m) => m.until > b.t); +} +function healActor(b, a, amount, empowered) { + if (!a.alive || amount <= 0) return; + a.hp = Math.min(a.maxHp, a.hp + Math.round(amount)); + log(b, "heal", a, { amount: Math.round(amount), ...empowered ? { empowered: true } : {} }); +} +function dealDamage(b, src, tgt, amount, label, opts = {}) { + if (!tgt.alive) return 0; + const { damageType = "physical", delivery = "spell", armorIgnoring = false, empowered = false } = opts; + const physical = delivery === "melee" || delivery === "projectile"; + if (physical) { + const blk = blockRoll(b, tgt, delivery); + if (blk) { + if (blk.reflect && delivery === "melee") dealDamage(b, tgt, src, blk.reflect, "reflect", { delivery: "spell", armorIgnoring: true }); + log(b, "miss", tgt, { name: label }); + return 0; + } + } + let dmg = amount; + if (physical) { + for (const m of activeMods(b, tgt, "amplify")) if (m.vs === "physical") dmg += m.amount; + } + if (!(armorIgnoring || ARMOR_IGNORING.has(damageType))) dmg = mitigate(dmg, tgt.armor + bonusArmor(b, tgt)); + const cap = activeMods(b, tgt, "cap")[0]; + if (cap) dmg = Math.min(dmg, cap.fraction * tgt.maxHp); + const conv = activeMods(b, tgt, "convert").find((m) => m.charges > 0); + if (conv) { + healActor(b, tgt, Math.min(dmg, conv.cap)); + conv.charges--; + dmg = 0; + } + for (const m of activeMods(b, tgt, "onIncomingHeal")) { + if (m.charges > 0 && dmg > m.threshold) { + healActor(b, tgt, m.amount); + m.charges--; + } + } + dmg = Math.max(0, Math.round(dmg)); + tgt.hp -= dmg; + log(b, "hit", tgt, { src: src.id, amount: dmg, name: label, ...empowered ? { empowered: true } : {} }); + gainAdr(tgt, 1); + if (physical) { + for (const m of activeMods(b, tgt, "armor")) if (m.attacksLeft != null && --m.attacksLeft <= 0) m.until = b.t; + fireTrigger(b, tgt, "onPhysicalHit"); + } + if (tgt.hp <= 0) kill(b, tgt); + return dmg; +} +function blockRoll(b, tgt, delivery) { + for (const m of activeMods(b, tgt, "block")) { + if (m.vs === "all" || m.vs === delivery) { + if (b.rng() < m.chance) return m; + } + } + return null; +} +function kill(b, a) { + if (!a.alive) return; + a.alive = false; + a.hp = 0; + a.deadAt = b.t; + log(b, "death", a); + if (hasCond(a, "disease")) for (const x of adjacentTo(b, a)) applyCondition(b, x, "disease", 10); +} +function applyContainer(b, src, tgt, e) { + const dur = val(e.duration, src.rank); + const cat = e.op; + const p = e.payload?.[0] || {}; + if (e.op === "hex") addMod(b, tgt, { kind: "hex", cat, dur }); + for (const m of tgt.mods) if (m.endsOnHexEnchant && m.until > b.t) m.until = b.t; + if (p.op === "amplify_damage") { + addMod(b, tgt, { kind: "amplify", cat, vs: p.vs || "physical", amount: val(p.amount, src.rank), dur }); + return; + } + if (p.op === "cap_damage") { + addMod(b, tgt, { kind: "cap", cat, fraction: p.maxFraction, dur }); + return; + } + if (p.op === "convert_damage_to_heal") { + addMod(b, tgt, { kind: "convert", cat, cap: val(p.cap, src.rank), charges: e.charges ?? 1, dur }); + return; + } + if (e.trigger === "on_end") { + addMod(b, tgt, { kind: "onEnd", cat, payload: e.payload, srcId: src.id, srcRank: src.rank, dur }); + return; + } + if (e.trigger === "on_incoming_damage" && p.op === "heal") { + addMod(b, tgt, { kind: "onIncomingHeal", cat, amount: val(p.amount, src.rank), threshold: e.threshold?.perHitDamageOver ?? 0, charges: e.charges ?? 1, dur }); + return; + } + if (e.trigger === "on_action") { + addMod(b, tgt, { kind: "onAction", cat, payload: e.payload, srcId: src.id, srcRank: src.rank, dur }); + return; + } + if (e.trigger === "on_physical_hit") { + addMod(b, tgt, { kind: "onPhysicalHit", cat, payload: e.payload, srcId: src.id, srcRank: src.rank, dur }); + return; + } + addMod(b, tgt, { kind: "enchant", cat, dur }); +} +function fireTrigger(b, a, kind) { + for (const m of activeMods(b, a, kind)) { + const fakeSrc = b.actors.find((x) => x.id === m.srcId) || { id: m.srcId, rank: m.srcRank }; + for (const e of m.payload || []) applyEffect(b, fakeSrc, a, e, "spell"); + } +} +function resolveScope(b, src, tgt, scope) { + switch (scope) { + case "self": + return [src]; + case "party": + return alliesOf(b, src); + case "target_and_adjacent": + return [tgt, ...adjacentTo(b, tgt)]; + case "adjacent_to_target": + return adjacentTo(b, tgt); + case "nearby": + case "area": + return [tgt, ...adjacentTo(b, tgt)]; + default: + return [tgt]; + } +} +function applyEffect(b, src, tgt, e, delivery = "spell", s = null) { + if (e.if && !branchOk(b, e.if, src, tgt)) return; + if (e.if && s) logEmpower(b, src, tgt, s, empowerLabel(e, src.rank), reasonOf(e.if)); + const emp = !!e.if; + const targets = resolveScope(b, src, tgt, e.scope); + for (const t of targets) { + if (!t || !t.alive) continue; + const dur = e.duration != null ? val(e.duration, src.rank) : 0; + switch (e.op) { + case "damage": { + const amt = val(e.amount, src.rank); + const n = e.projectiles || 0; + if (n > 1 || e.delivery === "projectile_spell") fireSpellProjectiles(b, src, t, amt, e, n || 1); + else dealDamage(b, src, t, amt, src.name || "spell", { damageType: e.damageType, delivery, empowered: emp }); + break; + } + case "life_steal": { + const dealt = dealDamage(b, src, t, val(e.amount, src.rank), src.name || "steal", { delivery, armorIgnoring: true }); + healActor(b, src, dealt); + break; + } + case "heal": { + let amt = val(e.amount, src.rank); + let scaled = 0; + if (e.plusPerMod) { + scaled = countCat(b, t, e.plusPerMod.kinds) * val(e.plusPerMod.amount, src.rank); + amt += scaled; + } + if (s && scaled > 0) logEmpower(b, src, t, s, `+${scaled} heal`, `${countCat(b, t, e.plusPerMod.kinds)} effects`); + healActor(b, t, amt, emp || scaled > 0); + break; + } + case "apply_condition": + applyCondition(b, t, e.condition, dur, emp); + break; + case "knockdown": + t.kd = Math.max(t.kd, b.t + dur); + t.casting = null; + break; + case "interrupt": + if (t.casting) { + t.casting = null; + log(b, "cond", t, { cond: "interrupted", amount: 0, ...emp ? { empowered: true } : {} }); + } + break; + case "regen_mod": + addMod(b, t, { kind: "regen", pips: val(e.pips, src.rank), dur }); + break; + case "attack_speed": + addMod(b, t, { kind: "attackSpeed", mult: e.mult, dur }); + break; + case "armor_mod": + addMod(b, t, { kind: "armor", amount: val(e.amount, src.rank), attacksLeft: e.attacksLeft, dur }); + break; + case "move_speed": + addMod(b, src, { kind: "moveSpeed", mult: e.mult, endsOnHexEnchant: e.endsOnHexEnchant, dur }); + break; + case "block": + addMod(b, src, { kind: "block", chance: e.chance, vs: e.vs || "all", reflect: e.reflect ? val(e.reflect, src.rank) : 0, endsOnHexEnchant: e.endsOnHexEnchant, dur }); + break; + case "shadow_step": + shadowStep(b, src, t); + break; + case "set_combo_mark": + src.marks[t.id] = { stage: e.stage, until: b.t + 20 }; + break; + case "lose_all_adrenaline": + src.adrenaline = 0; + break; + case "preparation": + src.prep = { on_attack: e.on_attack, until: b.t + dur }; + break; + case "hex": + case "enchant": + applyContainer(b, src, t, e); + break; + default: + break; + } + } +} +function shadowStep(b, a, tgt) { + const dx = a.x - tgt.x, dy = a.y - tgt.y, len = Math.hypot(dx, dy) || 1; + a.x = Math.max(0, Math.min(FIELD.w, tgt.x + dx / len * (BASIC_MELEE_GW * 0.8))); + a.y = Math.max(0, Math.min(FIELD.h, tgt.y + dy / len * (BASIC_MELEE_GW * 0.8))); +} +function fireSpellProjectiles(b, src, tgt, amt, e, n) { + const base = dist(src, tgt) / 900; + for (let i = 0; i < n; i++) { + b.projectiles.push({ + srcId: src.id, + tgtId: tgt.id, + aimX: tgt.x, + aimY: tgt.y, + bornT: b.t, + hitT: b.t + base + i * 0.1, + spell: true, + amount: amt, + damageType: e.damageType, + label: src.name || "spell" + }); + } + log(b, "shoot", src, { name: src.name }); +} +function branchOk(b, req2, a, tgt) { + if (req2.target_below_health != null) return tgt.hp / tgt.maxHp < req2.target_below_health; + if (req2.target_health_above_self) return tgt.hp > a.hp; + if (req2.target === "bleeding") return hasCond(tgt, "bleeding"); + if (req2.target === "casting_spell") return !!tgt.casting; + if (req2.target === "moving") return !!tgt.moving; + if (req2.target === "knocked_down") return isKd(b, tgt); + if (req2.target === "hexed") return hasHex(b, tgt); + if (req2.target === "attacking") return tgt.attackedAt != null && b.t - tgt.attackedAt < 1.2; + if (req2.self === "enchanted") return hasModKind(b, a, "cap") || hasModKind(b, a, "convert"); + return true; +} +function reasonOf(req2) { + if (!req2) return ""; + if (req2.target_below_health != null) return `foe <${req2.target_below_health * 100}%`; + if (req2.target_health_above_self) return "foe has more HP"; + if (req2.target === "bleeding") return "foe Bleeding"; + if (req2.target === "casting_spell") return "foe casting"; + if (req2.target === "moving") return "foe moving"; + if (req2.target === "knocked_down") return "knocked down"; + if (req2.target === "hexed") return "foe hexed"; + if (req2.target === "attacking") return "foe attacking"; + return ""; +} +function empowerLabel(e, rank) { + switch (e.op) { + case "bonus_damage": + case "damage": + return `+${val(e.amount, rank)} dmg`; + case "apply_condition": + return `+${e.condition}`; + case "heal": + return `+${val(e.amount, rank)} heal`; + case "interrupt": + return "INTERRUPT"; + default: + return "bonus"; + } +} +function logEmpower(b, src, tgt, s, label, reason) { + log(b, "empower", src, { tgt: tgt?.id, skillId: s?.id, name: s?.name, label, reason }); +} +function strike(b, a, enemy, s) { + a.attackTimer = a.weapon.interval * attackSpeedMult(b, a); + a.attackedAt = b.t; + if (hasCond(a, "blind") && b.rng() < 0.9) { + if (a.role !== "ranged") log(b, "swing", a, { name: s ? s.name : "attack" }); + log(b, "miss", enemy, { name: s ? s.name : "attack" }); + return; + } + let weaponDmg = a.weapon.min + b.rng() * (a.weapon.max - a.weapon.min); + if (hasCond(a, "weakness")) weaponDmg *= 0.75; + let bonus = 0, empEffect = null; + if (s) { + for (const e of s.effects) if (e.op === "bonus_damage" && (!e.if || branchOk(b, e.if, a, enemy))) { + bonus += val(e.amount, a.rank); + if (e.if) empEffect = e; + } + } + if (a.role === "ranged") { + const flight = dist(a, enemy) / (a.weapon.projSpeed || 800); + b.projectiles.push({ srcId: a.id, tgtId: enemy.id, fromX: a.x, fromY: a.y, aimX: enemy.x, aimY: enemy.y, bornT: b.t, hitT: b.t + flight, weaponDmg, bonus, s, empEffect }); + log(b, "shoot", a, { name: s ? s.name : "shot", skillId: s?.id }); + } else { + log(b, "swing", a, { name: s ? s.name : "attack" }); + applyHit(b, a, enemy, s, weaponDmg, bonus, empEffect); + } +} +function applyHit(b, a, enemy, s, weaponDmg, bonus, empEffect = null) { + if (!enemy.alive) return; + const delivery = a.role === "ranged" ? "projectile" : "melee"; + if (empEffect) logEmpower(b, a, enemy, s, empowerLabel(empEffect, a.rank), reasonOf(empEffect.if)); + dealDamage(b, a, enemy, weaponDmg + bonus, s ? s.name : "attack", { delivery, empowered: !!empEffect }); + if (s) { + for (const e of s.effects) { + if (e.op === "bonus_damage") continue; + if (e.if && !branchOk(b, e.if, a, enemy)) continue; + const emp = !!e.if; + if (emp && e.op !== "damage") logEmpower(b, a, enemy, s, empowerLabel(e, a.rank), reasonOf(e.if)); + switch (e.op) { + case "apply_condition": + for (const t of resolveScope(b, a, enemy, e.scope)) applyCondition(b, t, e.condition, val(e.duration, a.rank), emp); + break; + case "set_combo_mark": + a.marks[enemy.id] = { stage: e.stage, until: b.t + 20 }; + break; + case "lose_all_adrenaline": + a.adrenaline = 0; + break; + case "knockdown": + enemy.kd = Math.max(enemy.kd, b.t + val(e.duration, a.rank)); + enemy.casting = null; + break; + case "interrupt": + if (enemy.casting) { + enemy.casting = null; + log(b, "cond", enemy, { cond: "interrupted", amount: 0, ...emp ? { empowered: true } : {} }); + } + break; + case "damage": + applyEffect(b, a, enemy, e, "melee", s); + break; + default: + break; + } + } + if (s.category === "dual_attack") delete a.marks[enemy.id]; + } + if (a.prep && a.prep.until > b.t) for (const e of a.prep.on_attack) { + if (e.op === "apply_condition") applyCondition(b, enemy, e.condition, val(e.duration, a.rank)); + } + gainAdr(a, 1); +} +function advanceProjectiles(b) { + const live = []; + for (const p of b.projectiles) { + if (b.t < p.hitT) { + live.push(p); + continue; + } + const src = b.actors.find((x) => x.id === p.srcId); + const tgt = b.actors.find((x) => x.id === p.tgtId); + if (!src || !tgt || !tgt.alive) continue; + if (Math.hypot(tgt.x - p.aimX, tgt.y - p.aimY) > HIT_TOLERANCE) { + log(b, "miss", tgt, { name: p.label || p.s?.name || "shot" }); + continue; + } + if (p.spell) dealDamage(b, src, tgt, p.amount, p.label, { damageType: p.damageType, delivery: "spell" }); + else applyHit(b, src, tgt, p.s, p.weaponDmg, p.bonus, p.empEffect); + } + b.projectiles = live; +} +var fireOnAction = (b, a) => fireTrigger(b, a, "onAction"); +function applyActivationPenalty(b, a, s, cast) { + for (const e of s.whileActivating || []) { + if (e.op === "armor_mod") addMod(b, a, { kind: "armor", amount: val(e.amount, a.rank), dur: cast }); + } +} +var COMBO_STAGE = { lead_attack: "lead", offhand_attack: "offhand", dual_attack: "dual" }; +function performSkill(b, a, tgt, s) { + if (s.cost?.energy) a.energy -= s.cost.energy; + if (s.cost?.adrenaline) a.adrenaline -= s.cost.adrenaline; + if (s.cost?.sacrifice) a.hp = Math.max(1, a.hp - Math.round(a.maxHp * s.cost.sacrifice / 100)); + log(b, "cast", a, { name: s.name, elite: !!s.elite, skillId: s.id, tgt: tgt.id, ...COMBO_STAGE[s.category] ? { combo: COMBO_STAGE[s.category] } : {} }); + a.recharge[s.name] = b.t + (s.recharge || 0); + fireOnAction(b, a); + if (isAttack(s)) { + strike(b, a, tgt, s); + return; + } + for (const e of s.effects) applyEffect(b, a, tgt, e, "spell", s); +} +var isSupport = (s) => !!s && ["self", "ally", "other_ally", "party"].includes(s.target); +var enchantModKind = (e) => { + const p = e.payload?.[0] || {}; + if (p.op === "cap_damage") return "cap"; + if (p.op === "convert_damage_to_heal") return "convert"; + if (e.trigger === "on_incoming_damage") return "onIncomingHeal"; + return null; +}; +function skillTarget(b, a, s, foe) { + if (s.target === "self" || s.target === "party") return a; + if (s.target === "ally") return mostWoundedAlly(b, a, true); + if (s.target === "other_ally") return mostWoundedAlly(b, a, false); + return foe; +} +function usable(b, a, s, tgt, foe, free = false) { + if (!tgt) return false; + if (free) return true; + if (b.t < (a.recharge[s.name] || 0)) return false; + if (s.cost?.energy && a.energy < s.cost.energy) return false; + if (s.cost?.adrenaline && a.adrenaline < s.cost.adrenaline) return false; + if (isAttack(s) && edgeGap(a, foe) > reachOf(a)) return false; + if (!isAttack(s) && !isSupport(s) && dist(a, foe) > SPELL_RANGE) return false; + for (const r of s.requires || []) { + if (r === "on_hit") continue; + if (r.combo_follows && a.marks[foe.id]?.stage !== r.combo_follows) return false; + if (r.target === "bleeding" && !hasCond(foe, "bleeding")) return false; + if (r.target === "casting_spell" && !foe.casting) return false; + if (r.target === "moving" && !foe.moving) return false; + if (r.target === "knocked_down" && !isKd(b, foe)) return false; + } + if (s.effects.some((e) => e.op === "preparation") && a.prep && a.prep.until > b.t) return false; + if (isSupport(s)) { + if (s.effects.some((e) => e.op === "heal") && tgt.hp / tgt.maxHp >= 0.7 && s.effects.every((e) => e.op === "heal" || e.op === "shadow_step")) return false; + for (const e of s.effects) { + if (e.op === "enchant") { + const k = enchantModKind(e); + if (k && hasModKind(b, tgt, k)) return false; + } + const selfBuffKind = { block: "block", armor_mod: "armor", regen_mod: "regen", attack_speed: "attackSpeed", move_speed: "moveSpeed" }[e.op]; + if (selfBuffKind && hasModKind(b, a, selfBuffKind)) return false; + } + } else { + const meaningful = s.effects.filter((e) => e.op !== "set_combo_mark" && e.op !== "preparation"); + if (meaningful.length && meaningful.every((e) => e.op === "apply_condition" && hasCond(foe, e.condition))) return false; + } + return true; +} +function chooseAction(b, a, foe) { + for (const s of a.bar) { + const tgt = skillTarget(b, a, s, foe); + if (usable(b, a, s, tgt, foe)) return { skill: s, target: tgt }; + } + return null; +} +function moveActor(b, a, enemy, dt) { + const d = dist(a, enemy); + let toward = 0; + if (a.role === "ranged") { + if (d < (a.preferredRange ?? a.weapon.range * 0.7)) toward = -1; + else if (d > a.weapon.range) toward = 1; + } else if (edgeGap(a, enemy) > reachOf(a)) { + toward = 1; + } + if (!toward) { + a.vx = 0; + a.vy = 0; + return; + } + const speed = a.moveSpeed * moveSpeedMult(b, a); + const dx = enemy.x - a.x, dy = enemy.y - a.y, len = Math.hypot(dx, dy) || 1; + const desVx = dx / len * speed * toward, desVy = dy / len * speed * toward; + const [vx, vy] = avoidVelocity(b, a, enemy, desVx, desVy, speed); + a.x = clampField(a.x + vx * dt, a.radius, FIELD.w); + a.y = clampField(a.y + vy * dt, a.radius, FIELD.h); + a.vx = vx; + a.vy = vy; + a.moving = true; +} +var RVO_TAU = 1.6; +var RVO_RANGE = 280; +var RVO_W = 240; +var RVO_ANGLES = [0, 0.3, -0.3, 0.62, -0.62, 0.98, -0.98, 1.4, -1.4]; +var RVO_SPEEDS = [1, 0.6]; +function avoidVelocity(b, a, enemy, desVx, desVy, speed) { + const KY = COLLISION_Y_WEIGHT; + const obs = []; + for (const o of b.actors) { + if (o === a || !o.alive || o === enemy) continue; + const rpx = o.x - a.x, rpy = (o.y - a.y) * KY; + if (rpx * rpx + rpy * rpy > RVO_RANGE * RVO_RANGE) continue; + obs.push({ rpx, rpy, ovx: o.vx || 0, ovy: (o.vy || 0) * KY, R: a.radius + o.radius }); + } + if (!obs.length) return [desVx, desVy]; + const baseAng = Math.atan2(desVy, desVx); + let best = [desVx, desVy], bestPen = Infinity; + for (const da of RVO_ANGLES) { + const ang = baseAng + da, cs = Math.cos(ang), sn = Math.sin(ang); + for (const sf of RVO_SPEEDS) { + const cvx = cs * speed * sf, cvy = sn * speed * sf; + const cvxw = cvx, cvyw = cvy * KY; + let minTtc = Infinity; + for (const o of obs) { + const t = timeToHit(o.rpx, o.rpy, o.ovx - cvxw, o.ovy - cvyw, o.R); + if (t < minTtc) minTtc = t; + } + const collPen = minTtc <= RVO_TAU ? RVO_W / Math.max(minTtc, 0.05) : 0; + const dev = Math.hypot(cvx - desVx, cvy - desVy); + const pen = collPen + dev; + if (pen < bestPen) { + bestPen = pen; + best = [cvx, cvy]; + } + } + } + return best; +} +function timeToHit(px, py, rvx, rvy, R) { + const c = px * px + py * py - R * R; + if (c <= 0) return 0; + const a2 = rvx * rvx + rvy * rvy; + if (a2 < 1e-6) return Infinity; + const b2 = px * rvx + py * rvy; + if (b2 >= 0) return Infinity; + const disc = b2 * b2 - a2 * c; + if (disc <= 0) return Infinity; + return (-b2 - Math.sqrt(disc)) / a2; +} +var clampField = (v, r, max) => Math.max(r, Math.min(max - r, v)); +function resolveOverlaps(b) { + const live = b.actors.filter((a) => a.alive); + for (let it = 0; it < DEOVERLAP_ITERS; it++) { + for (let i = 0; i < live.length; i++) { + for (let j = i + 1; j < live.length; j++) { + const a = live[i], o = live[j]; + const dx = o.x - a.x, dy = (o.y - a.y) * COLLISION_Y_WEIGHT; + const d = Math.hypot(dx, dy) || 0.01; + const overlap = a.radius + o.radius - d; + if (overlap <= CONTACT_SLOP) continue; + const ux = dx / d, uy = dy / d; + const aFix = isImmovable(b, a), oFix = isImmovable(b, o); + const push = overlap * DEOVERLAP_FRACTION; + const aShare = aFix ? 0 : oFix ? 1 : 0.5; + const oShare = oFix ? 0 : aFix ? 1 : 0.5; + const yPush = uy / COLLISION_Y_WEIGHT; + a.x = clampField(a.x - ux * push * aShare, a.radius, FIELD.w); + a.y = clampField(a.y - yPush * push * aShare, a.radius, FIELD.h); + o.x = clampField(o.x + ux * push * oShare, o.radius, FIELD.w); + o.y = clampField(o.y + yPush * push * oShare, o.radius, FIELD.h); + } + } + } +} +var isImmovable = (b, a) => !!a.casting || isKd(b, a); +function stepPlayer(b, a, foe, dt) { + const cmd = b.input && b.input[a.id] || {}; + const mx = cmd.moveX || 0, my = cmd.moveY || 0; + if (mx || my) { + const len = Math.hypot(mx, my) || 1; + const speed = a.moveSpeed * moveSpeedMult(b, a); + a.x = clampField(a.x + mx / len * speed * dt, a.radius, FIELD.w); + a.y = clampField(a.y + my / len * speed * dt, a.radius, FIELD.h); + a.moving = true; + a.faceX = mx < 0 ? -1 : mx > 0 ? 1 : a.faceX; + a.faceY = my < 0 ? -1 : my > 0 ? 1 : a.faceY; + a.facing = a.faceX; + } + if (a.casting) { + a.casting.left -= dt; + if (a.casting.left <= 0) { + const { skill, target } = a.casting; + a.casting = null; + performSkill(b, a, target?.alive ? target : foe, skill); + } + return; + } + const action = cmd.action; + if (!action) return; + const free = !!b.freeCast; + if (free) { + a.energy = a.maxEnergy; + a.adrenaline = 25; + } + const clear = () => { + if (b.input) b.input[a.id] = { ...cmd, action: null }; + }; + if (action === "basic") { + if (!free && a.role !== "ranged" && edgeGap(a, foe) > reachOf(a)) { + clear(); + return; + } + if (!free && a.attackTimer > 0) return; + fireOnAction(b, a); + strike(b, a, foe, null); + clear(); + return; + } + const s = a.bar.find((x) => x.id === action); + if (!s) { + clear(); + return; + } + const tgt = skillTarget(b, a, s, foe); + if (!usable(b, a, s, tgt, foe, free)) return; + const cast = (s.cast || 0) * (hasCond(a, "dazed") ? 2 : 1); + if (cast <= 0) performSkill(b, a, tgt, s); + else { + applyActivationPenalty(b, a, s, cast); + a.casting = { skill: s, target: tgt, left: cast }; + } + clear(); +} +function reviveDummy(b, a) { + a.alive = true; + a.hp = a.maxHp = a.baseMaxHp; + a.energy = a.maxEnergy; + a.adrenaline = 0; + a.conds = []; + a.marks = {}; + a.mods = []; + a.casting = null; + a.kd = 0; + a.deadAt = null; +} +function step(b, dt) { + if (b.over) return; + b.t += dt; + if (b.sandbox && b.respawnDummies) { + for (const a of b.actors) if (!a.alive && a.control === "dummy" && a.deadAt != null && b.t - a.deadAt >= b.respawnDummies) reviveDummy(b, a); + } + for (const a of b.actors) { + if (!a.alive) continue; + a.energy = Math.min(a.maxEnergy, a.energy + a.energyRegen * dt); + let degen = 0; + for (const c of a.conds) if (c.until > b.t) degen += DEGEN[c.type] || 0; + const rate = degen - sumRegenPips(b, a) * 2; + if (rate) { + a.hp = Math.min(a.maxHp, a.hp - rate * dt); + if (a.hp <= 0) kill(b, a); + } + expireConds(b, a); + a.attackTimer -= dt; + for (const id of Object.keys(a.marks)) if (a.marks[id]?.until <= b.t) delete a.marks[id]; + } + advanceProjectiles(b); + for (const a of b.actors) { + if (!a.alive || b.over) continue; + const enemy = nearestFoe(b, a); + if (!enemy) continue; + a.facing = enemy.x < a.x ? -1 : 1; + a.faceX = a.facing; + a.faceY = enemy.y < a.y ? -1 : 1; + a.moving = false; + if (isKd(b, a)) { + a.casting = null; + continue; + } + if (a.control === "dummy") continue; + if (a.control === "player") { + stepPlayer(b, a, enemy, dt); + continue; + } + if (a.casting) { + a.casting.left -= dt; + if (a.casting.left <= 0) { + const { skill, target } = a.casting; + a.casting = null; + performSkill(b, a, target?.alive ? target : enemy, skill); + } + continue; + } + const action = chooseAction(b, a, enemy); + if (action) { + const cast = (action.skill.cast || 0) * (hasCond(a, "dazed") ? 2 : 1); + if (cast <= 0) performSkill(b, a, action.target, action.skill); + else { + applyActivationPenalty(b, a, action.skill, cast); + a.casting = { skill: action.skill, target: action.target, left: cast }; + } + } else if (edgeGap(a, enemy) <= reachOf(a) && a.attackTimer <= 0) { + fireOnAction(b, a); + strike(b, a, enemy, null); + } else { + moveActor(b, a, enemy, dt); + } + } + resolveOverlaps(b); + if (b.sandbox) return; + const playerAlive = b.actors.some((a) => a.alive && a.team === "player"); + const enemyAlive = b.actors.some((a) => a.alive && a.team === "enemy"); + if (!playerAlive || !enemyAlive) { + b.over = true; + b.winner = playerAlive ? "player" : enemyAlive ? "enemy" : null; + } else if (b.t >= MAX_BATTLE_T) { + const hp = (team) => b.actors.filter((a) => a.alive && a.team === team).reduce((n, a) => n + a.hp, 0); + const ph = hp("player"), eh = hp("enemy"); + b.over = true; + b.winner = ph === eh ? null : ph > eh ? "player" : "enemy"; + } +} + +// ../auto-battler/src/engine/describe.js +function val2(v) { + if (typeof v === "number") return String(v); + if (v && v.fixed !== void 0) return String(v.fixed); + if (v && v.scale) return `${v.scale[0]}\u2026${v.scale[1]}`; + return "?"; +} +function req(r) { + if (r === "on_hit") return "on hit"; + if (r.combo_follows) return `follows a ${r.combo_follows} attack`; + if (r.target) return `target is ${r.target}`; + if (r.target_below_health !== void 0) return `target < ${r.target_below_health * 100}% HP`; + if (r.target_health_above_self) return "target has more HP than you"; + if (r.self) return `you are ${r.self}`; + return JSON.stringify(r); +} +function effect(e) { + const ifc = e.if ? ` (if ${req(e.if)})` : ""; + const sc = e.scope && e.scope !== "target" ? ` [${e.scope.replace(/_/g, " ")}]` : ""; + let s; + switch (e.op) { + case "bonus_damage": + s = `+${val2(e.amount)} damage${e.unblockable ? " (unblockable)" : ""}`; + break; + case "damage": + s = `${val2(e.amount)}${e.projectiles ? `\xD7${e.projectiles}` : ""} ${e.damageType || ""} damage`.trim(); + break; + case "amplify_damage": + s = `+${val2(e.amount)} damage taken from ${e.vs}`; + break; + case "apply_condition": + s = `${e.condition} ${val2(e.duration)}s`; + break; + case "heal": + s = `heal ${val2(e.amount)}${e.plusPerMod ? ` (+${val2(e.plusPerMod.amount)} per ${e.plusPerMod.kinds.join("/")})` : ""}`; + break; + case "life_steal": + s = `steal ${val2(e.amount)} Health`; + break; + case "knockdown": + s = `knock down ${val2(e.duration)}s`; + break; + case "block": + s = `block ${Math.round(e.chance * 100)}% vs ${e.vs || "all"} for ${val2(e.duration)}s${e.reflect ? ` (reflect ${val2(e.reflect)})` : ""}`; + break; + case "armor_mod": + s = `+${val2(e.amount)} armor for ${val2(e.duration)}s${e.attacksLeft ? ` or ${e.attacksLeft} hits` : ""}`; + break; + case "regen_mod": + s = `${val2(e.pips)} health regen for ${val2(e.duration)}s`; + break; + case "attack_speed": + s = `attack ${e.mult}\xD7 speed for ${val2(e.duration)}s`; + break; + case "move_speed": + s = `move ${e.mult}\xD7 speed for ${val2(e.duration)}s`; + break; + case "shadow_step": + s = `shadow step to ${e.to || "foe"}`; + break; + case "convert_damage_to_heal": + s = `convert next damage \u2192 heal (max ${val2(e.cap)})`; + break; + case "cap_damage": + s = `cap each hit at ${e.maxFraction * 100}% max HP`; + break; + case "interrupt": + s = "interrupt"; + break; + case "set_combo_mark": + s = `mark: ${e.stage}`; + break; + case "lose_all_adrenaline": + s = "lose all adrenaline"; + break; + case "hex": + case "enchant": { + const trig = e.trigger ? ` ${e.trigger.replace(/_/g, " ")}` : " (passive)"; + const ch = e.charges ? `, ${e.charges}\xD7` : ""; + const th = e.threshold ? `, when hit >${e.threshold.perHitDamageOver}` : ""; + s = `${e.op} ${val2(e.duration)}s${ch}${th}${trig}: [${e.payload.map(effect).join("; ")}]`; + break; + } + case "preparation": + s = `prep ${val2(e.duration)}s: each attack \u2192 [${e.on_attack.map(effect).join("; ")}]`; + break; + default: + s = e.op; + } + return s + sc + ifc; +} + +// ../auto-battler/src/lib/skillVisuals.js +var CONDITION_COLOR = { + bleeding: "#e0584a", + deepWound: "#b3402f", + poison: "#6fae3f", + disease: "#9aa83a", + burning: "#e0822a", + dazed: "#e0c64f", + blind: "#9aa0a8", + crippled: "#5bb6c0", + weakness: "#b08a5a", + crackedArmor: "#c9a36a" +}; +var KIND = { + damage: { color: "#e0584a", label: "Damage" }, + condition: { color: "#e0584a", label: "Condition" }, + // overridden per-condition + heal: { color: "#6fbf73", label: "Heal" }, + defense: { color: "#5b9fd6", label: "Defense" }, + haste: { color: "#4fc0c0", label: "Speed" }, + vuln: { color: "#e0905a", label: "Weaken" }, + control: { color: "#b06fd8", label: "Control" }, + hex: { color: "#a05fd0", label: "Hex" }, + enchant: { color: "#e0c64f", label: "Enchant" }, + prep: { color: "#4fb0a0", label: "Prep" }, + utility: { color: "#9aa0a8", label: "Effect" } +}; +var OP_KIND = { + damage: "damage", + bonus_damage: "damage", + life_steal: "damage", + apply_condition: "condition", + heal: "heal", + regen_mod: "heal", + convert_damage_to_heal: "heal", + armor_mod: "defense", + block: "defense", + cap_damage: "defense", + attack_speed: "haste", + move_speed: "haste", + amplify_damage: "vuln", + knockdown: "control", + interrupt: "control", + hex: "hex", + enchant: "enchant", + preparation: "prep" +}; +var effectKind = (e) => OP_KIND[e?.op] ?? "utility"; +function effectStyle(e) { + const kind = effectKind(e); + if (kind === "condition") { + const color = CONDITION_COLOR[e.condition] ?? KIND.condition.color; + const label = e.condition ? e.condition.replace(/([A-Z])/g, " $1").replace(/^./, (c) => c.toUpperCase()) : "Condition"; + return { color, label }; + } + return KIND[kind]; +} +var conditionIcon = (condition) => `/gw/icons/condition-${condition.replace(/([A-Z])/g, "-$1").toLowerCase()}.jpg`; + +// ../auto-battler/src/lib/recolor.js +var QUANT = 24; +var SAT_MIN = 0.2; +var L_MIN = 0.1; +var L_MAX = 0.95; +var SWATCH_N = 5; +function rgbToHsl(r, g, b) { + r /= 255; + g /= 255; + b /= 255; + const max = Math.max(r, g, b), min = Math.min(r, g, b); + const l = (max + min) / 2; + let h = 0, s = 0; + if (max !== min) { + const d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + if (max === r) h = (g - b) / d + (g < b ? 6 : 0); + else if (max === g) h = (b - r) / d + 2; + else h = (r - g) / d + 4; + h *= 60; + } + return [h, s, l]; +} +function hslToRgb(h, s, l) { + h /= 360; + if (s === 0) { + const v = Math.round(l * 255); + return [v, v, v]; + } + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + const hue = (t) => { + if (t < 0) t += 1; + if (t > 1) t -= 1; + if (t < 1 / 6) return p + (q - p) * 6 * t; + if (t < 1 / 2) return q; + if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; + return p; + }; + return [Math.round(hue(h + 1 / 3) * 255), Math.round(hue(h) * 255), Math.round(hue(h - 1 / 3) * 255)]; +} +var hexToRgb = (hex) => { + const n = parseInt(hex.replace("#", ""), 16); + return [n >> 16 & 255, n >> 8 & 255, n & 255]; +}; +var rgbToHex = (r, g, b) => "#" + [r, g, b].map((v) => v.toString(16).padStart(2, "0")).join(""); +var isMaterial = (s, l) => s >= SAT_MIN && l >= L_MIN && l <= L_MAX; +var _imgCache = /* @__PURE__ */ new Map(); +function loadImage(url) { + if (_imgCache.has(url)) return _imgCache.get(url); + const p = new Promise((res, rej) => { + const img = new Image(); + img.crossOrigin = "anonymous"; + img.onload = () => res(img); + img.onerror = rej; + img.src = url; + }); + _imgCache.set(url, p); + return p; +} +function pixels(img) { + const cv = document.createElement("canvas"); + cv.width = img.naturalWidth; + cv.height = img.naturalHeight; + const ctx = cv.getContext("2d", { willReadFrequently: true }); + ctx.drawImage(img, 0, 0); + return { cv, ctx, data: ctx.getImageData(0, 0, cv.width, cv.height) }; +} +var _analysis = /* @__PURE__ */ new Map(); +function analyze(idleUrl) { + if (_analysis.has(idleUrl)) return _analysis.get(idleUrl); + const p = (async () => { + const { data } = pixels(await loadImage(idleUrl)); + const d = data.data; + const counts = /* @__PURE__ */ new Map(); + for (let i = 0; i < d.length; i += 4) { + if (d[i + 3] < 8) continue; + const r = d[i] - d[i] % QUANT, g = d[i + 1] - d[i + 1] % QUANT, b = d[i + 2] - d[i + 2] % QUANT; + const [h, s, l] = rgbToHsl(r, g, b); + if (!isMaterial(s, l)) continue; + const k = r << 16 | g << 8 | b; + counts.set(k, (counts.get(k) || 0) + 1); + } + if (!counts.size) return { anchor: null, swatches: [] }; + const entries = [...counts.entries()].map(([k, c]) => ({ k, c, hsl: rgbToHsl(k >> 16 & 255, k >> 8 & 255, k & 255) })).sort((a, b) => b.c - a.c); + const anchor = entries[0].hsl; + const swatches = entries.slice(0, SWATCH_N).sort((a, b) => a.hsl[0] - b.hsl[0] || a.hsl[2] - b.hsl[2]).map(({ k }) => rgbToHex(k >> 16 & 255, k >> 8 & 255, k & 255)); + return { anchor, swatches }; + })(); + _analysis.set(idleUrl, p); + return p; +} +function xform([h, s, l], anchor, tH, tS) { + const nh = ((h + (tH - anchor[0])) % 360 + 360) % 360; + const ns = Math.min(1, Math.max(0, s + (tS - anchor[1]))); + return hslToRgb(nh, ns, l); +} +var _canvasCache = /* @__PURE__ */ new Map(); +function recoloredCanvas(url, idleUrl, targetHex) { + const key = `${url}#${idleUrl}#${targetHex}`; + if (_canvasCache.has(key)) return _canvasCache.get(key); + const p = (async () => { + const [img, { anchor }] = await Promise.all([loadImage(url), analyze(idleUrl)]); + const { cv, ctx, data } = pixels(img); + if (anchor) { + const [tH, tS] = rgbToHsl(...hexToRgb(targetHex)); + const d = data.data; + for (let i = 0; i < d.length; i += 4) { + if (d[i + 3] < 8) continue; + const [h, s, l] = rgbToHsl(d[i], d[i + 1], d[i + 2]); + if (!isMaterial(s, l)) continue; + const [r, g, b] = xform([h, s, l], anchor, tH, tS); + d[i] = r; + d[i + 1] = g; + d[i + 2] = b; + } + ctx.putImageData(data, 0, 0); + } + return cv; + })(); + _canvasCache.set(key, p); + return p; +} +async function recoloredTexture(Texture, url, idleUrl, targetHex) { + const cv = targetHex ? await recoloredCanvas(url, idleUrl, targetHex) : await loadImage(url); + const t = Texture.from(cv); + t.source.scaleMode = "nearest"; + return t; +} + +// ../auto-battler/src/render/spriteSheet.js +var SHEET_ROWS = 4; +var cellOf = (height) => Math.round(height / SHEET_ROWS); +function sliceGridWith(pixi, texture, cell = cellOf(texture.source.height)) { + const { Texture, Rectangle } = pixi; + const src = texture.source; + const rows = Math.max(1, Math.round(src.height / cell)); + const cols = Math.max(1, Math.round(src.width / cell)); + return Array.from({ length: rows }, (_, r) => Array.from({ length: cols }, (_2, c) => new Texture({ source: src, frame: new Rectangle(c * cell, r * cell, cell, cell) }))); +} +var ANIM = { idle: 0.12, walk: 0.18, attack: 0.3, dmg: 0.25, die: 0.28 }; +var ROW_FOR = { "front-right": 0, "front-left": 1, "back-right": 2, "back-left": 3 }; +var rowFor = (grid, facing) => grid[ROW_FOR[facing]] ?? grid[0]; +var facingFor = (faceX, faceY) => (faceY < 0 ? "back" : "front") + "-" + (faceX < 0 ? "left" : "right"); + +// ../auto-battler/src/render/combatRenderer.js +var COND_ICON = { + bleeding: "bleeding", + poison: "poison", + burning: "burning", + deepWound: "deep-wound", + disease: "disease", + dazed: "dazed", + crippled: "crippled", + weakness: "weakness", + blind: "blind", + crackedArmor: "cracked-armor" +}; +var COND_STATUS = { + bleeding: "bleeding", + poison: "poison", + burning: "fire", + disease: "sickness", + deepWound: "petrification", + dazed: "stun", + crippled: "ice", + weakness: "nature", + blind: "fear", + crackedArmor: "shock" +}; +var COND_PRIORITY = ["deepWound", "burning", "poison", "bleeding", "disease", "dazed", "crippled", "blind", "crackedArmor", "weakness"]; +var OUT_OFF = [[-1, 0], [1, 0], [0, -1], [0, 1], [-1, -1], [1, -1], [-1, 1], [1, 1]]; +var MAT_RED = [0, 0, 0, 0, 0.86, 0, 0, 0, 0, 0.12, 0, 0, 0, 0, 0.12, 0, 0, 0, 1, 0]; +var MAT_YELLOW = [0, 0, 0, 0, 1, 0, 0, 0, 0, 0.84, 0, 0, 0, 0, 0.1, 0, 0, 0, 1, 0]; +var GOLD = 16767050; +var BAR_TOP = 3; +var GHOST_HOLD = 220; +var GHOST_TAU = 150; +var GHOST_FADE = 400; +var ICON_SIZE = 18; +var _footCache = /* @__PURE__ */ new Map(); +async function footFracOf(url) { + if (!url) return 1; + if (_footCache.has(url)) return _footCache.get(url); + try { + const img = await new Promise((ok, no) => { + const i = new Image(); + i.onload = () => ok(i); + i.onerror = no; + i.src = url; + }); + const cell = Math.round(img.naturalHeight / 4); + const cv = document.createElement("canvas"); + cv.width = cell; + cv.height = cell; + const ctx = cv.getContext("2d", { willReadFrequently: true }); + ctx.drawImage(img, 0, 0, cell, cell, 0, 0, cell, cell); + const d = ctx.getImageData(0, 0, cell, cell).data; + let bottom = cell - 1; + for (let y = cell - 1; y >= 0; y--) { + let any = false; + for (let x = 0; x < cell; x++) if (d[(y * cell + x) * 4 + 3] > 16) { + any = true; + break; + } + if (any) { + bottom = y; + break; + } + } + const frac = Math.min(1, (bottom + 1.5) / cell); + _footCache.set(url, frac); + return frac; + } catch { + return 1; + } +} +var facingOf = (a) => facingFor(a.faceX, a.faceY); +async function createCombatRenderer({ pixi, defsById = {}, layers, coords, getBattle }) { + const { Assets, Texture, Rectangle, AnimatedSprite, Sprite, Container, Graphics, Text, ColorMatrixFilter } = pixi; + const sliceGrid = (texture, cell) => sliceGridWith({ Texture, Rectangle }, texture, cell); + const rowFramesH = (texture, cell) => { + const src = texture.source; + const cols = Math.max(1, Math.round(src.width / cell)); + const h = Math.min(cell, src.height); + return Array.from({ length: cols }, (_, c) => new Texture({ source: src, frame: new Rectangle(c * cell, 0, cell, h) })); + }; + const { units, fx, projLayer } = layers; + const { mapX, mapY, depthOf } = coords; + const actorOf = (id) => getBattle()?.actors.find((x) => x.id === id); + const floats = []; + const sheetsById = {}; + for (const [id, def] of Object.entries(defsById)) { + if (!def?.idle) { + sheetsById[id] = null; + continue; + } + try { + const load = (url) => url ? recoloredTexture(Texture, url, def.idle, def.recolor) : null; + const [idle, walk, attack, dmg, die] = await Promise.all([ + load(def.idle), + load(def.walk), + load(def.attack), + load(def.dmg), + load(def.die) + ]); + for (const tx of [idle, walk, attack, dmg, die]) if (tx) tx.source.scaleMode = "nearest"; + const cell = Math.round(idle.source.height / 4); + sheetsById[id] = { + idle: sliceGrid(idle, cell), + walk: walk ? sliceGrid(walk, cell) : sliceGrid(idle, cell), + attack: attack ? sliceGrid(attack, cell) : sliceGrid(idle, cell), + dmg: dmg ? sliceGrid(dmg, cell) : null, + die: die ? sliceGrid(die, cell) : null, + cell, + footFrac: await footFracOf(def.idle) + }; + } catch { + sheetsById[id] = null; + } + } + const skillIcons = {}; + for (const s of CB_SKILLS) { + try { + const t = await Assets.load(iconUrl(s.id)); + t.source.scaleMode = "linear"; + skillIcons[s.id] = t; + } catch { + } + } + const condIcons = {}; + for (const [type, file] of Object.entries(COND_ICON)) { + try { + const t = await Assets.load(`/gw/icons/condition-${file}.jpg`); + t.source.scaleMode = "linear"; + condIcons[type] = t; + } catch { + } + } + const condFrames = {}; + try { + const catalogue = await fetch("/assets/effects.json").then((r) => r.json()).then((d) => d.effects || []); + const byKey = Object.fromEntries(catalogue.filter((e) => e.category === "status").map((e) => [e.key, e])); + for (const [type, statusKey] of Object.entries(COND_STATUS)) { + const e = byKey[statusKey]; + if (!e) continue; + try { + const t = await Assets.load(e.url); + t.source.scaleMode = "nearest"; + condFrames[type] = rowFramesH(t, e.cell || t.source.height); + } catch { + } + } + } catch { + } + const skillPlay = {}; + for (const [id, def] of Object.entries(defsById)) { + const cell = sheetsById[id]?.cell; + const map = {}; + for (const [skillId, fxCfg] of Object.entries(def.skillFx || {})) { + let animGrid = null; + if (fxCfg.animUrl && cell) { + try { + const t = await recoloredTexture(Texture, fxCfg.animUrl, def.idle, def.recolor); + animGrid = sliceGrid(t, cell); + } catch { + } + } + const effects = []; + for (const ef of fxCfg.effects || []) { + try { + const t = await Assets.load(ef.url); + t.source.scaleMode = "nearest"; + effects.push(sliceGrid(t, ef.cell || 32)[0]); + } catch { + } + } + map[skillId] = { animGrid, effects }; + } + skillPlay[id] = map; + } + const view = {}; + for (const [id, def] of Object.entries(defsById)) { + const sh = sheetsById[id]; + const c = new Container(); + let sp; + if (sh) { + sp = new AnimatedSprite(rowFor(sh.idle, "front-right")); + sp.anchor.set(0.5, sh.footFrac ?? 1); + sp.animationSpeed = ANIM.idle; + sp.play(); + } else { + sp = new Container(); + const g = new Graphics(); + g.circle(0, -16, 16).fill(def?.color || 8947848); + const t = new Text({ text: (def?.name || "?")[0], style: { fill: 16777215, fontSize: 15, fontWeight: "700" } }); + t.anchor.set(0.5); + t.y = -16; + sp.addChild(g, t); + } + const bars = new Graphics(); + const status = new Container(); + const overlay = new AnimatedSprite([Texture.EMPTY]); + overlay.anchor.set(0.5, sh?.footFrac ?? 1); + overlay.visible = false; + overlay.loop = true; + overlay.animationSpeed = 0.15; + const makeOutline = (matrix) => { + if (!sh) return null; + const o = { container: new Container(), copies: [] }; + const filter = new ColorMatrixFilter(); + filter.matrix = matrix; + o.container.filters = [filter]; + o.container.visible = false; + o.container.alpha = 0.75; + for (let i = 0; i < OUT_OFF.length; i++) { + const s = new Sprite(Texture.EMPTY); + s.anchor.set(0.5, sh.footFrac ?? 1); + o.container.addChild(s); + o.copies.push(s); + } + return o; + }; + const castOutline = makeOutline(MAT_YELLOW); + const outline = makeOutline(MAT_RED); + if (castOutline) c.addChild(castOutline.container); + if (outline) c.addChild(outline.container); + c.addChild(sp, overlay, bars, status); + units.addChild(c); + view[id] = { def, sheets: sh, container: c, sprite: sp, overlay, overlayKey: null, bars, status, statusSprites: {}, outline, castOutline, mode: null, facing: null, flash: 0, dead: false }; + } + function setLoop(v, mode, facing) { + if (v.mode === mode && v.facing === facing) return; + v.mode = mode; + v.facing = facing; + v.sprite.textures = rowFor(v.sheets[mode], facing); + v.sprite.loop = true; + v.sprite.animationSpeed = ANIM[mode]; + v.sprite.play(); + } + function playOnce(v, mode, facing, onDone, speedMul = 1) { + v.mode = mode; + v.facing = facing; + v.sprite.onComplete = null; + v.sprite.textures = rowFor(v.sheets[mode], facing); + v.sprite.loop = false; + v.sprite.animationSpeed = ANIM[mode] * speedMul; + v.sprite.onComplete = () => { + v.sprite.onComplete = null; + onDone && onDone(); + }; + v.sprite.gotoAndPlay(0); + } + const resume = (v) => { + v.mode = null; + }; + function playAttack(id, a, onDone = null, speedMul = 1) { + const v = view[id]; + if (!v?.sheets || v.dead) return; + playOnce(v, "attack", facingOf(a), () => { + resume(v); + onDone && onDone(); + }, speedMul); + } + function playHurt(id, a) { + const v = view[id]; + if (!v?.sheets?.dmg || v.dead || v.skillAnim || v.mode !== "idle") return; + playOnce(v, "dmg", facingOf(a), () => resume(v)); + } + function playDie(id, a) { + const v = view[id]; + if (!v || v.dead) return; + v.dead = true; + v.mode = "die"; + const facing = facingOf(a); + v.facing = facing; + if (!v.sheets) return; + const crumple = () => { + if (!v.sheets.die) return; + const frames = rowFor(v.sheets.die, facing); + v.sprite.onComplete = null; + v.sprite.textures = frames; + v.sprite.loop = false; + v.sprite.animationSpeed = ANIM.die * 0.75; + v.sprite.onComplete = () => { + v.sprite.onComplete = null; + v.sprite.gotoAndStop(frames.length - 1); + }; + v.sprite.gotoAndPlay(0); + }; + if (v.sheets.dmg) { + const hit = rowFor(v.sheets.dmg, facing); + v.sprite.onComplete = null; + v.sprite.textures = hit; + v.sprite.loop = false; + v.sprite.animationSpeed = ANIM.dmg; + v.sprite.onComplete = () => { + v.sprite.onComplete = null; + crumple(); + }; + v.sprite.gotoAndPlay(0); + } else crumple(); + } + function playGridOnce(v, grid, { speedMul = 1, hold = false, onDone = null } = {}) { + if (!grid || !v || v.dead) return false; + v.mode = "attack"; + v.skillAnim = true; + v.sprite.onComplete = null; + v.sprite.textures = grid[0]; + v.sprite.loop = false; + v.sprite.animationSpeed = ANIM.attack * speedMul; + v.sprite.onComplete = () => { + v.sprite.onComplete = null; + if (hold) v.sprite.gotoAndStop(grid[0].length - 1); + else { + v.skillAnim = false; + resume(v); + } + onDone && onDone(); + }; + v.sprite.gotoAndPlay(0); + return true; + } + function spawnEffects(casterId, framesList) { + const a = actorOf(casterId); + const v = view[casterId]; + if (!a || !v || !framesList?.length) return; + const cell = v.sheets?.cell ?? 24, ff = v.sheets?.footFrac ?? 1, depth = depthOf(a); + const cx = mapX(a.x), cy = mapY(a.y) - (ff - 0.5) * cell * depth; + for (const frames of framesList) { + if (!frames?.length) continue; + const s = new AnimatedSprite(frames); + s.anchor.set(0.5); + s.loop = false; + s.animationSpeed = 0.3; + s.scale.set(depth); + s.position.set(cx, cy); + s.onComplete = () => fx.removeChild(s); + fx.addChild(s); + s.gotoAndPlay(0); + } + } + const headY = (id, a) => { + const v = view[id], cell = v?.sheets?.cell ?? 24, ff = v?.sheets?.footFrac ?? 1; + return mapY(a.y) - ff * cell * depthOf(a) - 2; + }; + function floatText(id, text, color, opts = {}) { + const a = actorOf(id); + if (!a) return; + const t = new Text({ text, style: { fill: color, fontSize: opts.big ? 20 : 14, fontFamily: "monospace", fontWeight: "700", stroke: opts.big ? { color: 2760448, width: 3 } : void 0 } }); + t.anchor.set(0.5); + t.x = mapX(a.x) + (Math.random() * 20 - 10); + t.y = headY(id, a) - (opts.big ? 6 : 0); + fx.addChild(t); + floats.push({ t, life: opts.big ? 1.25 : 1, max: opts.big ? 1.25 : 1 }); + } + function floatIcon(id, skillId) { + const a = actorOf(id); + const tex = skillId && skillIcons[skillId]; + if (!a || !tex) return false; + const s = new Sprite(tex); + s.anchor.set(0.5); + s.width = ICON_SIZE; + s.height = ICON_SIZE; + s.x = mapX(a.x); + s.y = headY(id, a) - 8; + fx.addChild(s); + floats.push({ t: s, life: 1.2, max: 1.2, icon: true, size: ICON_SIZE, who: id }); + return true; + } + function flagEmpower(id) { + for (const f of floats) if (f.icon && f.who === id) { + f.t.tint = GOLD; + f.empower = true; + f.size = ICON_SIZE + 6; + } + floatText(id, "\u26A1", GOLD); + } + function spawnEcho(id) { + const a = actorOf(id), v = view[id]; + if (!a || !v?.sheets) return; + const depth = depthOf(a); + for (const cfg of [{ life: 0.55, scaleTo: 2.2, a0: 0.85 }, { life: 0.8, scaleTo: 3.1, a0: 0.55 }]) { + const s = new Sprite(v.sprite.texture); + s.anchor.set(0.5, v.sheets.footFrac ?? 1); + s.tint = 10473727; + s.x = mapX(a.x); + s.y = mapY(a.y); + fx.addChild(s); + floats.push({ t: s, life: cfg.life, max: cfg.life, echo: true, baseScale: depth, scaleTo: cfg.scaleTo, alpha0: cfg.a0, dir: a.facing || 1 }); + } + } + function drawBars(id, a, dt, now) { + const v = view[id]; + const g = v.bars; + g.clear(); + const w = 40; + const cur = Math.max(0, a.hp / a.baseMaxHp); + if (v.hpGhost == null || cur >= v.hpGhost) { + v.hpGhost = cur; + v.hpLast = cur; + v.hpGhostT = GHOST_HOLD; + } + if (cur < (v.hpLast ?? cur) - 8e-3) v.hpGhostT = 0; + v.hpLast = cur; + v.hpGhostT = (v.hpGhostT ?? GHOST_HOLD) + dt; + if (v.hpGhost > cur && v.hpGhostT > GHOST_HOLD) { + v.hpGhost = cur + (v.hpGhost - cur) * Math.max(0, 1 - dt / GHOST_TAU); + if (v.hpGhost - cur < 4e-3) v.hpGhost = cur; + } + g.rect(-w / 2, BAR_TOP, w, 5).fill(1316897); + if (v.hpGhost > cur + 1e-4) { + const fade = v.hpGhostT <= GHOST_HOLD ? 1 : Math.max(0, 1 - (v.hpGhostT - GHOST_HOLD) / GHOST_FADE); + g.rect(-w / 2 + 1 + (w - 2) * cur, BAR_TOP + 1, (w - 2) * (v.hpGhost - cur), 3).fill({ color: 16777215, alpha: 0.85 * fade }); + } + g.rect(-w / 2 + 1, BAR_TOP + 1, (w - 2) * cur, 3).fill(cur > 0.4 ? 12113482 : 14165786); + const res = a.profession === "Warrior" ? a.adrenaline / 25 : a.energy / a.maxEnergy; + g.rect(-w / 2, BAR_TOP + 6, w, 3).fill(1316897); + g.rect(-w / 2 + 1, BAR_TOP + 7, (w - 2) * Math.max(0, Math.min(1, res)), 1.5).fill(a.profession === "Warrior" ? 15247146 : 3832997); + const mark = Object.values(a.marks || {}).find((m) => m && m.until > now); + if (mark) for (let i = 0; i < (mark.stage === "offhand" ? 2 : 1); i++) g.circle(w / 2 - 2 - i * 3, BAR_TOP - 2, 1.3).fill(16767050); + const headRel = -((v.sheets?.footFrac ?? 1) * (v.sheets?.cell ?? 24) * depthOf(a)) - 4; + if (a.kd > now) { + const cy = headRel + 2; + for (let i = 0; i < 3; i++) { + const ang = now * 6 + i * 2 * Math.PI / 3; + g.circle(Math.cos(ang) * 6, cy + Math.sin(ang) * 2.2, 1.5).fill(16110658); + } + } + if (a.casting && a.casting.skill) { + const total = a.casting.skill.cast || 1; + const prog = Math.max(0, Math.min(1, 1 - (a.casting.left ?? 0) / total)); + g.rect(-11, headRel, 22, 3).fill(1316897); + g.rect(-10, headRel + 0.75, 20 * prog, 1.5).fill(7325664); + } + } + function drawStatus(id, a, t, depth) { + const v = view[id]; + const active = a.conds.filter((c) => c.until > t && condIcons[c.type]); + const sz = 11, gap = 2, totalW = active.length * sz + Math.max(0, active.length - 1) * gap; + active.forEach((c, i) => { + let s = v.statusSprites[c.type]; + if (!s) { + s = new Sprite(condIcons[c.type]); + s.anchor.set(0.5); + s.width = sz; + s.height = sz; + v.status.addChild(s); + v.statusSprites[c.type] = s; + } + s.x = -totalW / 2 + sz / 2 + i * (sz + gap); + s.y = BAR_TOP + 15; + const left = c.until - t; + s.visible = left >= 1.2 || Math.floor(t * 6) % 2 === 0; + }); + for (const type in v.statusSprites) if (!active.find((c) => c.type === type)) v.statusSprites[type].visible = false; + let key = null; + if (!v.dead) { + for (const type of COND_PRIORITY) if (condFrames[type] && active.some((c) => c.type === type)) { + key = type; + break; + } + } + if (key !== v.overlayKey) { + v.overlayKey = key; + if (key) { + v.overlay.textures = condFrames[key]; + v.overlay.visible = true; + v.overlay.gotoAndPlay(0); + } else { + v.overlay.visible = false; + } + } + if (key) v.overlay.scale.set(depth); + } + function resetForNewBattle() { + for (const id in view) { + const v = view[id]; + v.dead = false; + v.mode = null; + v.facing = null; + v.flash = 0; + v.skillAnim = false; + for (const k in v.statusSprites) v.statusSprites[k].visible = false; + v.overlay.visible = false; + v.overlayKey = null; + if (v.sheets) { + v.sprite.onComplete = null; + v.sprite.tint = 16777215; + v.sprite.alpha = 1; + } + } + } + function syncActors(b, dtMS, now, { cine = null, cineDim = 0, cineCfg = {} } = {}) { + for (const a of b.actors) { + const v = view[a.id]; + if (!v) continue; + v.container.x = mapX(a.x); + v.container.y = mapY(a.y); + if (cine?.hit && a.id === cine.targetId && !v.dead) { + v.container.x += (Math.random() * 2 - 1) * 2.5; + v.container.y += (Math.random() * 2 - 1) * 2.5; + } + v.container.zIndex = a.y; + const depth = depthOf(a); + const dimmed = cine && cine.freeze && cineCfg.dim !== false && a.id !== cine.casterId && a.id !== cine.targetId; + const dimTint = () => { + const g = Math.round(255 * (1 - cineDim * 0.5)); + return g << 16 | g << 8 | g; + }; + if (v.sheets) { + v.sprite.scale.set(depth); + if (v.dead) { + v.sprite.tint = dimmed ? dimTint() : 16777215; + v.sprite.alpha = 0.9; + } else { + if (v.mode !== "attack" && v.mode !== "dmg") { + setLoop(v, a.moving ? "walk" : "idle", facingOf(a)); + } + v.flash = Math.max(0, v.flash - dtMS); + v.sprite.tint = dimmed ? dimTint() : v.flash > 0 && !v.skillAnim ? 16738922 : 16777215; + v.sprite.alpha = 1; + } + } else { + v.sprite.scale.set(1); + v.sprite.alpha = a.alive ? 1 : 0.32; + } + drawBars(a.id, a, dtMS, b.t); + drawStatus(a.id, a, b.t, depth); + } + } + function updateFloats(dtMS) { + for (let i = floats.length - 1; i >= 0; i--) { + const f = floats[i]; + f.life -= dtMS / 1e3; + const age = f.max - f.life; + if (f.echo) { + const p = Math.min(1, age / f.max); + const sc = f.baseScale * (1 + (f.scaleTo - 1) * p); + f.t.scale.set(sc * (f.dir < 0 ? -1 : 1), sc); + f.t.alpha = f.alpha0 * (1 - p); + } else { + f.t.y -= dtMS * 0.022; + const fin = Math.min(1, age / 0.18), fout = Math.min(1, f.life / 0.35); + f.t.alpha = Math.max(0, Math.min(fin, fout)); + if (f.icon) { + const sz = f.size * (0.6 + 0.4 * fin); + f.t.width = sz; + f.t.height = sz; + } + } + if (f.life <= 0) { + fx.removeChild(f.t); + floats.splice(i, 1); + } + } + } + function drawProjectiles(b) { + projLayer.clear(); + for (const p of b.projectiles) { + const frac = Math.max(0, Math.min(1, (b.t - p.bornT) / (p.hitT - p.bornT))); + const px = mapX(p.fromX + (p.aimX - p.fromX) * frac); + const py = mapY(p.fromY + (p.aimY - p.fromY) * frac) - 26; + projLayer.circle(px, py, 4).fill(15786176).stroke({ width: 1, color: 2764602 }); + } + } + function processLog(b, r, hooks = {}) { + const log2 = b.log; + for (; r.logIdx < log2.length; r.logIdx++) { + const e = log2[r.logIdx]; + if (e.kind === "cast") { + const h = hooks.onCast?.(e) || {}; + if (h.break) { + r.logIdx++; + return; + } + const v = view[e.who], a = actorOf(e.who), play = skillPlay[e.who]?.[e.skillId]; + if (!h.skipInline) { + if (v && a) { + if (!playGridOnce(v, play?.animGrid)) playAttack(e.who, a); + spawnEffects(e.who, play?.effects); + } + if (!floatIcon(e.who, e.skillId)) floatText(e.who, e.name + (e.elite ? " \u2605" : ""), 15787730); + if (e.combo === "dual") floatText(e.who, "\u2726", GOLD); + } + } else if (e.kind === "swing") { + if (!e.skillId) playAttack(e.who, actorOf(e.who)); + } else if (e.kind === "shoot") { + if (!e.skillId) playAttack(e.who, actorOf(e.who)); + } else if (e.kind === "hit" && e.amount > 0) { + const v = view[e.who]; + if (v) v.flash = 130; + playHurt(e.who, actorOf(e.who)); + e.empowered ? floatText(e.who, "-" + e.amount + "!", GOLD, { big: true }) : floatText(e.who, "-" + e.amount, 16743018); + } else if (e.kind === "heal" && e.amount > 0) e.empowered ? floatText(e.who, "+" + e.amount + "!", GOLD, { big: true }) : floatText(e.who, "+" + e.amount, 9429896); + else if (e.kind === "cond" && e.empowered) floatText(e.who, (e.cond === "interrupted" ? "INTERRUPT" : String(e.cond).toUpperCase()) + "!", GOLD, { big: true }); + else if (e.kind === "empower") flagEmpower(e.who); + else if (e.kind === "miss") floatText(e.who, "dodge", 10405352); + else if (e.kind === "death") playDie(e.who, actorOf(e.who)); + } + } + return { + view, + floats, + actorOf, + skillPlay, + setLoop, + playOnce, + resume, + playAttack, + playHurt, + playDie, + playGridOnce, + spawnEffects, + floatText, + floatIcon, + flagEmpower, + spawnEcho, + drawBars, + drawStatus, + resetForNewBattle, + syncActors, + updateFloats, + drawProjectiles, + processLog + }; +} + +// ../auto-battler/src/render/sandboxStage.js +var C_BASIC = 16777215; +var C_SKILL = 15774761; +var STEP = 0.05; +var MAT_WHITE = [0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0]; +var MAT_AMBER = [0, 0, 0, 0, 0.94, 0, 0, 0, 0, 0.71, 0, 0, 0, 0, 0.16, 0, 0, 0, 1, 0]; +var OUT_OFF2 = [[-3, 0], [3, 0], [0, -3], [0, 3], [-3, -3], [3, -3], [-3, 3], [3, 3]]; +function mountSandboxStage(pixi, stageEl, ctx) { + const { Application, Sprite, Graphics, Text, Container, ColorMatrixFilter, Texture } = pixi; + const { input, state } = ctx; + let currentApp = null; + let token = 0; + const killApp = () => { + if (currentApp) { + try { + currentApp.destroy(true, { children: true }); + } catch { + } + currentApp = null; + } + }; + function rebuild(buildBattle) { + const myToken = ++token; + killApp(); + stageEl.replaceChildren(); + const built = buildBattle(); + if (!built) return; + const { battle, defsById } = built; + const app = new Application(); + app.init({ background: 15524556, antialias: false, resizeTo: stageEl }).then(async () => { + if (myToken !== token) { + app.destroy(true, { children: true }); + return; + } + stageEl.appendChild(app.canvas); + if (ctx.canvasTestId) app.canvas.setAttribute("data-testid", ctx.canvasTestId); + currentApp = app; + const W = app.screen.width, H = app.screen.height; + const sx = W / FIELD.w, sy = H / FIELD.h; + const mapX = (x) => x * sx, mapY = (y) => y * sy; + const depthOf = (a) => Math.min(Math.max(W / 190, 1.6), 3) * (0.85 + 0.4 * (a.y / FIELD.h)); + const ranges = new Graphics(); + app.stage.addChild(ranges); + const outlineLayer = new Container(); + app.stage.addChild(outlineLayer); + const units = new Container(); + units.sortableChildren = true; + app.stage.addChild(units); + const projLayer = new Graphics(); + app.stage.addChild(projLayer); + const fxLayer = new Container(); + app.stage.addChild(fxLayer); + const chromeLayer = new Container(); + app.stage.addChild(chromeLayer); + const p0 = battle.actors.find((a) => a.id === "P0"); + if (p0) { + p0.attackTimer = 0; + if (state.pos) { + p0.x = state.pos.x; + p0.y = state.pos.y; + } + } + const R = await createCombatRenderer({ pixi, defsById, layers: { units, fx: fxLayer, projLayer }, coords: { mapX, mapY, depthOf }, getBattle: () => battle }); + if (myToken !== token) { + app.destroy(true, { children: true }); + return; + } + const outlines = ["E0", "E1", "E2"].map(() => { + const c = new Container(); + const filter = new ColorMatrixFilter(); + filter.matrix = MAT_WHITE; + c.filters = [filter]; + c.visible = false; + const copies = OUT_OFF2.map(() => { + const s = new Sprite(Texture.EMPTY); + s.anchor.set(0.5, R.view.E0?.sheets?.footFrac ?? 0.5); + return s; + }); + copies.forEach((s) => c.addChild(s)); + outlineLayer.addChild(c); + return { c, filter, copies }; + }); + const reticles = new Graphics(); + chromeLayer.addChild(reticles); + const hud = new Text({ text: "", style: { fill: 2765632, fontSize: 13, fontFamily: "monospace", lineHeight: 18 } }); + hud.position.set(12, 10); + chromeLayer.addChild(hud); + const cursor = { logIdx: 0 }; + let acc = 0; + const foeActor = (i) => battle.actors.find((a) => a.id === "E" + i); + const NAME = ctx.targetName || "foe"; + app.ticker.add((t) => { + if (myToken !== token) return; + const dtMS = t.deltaMS, dt = dtMS / 1e3; + const player = battle.actors.find((a) => a.id === "P0"); + const tg = ctx.targeting(); + const selectedSkillId = ctx.getSelectedSkillId(); + const nearestFoeGw = [0, 1, 2].map(foeActor).filter((a) => a && a.alive).reduce((m, a) => Math.min(m, Math.hypot(a.x - (player?.x ?? 0), a.y - (player?.y ?? 0))), Infinity); + setInput(battle, "P0", { moveX: input.keys.x, moveY: input.keys.y }); + if (input.req.attack) { + input.req.attack = false; + if (nearestFoeGw <= tg.basicGw) setInput(battle, "P0", { action: "basic" }); + } + if (input.req.skill) { + input.req.skill = false; + if (selectedSkillId && (tg.skillSupport || tg.skillGw != null && nearestFoeGw <= tg.skillGw)) setInput(battle, "P0", { action: selectedSkillId }); + } + if (input.req.reset) { + input.req.reset = false; + for (const a of battle.actors) if (a.control === "dummy") { + a.alive = true; + a.hp = a.maxHp = a.baseMaxHp; + a.conds = []; + a.marks = {}; + a.mods = []; + a.casting = null; + a.kd = 0; + a.deadAt = null; + } + battle.log.length = 0; + cursor.logIdx = 0; + R.resetForNewBattle(); + state.targetId = null; + } + acc += Math.min(dt, 0.1); + while (acc >= STEP) { + step(battle, STEP); + acc -= STEP; + } + if (player) state.pos = { x: player.x, y: player.y }; + R.syncActors(battle, dtMS, battle.t); + R.updateFloats(dtMS); + R.drawProjectiles(battle); + R.processLog(battle, cursor); + const { basicGw, skillGw } = tg; + const px = mapX(player?.x ?? 0), py = mapY(player?.y ?? 0); + ranges.clear(); + ranges.ellipse(px, py, basicGw * sx, basicGw * sy).fill({ color: C_BASIC, alpha: 0.05 }); + if (skillGw != null) ranges.ellipse(px, py, skillGw * sx, skillGw * sy).stroke({ width: 2, color: C_SKILL, alpha: 0.6 }); + ranges.ellipse(px, py, basicGw * sx, basicGw * sy).stroke({ width: 2, color: C_BASIC, alpha: 0.55 }); + const targetable = []; + const infoArr = [0, 1, 2].map((i) => { + const a = foeActor(i); + if (!a) return null; + const d = a.alive ? Math.hypot(a.x - (player?.x ?? 0), a.y - (player?.y ?? 0)) : Infinity; + const inBasic = a.alive && d <= basicGw, inSkill = a.alive && skillGw != null && d <= skillGw; + if (inBasic || inSkill) targetable.push(i); + return { a, d, inBasic, inSkill }; + }); + if (input.req.f) { + input.req.f = false; + if (targetable.length) { + const at = targetable.indexOf(state.targetId); + state.targetId = targetable[(at + 1) % targetable.length]; + } + } + let cur = state.targetId; + if (cur == null || !targetable.includes(cur)) cur = targetable.length ? targetable.reduce((b, i) => infoArr[i].d < infoArr[b].d ? i : b, targetable[0]) : null; + state.targetId = cur; + reticles.clear(); + infoArr.forEach((it, i) => { + const o = outlines[i]; + if (!it) { + o.c.visible = false; + return; + } + const v = R.view["E" + i]; + const show = it.inBasic || it.inSkill; + o.c.visible = show; + if (show && v?.sprite) { + o.filter.matrix = it.inSkill ? MAT_AMBER : MAT_WHITE; + o.c.alpha = i === cur ? 1 : 0.55; + const ax = mapX(it.a.x), ay = mapY(it.a.y); + o.copies.forEach((s, k) => { + s.texture = v.sprite.texture; + s.scale.copyFrom(v.sprite.scale); + s.position.set(ax + OUT_OFF2[k][0], ay + OUT_OFF2[k][1]); + }); + } + if (i === cur && it.a.alive) { + const ax = mapX(it.a.x), top = mapY(it.a.y) - (v?.sheets ? v.sheets.footFrac * v.sheets.cell * depthOf(it.a) : 40) - 8; + reticles.moveTo(ax, top + 6).lineTo(ax - 6, top - 3).lineTo(ax + 6, top - 3).fill({ color: it.inSkill ? C_SKILL : C_BASIC }); + } + }); + const aliveN = [0, 1, 2].filter((i) => foeActor(i)?.alive).length; + const tInfo = cur != null ? infoArr[cur] : null; + hud.text = tInfo ? `Target: ${NAME} ${cur + 1} \xB7 ${Math.round(tInfo.d)} gw \xB7 ${Math.max(0, Math.round(tInfo.a.hp))}/${Math.round(tInfo.a.baseMaxHp)} hp +${tInfo.inSkill ? "\u25C6 in skill range" : "\u25C7 basic-attack range"} +F cycle target (${targetable.length} in range) \xB7 \` reset` : `${aliveN ? `No ${NAME.toLowerCase()} in range \u2014 move closer (WASD)` : `All ${NAME.toLowerCase()}s down \u2014 \` to reset`} +F cycle target \xB7 \` reset`; + }); + }); + } + return { rebuild, destroy() { + token++; + killApp(); + } }; +} + +// ../auto-battler/src/render/enemiesSandbox.js +var ATTACK_TYPES = ["melee", "ranged"]; +var ENEMY_ACCENT = "#b5532e"; +var TARGET_SLUG = "rts-humans-worker"; +var ACO_MAXHP = 80; +var DEF_BASIC_DMG = 18; +var DEF_SKILL_DMG = 30; +var MELEE_CATS = /* @__PURE__ */ new Set(["melee_attack", "lead_attack", "offhand_attack", "dual_attack", "scythe_attack"]); +var RANGED_CATS = /* @__PURE__ */ new Set(["bow_attack", "spear_attack"]); +var STAT_FIELDS = [ + { key: "hp", label: "Health", def: 100 }, + { key: "armor", label: "Armor", def: 60 }, + { key: "basicDamage", label: "Basic dmg", def: DEF_BASIC_DMG }, + { key: "skillDamage", label: "Skill dmg", def: DEF_SKILL_DMG } +]; +var SKILL_PROFESSIONS = PROFESSION_BY_ID.filter((p) => CB_SKILLS.some((s) => s.profession === p)); +function skillRangeGw(skill) { + if (!skill) return null; + if (MELEE_CATS.has(skill.category)) return MELEE_GW; + if (RANGED_CATS.has(skill.category)) return BOW_GW; + return skill.target == null || /foe/.test(skill.target) ? SPELL_GW : null; +} +var flatChars = (packs) => (packs || []).flatMap((p) => p.characters || []).filter((c) => c.idle && c.walk); +var slugify = (s) => s.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "enemy"; +function el(tag, props = {}, kids = []) { + const n = document.createElement(tag); + for (const [k, v] of Object.entries(props)) { + if (k === "class") n.className = v; + else if (k === "html") n.innerHTML = v; + else if (k === "style" && typeof v === "object") Object.assign(n.style, v); + else if (k.startsWith("on") && typeof v === "function") n.addEventListener(k.slice(2), v); + else if (v != null) n.setAttribute(k, v); + } + for (const kid of [].concat(kids)) if (kid != null) n.append(kid); + return n; +} +function renderSkillDetail(skill) { + if (!skill) return null; + const c = skill.cost || {}; + const chips = []; + if (c.energy) chips.push({ glyph: COST_GLYPH.energy, text: c.energy, tone: "energy" }); + if (c.adrenaline) chips.push({ glyph: COST_GLYPH.adrenaline, text: c.adrenaline, tone: "adrenaline" }); + if (c.sacrifice) chips.push({ glyph: COST_GLYPH.sacrifice, text: `${c.sacrifice}%`, tone: "sacrifice" }); + if (skill.cast) chips.push({ glyph: COST_GLYPH.activation, text: `${skill.cast}s`, tone: "time" }); + if (skill.recharge) chips.push({ glyph: COST_GLYPH.recharge, text: `${skill.recharge}s`, tone: "time" }); + const reqs = skill.requires || []; + const onHit = reqs.includes("on_hit"); + const gates = reqs.filter((r) => r !== "on_hit"); + return el("div", { class: "cbgame-skill-detail", "data-testid": "cbgame-skill-detail" }, [ + el("div", { class: "cbgame-skill-detail-head" }, [ + el("img", { src: iconUrl(skill.id), alt: "", class: `cbgame-skill-detail-icon${skill.elite ? " elite" : ""}`, width: 38, height: 38 }), + el("div", { class: "cbgame-skill-detail-title" }, [ + el("div", { class: "cbgame-skill-detail-name" }, skill.name + (skill.elite ? " \u2605" : "")), + el("div", { class: "cbgame-skill-detail-meta" }, [ + el("span", {}, skill.category.replace(/_/g, " ")), + el("span", { class: "dot" }, "\xB7"), + el("span", {}, skill.attribute), + skill.target ? el("span", { class: "cbgame-skill-target" }, skill.target) : null + ]) + ]) + ]), + chips.length ? el("div", { class: "cbgame-skill-stats" }, chips.map((ch) => el("span", { class: `cbgame-skill-stat tone-${ch.tone}` }, [el("img", { src: ch.glyph, alt: "", width: 13, height: 13 }), String(ch.text)]))) : null, + onHit || gates.length ? el("div", { class: "cbgame-skill-triggers" }, [ + onHit ? el("span", { class: "cbgame-skill-trigger" }, "\u2694 On hit") : null, + ...gates.map((g) => el("span", { class: "cbgame-skill-trigger" }, "\u21B3 " + req(g))) + ]) : null, + el("div", { class: "cbgame-skill-fx" }, (skill.effects || []).map((e) => { + const { color, label } = effectStyle(e); + const isCond = effectKind(e) === "condition"; + return el("div", { class: "cbgame-skill-fx-row", style: { "--fx": color } }, [ + isCond ? el("img", { class: "cbgame-skill-fx-cond", src: conditionIcon(e.condition), alt: "", width: 16, height: 16 }) : el("span", { class: "cbgame-skill-fx-tag" }, label), + el("span", { class: "cbgame-skill-fx-text" }, effect(e)) + ]); + })) + ]); +} +function mountEnemiesSandbox(pixi, host, opts = {}) { + const packs = opts.packs || []; + const fx = opts.fx || []; + const editable = !!opts.editable; + let config = opts.config && opts.config.enemies ? opts.config : { enemies: {} }; + const chars = flatChars(packs); + let active = opts.selected && config.enemies?.[opts.selected] && opts.selected || Object.keys(config.enemies || {})[0] || null; + let selectedSkillId = null; + let skillFilter = "All"; + const keys = { x: 0, y: 0 }; + const req2 = { attack: false, skill: false, f: false, reset: false }; + const stageState = { pos: null, targetId: null }; + let targeting = { basicGw: MELEE_GW, skillGw: null }; + const enemyIds = () => Object.keys(config.enemies || {}); + const enemyOf = () => config.enemies?.[active] || {}; + const accentOf = () => enemyOf().color || ENEMY_ACCENT; + const activeCharOf = () => chars.find((c) => c.slug === enemyOf().sprite) || chars[0] || null; + const attackTypeOf = () => enemyOf().attackType || "melee"; + const statsOf = () => enemyOf().stats || {}; + const enemySkillIdsOf = () => enemyOf().skills || []; + const enemySkillsOf = () => enemySkillIdsOf().map((id) => CB_SKILLS.find((s) => s.id === id)).filter(Boolean); + const selectedSkillOf = () => enemySkillsOf().find((s) => s.id === selectedSkillId) || null; + const animsOf = (ch) => { + if (!ch) return []; + const list = []; + if (ch.attack) list.push({ key: "attack", name: "Attack", url: ch.attack, effect: ch.attackEffect || null }); + if (ch.jump) list.push({ key: "jump", name: "Jump", url: ch.jump, effect: null }); + for (const e of ch.extras || []) list.push({ key: e.key, name: e.name, url: e.url, effect: e.effect || null }); + return list; + }; + const attackArcs = fx.filter((e) => e.category === "attack"); + const weaponArcs = fx.filter((e) => e.category === "weapon"); + const specialFx = fx.filter((e) => e.category === "special"); + const arrowVariants = fx.filter((e) => e.category === "projectile"); + const arcTypes = [...new Set(attackArcs.map((e) => e.type))]; + const weaponFamilies = [...new Set(weaponArcs.map((e) => e.label.includes(" \xB7 ") ? e.label.split(" \xB7 ")[0] : "legendary"))]; + const effectByKey = /* @__PURE__ */ new Map(); + for (const e of fx) if (e.category === "attack" || e.category === "weapon" || e.category === "special") effectByKey.set(e.key, e); + const resolveAnim = (anims, key) => anims.find((a) => a.key === key) || anims[0] || null; + const resolveEffectUrls = (animObj, list) => [...new Set((list || []).map((k) => k === "auto" ? animObj?.effect || null : effectByKey.get(k)?.url || null).filter(Boolean))]; + const stackOf = (cfg, key) => { + if (Array.isArray(cfg?.[key])) return cfg[key].filter((k) => k && k !== "none"); + const single = cfg?.[key.replace(/s$/, "")]; + if (single === void 0 || single === null) return ["auto"]; + return single === "none" ? [] : [single]; + }; + function persist(next) { + config = next; + opts.onSave?.(next); + } + function save(next) { + persist(next); + syncTargeting(); + renderRoster(); + renderControls(); + renderSkills(); + renderCustomize(); + rebuildScene(); + } + const updateEnemy = (patch) => save({ ...config, enemies: { ...config.enemies, [active]: { ...config.enemies?.[active] || {}, ...patch } } }); + const updateStats = (patch) => updateEnemy({ stats: { ...enemyOf().stats || {}, ...patch } }); + const updateSkillCfg = (id, patch) => updateEnemy({ skillCfg: { ...enemyOf().skillCfg || {}, [id]: { ...enemyOf().skillCfg?.[id] || {}, ...patch } } }); + function addEnemy() { + if (!editable) return; + const name = prompt("Enemy name?"); + if (!name) return; + let id = slugify(name); + let n = 2; + while (config.enemies?.[id]) id = `${slugify(name)}-${n++}`; + const def = { name, sprite: chars[0]?.slug || "", attackType: "melee", attackAnim: "attack", attackEffects: ["auto"], projectile: "auto", stats: { hp: 100, armor: 60, basicDamage: DEF_BASIC_DMG, skillDamage: DEF_SKILL_DMG }, skills: [], skillCfg: {} }; + persist({ ...config, enemies: { ...config.enemies, [id]: def } }); + active = id; + selectedSkillId = null; + syncTargeting(); + renderRoster(); + renderControls(); + renderSkills(); + renderCustomize(); + rebuildScene(); + } + function deleteEnemy(id) { + if (!editable || !confirm(`Delete "${config.enemies[id]?.name || id}"?`)) return; + const next = { ...config.enemies }; + delete next[id]; + if (active === id) { + active = Object.keys(next)[0] || null; + selectedSkillId = null; + } + save({ ...config, enemies: next }); + } + const removeSkill = (id) => { + updateEnemy({ skills: enemySkillIdsOf().filter((s) => s !== id) }); + if (selectedSkillId === id) setSelectedSkill(null); + }; + const toggleSkill = (id) => { + if (!editable) { + setSelectedSkill(id); + return; + } + if (enemySkillIdsOf().includes(id)) removeSkill(id); + else { + updateEnemy({ skills: [...enemySkillIdsOf(), id] }); + setSelectedSkill(id); + } + }; + const listAside = el("aside", { class: "classes-list" }); + const stageEl = el("div", { class: "classes-stage" }); + const controlsEl = el("div", { class: "classes-controls" }); + const skillsEl = el("div", { class: "classes-skills enemies-skills" }); + const customizeEl = el("aside", { class: "classes-customize" }); + const root = el("div", { class: "classes" }, [listAside, el("div", { class: "classes-main" }, [stageEl, controlsEl, skillsEl]), customizeEl]); + host.append(root); + const labelFor = (k) => k === "auto" ? "animation effect" : effectByKey.get(k)?.label || k; + const familyOf = (e) => e.label.includes(" \xB7 ") ? e.label.split(" \xB7 ")[0] : "legendary"; + function effectStack(list, onChange, tid) { + const chips = el( + "div", + { class: "classes-fx-chips" }, + list.length === 0 ? el("span", { class: "muted" }, "none") : list.map((k, i) => el("span", { class: "classes-fx-chip" }, [labelFor(k), editable ? el("button", { onclick: () => onChange(list.filter((_, j) => j !== i)) }, "\xD7") : null])) + ); + const kids = [chips]; + if (editable) { + kids.push(el("select", { onchange: (e) => { + const v = e.target.value; + if (v && !list.includes(v)) onChange([...list, v]); + e.target.value = ""; + } }, [ + el("option", { value: "" }, "+ add effect\u2026"), + el("option", { value: "auto" }, "animation effect"), + ...arcTypes.map((t) => el("optgroup", { label: `attack: ${t}` }, attackArcs.filter((e) => e.type === t).map((e) => el("option", { value: e.key }, e.element)))), + ...weaponFamilies.map((f) => el("optgroup", { label: `weapon: ${f}` }, weaponArcs.filter((e) => familyOf(e) === f).map((e) => el("option", { value: e.key }, e.label.includes(" \xB7 ") ? e.label.split(" \xB7 ")[1] : e.label)))), + specialFx.length ? el("optgroup", { label: "special (True Heroes)" }, specialFx.map((e) => el("option", { value: e.key }, e.label))) : null + ])); + } + return el("div", { class: "classes-fx-stack", "data-testid": tid }, kids); + } + const selectField = (value, options, onChange, tid) => { + const s = el("select", { "data-testid": tid, onchange: (e) => onChange(e.target.value) }, options.map(([v, label]) => el("option", { value: v }, label))); + s.value = value; + if (!editable) s.disabled = true; + return s; + }; + function renderRoster() { + const head = el("div", { class: "enemies-roster-head" }, [el("h2", {}, "Enemies"), editable ? el("button", { class: "enemies-add", title: "New enemy", onclick: addEnemy }, "+") : null]); + const ids = enemyIds(); + const ul = el("ul", {}, ids.length ? ids.map((id) => el("li", { class: "enemies-row" }, [ + el("button", { class: `classes-link${active === id ? " active" : ""}`, style: { "--c": config.enemies[id]?.color || ENEMY_ACCENT }, onclick: () => setActive(id) }, config.enemies[id]?.name || id), + editable ? el("button", { class: "enemies-del", title: "Delete", onclick: () => deleteEnemy(id) }, "\xD7") : null + ])) : el("li", { class: "enemies-empty" }, `No enemies yet.${editable ? " Click + to add one." : ""}`)); + listAside.replaceChildren(head, ul); + } + function renderControls() { + const atkBtn = el("button", { class: "classes-attack-btn", "data-testid": "enemies-attack", onclick: () => { + req2.attack = true; + } }, "\u2694 Attack (Space)"); + const skBtn = el("button", { class: "classes-attack-btn", "data-testid": "enemies-play-skill", onclick: () => { + req2.skill = true; + } }, "\u25B6 Skill (E)"); + if (!selectedSkillOf()) skBtn.disabled = true; + controlsEl.replaceChildren(atkBtn, skBtn, el("span", { class: "classes-taskbar-hint muted" }, "WASD move \xB7 Space attack \xB7 E skill \xB7 F target \xB7 ` reset")); + } + function renderSkills() { + const assigned = enemySkillsOf(); + const catalog = skillFilter === "All" ? CB_SKILLS : CB_SKILLS.filter((s) => s.profession === skillFilter); + const assignedRow = el("div", { class: "enemies-assigned", "data-testid": "enemies-assigned" }, assigned.length ? assigned.map((s) => el("span", { class: "enemies-skill-wrap" }, [ + el("button", { class: `classes-skill on-bar${s.elite ? " elite" : ""}${selectedSkillId === s.id ? " selected" : ""}`, title: `${s.name} \u2014 click to edit`, onclick: () => setSelectedSkill(s.id) }, el("img", { src: iconUrl(s.id), alt: s.name, loading: "lazy", width: 36, height: 36 })), + editable ? el("button", { class: "enemies-remove-badge", title: `Remove ${s.name}`, onclick: () => removeSkill(s.id) }, "\xD7") : null + ])) : el("span", { class: "muted" }, "No skills assigned \u2014 add some from the catalog below.")); + const catalogEl = el("div", { class: "enemies-catalog", "data-testid": "enemies-catalog" }, catalog.length ? catalog.map((s) => { + const on = enemySkillIdsOf().includes(s.id); + return el("button", { class: `classes-skill ${on ? "on-bar" : "off-bar"}${s.elite ? " elite" : ""}${selectedSkillId === s.id ? " selected" : ""}`, title: `${s.name} (${s.profession})`, onclick: () => toggleSkill(s.id) }, el("img", { src: iconUrl(s.id), alt: s.name, loading: "lazy", width: 36, height: 36 })); + }) : el("span", { class: "muted" }, "No skills for this filter.")); + const filterEl = el("div", { class: "enemies-filter", "data-testid": "enemies-skill-filter" }, ["All", ...SKILL_PROFESSIONS].map((p) => el("button", { class: skillFilter === p ? "active" : "", onclick: () => { + skillFilter = p; + renderSkills(); + } }, p))); + skillsEl.replaceChildren( + el("div", { class: "classes-skills-head" }, [el("strong", { style: { color: accentOf() } }, enemyOf().name || "\u2014"), " skills", el("span", { class: "muted" }, ` \xB7 ${assigned.length} \xB7 click to select`)]), + assignedRow, + el("div", { class: "enemies-skill-sub" }, `Catalog \xB7 click to ${editable ? "add / remove" : "inspect"}`), + catalogEl, + filterEl + ); + } + function renderCustomize() { + customizeEl.style.setProperty("--c", accentOf()); + if (!active) { + customizeEl.replaceChildren(el("h2", {}, "Customize"), el("p", { class: "muted" }, "Select or add an enemy.")); + return; + } + const enemy = enemyOf(); + const activeChar = activeCharOf(); + const attackType = attackTypeOf(); + const stats = statsOf(); + const anims = animsOf(activeChar); + const basicAnim = resolveAnim(anims, enemy.attackAnim); + const selectedSkill = selectedSkillOf(); + const kids = [el("h2", {}, "Customize")]; + if (!editable) kids.push(el("p", { class: "classes-readonly" }, "read-only \xB7 edit locally and push")); + const nameInput = el("input", { type: "text", value: enemy.name || "", "data-testid": "enemies-name", oninput: (e) => updateEnemy({ name: e.target.value }) }); + if (!editable) nameInput.disabled = true; + kids.push( + el("label", { class: "classes-field" }, ["Name", nameInput]), + el("label", { class: "classes-field" }, ["Sprite", selectField(enemy.sprite || "", chars.map((c) => [c.slug, c.name]), (v) => updateEnemy({ sprite: v }), "enemies-sprite")]), + el("label", { class: "classes-field" }, ["Attack type", selectField(attackType, ATTACK_TYPES.map((t) => [t, t]), (v) => updateEnemy({ attackType: v }), "enemies-attack-type")]), + el("label", { class: "classes-field" }, ["Attack animation", selectField(basicAnim?.key || "", anims.length ? anims.map((a) => [a.key, a.name]) : [["", "(none)"]], (v) => updateEnemy({ attackAnim: v }), "enemies-attack-anim")]), + el("div", { class: "classes-field" }, ["Attack effects ", el("small", { class: "muted" }, "(stack)"), effectStack(stackOf(enemy, "attackEffects"), (v) => updateEnemy({ attackEffects: v }), "enemies-attack-effect")]) + ); + if (attackType === "ranged") { + kids.push(el("label", { class: "classes-field" }, ["Projectile", selectField(enemy.projectile || "auto", [["auto", "auto (character's / arrow)"], ["none", "none"], ...arrowVariants.map((e) => [e.key, `arrow \xB7 ${e.element}`])], (v) => updateEnemy({ projectile: v }), "enemies-projectile")])); + } + const statsGrid = el("div", { class: "enemies-stats", "data-testid": "enemies-stats" }, STAT_FIELDS.map((f) => { + const inp = el("input", { type: "number", min: "0", value: String(stats[f.key] ?? f.def), "data-testid": `enemies-stat-${f.key}`, oninput: (e) => updateStats({ [f.key]: e.target.value === "" ? "" : Number(e.target.value) }) }); + if (!editable) inp.disabled = true; + return el("label", { class: "enemies-stat" }, [f.label, inp]); + })); + kids.push(el("div", { class: "classes-field" }, "Stats"), statsGrid); + if (selectedSkill) { + const skillCfg = enemy.skillCfg?.[selectedSkill.id] || {}; + const skillAnim = resolveAnim(anims, skillCfg.anim); + kids.push(el("div", { class: "classes-skill-detail", "data-testid": "enemies-skill-detail" }, [ + el("h2", {}, "Skill"), + renderSkillDetail(selectedSkill), + el("label", { class: "classes-field" }, ["Skill animation", selectField(skillAnim?.key || "", anims.map((a) => [a.key, a.name]), (v) => updateSkillCfg(selectedSkill.id, { anim: v }), "enemies-skill-anim")]), + el("div", { class: "classes-field" }, ["Skill effects ", el("small", { class: "muted" }, "(stack)"), effectStack(stackOf(skillCfg, "effects"), (v) => updateSkillCfg(selectedSkill.id, { effects: v }), "enemies-skill-effect")]), + editable ? el("div", { class: "classes-skill-actions" }, el("button", { class: "classes-skill-add", onclick: () => removeSkill(selectedSkill.id) }, "Remove from enemy")) : null + ])); + } + customizeEl.replaceChildren(...kids); + } + function syncTargeting() { + targeting = { basicGw: attackTypeOf() === "ranged" ? BOW_GW : MELEE_GW, skillGw: skillRangeGw(selectedSkillOf()), skillSupport: isSupport(selectedSkillOf()) }; + } + function setActive(id) { + if (id === active) return; + active = id; + selectedSkillId = null; + syncTargeting(); + renderRoster(); + renderControls(); + renderSkills(); + renderCustomize(); + rebuildScene(); + } + function setSelectedSkill(id) { + selectedSkillId = id; + syncTargeting(); + renderSkills(); + renderControls(); + renderCustomize(); + rebuildScene(); + } + const stage = mountSandboxStage(pixi, stageEl, { + input: { keys, req: req2 }, + targeting: () => targeting, + getSelectedSkillId: () => selectedSkillId, + targetName: "Worker", + state: stageState, + canvasTestId: "enemies-canvas" + }); + function buildEnemiesBattle() { + const activeChar = activeCharOf(); + if (!activeChar) return null; + const attackType = attackTypeOf(); + const stats = statsOf(); + const worker = chars.find((c) => c.slug === TARGET_SLUG) || null; + const anims = animsOf(activeChar); + const basicAnim = resolveAnim(anims, enemyOf().attackAnim); + const skillCfg = enemyOf().skillCfg?.[selectedSkillId] || {}; + const skillAnim = selectedSkillId ? resolveAnim(anims, skillCfg.anim) : null; + const skillEffectUrls = selectedSkillId ? resolveEffectUrls(skillAnim, stackOf(skillCfg, "effects")) : []; + const fxCellOf = (url) => fx.find((e) => e.url === url)?.cell || 32; + const enemyUnit = { name: enemyOf().name || active, control: "player", attackType, stats: { hp: stats.hp ?? 100, armor: stats.armor ?? 60, basicDamage: stats.basicDamage ?? DEF_BASIC_DMG }, skills: selectedSkillId ? [selectedSkillId] : [] }; + const workerUnit = () => ({ name: "Worker", control: "dummy", stats: { hp: ACO_MAXHP, armor: 0, basicDamage: 0 }, attackType: "melee" }); + const battle = makeTeamBattle({ players: [enemyUnit], enemies: [workerUnit(), workerUnit(), workerUnit()], sandbox: true, freeCast: true }); + const defsById = { + P0: { + name: enemyOf().name || active, + idle: activeChar.idle, + walk: activeChar.walk, + attack: activeChar.attack || activeChar.idle, + dmg: activeChar.dmg, + die: activeChar.die, + recolor: null, + skillFx: selectedSkillId ? { [selectedSkillId]: { animUrl: skillAnim?.url || basicAnim?.url, effects: skillEffectUrls.map((url) => ({ url, cell: fxCellOf(url) })) } } : {} + } + }; + ["E0", "E1", "E2"].forEach((id) => { + defsById[id] = { name: "Worker", idle: worker?.idle, walk: worker?.walk || worker?.idle, attack: worker?.attack || worker?.idle, dmg: worker?.dmg, die: worker?.die, recolor: null }; + }); + return { battle, defsById }; + } + const rebuildScene = () => stage.rebuild(buildEnemiesBattle); + const keyMap = { w: [0, -1], s: [0, 1], a: [-1, 0], d: [1, 0], arrowup: [0, -1], arrowdown: [0, 1], arrowleft: [-1, 0], arrowright: [1, 0] }; + const keyset = {}; + const applyKeys = () => { + let x = 0, y = 0; + for (const k in keyset) if (keyset[k]) { + x += keyMap[k][0]; + y += keyMap[k][1]; + } + keys.x = Math.sign(x); + keys.y = Math.sign(y); + }; + const onDown = (e) => { + const tag = e.target?.tagName; + if (tag === "INPUT" || tag === "SELECT" || tag === "TEXTAREA") return; + if (e.code === "Space" || e.key === " ") { + req2.attack = true; + e.preventDefault(); + return; + } + if (e.key.toLowerCase() === "e") { + req2.skill = true; + e.preventDefault(); + return; + } + if (e.key.toLowerCase() === "f") { + req2.f = true; + e.preventDefault(); + return; + } + if (e.key === "`" || e.key === "~" || e.code === "Backquote") { + req2.reset = true; + e.preventDefault(); + return; + } + const k = e.key.toLowerCase(); + if (keyMap[k]) { + keyset[k] = true; + applyKeys(); + e.preventDefault(); + } + }; + const onUp = (e) => { + const k = e.key.toLowerCase(); + if (keyMap[k]) { + keyset[k] = false; + applyKeys(); + } + }; + window.addEventListener("keydown", onDown); + window.addEventListener("keyup", onUp); + syncTargeting(); + renderRoster(); + renderControls(); + renderSkills(); + renderCustomize(); + rebuildScene(); + return { + destroy() { + stage.destroy(); + window.removeEventListener("keydown", onDown); + window.removeEventListener("keyup", onUp); + host.replaceChildren(); + } + }; +} +export { + mountEnemiesSandbox +}; diff --git a/web/engine.js b/web/engine.js index 7366f261aed630e13dc9042994ddf2fe3dfa75bf..3573a2f7f44ad5c7b4c3fd26b05fb26efd202d86 100644 --- a/web/engine.js +++ b/web/engine.js @@ -791,6 +791,10 @@ function makeActor(unit, team, id, slot) { team, name: unit.name || id, profession: unit.profession || null, + // control: 'ai' (autonomous), 'player' (driven by b.input via setInput) or + // 'dummy' (passive target — takes damage, never acts). Sandboxes use the + // latter two so the Classes/Enemies hero fights real engine dummies. + control: unit.control || "ai", role: tpl.role, rank: unit.rank ?? 12, armor: tpl.armor ?? 0, @@ -823,11 +827,15 @@ function makeActor(unit, team, id, slot) { kd: 0 }; } -function makeTeamBattle({ seed = 1, players = [], enemies = [] } = {}) { +function makeTeamBattle({ seed = 1, players = [], enemies = [], sandbox = false, respawnDummies = 0, freeCast = false } = {}) { const actors = []; players.filter(Boolean).slice(0, 5).forEach((u, i) => actors.push(makeActor(u, "player", `P${i}`, i))); enemies.filter(Boolean).slice(0, 5).forEach((u, i) => actors.push(makeActor(u, "enemy", `E${i}`, i))); - return { t: 0, rng: makeRng(seed), actors, projectiles: [], log: [], over: false, winner: null }; + return { t: 0, rng: makeRng(seed), actors, projectiles: [], log: [], over: false, winner: null, sandbox, respawnDummies, freeCast, input: {} }; +} +function setInput(b, id, cmd) { + if (!b.input) b.input = {}; + b.input[id] = { ...b.input[id] || {}, ...cmd }; } var ADJACENT_GW = 140; var BODY_RADIUS = { melee: 35, ranged: 32 }; @@ -971,6 +979,7 @@ function kill(b, a) { if (!a.alive) return; a.alive = false; a.hp = 0; + a.deadAt = b.t; log(b, "death", a); if (hasCond(a, "disease")) for (const x of adjacentTo(b, a)) applyCondition(b, x, "disease", 10); } @@ -1303,8 +1312,9 @@ function skillTarget(b, a, s, foe) { if (s.target === "other_ally") return mostWoundedAlly(b, a, false); return foe; } -function usable(b, a, s, tgt, foe) { +function usable(b, a, s, tgt, foe, free = false) { if (!tgt) return false; + if (free) return true; if (b.t < (a.recharge[s.name] || 0)) return false; if (s.cost?.energy && a.energy < s.cost.energy) return false; if (s.cost?.adrenaline && a.adrenaline < s.cost.adrenaline) return false; @@ -1441,9 +1451,82 @@ function resolveOverlaps(b) { } } var isImmovable = (b, a) => !!a.casting || isKd(b, a); +function stepPlayer(b, a, foe, dt) { + const cmd = b.input && b.input[a.id] || {}; + const mx = cmd.moveX || 0, my = cmd.moveY || 0; + if (mx || my) { + const len = Math.hypot(mx, my) || 1; + const speed = a.moveSpeed * moveSpeedMult(b, a); + a.x = clampField(a.x + mx / len * speed * dt, a.radius, FIELD.w); + a.y = clampField(a.y + my / len * speed * dt, a.radius, FIELD.h); + a.moving = true; + a.faceX = mx < 0 ? -1 : mx > 0 ? 1 : a.faceX; + a.faceY = my < 0 ? -1 : my > 0 ? 1 : a.faceY; + a.facing = a.faceX; + } + if (a.casting) { + a.casting.left -= dt; + if (a.casting.left <= 0) { + const { skill, target } = a.casting; + a.casting = null; + performSkill(b, a, target?.alive ? target : foe, skill); + } + return; + } + const action = cmd.action; + if (!action) return; + const free = !!b.freeCast; + if (free) { + a.energy = a.maxEnergy; + a.adrenaline = 25; + } + const clear = () => { + if (b.input) b.input[a.id] = { ...cmd, action: null }; + }; + if (action === "basic") { + if (!free && a.role !== "ranged" && edgeGap(a, foe) > reachOf(a)) { + clear(); + return; + } + if (!free && a.attackTimer > 0) return; + fireOnAction(b, a); + strike(b, a, foe, null); + clear(); + return; + } + const s = a.bar.find((x) => x.id === action); + if (!s) { + clear(); + return; + } + const tgt = skillTarget(b, a, s, foe); + if (!usable(b, a, s, tgt, foe, free)) return; + const cast = (s.cast || 0) * (hasCond(a, "dazed") ? 2 : 1); + if (cast <= 0) performSkill(b, a, tgt, s); + else { + applyActivationPenalty(b, a, s, cast); + a.casting = { skill: s, target: tgt, left: cast }; + } + clear(); +} +function reviveDummy(b, a) { + a.alive = true; + a.hp = a.maxHp = a.baseMaxHp; + a.energy = a.maxEnergy; + a.adrenaline = 0; + a.conds = []; + a.marks = {}; + a.mods = []; + a.casting = null; + a.kd = 0; + a.deadAt = null; +} function step(b, dt) { if (b.over) return; b.t += dt; + if (b.sandbox && b.respawnDummies) { + for (const a of b.actors) if (!a.alive && a.control === "dummy" && a.deadAt != null && b.t - a.deadAt >= b.respawnDummies) reviveDummy(b, a); + } for (const a of b.actors) { if (!a.alive) continue; a.energy = Math.min(a.maxEnergy, a.energy + a.energyRegen * dt); @@ -1471,6 +1554,11 @@ function step(b, dt) { a.casting = null; continue; } + if (a.control === "dummy") continue; + if (a.control === "player") { + stepPlayer(b, a, enemy, dt); + continue; + } if (a.casting) { a.casting.left -= dt; if (a.casting.left <= 0) { @@ -1496,6 +1584,7 @@ function step(b, dt) { } } resolveOverlaps(b); + if (b.sandbox) return; const playerAlive = b.actors.some((a) => a.alive && a.team === "player"); const enemyAlive = b.actors.some((a) => a.alive && a.team === "enemy"); if (!playerAlive || !enemyAlive) { @@ -1520,6 +1609,7 @@ export { isSupport, makeTeamBattle, runToEnd, + setInput, skillById, step, val diff --git a/web/gw/icons/condition-bleeding.jpg b/web/gw/icons/condition-bleeding.jpg new file mode 100644 index 0000000000000000000000000000000000000000..7c3cb2b8740b39facd07285e25c650351193ef92 Binary files /dev/null and b/web/gw/icons/condition-bleeding.jpg differ diff --git a/web/gw/icons/condition-blind.jpg b/web/gw/icons/condition-blind.jpg new file mode 100644 index 0000000000000000000000000000000000000000..85a5229a7ffc91d9ccefd34f814fbd8e412724a6 Binary files /dev/null and b/web/gw/icons/condition-blind.jpg differ diff --git a/web/gw/icons/condition-burning.jpg b/web/gw/icons/condition-burning.jpg new file mode 100644 index 0000000000000000000000000000000000000000..ca00ddd2d2639d59efa52981ff2a52114f760713 Binary files /dev/null and b/web/gw/icons/condition-burning.jpg differ diff --git a/web/gw/icons/condition-cracked-armor.jpg b/web/gw/icons/condition-cracked-armor.jpg new file mode 100644 index 0000000000000000000000000000000000000000..4f3c683670f76e8dd4fe3605286ec15898ba53cc Binary files /dev/null and b/web/gw/icons/condition-cracked-armor.jpg differ diff --git a/web/gw/icons/condition-crippled.jpg b/web/gw/icons/condition-crippled.jpg new file mode 100644 index 0000000000000000000000000000000000000000..cbf97d04d660b70f20734223a0a0e5f3a8400c99 Binary files /dev/null and b/web/gw/icons/condition-crippled.jpg differ diff --git a/web/gw/icons/condition-dazed.jpg b/web/gw/icons/condition-dazed.jpg new file mode 100644 index 0000000000000000000000000000000000000000..7bd26d22b20ad19b97de00254b9d5cb3f06dec9a Binary files /dev/null and b/web/gw/icons/condition-dazed.jpg differ diff --git a/web/gw/icons/condition-deep-wound.jpg b/web/gw/icons/condition-deep-wound.jpg new file mode 100644 index 0000000000000000000000000000000000000000..9ab75b0a52b0550d232f60af88d05a2b14a302ed Binary files /dev/null and b/web/gw/icons/condition-deep-wound.jpg differ diff --git a/web/gw/icons/condition-disease.jpg b/web/gw/icons/condition-disease.jpg new file mode 100644 index 0000000000000000000000000000000000000000..5a7787b73228ea6c7859a6c015fca1b3706f8ba6 Binary files /dev/null and b/web/gw/icons/condition-disease.jpg differ diff --git a/web/gw/icons/condition-poison.jpg b/web/gw/icons/condition-poison.jpg new file mode 100644 index 0000000000000000000000000000000000000000..b7897003c87a115fc8b108a1776ef9dbbb19a5ff Binary files /dev/null and b/web/gw/icons/condition-poison.jpg differ diff --git a/web/gw/icons/condition-weakness.jpg b/web/gw/icons/condition-weakness.jpg new file mode 100644 index 0000000000000000000000000000000000000000..08db62b89f366fc5ae2c88d77ff3dd641a6b832e Binary files /dev/null and b/web/gw/icons/condition-weakness.jpg differ diff --git a/web/gw/skills/1.jpg b/web/gw/skills/1.jpg new file mode 100644 index 0000000000000000000000000000000000000000..a1a44734b879e9ff962fef853f09d0a8943e01d8 Binary files /dev/null and b/web/gw/skills/1.jpg differ diff --git a/web/gw/skills/101.jpg b/web/gw/skills/101.jpg new file mode 100644 index 0000000000000000000000000000000000000000..64cea73fd80ce61a021bbd60fd897a5e583729bc Binary files /dev/null and b/web/gw/skills/101.jpg differ diff --git a/web/gw/skills/1024.jpg b/web/gw/skills/1024.jpg new file mode 100644 index 0000000000000000000000000000000000000000..93b0d1b239251d91f464cdcc8f78d8101e5641e5 Binary files /dev/null and b/web/gw/skills/1024.jpg differ diff --git a/web/gw/skills/106.jpg b/web/gw/skills/106.jpg new file mode 100644 index 0000000000000000000000000000000000000000..04ebfa0ca6bfb6fbf3d37eded09f44870ea2e790 Binary files /dev/null and b/web/gw/skills/106.jpg differ diff --git a/web/gw/skills/109.jpg b/web/gw/skills/109.jpg new file mode 100644 index 0000000000000000000000000000000000000000..f2fff0ea6ac6b97ef483b558b7cca8b4c1326e32 Binary files /dev/null and b/web/gw/skills/109.jpg differ diff --git a/web/gw/skills/1114.jpg b/web/gw/skills/1114.jpg new file mode 100644 index 0000000000000000000000000000000000000000..11d079834e9b1e9072a926fe105a4f6d219b9d56 Binary files /dev/null and b/web/gw/skills/1114.jpg differ diff --git a/web/gw/skills/115.jpg b/web/gw/skills/115.jpg new file mode 100644 index 0000000000000000000000000000000000000000..876ca9f26fcf72eaebbc726a1d9df25f1f81e703 Binary files /dev/null and b/web/gw/skills/115.jpg differ diff --git a/web/gw/skills/118.jpg b/web/gw/skills/118.jpg new file mode 100644 index 0000000000000000000000000000000000000000..de46dec7e1238958d4c6a9cfd71614696a7d2866 Binary files /dev/null and b/web/gw/skills/118.jpg differ diff --git a/web/gw/skills/121.jpg b/web/gw/skills/121.jpg new file mode 100644 index 0000000000000000000000000000000000000000..c4815289e9a7edc66daaddf32ad89d8ade68abc1 Binary files /dev/null and b/web/gw/skills/121.jpg differ diff --git a/web/gw/skills/135.jpg b/web/gw/skills/135.jpg new file mode 100644 index 0000000000000000000000000000000000000000..31e6731d9b649a434cdddd4d404f493253a81d24 Binary files /dev/null and b/web/gw/skills/135.jpg differ diff --git a/web/gw/skills/1466.jpg b/web/gw/skills/1466.jpg new file mode 100644 index 0000000000000000000000000000000000000000..021de1b57512308c283c9e09cb381076b05c6e49 Binary files /dev/null and b/web/gw/skills/1466.jpg differ diff --git a/web/gw/skills/1470.jpg b/web/gw/skills/1470.jpg new file mode 100644 index 0000000000000000000000000000000000000000..05ef3ad0aa97ef430424e35bbbcd0eaa49ec73a3 Binary files /dev/null and b/web/gw/skills/1470.jpg differ diff --git a/web/gw/skills/150.jpg b/web/gw/skills/150.jpg new file mode 100644 index 0000000000000000000000000000000000000000..56bcd580fec72a90d539b028af301cb9dcb4a67b Binary files /dev/null and b/web/gw/skills/150.jpg differ diff --git a/web/gw/skills/153.jpg b/web/gw/skills/153.jpg new file mode 100644 index 0000000000000000000000000000000000000000..76c24fc874df834feca82943b70327633afabf0b Binary files /dev/null and b/web/gw/skills/153.jpg differ diff --git a/web/gw/skills/1727.jpg b/web/gw/skills/1727.jpg new file mode 100644 index 0000000000000000000000000000000000000000..77ae1b592b8f64f9153c36ccdecb91d89214dee9 Binary files /dev/null and b/web/gw/skills/1727.jpg differ diff --git a/web/gw/skills/240.jpg b/web/gw/skills/240.jpg new file mode 100644 index 0000000000000000000000000000000000000000..54ca15068802a92320b87d59aba9657b686a8ef8 Binary files /dev/null and b/web/gw/skills/240.jpg differ diff --git a/web/gw/skills/245.jpg b/web/gw/skills/245.jpg new file mode 100644 index 0000000000000000000000000000000000000000..94f90aedb9f963f60c2abdf212d56dfc8e9e8124 Binary files /dev/null and b/web/gw/skills/245.jpg differ diff --git a/web/gw/skills/252.jpg b/web/gw/skills/252.jpg new file mode 100644 index 0000000000000000000000000000000000000000..6c22236a38a8994cc99a2275c6122bc6add18418 Binary files /dev/null and b/web/gw/skills/252.jpg differ diff --git a/web/gw/skills/281.jpg b/web/gw/skills/281.jpg new file mode 100644 index 0000000000000000000000000000000000000000..29a5cc57bedb0316efebbdb78cd87339f933a74a Binary files /dev/null and b/web/gw/skills/281.jpg differ diff --git a/web/gw/skills/282.jpg b/web/gw/skills/282.jpg new file mode 100644 index 0000000000000000000000000000000000000000..def60848ef977232d4c53b18952110433fc2df8f Binary files /dev/null and b/web/gw/skills/282.jpg differ diff --git a/web/gw/skills/283.jpg b/web/gw/skills/283.jpg new file mode 100644 index 0000000000000000000000000000000000000000..b5af3bad983d7e3e978fa64c165aff39adf4eb03 Binary files /dev/null and b/web/gw/skills/283.jpg differ diff --git a/web/gw/skills/307.jpg b/web/gw/skills/307.jpg new file mode 100644 index 0000000000000000000000000000000000000000..97d7f70d41d2c0b1ec87fadc3ff5834b55909485 Binary files /dev/null and b/web/gw/skills/307.jpg differ diff --git a/web/gw/skills/312.jpg b/web/gw/skills/312.jpg new file mode 100644 index 0000000000000000000000000000000000000000..503b463f1607402d8670e102239d00212dc30d0a Binary files /dev/null and b/web/gw/skills/312.jpg differ diff --git a/web/gw/skills/331.jpg b/web/gw/skills/331.jpg new file mode 100644 index 0000000000000000000000000000000000000000..049d2539ec70e97afb70bde204b980ebcfaaff52 Binary files /dev/null and b/web/gw/skills/331.jpg differ diff --git a/web/gw/skills/332.jpg b/web/gw/skills/332.jpg new file mode 100644 index 0000000000000000000000000000000000000000..29ec5fe73c461452cf752241a732d025d8a621d5 Binary files /dev/null and b/web/gw/skills/332.jpg differ diff --git a/web/gw/skills/348.jpg b/web/gw/skills/348.jpg new file mode 100644 index 0000000000000000000000000000000000000000..38413da069cec33a376c93d12d7965052f60bdd4 Binary files /dev/null and b/web/gw/skills/348.jpg differ diff --git a/web/gw/skills/352.jpg b/web/gw/skills/352.jpg new file mode 100644 index 0000000000000000000000000000000000000000..d9c2a958bcf8508a32809b6b3806b7c97bb3a0a3 Binary files /dev/null and b/web/gw/skills/352.jpg differ diff --git a/web/gw/skills/372.jpg b/web/gw/skills/372.jpg new file mode 100644 index 0000000000000000000000000000000000000000..612ee3bb5bbe07710b9e4bfe0242707e07836248 Binary files /dev/null and b/web/gw/skills/372.jpg differ diff --git a/web/gw/skills/382.jpg b/web/gw/skills/382.jpg new file mode 100644 index 0000000000000000000000000000000000000000..eed2c96ac72e1318fe4d69d030dc1e5d2d8ecced Binary files /dev/null and b/web/gw/skills/382.jpg differ diff --git a/web/gw/skills/384.jpg b/web/gw/skills/384.jpg new file mode 100644 index 0000000000000000000000000000000000000000..39e1a759e481c19416e60362391a5d3b22f61e82 Binary files /dev/null and b/web/gw/skills/384.jpg differ diff --git a/web/gw/skills/385.jpg b/web/gw/skills/385.jpg new file mode 100644 index 0000000000000000000000000000000000000000..5087e83cb5b7db67932fa27e280fc701d0d272f2 Binary files /dev/null and b/web/gw/skills/385.jpg differ diff --git a/web/gw/skills/391.jpg b/web/gw/skills/391.jpg new file mode 100644 index 0000000000000000000000000000000000000000..f9b5dc0def56e915902b05b927152fb119f3b071 Binary files /dev/null and b/web/gw/skills/391.jpg differ diff --git a/web/gw/skills/393.jpg b/web/gw/skills/393.jpg new file mode 100644 index 0000000000000000000000000000000000000000..a2bbf10360ee7b682812a922891f83f0f41d7dbd Binary files /dev/null and b/web/gw/skills/393.jpg differ diff --git a/web/gw/skills/409.jpg b/web/gw/skills/409.jpg new file mode 100644 index 0000000000000000000000000000000000000000..f443d6da8031f3a6c28a5036e42557f371a3277b Binary files /dev/null and b/web/gw/skills/409.jpg differ diff --git a/web/gw/skills/426.jpg b/web/gw/skills/426.jpg new file mode 100644 index 0000000000000000000000000000000000000000..f83cc7a805b49a07634d33a277eb64badd8b6f06 Binary files /dev/null and b/web/gw/skills/426.jpg differ diff --git a/web/gw/skills/435.jpg b/web/gw/skills/435.jpg new file mode 100644 index 0000000000000000000000000000000000000000..d4555cabeae8eb8e0d84435aab0f2b2eabeceb9f Binary files /dev/null and b/web/gw/skills/435.jpg differ diff --git a/web/gw/skills/446.jpg b/web/gw/skills/446.jpg new file mode 100644 index 0000000000000000000000000000000000000000..fbfff1ec852056e407bbd27dfa5fc434471f65f7 Binary files /dev/null and b/web/gw/skills/446.jpg differ diff --git a/web/gw/skills/775.jpg b/web/gw/skills/775.jpg new file mode 100644 index 0000000000000000000000000000000000000000..070baa0f34e0758bfce6593811fcaf340db0aed9 Binary files /dev/null and b/web/gw/skills/775.jpg differ diff --git a/web/gw/skills/780.jpg b/web/gw/skills/780.jpg new file mode 100644 index 0000000000000000000000000000000000000000..1af7fc31899ea6f35758e07d3f65cc7d430f0b74 Binary files /dev/null and b/web/gw/skills/780.jpg differ diff --git a/web/gw/skills/782.jpg b/web/gw/skills/782.jpg new file mode 100644 index 0000000000000000000000000000000000000000..72cbf8071bf856cffca659d2e06f19f8def9e117 Binary files /dev/null and b/web/gw/skills/782.jpg differ diff --git a/web/gw/skills/784.jpg b/web/gw/skills/784.jpg new file mode 100644 index 0000000000000000000000000000000000000000..18b4e32b458eeeada9f4c6139836499841b36a82 Binary files /dev/null and b/web/gw/skills/784.jpg differ diff --git a/web/gw/skills/858.jpg b/web/gw/skills/858.jpg new file mode 100644 index 0000000000000000000000000000000000000000..3860b16a81383970865e12bfc333a9cba05584b4 Binary files /dev/null and b/web/gw/skills/858.jpg differ diff --git a/web/gw/skills/952.jpg b/web/gw/skills/952.jpg new file mode 100644 index 0000000000000000000000000000000000000000..42b3ea60e10f7a358cf374d191b7708379d3154f Binary files /dev/null and b/web/gw/skills/952.jpg differ diff --git a/web/gw/skills/988.jpg b/web/gw/skills/988.jpg new file mode 100644 index 0000000000000000000000000000000000000000..633689cc36f08d655af1b21e0ba95d0e644726db Binary files /dev/null and b/web/gw/skills/988.jpg differ diff --git a/web/shell/classes.css b/web/shell/classes.css new file mode 100644 index 0000000000000000000000000000000000000000..bf25317d521026702470a759aa9b05fd1f9ed7d7 --- /dev/null +++ b/web/shell/classes.css @@ -0,0 +1,152 @@ +/* Classes sandbox styles. Kept in its own file (imported by Classes.jsx) rather + than the shared styles.css to avoid colliding with parallel work there. + Uses the global design tokens (--ink, --paper, …) defined in styles.css. */ + +.classes { display: flex; height: 100%; width: 100%; } +.classes-list { width: 170px; flex-shrink: 0; border-right: 2px solid var(--ink); background: var(--paper-2); padding: 12px; overflow-y: auto; } +.classes-list h2, .classes-customize h2, .classes-skills-head { font-family: var(--font-mono); font-size: 10px; letter-spacing: .2em; text-transform: uppercase; color: var(--transmit); margin: 0 0 8px; } +.classes-list ul { list-style: none; padding: 0; margin: 0; } +.classes-link { display: block; width: 100%; text-align: left; padding: 6px 8px; border: none; border-left: 3px solid var(--c); background: transparent; cursor: pointer; font-family: inherit; font-size: 14px; font-weight: 500; color: var(--ink); } +.classes-link:hover { background: var(--paper-3); } +.classes-link.active { background: var(--ink); color: var(--paper); } + +.classes-palette { margin-top: 14px; padding-top: 10px; border-top: 1px solid var(--paper-3); } +.classes-palette-head { font-family: var(--font-mono); font-size: 10px; letter-spacing: .15em; text-transform: uppercase; color: var(--transmit); margin-bottom: 6px; } +.classes-palette-head .muted { letter-spacing: 0; } +.classes-swatches { display: flex; flex-direction: column; gap: 4px; } +.classes-swatch { display: grid; grid-template-columns: auto 1fr; align-items: center; gap: 7px; width: 100%; text-align: left; padding: 4px 6px; border: 1px solid transparent; border-radius: 4px; background: transparent; cursor: pointer; font-family: inherit; font-size: 11px; color: var(--ink); } +.classes-swatch-num { grid-row: 1 / span 2; font-family: var(--font-mono); font-size: 12px; font-weight: 700; color: var(--transmit); width: 12px; text-align: center; } +.classes-swatch-ramp { display: flex; height: 14px; border-radius: 3px; overflow: hidden; box-shadow: inset 0 0 0 1px rgba(0,0,0,.25); } +.classes-swatch-ramp i { flex: 1 1 0; min-width: 0; } +.classes-swatch-name { font-size: 10px; color: var(--muted, #8a857a); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.classes-swatch:hover { background: var(--paper-3); } +.classes-swatch.active { border-color: var(--ink); background: var(--paper-3); } +.classes-swatch.active .classes-swatch-name { color: var(--ink); font-weight: 600; } + +.classes-main { flex: 1; min-width: 0; display: flex; flex-direction: column; } +.classes-stage { flex: 1; min-height: 0; } +.classes-stage canvas { display: block; width: 100%; height: 100%; } +.classes-taskbar { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-top: 2px solid var(--ink); background: var(--paper-2); } +.classes-slot { width: 48px; height: 48px; border: 1.5px solid var(--ink); background: var(--card); cursor: pointer; display: flex; align-items: center; justify-content: center; padding: 0; box-shadow: 2px 2px 0 rgba(20,24,33,.25); } +.classes-slot.filled { box-shadow: 0 0 0 1.5px var(--transmit), 2px 2px 0 rgba(20,24,33,.25); } +.classes-slot img { width: 40px; height: 40px; image-rendering: pixelated; } +.classes-slot-num { font-family: var(--font-mono); font-size: 14px; color: var(--ink-faint); } +.classes-controls { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-top: 2px solid var(--ink); background: var(--paper-2); } +.classes-attack-btn { padding: 6px 12px; border: 1.5px solid var(--ink); background: var(--ink); color: var(--paper); cursor: pointer; font-family: var(--font-mono); font-size: 10px; letter-spacing: .1em; text-transform: uppercase; } +.classes-attack-btn:hover:not(:disabled) { background: var(--ink-2); } +.classes-attack-btn:disabled { opacity: .45; cursor: default; } +.classes-taskbar-hint { font-family: var(--font-mono); font-size: 10px; margin-left: auto; } + +/* stackable effect editor (chips + add dropdown) */ +.classes-fx-stack { display: flex; flex-direction: column; gap: 5px; } +.classes-fx-chips { display: flex; flex-wrap: wrap; gap: 4px; } +.classes-fx-chip { display: inline-flex; align-items: center; gap: 4px; font-family: var(--font-mono); font-size: 10px; padding: 2px 4px 2px 7px; border: 1.5px solid var(--ink); background: var(--card); color: var(--ink); } +.classes-fx-chip button { border: none; background: transparent; cursor: pointer; color: var(--ink-muted); font-size: 13px; line-height: 1; padding: 0 2px; } +.classes-fx-chip button:hover { color: var(--transmit); } + +.classes-skills { height: 170px; flex-shrink: 0; border-top: 2px solid var(--ink); background: var(--paper); padding: 10px 12px; overflow-y: auto; } +.classes-skills-head { display: flex; align-items: baseline; gap: 4px; } +.classes-skills-head strong { font-family: var(--font-display); font-size: 14px; letter-spacing: 0; text-transform: none; } +.classes-skill-grid { display: flex; flex-wrap: wrap; gap: 5px; } +.classes-skill { padding: 0; border: 1.5px solid var(--ink); background: var(--card); cursor: pointer; line-height: 0; } +.classes-skill img { width: 36px; height: 36px; image-rendering: pixelated; } +.classes-skill:hover { transform: translate(-1px,-1px); } +.classes-skill.elite { border-color: var(--amber); box-shadow: 0 0 0 1.5px var(--amber); } +.classes-skill.on-bar { outline: 2px solid var(--transmit); outline-offset: 1px; } +.classes-skill.selected { outline: 2px solid var(--amber); outline-offset: 1px; } +.classes-skill:disabled { cursor: default; } + +.classes-customize { width: 220px; flex-shrink: 0; border-left: 2px solid var(--ink); background: var(--paper-2); padding: 12px; } +.classes-customize h2 { border-bottom: 3px solid var(--c); padding-bottom: 4px; } +.classes-readonly { font-family: var(--font-mono); font-size: 10px; color: var(--ink-muted); border: 1px dashed var(--ink-faint); padding: 4px 6px; } +.classes-field { display: flex; flex-direction: column; gap: 4px; font-family: var(--font-mono); font-size: 10px; letter-spacing: .12em; text-transform: uppercase; color: var(--ink-muted); margin-bottom: 12px; } +.classes-field select { padding: 5px 6px; border: 1.5px solid var(--ink); background: var(--paper); font-family: var(--font-sans); font-size: 12px; letter-spacing: 0; text-transform: none; color: var(--ink); } +.classes-preview-name { font-size: 13px; color: var(--ink-2); } + +/* selected-skill detail section (below the class customize fields) */ +.classes-skill-detail { margin-top: 16px; border-top: 1px dashed var(--ink-faint); padding-top: 12px; } +.classes-skill-detail h2 { border-bottom: 3px solid var(--c); padding-bottom: 4px; } +.classes-skill-name { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; } +.classes-skill-name img { border: 1.5px solid var(--ink); image-rendering: pixelated; } +.classes-skill-name img.elite { border-color: var(--amber); } +.classes-skill-name strong { font-family: var(--font-display); font-size: 15px; } +.classes-skill-ir { background: var(--ink); color: var(--paper); padding: 8px 10px; font-family: var(--font-mono); font-size: 10px; line-height: 1.5; white-space: pre-wrap; margin: 0 0 12px; } +.classes-skill-actions { display: flex; gap: 6px; margin-top: 8px; } +.classes-skill-add { flex: 1; padding: 6px; border: 1.5px dashed var(--ink); background: transparent; color: var(--ink); cursor: pointer; font-family: var(--font-sans); font-size: 12px; } +.classes-skill-add:hover:not(:disabled) { background: var(--ink); color: var(--paper); border-style: solid; } +.classes-skill-add:disabled { opacity: .5; cursor: default; } + +/* Enemies sandbox: roster add/delete + stats grid (reuses the .classes-* layout + above; only the bits unique to the editable enemy roster live here). */ +.enemies-roster-head { display: flex; align-items: center; justify-content: space-between; gap: 6px; } +.enemies-add { border: 1.5px dashed var(--ink); background: transparent; color: var(--ink); cursor: pointer; font-family: var(--font-mono); font-size: 14px; line-height: 1; padding: 1px 7px; } +.enemies-add:hover { background: var(--ink); color: var(--paper); border-style: solid; } +.enemies-row { display: flex; align-items: stretch; } +.enemies-row .classes-link { flex: 1; min-width: 0; } +.enemies-del { border: none; background: transparent; color: var(--ink-faint); cursor: pointer; font-size: 13px; padding: 0 6px; } +.enemies-row:hover .enemies-del { color: var(--transmit); } +.enemies-empty { font-family: var(--font-mono); font-size: 10px; color: var(--ink-muted); padding: 6px 2px; } + +.enemies-stats { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 12px; } +.enemies-stat { display: flex; flex-direction: column; gap: 4px; font-family: var(--font-mono); font-size: 10px; letter-spacing: .12em; text-transform: uppercase; color: var(--ink-muted); } +.enemies-stat input { padding: 5px 6px; border: 1.5px solid var(--ink); background: var(--paper); font-family: var(--font-mono); font-size: 12px; letter-spacing: 0; color: var(--ink); width: 100%; box-sizing: border-box; } +.classes-field input[type="text"] { padding: 5px 6px; border: 1.5px solid var(--ink); background: var(--paper); font-family: var(--font-sans); font-size: 12px; letter-spacing: 0; text-transform: none; color: var(--ink); } +.enemies-skill-add-row { margin-top: 8px; } +.enemies-skill-add-row select { width: 100%; padding: 5px 6px; border: 1.5px solid var(--ink); background: var(--paper); font-family: var(--font-sans); font-size: 12px; color: var(--ink); } + +/* Enemy skills panel: an "assigned" row + a filterable catalog whose chips + toggle membership (taskbar-style), with the profession filter pinned at the + bottom. A flex column so the catalog scrolls while header/filter stay put. */ +.enemies-skills { height: 220px; display: flex; flex-direction: column; gap: 4px; overflow: hidden; } +.enemies-skill-sub { font-family: var(--font-mono); font-size: 9px; letter-spacing: .15em; text-transform: uppercase; color: var(--ink-muted); margin: 4px 0 2px; } +.enemies-assigned { flex-shrink: 0; display: flex; flex-wrap: wrap; gap: 5px; min-height: 38px; } +.enemies-catalog { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-wrap: wrap; gap: 5px; align-content: flex-start; } +.enemies-filter { flex-shrink: 0; display: flex; flex-wrap: wrap; gap: 4px; border-top: 1px dashed var(--ink-faint); padding-top: 6px; margin-top: 2px; } +.enemies-filter button { font-family: var(--font-mono); font-size: 9px; letter-spacing: .1em; text-transform: uppercase; padding: 3px 7px; border: 1.5px solid var(--ink); background: var(--paper); color: var(--ink); cursor: pointer; } +.enemies-filter button:hover { background: var(--paper-3); } +.enemies-filter button.active { background: var(--ink); color: var(--paper); } +.classes-skill.on-bar img { opacity: 1; } +.classes-skill.off-bar img { opacity: .4; } +.classes-skill.off-bar:hover img { opacity: .8; } + +/* Remove badge: a small × in the top-right corner of an assigned skill chip, + for one-click removal without relying on the toggle. */ +.enemies-skill-wrap { position: relative; line-height: 0; } +.enemies-remove-badge { position: absolute; top: -6px; right: -6px; width: 16px; height: 16px; padding: 0; border: 1.5px solid var(--ink); border-radius: 50%; background: var(--paper); color: var(--ink); font-family: var(--font-mono); font-size: 11px; line-height: 1; cursor: pointer; display: flex; align-items: center; justify-content: center; z-index: 2; box-shadow: 1px 1px 0 rgba(20,24,33,.3); } +.enemies-remove-badge:hover { background: var(--transmit); color: var(--paper); border-color: var(--transmit); } + +/* ════════════════════════════════════════════════════════════════════════════ + * Gradio-host portability (Tiny Army Space). The Space loads ONLY this file, not + * the app's global styles.css, so (a) the design tokens would be undefined and + * (b) Gradio's `.gradio-container button/ul/li/select` base styles leak in (list + * bullets, tall rounded buttons, swapped fonts, dropped panel borders/bg). We fix + * both, scoped to `.classes`: redefine the tokens locally (mirroring styles.css + * :root) and re-assert the box/type props with !important. Both are harmless and + * redundant in the React app — same values, already-reset elements. + * ════════════════════════════════════════════════════════════════════════════ */ +.classes { + --paper: #f3ebdc; --paper-2: #ece2cc; --paper-3: #e2d6ba; --card: #fbf6ea; + --ink: #141821; --ink-2: #2a2f3a; --ink-muted: #6d6a5f; --ink-faint: #8a8574; + --transmit: #d8271a; --lime: #b8d64a; --amber: #e8a72a; + --font-display: 'Fraunces', Georgia, serif; + --font-sans: 'Space Grotesk', -apple-system, BlinkMacSystemFont, sans-serif; + --font-mono: 'JetBrains Mono', ui-monospace, Menlo, monospace; +} +.classes ul, .classes li { list-style: none !important; margin: 0 !important; padding: 0 !important; } +.classes button { min-height: 0 !important; box-shadow: none !important; border-radius: 0 !important; } +.classes-link { padding: 6px 8px !important; border: none !important; border-left: 3px solid var(--c) !important; background: transparent !important; font-family: inherit !important; font-size: 14px !important; font-weight: 500 !important; text-align: left !important; } +.classes-swatch { text-align: left !important; } +.classes-link.active { background: var(--ink) !important; color: var(--paper) !important; } +.classes-attack-btn { padding: 6px 12px !important; border: 1.5px solid var(--ink) !important; background: var(--ink) !important; color: var(--paper) !important; font-family: var(--font-mono) !important; font-size: 10px !important; } +.classes-attack-btn:disabled { opacity: .45 !important; } +.classes-skill { padding: 0 !important; border: 1.5px solid var(--ink) !important; line-height: 0 !important; } +.classes-skill.elite { border-color: var(--amber) !important; box-shadow: 0 0 0 1.5px var(--amber) !important; } +.classes-swatch { padding: 4px 6px !important; border: 1px solid transparent !important; border-radius: 4px !important; background: transparent !important; font-family: inherit !important; } +.classes-swatch.active { border-color: var(--ink) !important; background: var(--paper-3) !important; } +.classes-fx-chip button { border: none !important; padding: 0 2px !important; background: transparent !important; } +.classes-field select, .classes-field input, .enemies-stat input, .enemies-skill-add-row select { border: 1.5px solid var(--ink) !important; background: var(--paper) !important; } +.enemies-filter button { padding: 3px 7px !important; border: 1.5px solid var(--ink) !important; background: var(--paper) !important; } +.enemies-filter button.active { background: var(--ink) !important; color: var(--paper) !important; } +.enemies-add, .classes-skill-add { border: 1.5px dashed var(--ink) !important; background: transparent !important; } +.enemies-remove-badge { border-radius: 50% !important; border: 1.5px solid var(--ink) !important; padding: 0 !important; box-shadow: 1px 1px 0 rgba(20,24,33,.3) !important; } +.enemies-del { border: none !important; background: transparent !important; } diff --git a/web/shell/nav.json b/web/shell/nav.json index 9117869f6dd93e50fe1fb552ca822694d01d82e8..3b44d580291965608154df106e836f9f4e8ae371 100644 --- a/web/shell/nav.json +++ b/web/shell/nav.json @@ -13,26 +13,20 @@ "items": [ { "label": "Sprite Animations", "icon": "🎞", "href": "#/sandbox/sprite-animations", "view": "sandbox", "page": "movement", "space": "Sprite Animations" }, { "label": "Skill Forge", "icon": "⚒", "href": "#/sandbox/skill-forge", "view": "sandbox", "page": "skill-forge", "space": "Skill Forge" }, - { "label": "Classes", "href": "#/sandbox/classes", "view": "sandbox", "page": "classes" }, - { "label": "Enemies", "href": "#/sandbox/enemies", "view": "sandbox", "page": "enemies" }, + { "label": "Classes", "icon": "🛡", "href": "#/sandbox/classes", "view": "sandbox", "page": "classes", "space": "Classes" }, + { "label": "Enemies", "icon": "👹", "href": "#/sandbox/enemies", "view": "sandbox", "page": "enemies", "space": "Enemies" }, { "label": "Levels", "href": "#/sandbox/levels", "view": "sandbox", "page": "levels" }, { "label": "GW Skills", "href": "#/sandbox/gw-skills", "view": "sandbox", "page": "skills" }, { "label": "CB Skills", "href": "#/sandbox/cb-skills", "view": "sandbox", "page": "cb-skills" }, { "label": "Effects", "href": "#/sandbox/effects", "view": "sandbox", "page": "effects" }, - { "label": "Battle", "href": "#/sandbox/battle", "view": "sandbox", "page": "battle" } + { "label": "Battle", "href": "#/sandbox/battle", "view": "sandbox", "page": "battle" }, + { "label": "World Map", "icon": "🗺", "href": "#/sandbox/worldmap", "view": "sandbox", "page": "worldmap", "space": "World Map" } ] }, { "title": "Barracks", "items": [ - { "label": "War Diaries", "icon": "📓", "space": "Barracks" }, - { "label": "Personas", "icon": "🛡", "space": "Personas" } - ] - }, - { - "title": "App", - "items": [ - { "label": "Settings", "icon": "⚙", "space": "Settings" } + { "label": "War Diaries", "icon": "📓", "space": "Barracks" } ] } ] diff --git a/web/tiny.js b/web/tiny.js index 53a22d543c44d2c9bd65e9925c76be73802c3a5a..d0a405342c785e2438a52404daafec631cb179d1 100644 --- a/web/tiny.js +++ b/web/tiny.js @@ -7,6 +7,8 @@ import * as PIXI from 'https://cdn.jsdelivr.net/npm/pixi.js@8/dist/pixi.min.mjs' import { makeTeamBattle, step, FIELD } from '/web/engine.js' import { sliceGridWith, cellOf, rowFor, facingFor, ANIM } from '/web/sheet.js' import { mountSpritePlayground } from '/web/playground.js' +import { mountClassesSandbox } from '/web/classesSandbox.js' +import { mountEnemiesSandbox } from '/web/enemiesSandbox.js' import { mountPersonaPanel } from '/web/personaPanel.js' import { mountDiaryPanel } from '/web/diaryPanel.js' import { mountSettingsPanel } from '/web/settingsPanel.js' @@ -56,6 +58,32 @@ whenEl('sprite-stage', async (el) => { playground = mountSpritePlayground(PIXI, el, { packs: man.packs || [], urlFor: spriteUrl }) }) +// ── Classes sandbox — the shared playground (auto-battler src/render/classesSandbox.js +// → /web/classesSandbox.js, Pixi injected) builds the whole page: class picker + WASD +// combat + customize panel. Sheet/effect URLs in the data are authored as /assets/…; +// the Space serves them at /sprites/…, so we remap before handing the data over. +const remapAssets = (o) => JSON.parse(JSON.stringify(o).replaceAll('/assets/', '/sprites/')) +whenEl('classes-stage', async (el) => { + const j = (u) => fetch(u).then((r) => r.json()).catch(() => ({})) + const [chars, effects, classes] = await Promise.all([j('/sprites/characters.json'), j('/sprites/effects.json'), j('/sprites/classes.json')]) + mountClassesSandbox(PIXI, el, { + packs: remapAssets(chars).packs || [], + fx: remapAssets(effects).effects || [], + config: { classes: {}, skills: {}, ...remapAssets(classes) }, + editable: false, // no /api on the Space — the customize panel is read-only + }) +}) +whenEl('enemies-stage', async (el) => { + const j = (u) => fetch(u).then((r) => r.json()).catch(() => ({})) + const [chars, effects, enemies] = await Promise.all([j('/sprites/characters.json'), j('/sprites/effects.json'), j('/sprites/enemies.json')]) + mountEnemiesSandbox(PIXI, el, { + packs: remapAssets(chars).packs || [], + fx: remapAssets(effects).effects || [], + config: { enemies: {}, ...remapAssets(enemies) }, + editable: false, + }) +}) + // ── Personas + War Diary tabs — in-browser llama.cpp (wllama), runs on the device ── whenEl('persona-stage', (el) => { mountPersonaPanel(el) }) whenEl('diary-stage', (el) => { mountDiaryPanel(el) })