diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..aa6b51a4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ + +images/icon.png +module-releases_template.txt +workspace.code-workspace +git_tags.txt diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 00000000..7e14fb9c --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Jan Ole Peek + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 00000000..16bcd7c7 --- /dev/null +++ b/README.md @@ -0,0 +1,60 @@ +# Loot Sheet NPC 5E + +This module adds an additional NPC sheet which can be used for loot containers such as chests. It also allows spells to be automatically converted into spell scrolls by dragging them onto this sheet. + +This version was forked from Hooking's module which ended compatibility with Foundry VTT version 0.3.9 (https://gitlab.com/hooking/foundry-vtt---loot-sheet-npc). This fork should be updated by me to keep it current with Foundry VTT. + +### Features + +Allows for easy assembly of items and coins to be distributed to players. + +More features detailed below. + +##### Permissions +Permissions can be set in the sheet for each player and range from no access (cannot open sheet) to observer (view sheet and contents) to owner (view sheet and add/remove items). + +![demo_permissions](https://thumbs.gfycat.com/CaringWildKoi-size_restricted.gif) + +##### Shopkeeper Sheet +Can be used to create an inventory of a shopkeeper to allow players to peruse their inventory. Prices are listed next to each item. + +![demo_inventory](https://raw.githubusercontent.com/jopeek/fvtt-loot-sheet-npc-5e/master/images/demo_inventory.jpg) + +##### Price Modifier +Prices can be adjusted by percentage for all owned items. + +![price_modifier](https://thumbs.gfycat.com/WelloffFortunateInganue-size_restricted.gif) + +A Biography tab is also available. + +![demo_biography](https://raw.githubusercontent.com/jopeek/fvtt-loot-sheet-npc-5e/master/images/demo_biography.jpg) + +##### Coin Distribution +Any coins in the sheet can easily be split evenly across all players with owner access. The math and distribution is done for you via a single click if you're the GM. + +![demo_splitcoins](https://thumbs.gfycat.com/ElementaryDependentGalapagosdove-size_restricted.gif) + +##### Create Spell Scrolls +Dragging of spells into the sheet will automatically turn them into scrolls. + +![demo_scrolls](https://thumbs.gfycat.com/LividAccurateFluke-size_restricted.gif) + +### Compatibility: +- Tested with FVTT v0.5.3. + +### Known Issues: +- Dragging an item out of the sheet does not actually remove it from the sheet's inventory. +- Price Modifier currently doesn't save owned item prices properly on Tokens, so the button will not appear on tokens. Believe this to be related to a FoundryVTT issue. +- Currently can't get back to original prices, especially if percentage is set to 0. + +### Installation Instructions + +To install a module, follow these instructions: + +1. Start FVTT and browse to the Game Modules tab in the Configuration and Setup menu +2. Select the Install Module button and enter the following URL: https://raw.githubusercontent.com/jopeek/fvtt-loot-sheet-npc-5e/master/module.json +3. Click Install and wait for installation to complete + +### Feedback + +If you have any suggestions or feedback, please contact me on Discord (ChalkOne#0156). \ No newline at end of file diff --git a/css/lootsheetnpc5e.css b/css/lootsheetnpc5e.css new file mode 100644 index 00000000..1aa87279 --- /dev/null +++ b/css/lootsheetnpc5e.css @@ -0,0 +1,116 @@ +.npc-sheet .gm-section { + /* height: 48px; */ + color: #000; + /* font-weight: 700; */ + font-size: 20px; + margin-top: 0.5rem; + text-align: center; + border-top: 1px solid #CCC; + border-bottom: 1px solid #CCC; + font-family: "Nodesto", "Signika", "Palatino Linotype", serif; + font-size: 20px; + color: #4b4a44; + font-size: 18px; +} +.npc-sheet .gm-header { +margin-top: 0.5rem; +} +.npc-sheet .gm-settings .permissions-list, +.npc-sheet .gm-settings .coin-list { +margin: 0; +padding: 0; +list-style: none; +} +.npc-sheet .gm-settings .permission, +.npc-sheet .gm-settings .denomination { +display: flex; +flex-direction: row; +flex-wrap: wrap; +justify-content: flex-start; +height: 21px; +line-height: 21px; +border-bottom: 1px solid #CCC; +font-size: 13px; +} +.npc-sheet .gm-settings .permission, +.npc-sheet .gm-settings .denomination > * { +flex: 1; +} +.npc-sheet .gm-settings .permission .permission-name { +margin: 0; +} +.npc-sheet .gm-settings .permission .permission-proficiency { +text-align: center; +flex: 0 0 24px; +} +.npc-sheet .gm-settings .permission .permission-proficiency i.fa-circle { +font-size: 10px; +} +.npc-sheet .gm-settings .split-coins { +width: 95%; +} + +.dnd5e.sheet .window-content form { +overflow: auto !important; +} + +img.sheet-profile { +border: none; +max-width: 220px; +max-height: 220px; +} + +section.sheet-sidebar.sidebar { +flex: 0 0 250px; +border-bottom: 2px groove #eeede0; + border-right: 2px groove #eeede0; +} + +.sheet-profile-img { +text-align: center; +} + +section.sheet-content.content { + border-bottom: 2px groove #eeede0; +} + +.dnd5e.sheet.actor .inventory-list .item .item-price, .dnd5e.sheet.actor .inventory-list .item .item-quantity { + flex: 0 0 100px; + color: #666; + font-size: 10px; +} + +.dnd5e.sheet .sheet-header .charbutton { +flex: 0 0 200px; +margin: auto; +text-align: right; +} + +button.price-modifier { +width: auto; +padding: 1px 10px; +margin-right: 5px; +} + +.dnd5e.sheet .sheet-navigation { +margin-bottom: 15px; +} + +input#price-modifier-percent-display { +background: none; +border: 1px solid transparent; +width: 50px; +} + +input#price-modifier-percent-display:hover, input#price-modifier-percent-display:focus { +border: 1px solid #111; +box-shadow: 0 0 8px red; +} + +.loot-sheet-npc .sheet-profile-img { + padding: 10px; +} + +.loot-sheet-npc.sheet.actor .editor { +height: 600px; +} \ No newline at end of file diff --git a/images/demo_biography.jpg b/images/demo_biography.jpg new file mode 100644 index 00000000..9c927d9d Binary files /dev/null and b/images/demo_biography.jpg differ diff --git a/images/demo_inventory.jpg b/images/demo_inventory.jpg new file mode 100644 index 00000000..995ff7ee Binary files /dev/null and b/images/demo_inventory.jpg differ diff --git a/images/demo_shopkeeper.jpg b/images/demo_shopkeeper.jpg new file mode 100644 index 00000000..224cdee9 Binary files /dev/null and b/images/demo_shopkeeper.jpg differ diff --git a/lootsheetnpc5e.js b/lootsheetnpc5e.js new file mode 100644 index 00000000..248a5ee9 --- /dev/null +++ b/lootsheetnpc5e.js @@ -0,0 +1,543 @@ +import { + ActorSheet5eNPC +} from "../../systems/dnd5e/module/actor/sheets/npc.js"; + +class LootSheet5eNPC extends ActorSheet5eNPC { + + get template() { + // adding the #equals and #unequals handlebars helper + Handlebars.registerHelper('equals', function(arg1, arg2, options) { + return (arg1 == arg2) ? options.fn(this) : options.inverse(this); + }); + + Handlebars.registerHelper('unequals', function(arg1, arg2, options) { + return (arg1 != arg2) ? options.fn(this) : options.inverse(this); + }); + + const path = "systems/dnd5e/templates/actors/"; + if (!game.user.isGM && this.actor.limited) return path + "limited-sheet.html"; + return "modules/lootsheetnpc5e/template/npc-sheet.html"; + } + + static get defaultOptions() { + const options = super.defaultOptions; + + mergeObject(options, { + classes: ["dnd5e sheet actor npc npc-sheet loot-sheet-npc"], + width: 850, + height: 750 + }); + return options; + } + + getData() { + const sheetData = super.getData(); + + // Prepare GM Settings + this._prepareGMSettings(sheetData.actor); + + // Prepare isGM attribute in sheet Data + + //console.log("game.user: ", game.user); + if (game.user.isGM) sheetData.isGM = true; + else sheetData.isGM = false; + //console.log("sheetData.isGM: ", sheetData.isGM); + + // Return data for rendering + return sheetData; + } + + /* -------------------------------------------- */ + /* Event Listeners and Handlers + /* -------------------------------------------- */ + + /** + * Activate event listeners using the prepared sheet HTML + * @param html {HTML} The prepared HTML object ready to be rendered into the DOM + */ + activateListeners(html) { + super.activateListeners(html); + if (!this.options.editable) return; + + // Toggle Permissions + html.find('.permission-proficiency').click(ev => this._onCyclePermissionProficiency(ev)); + + // Split Coins + html.find('.split-coins').click(ev => this._distributeCoins(ev)); + + // Price Modifier + if (this.actor.isToken) { + html.find('.price-modifier').remove(); + } else { + html.find('.price-modifier').click(ev => this._priceModifier(ev)); + } + + + } + + /* -------------------------------------------- */ + + /** + * Handle price modifier + * @private + */ + _priceModifier(event) { + event.preventDefault(); + //console.log("Loot Sheet | Price Modifier clicked"); + //console.log(this.actor.isToken); + + var html = "

Use this slider to increase or decrease the price of all items in this inventory.

"; + + html += '

'; + + html += '

'; + + html += ''; + + let d = new Dialog({ + title: "Price Modifier", + content: html, + buttons: { + one: { + icon: '', + label: "Update", + callback: () => this._updatePrices(document.getElementById("price-modifier-percent").value) + }, + two: { + icon: '', + label: "Cancel", + callback: () => console.log("Loot Sheet | Price Modifier Cancelled") + } + }, + default: "two", + close: () => console.log("Loot Sheet | Price Modifier Closed") + }); + d.render(true); + } + + /* -------------------------------------------- */ + + /** + * Handle distribution of coins + * @private + */ + _distributeCoins(event) { + event.preventDefault(); + //console.log("Loot Sheet | Split Coins clicked"); + + let actorData = this.actor.data + let owners = []; + //console.log("Loot Sheet | actorData", actorData); + // Calculate owners + for (let u in actorData.permission) { + if (u != "default" && actorData.permission[u] == 3) { + //console.log("Loot Sheet | u in actorData.permission", u); + let player = game.users.get(u); + //console.log("Loot Sheet | player", player); + let actor = game.actors.get(player.data.character); + //console.log("Loot Sheet | actor", actor); + if (actor !== null) owners.push(actor); + } + } + + //console.log("Loot Sheet | owners", owners); + if (owners.length === 0) return; + + // Calculate split of currency + let currencySplit = duplicate(actorData.data.currency); + //console.log("Loot Sheet | Currency data", currencySplit); + for (let c in currencySplit) { + if (owners.length) + currencySplit[c].value = Math.floor(currencySplit[c].value / owners.length); + else + currencySplit[c].value = 0 + } + + // add currency to actors existing coins + let msg = []; + for (let u of owners) { + //console.log("Loot Sheet | u of owners", u); + if (u === null) continue; + + msg = []; + let currency = u.data.data.currency, + newCurrency = duplicate(u.data.data.currency); + + //console.log("Loot Sheet | Current Currency", currency); + + for (let c in currency) { + // add msg for chat description + if (currencySplit[c].value) { + //console.log("Loot Sheet | New currency for " + c, currencySplit[c]); + msg.push(` ${currencySplit[c].value} ${c} coins`) + } + + // Add currency to permitted actor + newCurrency[c] = currency[c] + currencySplit[c].value; + + //console.log("Loot Sheet | New Currency", newCurrency); + u.update({ + 'data.currency': newCurrency + }); + } + + // Remove currency from loot actor. + let lootCurrency = this.actor.data.data.currency, + zeroCurrency = {}; + + for (let c in lootCurrency) { + zeroCurrency[c] = { + 'type': currencySplit[c].type, + 'label': currencySplit[c].type, + 'value': 0 + } + this.actor.update({ + "data.currency": zeroCurrency + }); + } + + + // Create chat message for coins received + if (msg.length != 0) { + let message = `${u.data.name} receives: `; + message += msg.join(","); + ChatMessage.create({ + user: game.user._id, + speaker: { + actor: this.actor, + alias: this.actor.name + }, + content: message + }); + } + } + } + + /* -------------------------------------------- */ + + /** + * Handle cycling permissions + * @private + */ + _onCyclePermissionProficiency(event) { + + event.preventDefault(); + + //console.log("Loot Sheet | this.actor.data.permission", this.actor.data.permission); + + + let actorData = this.actor.data; + + + let field = $(event.currentTarget).siblings('input[type="hidden"]'); + + let level = parseFloat(field.val()); + if (typeof level === undefined) level = 0; + + //console.log("Loot Sheet | current level " + level); + + const levels = [0, 3, 2]; //const levels = [0, 2, 3]; + + let idx = levels.indexOf(level), + newLevel = levels[(idx === levels.length - 1) ? 0 : idx + 1]; + + //console.log("Loot Sheet | new level " + newLevel); + + let playerId = field[0].name; + + //console.log("Loot Sheet | Current actor: " + playerId); + + + // Read player permission on this actor and adjust to new level + let currentPermissions = duplicate(actorData.permission); + + //console.log("Loot Sheet | currentPermissions ", currentPermissions); + + + currentPermissions[playerId] = newLevel; + + + //console.log("Loot Sheet | updated currentPermissions ", currentPermissions); + + //console.log("Loot Sheet | this.actor.permission after update ", this.actor.data.permission); + + // Save updated player permissions + const lootPermissions = new PermissionControl(this.actor); + lootPermissions._updateObject(event, currentPermissions); + + this._onSubmit(event); + } + + /* -------------------------------------------- */ + + /** + * Organize and classify Items for Loot NPC sheets + * @private + */ + _updatePrices(pm) { + //console.log("Loot Sheet | Price Modifier Updating prices...", pm); + + let actorData = duplicate(this.actor.data); + + if (pm === undefined || pm === "100") return; + + for (let i of actorData.items) { + + //console.log("Loot Sheet | item", i); + + var currentPrice = i.data.price; + + //accomodate small prices so they don't get rounded to 0 + if (currentPrice < 1) { + var newPrice = pm === 0 ? 0 : (currentPrice * (pm / 100)).toFixed(2); + } else { + var newPrice = pm === 0 ? 0 : Math.round(currentPrice * (pm / 100)); + } + + //console.log(newPrice); + i.data.price = newPrice; + + this.actor.updateOwnedItem(i); + } + + } + + /* -------------------------------------------- */ + + /** + * Organize and classify Items for Loot NPC sheets + * @private + */ + _prepareItems(actorData) { + + //console.log("Loot Sheet | Prepare Features"); + // Actions + const features = { + weapons: { + label: "Weapons", + items: [], + type: "weapon" + }, + equipment: { + label: "Equipment", + items: [], + type: "equipment" + }, + consumables: { + label: "Consumables", + items: [], + type: "consumable" + }, + tools: { + label: "Tools", + items: [], + type: "tool" + }, + containers: { + label: "Containers", + items: [], + type: "container" + }, + loot: { + label: "Loot", + items: [], + type: "loot" + }, + + }; + + //console.log("Loot Sheet | Prepare Items"); + // Iterate through items, allocating to containers + for (let i of actorData.items) { + i.img = i.img || DEFAULT_TOKEN; + //console.log("Loot Sheet | item", i); + + // Features + if (i.type === "weapon") features.weapons.items.push(i); + else if (i.type === "equipment") features.equipment.items.push(i); + else if (i.type === "consumable") features.consumables.items.push(i); + else if (i.type === "tool") features.tools.items.push(i); + else if (["container", "backpack"].includes(i.type)) features.containers.items.push(i); + else if (i.type === "loot") features.loot.items.push(i); + else features.loot.items.push(i); + } + + // Assign and return + //actorData.features = features; + actorData.actor.features = features; + //console.log(this.actor); + } + + /* -------------------------------------------- */ + + + /** + * Get the font-awesome icon used to display the permission level. + * @private + */ + _getPermissionIcon(level) { + const icons = { + 0: '', + 2: '', + 3: '' + }; + return icons[level]; + } + + /* -------------------------------------------- */ + + /** + * Get the font-awesome icon used to display the permission level. + * @private + */ + _getPermissionDescription(level) { + const description = { + 0: "None (cannot access actor)", + 2: "Observer (access to actor but cannot access items)", + 3: "Owner (can access items and share coins)" + }; + return description[level]; + } + + /* -------------------------------------------- */ + + /** + * Prepares GM settings to be rendered by the loot sheet. + * @private + */ + _prepareGMSettings(actorData) { + + const players = [], + owners = []; + let users = game.users.entities; + + //console.log("Loot Sheet _prepareGMSettings | actorData.permission", actorData.permission); + + for (let u of users) { + //console.log("Loot Sheet | Checking user " + u.data.name, u); + + //check if the user is a player + if (u.data.role === 1 || u.data.role === 2) { + + // get the name of the primary actor for a player + const actor = game.actors.get(u.data.character); + + if (actor) { + + u.actor = actor.data.name; + u.actorId = actor.data._id; + u.playerId = u.data._id; + + //Check if there are default permissions to the actor + if (typeof actorData.permission.default !== "undefined") { + + //console.log("Loot Sheet | default permissions", actorData.permission.default); + + u.lootPermission = actorData.permission.default; + + if (actorData.permission.default === 3 && !owners.includes(actor.data._id)) { + + owners.push(actor.data._id); + } + + } else { + + u.lootPermission = 0; + //console.log("Loot Sheet | assigning 0 permission to hidden field"); + } + + //if the player has some form of permission to the object update the actorData + if (u.data._id in actorData.permission && !owners.includes(actor.data._id)) { + //console.log("Loot Sheet | Found individual actor permission"); + + u.lootPermission = actorData.permission[u.data._id]; + //console.log("Loot Sheet | assigning " + actorData.permission[u.data._id] + " permission to hidden field"); + + if (actorData.permission[u.data._id] === 3) { + owners.push(actor.data._id); + } + } + + //Set icons and permission texts for html + //console.log("Loot Sheet | lootPermission", u.lootPermission); + u.icon = this._getPermissionIcon(u.lootPermission); + u.lootPermissionDescription = this._getPermissionDescription(u.lootPermission); + players.push(u); + } + } + } + + // calculate the split of coins between all owners of the sheet. + let currencySplit = duplicate(actorData.data.currency); + for (let c in currencySplit) { + if (owners.length) + currencySplit[c].value = Math.floor(currencySplit[c].value / owners.length); + else + currencySplit[c] = 0 + } + + let loot = {} + loot.players = players; + loot.ownerCount = owners.length; + loot.currency = currencySplit; + actorData.flags.loot = loot; + } + + +} + +//Register the loot sheet +Actors.registerSheet("dnd5e", LootSheet5eNPC, { + types: ["npc"], + makeDefault: false +}); + + +/** + * Register a hook to convert any spell created on an actor with the LootSheet5eNPC sheet to a consumable scroll. + */ +Hooks.on('preCreateOwnedItem', (actor, item, data) => { + + // console.log("Loot Sheet | actor", actor); + // console.log("Loot Sheet | item", item); + // console.log("Loot Sheet | data", data); + + if (!actor) throw new Error(`Parent Actor ${actor._id} not found`); + + // Check if Actor is an NPC + if (actor.data.type === "character") return; + + // If the actor is using the LootSheet5eNPC then check in the item is a spell and if so update the name. + if ((actor.data.flags.core || {}).sheetClass === "dnd5e.LootSheet5eNPC") { + if (item.type === "spell") { + //console.log("Loot Sheet | dragged spell item", item); + + item.name = "Scroll of " + item.name; + item.type = "consumable"; + item.data.price = Math.round(10 * Math.pow(2.6, item.data.level)); + //console.log("Loot Sheet | price of scroll", item.data.price); + item.data.autoDestroy = { + label: "Destroy on Empty", + type: "Boolean", + value: true + } + item.data.autoUse = { + label: "Consume on Use", + type: "Boolean", + value: true + } + item.data.charges = { + label: "Charges", + max: 1, + type: "Number", + value: 1 + } + item.data.consumableType = { + label: "Consumable Type", + type: "String", + value: "scroll" + } + } + } else return; + +}); \ No newline at end of file diff --git a/module.json b/module.json new file mode 100644 index 00000000..55567110 --- /dev/null +++ b/module.json @@ -0,0 +1,15 @@ +{ + "name": "lootsheetnpc5e", + "title": "Loot Sheet NPC 5e", + "description": "This module adds an additional NPC sheet which can be used for loot containers such as chests or shopkeepers.", + "version": "1.4.0", + "minimumCoreVersion": "0.5.3", + "compatibleCoreVersion": "0.5.5", + "author": "Jan Ole Peek (ChalkOne)", + "systems": ["dnd5e"], + "esmodules": ["/lootsheetnpc5e.js"], + "styles": ["/css/lootsheetnpc5e.css"], + "url": "https://github.com/jopeek/fvtt-loot-sheet-npc-5e", + "manifest": "https://raw.githubusercontent.com/jopeek/fvtt-loot-sheet-npc-5e/master/module.json", + "download": "https://github.com/jopeek/fvtt-loot-sheet-npc-5e/archive/master.zip" +} \ No newline at end of file diff --git a/template/npc-sheet.html b/template/npc-sheet.html new file mode 100644 index 00000000..3b1c71f6 --- /dev/null +++ b/template/npc-sheet.html @@ -0,0 +1,154 @@ +
+ + + + +
+

+ +

+ {{#if isGM}} +
+ +
+ {{/if}} +
+ + + + + + + + + +
+ + + + + +
+ + +
+
+ {{editor content=data.details.biography.value target="data.details.biography.value" button=true owner=owner editable=editable}} +
+ +
+ +
+
+
    +

    + Currency +

    + {{#each data.currency as |c i|}} + + + {{/each}} +
+
+ +
+
    + {{#each actor.features as |section sid|}} +
  • +

    {{section.label}}

    + {{#if ../owner}} + + {{/if}} +
  • + + {{#each section.items as |item iid|}} +
  • +
    +
    +

    + {{item.name}} +

    +
    +
    + {{item.data.quantity}} +
    +
    + {{item.data.price}} +
    + {{#if ../../owner}} +
    + + +
    + {{/if}} +
  • + {{/each}} + {{/each}} +
+
+
+
+ +
+ + + + +
+ + +
+