diff --git a/module/actor-sheet.js b/module/actor-sheet.js new file mode 100644 index 00000000..dd5add0a --- /dev/null +++ b/module/actor-sheet.js @@ -0,0 +1,130 @@ +/** + * Extend the basic ActorSheet with some very simple modifications + * @extends {ActorSheet} + */ +export class SimpleActorSheet extends ActorSheet { + + /** @override */ + static get defaultOptions() { + return mergeObject(super.defaultOptions, { + classes: ["worldbuilding", "sheet", "actor"], + template: "systems/worldbuilding/templates/actor-sheet.html", + width: 600, + height: 600, + tabs: [{navSelector: ".sheet-tabs", contentSelector: ".sheet-body", initial: "description"}], + dragDrop: [{dragSelector: ".item-list .item", dropSelector: null}] + }); + } + + /* -------------------------------------------- */ + + /** @override */ + getData() { + const data = super.getData(); + data.dtypes = ["String", "Number", "Boolean"]; + for ( let attr of Object.values(data.data.attributes) ) { + attr.isCheckbox = attr.dtype === "Boolean"; + } + return data; + } + + /* -------------------------------------------- */ + + /** @override */ + activateListeners(html) { + super.activateListeners(html); + + // Everything below here is only needed if the sheet is editable + if (!this.options.editable) return; + + // Update Inventory Item + html.find('.item-edit').click(ev => { + const li = $(ev.currentTarget).parents(".item"); + const item = this.actor.getOwnedItem(li.data("itemId")); + item.sheet.render(true); + }); + + // Delete Inventory Item + html.find('.item-delete').click(ev => { + const li = $(ev.currentTarget).parents(".item"); + this.actor.deleteOwnedItem(li.data("itemId")); + li.slideUp(200, () => this.render(false)); + }); + + // Add or Remove Attribute + html.find(".attributes").on("click", ".attribute-control", this._onClickAttributeControl.bind(this)); + } + + /* -------------------------------------------- */ + + /** @override */ + setPosition(options={}) { + const position = super.setPosition(options); + const sheetBody = this.element.find(".sheet-body"); + const bodyHeight = position.height - 192; + sheetBody.css("height", bodyHeight); + return position; + } + + /* -------------------------------------------- */ + + /** + * Listen for click events on an attribute control to modify the composition of attributes in the sheet + * @param {MouseEvent} event The originating left click event + * @private + */ + async _onClickAttributeControl(event) { + event.preventDefault(); + const a = event.currentTarget; + const action = a.dataset.action; + const attrs = this.object.data.data.attributes; + const form = this.form; + + // Add new attribute + if ( action === "create" ) { + const nk = Object.keys(attrs).length + 1; + let newKey = document.createElement("div"); + newKey.innerHTML = ``; + newKey = newKey.children[0]; + form.appendChild(newKey); + await this._onSubmit(event); + } + + // Remove existing attribute + else if ( action === "delete" ) { + const li = a.closest(".attribute"); + li.parentElement.removeChild(li); + await this._onSubmit(event); + } + } + + /* -------------------------------------------- */ + + /** @override */ + _updateObject(event, formData) { + + // Handle the free-form attributes list + const formAttrs = expandObject(formData).data.attributes || {}; + const attributes = Object.values(formAttrs).reduce((obj, v) => { + let k = v["key"].trim(); + if ( /[\s\.]/.test(k) ) return ui.notifications.error("Attribute keys may not contain spaces or periods"); + delete v["key"]; + obj[k] = v; + return obj; + }, {}); + + // Remove attributes which are no longer used + for ( let k of Object.keys(this.object.data.data.attributes) ) { + if ( !attributes.hasOwnProperty(k) ) attributes[`-=${k}`] = null; + } + + // Re-combine formData + formData = Object.entries(formData).filter(e => !e[0].startsWith("data.attributes")).reduce((obj, e) => { + obj[e[0]] = e[1]; + return obj; + }, {_id: this.object._id, "data.attributes": attributes}); + + // Update the Actor + return this.object.update(formData); + } +} diff --git a/module/actor.js b/module/actor.js new file mode 100644 index 00000000..0f85c7f2 --- /dev/null +++ b/module/actor.js @@ -0,0 +1,35 @@ +/** + * Extend the base Actor entity by defining a custom roll data structure which is ideal for the Simple system. + * @extends {Actor} + */ +export class SimpleActor extends Actor { + + /** @override */ + getRollData() { + const data = super.getRollData(); + const shorthand = game.settings.get("worldbuilding", "macroShorthand"); + + // Re-map all attributes onto the base roll data + if ( !!shorthand ) { + for ( let [k, v] of Object.entries(data.attributes) ) { + if ( !(k in data) ) data[k] = v.value; + } + delete data.attributes; + } + + // Map all items data using their slugified names + data.items = this.data.items.reduce((obj, i) => { + let key = i.name.slugify({strict: true}); + let itemData = duplicate(i.data); + if ( !!shorthand ) { + for ( let [k, v] of Object.entries(itemData.attributes) ) { + if ( !(k in itemData) ) itemData[k] = v.value; + } + delete itemData["attributes"]; + } + obj[key] = itemData; + return obj; + }, {}); + return data; + } +} diff --git a/module/item-sheet.js b/module/item-sheet.js new file mode 100644 index 00000000..8d378e1e --- /dev/null +++ b/module/item-sheet.js @@ -0,0 +1,115 @@ +/** + * Extend the basic ItemSheet with some very simple modifications + * @extends {ItemSheet} + */ +export class SimpleItemSheet extends ItemSheet { + + /** @override */ + static get defaultOptions() { + return mergeObject(super.defaultOptions, { + classes: ["worldbuilding", "sheet", "item"], + template: "systems/worldbuilding/templates/item-sheet.html", + width: 520, + height: 480, + tabs: [{navSelector: ".sheet-tabs", contentSelector: ".sheet-body", initial: "description"}] + }); + } + + /* -------------------------------------------- */ + + /** @override */ + getData() { + const data = super.getData(); + data.dtypes = ["String", "Number", "Boolean"]; + for ( let attr of Object.values(data.data.attributes) ) { + attr.isCheckbox = attr.dtype === "Boolean"; + } + return data; + } + + /* -------------------------------------------- */ + + /** @override */ + setPosition(options={}) { + const position = super.setPosition(options); + const sheetBody = this.element.find(".sheet-body"); + const bodyHeight = position.height - 192; + sheetBody.css("height", bodyHeight); + return position; + } + + /* -------------------------------------------- */ + + /** @override */ + activateListeners(html) { + super.activateListeners(html); + + // Everything below here is only needed if the sheet is editable + if (!this.options.editable) return; + + // Add or Remove Attribute + html.find(".attributes").on("click", ".attribute-control", this._onClickAttributeControl.bind(this)); + } + + /* -------------------------------------------- */ + + /** + * Listen for click events on an attribute control to modify the composition of attributes in the sheet + * @param {MouseEvent} event The originating left click event + * @private + */ + async _onClickAttributeControl(event) { + event.preventDefault(); + const a = event.currentTarget; + const action = a.dataset.action; + const attrs = this.object.data.data.attributes; + const form = this.form; + + // Add new attribute + if ( action === "create" ) { + const nk = Object.keys(attrs).length + 1; + let newKey = document.createElement("div"); + newKey.innerHTML = ``; + newKey = newKey.children[0]; + form.appendChild(newKey); + await this._onSubmit(event); + } + + // Remove existing attribute + else if ( action === "delete" ) { + const li = a.closest(".attribute"); + li.parentElement.removeChild(li); + await this._onSubmit(event); + } + } + + /* -------------------------------------------- */ + + /** @override */ + _updateObject(event, formData) { + + // Handle the free-form attributes list + const formAttrs = expandObject(formData).data.attributes || {}; + const attributes = Object.values(formAttrs).reduce((obj, v) => { + let k = v["key"].trim(); + if ( /[\s\.]/.test(k) ) return ui.notifications.error("Attribute keys may not contain spaces or periods"); + delete v["key"]; + obj[k] = v; + return obj; + }, {}); + + // Remove attributes which are no longer used + for ( let k of Object.keys(this.object.data.data.attributes) ) { + if ( !attributes.hasOwnProperty(k) ) attributes[`-=${k}`] = null; + } + + // Re-combine formData + formData = Object.entries(formData).filter(e => !e[0].startsWith("data.attributes")).reduce((obj, e) => { + obj[e[0]] = e[1]; + return obj; + }, {_id: this.object._id, "data.attributes": attributes}); + + // Update the Item + return this.object.update(formData); + } +} diff --git a/module/simple.js b/module/simple.js new file mode 100644 index 00000000..a2337acb --- /dev/null +++ b/module/simple.js @@ -0,0 +1,46 @@ +/** + * A simple and flexible system for world-building using an arbitrary collection of character and item attributes + * Author: Atropos + * Software License: GNU GPLv3 + */ + +// Import Modules +import { SimpleActor } from "./actor.js"; +import { SimpleItemSheet } from "./item-sheet.js"; +import { SimpleActorSheet } from "./actor-sheet.js"; + +/* -------------------------------------------- */ +/* Foundry VTT Initialization */ +/* -------------------------------------------- */ + +Hooks.once("init", async function() { + console.log(`Initializing Simple Worldbuilding System`); + + /** + * Set an initiative formula for the system + * @type {String} + */ + CONFIG.Combat.initiative = { + formula: "1d20", + decimals: 2 + }; + + // Define custom Entity classes + CONFIG.Actor.entityClass = SimpleActor; + + // Register sheet application classes + Actors.unregisterSheet("core", ActorSheet); + Actors.registerSheet("dnd5e", SimpleActorSheet, { makeDefault: true }); + Items.unregisterSheet("core", ItemSheet); + Items.registerSheet("dnd5e", SimpleItemSheet, {makeDefault: true}); + + // Register system settings + game.settings.register("worldbuilding", "macroShorthand", { + name: "Shortened Macro Syntax", + hint: "Enable a shortened macro syntax which allows referencing attributes directly, for example @str instead of @attributes.str.value. Disable this setting if you need the ability to reference the full attribute model, for example @attributes.str.label.", + scope: "world", + type: Boolean, + default: true, + config: true + }); +}); diff --git a/styles/simple.css b/styles/simple.css new file mode 100644 index 00000000..f4090e12 --- /dev/null +++ b/styles/simple.css @@ -0,0 +1,139 @@ +.worldbuilding { + /* Sheet Tabs */ + /* Items List */ + /* Attributes */ +} +.worldbuilding .window-content { + height: 100%; + padding: 5px; + overflow-y: hidden; +} +.worldbuilding .sheet-header { + height: 100px; + overflow: hidden; + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: flex-start; + margin-bottom: 10px; +} +.worldbuilding .sheet-header .profile-img { + flex: 0 0 100px; + height: 100px; + margin-right: 10px; +} +.worldbuilding .sheet-header .header-fields { + flex: 1; + height: 100px; +} +.worldbuilding .sheet-header h1.charname { + height: 50px; + padding: 0px; + margin: 5px 0; + border-bottom: 0; +} +.worldbuilding .sheet-header h1.charname input { + width: 100%; + height: 100%; + margin: 0; +} +.worldbuilding .resource { + width: 50%; + height: 40px; + margin-top: 10px; + float: left; + text-align: center; +} +.worldbuilding .resource input { + width: 100px; + height: 28px; +} +.worldbuilding .tabs { + height: 40px; + border-top: 1px solid #AAA; + border-bottom: 1px solid #AAA; +} +.worldbuilding .tabs .item { + line-height: 40px; + font-weight: bold; +} +.worldbuilding .tabs .item.active { + text-decoration: underline; + text-shadow: none; +} +.worldbuilding .sheet-body { + overflow: hidden; +} +.worldbuilding .sheet-body .tab { + height: 100%; + overflow-y: auto; +} +.worldbuilding .editor, +.worldbuilding .editor-content { + height: 100%; +} +.worldbuilding .item-list { + list-style: none; + margin: 7px 0; + padding: 0; + overflow-y: auto; +} +.worldbuilding .item-list .item { + height: 30px; + line-height: 24px; + padding: 3px 0; + border-bottom: 1px solid #BBB; +} +.worldbuilding .item-list .item img { + flex: 0 0 24px; + margin-right: 5px; +} +.worldbuilding .item-list .item-name { + margin: 0; +} +.worldbuilding .item-list .item-controls { + flex: 0 0 36px; +} +.worldbuilding .attributes-header { + padding: 5px; + margin: 5px 0; + background: rgba(0, 0, 0, 0.05); + border: 1px solid #AAA; + border-radius: 2px; + text-align: center; + font-weight: bold; +} +.worldbuilding .attributes-header .attribute-label { + flex: 1.5; +} +.worldbuilding .attributes-header .attribute-control { + flex: 0 0 20px; +} +.worldbuilding .attributes-list { + list-style: none; + margin: 0; + padding: 0; +} +.worldbuilding .attributes-list li > * { + margin: 0 3px; + height: 28px; + line-height: 24px; + background: transparent; + border: none; + border-radius: 0; + border-bottom: 1px solid #AAA; +} +.worldbuilding .attributes-list a.attribute-control { + flex: 0 0 20px; + text-align: center; + line-height: 28px; + border: none; +} +.worldbuilding.sheet.actor { + min-width: 560px; + min-height: 420px; +} +.worldbuilding.sheet.item { + min-width: 460px; + min-height: 400px; +} diff --git a/styles/simple.less b/styles/simple.less new file mode 100644 index 00000000..ba89e194 --- /dev/null +++ b/styles/simple.less @@ -0,0 +1,162 @@ +.worldbuilding { + .window-content { + height: 100%; + padding: 5px; + overflow-y: hidden; + } + + .sheet-header { + height: 100px; + overflow: hidden; + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: flex-start; + margin-bottom: 10px; + + .profile-img { + flex: 0 0 100px; + height: 100px; + margin-right: 10px; + } + + .header-fields { + flex: 1; + height: 100px; + } + + h1.charname { + height: 50px; + padding: 0px; + margin: 5px 0; + border-bottom: 0; + input { + width: 100%; + height: 100%; + margin: 0; + } + } + } + + .resource { + width: 50%; + height: 40px; + margin-top: 10px; + float: left; + text-align: center; + input { + width: 100px; + height: 28px; + } + } + + /* Sheet Tabs */ + .tabs { + height: 40px; + border-top: 1px solid #AAA; + border-bottom: 1px solid #AAA; + + .item { + line-height: 40px; + font-weight: bold; + } + + .item.active { + text-decoration: underline; + text-shadow: none; + } + } + + .sheet-body { + overflow: hidden; + .tab { + height: 100%; + overflow-y: auto; + } + } + + .editor, .editor-content { + height: 100%; + } + + /* Items List */ + .item-list { + list-style: none; + margin: 7px 0; + padding: 0; + overflow-y: auto; + + .item { + height: 30px; + line-height: 24px; + padding: 3px 0; + border-bottom: 1px solid #BBB; + + img { + flex: 0 0 24px; + margin-right: 5px; + } + } + + .item-name { + margin: 0; + } + + .item-controls { + flex: 0 0 36px; + } + } + + /* Attributes */ + .attributes-header { + padding: 5px; + margin: 5px 0; + background: rgba(0, 0, 0, 0.05); + border: 1px solid #AAA; + border-radius: 2px; + text-align: center; + font-weight: bold; + + .attribute-label { + flex: 1.5; + } + + .attribute-control { + flex: 0 0 20px; + } + } + + .attributes-list { + list-style: none; + margin: 0; + padding: 0; + + li > * { + margin: 0 3px; + height: 28px; + line-height: 24px; + background: transparent; + border: none; + border-radius: 0; + border-bottom: 1px solid #AAA; + } + + a.attribute-control { + flex: 0 0 20px; + text-align: center; + line-height: 28px; + border: none; + } + } +} + +.worldbuilding.sheet.actor { + min-width: 560px; + min-height: 420px; +} + + +.worldbuilding.sheet.item { + min-width: 460px; + min-height: 400px; +} diff --git a/system.json b/system.json new file mode 100644 index 00000000..e134ce05 --- /dev/null +++ b/system.json @@ -0,0 +1,22 @@ +{ + "name": "worldbuilding", + "title": "Simple World-Building", + "description": "A minimalist game system with very simple Actor and Item models to support free-form system agnostic gameplay.", + "version": 0.36, + "minimumCoreVersion": "0.5.6", + "compatibleCoreVersion": "0.5.7", + "templateVersion": 2, + "author": "Atropos", + "esmodules": ["module/simple.js"], + "styles": ["styles/simple.css"], + "packs": [], + "languages": [], + "gridDistance": 5, + "gridUnits": "ft", + "primaryTokenAttribute": "health", + "secondaryTokenAttribute": "power", + "url": "https://gitlab.com/foundrynet/worldbuilding/", + "manifest": "https://gitlab.com/foundrynet/worldbuilding/raw/master/system.json", + "download": "https://gitlab.com/foundrynet/worldbuilding/-/archive/release-036/worldbuilding-release-036.zip", + "license": "LICENSE.txt" +} diff --git a/template.json b/template.json new file mode 100644 index 00000000..4e108a61 --- /dev/null +++ b/template.json @@ -0,0 +1,28 @@ +{ +"Actor": { + "types": ["character"], + "character": { + "biography": "", + "health": { + "value": 10, + "min": 0, + "max": 10 + }, + "power": { + "value": 5, + "min": 0, + "max": 5 + }, + "attributes": {} + } +}, +"Item": { + "types": ["item"], + "item": { + "description": "", + "quantity": 1, + "weight": 0, + "attributes": {} + } +} +} diff --git a/templates/actor-sheet.html b/templates/actor-sheet.html new file mode 100644 index 00000000..c51e2a3b --- /dev/null +++ b/templates/actor-sheet.html @@ -0,0 +1,86 @@ +
+ + {{!-- Sheet Header --}} +
+ +
+

+
+ + / + +
+
+ + / + +
+
+
+ + {{!-- Sheet Tab Navigation --}} + + + {{!-- Sheet Body --}} +
+ + {{!-- Biography Tab --}} +
+ {{editor content=data.biography target="data.biography" button=true owner=owner editable=editable}} +
+ + {{!-- Owned Items Tab --}} +
+
    + {{#each actor.items as |item id|}} +
  1. + +

    {{item.name}}

    +
    + + +
    +
  2. + {{/each}} +
+
+ + {{!-- Attributes Tab --}} +
+
+ Attribute Key + Value + Label + Data Type + +
+ +
    + {{#each data.attributes as |attr key|}} +
  1. + + {{#if attr.isCheckbox}} + + {{else}} + + {{/if}} + + + +
  2. + {{/each}} +
+
+
+
+ diff --git a/templates/item-sheet.html b/templates/item-sheet.html new file mode 100644 index 00000000..c8acc8e5 --- /dev/null +++ b/templates/item-sheet.html @@ -0,0 +1,64 @@ +
+
+ +
+

+
+ + +
+
+ + +
+
+
+ + {{!-- Sheet Tab Navigation --}} + + + {{!-- Sheet Body --}} +
+ + {{!-- Description Tab --}} +
+ {{editor content=data.description target="data.description" button=true owner=owner editable=editable}} +
+ + {{!-- Attributes Tab --}} +
+
+ Attribute Key + Value + Label + Data Type + +
+ +
    + {{#each data.attributes as |attr key|}} +
  1. + + {{#if attr.isCheckbox}} + + {{else}} + + {{/if}} + + + +
  2. + {{/each}} +
+
+
+