rpg-sheet/index.html

1598 lines
79 KiB
HTML

<!doctype html>
<!-- Site website is underl GPL-v3 license, please refer to https://git.sceptique.eu/Sceptique/rpg-sheet/src/branch/master/LICENSE -->
<html lang="fr" data-bs-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<title>Rpg Sheet</title>
</head>
<body>
<div class="container-fluid">
<div class="row px-1">
<div class="col-12 gx-4">
<div class="alert alert-success slideSource fade" id="characterUpdateSuccess">
Sauvegarde locale du personnage réussie !
</div>
<!-- <div class="alert alert-danger slideSource fade" id="characterUpdateFailure">
Sauvegarde locale du personnage échouée !
</div> -->
<!-- Rolling Dice Modal -->
<div class="modal fade" id="roll_dice" tabindex="-1" aria-labelledby="roll_dice_label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="roll_dice_label">Lancer des dés</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
...
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" onclick=RollDice.execute(event)>Jeter les dés !</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Fermer</button>
</div>
</div>
</div>
</div>
<div id="characters-list-part">
<div class="row">
<div class="accordion" id="characters_list">
</div>
</div>
<div class="row">
<div class="col-6">
<form onsubmit="return htmlNewCharacter(event)">
<div class="row">
<div class="col">
<div class="mb-3">
<label for="characters_list_new" class="form-text">Nom</label>
<input type="text" class="form-control" id="characters_list_new" />
</div>
</div>
<div class="col">
<button type="submit" class="btn btn-primary">Ajouter un nouveau personnage</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div><!-- col -->
<div class="col-12">
<div id="logs-part">
<h1>Journal des dés</h1>
<button onclick="reloadLogs(event, 20)">Reload last 20</button>
<div id="logs-entries"></div>
</div>
</div>
<div class="col-12">
<div id="discord-part">
<h1>Discord</h1>
<label for="discord_host">Nom de domaine</label>
<input id="discord_host" class="form-control" value="" />
<label for="discord_password">Mot de passe partagé</label>
<input id="discord_password" class="form-control" value="" type="password" />
</div>
</div>
</div> <!-- row -->
<footer class="row">
<a href="https://git.sceptique.eu/Sceptique/rpg-sheet">source code repository</a>
</footer>
</div><!-- container -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
<style>
.add-speciality:hover, .add-inventory:hover, .add-spell:hover { color: green; }
.add-speciality:active, .add-inventory:active, .add-spell:hover { color: red; }
textarea.character-speciality { font-size: 11px; }
.form-floating > label { font-size: 11px; }
.form-floating > .form-control {
/* padding: 0.5rem !important; */
height: calc(3.0rem + calc(var(--bs-border-width) * 2));
min-height: calc(3.0rem + calc(var(--bs-border-width) * 2));
}
.form-floating > .form-control {
padding-top: 1.2rem !important;
padding-bottom: 0.625rem !important;
}
.slideSource {
opacity:1;
-webkit-transition: opacity 3s;
-moz-transition: opacity 3s;
transition: opacity 3s;
}
.slideSource.fade {
opacity:0;
}
#characterUpdateSuccess {
position: absolute;
top: 2rem;
z-index: 10;
font-size: x-large;
}
</style>
<script type="text/javascript">
function blankCharacter(store, options = {}) {
if (options.force || store.stats == undefined) store.stats = {};
if (options.force || store.spells == undefined) store.spells = [];
if (options.force || store.inventory == undefined) store.inventory = [];
if (options.force || store.equipments == undefined) store.equipments = {};
if (options.force || store.personnals == undefined) store.personnals = {};
if (options.force || store.states == undefined) store.states = {};
if (options.force || store.specialities == undefined) store.specialities = {};
if (options.force || store.experience == undefined) store.experience = {};
return store;
}
function createHtmlElem(parent, type_ = "div", attributes = {}, text = "") {
if (!parent) { throw `Invalid parent to create ${type_} ${JSON.stringify(attributes)} ${text}` }
const elem = document.createElement(type_);
elem.textContent = text;
Object.keys(attributes).forEach((attr) => {
elem.setAttribute(attr, attributes[attr]);
});
parent.append(elem);
return elem;
}
function sluggify(text) {
return text
.replaceAll("à", "a")
.replaceAll("â", "a")
.replaceAll("é", "e")
.replaceAll("è", "e")
.replaceAll("ê", "e")
.replaceAll("î", "i")
.replaceAll("ï", "i")
.replaceAll("À", "A")
.replaceAll("Â", "A")
.replaceAll("É", "E")
.replaceAll("È", "E")
.replaceAll("Ê", "E")
.replaceAll("Î", "I")
.replaceAll("Ï", "I")
.replaceAll(/[^\w]/g, "_")
.toLowerCase();
}
function capitalize(text) {
return text[0].toUpperCase() + text.slice(1).toLowerCase();
}
function getElementByXpath(path) {
return document.evaluate(path, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
}
function stackTrace() {
const err = new Error();
return err.stack;
}
function checkHtmlErrors(html) {
const doc = new DOMParser().parseFromString(html, "text/html");
const errorNode = doc.querySelector("parsererror");
if (errorNode) {
const error = `Error renderHtml: ${errorNode.textContent}\n${html}\n\n${stackTrace()}`;
throw error;
}
}
function renderHtml(root, html, options = {}) {
if (options.version == 1) {
root.innerHTML += html;
return null;
}
else if (options.version == 2) {
const doc = new DOMParser().parseFromString(html, "text/html");
const errorNode = doc.querySelector("parsererror");
if (errorNode) {
const error = `Error renderHtml: ${errorNode.textContent}\n${html}\n\n${stackTrace()}`;
throw error;
}
root.append(doc.firstChild);
return doc;
}
else if (options.version == 3 || options.version == undefined) {
checkHtmlErrors(html);
const template = document.createElement("template"); // in fact it can be anything
template.innerHTML = html;
root.append(template.content);
return template.content;
}
}
function download(filename, text) {
var element = document.createElement('a');
element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(text));
element.setAttribute('download', filename);
element.style.display = 'none';
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
}
/*
in the inputs:
- mandatory: name
- optional: id (by default slug of name)
- optional: type (by default "text")
- optional: row (each uniq row number will be a new layout line) (default 1)
- optional: colspan relative size to 12 (default 12)
- optional: sticky (if true, no new column) (default false)
- optional: class (to add)
- optionals: min, max, select
in the options
{ roll: [...] },
*/
function addHtmlFloatForm(parent, parent_id, inputs, options = {}) {
const current_char = new CurrentCharacter().data();
if (!inputs || inputs.length == 0) {
throw `Invalid float form for ${parent} with ${parent_id} and no inputs`;
}
inputs.forEach((input) => {
input.id ||= sluggify(input.name);
input.type ||= "text";
if (input.row == undefined) input.row = 1;
if (input.colspan == undefined) input.colspan = 12;
if (input.sticky == undefined) input.sticky = true;
input.class ||= "";
if (input.value == undefined) input.value = "";
if (input.html == undefined) input.html = "";
});
const sorted_inputs = inputs.sort((a, b) => a.row - b.row) // sort by row so we can split them
const inputs_by_row = Sort.groupBy(sorted_inputs, "row");
let current_row = 0;
Object.keys(inputs_by_row).forEach((row) => {
const inputs = inputs_by_row[row];
let current_col = 0;
current_row += 1;
const rows_html = inputs.map((input) => {
const group_size = `input-group-${input.size || "sm"}`;
current_col += 1;
let input_html = "";
if (input.type == "textarea") input_html = makeFloatFormV2Textarea(parent_id, input);
else if (input.type == "checkbox") input_html = makeFloatFormV2Checkbox(parent_id, input);
else if (input.type == "select") input_html = makeFloatFormV2Select(parent_id, input);
/* else if (input.type == "radio") input_html = makeFloatFormV2Radio(parent_id, input); */
else if (input.type == "button") input_html = makeFloatFormV2Button(parent_id, input);
else input_html = makeFloatFormV2Input(parent_id, input);
if (input.name != undefined) {
input_html = `<div class="form-floating">${input_html}</div>`;
}
if (current_col == 1 && current_row == 1 && options.roll) {
const roll_paths = (options.roll.paths || []).map(c => `"${c}"`).join(", ")
input_html = `
<span
class="input-group-text ${options.roll.class || ''}"
data-bs-toggle="modal" data-bs-target="#roll_dice"
onclick='RollDice.openForm(event, "${current_char.name}", [${roll_paths}], "${options.roll.reason || ''}")'
>
<i class="bi bi-dice-3"></i>
</span>
${input_html}
`;
}
if (current_col == 1 && options.refresh) {
input_html = `
<span
class="input-group-text ${options.refresh.class || ''}"
onclick='${options.refresh.onclick || ''}'
value-id='${input.id}'
>
<i class="bi bi-arrow-clockwise"></i>
</span>
${input_html}
`;
}
if (!input.sticky || options.roll || options.refresh) {
input_html = `<div class="input-group ${group_size} ${options.groups_class}">${input_html}</div>`;
}
input_html = `<div class="col-${input.colspan} gx-${input.sticky ? 0 : 3}">${input_html}</div>`;
return input_html;
});
const html = `<span><div class="row ${options.row_class || ""}">${rows_html.join("")}</div></span>`;
renderHtml(parent, html, { version: 3 })
});
}
function makeFloatFormV2Button(parent_id, input) {
const full_id = `form_field_${parent_id}_${input.id}`;
const size = `btn-${input.size || "sm"}`;
const input_html = `
<button id="${full_id}"
type="${input.type}"
class="floatformfield ${input.class} ${size}"
value-id="${input.id}"
value="${input.value}" ${input.html}
>${input.name || input.id}</button>
`;
return input_html;
}
function makeFloatFormV2Input(parent_id, input) {
const full_id = `form_field_${parent_id}_${input.id}`;
const size = `form-control-${input.size || "sm"}`;
const label_html = input.name ? `<label for="${full_id}">${input.name}</label>` : "";
let optional_html = "";
if (input.min) optional_html += ` min="${input.min}"`
if (input.max) optional_html += ` max="${input.max}"`
const input_html = `
<input id="${full_id}"
type="${input.type}"
class="floatformfield form-control ${input.class} ${size}"
value-id="${input.id}"
value="${input.value}" ${optional_html} ${input.html}
/>${label_html}
`;
return input_html;
}
function makeFloatFormV2Textarea(parent_id, input) {
const full_id = `form_field_${parent_id}_${input.id}`;
const size = `form-control-${input.size || "sm"}`;
const label_html = input.name ? `<label for="${full_id}">${input.name}</label>` : "";
return `
<textarea id="${full_id}"
class="floatformfield form-control ${input.class} ${size}"
value-id="${input.id}" ${input.html}
>${input.value}</textarea>${label_html}
`;
}
function makeFloatFormV2Checkbox(parent_id, input) {
const full_id = `form_field_${parent_id}_${input.id}`;
const label_html = input.name ? `<label for="${full_id}">${input.name}</label>` : "";
const input_html = `
<div class="form-check">
<input type="checkbox"
id="${full_id}"
class="floatformfield form-check-input ${input.class}"
value-id="${input.id}" ${input.html}
${input.value ? 'checked' : ''}
/>
${label_html}
</div>
`;
return input_html;
}
function makeFloatFormV2Select(parent_id, input) {
const full_id = `form_field_${parent_id}_${input.id}`;
const size = `form-select-${input.size || "sm"}`;
const label_html = input.name ? `<label for="${full_id}">${input.name}</label>` : "";
const options_html = Object.keys(input.options).map(option_key => `<option value="${input.options[option_key]}" ${input.options[option_key] == input.value ? 'selected' : ''}>${option_key}</option>`).join("");
const input_html = `
<select id="${full_id}"
class="floatformfield form-select ${input.class} ${size}"
value-id="${input.id}"
aria-label="${input.name}"
>
${options_html}
</select>${label_html}
`;
return input_html;
}
/* function makeFloatFormV2Radio(parent_id, input) {
* const full_id = `form_field_${parent_id}_${input.id}`;
* const size = `form-select-${input.size || "sm"}`;
* const label_html = input.name ? `<label for="${full_id}">${input.name}</label>` : "";
* const options_html = Object.keys(input.options).map((option_key) => {
* const option_html = `
* <input class="floatformfield form-check-input ${input.class}" type="radio" id="${full_id}_${option_key}" value="${option_key}" ${input.value == option_key ? "selected" : ""} /><label class="form-check-label" for="${full_id}_${option_key}">${input.options[option_key]}</label>
* `;
* return option_html;
* });
* const full_options_html = options_html.map(option_html => `<div class="form-check form-check-inline">${option_html}</div>`).join("");
* const input_html = `
<div class="row"><div class="col-4"><span>${input.name}</span></div><div class="col-4">${full_options_html}</div><div class="col-4"><button class="btn">Upgrade</button></div></div>
* `;
* return input_html
* } */
class Sort {
static groupBy(list, callback) {
return list.reduce((base, item) => {
if (callback.__proto__.isPrototypeOf(Function)) {
const key = callback(item);
base[key] ||= [];
base[key].push(item);
} else {
const key = item[callback];
base[key] ||= [];
base[key].push(item);
}
return base;
}, {});
}
static sortBy(list, callback) {
return list.sort((a, b) => {
const ca = callback(a);
const cb = callback(b);
if (ca == cb) return 0;
if (ca > cb) return 1;
return -1;
});
}
static toggle(event, parent_selector, lines_selector, cell_selector, value) {
const parent = document.querySelector(parent_selector);
const lines = [];
parent.querySelectorAll(lines_selector).forEach((line) => lines.push(line));
const sorted_lines = Sort.sortBy(lines, (line) => {
const input = line.querySelector(cell_selector);
if (input == undefined) return null;
if (input.type == "checkbox") return input.checked;
return input.value;
});
const sort_order = lines.length > 0 ? lines[0].getAttribute("sort-order") : "desc";
if (sort_order == "asc") sorted_lines.reverse();
sorted_lines.forEach((line) => {
line.setAttribute("sort-order", sort_order == "asc" ? "desc" : "asc");
parent.removeChild(line);
parent.appendChild(line);
});
return sorted_lines;
}
}
/* access to local storage data with some error/default values management and serialization */
class DataAPI {
constructor(storage) { this.storage = storage; }
addDiscordInfos(host, password) {
this.storage.setItem("discord.host", host);
this.storage.setItem("discord.password", password);
}
getDiscordInfos() {
return {
host: this.storage.getItem("discord.host") || "",
password: this.storage.getItem("discord.password") || "",
};
}
addPortrait(name, b64) {
this.storage.setItem(`portraits.${name}`, b64);
}
getPortrait(name) {
return this.storage.getItem(`portraits.${name}`);
}
addLog(log) {
this.storage.setItem(log.key, JSON.stringify(log));
const keys = JSON.parse(this.storage.getItem("logs.keys") || "[]");
keys.push(log.key);
this.storage.setItem("logs.keys", JSON.stringify(keys));
}
getLastLogs(last = 20) {
const keys = JSON.parse(this.storage.getItem("logs.keys") || "[]");
const last_keys = keys.slice(0, last - 1);
return last_keys.map((key) => JSON.parse(this.storage.getItem(key)));
}
getCharacter(name) {
const list = this.getCharactersList();
const character = list[name];
if (character == undefined) {
alert("Erreur: personnage non-trouvé. Rafraichir la page ou contacter Sceptique.");
return null;
}
blankCharacter(character, { force: false });
return character;
}
addCharacter(name) {
const list = this.getCharactersList();
if (!list[name]) {
list[name] = { name: name };
this.setCharactersList(list);
return list[name];
} else {
throw `Invalid character already exists ${name}`;
}
}
updateCharacter(name, data) {
const list = this.getCharactersList();
if (list[name]) {
list[name] = data;
this.setCharactersList(list);
return list[name];
} else {
throw `Invalid character "${name}" do not exists`;
}
}
getCharactersList() {
try {
const data = JSON.parse(this.storage.getItem("characters_list") || "{}");
return data;
}
catch (error) {
console.log("invalid storage, reset")
return {};
}
}
setCharactersList(data) {
return this.storage.setItem("characters_list", JSON.stringify(data));
}
removeCharacter(name) {
const data = this.getCharactersList();
delete data[name];
return this.storage.setItem("characters_list", JSON.stringify(data));
}
}
/* reads where the character is in html and the local storage data link */
class CurrentCharacter {
constructor() {
}
isSelected() {
if (this._is_selected == undefined)
this._is_selected = document.getElementsByClassName("accordion-collapse show")[0] != undefined;
return this._is_selected;
}
htmlRoot() {
if (!this.isSelected()) return console.log("No character selected, no root selection");
this._html_root ||= document.getElementsByClassName("accordion-collapse show")[0];
return this._html_root;
}
name() {
if (!this.isSelected()) return console.log("No character selected, no name");
this._name ||= this.htmlRoot().attributes["character-name"].value;
return this._name;
}
data() {
if (!this.isSelected()) return console.log("No character selected, no data");
this._data ||= api.getCharacter(this.name());
return this._data;
}
recordUpdate(event) {
if (!this.isSelected()) return console.log("No character selected, no update");
const character = this.data();
console.log("update record for", character.name);
blankCharacter(character, { force: true });
updateStoreWithFields(
character, // TODO: use character.stats
this.htmlRoot().querySelectorAll(".character-value"),
);
// V2 done
updateStoreWithFields(
character.stats,
this.htmlRoot().querySelectorAll(".character-stats .character-stat"),
);
// V2 done
updateStoreWithFields(
character.states,
this.htmlRoot().querySelectorAll(".character-states .character-state"),
);
// V2 done
updateStoreWithFields(
character.personnals,
this.htmlRoot().querySelectorAll(".character-personnals .character-personnal"),
);
// V2 done
this.htmlRoot().querySelectorAll(".character-speciality-line").forEach((current_elem) => {
const inputs = current_elem.querySelectorAll(".character-speciality");
const speciality = parseMultiFields(inputs);
character.specialities[speciality.name] = speciality;
});
// V2 done
updateStoreWithFields(
character.equipments,
this.genericGetInputs(".character-equipments input"),
);
// V2 done
document.querySelectorAll(".accordion-collapse.show .character-inventory .inventory-line").forEach((inventory_line) => {
const inventory_item = parseMultiFields(
inventory_line.querySelectorAll("input"),
inventory_line.querySelectorAll("textarea"),
inventory_line.querySelectorAll("select"),
);
character.inventory.push(inventory_item);
});
// V2 done
document.querySelectorAll(".accordion-collapse.show .character-spells .character-spell").forEach((line) => {
const spell = parseMultiFields(line.querySelectorAll("input, textarea"));
character.spells.push(spell);
});
// V2 done
updateStoreWithFields(
character.experience,
document.querySelectorAll(".accordion-collapse.show .character-experience .character-xp"),
);
api.updateCharacter(character.name, character);
const alert = document.getElementById("characterUpdateSuccess");
alert.classList.toggle('fade');
setTimeout(() => {
alert.classList.toggle('fade');
}, 2000);
return character;
}
increaseStat(key, value) {
const input = this.genericGetInput(".character-stats .character-stat", key);
input.value = input.valueAsNumber + value;
return input;
}
increaseSpe(key, value) {
const input = this.htmlRoot().querySelector(`.character-speciality[value-id=name][value="${key}"]`).parentElement.parentElement.parentElement.querySelector(".character-speciality[value-id=level]")
input.value = input.valueAsNumber + value;
return input;
}
increaseExperience(key, value) {
const input = this.genericGetInput(".character-experience .character-xp", key);
if (input.valueAsNumber < 3) {
input.value = input.valueAsNumber + value;
}
return input;
}
setExperience(key, value) {
const input = this.genericGetInput(".character-experience .character-xp", key);
input.value = value;
return input;
}
getExperience(key, value) {
const inputs = Array.fromDom(this.htmlRoot().querySelectorAll(".character-experience .character-xp"));
return inputs.reduce((base, input) => {
base[input.getAttribute("value-id")] = input.valueAsNumber || 0;
return base;
}, {});
}
genericGetInputs(inputs_selector) {
return Array.fromDom(this.htmlRoot().querySelectorAll(inputs_selector));
}
genericGetInput(inputs_selector, key) {
return this.genericGetInputs(inputs_selector).find(e => e.getAttribute("value-id") == key);
}
}
Array.fromDom = function (dom) {
const arr = [];
dom.forEach(e => arr.push(e));
return arr;
};
class CharactersList {
constructor(api) { this.api = api; }
addHtml(characters_list_elem, display_pointer = undefined) {
let first_shown = false;
const names = Object.keys(this.api.getCharactersList());
characters_list_elem.innerHTML = "";
names.forEach((name) => {
const slug = sluggify(name);
/* <div class="accordion-item">
* <h2 class="accordion-header">
* <button class="accordion-button" type="button" data-bs-toggle="collapse" "data-bs-target"=#character_${slug} "aria-controls"=${slug}>${name}</button>
* </h2>
* <div id="character_${slug}" character-name=${name} class="accordion-collapse collapse" data-bs-parent="#characters_list">
* <div class="accordion-body">
* </div>
* </div>
* </div>
*/
let show = "";
let collapsed = "collapsed";
if (!first_shown && display_pointer == undefined) {
first_shown = true;
show = "show";
collapsed = "";
} else if (display_pointer == name) {
first_shown = true;
show = "show";
collapsed = "";
}
// build html dom item by item because we need to return one of the leaf
const character_elem = createHtmlElem(
characters_list_elem,
"div",
{ class: "accordion-item" },
);
const character_header = createHtmlElem(
character_elem,
"h2",
{ class: "accordion-header" },
);
const character_header_button = createHtmlElem(
character_header,
"button",
{ class: `accordion-button ${collapsed}`, "character-name": name, type: "button", "data-bs-toggle": "collapse", "data-bs-target": `#character_${slug}`, "aria-controls": slug },
name,
);
const character_body = createHtmlElem(
character_elem,
"div",
{ id: `character_${slug}`, "character-name": name, class: `accordion-collapse collapse ${show}`, "data-bs-parent": "#characters_list" },
);
const character_body_content = createHtmlElem(
character_body,
"div",
{ class: "accordion-body" },
);
const sheet = new CharacterSheet(this.api, name)
sheet.addHtml(character_body_content, name)
return character_elem;
});
}
addNew(name) {
if (!name) {
alert("Nom vide illégal, ajouter un nom pour continuer.")
}
else if (this.api.addCharacter(name)) {
this.addHtml(document.getElementById("characters_list"), name);
} else {
alert(`${name} est déjà un nom de personnage utilisé. Action annulée.`)
}
}
}
class Inventory {
addHtml(col) {
const inventory_row = createHtmlElem(col, "div", { class: "row character-inventory" });
const inventory_h2 = createHtmlElem(inventory_row, "h2", {}, "Inventaire & sacs");
const new_entry_button = ` <i class="bi bi-plus-square add-inventory" onclick="Inventory.addRow()"></i>`;
renderHtml(inventory_h2, new_entry_button, { version: 3 });
/* const inventory_table = createHtmlElem(inventory_row, "table", { class: "table table-striped table-hover table-sm" });
* const inventory_table_thead = createHtmlElem(inventory_table, "thead");
* const thead_html = "<tr><th>Infos</th><th>Usure</th><th>Utilisé ?</th></tr>";
* renderHtml(inventory_table_thead, thead_html, { version: 3 });
* const inventory_table_tbody = createHtmlElem(inventory_table, "tbody", { class: "table-group-divider"}); */
const current_char = new CurrentCharacter().data();
const headers_html = `
<div class="row">
<div onclick="Sort.toggle(event, '.accordion-collapse.show .character-inventory', '.inventory-line', '.floatformfield[value-id=objet]')" class="col-6" data-bs-toggle="tooltip" data-bs-title="Nom, description, nombre">Objet</div>
<div onclick="Sort.toggle(event, '.accordion-collapse.show .character-inventory', '.inventory-line', '.floatformfield[value-id=usure]')" class="col-2" data-bs-toggle="tooltip" data-bs-title="Le niveau d'usure. A fragilisé, l'usage devient dangereux">Usure</div>
<div onclick="Sort.toggle(event, '.accordion-collapse.show .character-inventory', '.inventory-line', '.floatformfield[value-id=used]')" class="col-1" data-bs-toggle="tooltip" data-bs-title="Est-ce que l'objet à été utilisé depuis le dernier jet de SUR ?">Uti.</div>
<div onclick="Sort.toggle(event, '.accordion-collapse.show .character-inventory', '.inventory-line', '.floatformfield[value-id=hold]')" class="col-1" data-bs-toggle="tooltip" data-bs-title="A porté de main ?">Port.</div>
<div onclick="Sort.toggle(event, '.accordion-collapse.show .character-inventory', '.inventory-line', '.floatformfield[value-id=categorie]')" class="col-2" data-bs-toggle="tooltip" data-bs-title="Catégorie">Cat.</div>
</div>
`;
renderHtml(inventory_row, headers_html, { version: 3 });
let index = 1;
current_char.inventory.forEach((inventory_data) => {
this.addHtmlInventoryLine(inventory_row, index, inventory_data);
index += 1;
});
Sort.toggle({}, '.accordion-collapse.show .character-inventory', '.inventory-line', '.floatformfield[value-id=categorie]');
}
addHtmlInventoryLine(table, index, data = {}) {
const inventory_line = createHtmlElem(table, "div", { class: "inventory-line" });
addHtmlFloatForm(
inventory_line,
`inventory_${index}`, [
{ id: "objet", colspan: 6, type: "text", value: data.objet || data.infos },
{ id: "usure", colspan: 2, type: "select", value: data.usure, options: {"parfait":1, "neuf":2, "bon":3, "usé":4, "endommagé":5, "fragilisé":6} },
{ id: "used", colspan: 1, type: "checkbox", value: data.used },
{ id: "hold", colspan: 1, type: "checkbox", value: data.hold },
{ id: "categorie", colspan: 2, type: "text", value: data.categorie },
],
{ },
)
}
static addRow(event) {
const current_character = new CurrentCharacter();
const inventory_elem = current_character.htmlRoot().querySelector(".character-inventory");
const new_inventory_line = new Inventory().addHtmlInventoryLine(inventory_elem);
}
}
class Spell {
addHtml(col) {
const character = new CurrentCharacter().data();
const spells_h2 = createHtmlElem(col, "h2", { }, "Magie & Sorts");
const add_entry_button = ` <i class="bi bi-plus-square add-inventory" onclick="Spell.addRow()"></i>`;
renderHtml(spells_h2, add_entry_button, { version: 3 });
const spells_row = createHtmlElem(col, "div", { class: "character-spells" }, "");
const spells = character.spells || [];
spells.forEach(spell => this.addHtmlSpellLine(spells_row, spell));
}
addHtmlSpellLine(parent, spell_data = {}) {
this.spell_count ||= 0;
this.spell_count += 1;
const spell_name = spell_data.nom || `Nouveau sort n°${this.spell_count}`;
const spell_id = sluggify(spell_name);
const spell_line_elem = createHtmlElem(parent, "div", { class: "row g-0 character-spell" });
addHtmlFloatForm(
spell_line_elem,
spell_id,
[
{ name: "Nom", row: 1, colspan: 8, value: spell_name, id: "nom" },
{ name: "Coût", row: 1, colspan: 1, value: spell_data.cost || 0, id: "cost", type: "number", min: 0, max: 100, },
{ name: "Bonus", row: 1, colspan: 1, value: spell_data.bonus || 0, id: "bonus", type: "number", min: -100, max: 100, },
{ name: "Rune", row: 1, colspan: 2, value: spell_data.rune || "", id: "run", },
{ name: "Description", row: 2, colspan: 6, value: spell_data.desc || "", id: "desc", type: "textarea" },
{ name: "Incantation", row: 2, colspan: 6, value: spell_data.incantation || "", id: "incantation", type: "textarea", },
],
{ roll: { paths: ["stats.MAG"], reason: spell_name } },
)
}
static addRow(event) {
const current_character = new CurrentCharacter();
const spells_elem = current_character.htmlRoot().querySelector(".character-spells");
const new_spell_line = new Spell().addHtmlSpellLine(spells_elem);
}
}
class Speciality {
addHtml(col) {
const current_char = new CurrentCharacter().data();
const specialities_h2 = createHtmlElem(col, "h2", {}, "Traits et spécialités");
const new_entry_button = ` <i class="bi bi-plus-square add-speciality" onclick="Speciality.addRow()"></i>`;
renderHtml(specialities_h2, new_entry_button, { version: 3 });
const specialities_group = createHtmlElem(col, "div", { class: "specialities" });
Object.keys(current_char.specialities || {}).forEach((speciality_name) => {
this.addHtmlSpecialityLine(specialities_group, speciality_name);
});
}
/*
* Add a new speciality line of html
* requires the root html to add the line in
*/
addHtmlSpecialityLine(parent, speciality_name_to_load = null) {
this.specialities_count ||= 0;
this.specialities_count += 1;
const current_char = new CurrentCharacter().data();
const speciality_id = `speciality_${speciality_name_to_load || this.specialities_count}`;
const speciality = (current_char.specialities || {})[speciality_name_to_load] || {};
// complex id so i don't rewrite them twice
const name_id = `character_${this.name}_s_${this.specialities_count}_name`;
const level_id = `character_${this.name}_s_${this.specialities_count}_level`;
const desc_id = `character_${this.name}_s_${this.specialities_count}_desc`;
// the main element where subfields are defined
const main = createHtmlElem(parent, "div", { class: "character-speciality-line" })
addHtmlFloatForm(main, "specialities", [
{ name: "Nom", colspan: 4, class: "character-speciality", value: speciality.name || "", id: "name" },
{ name: "Niveau", colspan: 2, class: "character-speciality", value: speciality.level || "", id: "level", type: "number", min: 1, max: 20 },
{ name: "Description", colspan: 6, class: "character-speciality", value: speciality.desc || "", id: "desc", type: "textarea" },
]);
return main;
}
static addRow(event) {
const current_character = new CurrentCharacter();
const specialities_elem = current_character.htmlRoot().querySelectorAll(".specialities")[0];
const new_spe = new Speciality().addHtmlSpecialityLine(specialities_elem);
}
}
class Personnal {
addHtml(col) {
const current_char = new CurrentCharacter().data();
const personnals = current_char.personnals;
createHtmlElem(col, "h2", {}, "Infos personnelles");
const personnals_row = createHtmlElem(col, "div", { class: "row character-personnals" });
addHtmlFloatForm(personnals_row, "personnals", [
{ name: "Prénoms", class: "character-personnal", value: personnals.prenoms || "" },
{ name: "Nom", class: "character-personnal", value: personnals.nom || "" },
{ name: "Carrière", class: "character-personnal", value: personnals.carriere || "" },
{ name: "Origine", class: "character-personnal", value: personnals.origine || "" },
{ name: "Âge", class: "character-personnal", value: personnals.age || "" },
{ name: "Taille", class: "character-personnal", value: personnals.taille || "" },
{ name: "Masse", class: "character-personnal", value: personnals.masse || "" },
{ name: "Silouette", class: "character-personnal", value: personnals.silouette || "" },
{ name: "Dextrie", class: "character-personnal", value: personnals.dextrie || "" },
{ name: "Cheveux", class: "character-personnal", value: personnals.cheveux || "" },
{ name: "Yeux", class: "character-personnal", value: personnals.yeux || "" },
{ name: "Traits", class: "character-personnal", value: personnals.traits || "" },
], { rows_class: "", groups_class: "" });
}
}
class Portrait {
addHtml(col) {
const current_char = new CurrentCharacter().data();
const figure = createHtmlElem(col, "figure", { class: "figure" });
const portraitb64 = api.getPortrait(current_char.name);
const figure_html = `
<figure class="figure">
<img class="figure-img img-fluid rounded" src="${portraitb64}" alt="Portrait de ${current_char.name} ici" />
<div class="mb-3">
<label for="portraitFile" class="form-label">Nouveau portrait</label>
<input class="form-control form-control-sm portraitFile" id="portraitFile" type="file" accept="image/*" />
</div>
</figure>
`;
renderHtml(figure, figure_html, { version: 3 });
}
}
class Notes {
addHtml(col) {
const current_char = new CurrentCharacter().data();
createHtmlElem(col, "h2", {}, "Notes");
createHtmlElem(col, "textarea", { class: "form-control character-value", "value-id": "custom_notes" }, current_char.custom_notes || "");
}
}
class CharacterHelper {
addHtml(col, name) {
const slug = sluggify(name);
const character_buttons = createHtmlElem(
col,
"div",
{ class: "btn-group", role: "group" },
);
const character_remove_button = createHtmlElem(
character_buttons,
"button",
{ class: "btn btn-danger", id: `character_${slug}_delete`, "onclick": "CharacterButtons.characterDelete()" },
"effacer",
);
const character_export_button = createHtmlElem(
character_buttons,
"button",
{ class: "btn btn-primary", id: `character_${slug}_export`, "onclick": "CharacterButtons.characterExport()" },
"export",
);
const character_import_button = createHtmlElem(
character_buttons,
"input",
{ class: "btn btn-warning", type: "file", id: `character_${slug}_import`, "onchange": "CharacterButtons.characterImport(event)" },
"import",
);
}
}
class CharacterState {
addHtml(col) {
const states_row = createHtmlElem(col, "div", { class: "character-states row" });
createHtmlElem(states_row, "h2", {}, "Bonus, Malus");
const states = new CurrentCharacter().data().states;
addHtmlFloatForm(states_row, "states", [{ name: "Inspiration", value: states.inspiration, class: "character-state", color: "green" }]);
addHtmlFloatForm(states_row, "states", [{ name: "État 1", id: "etat1", value: states.etat1, class: "character-state", colorizable: true }]);
addHtmlFloatForm(states_row, "states", [{ name: "État 2", id: "etat2", value: states.etat2, class: "character-state", colorizable: true }]);
addHtmlFloatForm(states_row, "states", [{ name: "État 3", id: "etat3", value: states.etat3, class: "character-state", colorizable: true }]);
addHtmlFloatForm(states_row, "states", [{ name: "État 4", id: "etat4", value: states.etat4, class: "character-state", colorizable: true }]);
addHtmlFloatForm(states_row, "states", [{ name: "État 5", id: "etat5", value: states.etat5, class: "character-state", colorizable: true }]);
}
}
class Characteristic {
static STATS = [
{ name: "Combat", code: "CMB" },
{ name: "Connaissance", code: "CNS" },
{ name: "Discretion", code: "DIS" },
{ name: "Endurance", code: "END" },
{ name: "Force", code: "FOR" },
{ name: "Habileté", code: "HAB" },
{ name: "Mouvement", code: "MVT" },
{ name: "Perception", code: "PER" },
{ name: "Sociabilité", code: "SOC" },
{ name: "Survie", code: "SUR" },
{ name: "Tir", code: "TIR" },
{ name: "Magie", code: "MAG" },
{ name: "Volonté", code: "VOL" },
];
static STATS_BY_CODE = Characteristic.STATS.reduce((base, s) => {
base[s.code] = s.name;
return base;
}, {})
addHtml(col) {
const current_char = new CurrentCharacter().data();
createHtmlElem(col, "h2", {}, "Caractéristiques");
const stats_row = createHtmlElem(col, "div", { class: "row character-stats" });
Characteristic.STATS.forEach((stat) => {
const { code, name } = stat;
const tmp_code = `${code}_tmp`;
addHtmlFloatForm(stats_row, "stats", [
{ name: `${stat.name} (${code})`, class: "character-stat", colspan: 6, id: code, size: "sm", value: current_char.stats[code] || 0, type: "number", min: -100, max: 200, sticky: true },
{ name: `Tmp (${code})`, class: "character-stat", colspan: 6, id: tmp_code, size: "sm", value: current_char.stats[tmp_code] || 0, type: "number", min: -100, max: 200, sticky: true },
], { roll: { paths: [`stats.${code}`] } });
});
}
}
class SecondaryCharacteristic {
addHtml(col) {
const current_char = new CurrentCharacter().data();
const stats_row = createHtmlElem(col, "div", { class: "character-stats" });
createHtmlElem(stats_row, "h2", {}, "Stats");
addHtmlFloatForm(stats_row, "stats", [
{ name: `Niveau`, class: "character-stat", colspan: 6, id: "niveau", size: "sm", type: "number", min: 0, max: 100, value: current_char.stats.niveau || 0, sticky: true },
{ name: `XP utilisé`, class: "character-stat", colspan: 6, id: "xp_used", size: "sm", type: "number", min: 0, max: 100, value: current_char.stats.xp_used || 0, sticky: true },
]);
addHtmlFloatForm(stats_row, "stats", [
{ name: `Points de Vie (PV)`, class: "character-stat", colspan: 6, id: "pv", size: "sm", type: "number", min: 0, max: 100, value: current_char.stats.pv || 0, sticky: true },
{ name: `PV restants`, class: "character-stat", colspan: 6, id: "pv_restants", size: "sm", type: "number", min: 0, max: 100, value: current_char.stats.pv_restants || 0, sticky: true },
], { refresh: { onclick: "SecondaryCharacteristic.refresh(event)", class: "" } });
addHtmlFloatForm(stats_row, "stats", [
{ name: `Points de Fortune (PF)`, class: "character-stat", colspan: 6, id: "pf", size: "sm", type: "number", min: 0, max: 100, value: current_char.stats.pf || 0, sticky: true },
{ name: `PF restants`, class: "character-stat", colspan: 6, id: "pf_restants", size: "sm", type: "number", min: 0, max: 100, value: current_char.stats.pf_restants || 0, sticky: true },
], { refresh: { onclick: "SecondaryCharacteristic.refresh(event)", class: "" } });
addHtmlFloatForm(stats_row, "stats", [
{ name: `Points de Mana (PM)`, class: "character-stat", colspan: 6, id: "mana", size: "sm", type: "number", min: 0, max: 100, value: current_char.stats.mana || 0, sticky: true },
{ name: `PM restants`, class: "character-stat", colspan: 6, id: "mana_restants", size: "sm", type: "number", min: 0, max: 100, value: current_char.stats.mana_restants || 0, sticky: true },
]);
addHtmlFloatForm(stats_row, "stats", [
{ name: `Bonus de Force (BF)`, class: "character-stat", colspan: 6, id: "bf", size: "sm", type: "number", min: 0, max: 100, value: current_char.stats.bf || 0, sticky: true },
], { refresh: { onclick: "SecondaryCharacteristic.refresh(event)", class: "" } });
}
static refresh(event) {
const stat = event.target.parentElement.getAttribute("value-id");
if (stat == "pv") { this.refreshPV(event) }
else if (stat == "pf") { this.refreshPF(event) }
else if (stat == "bf") { this.refreshBF(event) }
}
static refreshPV(event) {
const current_char = new CurrentCharacter();
const pv = current_char.genericGetInput(".character-stat", "pv");
const stat_end = current_char.genericGetInput(".character-stat", "END").valueAsNumber || 0;
const stat_for = current_char.genericGetInput(".character-stat", "FOR").valueAsNumber || 0;
const stat_sur = current_char.genericGetInput(".character-stat", "SUR").valueAsNumber || 0;
const stat_vol = current_char.genericGetInput(".character-stat", "VOL").valueAsNumber || 0;
pv.value = Math.floor(stat_end / 5) + Math.floor(stat_for / 10) + Math.floor(stat_sur / 10) + Math.floor(stat_vol / 10);
}
static refreshPF(event) {
const current_char = new CurrentCharacter();
const pf = current_char.genericGetInput(".character-stat", "pf");
const stat_mvt = current_char.genericGetInput(".character-stat", "MVT").valueAsNumber || 0;
const stat_per = current_char.genericGetInput(".character-stat", "PER").valueAsNumber || 0;
pf.value = Math.floor(stat_mvt / 10) + Math.floor(stat_per / 10);
}
static refreshBF(event) {
const current_char = new CurrentCharacter();
const bf = current_char.genericGetInput(".character-stat", "bf");
const stat_for = current_char.genericGetInput(".character-stat", "FOR").valueAsNumber || 0;
bf.value = Math.floor(stat_for / 10);
}
}
class Equipment {
addHtml(col) {
const col_row = createHtmlElem(col, "div", { class: "row character-equipments" });
const equipement_h2 = createHtmlElem(col_row, "h2", {}, "Équipement porté");
const current_char = new CurrentCharacter().data();
const equipments = current_char.equipments;
addHtmlFloatForm(col_row, "equipments", [
{ name: "Mêlée 1", value: equipments.melee_1, row: 1, colspan: 4 },
{ name: "Dégats", value: equipments.degats_melee_1, row: 1, colspan: 2, id: "degats_melee_1" },
{ name: "Bonus", value: equipments.bonus_melee_1, row: 1, colspan: 2, id: "bonus_melee_1" },
{ name: "Bouclier", value: equipments.bouclier, row: 1, colspan: 4, sticky: false },
], { roll: { paths: ["stats.CMB", "equipments.melee_1"], reason: equipments.melee_1 } });
addHtmlFloatForm(col_row, "equipments", [
{ name: "Mêlée 2", value: equipments.melee_2, row: 2, colspan: 4 },
{ name: "Dégats", value: equipments.degats_melee_2, row: 2, colspan: 2, id: "degats_melee_2" },
{ name: "Bonus", value: equipments.bonus_melee_2, row: 2, colspan: 2, id: "bonus_melee_2" },
{ name: "Armure", value: equipments.armure, row: 2, colspan: 4, sticky: false },
], { roll: { paths: ["stats.CMB", "equipments.melee_2"], reason: equipments.melee_2 } });
addHtmlFloatForm(col_row, "equipments", [
{ name: "Distance", value: equipments.distance, row: 3, colspan: 4 },
{ name: "Dégats", value: equipments.degats_distance, row: 3, colspan: 2, id: "degats_distance" },
{ name: "Bonus", value: equipments.bonus_distance, row: 3, colspan: 2, id: "bonus_distance" },
{ name: "Vêtements", value: equipments.vetements, row: 3, colspan: 4, sticky: false },
], { roll: { paths: ["stats.TIR", "equipments.distance"], reason: equipments.distance } });
}
}
class Experience {
addHtml(col) {
const col_row = createHtmlElem(col, "div", { class: "row character-experience" });
const col_stats = createHtmlElem(col_row, "div", { class: "col-md-6" });
const h2 = createHtmlElem(col_stats, "h2", {}, "Expérience");
const col_spe = createHtmlElem(col_row, "div", { class: "col-md-6" });
createHtmlElem(col_spe, "h2", {}, "Spécialitées");
this.addHtmlStats(col_stats);
this.addHtmlSpecialities(col_spe);
Experience.resetXpColors();
}
addHtmlStats(col_stats) {
const current_char = new CurrentCharacter().data();
const xp = current_char.experience;
Characteristic.STATS.forEach((stat) => {
addHtmlFloatForm(col_stats, "xp_stats", [
{ name: `${stat.name} (${stat.code})`, id: stat.code, value: xp[stat.code] || 0, type: "number", class: "character-xp", min: 0, max: 3, colspan: 3, html: 'onchange="Experience.resetXpColors()"' },
{ name: "Améliorer", id: stat.code, type: "button", class: "btn btn-primary character-xp-upgrade", html: `onclick=\"Experience.increaseStat(event, 'stats.${stat.code}')\"`, colspan: 3, size: "lg" },
]);
});
}
addHtmlSpecialities(col_spe) {
const current_char = new CurrentCharacter().data();
const xp = current_char.experience;
Object.values(current_char.specialities).forEach((spe) => {
addHtmlFloatForm(col_spe, "xp_specialities", [
{ name: spe.name, id: spe.name, value: xp[spe.level] || 0, type: "number", class: "character-xp", min: 0, max: 20, colspan: 3, html: 'onchange="Experience.resetXpColors()"' },
{ name: "Améliorer", id: spe.name, type: "button", class: "btn btn-primary character-xp-upgrade", html: `onclick=\"Experience.increaseStat(event, 'specialities.${spe.name}')\"`, colspan: 3, size: "lg" },
]);
});
}
static increaseStat(event, path) {
const current_char = new CurrentCharacter();
const character = current_char.data();
const [store, key] = path.split(".");
if (store == "stats") {
const current_value = character.stats[key];
const increase = current_value % 5 == 0 ? 3 : 2;
current_char.increaseStat(key, increase);
current_char.setExperience(key, 0);
}
else if (store == "specialities") {
const current_value = character.specialities[key];
current_char.increaseSpe(key, 1);
current_char.setExperience(key, 0);
}
const xp_used = current_char.genericGetInput(".character-stat", "xp_used");
xp_used.valueAsNumber++;
current_char.recordUpdate(null);
refreshPage();
}
static increaseExperience(event, path) {
console.log("Is this DEAD code?")
const [store, key] = stat_path.split(".");
current_char.increaseExperience(key, increase);
Experience.resetXpColors();
}
static resetXpColors() {
const current_char = new CurrentCharacter()
const character = current_char.data();
const inputs = Array.fromDom(current_char.htmlRoot().querySelectorAll(".character-experience .character-xp"));
const xp = current_char.getExperience();
xp.template = 0; // ensure we always have a 0
const high_xp = Object.values(xp).sort((a, b) => b - a)[0];
inputs.forEach((input) => {
const carac = input.getAttribute("value-id");
if (carac) {
const btn = current_char.htmlRoot().querySelector(`.character-experience .character-xp-upgrade[value-id="${carac}"]`);
if (!btn) {
console.log(input)
}
if (input.valueAsNumber == high_xp) {
btn.disabled = false;
btn.classList.remove("btn-danger");
btn.classList.remove("btn-primary");
btn.classList.add("btn-success");
} else {
btn.disabled = true;
btn.classList.remove("btn-success");
btn.classList.remove("btn-primary");
btn.classList.add("btn-danger");
}
}
});
}
}
class CharacterSheet {
constructor(api, name) {
this.api = api;
this.name = name;
}
currentCharacter() {
this.character ||= this.api.getCharacter(this.name);
return this.character;
}
/*
* Update roll modale parameters
*/
rollForm(options = {carac: "", specialities: [], equipments: [], reason: "?" }) {
const { carac, specialities, equipments, reason } = options;
const STATS = {
CMB: "Combat",
CNS: "Connaissance",
DIS: "Discretion",
END: "Endurance",
FOR: "Force",
HAB: "Habileté",
MVT: "Mouvement",
PER: "Perception",
SOC: "Sociabilité",
SUR: "Survie",
TIR: "Tir",
MAG: "Magie",
VOL: "Volonté",
};
const current_char = new CurrentCharacter().data();
const real_name = current_char.personnals.prenoms || current_char.name;
const modal = document.querySelectorAll("#roll_dice .modal-body")[0];
const specialities_list_html = Object.values(current_char.specialities).map((spe) => {
const spe_id = `roll_dice_speciality_${spe.name}`;
const spe_html = `
<div class="row">
<div class="form-check">
<input class="form-control form-check-input speciality_value" speciality_level="${spe.level}" speciality_name="${spe.name}" type="checkbox" value="" id="${spe_id}">
<label class="form-check-label" for="${spe_id}">
${spe.name} pour un bonus de ${Number(spe.level) * 5}
</label>
</div>
</div>
`;
return spe_html;
}).join("");
const equipment_html = equipments.map(equipment => `
<div class="input-group mb-3">
<div class="form-floating">
<input id="roll_dice_equipment" class="form-control equipment_value" type="number" value="${current_char.equipments["bonus_" + equipment]}" min="-200" max="200" disabled />
<label for="roll_dice_base">Arme utilisée: ${current_char.equipments[equipment]}</label>
<input id="roll_dice_equipment_degats" class="form-control equipment_degats" type="number" value="${current_char.equipments["degats_" + equipment]}" min="-200" max="200" disabled hidden />
<input id="roll_dice_equipment_name" class="form-control equipment_name" type="text" value="${current_char.equipments[equipment]}" disabled hidden />
</div>
</div>
`).join("");
const modal_body_html = `
<div class="row g-3">
<p>Personnage: <strong id="roll_dice_character_name">${real_name}</strong></p>
<p>Jet: <strong id="roll_dice_carac">${STATS[carac]} (<span id="roll_dice_carac_code">${carac}</span>)</strong>: ${reason}</p>
<p hidden id="roll_dice_reason">${reason}</p>
<div class="input-group mb-3">
<div class="form-floating">
<input id="roll_dice_base" class="form-control base_value" type="number" value="${current_char.stats[carac]}" min="-200" max="200" disabled />
<label for="roll_dice_base">Valeur de base</label>
</div>
</div>
<div class="input-group mb-3">
<div class="form-floating">
<input id="roll_dice_base" class="form-control base_value" type="number" value="${current_char.stats[carac + "_tmp"]}" min="-200" max="200" disabled />
<label for="roll_dice_base">Modificateur temporaire</label>
</div>
</div>
${equipment_html}
<div class="input-group mb-3">
<div class="form-floating">
<input id="roll_dice_base" class="form-control base_value" type="number" value="0" min="-200" max="200" />
<label for="roll_dice_base">Circonstances</label>
</div>
</div>
${specialities_list_html}
</div>
`;
modal.innerHTML = "";
renderHtml(modal, modal_body_html, { version: 3 });
}
/*
* Apply current values in the roll modal
* and run the dice.
*/
rollResult(char_name, carac_code, reason, roll_values) {
// base values
const current_char = new CurrentCharacter();
const character = current_char.data();
const carac_name = `${Characteristic.STATS_BY_CODE[carac_code]} (${carac_code})`
const target = roll_values.reduce((base, spe) => base + spe.value, 0);
const modal = document.querySelectorAll("#roll_dice .modal-body")[0];
const real_name = character.personnals.prenoms || char_name;
const equipment_name = document.getElementById("roll_dice_equipment_name")?.value;
const reasons = [reason].concat(roll_values.filter(e => e.show == true).map(e => e.text)).filter(e => e);
// roll related
const roll = Math.round(Math.random() * 100) % 100 + 1;
const is_success = roll <= target;
const result_color = !is_success ? "danger" : "success";
const result_word = !is_success ? "d'échec" : "de succès";
const grade = Math.floor(Math.abs(target - roll) / 10);
const is_fight = carac_code == "CMB" || carac_code == "TIR"
const is_doublon = roll % 11 == 0;
const is_critic = roll % 10 == 0;
const critical_bonus = is_critic && is_success ? Math.round((Math.random() * 10) % 10 + 1) : 0;
const bf_value = current_char.genericGetInput(".character-stat", "bf").valueAsNumber || 0;
const equipment_degats = document.getElementById("roll_dice_equipment_degats")?.valueAsNumber || 0;
const degats = is_fight ? roll % 10 + bf_value + equipment_degats + critical_bonus : null;
const RU = roll % 10;
// html
const degats_critical_html = is_success && is_critic ? ` + ${critical_bonus} (CRITIQUE)` : ""
const degats_html = is_fight && is_success && degats != null ? `${degats} dégâts [ ${RU} + ${bf_value} (BF) + ${equipment_degats} (${equipment_name})${degats_critical_html} ]` : "";
const doublon_html = is_fight && is_doublon ? `DOUBLON !` : "";
const reason_html = reason;
const niveau_html = `Niveau ${result_word}: ${grade}`;
const alert_html = `
<div class="row alert alert-${result_color}">
Jet de dé ${reason}: ${roll} / ${target}. ${niveau_html}<br />${degats_html} ${doublon_html}
</div>`;
renderHtml(modal, alert_html, { version: 3 });
// discord
const degats_critical_discord = is_success && is_critic ? ` + ${critical_bonus} (CRITIQUE)` : ""
const degats_discord = is_fight && is_success && degats != null ? `\n**${degats} dégâts** \`${RU} + ${bf_value} (BF) + ${equipment_degats} (${equipment_name})${degats_critical_discord}\`` : "";
const doublon_discord = is_fight && is_doublon ? `**DOUBLON** !` : "";
const reason_discord = reason;
const niveau_discord = `Niveau ${result_word}: ${grade}`;
const alert_discord = `${real_name} lance un dé ! \`${carac_name} ${reasons.join(", ")}\` : **${roll} / ${target}** : ${niveau_discord}${degats_discord}`;
new DiscordMessage(this.api).forwardMessage(alert_discord);
// more things to do
new CharacterLogs(this.api).add(char_name, carac_name, target, roll, roll_values);
if (roll < target) current_char.increaseExperience(carac_code, 1);
const specialities = roll_values.filter(i => i.speciality).map(i => i.speciality);
specialities.forEach(spe_name => current_char.increaseExperience(spe_name, 1))
Experience.resetXpColors();
}
/*
* Squeleton html of the character
*/
addHtml(character_body_content, name) {
if (!new CurrentCharacter().isSelected()) return null;
const main_row = createHtmlElem(character_body_content, "div", { class: "row gy-0 py-2" });
const main_col_1 = createHtmlElem(main_row, "div", { class: "col-lg-2 gx-5" });
const main_col_2 = createHtmlElem(main_row, "div", { class: "col-lg-2 gx-5" });
const main_col_3 = createHtmlElem(main_row, "div", { class: "col-lg-4 gx-5" });
const main_col_4 = createHtmlElem(main_row, "div", { class: "col-lg-4 gx-5" });
const secondary_row = createHtmlElem(character_body_content, "div", { class: "row gy-0 py-2 border-top" });
const secondary_col_1 = createHtmlElem(secondary_row, "div", { class: "col-lg-4 gx-5" });
const secondary_col_2 = createHtmlElem(secondary_row, "div", { class: "col-lg-4 gx-5" });
const secondary_col_3 = createHtmlElem(secondary_row, "div", { class: "col-lg-4 gx-5" });
const third_row = createHtmlElem(character_body_content, "div", { class: "row gy-0 py-2" });
const third_col_1 = createHtmlElem(third_row, "div", { class: "col-lg-6 gx-5" });
new Characteristic().addHtml(main_col_1);
new SecondaryCharacteristic().addHtml(main_col_2);
new CharacterState().addHtml(main_col_2);
new Equipment().addHtml(main_col_3);
new Speciality().addHtml(main_col_3);
new Inventory().addHtml(main_col_4);
new Personnal().addHtml(secondary_col_1);
new Portrait().addHtml(secondary_col_2);
new Notes().addHtml(secondary_col_2);
new Spell().addHtml(secondary_col_3);
new CharacterHelper().addHtml(character_body_content, name);
new Experience().addHtml(third_col_1);
return character_body_content;
}
}
class CharacterLogs {
constructor(api) { this.api = api; }
add(character_name, carac, target, roll, roll_details = {}) {
const timestamp = new Date().toISOString();
const log = {
key: `logs.${character_name}.${timestamp}`,
character_name: character_name,
carac: carac,
target: target,
roll: roll,
roll_details: roll_details,
timestamp: timestamp,
success: target <= roll,
}
this.api.addLog(log);
}
}
class CharacterButtons {
static characterDelete(event) {
if (confirm("Supprimer le personnage ?")) {
const current_character = new CurrentCharacter();
const char_list = new CharactersList(api);
const name = current_character.name();
api.removeCharacter(name);
alert(`${name} a été supprimé !`);
new CharactersList(api).addHtml(characters_list_elem);
}
}
static characterExport(event) {
const current_character = new CurrentCharacter();
download(
`${current_character.name()}.json`,
JSON.stringify(current_character.data()),
);
}
static characterImport(event) {
if (confirm("Remplacer totalement le personnage ?")) {
const current_character = new CurrentCharacter();
const file = event.target.files[0];
const reader = new FileReader();
reader.onloadend = function() {
api.updateCharacter(current_character.name(), JSON.parse(reader.result));
};
reader.readAsText(file);
}
}
}
function htmlNewCharacter(event) {
event.preventDefault();
char_list.addNew(document.getElementById("characters_list_new").value);
return null;
}
function inputValueParsed(input) {
if (input.type == "number") return Number(input.value);
else if (input.type == "checkbox") return input.checked;
else if (input.tagName == "TEXTAREA") return input.value;
else return input.value;
}
function parseField(element) {
const name = (element.attributes["value-id"]).value;
const value = inputValueParsed(element);
return { name, value };
}
function parseMultiFields(...args_elements) {
const base = {};
args_elements.forEach((elements) => {
elements.forEach((element) => {
const parsed = parseField(element);
base[parsed.name] = parsed.value;
});
});
return base;
}
function updateStoreWithFields(store, elements, field_value_modifier = undefined) {
elements.forEach((current_elem) => {
const parsed = parseField(current_elem);
store[parsed.name] = field_value_modifier ? field_value_modifier(parsed.value) : parsed.value;
});
}
class RollDice {
constructor() {}
static openForm(event, char_name, paths, reason = "?") {
const paths_by_store = Sort.groupBy(
paths.map(e => e.split(".")),
path => path[0],
);
paths_by_store.specialities ||= []
/* const carac = paths[0].split(".").slice(-1)[0]; */
const carac = paths_by_store.stats[0].slice(-1);
const specialities = paths_by_store?.specialities?.map(item => item[1]) || [];
const equipments = paths_by_store?.equipments?.map(item => item[1]) || [];
new CharacterSheet(api, char_name).rollForm({
carac: carac, specialities: specialities, equipments: equipments, reason: reason,
});
}
static execute(event) {
const current_character = new CurrentCharacter();
const carac_name = document.getElementById("roll_dice_carac").textContent;
const carac_code = document.getElementById("roll_dice_carac_code").textContent;
const roll_values = [];
document.querySelectorAll("#roll_dice .modal-body input.base_value").forEach((input) => {
roll_values.push({
value: Number(input.value),
text: input.parentNode.children[1].textContent,
show: false,
});
})
document.querySelectorAll("#roll_dice .modal-body input.speciality_value").forEach((spe) => {
if (spe.checked) {
const spe_name = spe.attributes["speciality_name"].value;
const spe_level = Number(spe.attributes["speciality_level"].value);
roll_values.push({
value: spe_level * 5,
text: `${spe_name} niveau ${spe_level}`,
show: true,
speciality: spe_name,
});
}
});
const reason = document.querySelector("#roll_dice .modal-body #roll_dice_reason").textContent || "";
const name = current_character.name();
new CharacterSheet(api, name).rollResult(name, carac_code, reason, roll_values);
}
}
function reloadLogs(event, logs_amount) {
const logs = api.getLastLogs(logs_amount);
let log_entries_html = "";
logs.reverse().forEach((log) => {
const color = log.success ? "success" : "danger";
const log_entry_html = `<div class="alert alert-${color}">${log.timestamp}: ${log.character_name} - ${log.carac}: ${log.roll} / ${log.target}</div>`;
log_entries_html += log_entry_html;
});
document.getElementById("logs-entries").innerHTML = log_entries_html;
}
class DiscordMessage {
constructor(api) { this.api = api; }
forwardMessage(message) {
const { host, password } = this.api.getDiscordInfos();
const xhttp = new XMLHttpRequest();
xhttp.onload = function() { console.log(`Discord sent: ${message}`); }
xhttp.open("POST", host, true);
xhttp.setRequestHeader("AUTH", password);
xhttp.setRequestHeader("Access-Control-Allow-Origin", "*");
xhttp.send(message);
}
recordUpdate(_event) {
const host = document.querySelector("input#discord_host").value
const password = document.querySelector("input#discord_password").value
api.addDiscordInfos(host, password);
}
refreshFormValues() {
const discord_infos = api.getDiscordInfos();
document.querySelector("input#discord_host").value = discord_infos.host;
document.querySelector("input#discord_password").value = discord_infos.password;
}
}
var api = new DataAPI(localStorage);
const char_list = new CharactersList(api);
const characters_list_elem = document.getElementById("characters_list");
function refreshPage(target_name = undefined) {
char_list.addHtml(characters_list_elem, target_name);
}
refreshPage();
document.addEventListener('click', (event) => {
if (event.target.classList.contains("accordion-button") && !event.target.classList.contains("collapsed")) {
let target_name = event.target.attributes["character-name"].value;
refreshPage(target_name);
}
});
function colorizeUsure(target) {
if (target.tagName == "SELECT" && target.attributes["value-id"].value == "usure") {
const value = target.value;
const colors = {
"1": "green",
"2": "green",
"4": "yellow",
"5": "orange",
"6": "red",
}
const color = colors[value];
if (color) target.style.color = color;
}
}
function portraitUploader(target) {
if (target.classList.contains('portraitFile')) {
const current_character = new CurrentCharacter();
const file = target.files[0];
const reader = new FileReader();
reader.onloadend = function() {
const base64 = reader.result;
try {
api.addPortrait(current_character.name(), base64);
current_character.htmlRoot().querySelector("img").src = base64;
} catch (error) {
alert("Impossible de charger le portrait, fichier trop gros ? (essayer une image de <2MB)");
}
};
reader.readAsDataURL(file);
}
}
document.onchange = (event) => {
colorizeUsure(event.target);
portraitUploader(event.target);
};
document.querySelectorAll("select[value-id=usure]").forEach((target) => colorizeUsure(target));
new DiscordMessage(api).refreshFormValues();
document.addEventListener("keypress", (event) => {
if (event.key != "Enter") return null;
const current_char = new CurrentCharacter();
if (current_char.isSelected()) {
current_char.recordUpdate(event);
}
});
document.onkeydown = (event) => {
if (event.ctrlKey && event.key === 's') {
event.preventDefault();
const current_char = new CurrentCharacter();
if (current_char.isSelected()) {
current_char.recordUpdate(event);
new DiscordMessage(api).recordUpdate(event);
}
}
};
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
const tooltipList = [...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl));
</script>
</body>
</html>