/** ATreeNode spec: ATreeNode: { children: List[ATreeNode] // nodes that this node can link to downstream (or sideways) parents: List[ATreeNode] // nodes that can link to this one from upstream (or sideways) ability: atree_node // raw data from atree json } atree_node: { display_name: str id: int desc: str archetype: Optional[str] // not present or empty string = no arch archetype_req: Optional[int] // default: 0 base_abil: Optional[int] // Modify another abil? poorly defined... parents: List[int] dependencies: List[int] // Hard reqs blockers: List[int] // If any in here are taken, i am invalid cost: int // cost in AP display: { // stuff for rendering ATree row: int col: int icon: str } properties: Map[str, float] // Dynamic (modifiable) misc. properties; ex. AOE effects: List[effect] } effect: replace_spell | add_spell_prop | convert_spell_conv | raw_stat | stat_scaling replace_spell: { type: "replace_spell" ... rest of fields are same as `spell` type (see: damage_calc.js) } add_spell_prop: { type: "add_spell_prop" base_spell: int // spell identifier target_part: Optional[str] // Part of the spell to modify. Can be not present/empty for ex. cost modifier. // If target part does not exist, a new part is created. cost: Optional[int] // change to spellcost multipliers: Optional[array[float, 6]] // Additive changes to spellmult (for damage spell) power: Optional[float] // Additive change to healing power (for heal spell) hits: Optional[Map[str, float]] // Additive changes to hits (for total entry) display: Optional[str] // Optional change to the displayed entry. Replaces old } convert_spell_conv: { type: "convert_spell_conv" base_spell: int // spell identifier target_part: "all" | str // Part of the spell to modify. Can be not present/empty for ex. cost modifier. // "all" means modify all parts. conversion: element_str } raw_stat: { type: "raw_stat" toggle: Optional[bool | str] // default: false; true means create anon. toggle, // string value means bind to (or create) named button bonuses: List[stat_bonus] } stat_bonus: { "type": "stat" | "prop", "abil": Optional[int], "name": str, "value": float } stat_scaling: { "type": "stat_scaling", "slider": bool, "slider_name": Optional[str], "slider_step": Optional[float], "inputs": Optional[list[scaling_target]], "output": scaling_target | List[scaling_target], "scaling": list[float], "max": float } scaling_target: { "type": "stat" | "prop", "abil": Optional[int], "name": str } */ // TODO: Range numbers const default_abils = { wand: [{ display_name: "Mage Melee", id: 999, desc: "Mage basic attack.", properties: {range: 5000}, effects: [default_spells.wand[0]] }], spear: [{ display_name: "Warrior Melee", id: 999, desc: "Warrior basic attack.", properties: {range: 2}, effects: [default_spells.spear[0]] }], bow: [{ display_name: "Archer Melee", id: 999, desc: "Archer basic attack.", properties: {range: 20}, effects: [default_spells.bow[0]] }], dagger: [{ display_name: "Assassin Melee", id: 999, desc: "Assassin basic attack.", properties: {range: 2}, effects: [default_spells.dagger[0]] }], relik: [{ display_name: "Shaman Melee", id: 999, desc: "Shaman basic attack.", properties: {range: 15, speed: 0}, effects: [default_spells.relik[0]] }], }; /** * Update ability tree internal representation. (topologically sorted node list) * * Signature: AbilityTreeUpdateNode(player-class: str) => ATree (List of atree nodes in topological order) */ const atree_node = new (class extends ComputeNode { constructor() { super('builder-atree-update'); } compute_func(input_map) { if (input_map.size !== 1) { throw "AbilityTreeUpdateNode accepts exactly one input (player-class)"; } const [player_class] = input_map.values(); // Extract values, pattern match it into size one list and bind to first element const atree_raw = atrees[player_class]; if (!atree_raw) return null; let atree_map = new Map(); let atree_head; for (const i of atree_raw) { atree_map.set(i.id, {children: [], ability: i}); if (i.parents.length == 0) { // Assuming there is only one head. atree_head = atree_map.get(i.id); } } for (const i of atree_raw) { let node = atree_map.get(i.id); let parents = []; for (const parent_id of node.ability.parents) { let parent_node = atree_map.get(parent_id); parent_node.children.push(node); parents.push(parent_node); } node.parents = parents; } let atree_topo_sort = []; topological_sort_tree(atree_head, atree_topo_sort, new Map()); atree_topo_sort.reverse(); return atree_topo_sort; } })(); /** * Display ability tree from topologically sorted list. * * Signature: AbilityTreeRenderNode(atree: ATree) => RenderedATree ( Map[id, RenderedATNode] ) */ const atree_render = new (class extends ComputeNode { constructor() { super('builder-atree-render'); this.fail_cb = true; this.UI_elem = document.getElementById("atree-ui"); this.list_elem = document.getElementById("atree-header"); } compute_func(input_map) { if (input_map.size !== 1) { throw "AbilityTreeRenderNode accepts exactly one input (atree)"; } const [atree] = input_map.values(); // Extract values, pattern match it into size one list and bind to first element //for some reason we have to cast to string this.list_elem.innerHTML = ""; //reset all atree actives - should be done in a more general way later this.UI_elem.innerHTML = ""; //reset the atree in the DOM let ret = null; if (atree) { ret = render_AT(this.UI_elem, this.list_elem, atree); } //Toggle on, previously was toggled off toggle_tab('atree-dropdown'); toggleButton('toggle-atree'); return ret; } })().link_to(atree_node); // This exists so i don't have to re-render the UI to push atree updates. const atree_state_node = new (class extends ComputeNode { constructor() { super('builder-atree-state'); } compute_func(input_map) { if (input_map.size !== 1) { throw "AbilityTreeStateNode accepts exactly one input (atree-rendered)"; } const [rendered_atree] = input_map.values(); // Extract values, pattern match it into size one list and bind to first element return rendered_atree; } })().link_to(atree_render, 'atree-render'); /** * Create a reverse topological sort of the tree in the result list. * * https://en.wikipedia.org/wiki/Topological_sorting * @param tree: Root of tree to sort * @param res: Result list (reverse topological order) * @param mark_state: Bookkeeping. Call with empty Map() */ function topological_sort_tree(tree, res, mark_state) { const state = mark_state.get(tree); if (state === undefined) { // unmarked. mark_state.set(tree, false); // temporary mark for (const child of tree.children) { topological_sort_tree(child, res, mark_state); } mark_state.set(tree, true); // permanent mark res.push(tree); } // these cases are not needed. Case 1 does nothing, case 2 should never happen. // else if (state === true) { return; } // permanent mark. // else if (state === false) { throw "not a DAG"; } // temporary mark. } /** * Collect abilities and condense them into a list of "final abils". * This is just for rendering purposes, and for collecting things that modify spells into one chunk. * I stg if wynn makes abils that modify multiple spells * ... well we can extend this by making `base_abil` a list instead but annoy * * Signature: AbilityTreeMergeNode(build: Build, atree: ATree, atree-state: RenderedATree) => Map[id, Ability] */ const atree_merge = new (class extends ComputeNode { constructor() { super('builder-atree-merge'); } compute_func(input_map) { const build = input_map.get('build'); const atree_state = input_map.get('atree-state'); const atree_order = input_map.get('atree'); let abils_merged = new Map(); for (const abil of default_abils[build.weapon.statMap.get('type')]) { let tmp_abil = deepcopy(abil); if (!Array.isArray(tmp_abil.desc)) { tmp_abil.desc = [tmp_abil.desc]; } tmp_abil.subparts = [abil.id]; abils_merged.set(abil.id, tmp_abil); } for (const node of atree_order) { const abil_id = node.ability.id; if (!atree_state.get(abil_id).active) { continue; } const abil = node.ability; if (abils_merged.has(abil.base_abil)) { // Merge abilities. // TODO: What if there is more than one base abil? let base_abil = abils_merged.get(abil.base_abil); if (Array.isArray(abil.desc)) { base_abil.desc = base_abil.desc.concat(abil.desc); } else { base_abil.desc.push(abil.desc); } base_abil.subparts.push(abil.id); base_abil.effects = base_abil.effects.concat(abil.effects); for (let propname in abil.properties) { base_abil[propname] = abil[propname]; } } else { let tmp_abil = deepcopy(abil); if (!Array.isArray(tmp_abil.desc)) { tmp_abil.desc = [tmp_abil.desc]; } tmp_abil.subparts = [abil.id]; abils_merged.set(abil_id, tmp_abil); } } return abils_merged; } })().link_to(atree_node, 'atree').link_to(atree_state_node, 'atree-state'); // TODO: THIS IS WRONG!!!!! Need one "collect" node... /** * Validate ability tree. * Return list of errors for rendering. * * Signature: AbilityTreeMergeNode(atree: ATree, atree-state: RenderedATree) => List[str] */ const atree_validate = new (class extends ComputeNode { constructor() { super('atree-validator'); } compute_func(input_map) { const atree_state = input_map.get('atree-state'); const atree_order = input_map.get('atree'); let errors = []; let reachable = new Map(); atree_dfs_mark(atree_order[0], atree_state, reachable); let abil_points_total = 0; let archetype_count = new Map(); for (const node of atree_order) { const abil = node.ability; if (!atree_state.get(abil.id).active) { continue; } abil_points_total += abil.cost; if (!reachable.get(abil.id)) { errors.push(abil.display_name + ' is not reachable!'); } let failed_deps = []; for (const dep_id of abil.dependencies) { if (!atree_state.get(dep_id).active) { failed_deps.push(dep_id) } } if (failed_deps.length > 0) { const dep_string = failed_deps.map(i => '"' + atree_state.get(i).ability.display_name + '"'); errors.push(abil.display_name + ' dependencies not satisfied: ' + dep_string.join(", ")); } let blocking_ids = []; for (const blocker_id of abil.blockers) { if (atree_state.get(blocker_id).active) { blocking_ids.push(blocker_id); } } if (blocking_ids.length > 0) { const blockers_string = blocking_ids.map(i => '"' + atree_state.get(i).ability.display_name + '"'); errors.push(abil.display_name+' is blocked by: '+blockers_string.join(", ")); } if ('archetype' in abil && abil.archetype !== "") { let val = 1; if (archetype_count.has(abil.archetype)) { val = archetype_count.get(abil.archetype) + 1; } archetype_count.set(abil.archetype, val); } } // TODO: FIX THIS! ARCHETYPE REQ IS A PAIN IN THE ASS // it doesn't follow topological order and theres some cases where "equip order" matters. for (const node of atree_order) { const abil = node.ability; if (!atree_state.get(abil.id).active) { continue; } if ('archetype_req' in abil && abil.archetype_req !== 0) { const others = archetype_count.get(abil.archetype) - 1; if (others < abil.archetype_req) { errors.push(abil.display_name+' fails archetype: '+abil.archetype+': '+others+' < '+abil.archetype_req) } } } if (abil_points_total > 45) { errors.push('too many ability points assigned! ('+abil_points_total+' > 45)'); } return [abil_points_total, errors]; } })().link_to(atree_node, 'atree').link_to(atree_state_node, 'atree-state'); function atree_dfs_mark(start, atree_state, mark) { if (mark.get(start.ability.id)) { return; } mark.set(start.ability.id, true); for (const child of start.children) { if (atree_state.get(child.ability.id).active) { atree_dfs_mark(child, atree_state, mark); } } } const atree_render_active = new (class extends ComputeNode { constructor() { super('atree-render-active'); this.list_elem = document.getElementById("atree-active"); } compute_func(input_map) { const merged_abils = input_map.get('atree-merged'); const atree_order = input_map.get('atree-order'); const [abil_points_total, errors] = input_map.get('atree-errors'); this.list_elem.innerHTML = ""; //reset all atree actives - should be done in a more general way later // TODO: move to display? document.getElementById("active_AP_cost").textContent = abil_points_total; if (errors.length > 0) { let errorbox = document.createElement('div'); errorbox.classList.add("rounded-bottom", "dark-4", "border", "p-0", "mx-2", "my-4", "dark-shadow"); this.list_elem.appendChild(errorbox); let error_title = document.createElement('b'); error_title.classList.add("warning", "scaled-font"); error_title.innerHTML = "ATree Error!"; errorbox.appendChild(error_title); for (const error of errors) { let atree_warning = document.createElement("p"); atree_warning.classList.add("warning", "small-text"); atree_warning.textContent = error; errorbox.appendChild(atree_warning); } } for (const node of atree_order) { if (!merged_abils.has(node.ability.id)) { continue; } const abil = merged_abils.get(node.ability.id); let active_tooltip = document.createElement('div'); active_tooltip.classList.add("rounded-bottom", "dark-4", "border", "p-0", "mx-2", "my-4", "dark-shadow"); let active_tooltip_title = document.createElement('b'); active_tooltip_title.classList.add("scaled-font"); active_tooltip_title.innerHTML = abil.display_name; active_tooltip.appendChild(active_tooltip_title); for (const desc of abil.desc) { let active_tooltip_desc = document.createElement('p'); active_tooltip_desc.classList.add("scaled-font-sm", "my-0", "mx-1", "text-wrap"); active_tooltip_desc.textContent = desc; active_tooltip.appendChild(active_tooltip_desc); } this.list_elem.appendChild(active_tooltip); } } })().link_to(atree_node, 'atree-order').link_to(atree_merge, 'atree-merged').link_to(atree_validate, 'atree-errors'); /** * Collect spells from abilities. * * Signature: AbilityCollectSpellsNode(atree-merged: Map[id, Ability]) => List[Spell] */ const atree_collect_spells = new (class extends ComputeNode { constructor() { super('atree-spell-collector'); } compute_func(input_map) { if (input_map.size !== 1) { throw "AbilityTreeCollectSpellsNode accepts exactly one input (atree-merged)"; } const [atree_merged] = input_map.values(); // Extract values, pattern match it into size one list and bind to first element let ret_spells = new Map(); for (const [abil_id, abil] of atree_merged.entries()) { // TODO: Possibly, make a better way for detecting "spell abilities"? if (abil.effects.length == 0) { continue; } let ret_spell = deepcopy(abil.effects[0]); // NOTE: do not mutate results of previous steps! let has_spell_def = false; for (const effect of abil.effects) { if (effect.type === 'replace_spell') { has_spell_def = true; // replace_spell just replaces all (defined) aspects. for (const key in effect) { ret_spell[key] = effect[key]; } } } if (!has_spell_def) { continue; } const base_spell_id = ret_spell.base_spell; for (const effect of abil.effects) { switch (effect.type) { case 'replace_spell': // Already handled above. continue; case 'add_spell_prop': { const { base_spell, target_part = null, cost = 0} = effect; if (base_spell !== base_spell_id) { continue; } // TODO: redundant? if we assume abils only affect one spell ret_spell.cost += cost; if (target_part === null) { continue; } let found_part = false; for (let part of ret_spell.parts) { // TODO: replace with Map? to avoid this linear search... idk prolly good since its not more verbose to type in json if (part.name === target_part) { if ('multipliers' in effect) { for (const [idx, v] of effect.multipliers.entries()) { // python: enumerate() part.multipliers[idx] += v; } } else if ('power' in effect) { part.power += effect.power; } else if ('hits' in effect) { for (const [idx, v] of Object.entries(effect.hits)) { // looks kinda similar to multipliers case... hmm... can we unify all of these three? (make healpower a list) if (idx in part.hits) { part.hits[idx] += v; } else { part.hits[idx] = v; } } } else { throw "uhh invalid spell add effect"; } found_part = true; break; } } if (!found_part) { // add part. let spell_part = deepcopy(effect); spell_part.name = target_part; // has some extra fields but whatever ret_spell.parts.push(spell_part); } if ('display' in effect) { ret_spell.display = effect.display; } continue; } case 'convert_spell_conv': const { base_spell, target_part, conversion } = effect; if (base_spell !== base_spell_id) { continue; } // TODO: redundant? if we assume abils only affect one spell const elem_idx = damageClasses.indexOf(conversion); let filter = target_part === 'all'; for (let part of ret_spell.parts) { // TODO: replace with Map? to avoid this linear search... idk prolly good since its not more verbose to type in json if (filter || part.name === target_part) { if ('multipliers' in part) { let total_conv = 0; for (let i = 1; i < 6; ++i) { // skip neutral total_conv += part.multipliers[i]; } let new_conv = [part.multipliers[0], 0, 0, 0, 0, 0]; new_conv[elem_idx] = total_conv; part.multipliers = new_conv; } } } continue; } } ret_spells.set(base_spell_id, ret_spell); } return ret_spells; } })().link_to(atree_merge, 'atree-merged'); /** * Construct compute nodes to link builder items and edit IDs to the appropriate display outputs. * To make things a bit cleaner, the compute graph structure goes like * [builder, build stats] -> [one agg node that is just a passthrough] -> all the spell calc nodes * This way, when things have to be deleted i can just delete one node from the dependencies of builder/build stats... * thats the idea anyway. * * Whenever this is updated, it forces an update of all the newly created spell nodes (if the build is clean). * * Signature: AbilityEnsureSpellsNodes(spells: Map[id, Spell]) => null */ class AbilityTreeEnsureNodesNode extends ComputeNode { /** * Kinda "hyper-node": Constructor takes nodes that should be linked to (build node and stat agg node) */ constructor(build_node, stat_agg_node) { super('atree-make-nodes'); this.build_node = build_node; this.stat_agg_node = stat_agg_node; // Slight amount of wasted compute to keep internal state non-changing. this.passthrough = new PassThroughNode('atree-make-nodes_internal').link_to(this.build_node, 'build').link_to(this.stat_agg_node, 'stats'); this.spelldmg_nodes = []; // debugging use this.spell_display_elem = document.getElementById("all-spells-display"); } compute_func(input_map) { console.log('atree make nodes'); this.passthrough.remove_link(this.build_node); this.passthrough.remove_link(this.stat_agg_node); this.passthrough = new PassThroughNode('atree-make-nodes_internal').link_to(this.build_node, 'build').link_to(this.stat_agg_node, 'stats'); this.spell_display_elem.textContent = ""; const build_node = this.passthrough.get_node('build'); // aaaaaaaaa performance... savings... help.... const stat_agg_node = this.passthrough.get_node('stats'); const spell_map = input_map.get('spells'); // TODO: is this gonna need more? idk... // TODO shortcut update path for sliders for (const [spell_id, spell] of new Map([...spell_map].sort((a, b) => a[0] - b[0])).entries()) { let spell_node = new SpellSelectNode(spell); spell_node.link_to(build_node, 'build'); let calc_node = new SpellDamageCalcNode(spell.base_spell); calc_node.link_to(build_node, 'build').link_to(stat_agg_node, 'stats') .link_to(spell_node, 'spell-info'); this.spelldmg_nodes.push(calc_node); let display_elem = document.createElement('div'); display_elem.classList.add("col", "pe-0"); // TODO: just pass these elements into the display node instead of juggling the raw IDs... let spell_summary = document.createElement('div'); spell_summary.setAttribute('id', "spell"+spell.base_spell+"-infoAvg"); spell_summary.classList.add("col", "spell-display", "spell-expand", "dark-5", "rounded", "dark-shadow", "pt-2", "border", "border-dark"); let spell_detail = document.createElement('div'); spell_detail.setAttribute('id', "spell"+spell.base_spell+"-info"); spell_detail.classList.add("col", "spell-display", "dark-5", "rounded", "dark-shadow", "py-2"); spell_detail.style.display = "none"; display_elem.appendChild(spell_summary); display_elem.appendChild(spell_detail); let display_node = new SpellDisplayNode(spell.base_spell); display_node.link_to(stat_agg_node, 'stats'); display_node.link_to(spell_node, 'spell-info'); display_node.link_to(calc_node, 'spell-damage'); this.spell_display_elem.appendChild(display_elem); } this.passthrough.mark_dirty().update(); // Force update once. } } /** The main function for rendering an ability tree. * * @param {Element} UI_elem - the DOM element to draw the atree within. * @param {Element} list_elem - the DOM element to list selected abilities within. * @param {*} tree - the ability tree to work with. */ function render_AT(UI_elem, list_elem, tree) { console.log("constructing ability tree UI"); // add in the "Active" title to atree let active_row = document.createElement("div"); active_row.classList.add("row", "item-title", "mx-auto", "justify-content-center"); let active_word = document.createElement("div"); active_word.classList.add("col-auto"); active_word.textContent = "Active Abilities:"; let active_AP_container = document.createElement("div"); active_AP_container.classList.add("col-auto"); let active_AP_subcontainer = document.createElement("div"); active_AP_subcontainer.classList.add("row"); let active_AP_cost = document.createElement("div"); active_AP_cost.classList.add("col-auto", "mx-0", "px-0"); active_AP_cost.id = "active_AP_cost"; active_AP_cost.textContent = "0"; let active_AP_slash = document.createElement("div"); active_AP_slash.classList.add("col-auto", "mx-0", "px-0"); active_AP_slash.textContent = "/"; let active_AP_cap = document.createElement("div"); active_AP_cap.classList.add("col-auto", "mx-0", "px-0"); active_AP_cap.id = "active_AP_cap"; active_AP_cap.textContent = "45"; let active_AP_end = document.createElement("div"); active_AP_end.classList.add("col-auto", "mx-0", "px-0"); active_AP_end.textContent = " AP"; //I can't believe we can't pass in multiple children at once active_AP_subcontainer.appendChild(active_AP_cost); active_AP_subcontainer.appendChild(active_AP_slash); active_AP_subcontainer.appendChild(active_AP_cap); active_AP_subcontainer.appendChild(active_AP_end); active_AP_container.appendChild(active_AP_subcontainer); active_row.appendChild(active_word); active_row.appendChild(active_AP_container); list_elem.appendChild(active_row); let atree_map = new Map(); let atree_connectors_map = new Map() let max_row = 0; for (const i of tree) { atree_map.set(i.ability.id, {ability: i.ability, connectors: new Map(), active: false}); if (i.ability.display.row > max_row) { max_row = i.ability.display.row; } } // Copy graph structure. for (const i of tree) { let node_wrapper = atree_map.get(i.ability.id); node_wrapper.parents = []; node_wrapper.children = []; for (const parent of i.parents) { node_wrapper.parents.push(atree_map.get(parent.ability.id)); } for (const child of i.children) { node_wrapper.children.push(atree_map.get(child.ability.id)); } } // Setup grid. for (let j = 0; j <= max_row; j++) { let row = document.createElement('div'); row.classList.add("row"); row.id = "atree-row-" + j; for (let k = 0; k < 9; k++) { col = document.createElement('div'); col.classList.add('col', 'px-0'); row.appendChild(col); } UI_elem.appendChild(row); } for (const _node of tree) { let node_wrap = atree_map.get(_node.ability.id); let ability = _node.ability; // create connectors based on parent location for (let parent of node_wrap.parents) { node_wrap.connectors.set(parent, []); let parent_abil = parent.ability; const parent_id = parent_abil.id; let connect_elem = document.createElement("div"); connect_elem.style = "background-size: cover; width: 100%; height: 100%;"; // connect up for (let i = ability.display.row - 1; i > parent_abil.display.row; i--) { const coord = i + "," + ability.display.col; let connector = connect_elem.cloneNode(); node_wrap.connectors.get(parent).push(coord); resolve_connector(atree_connectors_map, coord, {connector: connector, connections: [0, 0, 1, 1]}); } // connect horizontally let min = Math.min(parent_abil.display.col, ability.display.col); let max = Math.max(parent_abil.display.col, ability.display.col); for (let i = min + 1; i < max; i++) { const coord = parent_abil.display.row + "," + i; let connector = connect_elem.cloneNode(); node_wrap.connectors.get(parent).push(coord); resolve_connector(atree_connectors_map, coord, {connector: connector, connections: [1, 1, 0, 0]}); } // connect corners if (parent_abil.display.row != ability.display.row && parent_abil.display.col != ability.display.col) { const coord = parent_abil.display.row + "," + ability.display.col; let connector = connect_elem.cloneNode(); node_wrap.connectors.get(parent).push(coord); let connections = [0, 0, 0, 1]; if (parent_abil.display.col > ability.display.col) { connections[1] = 1; } else {// if (parent_node.display.col < node.display.col && (parent_node.display.row != node.display.row)) { connections[0] = 1; } resolve_connector(atree_connectors_map, coord, {connector: connector, connections: connections}); } } // create node let node_elem = document.createElement('div'); let icon = ability.display.icon; if (icon === undefined) { icon = "node"; } let node_img = document.createElement('img'); node_img.src = '../media/atree/'+icon+'.png'; node_img.style = "width: 100%; height: 100%;"; node_elem.appendChild(node_img); node_elem.classList.add("atree-circle"); // add tooltip node_elem.addEventListener('mouseover', function(e) { if (e.target !== this) {return;} let tooltip = this.children[0]; tooltip.style.top = this.getBoundingClientRect().bottom + window.scrollY * 1.02 + "px"; tooltip.style.left = this.parentElement.parentElement.getBoundingClientRect().left + (elem.getBoundingClientRect().width * .2 / 2) + "px"; tooltip.style.display = "block"; }); node_elem.addEventListener('mouseout', function(e) { if (e.target !== this) {return;} let tooltip = this.children[0]; tooltip.style.display = "none"; }); node_elem.classList.add("fake-button"); let node_tooltip = document.createElement('div'); node_tooltip.classList.add("rounded-bottom", "dark-4", "border", "p-0", "mx-2", "my-4", "dark-shadow"); node_tooltip.style.display = "none"; // tooltip text formatting let node_tooltip_title = document.createElement('b'); node_tooltip_title.classList.add("scaled-font"); node_tooltip_title.innerHTML = ability.display_name; node_tooltip.appendChild(node_tooltip_title); if ('archetype' in ability && ability.archetype !== "") { let node_tooltip_archetype = document.createElement('p'); node_tooltip_archetype.classList.add("scaled-font"); node_tooltip_archetype.innerHTML = "(Archetype: " + ability.archetype+")"; node_tooltip.appendChild(node_tooltip_archetype); } let node_tooltip_desc = document.createElement('p'); node_tooltip_desc.classList.add("scaled-font-sm", "my-0", "mx-1", "text-wrap"); node_tooltip_desc.textContent = ability.desc; node_tooltip.appendChild(node_tooltip_desc); let node_tooltip_cost = document.createElement('p'); node_tooltip_cost.classList.add("scaled-font-sm", "my-0", "mx-1", "text-start"); node_tooltip_cost.textContent = "Cost: " + ability.cost + " AP"; node_tooltip.appendChild(node_tooltip_cost); node_tooltip.style.position = "absolute"; node_tooltip.style.zIndex = "100"; node_elem.appendChild(node_tooltip); //list_elem.appendChild(active_tooltip); NOTE: moved to `atree_render_active` node_wrap.elem = node_elem; node_wrap.all_connectors_ref = atree_connectors_map; node_elem.addEventListener('click', function(e) { if (e.target !== this && e.target!== this.children[0]) {return;} atree_set_state(node_wrap, !node_wrap.active); atree_state_node.mark_dirty().update(); }); // add tooltip node_elem.addEventListener('mouseover', function(e) { if (e.target !== this && e.target!== this.children[0]) {return;} let tooltip = this.children[this.children.length - 1]; tooltip.style.top = this.getBoundingClientRect().bottom + window.scrollY * 1.02 + "px"; tooltip.style.left = this.parentElement.parentElement.getBoundingClientRect().left + (elem.getBoundingClientRect().width * .2 / 2) + "px"; tooltip.style.maxWidth = UI_elem.getBoundingClientRect().width * .95 + "px"; tooltip.style.display = "block"; }); node_elem.addEventListener('mouseout', function(e) { if (e.target !== this && e.target!== this.children[0]) {return;} let tooltip = this.children[this.children.length - 1]; tooltip.style.display = "none"; }); document.getElementById("atree-row-" + ability.display.row).children[ability.display.col].appendChild(node_elem); }; console.log(atree_connectors_map); atree_render_connection(atree_connectors_map); return atree_map; }; // resolve connector conflict, when they occupy the same cell. function resolve_connector(atree_connectors_map, pos, new_connector) { if (!atree_connectors_map.has(pos)) { atree_connectors_map.set(pos, new_connector); return; } let existing = atree_connectors_map.get(pos).connections; for (let i = 0; i < 4; ++i) { existing[i] += new_connector.connections[i]; } } function set_connector_type(connector_info) { // left right up down const connections = connector_info.connections; const connector_elem = connector_info.connector; if (connections[2]) { if (connections[0]) { connector_info.type = 'c'; // cross return; } connector_info.type = 'line'; // vert line return; } if (connections[3]) { // if down: if (connections[0] && connections[1]) { connector_info.type = 't'; // all 3 t return; } connector_info.type = 'angle'; // elbow if (connections[1]) { connector_elem.classList.add("rotate-180"); } else { connector_elem.classList.add("rotate-270"); } return; } connector_info.type = 'line'; // horiz line connector_elem.classList.add("rotate-90"); } // draw the connector onto the screen function atree_render_connection(atree_connectors_map) { for (let i of atree_connectors_map.keys()) { let connector_info = atree_connectors_map.get(i); let connector_elem = connector_info.connector; let connector_img = document.createElement('img'); set_connector_type(connector_info); connector_img.src = '../media/atree/connect_'+connector_info.type+'.png'; connector_img.style = "width: 100%; height: 100%;" connector_elem.replaceChildren(connector_img); connector_info.highlight = [0, 0, 0, 0]; let target_elem = document.getElementById("atree-row-" + i.split(",")[0]).children[i.split(",")[1]]; if (target_elem.children.length != 0) { // janky special case... connector_elem.style.display = 'none'; } target_elem.appendChild(connector_elem); }; }; // toggle the state of a node. function atree_set_state(node_wrapper, new_state) { if (new_state) { node_wrapper.active = true; node_wrapper.elem.classList.add("atree-selected"); } else { node_wrapper.active = false; node_wrapper.elem.classList.remove("atree-selected"); } let atree_connectors_map = node_wrapper.all_connectors_ref; for (const parent of node_wrapper.parents) { if (parent.active) { atree_set_edge(atree_connectors_map, parent, node_wrapper, new_state); // self->parent state only changes if parent is on } } for (const child of node_wrapper.children) { if (child.active) { atree_set_edge(atree_connectors_map, node_wrapper, child, new_state); // Same logic as above. } } }; // refresh all connector to default state, then try to calculate the connector for all node function atree_update_connector() { atree_connectors_map.forEach((v) => { if (v.length != 0) { let connector_elem = document.createElement("img"); connector_elem.style = "width: 100%; height: 100%;"; connector_elem.src = '../media/atree/connect_' + v[0].type + '.png' v[0].replaceChildren(connector_elem); } }); atree_map.forEach((v) => { atree_compute_highlight(v); }); } function atree_set_edge(atree_connectors_map, parent, child, state) { const connectors = child.connectors.get(parent); const parent_row = parent.ability.display.row; const parent_col = parent.ability.display.col; const child_row = child.ability.display.row; const child_col = child.ability.display.col; let state_delta = (state ? 1 : -1); let child_side_idx = (parent_col > child_col ? 0 : 1); let parent_side_idx = 1 - child_side_idx; for (const connector_label of connectors) { let connector_info = atree_connectors_map.get(connector_label); let connector_elem = connector_info.connector; let highlight_state = connector_info.highlight; // left right up down let connector_img_elem = document.createElement("img"); connector_img_elem.style = "width: 100%; height: 100%;"; const ctype = connector_info.type; if (ctype === 't' || ctype === 'c') { // c, t const [connector_row, connector_col] = connector_label.split(',').map(x => parseInt(x)); if (connector_row === parent_row) { highlight_state[parent_side_idx] += state_delta; } else { highlight_state[2] += state_delta; // up connection guaranteed. } if (connector_col === child_col) { highlight_state[3] += state_delta; } else { highlight_state[child_side_idx] += state_delta; } let render_state = highlight_state.map(x => (x > 0 ? 1 : 0)); let connector_img = atree_parse_connector(render_state, ctype); connector_img_elem.src = connector_img.img connector_elem.className = ""; connector_elem.classList.add("rotate-" + connector_img.rotate); connector_elem.replaceChildren(connector_img_elem); continue; } // lol bad overloading, [0] is just the whole state highlight_state[0] += state_delta; if (highlight_state[0] > 0) { connector_img_elem.src = '../media/atree/highlight_'+ctype+'.png'; connector_elem.replaceChildren(connector_img_elem); } else { connector_img_elem.src = '../media/atree/connect_'+ctype+'.png'; connector_elem.replaceChildren(connector_img_elem); } } } // parse a sequence of left, right, up, down to appropriate connector image function atree_parse_connector(orient, type) { // left, right, up, down let c_connector_dict = { "1100": {attrib: "_2_l", rotate: 0}, "1010": {attrib: "_2_a", rotate: 0}, "1001": {attrib: "_2_a", rotate: 270}, "0110": {attrib: "_2_a", rotate: 90}, "0101": {attrib: "_2_a", rotate: 180}, "0011": {attrib: "_2_l", rotate: 90}, "1110": {attrib: "_3", rotate: 0}, "1101": {attrib: "_3", rotate: 180}, "1011": {attrib: "_3", rotate: 270}, "0111": {attrib: "_3", rotate: 90}, "1111": {attrib: "", rotate: 0} }; let t_connector_dict = { "1100": {attrib: "_2_l", rotate: 0}, "1001": {attrib: "_2_a", rotate: "flip"}, "0101": {attrib: "_2_a", rotate: 0}, "1101": {attrib: "_3", rotate: 0} }; let res = ""; for (let i of orient) { res += i; } if (res === "0000") { return {img: "../media/atree/connect_" + type + ".png", rotate: 0}; } let ret; if (type == "c") { ret = c_connector_dict[res]; } else { ret = t_connector_dict[res]; }; ret.img = "../media/atree/highlight_" + type + ret.attrib + ".png"; return ret; };