Files
5etools-mirror-2.github.io/js/bestiary.js
TheGiddyLimit 99cb60d804 v1.206.0
2024-04-23 23:14:25 +01:00

993 lines
34 KiB
JavaScript

import {EncounterBuilderCacheBestiaryPage} from "./bestiary/bestiary-encounterbuilder-cache.js";
import {EncounterBuilderComponentBestiary} from "./bestiary/bestiary-encounterbuilder-component.js";
import {EncounterBuilderUiBestiary} from "./bestiary/bestiary-encounterbuilder-ui.js";
import {EncounterBuilderSublistPlugin} from "./bestiary/bestiary-encounterbuilder-sublistplugin.js";
class _BestiaryConsts {
static PROF_MODE_BONUS = "bonus";
static PROF_MODE_DICE = "dice";
}
class _BestiaryUtil {
static getUrlSubhashes (mon, {isAddLeadingSep = true} = {}) {
const subhashesRaw = [
mon._isScaledCr ? `${UrlUtil.HASH_START_CREATURE_SCALED}${mon._scaledCr}` : null,
mon._summonedBySpell_level ? `${UrlUtil.HASH_START_CREATURE_SCALED_SPELL_SUMMON}${mon._summonedBySpell_level}` : null,
mon._summonedByClass_level ? `${UrlUtil.HASH_START_CREATURE_SCALED_CLASS_SUMMON}${mon._summonedByClass_level}` : null,
].filter(Boolean);
if (!subhashesRaw.length) return "";
return `${isAddLeadingSep ? HASH_PART_SEP : ""}${subhashesRaw.join(HASH_PART_SEP)}`;
}
static getListDisplayType (mon) {
let type = mon._pTypes.asTextShort.uppercaseFirst();
if (mon._pTypes.asTextSidekick) type += `, ${mon._pTypes.asTextSidekick}`;
return type;
}
}
class BestiarySublistManager extends SublistManager {
constructor () {
super({
sublistListOptions: {
fnSort: PageFilterBestiary.sortMonsters,
},
shiftCountAddSubtract: 5,
isSublistItemsCountable: true,
});
this._$dispCrTotal = null;
this._encounterBuilder = null;
}
set encounterBuilder (val) { this._encounterBuilder = val; }
_getCustomHashId ({entity}) {
return Renderer.monster.getCustomHashId(entity);
}
_getSerializedPinnedItemData (listItem) {
return {l: listItem.data.isLocked ? listItem.data.isLocked : undefined};
}
_getDeserializedPinnedItemData (serialData) {
return {isLocked: !!serialData.l};
}
_onSublistChange () {
this._$dispCrTotal = this._$dispCrTotal || $(`#totalcr`);
this._encounterBuilder.onSublistChange({$dispCrTotal: this._$dispCrTotal});
}
_getSublistFullHash ({entity}) {
return `${super._getSublistFullHash({entity})}${_BestiaryUtil.getUrlSubhashes(entity)}`;
}
static get _ROW_TEMPLATE () {
return [
new SublistCellTemplate({
name: "Name",
css: "bold ve-col-5 pl-0",
colStyle: "",
}),
new SublistCellTemplate({
name: "Type",
css: "ve-col-3-8",
colStyle: "",
}),
new SublistCellTemplate({
name: "CR",
css: "ve-col-1-2 ve-text-center",
colStyle: "text-center",
}),
new SublistCellTemplate({
name: "Number",
css: "ve-col-2 ve-text-center",
colStyle: "text-center",
}),
];
}
async pGetSublistItem (mon, hash, {count = 1, customHashId = null, initialData} = {}) {
const name = mon._displayName || mon.name;
const type = _BestiaryUtil.getListDisplayType(mon);
const cr = mon._pCr;
const hashBase = UrlUtil.URL_TO_HASH_BUILDER[UrlUtil.PG_BESTIARY](mon);
const isLocked = !!initialData?.isLocked; // If e.g. reloading from a save
const cellsText = [name, type, cr];
const $hovStatblock = $(`<span class="ve-col-1-4 help help--hover best-ecgen__visible">Stat Block</span>`)
.mouseover(evt => this._encounterBuilder.doStatblockMouseOver({
evt,
ele: $hovStatblock[0],
source: mon.source,
hash: hashBase,
customHashId: this._getCustomHashId({entity: mon}),
}))
.mousemove(evt => Renderer.hover.handleLinkMouseMove(evt, $hovStatblock[0]))
.mouseleave(evt => Renderer.hover.handleLinkMouseLeave(evt, $hovStatblock[0]));
const hovTokenMeta = EncounterBuilderUiBestiary.getTokenHoverMeta(mon);
const $hovToken = !hovTokenMeta ? $(`<span class="ve-col-1-2 best-ecgen__visible"></span>`) : $(`<span class="ve-col-1-2 best-ecgen__visible help help--hover">Token</span>`)
.mouseover(evt => hovTokenMeta.mouseOver(evt, $hovToken[0]))
.mousemove(evt => hovTokenMeta.mouseMove(evt, $hovToken[0]))
.mouseleave(evt => hovTokenMeta.mouseLeave(evt, $hovToken[0]));
const $hovImage = $(`<span class="ve-col-1-2 best-ecgen__visible help help--hover">Image</span>`);
Renderer.monster.hover.bindFluffImageMouseover({mon, $ele: $hovImage});
const $ptCr = (() => {
if (!ScaleCreature.isCrInScaleRange(mon)) return $(`<span class="ve-col-1-2 ve-text-center">${cr}</span>`);
const $iptCr = $(`<input value="${cr}" class="w-100 ve-text-center form-control form-control--minimal input-xs">`)
.click(() => $iptCr.select())
.change(() => this._encounterBuilder.pDoCrChange($iptCr, mon, mon._scaledCr));
return $$`<span class="ve-col-1-2 ve-text-center">${$iptCr}</span>`;
})();
const $eleCount1 = $(`<span class="ve-col-2 ve-text-center">${count}</span>`);
const $eleCount2 = $(`<span class="ve-col-2 pr-0 ve-text-center">${count}</span>`);
const listItem = new ListItem(
hash,
null,
name,
{
hash,
source: Parser.sourceJsonToAbv(mon.source),
type,
cr,
page: mon.page,
},
{
count,
customHashId,
isLocked,
$elesCount: [$eleCount1, $eleCount2],
fnsUpdate: [],
entity: mon,
entityBase: await DataLoader.pCacheAndGetHash(
UrlUtil.PG_BESTIARY,
hashBase,
),
mdRow: [...cellsText, ({listItem}) => listItem.data.count],
},
);
const sublistButtonsMeta = this._encounterBuilder.getSublistButtonsMeta(listItem);
listItem.data.fnsUpdate.push(sublistButtonsMeta.fnUpdate);
listItem.ele = $$`<div class="lst__row lst__row--sublist ve-flex-col lst__row--bestiary-sublist">
<a href="#${hash}" draggable="false" class="best-ecgen__hidden lst--border lst__row-inner">
${this.constructor._getRowCellsHtml({values: cellsText, templates: this.constructor._ROW_TEMPLATE.slice(0, 3)})}
${$eleCount1}
</a>
<div class="lst__wrp-cells best-ecgen__visible--flex lst--border lst__row-inner">
${sublistButtonsMeta.$wrp}
<span class="best-ecgen__name--sub ve-col-3-5">${name}</span>
${$hovStatblock}
${$hovToken}
${$hovImage}
${$ptCr}
${$eleCount2}
</div>
</div>`
.contextmenu(evt => this._handleSublistItemContextMenu(evt, listItem))
.click(evt => this._handleBestiaryLinkClickSub(evt, listItem));
return listItem;
}
_handleBestiaryLinkClickSub (evt, listItem) {
if (this._encounterBuilder.isActive()) evt.preventDefault();
else this._listSub.doSelect(listItem, evt);
}
}
class BestiaryPageBookView extends ListPageBookView {
constructor (opts) {
super({
namePlural: "creatures",
pageTitle: "Bestiary Printer View",
...opts,
});
}
async _$pGetWrpControls ({$wrpContent}) {
const out = await super._$pGetWrpControls({$wrpContent});
const {$wrpPrint} = out;
// region Markdown
// TODO refactor this and spell markdown section
const pGetAsMarkdown = async () => {
const toRender = this._bookViewToShow.length ? this._bookViewToShow : [this._fnGetEntLastLoaded()];
return RendererMarkdown.monster.pGetMarkdownDoc(toRender);
};
const $btnDownloadMarkdown = $(`<button class="btn btn-default btn-sm">Download as Markdown</button>`)
.click(async () => DataUtil.userDownloadText("bestiary.md", await pGetAsMarkdown()));
const $btnCopyMarkdown = $(`<button class="btn btn-default btn-sm px-2" title="Copy Markdown to Clipboard"><span class="glyphicon glyphicon-copy"/></button>`)
.click(async () => {
await MiscUtil.pCopyTextToClipboard(await pGetAsMarkdown());
JqueryUtil.showCopiedEffect($btnCopyMarkdown);
});
const $btnDownloadMarkdownSettings = $(`<button class="btn btn-default btn-sm px-2" title="Markdown Settings"><span class="glyphicon glyphicon-cog"/></button>`)
.click(async () => RendererMarkdown.pShowSettingsModal());
$$`<div class="ve-flex-v-center btn-group ml-2">
${$btnDownloadMarkdown}
${$btnCopyMarkdown}
${$btnDownloadMarkdownSettings}
</div>`.appendTo($wrpPrint);
// endregion
return out;
}
async _pGetRenderContentMeta ({$wrpContent}) {
this._bookViewToShow = this._sublistManager.getPinnedEntities()
.sort(this._getSorted.bind(this));
let cntSelectedEnts = 0;
let isAnyEntityRendered = false;
const stack = [];
const renderCreature = (mon) => {
isAnyEntityRendered = true;
stack.push(`<div class="bkmv__wrp-item ve-inline-block print__ve-block print__my-2"><table class="w-100 stats stats--book stats--bkmv"><tbody>`);
stack.push(Renderer.monster.getCompactRenderedString(mon));
stack.push(`</tbody></table></div>`);
};
this._bookViewToShow.forEach(mon => renderCreature(mon));
if (!this._bookViewToShow.length && Hist.lastLoadedId != null) {
renderCreature(this._fnGetEntLastLoaded());
}
cntSelectedEnts += this._bookViewToShow.length;
$wrpContent.append(stack.join(""));
return {cntSelectedEnts, isAnyEntityRendered};
}
_getSorted (a, b) {
return SortUtil.ascSort(a._displayName || a.name, b._displayName || b.name);
}
}
class BestiaryPage extends ListPageMultiSource {
static async _prereleaseBrewDataSource ({brewUtil}) {
const brew = await brewUtil.pGetBrewProcessed();
DataUtil.monster.populateMetaReference(brew);
return brew;
}
static _tableView_getEntryPropTransform ({mon, fnGet}) {
const fnGetSpellTraits = Renderer.monster.getSpellcastingRenderedTraits.bind(Renderer.monster, Renderer.get());
const allEntries = fnGet(mon, {fnGetSpellTraits});
return (allEntries || []).map(it => it.rendered || Renderer.get().render(it, 2)).join("");
}
constructor () {
const pFnGetFluff = Renderer.monster.pGetFluff.bind(Renderer.monster);
super({
pageFilter: new PageFilterBestiary(),
listOptions: {
fnSort: PageFilterBestiary.sortMonsters,
},
dataProps: ["monster"],
prereleaseDataSource: () => BestiaryPage._prereleaseBrewDataSource({brewUtil: PrereleaseUtil}),
brewDataSource: () => BestiaryPage._prereleaseBrewDataSource({brewUtil: BrewUtil2}),
pFnGetFluff,
hasAudio: true,
bookViewOptions: {
ClsBookView: BestiaryPageBookView,
},
tableViewOptions: {
title: "Bestiary",
colTransforms: {
name: UtilsTableview.COL_TRANSFORM_NAME,
source: UtilsTableview.COL_TRANSFORM_SOURCE,
page: UtilsTableview.COL_TRANSFORM_PAGE,
size: {name: "Size", transform: size => Renderer.utils.getRenderedSize(size)},
type: {name: "Type", transform: type => Parser.monTypeToFullObj(type).asText},
alignment: {name: "Alignment", transform: align => Parser.alignmentListToFull(align)},
ac: {name: "AC", transform: ac => Parser.acToFull(ac)},
hp: {name: "HP", transform: hp => Renderer.monster.getRenderedHp(hp)},
_speed: {name: "Speed", transform: mon => Parser.getSpeedString(mon)},
...Parser.ABIL_ABVS.mergeMap(ab => ({[ab]: {name: Parser.attAbvToFull(ab)}})),
_save: {name: "Saving Throws", transform: mon => Renderer.monster.getSavesPart(mon)},
_skill: {name: "Skills", transform: mon => Renderer.monster.getSkillsString(Renderer.get(), mon)},
vulnerable: {name: "Damage Vulnerabilities", transform: it => Parser.getFullImmRes(it)},
resist: {name: "Damage Resistances", transform: it => Parser.getFullImmRes(it)},
immune: {name: "Damage Immunities", transform: it => Parser.getFullImmRes(it)},
conditionImmune: {name: "Condition Immunities", transform: it => Parser.getFullCondImm(it)},
_senses: {name: "Senses", transform: mon => Renderer.monster.getSensesPart(mon)},
languages: {name: "Languages", transform: it => Renderer.monster.getRenderedLanguages(it)},
_cr: {name: "CR", transform: mon => Parser.monCrToFull(mon.cr, {isMythic: !!mon.mythic})},
_trait: {
name: "Traits",
transform: mon => BestiaryPage._tableView_getEntryPropTransform({mon, fnGet: Renderer.monster.getOrderedTraits}),
flex: 3,
},
_action: {
name: "Actions",
transform: mon => BestiaryPage._tableView_getEntryPropTransform({mon, fnGet: Renderer.monster.getOrderedActions}),
flex: 3,
},
_bonus: {
name: "Bonus Actions",
transform: mon => BestiaryPage._tableView_getEntryPropTransform({mon, fnGet: Renderer.monster.getOrderedBonusActions}),
flex: 3,
},
_reaction: {
name: "Reactions",
transform: mon => BestiaryPage._tableView_getEntryPropTransform({mon, fnGet: Renderer.monster.getOrderedReactions}),
flex: 3,
},
legendary: {name: "Legendary Actions", transform: it => (it || []).map(x => Renderer.get().render(x, 2)).join(""), flex: 3},
mythic: {name: "Mythic Actions", transform: it => (it || []).map(x => Renderer.get().render(x, 2)).join(""), flex: 3},
_lairActions: {
name: "Lair Actions",
transform: mon => {
const legGroup = DataUtil.monster.getMetaGroup(mon);
if (!legGroup?.lairActions?.length) return "";
return Renderer.get().render({entries: legGroup.lairActions});
},
flex: 3,
},
_regionalEffects: {
name: "Regional Effects",
transform: mon => {
const legGroup = DataUtil.monster.getMetaGroup(mon);
if (!legGroup?.regionalEffects?.length) return "";
return Renderer.get().render({entries: legGroup.regionalEffects});
},
flex: 3,
},
environment: {name: "Environment", transform: it => Renderer.monster.getRenderedEnvironment(it)},
},
},
isMarkdownPopout: true,
propEntryData: "monster",
propLoader: "monster",
listSyntax: new ListSyntaxBestiary({fnGetDataList: () => this._dataList, pFnGetFluff}),
});
this._$wrpBtnProf = null;
this._$btnProf = null;
this._profDiceMode = null;
this._encounterBuilder = null;
this._$dispToken = null;
}
get _bindOtherButtonsOptions () {
return {
upload: {
pFnPreLoad: (...args) => this._pPreloadSublistSources(...args),
},
sendToBrew: {
mode: "creatureBuilder",
fnGetMeta: () => ({
page: UrlUtil.getCurrentPage(),
source: Hist.getHashSource(),
hash: `${UrlUtil.autoEncodeHash(this._lastRender.entity)}${_BestiaryUtil.getUrlSubhashes(this._lastRender.entity)}`,
}),
},
other: [
this._bindOtherButtonsOptions_openAsSinglePage({slugPage: "bestiary", fnGetHash: () => UrlUtil.autoEncodeHash(this._lastRender.entity)}),
].filter(Boolean),
};
}
set encounterBuilder (val) { this._encounterBuilder = val; }
get list_ () { return this._list; }
getListItem (mon, mI) {
const hash = UrlUtil.autoEncodeHash(mon);
if (this._seenHashes.has(hash)) return null;
this._seenHashes.add(hash);
Renderer.monster.updateParsed(mon);
const isExcluded = ExcludeUtil.isExcluded(hash, "monster", mon.source);
this._pageFilter.mutateAndAddToFilters(mon, isExcluded);
const source = Parser.sourceJsonToAbv(mon.source);
const type = _BestiaryUtil.getListDisplayType(mon);
const cr = mon._pCr;
const eleLi = e_({
tag: "div",
clazz: `lst__row ve-flex-col ${isExcluded ? "lst__row--blocklisted" : ""}`,
click: (evt) => this._handleBestiaryLiClick(evt, listItem),
contextmenu: (evt) => this._handleBestiaryLiContext(evt, listItem),
children: [
e_({
tag: "a",
href: `#${hash}`,
clazz: "lst--border lst__row-inner",
click: evt => this._handleBestiaryLinkClick(evt),
children: [
this._encounterBuilder.getButtons(mI),
e_({tag: "span", clazz: `best-ecgen__name bold ve-col-4-2 pl-0`, text: mon.name}),
e_({tag: "span", clazz: `ve-col-4-1`, text: type}),
e_({tag: "span", clazz: `ve-col-1-7 ve-text-center`, text: cr}),
e_({
tag: "span",
clazz: `ve-col-2 ve-text-center ${Parser.sourceJsonToColor(mon.source)} pr-0`,
style: Parser.sourceJsonToStylePart(mon.source),
title: `${Parser.sourceJsonToFull(mon.source)}${Renderer.utils.getSourceSubText(mon)}`,
text: source,
}),
],
}),
],
});
const listItem = new ListItem(
mI,
eleLi,
mon.name,
{
hash,
source,
type,
cr,
group: mon.group ? [mon.group].flat().join(",") : "",
alias: (mon.alias || []).map(it => `"${it}"`).join(","),
page: mon.page,
},
{
isExcluded,
},
);
return listItem;
}
handleFilterChange () {
super.handleFilterChange();
this._encounterBuilder.resetCache();
}
async _pDoLoadHash ({id, lockToken}) {
const mon = this._dataList[id];
this._renderStatblock(mon);
await this._pDoLoadSubHash({sub: [], lockToken});
this._updateSelected();
}
async _pDoLoadSubHash ({sub, lockToken}) {
sub = await super._pDoLoadSubHash({sub, lockToken});
const scaledHash = sub.find(it => it.startsWith(UrlUtil.HASH_START_CREATURE_SCALED));
const scaledSpellSummonHash = sub.find(it => it.startsWith(UrlUtil.HASH_START_CREATURE_SCALED_SPELL_SUMMON));
const scaledClassSummonHash = sub.find(it => it.startsWith(UrlUtil.HASH_START_CREATURE_SCALED_CLASS_SUMMON));
const mon = this._dataList[Hist.lastLoadedId];
if (scaledHash) {
const scaleTo = Number(UrlUtil.unpackSubHash(scaledHash)[VeCt.HASH_SCALED][0]);
const scaleToStr = Parser.numberToCr(scaleTo);
if (Parser.isValidCr(scaleToStr) && scaleTo !== Parser.crToNumber(this._lastRender.entity.cr)) {
ScaleCreature.scale(mon, scaleTo)
.then(monScaled => this._renderStatblock(monScaled, {isScaledCr: true}));
}
} else if (scaledSpellSummonHash) {
const scaleTo = Number(UrlUtil.unpackSubHash(scaledSpellSummonHash)[VeCt.HASH_SCALED_SPELL_SUMMON][0]);
if (mon.summonedBySpellLevel != null && scaleTo >= mon.summonedBySpellLevel && scaleTo !== this._lastRender.entity._summonedBySpell_level) {
ScaleSpellSummonedCreature.scale(mon, scaleTo)
.then(monScaled => this._renderStatblock(monScaled, {isScaledSpellSummon: true}));
}
} else if (scaledClassSummonHash) {
const scaleTo = Number(UrlUtil.unpackSubHash(scaledClassSummonHash)[VeCt.HASH_SCALED_CLASS_SUMMON][0]);
if (mon.summonedByClass != null && scaleTo > 0 && scaleTo !== this._lastRender.entity._summonedByClass_level) {
ScaleClassSummonedCreature.scale(mon, scaleTo)
.then(monScaled => this._renderStatblock(monScaled, {isScaledClassSummon: true}));
}
}
this._encounterBuilder.handleSubhash(sub);
}
async _pOnLoad_pPreDataLoad () {
this._encounterBuilder.initUi();
await DataUtil.monster.pPreloadMeta();
this._bindProfDiceHandlers();
}
async _pOnLoad_pPreDataAdd () {
await this._pPageInit_pProfBonusDiceToggle();
}
_pOnLoad_pPostLoad () {
this._encounterBuilder.render();
}
async _pPageInit_pProfBonusDiceToggle () {
const $btnProfBonusDice = $("button#profbonusdice");
this._profDiceMode = await StorageUtil.pGetForPage("proficiencyDiceMode") || _BestiaryConsts.PROF_MODE_BONUS;
const hk = () => {
$btnProfBonusDice.text(this._profDiceMode === _BestiaryConsts.PROF_MODE_DICE ? "Use Proficiency Bonus" : "Use Proficiency Dice");
this._$pgContent.attr("data-proficiency-dice-mode", this._profDiceMode);
StorageUtil.pSetForPage("proficiencyDiceMode", this._profDiceMode).then(null);
};
$btnProfBonusDice.click(() => {
if (this._profDiceMode === _BestiaryConsts.PROF_MODE_DICE) {
this._profDiceMode = _BestiaryConsts.PROF_MODE_BONUS;
hk();
return;
}
this._profDiceMode = _BestiaryConsts.PROF_MODE_DICE;
hk();
});
hk();
}
_handleBestiaryLiClick (evt, listItem) {
if (this._encounterBuilder.isActive()) Renderer.hover.doPopoutCurPage(evt, this._dataList[listItem.ix]);
else this._list.doSelect(listItem, evt);
}
_handleBestiaryLiContext (evt, listItem) {
this._openContextMenu(evt, this._list, listItem);
}
_handleBestiaryLinkClick (evt) {
if (this._encounterBuilder.isActive()) evt.preventDefault();
}
_bindProfDiceHandlers () {
this._$pgContent
.on(`mousedown`, `[data-roll-prof-type]`, evt => {
if (this._profDiceMode !== _BestiaryConsts.PROF_MODE_BONUS) evt.preventDefault();
})
.on(`click`, `[data-roll-prof-type]`, evt => {
const parent = evt.currentTarget.closest(`[data-roll-prof-type]`);
const type = parent?.dataset?.rollProfType;
if (!type) return;
switch (type) {
case "d20": {
if (this._profDiceMode === _BestiaryConsts.PROF_MODE_BONUS) return;
evt.stopPropagation();
evt.preventDefault();
const cpyOriginalEntry = JSON.parse(parent.dataset.packedDice);
cpyOriginalEntry.toRoll = `d20${parent.dataset.rollProfDice}`;
cpyOriginalEntry.d20mod = parent.dataset.rollProfDice;
Renderer.dice.pRollerClick(evt, parent, JSON.stringify(cpyOriginalEntry));
break;
}
case "dc": {
if (this._profDiceMode === _BestiaryConsts.PROF_MODE_BONUS) {
evt.stopPropagation();
evt.preventDefault();
return;
}
const fauxEntry = Renderer.utils.getTagEntry(`@d20`, parent.dataset.rollProfDice);
Renderer.dice.pRollerClick(evt, parent, JSON.stringify(fauxEntry));
break;
}
default: throw new Error(`Unhandled roller type "${type}"`);
}
});
}
_renderStatblock (mon, {isScaledCr = false, isScaledSpellSummon = false, isScaledClassSummon = false} = {}) {
this._lastRender.entity = mon;
this._lastRender.isScaledCr = isScaledCr;
this._lastRender.isScaledSpellSummon = isScaledSpellSummon;
this._lastRender.isScaledClassSummon = isScaledClassSummon;
this._$wrpBtnProf = this._$wrpBtnProf || $(`#wrp-profbonusdice`);
this._$dispToken = this._$dispToken || $(`#float-token`);
this._$pgContent.empty();
if (this._$btnProf != null) {
this._$wrpBtnProf.append(this._$btnProf);
this._$btnProf = null;
}
const tabMetaStats = new Renderer.utils.TabButton({
label: "Stat Block",
fnChange: () => {
this._$wrpBtnProf.append(this._$btnProf);
this._$dispToken.showVe();
},
fnPopulate: () => this._renderStatblock_doBuildStatsTab({mon, isScaledCr, isScaledSpellSummon, isScaledClassSummon}),
isVisible: true,
});
Renderer.utils.bindTabButtons({
tabButtons: [tabMetaStats],
tabLabelReference: [tabMetaStats].map(it => it.label),
$wrpTabs: this._$wrpTabs,
$pgContent: this._$pgContent,
});
Promise.all([
Renderer.utils.pHasFluffText(mon, "monsterFluff"),
Renderer.utils.pHasFluffImages(mon, "monsterFluff"),
])
.then(([hasFluffText, hasFluffImages]) => {
if (!hasFluffText && !hasFluffImages) return;
if (this._lastRender.entity !== mon) return;
const tabMetas = [
tabMetaStats,
new Renderer.utils.TabButton({
label: "Info",
fnChange: () => {
this._$btnProf = this._$wrpBtnProf.children().length ? this._$wrpBtnProf.children().detach() : this._$btnProf;
this._$dispToken.hideVe();
},
fnPopulate: () => this._renderStats_doBuildFluffTab({ent: mon}),
isVisible: hasFluffText,
}),
new Renderer.utils.TabButton({
label: "Images",
fnChange: () => {
this._$btnProf = this._$wrpBtnProf.children().length ? this._$wrpBtnProf.children().detach() : this._$btnProf;
this._$dispToken.hideVe();
},
fnPopulate: () => this._renderStats_doBuildFluffTab({ent: mon, isImageTab: true}),
isVisible: hasFluffImages,
}),
];
Renderer.utils.bindTabButtons({
tabButtons: tabMetas.filter(it => it.isVisible),
tabLabelReference: tabMetas.map(it => it.label),
$wrpTabs: this._$wrpTabs,
$pgContent: this._$pgContent,
});
});
}
_renderStatblock_doBuildStatsTab (
{
mon,
isScaledCr,
isScaledSpellSummon,
isScaledClassSummon,
},
) {
Renderer.get().setFirstSection(true);
const $btnScaleCr = !ScaleCreature.isCrInScaleRange(mon) ? null : $(`<button id="btn-scale-cr" title="Scale Creature By CR (Highly Experimental)" class="mon__btn-scale-cr btn btn-xs btn-default ve-popwindow__hidden no-print lst-is-exporting-image__hidden"><span class="glyphicon glyphicon-signal"></span></button>`)
.click((evt) => {
evt.stopPropagation();
const win = (evt.view || {}).window;
const mon = this._dataList[Hist.lastLoadedId];
const lastCr = this._lastRender.entity ? this._lastRender.entity.cr.cr || this._lastRender.entity.cr : mon.cr.cr || mon.cr;
Renderer.monster.getCrScaleTarget({
win,
$btnScale: $btnScaleCr,
initialCr: lastCr,
cbRender: (targetCr) => {
if (targetCr === Parser.crToNumber(mon.cr)) this._renderStatblock(mon);
else Hist.setSubhash(VeCt.HASH_SCALED, targetCr);
},
});
});
const $btnResetScaleCr = !ScaleCreature.isCrInScaleRange(mon) ? null : $(`<button id="btn-reset-cr" title="Reset CR Scaling" class="mon__btn-reset-cr btn btn-xs btn-default ve-popwindow__hidden no-print lst-is-exporting-image__hidden"><span class="glyphicon glyphicon-refresh"></span></button>`)
.click(() => Hist.setSubhash(VeCt.HASH_SCALED, null))
.toggle(isScaledCr);
const selSummonSpellLevel = Renderer.monster.getSelSummonSpellLevel(mon);
if (selSummonSpellLevel) {
selSummonSpellLevel
.onChange(evt => {
evt.stopPropagation();
const scaleTo = Number(selSummonSpellLevel.val());
if (!~scaleTo) Hist.setSubhash(VeCt.HASH_SCALED_SPELL_SUMMON, null);
else Hist.setSubhash(VeCt.HASH_SCALED_SPELL_SUMMON, scaleTo);
});
}
if (isScaledSpellSummon) selSummonSpellLevel.val(`${mon._summonedBySpell_level}`);
const selSummonClassLevel = Renderer.monster.getSelSummonClassLevel(mon);
if (selSummonClassLevel) {
selSummonClassLevel
.onChange(evt => {
evt.stopPropagation();
const scaleTo = Number(selSummonClassLevel.val());
if (!~scaleTo) Hist.setSubhash(VeCt.HASH_SCALED_CLASS_SUMMON, null);
else Hist.setSubhash(VeCt.HASH_SCALED_CLASS_SUMMON, scaleTo);
});
}
if (isScaledClassSummon) selSummonClassLevel.val(`${mon._summonedByClass_level}`);
// region dice rollers
const expectedPB = Parser.crToPb(mon.cr);
const pluginDc = (commonArgs, {input: {tag, text}}) => {
if (isNaN(text) || expectedPB <= 0) return null;
const withoutPB = Number(text) - expectedPB;
const profDiceString = BestiaryPage._addSpacesToDiceExp(`1d${(expectedPB * 2)}${withoutPB >= 0 ? "+" : ""}${withoutPB}`);
return `DC <span class="rd__dc rd__dc--rollable" data-roll-prof-type="dc" data-roll-prof-dice="${profDiceString.qq()}"><span class="rd__dc--rollable-text">${text}</span><span class="rd__dc--rollable-dice">${profDiceString}</span></span>`;
};
const pluginDice = (commonArgs, {input: entry}) => {
if (expectedPB <= 0 || entry.subType !== "d20" || entry.context?.type == null) return null;
const text = Renderer.getEntryDiceDisplayText(entry);
let profDiceString;
let expert = 1;
let pB = expectedPB;
const bonus = Number(entry.d20mod);
switch (entry.context?.type) {
case "savingThrow": {
const ability = entry.context.ability;
const fromAbility = Parser.getAbilityModNumber(mon[ability]);
pB = bonus - fromAbility;
expert = (pB === expectedPB * 2) ? 2 : 1;
break;
}
case "skillCheck": {
const ability = Parser.skillToAbilityAbv(entry.context.skill.toLowerCase().trim());
const fromAbility = Parser.getAbilityModNumber(mon[ability]);
pB = bonus - fromAbility;
expert = (pB === expectedPB * 2) ? 2 : 1;
break;
}
// add proficiency dice stuff for attack rolls, since those _generally_ have proficiency
// this is not 100% accurate; for example, ghouls don't get their prof bonus on bite attacks
// fixing this would require additional context, which is not (yet) available in the renderer
case "hit": break;
case "abilityCheck": return null;
default: throw new Error(`Unhandled roll context "${entry.context.type}"`);
}
const withoutPB = bonus - pB;
profDiceString = BestiaryPage._addSpacesToDiceExp(`+${expert}d${pB * (3 - expert)}${withoutPB >= 0 ? "+" : ""}${withoutPB}`);
return {
toDisplay: `<span class="rd__roller--roll-prof-bonus">${text}</span><span class="rd__roller--roll-prof-dice">${profDiceString}</span>`,
additionalData: {
"data-roll-prof-type": "d20",
"data-roll-prof-dice": profDiceString,
},
};
};
try {
Renderer.get().addPlugin("string_@dc", pluginDc);
Renderer.get().addPlugin("dice", pluginDice);
this._$pgContent.empty().append(RenderBestiary.$getRenderedCreature(mon, {$btnScaleCr, $btnResetScaleCr, selSummonSpellLevel, selSummonClassLevel}));
} finally {
Renderer.get().removePlugin("dice", pluginDice);
Renderer.get().removePlugin("string_@dc", pluginDc);
}
// endregion
// tokens
this._renderStatblock_doBuildStatsTab_token(mon);
}
_renderStatblock_doBuildStatsTab_token (mon) {
const $tokenImages = [];
// statblock scrolling handler
$(`#wrp-pagecontent`).off("scroll").on("scroll", function () {
$tokenImages.forEach($img => {
$img
.toggle(this.scrollTop < 32)
.css({
opacity: (32 - this.scrollTop) / 32,
top: -this.scrollTop,
});
});
});
const $floatToken = this._$dispToken.empty();
if (!Renderer.monster.hasToken(mon)) return;
const imgLink = Renderer.monster.getTokenUrl(mon);
const $img = $(`<img src="${imgLink}" class="mon__token" alt="Token Image: ${(mon.name || "").qq()}" ${mon.tokenCredit ? `title="Credit: ${mon.tokenCredit.qq()}"` : ""} loading="lazy">`);
$tokenImages.push($img);
const $lnkToken = $$`<a href="${imgLink}" class="mon__wrp-token" target="_blank" rel="noopener noreferrer">${$img}</a>`
.appendTo($floatToken);
const altArtMeta = [];
if (mon.altArt) altArtMeta.push(...MiscUtil.copy(mon.altArt));
if (mon.variant) {
const variantTokens = mon.variant.filter(it => it.token).map(it => it.token);
if (variantTokens.length) altArtMeta.push(...MiscUtil.copy(variantTokens).map(it => ({...it, displayName: `Variant; ${it.name}`})));
}
if (altArtMeta.length) {
// make a fake entry for the original token
altArtMeta.unshift({$ele: $lnkToken});
const buildEle = (meta) => {
if (!meta.$ele) {
const imgLink = Renderer.monster.getTokenUrl(meta);
const displayName = Renderer.monster.getAltArtDisplayName(meta);
const $img = $(`<img src="${imgLink}" class="mon__token" alt="Token Image${displayName ? `: ${displayName.qq()}` : ""}}" ${meta.tokenCredit ? `title="Credit: ${meta.tokenCredit.qq()}"` : ""} loading="lazy">`)
.on("error", () => {
$img.attr(
"src",
`data:image/svg+xml,${encodeURIComponent(`
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="400">
<circle cx="200" cy="200" r="175" fill="#b00"/>
<rect x="190" y="40" height="320" width="20" fill="#ddd" transform="rotate(45 200 200)"/>
<rect x="190" y="40" height="320" width="20" fill="#ddd" transform="rotate(135 200 200)"/>
</svg>`,
)}`,
);
});
$tokenImages.push($img);
meta.$ele = $$`<a href="${imgLink}" class="mon__wrp-token" target="_blank" rel="noopener noreferrer">${$img}</a>`
.hide()
.css("max-width", "100%") // hack to ensure the token gets shown at max width on first look
.appendTo($floatToken);
}
};
altArtMeta.forEach(buildEle);
let ix = 0;
const handleClick = (evt, direction) => {
evt.stopPropagation();
evt.preventDefault();
// avoid going off the edge of the list
if (ix === 0 && !~direction) return;
if (ix === altArtMeta.length - 1 && ~direction) return;
ix += direction;
if (!~direction) { // left
if (ix === 0) {
$btnLeft.hide();
$wrpFooter.hide();
}
$btnRight.show();
} else {
$btnLeft.show();
$wrpFooter.show();
if (ix === altArtMeta.length - 1) {
$btnRight.hide();
}
}
altArtMeta.filter(it => it.$ele).forEach(it => it.$ele.hide());
const meta = altArtMeta[ix];
meta.$ele.show();
setTimeout(() => meta.$ele.css("max-width", ""), 10); // hack to clear the earlier 100% width
$footer.html(Renderer.monster.getRenderedAltArtEntry(meta));
$wrpFooter.detach().appendTo(meta.$ele);
$btnLeft.detach().appendTo(meta.$ele);
$btnRight.detach().appendTo(meta.$ele);
};
// append footer first to be behind buttons
const $footer = $(`<div class="mon__token-footer"/>`);
const $wrpFooter = $$`<div class="mon__wrp-token-footer">${$footer}</div>`.hide().appendTo($lnkToken);
const $btnLeft = $$`<div class="mon__btn-token-cycle mon__btn-token-cycle--left"><span class="glyphicon glyphicon-chevron-left"/></div>`
.click(evt => handleClick(evt, -1)).appendTo($lnkToken)
.hide();
const $btnRight = $$`<div class="mon__btn-token-cycle mon__btn-token-cycle--right"><span class="glyphicon glyphicon-chevron-right"/></div>`
.click(evt => handleClick(evt, 1)).appendTo($lnkToken);
}
}
static _addSpacesToDiceExp (exp) {
return exp.replace(/([^0-9d])/gi, " $1 ").replace(/\s+/g, " ").trim().replace(/^([-+])\s*/, "$1");
}
async _pPreloadSublistSources (json) {
if (json.l && json.l.items && json.l.sources) { // if it's an encounter file
json.items = json.l.items;
json.sources = json.l.sources;
}
const loaded = Object.keys(this._loadedSources)
.filter(it => this._loadedSources[it].loaded);
const lowerSources = json.sources.map(it => it.toLowerCase());
const toLoad = Object.keys(this._loadedSources)
.filter(it => !loaded.includes(it))
.filter(it => lowerSources.includes(it.toLowerCase()));
const loadTotal = toLoad.length;
if (loadTotal) {
await Promise.all(toLoad.map(src => this._pLoadSource(src, "yes")));
}
}
async pHandleUnknownHash (link, sub) {
const src = Object.keys(this._loadedSources)
.find(src => src.toLowerCase() === (UrlUtil.decodeHash(link)[1] || "").toLowerCase());
if (src) {
await this._pLoadSource(src, "yes");
Hist.hashChange();
}
}
_pOnLoad_initVisibleItemsDisplay (...args) {
super._pOnLoad_initVisibleItemsDisplay(...arguments);
this._list.on("updated", () => {
this._encounterBuilder.resetCache();
});
}
}
const bestiaryPage = new BestiaryPage();
window.bestiaryPage = bestiaryPage;
const sublistManager = new BestiarySublistManager();
const encounterBuilderCache = new EncounterBuilderCacheBestiaryPage({bestiaryPage});
const encounterBuilderComp = new EncounterBuilderComponentBestiary();
const encounterBuilder = new EncounterBuilderUiBestiary({
cache: encounterBuilderCache,
comp: encounterBuilderComp,
bestiaryPage,
sublistManager,
});
const sublistPlugin = new EncounterBuilderSublistPlugin({
sublistManager,
encounterBuilder,
encounterBuilderComp,
});
sublistManager.addPlugin(sublistPlugin);
bestiaryPage.encounterBuilder = encounterBuilder;
bestiaryPage.sublistManager = sublistManager;
encounterBuilder.bestiaryPage = bestiaryPage;
encounterBuilder.sublistManager = sublistManager;
sublistManager.encounterBuilder = encounterBuilder;
window.addEventListener("load", () => bestiaryPage.pOnLoad());