tiny-army / web /classesSandbox.js
polats's picture
Game: free-roam overworld with biome-themed enemy spawning
992a52d
// ../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,
aggroRadius: unit.aggroRadius ?? null
// optional: idle until a foe is within this distance (else always engage)
};
}
function makeTeamBattle({ seed = 1, players = [], enemies = [], sandbox = false, respawnDummies = 0, freeCast = false, world = null, field = null } = {}) {
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: {}, world, field: field || FIELD };
}
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 f = b.field || FIELD;
const dx = a.x - tgt.x, dy = a.y - tgt.y, len = Math.hypot(dx, dy) || 1;
a.x = Math.max(0, Math.min(f.w, tgt.x + dx / len * (BASIC_MELEE_GW * 0.8)));
a.y = Math.max(0, Math.min(f.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);
const px = a.x, py = a.y;
stepMove(b, a, vx, vy, dt);
a.vx = vx;
a.vy = vy;
a.moving = b.world ? a.x !== px || a.y !== py : 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 stepMove(b, a, vx, vy, dt) {
const f = b.field || FIELD;
const w = b.world && b.world.walkable;
let nx = clampField(a.x + vx * dt, a.radius, f.w);
let ny = clampField(a.y + vy * dt, a.radius, f.h);
if (w) {
if (!w(nx, a.y)) nx = a.x;
if (!w(nx, ny)) ny = a.y;
}
a.x = nx;
a.y = ny;
}
function resolveOverlaps(b) {
const f = b.field || FIELD;
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, f.w);
a.y = clampField(a.y - yPush * push * aShare, a.radius, f.h);
o.x = clampField(o.x + ux * push * oShare, o.radius, f.w);
o.y = clampField(o.y + yPush * push * oShare, o.radius, f.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);
const px = a.x, py = a.y;
stepMove(b, a, mx / len * speed, my / len * speed, dt);
a.moving = b.world ? a.x !== px || a.y !== py : 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 && a.control !== "player") continue;
if (a.aggroRadius != null && a.control !== "player" && dist(a, enemy) > a.aggroRadius) {
a.moving = false;
continue;
}
if (enemy) {
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 = {};
async function loadSheets(id, def) {
if (!def?.idle) {
sheetsById[id] = null;
return;
}
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;
}
}
for (const [id, def] of Object.entries(defsById)) await loadSheets(id, def);
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 = {};
async function buildSkillPlay(id, def) {
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;
}
for (const [id, def] of Object.entries(defsById)) await buildSkillPlay(id, def);
const view = {};
function buildView(id, def) {
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 };
}
for (const [id, def] of Object.entries(defsById)) buildView(id, def);
async function addActor(id, def) {
if (view[id]) return view[id];
defsById[id] = def;
await loadSheets(id, def);
await buildSkillPlay(id, def);
buildView(id, def);
return view[id];
}
function removeActor(id) {
const v = view[id];
if (!v) return;
units.removeChild(v.container);
v.container.destroy({ children: true });
delete view[id];
delete sheetsById[id];
delete skillPlay[id];
delete defsById[id];
}
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,
addActor,
removeActor,
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
};