diff --git a/lang/en.json b/lang/en.json index 32e2a6b1..b1430083 100644 --- a/lang/en.json +++ b/lang/en.json @@ -41,18 +41,18 @@ "NUMENERA.pcActorSheet.tab.equipment": "Equipment", "NUMENERA.pcActorSheet.tab.bio": "Bio", - "NUMENERA.abilities.newAbility": "New Ability", - "NUMENERA.abilities.name": "Abilities", - "NUMENERA.abilities.type.action": "Action", - "NUMENERA.abilities.type.enabler": "Enabler", - "NUMENERA.abilities.tab.createTooltip": "Create", - "NUMENERA.abilities.tab.deleteTooltip": "Delete", - "NUMENERA.abilities.tab.rollTooltip": "Ability Roll", - "NUMENERA.abilities.tab.cost": "Cost", - "NUMENERA.abilities.tab.description": "Description", - "NUMENERA.abilities.tab.name": "Name", - "NUMENERA.abilities.tab.range": "Range", - "NUMENERA.abilities.tab.instructions": "No abilities yet. Create one by clicking the icon on the top right of the table or pick one from the Items Directory or a Compendium and drag it here.", + "NUMENERA.item.ability.newAbility": "New Ability", + "NUMENERA.item.ability.name": "Abilities", + "NUMENERA.item.ability.type.action": "Action", + "NUMENERA.item.ability.type.enabler": "Enabler", + "NUMENERA.item.ability.tab.createTooltip": "Create", + "NUMENERA.item.ability.tab.deleteTooltip": "Delete", + "NUMENERA.item.ability.tab.rollTooltip": "Ability Roll", + "NUMENERA.item.ability.tab.cost": "Cost", + "NUMENERA.item.ability.tab.description": "Description", + "NUMENERA.item.ability.tab.name": "Name", + "NUMENERA.item.ability.tab.range": "Range", + "NUMENERA.item.ability.tab.instructions": "No abilities yet. Create one by clicking the icon on the top right of the table or pick one from the Items Directory or a Compendium and drag it here.", "NUMENERA.pc.advances.statPools": "+4 to stat pools", "NUMENERA.pc.advances.effort": "+1 to Effort", @@ -78,7 +78,11 @@ "NUMENERA.pc.numenera.artifact.name": "Artifacts", "NUMENERA.pc.numenera.artifact.none": "No artifacts", "NUMENERA.pc.numenera.artifact.roll": "Roll", + "NUMENERA.pc.numenera.artifact.effect": "Effect", + "NUMENERA.pc.numenera.artifact.form": "Form", "NUMENERA.pc.numenera.artifact.unidentified": "Unidentified Artifact", + "NUMENERA.pc.numenera.artifact.identified": "Identified?", + "NUMENERA.pc.numenera.artifact.level": "Level", "NUMENERA.pc.numenera.cypher.deleteTooltip": "Delete", "NUMENERA.pc.numenera.cypher.effect": "Effect", @@ -165,12 +169,24 @@ "NUMENERA.npc.movement": "Movement", "NUMENERA.npc.armor": "Armor", "NUMENERA.npc.damage": "Damage", - "NUMENERA.npc.attacks.sectionName": "Attacks", "NUMENERA.npc.attacks.description": "Description", + "NUMENERA.npc.attacks.placeholder": "Attack Description", + "NUMENERA.npc.attacks.createTooltip": "Create", + "NUMENERA.npc.attacks.deleteTooltip": "Delete", + "NUMENERA.npc.attacks.none": "No attacks yet. Create one by clicking the icon on the top right of the table or pick one from the Items Directory or a Compendium and drag it here.", + "NUMENERA.npc.modifications": "Modifications", "NUMENERA.npc.description": "Description", "NUMENERA.npc.use": "Use", "NUMENERA.npc.environment": "Environment", "NUMENERA.npc.loot": "Loot", + "NUMENERA.npc.motive": "Motive", + "NUMENERA.npc.combat": "Combat", + "NUMENERA.npc.interaction": "Interaction", + "NUMENERA.npc.gmIntrusion": "GM Intrusion", + + "NUMENERA.npcActorSheet.tab.attacks": "Attacks", + "NUMENERA.npcActorSheet.tab.description": "Description", + "NUMENERA.npcActorSheet.tab.information": "Information", "NUMENERA.item.ability.isAction": "This ability is an Action", "NUMENERA.item.ability.isEnabler": "This ability is an Enabler", diff --git a/lang/fr.json b/lang/fr.json index 9e879fa6..c45e5945 100644 --- a/lang/fr.json +++ b/lang/fr.json @@ -41,18 +41,18 @@ "NUMENERA.pcActorSheet.tab.equipment": "Équipement", "NUMENERA.pcActorSheet.tab.bio": "Bio", - "NUMENERA.abilities.newAbility": "Nouvelle Aptitude", - "NUMENERA.abilities.name": "Aptitudes", - "NUMENERA.abilities.type.action": "Action", - "NUMENERA.abilities.type.enabler": "Catalyseur", - "NUMENERA.abilities.tab.createTooltip": "Créer", - "NUMENERA.abilities.tab.deleteTooltip": "Supprimer", - "NUMENERA.abilities.tab.rollTooltip": "Jet d'aptitude", - "NUMENERA.abilities.tab.cost": "Coût", - "NUMENERA.abilities.tab.description": "Description", - "NUMENERA.abilities.tab.name": "Nom", - "NUMENERA.abilities.tab.range": "Portée", - "NUMENERA.abilities.tab.instructions": "Pas d'aptitudes. Créez-en une en cliquant sur l'icône en haut à droite du tableau ou sélectionnez-en une du ITEMS DIRECTORY ou d'un Compendium et DRAGGEZ-la jusque ici.", + "NUMENERA.item.ability.newAbility": "Nouvelle Aptitude", + "NUMENERA.item.ability.name": "Aptitudes", + "NUMENERA.item.ability.type.action": "Action", + "NUMENERA.item.ability.type.enabler": "Catalyseur", + "NUMENERA.item.ability.tab.createTooltip": "Créer", + "NUMENERA.item.ability.tab.deleteTooltip": "Supprimer", + "NUMENERA.item.ability.tab.rollTooltip": "Jet d'aptitude", + "NUMENERA.item.ability.tab.cost": "Coût", + "NUMENERA.item.ability.tab.description": "Description", + "NUMENERA.item.ability.tab.name": "Nom", + "NUMENERA.item.ability.tab.range": "Portée", + "NUMENERA.item.ability.tab.instructions": "Pas d'aptitudes. Créez-en une en cliquant sur l'icône en haut à droite du tableau ou sélectionnez-en une du ITEMS DIRECTORY ou d'un Compendium et DRAGGEZ-la jusque ici.", "NUMENERA.pc.advances.statPools": "+4 aux Réserves", "NUMENERA.pc.advances.effort": "+1 en Effort", @@ -78,7 +78,11 @@ "NUMENERA.pc.numenera.artifact.name": "Artefacts", "NUMENERA.pc.numenera.artifact.none": "Pas d'artefacts", "NUMENERA.pc.numenera.artifact.roll": "Jet", + "NUMENERA.pc.numenera.artifact.effect": "Effet", + "NUMENERA.pc.numenera.artifact.form": "Forme", "NUMENERA.pc.numenera.artifact.unidentified": "Artefact non identifié", + "NUMENERA.pc.numenera.artifact.identified": "Identifié ?", + "NUMENERA.pc.numenera.artifact.level": "Niveau", "NUMENERA.pc.numenera.cypher.deleteTooltip": "Supprimer", "NUMENERA.pc.numenera.cypher.effect": "Effet", @@ -165,12 +169,24 @@ "NUMENERA.npc.movement": "Mouvement", "NUMENERA.npc.armor": "Armure", "NUMENERA.npc.damage": "Dommages", - "NUMENERA.npc.attacks.sectionName": "Attaques", "NUMENERA.npc.attacks.description": "Description", + "NUMENERA.npc.attacks.placeholder": "Description de l'attaque", + "NUMENERA.npc.attacks.createTooltip": "Créer", + "NUMENERA.npc.attacks.deleteTooltip": "Supprimer", + "NUMENERA.npc.attacks.none": "Pas d'attaques. Créez-en une en cliquant sur l'icône en haut à droite du tableau ou sélectionnez-en une du ITEMS DIRECTORY ou d'un Compendium et DRAGGEZ-la jusque ici.", + "NUMENERA.npc.modifications": "Modifications", "NUMENERA.npc.description": "Description", "NUMENERA.npc.use": "Utilisation", "NUMENERA.npc.environment": "Environnement", "NUMENERA.npc.loot": "Butin", + "NUMENERA.npc.motive": "Motivations", + "NUMENERA.npc.combat": "Combat", + "NUMENERA.npc.interaction": "Interaction", + "NUMENERA.npc.gmIntrusion": "Intrusion du MJ", + + "NUMENERA.npcActorSheet.tab.attacks": "Attaques", + "NUMENERA.npcActorSheet.tab.description": "Description", + "NUMENERA.npcActorSheet.tab.information": "Informations", "NUMENERA.item.ability.isAction": "Cette aptitude est une Action", "NUMENERA.item.ability.isEnabler": "Cette aptitude est un ENABLER", diff --git a/module/actor/NumeneraNPCActor.js b/module/actor/NumeneraNPCActor.js index 769ad24a..dd5f6a42 100644 --- a/module/actor/NumeneraNPCActor.js +++ b/module/actor/NumeneraNPCActor.js @@ -1,4 +1,19 @@ export class NumeneraNPCActor extends Actor { + static defaultInfo() { + return `

${game.i18n.localize("NUMENERA.npc.motive")}

...

+

${game.i18n.localize("NUMENERA.npc.combat")}

...

+

${game.i18n.localize("NUMENERA.npc.interaction")}

...

+

${game.i18n.localize("NUMENERA.npc.use")}

...

+

${game.i18n.localize("NUMENERA.npc.loot")}

...

+

${game.i18n.localize("NUMENERA.npc.gmIntrusion")}

...

`; + } + + constructor(...args) { + super(...args); + + this.data.data.info = this.data.data.info || NumeneraNPCActor.defaultInfo(); + } + getInitiativeFormula() { /* TODO: improve this The init system expects a formula for initiative: fixed values don't seem to work. diff --git a/module/actor/NumeneraPCActor.js b/module/actor/NumeneraPCActor.js index fa3a0cf5..a01a3a2f 100644 --- a/module/actor/NumeneraPCActor.js +++ b/module/actor/NumeneraPCActor.js @@ -1,4 +1,4 @@ -import { numeneraRoll } from "../roll.js"; +import { numeneraRoll, numeneraRollFormula } from "../roll.js"; const effortObject = { cost: 0, @@ -19,21 +19,11 @@ export class NumeneraPCActor extends Actor { } getInitiativeFormula() { - //TODO: use numeneraRoll() here instead of duplicating roll logic - //Check for an initiative skill - const initSkill = this.items.find(i => i.type === "skill" && i.name.toLowerCase() === "Initiative") - let initSkillLevel = 0; - if (initSkill) - initSkillLevel = 3 * this.getSkillLevel(initSkill); - - //TODO possible assets, effort on init roll - let formula = "1d20" - if (initSkill !== 0) { - formula += `+${initSkillLevel}`; - } + const initSkill = this.items.find(i => i.type === "skill" && i.name.toLowerCase() === "initiative") - return formula; + //TODO possible assets, effort on init roll + return this.getSkillFormula(initSkill); } get effort() { @@ -56,6 +46,19 @@ export class NumeneraPCActor extends Actor { }).length; } + getSkillFormula(skill) { + if (!skill) + return; + + const skillLevel = this.getSkillLevel(skill); + return numeneraRollFormula(skillLevel); + } + + rollSkillById(skillId) { + const skill = this.getOwnedItem(skillId); + return this.rollSkill(skill); + } + /** * Given a skill ID, fetch the skill level bonus and roll a d20, adding the skill * bonus. @@ -64,8 +67,8 @@ export class NumeneraPCActor extends Actor { * @returns * @memberof NumeneraPCActor */ - rollSkill(skillId) { - if (!skillId) + rollSkill(skill) { + if (!skill) return; switch (this.data.data.damageTrack) { @@ -78,10 +81,7 @@ export class NumeneraPCActor extends Actor { return; } - const skill = this.getOwnedItem(skillId); - const skillLevel = this.getSkillLevel(skill); - - const roll = numeneraRoll(skillLevel); + const roll = new Roll(this.getSkillFormula(skill)).roll(); roll.toMessage({ speaker: ChatMessage.getSpeaker({ actor: this.actor }), diff --git a/module/actor/sheets/NumeneraNPCActorSheet.js b/module/actor/sheets/NumeneraNPCActorSheet.js index 13a763a7..b9892374 100644 --- a/module/actor/sheets/NumeneraNPCActorSheet.js +++ b/module/actor/sheets/NumeneraNPCActorSheet.js @@ -1,4 +1,104 @@ +import { confirmDeletion } from "../../apps/ConfirmationDialog.js"; import { NUMENERA } from "../../config.js"; +import { NumeneraNpcAttackItem } from "../../item/NumeneraNPCAttack.js"; + +//TODO copied from PCSheet, should be in a separate, shared file. + + +//Sort function for order +const sortFunction = (a, b) => a.data.order < b.data.order ? -1 : a.data.order > b.data.order ? 1 : 0; + +/** + * Higher order function that generates an item creation handler. + * + * @param {String} itemType The type of the Item (eg. 'ability', 'cypher', etc.) + * @param {*} itemClass + * @param {*} [callback=null] + * @returns + */ +function onItemCreateGenerator(itemType, itemClass, callback = null) { + return async function(event = null) { + if (event) + event.preventDefault(); + + const newName = game.i18n.localize(`NUMENERA.item.${itemType}.new${itemType.capitalize()}`); + + const itemData = { + name: newName, + type: itemType, + data: new itemClass({}), + }; + + const newItem = await this.actor.createOwnedItem(itemData); + if (callback) + callback(newItem); + + return newItem; + } +} + +function onItemEditGenerator(editClass, callback = null) { + return async function (event) { + event.preventDefault(); + event.stopPropagation(); //Important! otherwise we get double rendering + + const elem = event.currentTarget.closest(editClass); + + if (!elem) + throw new Error(`Missing ${editClass} class element`); + else if (!elem.dataset.itemId) + throw new Error(`No itemID on ${editClass} element`); + + const updated = {_id: elem.dataset.itemId}; + + const splitName = event.currentTarget.name.split("."); + const idIndex = splitName.indexOf(updated._id); + const parts = splitName.splice(idIndex + 1); + + //Add the newly added property to the object + //This next block is necessary to support properties at various depths + //e.g support actor.name as well as actor.data.cost.pool + + let previous = updated; + for (let i = 0; i < parts.length; i++) { + const name = parts[i]; + + if (i === parts.length - 1) { + //Last part, the actual property + if (event.target.type === "checkbox") { + previous[name] = event.currentTarget.checked; + } else if (event.target.dataset.dtype === "Boolean") { + previous[name] = (event.currentTarget.value === "true"); + } else { + previous[name] = event.currentTarget.value; + } + } else { + previous[name] = {}; + previous = previous[name]; + } + } + + const updatedItem = await this.actor.updateEmbeddedEntity("OwnedItem", updated); + if (callback) + callback(updatedItem); + } +} + +function onItemDeleteGenerator(deleteType, callback = null) { + return async function (event) { + event.preventDefault(); + + if (await confirmDeletion(deleteType)) { + const elem = event.currentTarget.closest("." + deleteType); + const itemId = elem.dataset.itemId; + const toDelete = this.actor.data.items.find(i => i._id === itemId); + this.actor.deleteOwnedItem(itemId); + + if (callback) + callback(toDelete); + } + } +} /** * Extend the basic ActorSheet class to do all the Numenera things! @@ -12,11 +112,25 @@ export class NumeneraNPCActorSheet extends ActorSheet { */ static get defaultOptions() { return mergeObject(super.defaultOptions, { - width: 750, - height: 700, + width: 850, + height: 650, + tabs: [ + { + navSelector: ".tabs", + contentSelector: "#npc-sheet-body", + }, + ], }); } + constructor(...args) { + super(...args); + + this.onAttackCreate = onItemCreateGenerator("npcAttack", NumeneraNpcAttackItem); + this.onAttackEdit = onItemEditGenerator(".npcAttack"); + this.onAttackDelete = onItemDeleteGenerator("npcAttack"); + } + /** * Get the correct HTML template path to use for rendering this particular sheet * @type {String} @@ -33,6 +147,12 @@ export class NumeneraNPCActorSheet extends ActorSheet { sheetData.ranges = NUMENERA.ranges.map(r => game.i18n.localize(r)); + sheetData.data.items = sheetData.actor.items || {}; + + const items = sheetData.data.items; + if (!sheetData.data.items.attacks) + sheetData.data.items.attacks = items.filter(i => i.type === "npcAttack").sort(sortFunction); + return sheetData; } @@ -45,135 +165,25 @@ export class NumeneraNPCActorSheet extends ActorSheet { activateListeners(html) { super.activateListeners(html); - html - .find("table.attacks") - .on("click", ".attack-control", this.onAttackControl.bind(this)); + const attacksTable = html.find("table.attacks"); + attacksTable.on("click", ".attack-create", this.onAttackCreate.bind(this)); + attacksTable.on("click", ".attack-delete", this.onAttackDelete.bind(this)); + attacksTable.on("change", "input", this.onAttackEdit.bind(this)); } /** - * Handles the click event on add/delete attack controls. - * - * @param {*} event - * @memberof NumeneraNPCActorSheet + * @override */ - async onAttackControl(event) { - event.preventDefault(); - - const a = event.currentTarget; - const action = a.dataset.action; - - switch (action) { - case "create": - const table = a.closest("table"); - const template = table.getElementsByTagName("template")[0]; - const body = table.getElementsByTagName("tbody")[0]; - - if (!template) - throw new Error(`No row template found in attacks table`); - - //Let's keep things simple here: get the largest existing id and add one - const id = - Math.max( - ...[...body.children].map((c) => c.children[0].children[0].value || 0) - ) + 1 + ""; - - const newRow = template.content.cloneNode(true); - body.appendChild(newRow); - - //That "newRow"? A DocumentFragment. AN IMPOSTOR. - const actualRow = body.children[body.children.length - 1]; - actualRow.children[0].children[0].name = `data.attacks.${id}.id`; - actualRow.children[0].children[0].value = id; - actualRow.children[0].children[1].name = `data.attacks.${id}.description`; - - await this._onSubmit(event); - break; - - case "delete": - const row = a.closest(".attack"); - row.parentElement.removeChild(row); - - await this._onSubmit(event); - break; - - default: - throw new Error("Unhandled case in onAttackControl"); - } - } - - /** - * Implement the _updateObject method as required by the parent class spec - * This defines how to update the subject of the form when the form is submitted - * - * Mostly handles the funky behavior of dynamic tables inside the form. - * - * @private - */ - async _updateObject(event, formData) { - //TODO this is repeated in NumeneraPCActorSheet, try to abstract all of this a bit plz - const fd = expandObject(formData); - - const formAttacks = fd.data.attacks || {}; - - //*************************** - //DISGUSTING WORKAROUND ALERT - //*************************** - - //TODO FIX THIS SHIT - //For some extra-weird reason, I get NaN sometimes as an ID, so just swap it around - let nAnPatch = 1000; - - for (let at of Object.values(formAttacks)) { - if (typeof at.id !== "string") { - console.warn("Oops! Weird NaN problem here, buddy"); - - //Avoid collisions, in case this is not the first time this happens - while (Object.values(formAttacks).some(at => at.id == nAnPatch)) - ++nAnPatch; - - at.id = nAnPatch.toString(); - ++nAnPatch; + _onDeleteEmbeddedEntity(...args) { + /* Necessary because, after deleting an item, Foundry fetches the Item's sheet + class and, well, NPC attacks don't have one. Intercept the exception and, in that + particular case, ignore it */ + try { + super._onDeleteEmbeddedEntity(...args); + } catch (e) { + if (!e.message.includes("No valid Item sheet found for type npcAttack")) { + throw e; } } - - //******************************* - //END DISGUSTING WORKAROUND ALERT - //******************************* - - const formDataReduceFunction = function (obj, v) { - if (v.hasOwnProperty("id")) { - const id = v["id"].trim(); - if (id) obj[id] = v; - } - - return obj; - }; - - const attacks = Object.values(formAttacks).reduce(formDataReduceFunction, {}); - - // Remove attacks which are no longer used - for (let at of Object.keys(this.object.data.data.attacks)) { - if (at && !attacks.hasOwnProperty(at)) attacks[`-=${at}`] = null; - } - - // Re-combine formData - formData = Object.entries(formData) - .filter((e) => !e[0].startsWith("data.attacks")) - .reduce( - (obj, e) => { - obj[e[0]] = e[1]; - return obj; - }, - { - _id: this.object._id, - "data.attacks": attacks, - } - ); - - // Update the Actor - await this.object.update(formData); - - //In case the NPC level changed, re-render the ActorDirectory - ui.actors.render(); } } diff --git a/module/actor/sheets/NumeneraPCActorSheet.js b/module/actor/sheets/NumeneraPCActorSheet.js index c17ce54f..938c5fbe 100644 --- a/module/actor/sheets/NumeneraPCActorSheet.js +++ b/module/actor/sheets/NumeneraPCActorSheet.js @@ -32,7 +32,7 @@ function onItemCreate(itemType, itemClass, callback = null) { if (event) event.preventDefault(); - const newName = game.i18n.localize(`NUMENERA.item.${itemType}s.new${itemType.capitalize()}`); + const newName = game.i18n.localize(`NUMENERA.item.${itemType}.new${itemType.capitalize()}`); const itemData = { name: newName, @@ -359,7 +359,6 @@ export class NumeneraPCActorSheet extends ActorSheet { super.activateListeners(html); const abilitiesTable = html.find("table.abilities"); - abilitiesTable.find("*").off("change"); //TODO remove this brutal thing when transition to 0.5.6+ is done abilitiesTable.on("click", ".ability-create", this.onAbilityCreate.bind(this)); abilitiesTable.on("click", ".ability-delete", this.onAbilityDelete.bind(this)); abilitiesTable.on("blur", "input,select,textarea", this.onAbilityEdit.bind(this)); @@ -439,7 +438,7 @@ export class NumeneraPCActorSheet extends ActorSheet { event.preventDefault(); const skillId = event.target.closest(".skill").dataset.itemId; - return this.actor.rollSkill(skillId); + return this.actor.rollSkillById(skillId); } onAbilityUse(event) { @@ -456,7 +455,7 @@ export class NumeneraPCActorSheet extends ActorSheet { return; } - return this.actor.rollSkill(skill._id); + return this.actor.rollSkillById(skill._id); } onArtifactDepletionRoll(event) { diff --git a/module/config.js b/module/config.js index c46a3b2a..fe91c023 100644 --- a/module/config.js +++ b/module/config.js @@ -122,8 +122,8 @@ NUMENERA.ranges = [ NUMENERA.optionalRanges = ["N/A"].concat(NUMENERA.ranges); NUMENERA.abilityTypes = [ - "NUMENERA.abilities.type.action", - "NUMENERA.abilities.type.enabler", + "NUMENERA.item.ability.type.action", + "NUMENERA.item.ability.type.enabler", ]; NUMENERA.cypherTypes = [ diff --git a/module/hooks.js b/module/hooks.js new file mode 100644 index 00000000..ef4eb2cd --- /dev/null +++ b/module/hooks.js @@ -0,0 +1,122 @@ +import { rollText } from './roll.js'; + + +/** + * This function is simply meant to be the place where all hooks are registered. + * + * @export + */ +export function registerHooks() { + Hooks.on("ready", () => ui.notifications.info( + `Numenera and its logo are trademarks of Monte Cook Games, LLC in the U.S.A. and other countries. + All Monte Cook Games characters and character names, and the distinctive likenesses thereof, + are trademarks of Monte Cook Games, LLC. Content derived from Monte Cook Games publications is + © 2013-2019 Monte Cook Games, LLC.`) + ); + + /* + Display an NPC's difficulty between parentheses in the Actors list + */ + Hooks.on('renderActorDirectory', (app, html, options) => { + const found = html.find(".entity-name"); + + app.entities + .filter(actor => actor.data.type === 'npc') + .forEach(actor => { + found.filter((i, elem) => elem.innerText === actor.data.name) + .each((i, elem) => elem.innerText += ` (${actor.data.data.level * 3})`); + }) + }); + + Hooks.on('renderCompendium', async (app, html, options) => { + const npcs = game.actors.entities.filter(e => e.constructor === NumeneraNPCActor); + + html.find(".entry-name") + .each((i, el) => { + const actor = npcs.find(npc => el.innerText.indexOf(npc.data.name) !== -1); + if (!actor) + return; + + //Display the NPC's target between parentheses + el.innerHTML += ` (${actor.data.data.level * 3})`; + }); + + }); + + Hooks.on("renderChatMessage", (app, html, data) => { + if (!data.message.roll) + return; + + const roll = JSON.parse(data.message.roll); + + //Don't apply ChatMessage enhancement to recovery rolls + if (roll && roll.dice[0].faces === 20) + { + const special = rollText(roll.dice[0].rolls[0].roll); + const dt = html.find("h4.dice-total")[0]; + + //"special" refers to special attributes: minor/major effect or GM intrusion text, special background, etc. + if (special) { + const { text, color } = special; + const newContent = `${text}`; + + $(newContent).insertBefore(dt); + } + + if (game.settings.get("numenera", "d20Rolling") === "taskLevels") { + const rolled = roll.dice[0].rolls[0].roll; + const taskLevel = Math.floor(rolled / 3); + const skillLevel = (roll.total - rolled) / 3; + const sum = taskLevel + skillLevel; + + let text = `${game.i18n.localize("NUMENERA.successLevel")} ${sum}`; + + if (skillLevel !== 0) { + const sign = sum > 0 ? "+" : ""; + text += ` (${taskLevel}${sign}${skillLevel})`; + } + + dt.textContent = text; + } + + } + }); + + /** + * Add additional system-specific sidebar directory context menu options for D&D5e Actor entities + * @param {jQuery} html The sidebar HTML + * @param {Array} entryOptions The default array of context menu options + */ + Hooks.on("getActorDirectoryEntryContext", (html, entryOptions) => { + entryOptions.push({ + name: game.i18n.localize("NUMENERA.gmIntrusion"), + icon: '', + callback: li => { + const actor = game.actors.get(li.data("entityId")); + const ownerIds = Object.entries(actor.data.permission) + .filter(entry => { + const [id, permissionLevel] = entry; + return permissionLevel >= ENTITY_PERMISSIONS.OWNER + && id !== game.user.id + }) + .map(usersPermissions => usersPermissions[0]); + + game.socket.emit("system.numenera", {type: "gmIntrusion", data: { + userIds: ownerIds, + actorId: actor.data._id, + }}); + + ChatMessage.create({ + content: `

${game.i18n.localize("NUMENERA.gmIntrusion")}


${game.i18n.localize("NUMENERA.gmIntrusionText")} ${actor.data.name}`, + }); + }, + condition: li => { + if (!game.user.isGM) + return false; + + const actor = game.actors.get(li.data("entityId")); + return actor && actor.data.type === "pc"; + } + }); + }); +} \ No newline at end of file diff --git a/module/item/NumeneraAbilityItem.js b/module/item/NumeneraAbilityItem.js index 8ee1c5a9..ac6af4f8 100644 --- a/module/item/NumeneraAbilityItem.js +++ b/module/item/NumeneraAbilityItem.js @@ -11,7 +11,7 @@ export class NumeneraAbilityItem extends Item { const itemData = this.data.data || {}; - itemData.name = this.data ? this.data.name : game.i18n.localize("NUMENERA.abilities.newAbility"); + itemData.name = this.data ? this.data.name : game.i18n.localize("NUMENERA.item.ability.newAbility"); itemData.category = itemData.category || ""; itemData.categoryValue = itemData.categoryValue || ""; itemData.isAction = itemData.isAction || false; diff --git a/module/item/NumeneraItem.js b/module/item/NumeneraItem.js index 143c1408..92828807 100644 --- a/module/item/NumeneraItem.js +++ b/module/item/NumeneraItem.js @@ -4,6 +4,7 @@ import { NumeneraArmorItem } from "./NumeneraArmorItem.js"; import { NumeneraCypherItem } from "./NumeneraCypherItem.js"; import { NumeneraEquipmentItem } from "./NumeneraEquipmentItem.js"; import { NumeneraOddityItem } from "./NumeneraOddityItem.js"; +import { NumeneraNpcAttackItem } from "./NumeneraNPCAttack.js"; import { NumeneraSkillItem } from "./NumeneraSkillItem.js"; import { NumeneraWeaponItem } from "./NumeneraWeaponItem.js"; @@ -36,6 +37,8 @@ export const NumeneraItem = new Proxy(function () {}, { return new NumeneraCypherItem(...args); case "equipment": return new NumeneraEquipmentItem(...args); + case "npcAttack": + return new NumeneraNpcAttackItem(...args); case "oddity": return new NumeneraOddityItem(...args); case "skill": @@ -61,6 +64,8 @@ export const NumeneraItem = new Proxy(function () {}, { return NumeneraCypherItem.create(data, options); case "equipment": return NumeneraEquipmentItem.create(data, options); + case "npcAttack": + return NumeneraNpcAttackItem.create(data, options); case "oddity": return NumeneraOddityItem.create(data, options); case "skill": @@ -80,6 +85,7 @@ export const NumeneraItem = new Proxy(function () {}, { instance instanceof NumeneraCypherItem || instance instanceof NumeneraEquipmentItem || instance instanceof NumeneraOddityItem || + instance instanceof NumeneraNpcAttackItem || instance instanceof NumeneraSkillItem || instance instanceof NumeneraWeaponItem ); diff --git a/module/item/NumeneraNPCAttack.js b/module/item/NumeneraNPCAttack.js new file mode 100644 index 00000000..0460decc --- /dev/null +++ b/module/item/NumeneraNPCAttack.js @@ -0,0 +1,14 @@ +export class NumeneraNpcAttackItem extends Item { + get type() { + return "npcattack"; + } + + prepareData() { + super.prepareData(); + + const itemData = this.data.data || {}; + + itemData.notes = itemData.notes || ""; + itemData.info = itemData.info || ""; + } +} \ No newline at end of file diff --git a/module/migrations/NPCActorMigrations.js b/module/migrations/NPCActorMigrations.js new file mode 100644 index 00000000..707285f2 --- /dev/null +++ b/module/migrations/NPCActorMigrations.js @@ -0,0 +1,56 @@ +import { Migrator } from "./Migrator.js"; +import { NumeneraItem } from "../item/NumeneraItem.js"; + +//Keep migrators in order: v1 to v2, v2 to v3, etc. +const NPCActorv1ToV2Migrator = Object.create(Migrator); + +NPCActorv1ToV2Migrator.forVersion = 2; +NPCActorv1ToV2Migrator.forType = "npc"; + +/* Summary of changes: +* - NPC attacks are now items, which will make them easier to manage internally +* - the various properties related to NPC descritions (use, loot, etc) have + been moved to a single "info" property +*/ +NPCActorv1ToV2Migrator.migrationFunction = async function(actor, obj = {}) { + const newData = Object.assign({ _id: actor._id}, obj); + + //Convert attack POJOs into Items + const attacks = Object.values(actor.data.data.attacks); + for (let attack of attacks) { + await actor.createOwnedItem({ + type: "npcAttack", + data: { + notes: attack.description, + } + }); + } + + newData["data.-=attacks"] = null; + + let newInfo = ""; + if (actor.data.data.use) + newInfo += `

Use

\n${actor.data.data.use}\n`; + + if (actor.data.data.interaction) + newInfo += `

Interaction

\n${actor.data.data.interaction}\n`; + + if (actor.data.data.loot) + newInfo += `

Loot

\n${actor.data.data.loot}\n`; + + newData["data.info"] = newInfo; + newData["data.-=combat"] = null; + newData["data.-=environment"] = null; + newData["data.-=motive"] = null; + newData["data.-=gmIntrusion"] = null; + newData["data.-=interaction"] = null; + newData["data.-=use"] = null; + newData["data.-=loot"] = null; + + newData["data.version"] = this.forVersion; + + return newData; +}; + +//Only export the latest migrator +export const NPCActorMigrator = NPCActorv1ToV2Migrator; diff --git a/module/migrations/migrate.js b/module/migrations/migrate.js index 66bf0176..f43f0418 100644 --- a/module/migrations/migrate.js +++ b/module/migrations/migrate.js @@ -1,3 +1,4 @@ +import { NPCActorMigrator } from "./NPCActorMigrations.js"; import { PCActorMigrator } from "./PCActorMigrations.js"; import { ItemMigrator } from "./ItemMigrations.js"; @@ -9,7 +10,7 @@ export async function migrateWorld() { return; const currentPCActorVersion = PCActorMigrator.forVersion; - const currentNPCActorVersion = 1; //NPCActorMigrator.forVersion; + const currentNPCActorVersion = NPCActorMigrator.forVersion; const currentItemVersion = Item.forVersion; let pcActors = game.actors.entities.filter(actor => actor.data.type === 'pc' && actor.data.data.version < currentPCActorVersion); @@ -19,6 +20,7 @@ export async function migrateWorld() { if (pcActors && pcActors.length > 0 || npcActors && npcActors.length > 0 || items && items.length > 0) { ui.notifications.info(`Applying Numenera system migrations. Please be patient and do not close your game or shut down your server.`, {permanent: true}); + //TODO these 3 migration blocks are exactly the same, refactor plz try { if (pcActors && pcActors.length > 0) { const updatedPcData = await Promise.all(pcActors.map(async actor => await PCActorMigrator.migrate(actor))); @@ -33,21 +35,20 @@ export async function migrateWorld() { console.error("Error in PC migrations", e); } - //No NPC migrations yet - // try { - // if (npcActors) - // npcActors = await Promise.all(pcActors.map(async actor => await NPCActorMigrator.migrate(actor))); + try { + if (npcActors && npcActors.length > 0) { + const updatedNpcData = await Promise.all(npcActors.map(async actor => await NPCActorMigrator.migrate(actor))); - // for (const npcActor of npcActors) { - // await NumeneraNPCActor.update(npcActor.data); - // } + for (let i = 0; i < npcActors.length; i++) { + await npcActors[i].update(updatedNpcData[i]); + } - // console.log("NPC Actor migration succeeded!"); - // } catch (e) {console.l - // console.error("Error in NPC migrations", e); - // } + console.log("NPC Actor migration succeeded!"); + } + } catch (e) { + console.error("Error in NPC migrations", e); + } - //No separate Item migrations yet try { if (items && items.length > 0) { const updatedItems = await Promise.all(items.map(async item => await ItemMigrator.migrate(item))); diff --git a/module/roll.js b/module/roll.js index 611aed9d..fcab2d3c 100644 --- a/module/roll.js +++ b/module/roll.js @@ -2,11 +2,15 @@ Rolls a d20 and then determines your success level. */ -export function numeneraRoll(level = 0) { +export function numeneraRollFormula(level = 0) { let formula = "d20"; - if (level) formula += "+" + 3 * level; + if (level > 0) formula += "+" + 3 * level; + + return formula; +} - return new Roll(formula).roll(); +export function numeneraRoll(level = 0) { + return new Roll(numeneraRollFormula(level)); } export function rollText(dieRoll) { diff --git a/module/settings.js b/module/settings.js index 1e4f6059..e70037bb 100644 --- a/module/settings.js +++ b/module/settings.js @@ -79,4 +79,13 @@ export const registerSystemSettings = function() { type: Boolean, default: true }); + + game.settings.register("numenera", "trademarkNotice", { + name: "Trademark Notice", + hint: "The Monte Cook Games logo, Numenera, and the Numenera logo are trademarks of Monte Cook Games, LLC in the U.S.A. and other countries. All Monte Cook Games characters and character names, and the distinctive likenesses thereof, are trademarks of Monte Cook Games, LLC. Content on this site or associated files derived from Monte Cook Games publications is © 2013-2019 Monte Cook Games, LLC. Monte Cook Games permits web sites and similar fan-created publications for their games, subject to the policy given at http://www.montecookgames.com/fan-use-policy/. The contents of this site are for personal, non-commercial use only. Monte Cook Games is not responsible for this site or any of the content, that did not originate directly from Monte Cook Games, on or in it. Use of Monte Cook Games’s trademarks and copyrighted materials anywhere on this site and its associated files should not be construed as a challenge to those trademarks or copyrights. Materials on this site may not be reproduced or distributed except with the permission of the site owner and in compliance with Monte Cook Games policy given at http://www.montecookgames.com/fan-use-policy/.", + scope: "world", + config: true, + type: null, + default: null + }); } diff --git a/module/socket.js b/module/socket.js index ab8265e4..48949f6d 100644 --- a/module/socket.js +++ b/module/socket.js @@ -13,7 +13,6 @@ function handleGMIntrusion(args) { return; //TODO disable or don't show Refuse button if PC has 0 XP - //TODO display message for everyone about 1) intrusion and 2) choice const actor = game.actors.entities.find(a => a.data._id === actorId); const dialog = new GMIntrusionDialog(actor); dialog.render(true); diff --git a/numenera.js b/numenera.js index d9551dbb..0cfa02e1 100644 --- a/numenera.js +++ b/numenera.js @@ -13,10 +13,8 @@ import { NumeneraOddityItemSheet } from './module/item/sheets/NumeneraOddityItem import { NumeneraSkillItemSheet } from './module/item/sheets/NumeneraSkillItemSheet.js'; import { NumeneraWeaponItemSheet } from './module/item/sheets/NumeneraWeaponItemSheet.js'; - import { NUMENERA } from './module/config.js'; import { getInitiativeFormula, rollInitiative } from './module/combat.js'; -import { rollText } from './module/roll.js'; import { preloadHandlebarsTemplates } from './module/templates.js'; import { registerSystemSettings } from './module/settings.js'; import { migrateWorld } from './module/migrations/migrate.js'; @@ -24,6 +22,7 @@ import { numeneraSocketListeners } from './module/socket.js'; import { RecoveryDialog } from './module/apps/RecoveryDialog.js'; import { registerHandlebarHelpers } from './module/handlebarHelpers.js'; import { add3rdBarToPCTokens, cypherToken } from './module/token.js'; +import { registerHooks } from './module/hooks.js'; Hooks.once("init", function() { console.log('Numenera | Initializing Numenera System'); @@ -59,125 +58,20 @@ Hooks.once("init", function() { Items.registerSheet("numenera", NumeneraOddityItemSheet, { types: ["oddity"], makeDefault: true }); Items.registerSheet("numenera", NumeneraSkillItemSheet, { types: ["skill"], makeDefault: true }); Items.registerSheet("numenera", NumeneraWeaponItemSheet, { types: ["weapon"], makeDefault: true }); + + //May seem weird but otherwise + Items.registerSheet("numenera", ActorSheet, { types: ["npcAttack"], makeDefault: true }); registerSystemSettings(); registerHandlebarHelpers(); preloadHandlebarsTemplates(); }); +//Place asy clean, well-behaved hook here Hooks.once("init", cypherToken); Hooks.once("ready", add3rdBarToPCTokens); - -//TODO cleanup the functions here, it's gonna get messy real quick - -/* -Display an NPC's difficulty between parentheses in the Actors list -*/ -Hooks.on('renderActorDirectory', (app, html, options) => { - const found = html.find(".entity-name"); - - app.entities - .filter(actor => actor.data.type === 'npc') - .forEach(actor => { - found.filter((i, elem) => elem.innerText === actor.data.name) - .each((i, elem) => elem.innerText += ` (${actor.data.data.level * 3})`); - }) -}); - -Hooks.on('renderCompendium', async (app, html, options) => { - const npcs = game.actors.entities.filter(e => e.constructor === NumeneraNPCActor); - - html.find(".entry-name") - .each((i, el) => { - const actor = npcs.find(npc => el.innerText.indexOf(npc.data.name) !== -1); - if (!actor) - return; - - //Display the NPC's target between parentheses - el.innerHTML += ` (${actor.data.data.level * 3})`; - }); - -}); - -Hooks.on("renderChatMessage", (app, html, data) => { - if (!data.message.roll) - return; - - const roll = JSON.parse(data.message.roll); - - //Don't apply ChatMessage enhancement to recovery rolls - if (roll && roll.dice[0].faces === 20) - { - const special = rollText(roll.total); - const dt = html.find("h4.dice-total")[0]; - - //"special" refers to special attributes: minor/major effect or GM intrusion text, special background, etc. - if (special) { - const { text, color } = special; - const newContent = `${text}`; - - $(newContent).insertBefore(dt); - } - - if (game.settings.get("numenera", "d20Rolling") === "taskLevels") { - const rolled = roll.dice[0].rolls[0].roll; - const taskLevel = Math.floor(rolled / 3); - const skillLevel = (roll.total - rolled) / 3; - const sum = taskLevel + skillLevel; - - let text = `${game.i18n.localize("NUMENERA.successLevel")} ${sum}`; - - if (skillLevel !== 0) { - const sign = sum > 0 ? "+" : "-"; - text += ` (${taskLevel}${sign}${skillLevel})`; - } - - dt.textContent = text; - } - - } -}); - -/** - * Add additional system-specific sidebar directory context menu options for D&D5e Actor entities - * @param {jQuery} html The sidebar HTML - * @param {Array} entryOptions The default array of context menu options - */ -Hooks.on("getActorDirectoryEntryContext", (html, entryOptions) => { - entryOptions.push({ - name: game.i18n.localize("NUMENERA.gmIntrusion"), - icon: '', - callback: li => { - const actor = game.actors.get(li.data("entityId")); - const ownerIds = Object.entries(actor.data.permission) - .filter(entry => { - const [id, permissionLevel] = entry; - return permissionLevel >= ENTITY_PERMISSIONS.OWNER - && id !== game.user.id - }) - .map(usersPermissions => usersPermissions[0]); - - game.socket.emit("system.numenera", {type: "gmIntrusion", data: { - userIds: ownerIds, - actorId: actor.data._id, - }}); - - ChatMessage.create({ - content: `

${game.i18n.localize("NUMENERA.gmIntrusion")}


${game.i18n.localize("NUMENERA.gmIntrusionText")} ${actor.data.name}`, - }); - }, - condition: li => { - if (!game.user.isGM) - return false; - - const actor = game.actors.get(li.data("entityId")); - return actor && actor.data.type === "pc"; - } - }); -}); - -/** - * Once the entire VTT framework is initialized, check to see if we should perform a data migration - */ Hooks.once("ready", migrateWorld); Hooks.once("ready", numeneraSocketListeners); + +//Random hooks should go in there +registerHooks(); \ No newline at end of file diff --git a/package.json b/package.json index 1bdea2e5..eef481fe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "numenera-foundryvtt", - "version": "0.10.0", + "version": "0.11.0", "description": "Support for the Numenera role playing game for the Foundry virtual tabletop", "dependencies": {}, "devDependencies": { diff --git a/sass/numenera.scss b/sass/numenera.scss index 2fd78657..1483b5f4 100644 --- a/sass/numenera.scss +++ b/sass/numenera.scss @@ -7,6 +7,20 @@ $foundry-border-color: #782e22; $border-color: #325d6d; $font-stack: Ogirema; +@mixin editor($class, $height, $id) { + div.#{$class} { + height: #{$height}; + + .editor { + height: 100%; + } + + button#save-#{$id} { + width: 99%; + } + } +} + span.numenera-message-special { font-weight: bold; font-size: large; @@ -150,14 +164,35 @@ form.numenera { div > div.npc-info { flex: 0.25; - input.levelDie { - width: 2ch; - align-content: center; + h1 { + margin-left: 0.5em; + margin-right: 0.5em; + + input.charname { + width: 70%; + } + + input.level { + width: 3em; + align-content: center; + } } } div > div.npc-stats { - flex: 0.5; + flex: 0.15; + padding: 1em; + text-align: center; + + label { + padding-top: 2px; + font-size: 16px; + font-weight: bold; + } + + input { + width: 2ch; + } } ul { @@ -342,15 +377,22 @@ form.numenera { border-bottom: 1px solid $border-color; } - div.description { - height: 425px; - - .editor { - height: 100%; - } + //TinyMCE editors + @include editor("description", 425px, "bio"); - button#save-bio { - width: 99%; + $totalDescriptionHeight: 285px; + $buttonHeight: 34px; + $descriptionHeight: $totalDescriptionHeight - $buttonHeight; + + @include editor("npc-description", $descriptionHeight, "npc-description"); + @include editor("npc-info", $descriptionHeight, "npc-info"); + + div.npc-modifications { + display: flex; + flex-direction: column; + + textarea { + flex: 1; } } @@ -404,6 +446,10 @@ form.numenera { margin: 0.5em 0 0.5em 0; border: 1px solid black; border-radius: 8px; + + &.sheet-tabs { + flex: 0; + } } a.item { @@ -645,4 +691,4 @@ form.numenera-recovery { padding: 4px 12px 4px 12px; margin: 8px; border: 1px solid #737373; -} \ No newline at end of file +} diff --git a/system.json b/system.json index cc5d9cf0..75553c30 100644 --- a/system.json +++ b/system.json @@ -2,7 +2,7 @@ "name": "numenera", "title": "Numenera", "description": "Simple support for Numenera for the Fountry Virtual TableTop.", - "version": "0.10.0", + "version": "0.11.0", "author": "SolarBear", "scripts": ["lib/dragula/dragula.min.js"], "esmodules": ["numenera.js"], @@ -21,9 +21,9 @@ } ], "socket": true, - "compatibleCoreVersion": "0.6.1", + "compatibleCoreVersion": "0.6.2", "minimumCoreVersion" : "0.5.5", "url": "https://github.com/SolarBear/Numenera-FoundryVTT", "manifest": "https://raw.githubusercontent.com/SolarBear/Numenera-FoundryVTT/master/system.json", - "download": "https://github.com/SolarBear/Numenera-FoundryVTT/releases/download/0.10.0/numenera-foundryvtt-0.10.0.zip" + "download": "https://github.com/SolarBear/Numenera-FoundryVTT/releases/download/0.11.0/numenera-foundryvtt-0.11.0.zip" } diff --git a/template.json b/template.json index bed80fc5..b76a2613 100644 --- a/template.json +++ b/template.json @@ -50,29 +50,22 @@ }, "npc": { "level": 1, - "attacks": {}, - "description": null, - "motive": null, - "environment": null, + "description": "", + "info": "", "health": { "value": 1, "max": 1 }, - "combat": null, "damage": 0, "armor": 0, "movement": "short", - "modifications": null, - "interaction": null, - "use": null, - "loot": null, - "gmIntrusion": null, + "modifications": "", "image": null, - "version": 1 + "version": 2 } }, "Item": { - "types": ["ability", "armor", "artifact", "cypher", "equipment", "oddity", "skill", "weapon"], + "types": ["ability", "armor", "artifact", "cypher", "equipment", "npcAttack", "oddity", "skill", "weapon"], "templates": { "common": { "notes": "", @@ -128,6 +121,9 @@ "templates": ["common", "sellable"], "quantity": 0 }, + "npcAttack": { + "templates": ["common"] + }, "oddity": { "templates": ["carriable", "common"] }, diff --git a/templates/actor/characterSheet.html b/templates/actor/characterSheet.html index d6a89eda..f376c73e 100644 --- a/templates/actor/characterSheet.html +++ b/templates/actor/characterSheet.html @@ -171,15 +171,16 @@

{{localize "NUMENERA.pc.armor.label"}}  {{/if}} - {{localize "NUMENERA.abilities.tab.name"}} - {{localize "NUMENERA.abilities.tab.cost"}} - {{localize "NUMENERA.abilities.tab.range"}} - {{localize "NUMENERA.abilities.tab.description"}} - + {{localize "NUMENERA.item.ability.tab.name"}} + {{localize "NUMENERA.item.ability.tab.cost"}} + {{localize "NUMENERA.item.ability.tab.range"}} + {{localize "NUMENERA.item.ability.tab.description"}} + {{#each data.items.abilities as |ability| }} + @@ -195,8 +196,8 @@

{{localize "NUMENERA.pc.armor.label"}} 
@@ -225,8 +226,8 @@

{{localize "NUMENERA.pc.armor.label"}} {{ability.data.notes}} - d20 - + d20 + {{else}} @@ -236,7 +237,7 @@

{{localize "NUMENERA.pc.armor.label"}}  {{/if}} - {{{localize "NUMENERA.abilities.tab.instructions"}}} + {{{localize "NUMENERA.item.ability.tab.instructions"}}} {{/each}} @@ -272,11 +273,11 @@

{{cypher.name}}

{{#if cypher.editable}} -