diff --git a/scripts/data/database.js b/scripts/data/database.js index 20af851..fb63a60 100644 --- a/scripts/data/database.js +++ b/scripts/data/database.js @@ -1,5 +1,6 @@ import { objectiveState } from "../helpers/constants.js"; import { Settings } from "../helpers/settings.js"; +import { newId } from "../helpers/constants.js"; export class QuestDatabase extends Collection { static #quests; @@ -7,17 +8,25 @@ export class QuestDatabase extends Collection { // Collect all quests. let q = this.#quests.map((q) => { // Get all of the objectives with index. - let objs = q.objectives.map((o, i) => { - return { - ...o, - index: i, - }; - }); + let objs = q.objectives.map((o, i) => + QuestDatabase.nestedObjectives(o, i, 0) + ); // If the user is not a GM, completely remove // all secret objectives from the list. if (!game.user.isGM) { objs = objs.filter((o) => !o.secret); + function filterObjectives(arr) { + return arr.filter((o) => { + // If the current object is a secret, filter it out. + if (o.secret) return false; + + // If the object is not a secret, check each sub objective. + if (o.subs) o.subs = filterObjectives(o.subs); + return true; + }); + } + filterObjectives(objs); // If the view mode is next only, strip extra objectives if (q.viewStyle === "next") { @@ -104,7 +113,7 @@ export class QuestDatabase extends Collection { let index = -1; do { // Create a new id - data.id = "id" + Math.random().toString(16).slice(2); + data.id = QuestDatabase.newId(); // Try to find any quests with the id // index will be -1 if none were found. @@ -140,6 +149,28 @@ export class QuestDatabase extends Collection { static refresh() { this.#quests = Settings.get(Settings.NAMES.QUEST_DB).quests; + + // Verify that all quest objectives have an id + this.#quests.forEach((q, i) => { + this.#quests[i] = { + ...q, + objectives: q.objectives.map((o) => + QuestDatabase.nestedObjectives(o, 0) + ), + }; + }); + } + + static nestedObjectives(objective) { + return { + ...objective, + id: objective.id || newId(), + subs: objective.subs + ? objective.subs.map((o) => QuestDatabase.nestedObjectives(o)) + : null, + }; + // Append the index to the objective + // Iterate through the nested objects console.log("Loaded " + this.#quests.length + " quests."); } } diff --git a/scripts/data/objective.js b/scripts/data/objective.js index c4615ab..9152682 100644 --- a/scripts/data/objective.js +++ b/scripts/data/objective.js @@ -1,4 +1,4 @@ -import { objectiveState } from "../helpers/constants.js"; +import { newId, objectiveState } from "../helpers/constants.js"; export class Objective { constructor(data = {}) { @@ -14,32 +14,36 @@ export class Objective { // Prepare the basic data. let data = { + id: newId(), text: "", secret: false, state: objectiveState.INCOMPLETE, + sublevel: 0, + subs: [], }; // Check for headers. while ( str.startsWith("-") || str.startsWith("+") || - str.startsWith("/") + str.startsWith("/") || + str.startsWith("*") ) { // Check for State flag if (str.startsWith("-")) { data.state = objectiveState.FAILED; - str = str.substring(1); } else if (str.startsWith("+")) { data.state = objectiveState.COMPLETE; - str = str.substring(1); } else if (str.startsWith("/")) { // Mark as secret data.secret = true; - str = str.substring(1); + } else if (str.startsWith("*")) { + data.sublevel++; } // Trim any whitespace that may be created. // This is to fix lines like "+ Objective" + str = str.substring(1); str = str.trim(); } @@ -66,8 +70,27 @@ export class Objective { // Try to parse the line. let o = Objective.parse(l); - // Skip any lines that failed to parse. - if (o) result.push(o); + // If the line fails to parse then skip it. + if (!o) return; + + // If there are no sublevels, the objective is not a subobjective + if (!o.sublevel) { + result.push(o); + return; + } + + // If the sublevel is greater than zero + // Get the last item. + let parent = result[result.length - 1]; + + // For each sub level, move a layer deeper + for (let i = 1; i < o.sublevel; i++) { + // While there are more sublevels, iterate through the subobjectives + parent = parent.subs[parent.subs.length - 1]; + } + + // Parent should now be the parent of the current objective. + parent.subs.push(o); }); // Return the list of objectives. @@ -75,8 +98,45 @@ export class Objective { } loadData(data = {}) { + this.id = data.id || newId(); this.text = data.text || ""; this.secret = data.secret || false; this.state = data.state || objectiveState.INCOMPLETE; + this.sublevel = data.sublevel || 0; + this.subs = data.subs || []; + } + + static stringify(objective) { + let line = ""; + // First, start by marking the quest as complete or failed if necessary. + if (objective.state === objectiveState.COMPLETE) line += "+"; + else if (objective.state === objectiveState.FAILED) line += "-"; + + // Mark the quest as a secret if needed. + if (objective.secret) line += "/"; + + // Add any subquest indentation + line += "*".repeat(objective.sublevel); + + // Add the text. + line += objective.text; + + // For each subobjective + // stringify the subobjective + // Add it to the line as a new line + if (objective.subs) + objective.subs.forEach((sub) => { + line += "\n" + Objective.stringify(sub); + }); + + // Push the completed line to the stack. + return line; + } + + static getNextState(state) { + if (state === objectiveState.INCOMPLETE) return objectiveState.COMPLETE; + else if (state === objectiveState.COMPLETE) + return objectiveState.FAILED; + else return objectiveState.INCOMPLETE; } } diff --git a/scripts/data/quest.js b/scripts/data/quest.js index 593fce2..f070315 100644 --- a/scripts/data/quest.js +++ b/scripts/data/quest.js @@ -24,4 +24,12 @@ export class Quest { }); } } + + static stringify(quest) { + let strings = []; + quest.objectives.forEach((o) => { + strings.push(Objective.stringify(o)); + }); + return strings.join("\n"); + } } diff --git a/scripts/helpers/constants.js b/scripts/helpers/constants.js index efb9586..1de8c04 100644 --- a/scripts/helpers/constants.js +++ b/scripts/helpers/constants.js @@ -18,3 +18,7 @@ export const questViewStyle = Object.freeze({ VIEW_NEXT: 1, VIEW_COMPLETE: 2, }); + +export function newId() { + return "id" + Math.random().toString(16).slice(2); +} diff --git a/scripts/helpers/handlebars.js b/scripts/helpers/handlebars.js index 4e3d6fa..ee3ff5b 100644 --- a/scripts/helpers/handlebars.js +++ b/scripts/helpers/handlebars.js @@ -12,8 +12,14 @@ export class HandlebarHelper { return o1 === o2; }); + Handlebars.registerHelper("simplerQuestsLastObjective", (list, id) => { + return list[list.length - 1].id === id; + }); + await loadTemplates({ trackerBody: "modules/simpler-quests/templates/tracker-body.hbs", + trackerObjective: + "modules/simpler-quests/templates/tracker-objective.hbs", }); } } diff --git a/scripts/ui/questeditor.js b/scripts/ui/questeditor.js index 7a6628e..38978a0 100644 --- a/scripts/ui/questeditor.js +++ b/scripts/ui/questeditor.js @@ -99,30 +99,6 @@ export class QuestEditor extends Application { } async getData(options = {}) { - // Create an empty collection of strings. - // This is to convert the objectives back into - // a text body for the user to interact with. - let strings = []; - - // Iterate through each objective to convert individual - // objectives into lines. - this.quest.objectives.forEach((o) => { - let line = ""; - // First, start by marking the quest as complete or failed if necessary. - if (o.state === objectiveState.COMPLETE) line += "+"; - else if (o.state === objectiveState.FAILED) line += "-"; - - // Mark the quest as a secret if needed. - if (o.secret) line += "/"; - line += o.text; - - // Push the completed line to the stack. - strings.push(line); - }); - - // Create the final text for the user to interact with. - const objectiveString = strings.join("\n"); - const viewStyle = this.quest.viewStyle || Settings.get(Settings.NAMES.QUEST_VIEW_STYLE); @@ -130,7 +106,7 @@ export class QuestEditor extends Application { return foundry.utils.mergeObject(super.getData(), { title: "Quest Editor Test", questTitle: this.quest.title, - objectives: objectiveString, + objectives: Quest.stringify(this.quest), visible: this.quest.visible, viewStyle: viewStyle, viewStyleHint: this.#viewStyles[viewStyle], diff --git a/scripts/ui/questtracker.js b/scripts/ui/questtracker.js index 1d2039f..9761e93 100644 --- a/scripts/ui/questtracker.js +++ b/scripts/ui/questtracker.js @@ -3,6 +3,7 @@ import { QuestDatabase } from "../data/database.js"; import { QuestEditor } from "./questeditor.js"; import { objectiveState } from "../helpers/constants.js"; import { Settings } from "../helpers/settings.js"; +import { Objective } from "../data/objective.js"; export class QuestTracker extends Application { #collapsed; @@ -154,10 +155,12 @@ export class QuestTracker extends Application { // Get the quest and objective ID const questId = evt.target.closest("[data-quest-id]").dataset.questId; - const objIndex = evt.target.dataset.index; + + // Get the objective index + const objId = evt.target.dataset.id; // Get the index of the quest objective. - if (!questId || objIndex === undefined) return; + if (!questId || objId === undefined) return; // Get the quest. let quest = QuestDatabase.getQuest(questId); @@ -165,22 +168,24 @@ export class QuestTracker extends Application { // Ensure the quest is a valid object. if (!quest) return; - // Progress the quest to the next state. Progression is cyclical. - if (quest.objectives.length > objIndex) { - if ( - quest.objectives[objIndex].state === - objectiveState.INCOMPLETE - ) - quest.objectives[objIndex].state = objectiveState.COMPLETE; - else if ( - quest.objectives[objIndex].state === objectiveState.COMPLETE - ) - quest.objectives[objIndex].state = objectiveState.FAILED; - else - quest.objectives[objIndex].state = - objectiveState.INCOMPLETE; + // This function will be used to recursively search for and update + // the selected quest objective. + function findAndUpdateState(obj, id) { + // If the current objective is the correct one, update it. + if (obj.id === id) { + obj.state = Objective.getNextState(obj.state); + } else if (obj.subs) { + // Iterate through subobjectives and attempt to update. + obj.subs.forEach((o) => { + findAndUpdateState(o, id); + }); + } } + quest.objectives.forEach((o) => { + findAndUpdateState(o, objId); + }); + // Update the quest and save. QuestDatabase.update(quest); QuestDatabase.save(); @@ -194,17 +199,31 @@ export class QuestTracker extends Application { // Get the quest and objective ID const questId = evt.target.closest("[data-quest-id]").dataset.questId; - const objIndex = evt.target.dataset.index; + const objId = evt.target.dataset.id; // If either ID is invalid, abort. - if (!questId || objIndex === undefined) return; + if (!questId || objId === undefined) return; // Get the quest let quest = QuestDatabase.getQuest(questId); - // Update the objective's secret status. - quest.objectives[objIndex].secret = - !quest.objectives[objIndex].secret; + // This function will be used to recursively search for and update + // the selected quest objective. + function findAndUpdateSecret(obj, id) { + // If the current objective is the correct one, update it. + if (obj.id === id) { + obj.secret = !obj.secret; + } else if (obj.subs) { + // Iterate through subobjectives and attempt to update. + obj.subs.forEach((o) => { + findAndUpdateSecret(o, id); + }); + } + } + + quest.objectives.forEach((o) => { + findAndUpdateSecret(o, objId); + }); // Update the database and save. QuestDatabase.update(quest); diff --git a/styles/tracker.css b/styles/tracker.css index cfaca3c..8104779 100644 --- a/styles/tracker.css +++ b/styles/tracker.css @@ -1,4 +1,5 @@ .tracker { + --dark-rail: var(--dark-text); display: flex; flex-direction: column; gap: 5px; @@ -88,19 +89,50 @@ } .simpler-quest-objective { + color: var(--dark-text); margin-left: 1rem; font-size: 0.9rem; } +.subobjective { + position: relative; +} + +.subobjective > .border-left { + position: absolute; + top: 0; + left: 0.4rem; + height: 0.5rem; + width: 0.5rem; +} +.subobjective > .border-down { + position: absolute; + top: 0.5rem; + left: 0.4rem; + bottom: 0; + width: 0.5rem; +} + .simpler-quest-objective.complete { - opacity: 0.5; text-decoration: line-through; } .simpler-quest-objective.failed { + --dark-rail: var(--dark-failed); + --dark-text: var(--dark-failed); color: var(--dark-failed); } .simpler-quest-objective.secret { + --dark-rail: var(--dark-secret); color: var(--dark-secret); } + +.subobjective > .border-left { + border-left: 1px solid var(--dark-rail); + border-bottom: 1px solid var(--dark-rail); +} + +.subobjective > .border-down { + border-left: 1px solid var(--dark-rail); +} diff --git a/templates/tracker-body.hbs b/templates/tracker-body.hbs index 78e1cbb..805271d 100644 --- a/templates/tracker-body.hbs +++ b/templates/tracker-body.hbs @@ -17,17 +17,7 @@ {{#if (simplerQuestIsActiveQuest ../activeQuests this.id)}} {{#each this.objectives}} -
- {{#if (simplerQuestsEquals this.state 1)}} - - {{else if (simplerQuestsEquals this.state -1)}} - - {{else}} - - {{/if}} - {{this.text}} -
+ {{> trackerObjective}} {{/each}} {{/if}} diff --git a/templates/tracker-objective.hbs b/templates/tracker-objective.hbs new file mode 100644 index 0000000..98ff447 --- /dev/null +++ b/templates/tracker-objective.hbs @@ -0,0 +1,23 @@ + +
+ {{#if (simplerQuestsEquals this.state 1)}} + + {{else if (simplerQuestsEquals this.state -1)}} + + {{else}} + + {{/if}} + {{this.text}} + {{#if this.subs}} +
+ {{#each this.subs}} +
+
+ {{#unless (simplerQuestsLastObjective ../subs this.id)}}
{{/unless}} + {{> trackerObjective}} +
+ {{/each}} +
+ {{/if}} +
\ No newline at end of file