Skip to content

Commit

Permalink
Merge pull request #552 from PrototypeESBU/npc
Browse files Browse the repository at this point in the history
New Feature: Monster Importer
  • Loading branch information
Muttley authored Oct 28, 2023
2 parents 95ccda2 + e5a7c5f commit 4bf7c04
Show file tree
Hide file tree
Showing 7 changed files with 362 additions and 0 deletions.
5 changes: 5 additions & 0 deletions data/macros/open-monster-importer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/**
****************************************************************
* This macro can be used to open the monster importer
***************************************************************/
shadowdark.macro.openMonsterImporter();
11 changes: 11 additions & 0 deletions data/packs/macros.db/open-monster-importer_sJTtbWtWigzBHf6N.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"_id": "sJTtbWtWigzBHf6N",
"_key": "!macros!sJTtbWtWigzBHf6N",
"author": "mu8H7NbWc0seFWcA",
"command": "// This opens the Monster Importer Tool\nshadowdark.macro.openMonsterImporter();",
"folder": null,
"img": "icons/magic/death/skull-energy-light-purple.webp",
"name": "Open Monster Importer",
"scope": "global",
"type": "script"
}
9 changes: 9 additions & 0 deletions i18n/en.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,15 @@ SHADOWDARK.apps.shadowdarkling-importer.instruction3: 3. Copy the content of fil
SHADOWDARK.apps.shadowdarkling-importer.instruction4: 4. Click import.
SHADOWDARK.apps.shadowdarkling-importer.open-generator: Open Generator
SHADOWDARK.apps.shadowdarkling-importer.title: Import Shadowdarkling
SHADOWDARK.apps.monster-importer.import_button: Import Monster
SHADOWDARK.apps.monster-importer.instruction1: 1. Copy monster text from source material.
SHADOWDARK.apps.monster-importer.instruction2a: 2. Paste text into this box following the monster format shown in the core rules. Add a blank line between each ability.
SHADOWDARK.apps.monster-importer.instruction2b: Monster Name
SHADOWDARK.apps.monster-importer.instruction2c: Flavor text
SHADOWDARK.apps.monster-importer.instruction2d: Main stat block
SHADOWDARK.apps.monster-importer.instruction2e: Feature
SHADOWDARK.apps.monster-importer.instruction3: 3. Click Import Monster.
SHADOWDARK.apps.monster-importer.title: Import Monster
SHADOWDARK.armor.properties.disadvantage_stealth: Disadvantage/Stealth
SHADOWDARK.armor.properties.disadvantage_swimming: Disadvantage/Swim
SHADOWDARK.armor.properties.no_swimming: No Swim
Expand Down
301 changes: 301 additions & 0 deletions system/src/apps/MonsterImporterSD.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,301 @@
export default class MonsterImporterSD extends FormApplication {
/**
* Contains an importer function to import monster stat blocks
*/

/** @inheritdoc */
static get defaultOptions() {
return mergeObject(super.defaultOptions, {
classes: ["monster-importer"],
width: 300,
resizable: false,
});
}

/** @inheritdoc */
get template() {
return "systems/shadowdark/templates/apps/monster-importer.hbs";
}

/** @inheritdoc */
get title() {
const title = game.i18n.localize("SHADOWDARK.apps.monster-importer.title");
return `${title}`;
}

/** @inheritdoc */
async _updateObject(event, formData) {
event.preventDefault();
try {
let newNPC = await this._importMonster(formData.monsterText);
ui.notifications.info(`Successfully Created: ${newNPC.name} [${newNPC._id}]`);
ui.sidebar.activateTab("actors");
return;
}
catch(error) {
ui.notifications.error(`Failed to fully parse the monster stat block. ${error}`);
}
}

/** @inheritdoc */
_onSubmit(event) {
event.preventDefault();
super._onSubmit(event);
}

_toTitleCase(str) {
return str.replace(/\w\S*/g, m => m.charAt(0).toUpperCase() + m.substr(1).toLowerCase());
}

_toCamelCase(str) {
return str.toLowerCase().replace(/[^a-zA-Z0-9]+(.)/g, (m, chr) => chr.toUpperCase());
}

/**
* Parses an NPC movement string and returns an obj listing the movement type and range
* @param {string} str
* @returns {moveObj}
*/
_parseMovement(str) {
let moveObj ={
type: "",
notes: "",
};
let parsedMove = str.match(/([\s\w]*)(?:\(([\s\w]*)\))?/);
moveObj.type = this._toCamelCase(parsedMove[1].trim());

// makes sure the string is a valid move type
if (!(moveObj.type in CONFIG.SHADOWDARK.NPC_MOVES)) {
moveObj.type = "";
}

// if there are () in the move string copy to move notes
if (typeof parsedMove[2] !== "undefined") {
moveObj.notes = parsedMove[2];
}

return moveObj;
}

/**
* Parses an NPC attack string and returns an item obj representing that attack
* @param {string} str
* @returns {attackObj}
*/
_parseAttack(str) {
const atk = str.match([
/(\d*)\s*/, // atk[1] matches # of attacks
/([\w\s\d]*)/, // atk[2] matches attack name
/(?:\(([^)]*)\))?\s*/, // atk[3] matches attack range
/([+-]\d*)?\s*/, // atk[4] matches attack bonus
/(?:\((.*)\))?/, // atk[5] matches damage string
].map(function(r) {
return r.source;
}).join(""));

let attackObj = {
name: atk[2].trim(),
type: "NPC Attack",
system: {
attack: {
num: atk[1],
},
attackType: "special",
bonuses: {
attackBonus: 0,
damageBonus: 0,
},
damage: {
numDice: 0,
value: "",
special: "",
},
},
};

// Validate Attack ranges and add
if (typeof atk[3] !== "undefined") {
let rangeArray = [];
atk[3].split(/\/|,/).forEach( x => {
let range = this._toCamelCase(x);
if (range in CONFIG.SHADOWDARK.RANGES) {
rangeArray.push(range);
}
});
attackObj.system.ranges = rangeArray;
}

// Add Hit bonus if any
if (typeof atk[4] !== "undefined") {
attackObj.system.bonuses.attackBonus = parseInt(atk[4]);
}

// Attack is a phyical attack if damage exists
if (typeof atk[5] !== "undefined") {
attackObj.system.attackType = "physical";

// split up damage string and parse # of dice, dice type, bonuses, features
const dmgStrs = atk[5].split("+").map( x => {
return x.trim();
});
// parse first object as # dice and dice type
const diceStr = dmgStrs[0].match(/(\d*)(d\d*)?/);
if (typeof diceStr[2] !== "undefined") {
attackObj.system.damage.numDice = diceStr[1];
attackObj.system.damage.value = diceStr[2];
}
else {
// TODO no way to set static damage: attackObj.system.damage.value = diceStr[1]
}

// parse remaining string parts for +dmg or feature
for (let i = 1; i < dmgStrs.length; i++) {
if (parseInt(dmgStrs[i])) {
attackObj.system.bonuses.damageBonus = parseInt(dmgStrs[i]);
}
else {
attackObj.system.damage.special = this._toTitleCase(dmgStrs[i]);
}
}
}

return attackObj;
}

/**
* Parses an NPC feature string and returns an obj
* @param {string} str
* @returns {featureObj}
*/
_parseFeature(str) {
const featureStr = str.match(/([^.]*)\.(?:\s*)?(.*)/);
const featureObj = {
name: this._toTitleCase(featureStr[1]),
type: "NPC Feature",
system: {
description: `<p>${featureStr[2]}</p>`,
predefinedEffects: "",
},
};
return featureObj;
}

/**
* Parses pasted text representing a monster and creates an NPC actor from it.
* @param {string} string - String data posted by user
* @returns {ActorSD}
*/
async _importMonster(monsterText) {

// parse monster text into 4 main parts:
const parsedText = monsterText.match([
/(.*)\n/, // parsedText[1] matches Title
/([\S\s]*)\n/, // parsedText[2] matches flavor Text
/(AC \d*[\S\s]*LV \d*)(?:\n|$)/, // parsedText[3] matches Stat Block
/([\S\s]*)?/, // parsedText[4] matches features
].map(function(r) {
return r.source;
}).join(""));

// set 4 main variables, removing newlines
const titleName = this._toTitleCase(parsedText[1]);
const flavorText = parsedText[2].replace(/(\r\n|\n|\r)/gm, " ");
const statBlock = parsedText[3].replace(/(\r\n|\n|\r)/gm, " ");
let features = [];
if (typeof parsedText[4] !== "undefined") {
features = parsedText[4].split(/\n\s*\n/).map( x => x.replace(/(\r\n|\n|\r)/gm, " "));
}
// parse out main stat block
const stats = statBlock.match([
/.*AC (\d*)/, // stats[1] matches AC
/.*HP (\d*)/, // stats[2] matches HP
/.*ATK (.*),/, // stats[3] matches unparsed ATK
/.*MV (.*),/, // stats[4] matches unparsed MV
/.*S ([-+]\d*),/, // stats[5] matches STR
/.*D ([-+]\d*),/, // stats[6] matches DEX
/.*C ([-+]\d*),/, // stats[7] matches CON
/.*I ([-+]\d*),/, // stats[8] matches INT
/.*W ([-+]\d*),/, // stats[9] matches WIS
/.*Ch ([-+]\d*),/, // stats[10] matches CHA
/.*AL (\w),/, // stats[11] matches AL (single letter)
/.*LV (\d*)/, // stats[12] matches LV
].map(function(r) {
return r.source;
}).join(""));

// build parse complex outputs
const alignments = {L: "lawful", N: "neutral", C: "chaotic"};
const movement = this._parseMovement(stats[4]);
const notesText = `
<p><i>${flavorText}</i></p><br>
<p>${statBlock}</p><br>
<p>${features.join("<br><br>")}</p>`;

// create the monster template
let actorObj = {
name: titleName,
img: "systems/shadowdark/assets/tokens/cowled_token.webp",
type: "NPC",
system: {
alignment: alignments[stats[11].toUpperCase()],
attributes: {
ac: {
value: stats[1],
},
hp: {
max: stats[2],
value: stats[2],
hd: 0,
},
},
level: {
value: stats[12],
},
notes: notesText,
abilities: {
str: {
mod: parseInt(stats[5]),
},
int: {
mod: parseInt(stats[8]),
},
dex: {
mod: parseInt(stats[6]),
},
wis: {
mod: parseInt(stats[9]),
},
con: {
mod: parseInt(stats[7]),
},
cha: {
mod: parseInt(stats[10]),
},
},
darkAdapted: true,
move: movement.type,
moveNote: movement.notes,
spellcastingAbility: "",
},
};

// Create the NPC actor
const newActor = await Actor.create(actorObj);

// Parse attacks and add to actor
let attackArray = [];
stats[3].split(/ and | or /).forEach( line => {
attackArray.push(this._parseAttack(line));
});
await newActor.createEmbeddedDocuments("Item", attackArray);

// Parse features and add to actor
let featureArray = [];
features.forEach( text => {
featureArray.push(this._parseFeature(text));
});
await newActor.createEmbeddedDocuments("Item", featureArray);

return newActor;
}
}
2 changes: 2 additions & 0 deletions system/src/apps/_module.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,5 @@ export {default as GemBagSD} from "./GemBagSD.mjs";
export {default as LightSourceTrackerSD} from "./LightSourceTrackerSD.mjs";

export {default as ShadowdarklingImporterSD} from "./ShadowdarklingImporterSD.mjs";

export {default as MonsterImporterSD} from "./MonsterImporterSD.mjs";
4 changes: 4 additions & 0 deletions system/src/macro.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,8 @@ export default class ShadowdarkMacro {
}
}
}

static async openMonsterImporter() {
new shadowdark.apps.MonsterImporterSD().render(true);
}
}
30 changes: 30 additions & 0 deletions system/templates/apps/monster-importer.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<form class="monster-importer" autocomplete="off">
<section>
<div>
<p>{{localize "SHADOWDARK.apps.monster-importer.instruction1"}}</p>
<hr />
<p>

{{localize "SHADOWDARK.apps.monster-importer.instruction2a"}}<br>
<div style="border:1px solid black;background-color: lightgrey; font-size:0.65em;margin-left:50px;margin-right:50px;">
{{localize "SHADOWDARK.apps.monster-importer.instruction2b"}}<br>
<i>{{localize "SHADOWDARK.apps.monster-importer.instruction2c"}}</i><br>
{{localize "SHADOWDARK.apps.monster-importer.instruction2d"}}<br>
{{localize "SHADOWDARK.apps.monster-importer.instruction2e"}} 1 <br>
<br>
{{localize "SHADOWDARK.apps.monster-importer.instruction2e"}} 2 <br>
<br>
{{localize "SHADOWDARK.apps.monster-importer.instruction2e"}} N
</div>
</p>
<textarea name="monsterText" style="height:300px;"></textarea>
<hr />
<p>{{localize "SHADOWDARK.apps.monster-importer.instruction3"}}</p>
</div>
</section>
<footer class="sheet-footer flexrow">
<button class="import">
{{localize "SHADOWDARK.apps.monster-importer.import_button"}}
</button>
</footer>
</form>

0 comments on commit 4bf7c04

Please sign in to comment.