This commit is contained in:
TheGiddyLimit
2024-01-01 19:34:49 +00:00
parent 332769043f
commit 8117ebddc5
1748 changed files with 2544409 additions and 1 deletions

View File

@@ -0,0 +1,26 @@
export const PANEL_TYP_EMPTY = 0;
export const PANEL_TYP_STATS = 1;
export const PANEL_TYP_ROLLBOX = 2;
export const PANEL_TYP_TEXTBOX = 3;
export const PANEL_TYP_RULES = 4;
export const PANEL_TYP_INITIATIVE_TRACKER = 5;
export const PANEL_TYP_INITIATIVE_TRACKER_CREATURE_VIEWER = 51;
export const PANEL_TYP_UNIT_CONVERTER = 6;
export const PANEL_TYP_CREATURE_SCALED_CR = 7;
export const PANEL_TYP_CREATURE_SCALED_SPELL_SUMMON = 71;
export const PANEL_TYP_CREATURE_SCALED_CLASS_SUMMON = 72;
export const PANEL_TYP_TIME_TRACKER = 8;
export const PANEL_TYP_MONEY_CONVERTER = 9;
export const PANEL_TYP_TUBE = 10;
export const PANEL_TYP_TWITCH = 11;
export const PANEL_TYP_TWITCH_CHAT = 12;
export const PANEL_TYP_ADVENTURES = 13;
export const PANEL_TYP_BOOKS = 14;
export const PANEL_TYP_INITIATIVE_TRACKER_PLAYER_V1 = 15;
export const PANEL_TYP_INITIATIVE_TRACKER_PLAYER_V0 = 151;
export const PANEL_TYP_COUNTER = 16;
export const PANEL_TYP_IMAGE = 20;
export const PANEL_TYP_ADVENTURE_DYNAMIC_MAP = 21;
export const PANEL_TYP_GENERIC_EMBED = 90;
export const PANEL_TYP_ERROR = 98;
export const PANEL_TYP_BLANK = 99;

View File

@@ -0,0 +1,161 @@
export class Counter {
static $getCounter (board, state) {
const $wrpPanel = $(`<div class="w-100 h-100 dm-cnt__root dm__panel-bg dm__data-anchor"/>`) // root class used to identify for saving
.data("getState", () => counters.getSaveableState());
const counters = new CounterRoot(board, $wrpPanel);
counters.setStateFrom(state);
counters.render($wrpPanel);
return $wrpPanel;
}
}
class CounterComponent extends BaseComponent {
constructor (board, $wrpPanel) {
super();
this._board = board;
this._$wrpPanel = $wrpPanel;
this._addHookAll("state", () => this._board.doSaveStateDebounced());
}
}
class CounterRoot extends CounterComponent {
constructor (board, $wrpPanel) {
super(board, $wrpPanel);
this._childComps = [];
this._$wrpRows = null;
}
render ($parent) {
$parent.empty();
const pod = this.getPod();
this._$wrpRows = $$`<div class="ve-flex-col w-100 h-100 overflow-y-auto relative"/>`;
this._childComps.forEach(it => it.render(this._$wrpRows, pod));
const $btnAdd = $(`<button class="btn btn-primary btn-xs"><span class="glyphicon glyphicon-plus"/> Add Counter</button>`)
.click(() => {
const comp = new CounterRow(this._board, this._$wrpPanel);
this._childComps.push(comp);
comp.render(this._$wrpRows, pod);
this._board.doSaveStateDebounced();
});
$$`<div class="w-100 h-100 ve-flex-col px-2 pb-3">
<div class="no-shrink pt-4"/>
${this._$wrpRows}
<div class="no-shrink ve-flex-h-right">${$btnAdd}</div>
</div>`.appendTo($parent);
}
_swapRowPositions (ixA, ixB) {
const a = this._childComps[ixA];
this._childComps[ixA] = this._childComps[ixB];
this._childComps[ixB] = a;
this._childComps.forEach(it => it.$row.detach().appendTo(this._$wrpRows));
this._board.doSaveStateDebounced();
}
_removeRow (comp) {
const ix = this._childComps.indexOf(comp);
if (~ix) {
this._childComps.splice(ix, 1);
comp.$row.remove();
this._board.doSaveStateDebounced();
}
}
getPod () {
const pod = super.getPod();
pod.swapRowPositions = this._swapRowPositions.bind(this);
pod.removeRow = this._removeRow.bind(this);
pod.$getChildren = () => this._childComps.map(comp => comp.$row);
return pod;
}
setStateFrom (toLoad) {
this.setBaseSaveableStateFrom(toLoad);
this._childComps = [];
if (toLoad.rowState) {
toLoad.rowState.forEach(r => {
const comp = new CounterRow(this._board, this._$wrpPanel);
comp.setStateFrom(r);
this._childComps.push(comp);
});
}
}
getSaveableState () {
return {
...this.getBaseSaveableState(),
rowState: this._childComps.map(r => r.getSaveableState()),
};
}
}
class CounterRow extends CounterComponent {
constructor (board, $wrpPanel) {
super(board, $wrpPanel);
this._$row = null;
}
get $row () { return this._$row; }
render ($parent, parent) {
this._parent = parent;
const $iptName = ComponentUiUtil.$getIptStr(this, "name").addClass("mr-2 small-caps");
const $iptCur = ComponentUiUtil.$getIptInt(this, "current", 0, {$ele: $(`<input class="form-control input-xs form-control--minimal ve-text-center dm-cnt__ipt dm-cnt__ipt--cur bold">`)});
const $iptMax = ComponentUiUtil.$getIptInt(this, "max", 0, {$ele: $(`<input class="form-control input-xs form-control--minimal ve-text-center dm-cnt__ipt dm-cnt__ipt--max mr-2 text-muted bold">`)});
const hookDisplayMinMax = () => {
$iptCur.removeClass("text-success text-danger");
if (this._state.current >= this._state.max) $iptCur.addClass("text-success");
else if (this._state.current <= 0) $iptCur.addClass("text-danger");
};
this._addHookBase("current", hookDisplayMinMax);
this._addHookBase("max", hookDisplayMinMax);
hookDisplayMinMax();
const $btnDown = $(`<button class="btn btn-danger btn-xs"><span class="glyphicon glyphicon-minus"/></button>`)
.click(() => this._state.current--);
const $btnUp = $(`<button class="btn btn-success btn-xs"><span class="glyphicon glyphicon-plus"/></button>`)
.click(() => this._state.current++);
const $btnRemove = $(`<button class="btn btn-danger btn-xxs"><span class="glyphicon glyphicon-trash"/></button>`)
.click(() => {
const {removeRow} = this._parent;
removeRow(this);
});
this._$row = $$`<div class="ve-flex-v-center w-100 my-1">
${$iptName}
<div class="relative ve-flex-vh-center">
${$iptCur}
<div class="dm-cnt__slash text-muted ve-text-center">/</div>
${$iptMax}
</div>
<div class="ve-flex btn-group mr-2">
${$btnDown}
${$btnUp}
</div>
${DragReorderUiUtil.$getDragPad2(() => this._$row, $parent, this._parent)}
${$btnRemove}
</div>`.appendTo($parent);
}
_getDefaultState () { return MiscUtil.copy(CounterRow._DEFAULT_STATE); }
}
CounterRow._DEFAULT_STATE = {
name: "",
current: 0,
max: 1,
};

View File

@@ -0,0 +1,209 @@
import {DmScreenUtil} from "./dmscreen-util.js";
import {PANEL_TYP_INITIATIVE_TRACKER} from "./dmscreen-consts.js";
export class InitiativeTrackerCreatureViewer extends BaseComponent {
static $getPanelElement (board, savedState) {
return new this({board, savedState}).render();
}
constructor ({board, savedState}) {
super();
this._board = board;
this._savedState = savedState;
this._trackerLinked = null;
}
/* -------------------------------------------- */
render () {
const $out = $$`<div class="ve-flex-col w-100 h-100 min-h-0 dm__data-anchor">
${this._render_$getStgNoTrackerAvailable()}
${this._render_$getStgConnect()}
${this._render_$getStgCreature()}
</div>`;
this._addHookBase("cntPanelsAvailable", (prop, val, prev) => {
if (prop == null) return;
if (prev || val !== 1) return;
this._setLinkedTrackerFromEle({
$eleData: DmScreenUtil.$getPanelDataElements({board: this._board, type: PANEL_TYP_INITIATIVE_TRACKER})[0],
});
})();
const hkBoardPanels = () => {
const $elesData = DmScreenUtil.$getPanelDataElements({board: this._board, type: PANEL_TYP_INITIATIVE_TRACKER});
this._state.cntPanelsAvailable = $elesData.length;
};
hkBoardPanels();
$out
.data("onDestroy", () => {
if (!this._trackerLinked) return;
this._trackerLinked.doDisconnectCreatureViewer({creatureViewer: this});
this._state.isActive = false;
})
.data("onBoardEvent", ({type, payload = {}}) => {
if (
!(
type === "panelDestroy"
|| (type === "panelPopulate" && payload.type === PANEL_TYP_INITIATIVE_TRACKER)
|| (type === "panelIdSetActive" && payload.type === PANEL_TYP_INITIATIVE_TRACKER)
)
) return;
hkBoardPanels();
});
return $out;
}
_render_$getStgNoTrackerAvailable () {
const $stg = $$`<div class="ve-flex-vh-center w-100 h-100 min-h-0">
<div class="dnd-font italic small-caps ve-muted">No Initiative Tracker available.</div>
</div>`;
const hkIsVisible = () => $stg.toggleVe(!this._state.isActive && !this._state.cntPanelsAvailable);
this._addHookBase("isActive", hkIsVisible);
this._addHookBase("cntPanelsAvailable", hkIsVisible);
hkIsVisible();
return $stg;
}
_render_$getStgConnect () {
const $btnConnect = $(`<button class="btn btn-primary min-w-200p">Connect to Tracker</button>`)
.on("click", async () => {
const $elesData = DmScreenUtil.$getPanelDataElements({board: this._board, type: PANEL_TYP_INITIATIVE_TRACKER});
if ($elesData.length === 1) return this._setLinkedTrackerFromEle({$eleData: $elesData[0]});
const {$modalInner, doClose, pGetResolved} = UiUtil.getShowModal({
isMinHeight0: true,
isHeaderBorder: true,
title: "Select Tracker",
});
const $selTracker = $(`<select class="form-control input-xs mr-1">
<option value="-1" disabled>Select tracker</option>
${$elesData.map(($e, i) => `<option value="${i}">${$e.data("getSummary")()}</option>`).join("")}
</select>`)
.on("change", () => $selTracker.removeClass("error-background"));
const $btnSubmit = $(`<button class="btn btn-primary btn-xs">Connect</button>`)
.on("click", () => {
const ix = Number($selTracker.val());
if (!~ix) {
$selTracker.addClass("error-background");
return;
}
doClose(true, ix);
});
$$($modalInner)`
${$selTracker}
${$btnSubmit}
`;
const ixSel = await pGetResolved();
if (ixSel == null) return;
this._setLinkedTrackerFromEle({$eleData: $elesData[ixSel]});
});
const $stg = $$`<div class="ve-flex-vh-center w-100 h-100 min-h-0">
${$btnConnect}
</div>`;
const hkIsVisible = () => $stg.toggleVe(!this._state.isActive && this._state.cntPanelsAvailable);
this._addHookBase("isActive", hkIsVisible);
this._addHookBase("cntPanelsAvailable", hkIsVisible);
hkIsVisible();
return $stg;
}
_render_$getStgCreature () {
const dispCreature = e_({
tag: "div",
clazz: "ve-flex-col w-100 h-100 min-h-0",
});
const lock = new VeLock({name: "Creature display"});
const hkCreature = async () => {
const mon = (this._state.creatureName && this._state.creatureSource)
? await DmScreenUtil.pGetScaledCreature({
name: this._state.creatureName,
source: this._state.creatureSource,
scaledCr: this._state.creatureScaledCr,
scaledSummonSpellLevel: this._state.creatureScaledSummonSpellLevel,
scaledSummonClassLevel: this._state.creatureScaledSummonClassLevel,
})
: null;
if (!mon) return dispCreature.innerHTML = `<div class="dnd-font italic small-caps ve-muted ve-flex-vh-center w-100 h-100">No active creature.</div>`;
dispCreature.innerHTML = `<table class="w-100 stats"><tbody>${Renderer.monster.getCompactRenderedString(mon, {isShowScalers: false})}</tbody></table>`;
};
this._addHookBase("creaturePulse", async () => {
try {
await lock.pLock();
await hkCreature();
} finally {
lock.unlock();
}
})().then(null);
const $stg = $$`<div class="ve-flex-col w-100 h-100 min-h-0 overflow-y-auto">
${dispCreature}
</div>`;
this._addHookBase("isActive", () => $stg.toggleVe(this._state.isActive))();
return $stg;
}
/* -------------------------------------------- */
_setLinkedTrackerFromEle ({$eleData}) {
this._trackerLinked = $eleData.data("getApi")().doConnectCreatureViewer({creatureViewer: this});
this._state.isActive = true;
}
/* -------------------------------------------- */
setCreatureState (state) {
if (state == null) return;
this._proxyAssignSimple(
"state",
Object.fromEntries(
Object.entries(state)
.map(([k, v]) => [`creature${k.uppercaseFirst()}`, v]),
),
);
// Avoid render spam
this._state.creaturePulse = !this._state.creaturePulse;
}
/* -------------------------------------------- */
_getDefaultState () {
return {
isActive: false,
cntPanelsAvailable: 0,
creatureName: null,
creatureSource: null,
creatureScaledCr: null,
creatureScaledSummonSpellLevel: null,
creatureScaledSummonClassLevel: null,
creaturePulse: false,
};
}
}

View File

@@ -0,0 +1,142 @@
export class DmMapper {
static $getMapper (board, state) {
const $wrpPanel = $(`<div class="w-100 h-100 dm-map__root dm__panel-bg dm__data-anchor"/>`) // root class used to identify for saving
.data("getState", () => mapper.getSaveableState());
const mapper = new DmMapperRoot(board, $wrpPanel);
mapper.setStateFrom(state);
mapper.render($wrpPanel);
return $wrpPanel;
}
static _getProps ({catId}) {
const prop = catId === Parser.CAT_ID_ADVENTURE ? "adventure" : "book";
return {prop, propData: `${prop}Data`};
}
static async pHandleMenuButtonClick (menu) {
const chosenDoc = await SearchWidget.pGetUserAdventureBookSearch({
fnFilterResults: doc => doc.hasMaps,
contentIndexName: "entity_AdventuresBooks_maps",
pFnGetDocExtras: async ({doc}) => {
// Load the adventure/book, and scan it for maps
const {propData} = this._getProps({catId: doc.c});
const {page, source, hash} = SearchWidget.docToPageSourceHash(doc);
const adventureBookPack = await DataLoader.pCacheAndGet(page, source, hash);
let hasMaps = false;
const walker = MiscUtil.getWalker({
isBreakOnReturn: true,
keyBlocklist: MiscUtil.GENERIC_WALKER_ENTRIES_KEY_BLOCKLIST,
isNoModification: true,
});
walker.walk(
adventureBookPack[propData],
{
object: (obj) => {
if (obj.type === "image" && obj.mapRegions?.length) return hasMaps = true;
},
},
);
return {hasMaps};
},
});
if (!chosenDoc) return;
menu.doClose();
const {$modalInner, doClose} = UiUtil.getShowModal({
title: `Select Map\u2014${chosenDoc.n}`,
isWidth100: true,
isHeight100: true,
isUncappedHeight: true,
});
$modalInner.append(`<div class="ve-flex-vh-center w-100 h-100"><i class="dnd-font ve-muted">Loading...</i></div>`);
const {page, source, hash} = SearchWidget.docToPageSourceHash(chosenDoc);
const adventureBookPack = await DataLoader.pCacheAndGet(page, source, hash);
const mapDatas = [];
const walker = MiscUtil.getWalker();
const {prop, propData} = this._getProps({catId: chosenDoc.c});
adventureBookPack[propData].data.forEach((chap, ixChap) => {
let cntChapImages = 0;
const handlers = {
object (obj) {
if (obj.mapRegions) {
const out = {
...Renderer.get().getMapRegionData(obj),
page: chosenDoc.q,
source: adventureBookPack[prop].source,
hash: UrlUtil.URL_TO_HASH_BUILDER[chosenDoc.q](adventureBookPack[prop]),
};
mapDatas.push(out);
if (obj.title) {
out.name = Renderer.stripTags(obj.title);
} else {
out.name = `${(adventureBookPack[prop].contents[ixChap] || {}).name || "(Unknown)"}, Map ${cntChapImages + 1}`;
}
cntChapImages++;
}
return obj;
},
};
walker.walk(
chap,
handlers,
);
});
if (!mapDatas.length) {
$modalInner
.empty()
.append(`<div class="ve-flex-vh-center w-100 h-100"><span class="dnd-font">Adventure did not contain any valid maps!</span></div>`);
return;
}
$modalInner
.empty()
.removeClass("ve-flex-col")
.addClass("ve-text-center");
mapDatas.map(mapData => {
$(`<div class="m-1 p-1 clickable dm-map__picker-wrp-img relative">
<div class="dm-map__picker-img" style="background-image: url(${encodeURI(mapData.hrefThumbnail || mapData.href)})"></div>
<span class="absolute ve-text-center dm-map__picker-disp-name">${mapData.name.escapeQuotes()}</span>
</div>`)
.click(() => {
doClose();
menu.pnl.doPopulate_AdventureBookDynamicMap({state: mapData});
})
.appendTo($modalInner);
});
}
}
class DmMapperRoot extends BaseComponent {
/**
* @param board DM Screen board.
* @param $wrpPanel Panel wrapper element for us to populate.
*/
constructor (board, $wrpPanel) {
super();
this._board = board;
this._$wrpPanel = $wrpPanel;
}
render ($parent) {
$parent.empty();
$parent.append(`<div class="ve-flex-vh-center w-100 h-100"><i class="dnd-font ve-muted">Loading...</i></div>`);
RenderMap.$pGetRendered(this._state)
.then($ele => $parent.empty().append($ele));
}
}

View File

@@ -0,0 +1,224 @@
// a simple money converter, i.e.: input x electrum, y silver, z copper and get the total in gold, or in any other type of coin chosen.
export class MoneyConverter {
static make$Converter (board, state) {
const disabledCurrency = state.d || {};
const COIN_WEIGHT = 0.02;
const CURRENCY = [
new MoneyConverterUnit("Copper", 1, "cp"),
new MoneyConverterUnit("Silver", 10, "sp"),
new MoneyConverterUnit("Electrum", 50, "ep"),
new MoneyConverterUnit("Gold", 100, "gp"),
new MoneyConverterUnit("Platinum", 1000, "pp"),
new MoneyConverterUnit("Nib (WDH)", 1, "nib"),
new MoneyConverterUnit("Shard (WDH)", 10, "shard"),
new MoneyConverterUnit("Taol (WDH)", 200, "taol"),
new MoneyConverterUnit("Dragon (WDH)", 100, "dgn"),
new MoneyConverterUnit("Sun (WDH)", 1000, "sun"),
new MoneyConverterUnit("Harbor Moon (WDH)", 5000, "moon"),
];
const CURRENCY_INDEXED = [...CURRENCY].map((it, i) => {
it.ix = i;
return it;
}).reverse();
const DEFAULT_CURRENCY = 3;
const $wrpConverter = $(`<div class="dm_money dm__panel-bg split-column"/>`);
const doUpdate = () => {
if (!$wrpRows.find(`.dm-money__row`).length) {
addRow();
}
Object.entries(disabledCurrency).forEach(([currency, disabled]) => {
$selOut.find(`option[value=${currency}]`).toggle(!disabled);
});
// if the current choice is disabled, deselect it, and restart
if (disabledCurrency[$selOut.val()]) {
$selOut.val("-1");
doUpdate();
return;
}
const $rows = $wrpRows.find(`.dm-money__row`)
.removeClass("form-control--error");
$iptSplit.removeClass("form-control--error");
const outCurrency = Number($selOut.val()) || 0;
const outParts = [];
let totalWeight = 0;
const splitBetweenStr = ($iptSplit.val() || "").trim();
let split = 1;
if (splitBetweenStr) {
const splitBetweenNum = Number(splitBetweenStr);
if (isNaN(splitBetweenNum)) $iptSplit.addClass("form-control--error");
else split = splitBetweenNum;
}
if (outCurrency === -1) { // only split, don't convert
const totals = [];
const extras = [];
const allowedCategories = new Set();
$rows.each((i, e) => {
const $e = $(e);
const strVal = ($e.find(`input`).val() || "").trim();
if (strVal) {
const asNum = Number(strVal);
if (isNaN(asNum)) $e.addClass("form-control--error");
else {
const ix = Number($e.find(`select`).val());
totals[ix] = (totals[ix] || 0) + asNum;
allowedCategories.add(CURRENCY[ix]._cat);
}
}
});
if (split > 1) {
CURRENCY_INDEXED.forEach((c, i) => {
const it = totals[c.ix];
if (it) {
let remainder = (it % split) * c.mult;
totals[c.ix] = Math.floor(it / split);
for (let j = i + 1; j < CURRENCY_INDEXED.length; ++j) {
const nxtCurrency = CURRENCY_INDEXED[j];
// skip and convert to a smaller denomination as required
if (disabledCurrency[nxtCurrency.ix]) continue;
if (remainder >= nxtCurrency.mult) {
totals[nxtCurrency.ix] = (totals[nxtCurrency.ix] || 0) + Math.floor(remainder / nxtCurrency.mult);
remainder %= nxtCurrency.mult;
}
}
}
});
}
CURRENCY_INDEXED.forEach(c => {
const it = totals[c.ix] || 0;
const itExtra = extras[c.ix] || 0;
if (it || itExtra) {
const val = it + itExtra;
totalWeight += val * COIN_WEIGHT;
outParts.push(`${val.toLocaleString()} ${c.abbv}`);
}
});
} else {
let total = 0;
$rows.each((i, e) => {
const $e = $(e);
const strVal = ($e.find(`input`).val() || "").trim();
if (strVal) {
const asNum = Number(strVal);
if (isNaN(asNum)) $e.addClass("form-control--error");
else {
total += asNum * (CURRENCY[$e.find(`select`).val()] || CURRENCY[0]).mult;
}
}
});
const totalSplit = Math.floor(total / split);
const toCurrencies = CURRENCY_INDEXED.filter(it => !disabledCurrency[it.ix] && it.ix <= outCurrency);
let copper = totalSplit;
toCurrencies.forEach(c => {
if (copper >= c.mult) {
const remainder = copper % c.mult;
const theseCoins = Math.floor(copper / c.mult);
totalWeight += COIN_WEIGHT * theseCoins;
copper = remainder;
outParts.push(`${theseCoins.toLocaleString()} ${c.abbv}`);
}
});
}
$iptOut.val(`${outParts.join("; ")}${totalWeight ? ` (${totalWeight.toLocaleString()} lb.)` : ""}`);
board.doSaveStateDebounced();
};
const buildCurrency$Select = (isOutput) => $(`<select class="form-control input-sm" style="padding: 5px">${isOutput ? `<option value="-1">(No conversion)</option>` : ""}${CURRENCY.map((c, i) => `<option value="${i}">${c.n}</option>`).join("")}</select>`);
const addRow = (currency, count) => {
const $row = $(`<div class="dm-money__row"/>`).appendTo($wrpRows);
const $iptCount = $(`<input type="number" step="1" placeholder="Coins" class="form-control input-sm">`).appendTo($row).change(doUpdate);
if (count != null) $iptCount.val(count);
const $selCurrency = buildCurrency$Select().appendTo($row).change(doUpdate);
$selCurrency.val(currency == null ? DEFAULT_CURRENCY : currency);
const $btnRemove = $(`<button class="btn btn-sm btn-danger" title="Remove Row"><span class="glyphicon glyphicon-trash"></span></button>`).appendTo($row).click(() => {
$row.remove();
doUpdate();
});
};
const $wrpRows = $(`<div class="dm-money__rows"/>`).appendTo($wrpConverter);
const $wrpCtrl = $(`<div class="split dm-money__ctrl"/>`).appendTo($wrpConverter);
const $wrpCtrlLhs = $(`<div class="dm-money__ctrl__lhs split-child" style="width: 66%;"/>`).appendTo($wrpCtrl);
const $wrpBtnAddSettings = $(`<div class="split"/>`).appendTo($wrpCtrlLhs);
const $btnAddRow = $(`<button class="btn btn-primary btn-sm" title="Add Row"><span class="glyphicon glyphicon-plus"/></button>`)
.appendTo($wrpBtnAddSettings)
.click(() => {
addRow();
doUpdate();
});
const $btnSettings = $(`<button class="btn btn-default btn-sm" title="Settings"><span class="glyphicon glyphicon-cog"/></button>`)
.appendTo($wrpBtnAddSettings)
.click(() => {
const {$modalInner} = UiUtil.getShowModal({
title: "Settings",
cbClose: () => doUpdate(),
});
[...CURRENCY_INDEXED].reverse().forEach(cx => {
UiUtil.$getAddModalRowCb($modalInner, `Disable ${cx.n} in Output`, disabledCurrency, cx.ix);
});
});
const $iptOut = $(`<input class="form-control input-sm dm-money__out" disabled/>`)
.appendTo($wrpCtrlLhs)
.mousedown(async () => {
await MiscUtil.pCopyTextToClipboard($iptOut.val());
JqueryUtil.showCopiedEffect($iptOut);
});
const $wrpCtrlRhs = $(`<div class="dm-money__ctrl__rhs split-child" style="width: 33%;"/>`).appendTo($wrpCtrl);
const $iptSplit = $(`<input type="number" min="1" step="1" placeholder="Split Between..." class="form-control input-sm">`).appendTo($wrpCtrlRhs).change(doUpdate);
const $selOut = buildCurrency$Select(true).appendTo($wrpCtrlRhs).change(doUpdate);
$wrpConverter.data("getState", () => {
return {
c: $selOut.val(),
s: $iptSplit.val(),
r: $wrpRows.find(`.dm-money__row`).map((i, e) => {
const $e = $(e);
return {
c: $e.find(`select`).val(),
n: $e.find(`input`).val(),
};
}).get(),
d: disabledCurrency,
};
});
if (state) {
$selOut.val(state.c == null ? DEFAULT_CURRENCY : state.c);
$iptSplit.val(state.s);
(state.r || []).forEach(r => addRow(r.c, r.n));
}
doUpdate();
return $wrpConverter;
}
}
class MoneyConverterUnit {
constructor (name, multiplier, abbreviation) {
this.n = name;
this.mult = multiplier;
this.abbv = abbreviation;
}
}

View File

@@ -0,0 +1,165 @@
import {
PANEL_TYP_INITIATIVE_TRACKER, PANEL_TYP_INITIATIVE_TRACKER_CREATURE_VIEWER,
PANEL_TYP_INITIATIVE_TRACKER_PLAYER_V0,
PANEL_TYP_INITIATIVE_TRACKER_PLAYER_V1,
} from "./dmscreen-consts.js";
import {InitiativeTracker} from "./initiativetracker/dmscreen-initiativetracker.js";
import {InitiativeTrackerPlayerV0, InitiativeTrackerPlayerV1} from "./dmscreen-playerinitiativetracker.js";
import {InitiativeTrackerCreatureViewer} from "./dmscreen-initiativetrackercreatureviewer.js";
export class PanelContentManagerFactory {
static _PANEL_TYPES = {};
static registerPanelType ({panelType, Cls}) {
this._PANEL_TYPES[panelType] = Cls;
}
/* -------------------------------------------- */
static async pFromSavedState ({board, saved, ixTab, panel}) {
if (!this._PANEL_TYPES[saved.t]) return undefined;
const ContentManager = new this._PANEL_TYPES[saved.t]({board, panel});
await ContentManager.pLoadState({ixTab, saved});
return true;
}
/* -------------------------------------------- */
static getSaveableContent (
{
type,
toSaveTitle,
$content,
},
) {
if (!this._PANEL_TYPES[type]) return undefined;
return this._PANEL_TYPES[type]
.getSaveableContent({
type,
toSaveTitle,
$content,
});
}
}
/* -------------------------------------------- */
class _PanelContentManager {
static _PANEL_TYPE = null;
static _TITLE = null;
static _IS_STATELESS = false;
static _register () {
PanelContentManagerFactory.registerPanelType({panelType: this._PANEL_TYPE, Cls: this});
return null;
}
static getSaveableContent (
{
type,
toSaveTitle,
$content,
},
) {
return {
t: type,
r: toSaveTitle,
s: this._IS_STATELESS
? {}
: $($content.children()[0]).data("getState")(),
};
}
/* -------------------------------------------- */
constructor (
{
board,
panel,
},
) {
this._board = board;
this._panel = panel;
}
/* -------------------------------------------- */
/**
* @abstract
* @return {jQuery}
*/
_$getPanelElement ({state}) {
throw new Error("Unimplemented!");
}
async pDoPopulate ({state = {}, title = null} = {}) {
this._panel.set$ContentTab(
this.constructor._PANEL_TYPE,
state,
$(`<div class="panel-content-wrapper-inner"></div>`).append(this._$getPanelElement({state})),
title || this.constructor._TITLE,
true,
);
this._board.fireBoardEvent({type: "panelPopulate", payload: {type: this.constructor._PANEL_TYPE}});
}
_doHandleTabRenamed ({ixTab, saved}) {
if (saved.r != null) this._panel.tabDatas[ixTab].tabRenamed = true;
}
async pLoadState ({ixTab, saved}) {
await this.pDoPopulate({state: saved.s, title: saved.r});
this._doHandleTabRenamed({ixTab, saved});
}
}
export class PanelContentManager_InitiativeTracker extends _PanelContentManager {
static _PANEL_TYPE = PANEL_TYP_INITIATIVE_TRACKER;
static _TITLE = "Initiative Tracker";
static _ = this._register();
_$getPanelElement ({state}) {
return InitiativeTracker.$getPanelElement(this._board, state);
}
}
export class PanelContentManager_InitiativeTrackerCreatureViewer extends _PanelContentManager {
static _PANEL_TYPE = PANEL_TYP_INITIATIVE_TRACKER_CREATURE_VIEWER;
static _TITLE = "Creature Viewer";
static _IS_STATELESS = true;
static _ = this._register();
_$getPanelElement ({state}) {
return InitiativeTrackerCreatureViewer.$getPanelElement(this._board, state);
}
}
export class PanelContentManager_InitiativeTrackerPlayerViewV1 extends _PanelContentManager {
static _PANEL_TYPE = PANEL_TYP_INITIATIVE_TRACKER_PLAYER_V1;
static _TITLE = "Initiative Tracker";
static _IS_STATELESS = true;
static _ = this._register();
_$getPanelElement ({state}) {
return InitiativeTrackerPlayerV1.$getPanelElement(this._board, state);
}
}
export class PanelContentManager_InitiativeTrackerPlayerViewV0 extends _PanelContentManager {
static _PANEL_TYPE = PANEL_TYP_INITIATIVE_TRACKER_PLAYER_V0;
static _TITLE = "Initiative Tracker";
static _IS_STATELESS = true;
static _ = this._register();
_$getPanelElement ({state}) {
return InitiativeTrackerPlayerV0.$getPanelElement(this._board, state);
}
}

View File

@@ -0,0 +1,323 @@
import {PANEL_TYP_INITIATIVE_TRACKER} from "./dmscreen-consts.js";
import {
InitiativeTrackerPlayerMessageHandlerV0,
InitiativeTrackerPlayerMessageHandlerV1,
InitiativeTrackerPlayerUiV0,
InitiativeTrackerPlayerUiV1,
} from "../initiativetracker/initiativetracker-player.js";
import {DmScreenUtil} from "./dmscreen-util.js";
// region v1
export class InitiativeTrackerPlayerV1 {
static $getPanelElement (board, state) {
const $meta = $(`<div class="initp__meta"/>`).hide();
const $head = $(`<div class="initp__header"/>`).hide();
const $rows = $(`<div class="ve-flex-col"></div>`).hide();
const $wrpTracker = $$`<div class="initp__wrp_active">
${$meta}
${$head}
${$rows}
</div>`;
const view = new InitiativeTrackerPlayerMessageHandlerScreenV1();
view.setElements($meta, $head, $rows);
let ui;
const $btnConnectRemote = $(`<button class="btn btn-primary mb-2 min-w-200p" title="Connect to a tracker outside of this browser tab.">Connect to Remote Tracker</button>`)
.click(async () => {
$btnConnectRemote.detach();
$btnConnectLocal.detach();
const $iptPlayerName = $(`<input class="form-control input-sm code">`)
.change(() => $iptPlayerName.removeClass("form-control--error"))
.disableSpellcheck();
const $iptServerToken = $(`<input class="form-control input-sm code">`)
.change(() => $iptServerToken.removeClass("form-control--error"))
.disableSpellcheck();
const $btnGenConnect = $(`<button class="btn btn-primary btn-xs mr-2">Connect</button>`);
const $btnCancel = $(`<button class="btn btn-default btn-xs">Back</button>`)
.click(() => {
// restore original state
$wrpClient.remove();
view.$wrpInitial.append($btnConnectRemote).append($btnConnectLocal);
});
const $wrpClient = $$`<div class="ve-flex-col w-100">
<div class="ve-flex-vh-center px-4 mb-2">
<span style="min-width: fit-content;" class="mr-2">Player Name</span>
${$iptPlayerName}
</div>
<div class="ve-flex-vh-center px-4 mb-2">
<span style="min-width: fit-content;" class="mr-2">Server Token</span>
${$iptServerToken}
</div>
<div class="split px-4 ve-flex-vh-center">
${$btnGenConnect}${$btnCancel}
</div>
</div>`.appendTo(view.$wrpInitial);
$btnGenConnect.click(async () => {
if (!$iptPlayerName.val().trim()) return $iptPlayerName.addClass("form-control--error");
if (!$iptServerToken.val().trim()) return $iptServerToken.addClass("form-control--error");
try {
$btnGenConnect.attr("disabled", true);
ui = new InitiativeTrackerPlayerUiV1(view, $iptPlayerName.val(), $iptServerToken.val());
await ui.pInit();
InitiativeTrackerPlayerMessageHandlerScreenV1.initUnloadMessage();
} catch (e) {
$btnGenConnect.attr("disabled", false);
JqueryUtil.doToast({content: `Failed to connect. ${VeCt.STR_SEE_CONSOLE}`, type: "danger"});
setTimeout(() => { throw e; });
}
});
});
const $btnConnectLocal = $(`<button class="btn btn-primary min-w-200p">Connect to Local Tracker</button>`)
.click(async () => {
const $elesData = DmScreenUtil.$getPanelDataElements({board, type: PANEL_TYP_INITIATIVE_TRACKER});
if (!$elesData.length) return JqueryUtil.doToast({content: "No local trackers detected!", type: "warning"});
if ($elesData.length === 1) {
try {
const token = await $elesData[0].data("pDoConnectLocalV1")(view);
ui = new InitiativeTrackerPlayerUiV1(view, "Local", token);
await ui.pInit();
InitiativeTrackerPlayerMessageHandlerScreenV1.initUnloadMessage();
} catch (e) {
JqueryUtil.doToast({content: `Failed to connect. ${VeCt.STR_SEE_CONSOLE}`, type: "danger"});
setTimeout(() => { throw e; });
}
} else {
$btnConnectRemote.detach();
$btnConnectLocal.detach();
const $selTracker = $(`<select class="form-control input-xs mr-1">
<option value="-1" disabled>Select a local tracker</option>
</select>`).change(() => $selTracker.removeClass("form-control--error"));
$elesData.forEach(($e, i) => $selTracker.append(`<option value="${i}">${$e.data("getSummary")()}</option>`));
$selTracker.val("-1");
const $btnOk = $(`<button class="btn btn-primary btn-xs">OK</button>`)
.click(async () => {
// jQuery reads the disabled value as null
if ($selTracker.val() == null) return $selTracker.addClass("form-control--error");
$btnOk.prop("disabled", true);
try {
const token = await $elesData[Number($selTracker.val())].data("pDoConnectLocalV1")(view);
ui = new InitiativeTrackerPlayerUiV1(view, "Local", token);
await ui.pInit();
InitiativeTrackerPlayerMessageHandlerScreenV1.initUnloadMessage();
} catch (e) {
JqueryUtil.doToast({content: `Failed to connect. ${VeCt.STR_SEE_CONSOLE}`, type: "danger"});
// restore original state
$btnCancel.remove(); $wrpSel.remove();
view.$wrpInitial.append($btnConnectRemote).append($btnConnectLocal);
setTimeout(() => { throw e; });
}
});
const $wrpSel = $$`<div class="ve-flex-vh-center mb-2">
${$selTracker}
${$btnOk}
</div>`.appendTo(view.$wrpInitial);
const $btnCancel = $(`<button class="btn btn-default btn-xs">Back</button>`)
.click(() => {
// restore original state
$btnCancel.remove(); $wrpSel.remove();
view.$wrpInitial.append($btnConnectRemote).append($btnConnectLocal);
})
.appendTo(view.$wrpInitial);
}
});
view.$wrpInitial = $$`<div class="ve-flex-vh-center h-100 ve-flex-col dm__panel-bg">
${$btnConnectRemote}
${$btnConnectLocal}
</div>`.appendTo($wrpTracker);
return $wrpTracker;
}
}
class InitiativeTrackerPlayerMessageHandlerScreenV1 extends InitiativeTrackerPlayerMessageHandlerV1 {
constructor () {
super(true);
this._$wrpInitial = null;
}
initUi () {
if (this._isUiInit) return;
this._isUiInit = true;
this._$meta.show();
this._$head.show();
this._$rows.show();
this._$wrpInitial.addClass("hidden");
}
set $wrpInitial ($wrpInitial) { this._$wrpInitial = $wrpInitial; }
get $wrpInitial () { return this._$wrpInitial; }
static initUnloadMessage () {
$(window).on("beforeunload", evt => {
const message = `The connection will be closed`;
(evt || window.event).message = message;
return message;
});
}
}
// endregion
/// /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// region v0
export class InitiativeTrackerPlayerV0 {
static $getPanelElement (board, state) {
const $meta = $(`<div class="initp__meta"/>`).hide();
const $head = $(`<div class="initp__header"/>`).hide();
const $rows = $(`<div class="ve-flex-col"></div>`).hide();
const $wrpTracker = $$`<div class="initp__wrp_active">
${$meta}
${$head}
${$rows}
</div>`;
const view = new InitiativeTrackerPlayerMessageHandlerScreenV0();
view.setElements($meta, $head, $rows);
const $btnConnectRemote = $(`<button class="btn btn-primary mb-2 min-w-200p" title="Connect to a tracker outside of this browser tab.">Connect to Remote Tracker</button>`)
.click(() => {
$btnConnectRemote.detach();
$btnConnectLocal.detach();
const $iptServerToken = $(`<input class="form-control input-sm code">`).disableSpellcheck();
const $btnGenClientToken = $(`<button class="btn btn-primary btn-xs">Generate Client Token</button>`);
const $iptClientToken = $(`<input class="form-control input-sm code copyable">`).disableSpellcheck();
const $btnCancel = $(`<button class="btn btn-default btn-xs">Back</button>`)
.click(() => {
// restore original state
$wrpClient.remove();
view.$wrpInitial.append($btnConnectRemote).append($btnConnectLocal);
});
const $wrpClient = $$`<div class="ve-flex-col w-100">
<div class="ve-flex-vh-center px-4 mb-2">
<span style="min-width: fit-content;" class="mr-2">Server Token</span>
${$iptServerToken}
</div>
<div class="ve-flex-v-center ve-flex-h-right px-4 mb-2">
${$btnGenClientToken}
</div>
<div class="ve-flex-vh-center px-4 mb-2">
<span style="min-width: fit-content;" class="mr-2">Client Token</span>
${$iptClientToken}
</div>
<div class="ve-flex-vh-center px-4">
${$btnCancel}
</div>
</div>`.appendTo(view.$wrpInitial);
const ui = new InitiativeTrackerPlayerUiV0(view, $iptServerToken, $btnGenClientToken, $iptClientToken);
ui.init();
});
const $btnConnectLocal = $(`<button class="btn btn-primary min-w-200p" title="Connect to a tracker in this browser tab.">Connect to Local Tracker</button>`)
.click(async () => {
const $elesData = DmScreenUtil.$getPanelDataElements({board, type: PANEL_TYP_INITIATIVE_TRACKER});
if ($elesData.length) {
if ($elesData.length === 1) {
await $elesData[0].data("pDoConnectLocalV0")(view);
} else {
$btnConnectRemote.detach();
$btnConnectLocal.detach();
const $selTracker = $(`<select class="form-control input-xs mr-1">
<option value="-1" disabled>Select a local tracker</option>
</select>`).change(() => $selTracker.removeClass("error-background"));
$elesData.forEach(($e, i) => $selTracker.append(`<option value="${i}">${$e.data("getSummary")()}</option>`));
$selTracker.val("-1");
const $btnOk = $(`<button class="btn btn-primary btn-xs">OK</button>`)
.click(async () => {
if ($selTracker.val() === "-1") return $selTracker.addClass("error-background");
await $elesData[Number($selTracker.val())].data("pDoConnectLocalV0")(view);
// restore original state
$btnCancel.remove(); $wrpSel.remove();
view.$wrpInitial.append($btnConnectRemote).append($btnConnectLocal);
});
const $wrpSel = $$`<div class="ve-flex-vh-center mb-2">
${$selTracker}
${$btnOk}
</div>`.appendTo(view.$wrpInitial);
const $btnCancel = $(`<button class="btn btn-default btn-xs">Back</button>`)
.click(() => {
// restore original state
$btnCancel.remove(); $wrpSel.remove();
view.$wrpInitial.append($btnConnectRemote).append($btnConnectLocal);
})
.appendTo(view.$wrpInitial);
}
} else {
JqueryUtil.doToast({content: "No local trackers detected!", type: "warning"});
}
});
view.$wrpInitial = $$`<div class="ve-flex-vh-center h-100 ve-flex-col dm__panel-bg">
${$btnConnectRemote}
${$btnConnectLocal}
</div>`.appendTo($wrpTracker);
return $wrpTracker;
}
}
class InitiativeTrackerPlayerMessageHandlerScreenV0 extends InitiativeTrackerPlayerMessageHandlerV0 {
constructor () {
super(true);
this._$wrpInitial = null;
}
initUi () {
if (this._isUiInit) return;
this._isUiInit = true;
this._$meta.show();
this._$head.show();
this._$rows.show();
this._$wrpInitial.addClass("hidden");
$(window).on("beforeunload", evt => {
if (this._clientData.client.isActive) {
const message = `The connection will be closed`;
(evt || window.event).message = message;
return message;
}
});
}
set $wrpInitial ($wrpInitial) { this._$wrpInitial = $wrpInitial; }
get $wrpInitial () { return this._$wrpInitial; }
}
// endregion

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,25 @@
export class DmScreenUtil {
// FIXME(Future) switch to a better interface than `data`
static $getPanelDataElements ({board, type, selector = ".dm__data-anchor"}) {
return board.getPanelsByType(type)
.flatMap(it => it.tabDatas.filter(td => td.type === type).map(td => td.$content.find(selector)));
}
/* -------------------------------------------- */
static async pGetScaledCreature ({name, source, scaledCr, scaledSummonSpellLevel, scaledSummonClassLevel}) {
const mon = await DataLoader.pCacheAndGet(
UrlUtil.PG_BESTIARY,
source,
UrlUtil.URL_TO_HASH_BUILDER[UrlUtil.PG_BESTIARY]({name: name, source: source}),
);
if (scaledCr == null && scaledSummonSpellLevel == null && scaledSummonClassLevel == null) return mon;
if (scaledCr != null) return ScaleCreature.scale(mon, scaledCr);
if (scaledSummonSpellLevel != null) return ScaleSpellSummonedCreature.scale(mon, scaledCr);
if (scaledSummonClassLevel != null) return ScaleClassSummonedCreature.scale(mon, scaledCr);
throw new Error(`Should never occur!`);
}
}

View File

@@ -0,0 +1,12 @@
export class InitiativeTrackerConditionUtil {
static getNewRowState ({name, color, turns}) {
return {
id: CryptUtil.uid(),
entity: {
name: name ?? "",
color: color ?? MiscUtil.randomColor(),
turns: turns ?? null,
},
};
}
}

View File

@@ -0,0 +1,241 @@
import {InitiativeTrackerUtil, UtilConditions} from "../../initiativetracker/initiativetracker-utils.js";
import {InitiativeTrackerConditionCustomEdit} from "./dmscreen-initiativetracker-conditioncustom.js";
import {InitiativeTrackerConditionUtil} from "./dmscreen-initiativetracker-condition.js";
class _UtilConditionsCustomView {
static $getBtnCondition ({comp, cbSubmit, cbClick}) {
const $btn = $(`<button class="btn btn-default btn-xs dm-init-cond__btn-cond" title="SHIFT to add with &quot;Unlimited&quot; duration; CTRL to add with 1-turn duration; SHIFT+CTRL to add with 10-turn duration."></button>`)
.on("click", evt => {
cbClick({
name: comp._state.name,
color: comp._state.color,
turns: comp._state.turns,
});
if (evt.shiftKey && EventUtil.isCtrlMetaKey(evt)) return cbSubmit({turns: 10});
if (EventUtil.isCtrlMetaKey(evt)) return cbSubmit({turns: 1});
if (evt.shiftKey) return cbSubmit({turns: null});
});
comp._addHookBase("color", () => $btn.css({"background-color": `${comp._state.color}`}))();
comp._addHookBase("name", () => $btn.text(comp._state.name || "\u00A0"))();
return $btn;
}
}
class _RenderableCollectionConditionsCustomView extends RenderableCollectionGenericRows {
constructor (
{
comp,
$wrpRows,
rdState,
cbDoSubmit,
},
) {
super(comp, "conditionsCustom", $wrpRows);
this._rdState = rdState;
this._cbDoSubmit = cbDoSubmit;
}
_$getWrpRow () {
return $(`<div class="ve-flex-vh-center w-33 my-1"></div>`);
}
/* -------------------------------------------- */
_populateRow ({comp, $wrpRow, entity}) {
_UtilConditionsCustomView.$getBtnCondition({
comp,
cbClick: ({name, color, turns}) => {
this._comp._state.name = name;
this._comp._state.color = color;
this._comp._state.turns = turns;
},
cbSubmit: ({turns}) => {
this._comp._state.turns = turns;
this._cbDoSubmit({rdState: this._rdState});
},
}).appendTo($wrpRow);
}
}
export class InitiativeTrackerConditionAdd extends BaseComponent {
static _RenderState = class {
constructor () {
this.cbDoClose = null;
}
};
constructor ({conditionsCustom}) {
super();
this.__state.conditionsCustom = conditionsCustom;
}
getConditionsCustom () {
return MiscUtil.copyFast(this._state.conditionsCustom);
}
async pGetShowModalResults () {
const rdState = new this.constructor._RenderState();
const {$modalInner, doClose, pGetResolved} = UiUtil.getShowModal({
isMinHeight0: true,
isHeaderBorder: true,
title: "Add Condition",
$titleSplit: this._render_$getBtnEditCustom({rdState}),
});
rdState.cbDoClose = doClose;
$$($modalInner)`
${this._render_$getStgConditionsStandard({rdState})}
<hr class="hr-3">
${this._render_$getStgConditionsCustom({rdState})}
${this._render_$getStgIpts({rdState})}
${this._render_$getStgSubmit({rdState})}
`;
return pGetResolved();
}
_render_$getBtnEditCustom ({rdState}) {
return $(`<button class="btn btn-default btn-xs" title="Manage Custom Conditions"><span class="glyphicon glyphicon-cog"></span></button>`)
.on("click", async () => {
const compEdit = new InitiativeTrackerConditionCustomEdit({conditionsCustom: MiscUtil.copyFast(this._state.conditionsCustom)});
await compEdit.pGetShowModalResults();
this._state.conditionsCustom = compEdit.getConditionsCustom();
});
}
_render_$getStgConditionsStandard ({rdState}) {
const $wrps = InitiativeTrackerUtil.CONDITIONS
.map(cond => {
const $btn = _UtilConditionsCustomView.$getBtnCondition({
comp: BaseComponent.fromObject({
name: cond.name,
color: cond.color,
turns: cond.turns,
}, "*"),
cbClick: ({name, color, turns}) => {
this._state.name = name;
this._state.color = color;
},
cbSubmit: ({turns}) => {
this._state.turns = turns;
this._doSubmit({rdState});
},
});
return $$`<div class="ve-flex-vh-center w-33 my-1">${$btn}</div>`;
});
return $$`
<div class="ve-flex-col w-100 h-100 min-h-0 ve-flex-v-center">
<div class="ve-flex-wrap w-100 h-100 min-h-0 dm-init-cond__wrp-btns">
${$wrps}
</div>
</div>
`;
}
_render_$getStgConditionsCustom ({rdState}) {
const $wrpRows = $(`<div class="ve-flex-wrap w-100 min-h-0 dm-init-cond__wrp-btns"></div>`);
const compRows = new _RenderableCollectionConditionsCustomView({
comp: this,
$wrpRows,
rdState,
cbDoSubmit: this._doSubmit.bind(this),
});
this._addHookBase("conditionsCustom", () => compRows.render())();
const $stg = $$`<div class="ve-flex-col w-100 h-100 min-h-0 ve-flex-v-center">
${$wrpRows}
<hr class="hr-3">
</div>`;
this._addHookBase("conditionsCustom", () => $stg.toggleVe(!!this._state.conditionsCustom.length))();
return $stg;
}
_render_$getStgIpts ({rdState}) {
const $iptName = ComponentUiUtil.$getIptStr(this, "name", {html: `<input class="form-control">`})
.on("keydown", evt => {
if (evt.key !== "Enter") return;
$iptName.trigger("change");
this._doSubmit({rdState});
});
const $iptColor = ComponentUiUtil.$getIptColor(this, "color", {html: `<input class="form-control" type="color">`});
const $iptTurns = ComponentUiUtil.$getIptInt(this, "turns", null, {isAllowNull: true, fallbackOnNaN: null, html: `<input class="form-control" placeholder="Unlimited">`})
.on("keydown", evt => {
if (evt.key !== "Enter") return;
$iptTurns.trigger("change");
this._doSubmit({rdState});
});
const $btnSave = $(`<button class="btn btn-default w-100" title="Save as New Custom Condition"><span class="glyphicon glyphicon-floppy-disk"></span></button>`)
.click(() => {
this._state.conditionsCustom = [
...this._state.conditionsCustom,
InitiativeTrackerConditionUtil.getNewRowState({
name: this._state.name,
color: this._state.color,
turns: this._state.turns,
}),
];
});
return $$`
<div class="ve-flex-v-center mb-2">
<div class="small-caps col-5 pr-1">Name</div>
<div class="small-caps col-2 px-1">Color</div>
<div class="small-caps col-4 px-1">Duration</div>
<div class="col-1 pl-1">&nbsp;</div>
</div>
<div class="ve-flex-v-center mb-3">
<div class="col-5 pr-1">${$iptName}</div>
<div class="col-2 px-1">${$iptColor}</div>
<div class="col-4 px-1">${$iptTurns}</div>
<div class="col-1 pl-1">${$btnSave}</div>
</div>
`;
}
_render_$getStgSubmit ({rdState}) {
const $btnAdd = $(`<button class="btn btn-primary w-100">Set Condition</button>`)
.click(() => this._doSubmit({rdState}));
return $$`
<div class="ve-flex-v-center">
${$btnAdd}
</div>
`;
}
_doSubmit ({rdState}) {
rdState.cbDoClose(
true,
UtilConditions.getDefaultState({
name: this._state.name,
color: this._state.color,
turns: this._state.turns,
}),
);
}
_getDefaultState () {
return {
name: "",
color: MiscUtil.randomColor(),
turns: null,
conditionsCustom: [],
};
}
}

View File

@@ -0,0 +1,112 @@
import {InitiativeTrackerConditionUtil} from "./dmscreen-initiativetracker-condition.js";
class _RenderableCollectionConditionsCustomEdit extends RenderableCollectionGenericRows {
constructor (
{
comp,
$wrpRows,
},
) {
super(comp, "conditionsCustom", $wrpRows);
}
/* -------------------------------------------- */
_populateRow ({comp, $wrpRow, entity}) {
const $iptName = ComponentUiUtil.$getIptStr(comp, "name");
const $iptColor = ComponentUiUtil.$getIptColor(comp, "color")
.addClass("w-100");
const $iptTurns = ComponentUiUtil.$getIptInt(comp, "turns", null, {isAllowNull: true, fallbackOnNaN: null})
.addClass("mr-2")
.placeholder("Unlimited");
const $btnDelete = this._utils.$getBtnDelete({entity});
$$($wrpRow)`
<div class="ve-flex-vh-center w-100 my-1">
<div class="col-5 pr-1 ve-flex-v-center">${$iptName}</div>
<div class="col-2 px-1 ve-flex-v-center">${$iptColor}</div>
<div class="col-5 pr-1 ve-flex-v-center">
${$iptTurns}
<div class="ve-flex-vh-center btn-group">
${$btnDelete}
</div>
</div>
</div>
`;
}
}
export class InitiativeTrackerConditionCustomEdit extends BaseComponent {
static _RenderState = class {
constructor () {
this.cbDoClose = null;
}
};
constructor ({conditionsCustom}) {
super();
this._state.conditionsCustom = conditionsCustom;
}
getConditionsCustom () {
return MiscUtil.copyFast(this._state.conditionsCustom);
}
async pGetShowModalResults () {
const rdState = new this.constructor._RenderState();
const {$modalInner, $modalFooter, doClose, pGetResolved} = UiUtil.getShowModal({
title: "Manage Custom Conditions",
isHeaderBorder: true,
hasFooter: true,
});
rdState.cbDoClose = doClose;
const $btnAdd = $(`<button class="btn btn-default btn-xs bb-0 bbr-0 bbl-0" title="Add"><span class="glyphicon glyphicon-plus"></span></button>`)
.on("click", () => {
this._state.conditionsCustom = [...this._state.conditionsCustom, InitiativeTrackerConditionUtil.getNewRowState()];
});
const $wrpRows = $(`<div class="ve-flex-col h-100 min-h-0 overflow-y-auto"></div>`);
const compRows = new _RenderableCollectionConditionsCustomEdit({comp: this, $wrpRows});
this._addHookBase("conditionsCustom", () => compRows.render())();
$$($modalInner)`
<div class="ve-flex-col mt-2 h-100 min-h-0">
<div class="ve-flex-vh-center w-100 mb-2 bb-1p-trans">
<div class="col-5">Name</div>
<div class="col-2">Color</div>
<div class="col-4">Turns</div>
<div class="col-1 ve-flex-v-center ve-flex-h-right">${$btnAdd}</div>
</div>
${$wrpRows}
</div>
`;
$$($modalFooter)`
${this._render_$getFooter({rdState})}
`;
return pGetResolved();
}
_render_$getFooter ({rdState}) {
const $btnSave = $(`<button class="btn btn-primary btn-sm w-100">Save</button>`)
.click(() => rdState.cbDoClose(true));
return $$`<div class="w-100 py-3 no-shrink">
${$btnSave}
</div>`;
}
_getDefaultState () {
return {
conditionsCustom: [],
};
}
}

View File

@@ -0,0 +1,11 @@
export class InitiativeTrackerConst {
static SORT_ORDER_ALPHA = "ALPHA";
static SORT_ORDER_NUM = "NUMBER";
static SORT_DIR_ASC = "ASC";
static SORT_DIR_DESC = "DESC";
static DIR_FORWARDS = 1;
static DIR_BACKWARDS = -1;
static DIR_NEUTRAL = 0;
}

View File

@@ -0,0 +1,99 @@
import {
InitiativeTrackerRowDataViewDefaultParty,
} from "./dmscreen-initiativetracker-rowsdefaultparty.js";
export class InitiativeTrackerDefaultParty extends BaseComponent {
static _RenderState = class {
constructor () {
this.cbDoClose = null;
this.fnsCleanup = [];
}
};
constructor ({comp, roller, rowStateBuilder}) {
super();
this._comp = comp;
this._roller = roller;
this._rowStateBuilder = rowStateBuilder;
this._prop = "rowsDefaultParty";
this._viewRowsDefaultParty = null;
}
/* -------------------------------------------- */
pGetShowModalResults () {
const rdState = new this.constructor._RenderState();
const {$modalInner, $modalFooter, pGetResolved, doClose} = UiUtil.getShowModal({
title: "Edit Default Party",
isHeaderBorder: true,
isUncappedHeight: true,
hasFooter: true,
cbClose: () => rdState.fnsCleanup.forEach(fn => fn()),
$titleSplit: this._render_$getBtnAdd({rdState}),
});
rdState.cbDoClose = doClose;
this._render_renderBody({rdState, $modalInner});
this._render_renderFooter({rdState, $modalFooter});
return pGetResolved();
}
_render_$getBtnAdd ({rdState}) {
return $(`<button class="btn btn-default btn-xs" title="Add Player"><span class="glyphicon glyphicon-plus"></span></button>`)
.on("click", async () => {
this._comp._state[this._prop] = [
...this._comp._state[this._prop],
await this._rowStateBuilder.pGetNewRowState(),
];
});
}
/* -------------------------------------------- */
_render_renderBody ({rdState, $modalInner}) {
this._viewRowsDefaultParty = new InitiativeTrackerRowDataViewDefaultParty({
comp: this._comp,
prop: this._prop,
roller: this._roller,
rowStateBuilder: this._rowStateBuilder,
});
this._viewRowsDefaultPartyMeta = this._viewRowsDefaultParty.getRenderedView();
this._viewRowsDefaultPartyMeta.$ele.appendTo($modalInner);
rdState.fnsCleanup.push(this._viewRowsDefaultPartyMeta.cbDoCleanup);
}
/* -------------------------------------------- */
_render_renderFooter ({rdState, $modalFooter}) {
const $btnSave = $(`<button class="btn btn-primary btn-sm w-100">Save</button>`)
.click(() => rdState.cbDoClose(true));
$$($modalFooter)`<div class="w-100 py-3 no-shrink">
${$btnSave}
</div>`;
}
/* -------------------------------------------- */
async pGetConvertedDefaultPartyActiveRows () {
const rows = MiscUtil.copyFast(this._comp._state.rowsDefaultParty);
if (!this._comp._state.isRollInit) return rows;
await rows.pSerialAwaitMap(async row => {
const {entity} = row;
const {initiativeModifier} = await this._rowStateBuilder.pGetRowInitiativeMeta({row});
// Skip rolling no-modifier-exists initiative for player rows, as we assume the user wants to input them
// manually.
if (initiativeModifier == null) return;
entity.initiative = await this._roller.pGetRollInitiative({initiativeModifier, name: entity.name});
});
return rows;
}
}

View File

@@ -0,0 +1,206 @@
import {InitiativeTrackerStatColumnFactory} from "./dmscreen-initiativetracker-statcolumns.js";
class _ConvertedEncounter {
constructor () {
this.isStatsAddColumns = false;
this.statsCols = [];
this.rows = [];
}
}
export class InitiativeTrackerEncounterConverter {
constructor (
{
roller,
rowStateBuilderActive,
importIsAddPlayers,
importIsRollGroups,
isRollInit,
isRollHp,
},
) {
this._roller = roller;
this._rowStateBuilderActive = rowStateBuilderActive;
this._importIsAddPlayers = importIsAddPlayers;
this._importIsRollGroups = importIsRollGroups;
this._isRollInit = isRollInit;
this._isRollHp = isRollHp;
}
async pGetConverted ({entityInfos, encounterInfo}) {
const out = new _ConvertedEncounter();
await this._pGetConverted_pPlayers({entityInfos, encounterInfo, out});
await this._pGetConverted_pCreatures({entityInfos, encounterInfo, out});
return out;
}
/* -------------------------------------------- */
async _pGetConverted_pPlayers ({entityInfos, encounterInfo, out}) {
if (!this._importIsAddPlayers) return;
await this._pGetConverted_pPlayers_advanced({entityInfos, encounterInfo, out});
await this._pGetConverted_pPlayers_simple({entityInfos, encounterInfo, out});
}
async _pGetConverted_pPlayers_advanced ({entityInfos, encounterInfo, out}) {
if (!encounterInfo.isAdvanced || !encounterInfo.playersAdvanced) return;
const colNameIndex = {};
encounterInfo.colsExtraAdvanced = encounterInfo.colsExtraAdvanced || [];
if (encounterInfo.colsExtraAdvanced.length) out.isStatsAddColumns = true;
encounterInfo.colsExtraAdvanced.forEach((col, i) => colNameIndex[i] = (col?.name || "").toLowerCase());
const {ixsColLookup, ixExtrasHp, statsCols} = this._pGetConverted_pPlayers_advanced_getExtrasInfo({encounterInfo});
out.statsCols.push(...statsCols);
await encounterInfo.playersAdvanced
.pSerialAwaitMap(async playerDetails => {
out.rows.push(
await this._rowStateBuilderActive
.pGetNewRowState({
isActive: false,
isPlayerVisible: true,
name: playerDetails.name || "",
initiative: null,
conditions: [],
...this._pGetConverted_pPlayers_advanced_extras({
playerDetails,
ixExtrasHp,
ixsColLookup,
}),
}),
);
});
}
_pGetConverted_pPlayers_advanced_getExtrasInfo ({encounterInfo}) {
const statsCols = [];
const ixsColLookup = {};
let ixExtrasHp = null;
encounterInfo.colsExtraAdvanced.forEach((col, i) => {
let colName = col?.name || "";
if (colName.toLowerCase() === "hp") {
ixExtrasHp = i;
return;
}
const newCol = InitiativeTrackerStatColumnFactory.fromEncounterAdvancedColName({colName});
ixsColLookup[i] = newCol;
statsCols.push(newCol);
});
return {ixsColLookup, ixExtrasHp, statsCols};
}
_pGetConverted_pPlayers_advanced_extras ({playerDetails, ixExtrasHp, ixsColLookup}) {
const out = {
hpCurrent: null,
hpMax: null,
};
if (!playerDetails.extras?.length) return out;
const rowStatColData = playerDetails.extras
.map((extra, i) => {
const val = extra?.value || "";
if (i === ixExtrasHp) return null;
const meta = ixsColLookup[i];
return meta.getInitialCellStateData({obj: {value: val}});
})
.filter(Boolean);
if (ixExtrasHp == null) {
return {
...out,
rowStatColData,
};
}
const [hpCurrent, hpMax] = (playerDetails.extras[ixExtrasHp]?.value || "")
.split("/")
.map(it => {
const clean = it.trim();
if (!clean) return null;
if (isNaN(clean)) return null;
return Number(clean);
});
return {
...out,
hpCurrent,
hpMax: hpCurrent != null && hpMax == null ? hpCurrent : hpCurrent,
rowStatColData,
};
}
async _pGetConverted_pPlayers_simple ({entityInfos, encounterInfo, out}) {
if (encounterInfo.isAdvanced || !encounterInfo.playersSimple) return;
await encounterInfo.playersSimple
.pSerialAwaitMap(async playerGroup => {
await [...new Array(playerGroup.count || 1)]
.pSerialAwaitMap(async () => {
out.rows.push(
await this._rowStateBuilderActive
.pGetNewRowState({
name: "",
hpCurrent: null,
hpMax: null,
initiative: null,
isActive: false,
conditions: [],
isPlayerVisible: true,
}),
);
});
});
}
/* -------------------------------------------- */
async _pGetConverted_pCreatures ({entityInfos, encounterInfo, out}) {
if (!entityInfos?.length) return;
await entityInfos
.filter(Boolean)
.pSerialAwaitMap(async entityInfo => {
const groupInit = this._importIsRollGroups && this._isRollInit ? await this._roller.pGetRollInitiative({mon: entityInfo.entity}) : null;
const groupHp = this._importIsRollGroups ? await this._roller.pGetOrRollHp(entityInfo.entity, {isRollHp: this._isRollHp}) : null;
await [...new Array(entityInfo.count || 1)]
.pSerialAwaitMap(async () => {
const hpVal = this._importIsRollGroups
? groupHp
: await this._roller.pGetOrRollHp(entityInfo.entity, {isRollHp: this._isRollHp});
out.rows.push(
await this._rowStateBuilderActive
.pGetNewRowState({
rows: out.rows,
name: entityInfo.entity.name,
displayName: entityInfo.entity._displayName,
scaledCr: entityInfo.entity._scaledCr,
scaledSummonSpellLevel: entityInfo.entity._summonedBySpell_level,
scaledSummonClassLevel: entityInfo.entity._summonedByClass_level,
initiative: this._isRollInit
? this._importIsRollGroups ? groupInit : await this._roller.pGetRollInitiative({mon: entityInfo.entity})
: null,
isActive: false,
source: entityInfo.entity.source,
conditions: [],
hpCurrent: hpVal,
hpMax: hpVal,
}),
);
});
});
}
}

View File

@@ -0,0 +1,68 @@
export class InitiativeTrackerSettingsImport extends BaseComponent {
static _PROPS_TRACKED = [
"isRollInit",
"isRollHp",
"importIsRollGroups",
"importIsAddPlayers",
"importIsAppend",
];
constructor ({state}) {
super();
this._proxyAssignSimple(
"state",
InitiativeTrackerSettingsImport._PROPS_TRACKED
.mergeMap(prop => ({[prop]: state[prop]})),
);
}
/* -------------------------------------------- */
getStateUpdate () {
return MiscUtil.copyFast(this._state);
}
/* -------------------------------------------- */
pGetShowModalResults () {
const {$modalInner, $modalFooter, pGetResolved, doClose} = UiUtil.getShowModal({
title: "Import Settings",
isUncappedHeight: true,
hasFooter: true,
});
UiUtil.addModalSep($modalInner);
this._pGetShowModalResults_renderSection_isRolls({$modalInner});
UiUtil.addModalSep($modalInner);
this._pGetShowModalResults_renderSection_import({$modalInner});
this._pGetShowModalResults_renderFooter({$modalFooter, doClose});
return pGetResolved();
}
/* -------------------------------------------- */
_pGetShowModalResults_renderSection_isRolls ({$modalInner}) {
UiUtil.$getAddModalRowCb2({$wrp: $modalInner, comp: this, prop: "isRollInit", text: "Roll creature initiative"});
UiUtil.$getAddModalRowCb2({$wrp: $modalInner, comp: this, prop: "isRollHp", text: "Roll creature hit points"});
}
_pGetShowModalResults_renderSection_import ({$modalInner}) {
UiUtil.$getAddModalRowCb2({$wrp: $modalInner, comp: this, prop: "importIsRollGroups", text: "Roll groups of creatures together"});
UiUtil.$getAddModalRowCb2({$wrp: $modalInner, comp: this, prop: "importIsAddPlayers", text: "Add players"});
UiUtil.$getAddModalRowCb2({$wrp: $modalInner, comp: this, prop: "importIsAppend", text: "Add to existing tracker state"});
}
/* -------------------------------------------- */
_pGetShowModalResults_renderFooter ({$modalFooter, doClose}) {
const $btnSave = $(`<button class="btn btn-primary btn-sm w-100">Save</button>`)
.click(() => doClose(true));
$$($modalFooter)`<div class="w-100 py-3 no-shrink">
${$btnSave}
</div>`;
}
}

View File

@@ -0,0 +1,380 @@
class _MonstersToLoad {
constructor (
{
count,
name,
source,
isRollHp,
displayName,
customName,
scaledCr,
scaledSummonSpellLevel,
scaledSummonClassLevel,
},
) {
this.count = count;
this.name = name;
this.source = source;
this.isRollHp = isRollHp;
this.displayName = displayName;
this.customName = customName;
this.scaledCr = scaledCr;
this.scaledSummonSpellLevel = scaledSummonSpellLevel;
this.scaledSummonClassLevel = scaledSummonClassLevel;
}
}
class _InitiativeTrackerMonsterAddCustomizer extends BaseComponent {
static _RenderState = class {
constructor () {
this.cbDoClose = null;
}
};
constructor ({mon}) {
super();
this._mon = mon;
}
async pGetShowModalResults () {
const rdState = new this.constructor._RenderState();
const {$modalInner, $modalFooter, doClose, pGetResolved} = UiUtil.getShowModal({
title: `Customize Creature \u2014 ${this._mon.name}`,
isHeaderBorder: true,
hasFooter: true,
isMinHeight0: true,
});
rdState.cbDoClose = doClose;
const $iptCustomName = ComponentUiUtil.$getIptStr(this, "customName");
$$($modalInner)`
<div class="ve-flex-col py-2 w-100 h-100 overflow-y-auto">
<label class="split-v-center mb-2">
<span class="w-200p text-right no-shrink mr-2 bold">Custom Name:</span>
${$iptCustomName}
</label>
${this._render_$getRowScaler()}
</div>
`;
$$($modalFooter)`
${this._render_$getFooter({rdState})}
`;
return pGetResolved();
}
_render_$getRowScaler () {
const isShowCrScaler = Parser.crToNumber(this._mon.cr) !== VeCt.CR_UNKNOWN;
const isShowSpellLevelScaler = !isShowCrScaler && this._mon.summonedBySpellLevel != null;
const isShowClassLevelScaler = !isShowSpellLevelScaler && this._mon.summonedByClass != null;
if (!isShowCrScaler && !isShowSpellLevelScaler && !isShowClassLevelScaler) return null;
if (isShowSpellLevelScaler) {
const sel = Renderer.monster.getSelSummonSpellLevel(this._mon)
.on("change", async () => {
const val = Number(sel.val());
this._state.scaledSummonSpellLevel = !~val ? null : val;
if (this._state.scaledSummonSpellLevel == null) return delete this._state.displayName;
this._state.displayName = (await ScaleSpellSummonedCreature.scale(this._mon, this._state.scaledSummonSpellLevel))._displayName;
});
return $$`<label class="split-v-center mb-2">
<span class="w-200p text-right no-shrink mr-2 bold">Spell Level:</span>
${sel}
</label>`;
}
if (isShowClassLevelScaler) {
const sel = Renderer.monster.getSelSummonClassLevel(this._mon)
.on("change", async () => {
const val = Number(sel.val());
this._state.scaledSummonClassLevel = !~val ? null : val;
if (this._state.scaledSummonClassLevel == null) return delete this._state.displayName;
this._state.displayName = (await ScaleClassSummonedCreature.scale(this._mon, this._state.scaledSummonClassLevel))._displayName;
});
return $$`<label class="split-v-center mb-2">
<span class="w-200p text-right no-shrink mr-2 bold">Class Level:</span>
${sel}
</label>`;
}
const $dispScaledCr = $(`<span class="inline-block"></span>`);
this._addHookBase("scaledCr", () => $dispScaledCr.text(this._state.scaledCr ? Parser.numberToCr(this._state.scaledCr) : `${(this._mon.cr.cr || this._mon.cr)} (default)`))();
const $btnScaleCr = $(`<button class="btn btn-default btn-xs mr-2"><span class="glyphicon glyphicon-signal"></span></button>`)
.on("click", async () => {
const crBase = this._mon.cr.cr || this._mon.cr;
const cr = await InputUiUtil.pGetUserScaleCr({default: crBase});
if (cr == null) return;
if (crBase === cr) {
delete this._state.scaledCr;
delete this._state.displayName;
return;
}
this._state.scaledCr = Parser.crToNumber(cr);
this._state.displayName = (await ScaleCreature.scale(this._mon, this._state.scaledCr))._displayName;
});
return $$`<label class="split-v-center mb-2">
<span class="w-200p text-right no-shrink mr-2 bold">CR:</span>
<span class="ve-flex-v-center mr-auto">
${$btnScaleCr}
${$dispScaledCr}
</span>
</label>`;
}
_render_$getFooter ({rdState}) {
const $btnSave = $(`<button class="btn btn-primary btn-sm w-100">Save</button>`)
.click(() => {
rdState.cbDoClose(
true,
MiscUtil.copyFast(this.__state),
);
});
return $$`<div class="w-100 py-3 no-shrink">
${$btnSave}
</div>`;
}
_getDefaultState () {
return {
customName: null,
displayName: null,
scaledCr: null,
scaledSummonSpellLevel: null,
scaledSummonClassLevel: null,
};
}
}
export class InitiativeTrackerMonsterAdd extends BaseComponent {
static _RESULTS_MAX_DISPLAY = 75; // hard cap at 75 results
static _RenderState = class {
constructor () {
this.cbDoClose = null;
}
};
constructor ({board, isRollHp}) {
super();
this._board = board;
this._state.isRollHp = isRollHp;
}
_getDefaultState () {
return {
isRollHp: false,
cntToAdd: 1,
cntToAddCustom: 13,
};
}
_getCntToAdd () {
return this._state.cntToAdd === -1
? Math.max(1, this._state.cntToAddCustom)
: this._state.cntToAdd;
}
/* -------------------------------------------- */
_$getCbCntToAdd ({cnt}) {
const $cb = $(`<input type="radio" class="ui-search__ipt-search-sub-ipt">`);
$cb.on("change", () => {
this._state.cntToAdd = cnt;
});
this._addHookBase("cntToAdd", () => $cb.prop("checked", this._state.cntToAdd === cnt))();
return $cb;
}
_$getIptCntToAddCustom () {
const $iptCntToAddCustom = ComponentUiUtil.$getIptInt(
this,
"cntToAddCustom",
1,
{
html: `<input type="number" class="form-control ui-search__ipt-search-sub-ipt-custom">`,
min: 1,
},
);
this._addHookBase("cntToAdd", () => {
if (this._state.cntToAdd !== -1) return;
$iptCntToAddCustom.select();
})();
$iptCntToAddCustom.click(() => {
this._state.cntToAdd = -1;
});
return $iptCntToAddCustom;
}
/**
* @return {Promise<[boolean, _MonstersToLoad]>}
*/
async pGetShowModalResults () {
const rdState = new this.constructor._RenderState();
const flags = {
doClickFirst: false,
isWait: false,
};
const {$modalInner, doClose, pGetResolved} = UiUtil.getShowModal();
rdState.cbDoClose = doClose;
const $iptSearch = $(`<input class="ui-search__ipt-search search form-control" autocomplete="off" placeholder="Search...">`)
.blurOnEsc();
$$`<div class="split no-shrink">
${$iptSearch}
<div class="ui-search__ipt-search-sub-wrp ve-flex-v-center pr-0">
<div class="mr-1">Add</div>
<label class="ui-search__ipt-search-sub-lbl">${this._$getCbCntToAdd({cnt: 1})} 1</label>
<label class="ui-search__ipt-search-sub-lbl">${this._$getCbCntToAdd({cnt: 2})} 2</label>
<label class="ui-search__ipt-search-sub-lbl">${this._$getCbCntToAdd({cnt: 3})} 3</label>
<label class="ui-search__ipt-search-sub-lbl">${this._$getCbCntToAdd({cnt: 5})} 5</label>
<label class="ui-search__ipt-search-sub-lbl">${this._$getCbCntToAdd({cnt: 8})} 8</label>
<label class="ui-search__ipt-search-sub-lbl">${this._$getCbCntToAdd({cnt: -1})} ${this._$getIptCntToAddCustom()}</label>
</div>
<label class="ui-search__ipt-search-sub-wrp ve-flex-vh-center">${ComponentUiUtil.$getCbBool(this, "isRollHp").addClass("mr-1")} <span>Roll HP</span></label>
</div>`.appendTo($modalInner);
const $results = $(`<div class="ui-search__wrp-results"></div>`).appendTo($modalInner);
const showMsgIpt = () => {
flags.isWait = true;
$results.empty().append(SearchWidget.getSearchEnter());
};
const showMsgDots = () => $results.empty().append(SearchWidget.getSearchLoading());
const showNoResults = () => {
flags.isWait = true;
$results.empty().append(SearchWidget.getSearchNoResults());
};
const $ptrRows = {_: []};
const doSearch = () => {
const searchTerm = $iptSearch.val().trim();
const index = this._board.availContent["Creature"];
const results = index.search(searchTerm, {
fields: {
n: {boost: 5, expand: true},
s: {expand: true},
},
bool: "AND",
expand: true,
});
const resultCount = results.length ? results.length : index.documentStore.length;
const toProcess = results.length ? results : Object.values(index.documentStore.docs).slice(0, 75).map(it => ({doc: it}));
$results.empty();
$ptrRows._ = [];
if (toProcess.length) {
if (flags.doClickFirst) {
this._render_pHandleClickRow({rdState}, toProcess[0]);
flags.doClickFirst = false;
return;
}
const results = toProcess.slice(0, this.constructor._RESULTS_MAX_DISPLAY);
results.forEach(res => {
const $row = this._render_$getSearchRow({rdState, res}).appendTo($results);
SearchWidget.bindRowHandlers({result: res, $row, $ptrRows, fnHandleClick: this._render_pHandleClickRow.bind(this, {rdState}), $iptSearch});
$ptrRows._.push($row);
});
if (resultCount > this.constructor._RESULTS_MAX_DISPLAY) {
const diff = resultCount - this.constructor._RESULTS_MAX_DISPLAY;
$results.append(`<div class="ui-search__row ui-search__row--readonly">...${diff} more result${diff === 1 ? " was" : "s were"} hidden. Refine your search!</div>`);
}
} else {
if (!searchTerm.trim()) showMsgIpt();
else showNoResults();
}
};
SearchWidget.bindAutoSearch($iptSearch, {
flags,
fnSearch: doSearch,
fnShowWait: showMsgDots,
$ptrRows,
});
$iptSearch.focus();
doSearch();
return pGetResolved();
}
async _render_pHandleClickRow ({rdState}, res) {
await rdState.cbDoClose(
true,
new _MonstersToLoad({
count: this._getCntToAdd(),
name: res.doc.n,
source: res.doc.s,
isRollHp: this._state.isRollHp,
}),
);
}
_render_$getSearchRow ({rdState, res}) {
const $btnCustomize = $(`<button class="btn btn-default btn-xxs" title="Customize"><span class="glyphicon glyphicon-stats"></span></button>`)
.on("click", async evt => {
evt.stopPropagation();
await this._render_pHandleClickCustomize({rdState, res});
});
return $$`
<div class="ui-search__row ve-flex-v-center" tabindex="0">
<span>${res.doc.n}</span>
<div class="ve-flex-vh-center">
<span class="mr-2">${res.doc.s ? `<i title="${Parser.sourceJsonToFull(res.doc.s)}">${Parser.sourceJsonToAbv(res.doc.s)}${res.doc.p ? ` p${res.doc.p}` : ""}</i>` : ""}</span>
${$btnCustomize}
</div>
</div>
`;
}
async _render_pHandleClickCustomize ({rdState, res}) {
const mon = await DataLoader.pCacheAndGet(UrlUtil.PG_BESTIARY, res.doc.s, res.doc.u);
if (!mon) return;
const comp = new _InitiativeTrackerMonsterAddCustomizer({mon});
const resModal = await comp.pGetShowModalResults();
if (resModal == null) return;
const [isDataEntered, data] = resModal;
if (!isDataEntered) return;
await rdState.cbDoClose(
true,
new _MonstersToLoad({
count: this._getCntToAdd(),
name: res.doc.n,
source: res.doc.s,
isRollHp: this._state.isRollHp,
...data,
}),
);
}
}

View File

@@ -0,0 +1,561 @@
class _InitiativeTrackerNetworkingP2pMetaV1 {
constructor () {
this.rows = [];
this.serverInfo = null;
this.serverPeer = null;
}
}
class _InitiativeTrackerNetworkingP2pMetaV0 {
constructor () {
this.rows = [];
this.serverInfo = null;
}
}
export class InitiativeTrackerNetworking {
constructor ({board}) {
this._board = board;
this._p2pMetaV1 = new _InitiativeTrackerNetworkingP2pMetaV1();
this._p2pMetaV0 = new _InitiativeTrackerNetworkingP2pMetaV0();
}
/* -------------------------------------------- */
sendStateToClients ({fnGetToSend}) {
return this._sendMessageToClients({fnGetToSend});
}
sendShowImageMessageToClients ({imageHref}) {
return this._sendMessageToClients({
fnGetToSend: () => ({
type: "showImage",
payload: {
imageHref,
},
}),
});
}
_sendMessageToClients ({fnGetToSend}) {
let toSend = null;
// region V1
if (this._p2pMetaV1.serverPeer) {
if (!this._p2pMetaV1.serverPeer.hasConnections()) return;
toSend ||= fnGetToSend();
this._p2pMetaV1.serverPeer.sendMessage(toSend);
}
// endregion
// region V0
if (this._p2pMetaV0.serverInfo) {
this._p2pMetaV0.rows = this._p2pMetaV0.rows.filter(row => !row.isDeleted);
this._p2pMetaV0.serverInfo = this._p2pMetaV0.serverInfo.filter(row => {
if (row.isDeleted) {
row.server.close();
return false;
}
return true;
});
toSend ||= fnGetToSend();
try {
this._p2pMetaV0.serverInfo.filter(info => info.server.isActive).forEach(info => info.server.sendMessage(toSend));
} catch (e) { setTimeout(() => { throw e; }); }
}
// endregion
}
/* -------------------------------------------- */
/**
* @param opts
* @param opts.doUpdateExternalStates
* @param [opts.$btnStartServer]
* @param [opts.$btnGetToken]
* @param [opts.$btnGetLink]
* @param [opts.fnDispServerStoppedState]
* @param [opts.fnDispServerRunningState]
*/
async startServerV1 (opts) {
opts = opts || {};
if (this._p2pMetaV1.serverPeer) {
await this._p2pMetaV1.serverPeer.pInit();
return {
isRunning: true,
token: this._p2pMetaV1.serverPeer?.token,
};
}
try {
if (opts.$btnStartServer) opts.$btnStartServer.prop("disabled", true);
this._p2pMetaV1.serverPeer = new PeerVeServer();
await this._p2pMetaV1.serverPeer.pInit();
if (opts.$btnGetToken) opts.$btnGetToken.prop("disabled", false);
if (opts.$btnGetLink) opts.$btnGetLink.prop("disabled", false);
this._p2pMetaV1.serverPeer.on("connection", connection => {
const pConnected = new Promise(resolve => {
connection.on("open", () => {
resolve(true);
opts.doUpdateExternalStates();
});
});
const pTimeout = MiscUtil.pDelay(5 * 1000, false);
Promise.race([pConnected, pTimeout])
.then(didConnect => {
if (!didConnect) {
JqueryUtil.doToast({content: `Connecting to "${connection.label.escapeQuotes()}" has taken more than 5 seconds! The connection may need to be re-attempted.`, type: "warning"});
}
});
});
$(window).on("beforeunload", evt => {
const message = `The connection will be closed`;
(evt || window.event).message = message;
return message;
});
if (opts.fnDispServerRunningState) opts.fnDispServerRunningState();
return {
isRunning: true,
token: this._p2pMetaV1.serverPeer?.token,
};
} catch (e) {
if (opts.fnDispServerStoppedState) opts.fnDispServerStoppedState();
if (opts.$btnStartServer) opts.$btnStartServer.prop("disabled", false);
this._p2pMetaV1.serverPeer = null;
JqueryUtil.doToast({content: `Failed to start server! ${VeCt.STR_SEE_CONSOLE}`, type: "danger"});
setTimeout(() => { throw e; });
}
return {
isRunning: false,
token: this._p2pMetaV1.serverPeer?.token,
};
}
handleClick_playerWindowV1 ({doUpdateExternalStates}) {
const {$modalInner} = UiUtil.getShowModal({
title: "Configure Player View",
isUncappedHeight: true,
isHeight100: true,
cbClose: () => {
if (this._p2pMetaV1.rows.length) this._p2pMetaV1.rows.forEach(row => row.$row.detach());
if (this._p2pMetaV1.serverPeer) this._p2pMetaV1.serverPeer.offTemp("connection");
},
});
const $wrpHelp = UiUtil.$getAddModalRow($modalInner, "div");
const fnDispServerStoppedState = () => {
$btnStartServer.html(`<span class="glyphicon glyphicon-play"></span> Start Server`).prop("disabled", false);
$btnGetToken.prop("disabled", true);
$btnGetLink.prop("disabled", true);
};
const fnDispServerRunningState = () => {
$btnStartServer.html(`<span class="glyphicon glyphicon-play"></span> Server Running`).prop("disabled", true);
$btnGetToken.prop("disabled", false);
$btnGetLink.prop("disabled", false);
};
const $btnStartServer = $(`<button class="btn btn-default mr-2"></button>`)
.click(async () => {
const {isRunning} = await this.startServerV1({doUpdateExternalStates, $btnStartServer, $btnGetToken, $btnGetLink, fnDispServerStoppedState, fnDispServerRunningState});
if (!isRunning) return;
this._p2pMetaV1.serverPeer.onTemp("connection", showConnected);
showConnected();
});
const $btnGetToken = $(`<button class="btn btn-default mr-2" disabled><span class="glyphicon glyphicon-copy"></span> Copy Token</button>`).appendTo($wrpHelp)
.click(async () => {
await MiscUtil.pCopyTextToClipboard(this._p2pMetaV1.serverPeer.token);
JqueryUtil.showCopiedEffect($btnGetToken);
});
const $btnGetLink = $(`<button class="btn btn-default" disabled><span class="glyphicon glyphicon-link"></span> Copy Link</button>`).appendTo($wrpHelp)
.click(async () => {
const cleanOrigin = window.location.origin.replace(/\/+$/, "");
const url = `${cleanOrigin}/inittrackerplayerview.html#v1:${this._p2pMetaV1.serverPeer.token}`;
await MiscUtil.pCopyTextToClipboard(url);
JqueryUtil.showCopiedEffect($btnGetLink);
});
if (this._p2pMetaV1.serverPeer) fnDispServerRunningState();
else fnDispServerStoppedState();
$$`<div class="row w-100">
<div class="col-12">
<p>
The Player View is part of a peer-to-peer system to allow players to connect to a DM's initiative tracker. Players should use the <a href="inittrackerplayerview.html">Initiative Tracker Player View</a> page to connect to the DM's instance. As a DM, the usage is as follows:
<ol>
<li>Start the server.</li>
<li>Copy your link/token and share it with your players.</li>
<li>Wait for them to connect!</li>
</ol>
</p>
<p>${$btnStartServer}${$btnGetLink}${$btnGetToken}</p>
<p><i>Please note that this system is highly experimental. Your experience may vary.</i></p>
</div>
</div>`.appendTo($wrpHelp);
UiUtil.addModalSep($modalInner);
const $wrpConnected = UiUtil.$getAddModalRow($modalInner, "div").addClass("flx-col");
const showConnected = () => {
if (!this._p2pMetaV1.serverPeer) return $wrpConnected.html(`<div class="w-100 ve-flex-vh-center"><i>No clients connected.</i></div>`);
let stack = `<div class="w-100"><h5>Connected Clients:</h5><ul>`;
this._p2pMetaV1.serverPeer.getActiveConnections()
.map(it => it.label || "(Unknown)")
.sort(SortUtil.ascSortLower)
.forEach(it => stack += `<li>${it.escapeQuotes()}</li>`);
stack += "</ul></div>";
$wrpConnected.html(stack);
};
if (this._p2pMetaV1.serverPeer) this._p2pMetaV1.serverPeer.onTemp("connection", showConnected);
showConnected();
}
// nop on receiving a message; we want to send only
// TODO expand this, to allow e.g. players to set statuses or assign damage/healing (at DM approval?)
_playerWindowV0_DM_MESSAGE_RECEIVER = function () {};
_playerWindowV0_DM_ERROR_HANDLER = function (err) {
if (!this.isClosed) {
// TODO: this could be better at handling `err.error == "RTCError: User-Initiated Abort, reason=Close called"`
JqueryUtil.doToast({
content: `Server error:\n${err ? (err.message || err.error || err) : "(Unknown error)"}`,
type: "danger",
});
}
};
async _playerWindowV0_pGetServerTokens ({rowMetas}) {
const targetRows = rowMetas.filter(it => !it.isDeleted).filter(it => !it.isActive);
if (targetRows.every(it => it.isActive)) {
return JqueryUtil.doToast({
content: "No rows require Server Token generation!",
type: "warning",
});
}
let anyInvalidNames = false;
targetRows.forEach(row => {
row.$iptName.removeClass("error-background");
if (!row.$iptName.val().trim()) {
anyInvalidNames = true;
row.$iptName.addClass("error-background");
}
});
if (anyInvalidNames) return;
const names = targetRows.map(row => {
row.isActive = true;
row.$iptName.attr("disabled", true);
row.$btnGenServerToken.attr("disabled", true);
return row.$iptName.val();
});
if (this._p2pMetaV0.serverInfo) {
await this._p2pMetaV0.serverInfo;
const serverInfo = await PeerUtilV0.pInitialiseServersAddToExisting(
names,
this._p2pMetaV0.serverInfo,
this._playerWindowV0_DM_MESSAGE_RECEIVER,
this._playerWindowV0_DM_ERROR_HANDLER,
);
return targetRows.map((row, i) => {
row.name = serverInfo[i].name;
row.serverInfo = serverInfo[i];
row.$iptTokenServer.val(serverInfo[i].textifiedSdp).attr("disabled", false);
serverInfo[i].rowMeta = row;
row.$iptTokenClient.attr("disabled", false);
row.$btnAcceptClientToken.attr("disabled", false);
return serverInfo[i].textifiedSdp;
});
} else {
this._p2pMetaV0.serverInfo = (async () => {
this._p2pMetaV0.serverInfo = await PeerUtilV0.pInitialiseServers(names, this._playerWindowV0_DM_MESSAGE_RECEIVER, this._playerWindowV0_DM_ERROR_HANDLER);
targetRows.forEach((row, i) => {
row.name = this._p2pMetaV0.serverInfo[i].name;
row.serverInfo = this._p2pMetaV0.serverInfo[i];
row.$iptTokenServer.val(this._p2pMetaV0.serverInfo[i].textifiedSdp).attr("disabled", false);
this._p2pMetaV0.serverInfo[i].rowMeta = row;
row.$iptTokenClient.attr("disabled", false);
row.$btnAcceptClientToken.attr("disabled", false);
});
})();
await this._p2pMetaV0.serverInfo;
return targetRows.map(row => row.serverInfo.textifiedSdp);
}
}
handleClick_playerWindowV0 ({doUpdateExternalStates}) {
const {$modalInner} = UiUtil.getShowModal({
title: "Configure Player View",
isUncappedHeight: true,
isHeight100: true,
cbClose: () => {
if (this._p2pMetaV0.rows.length) this._p2pMetaV0.rows.forEach(row => row.$row.detach());
},
});
const $wrpHelp = UiUtil.$getAddModalRow($modalInner, "div");
const $btnAltGenAll = $(`<button class="btn btn-primary btn-text-insert">Generate All</button>`).click(() => $btnGenServerTokens.click());
const $btnAltCopyAll = $(`<button class="btn btn-primary btn-text-insert">Copy Server Tokens</button>`).click(() => $btnCopyServers.click());
$$`<div class="ve-flex w-100">
<div class="col-12">
<p>
The Player View is part of a peer-to-peer (i.e., serverless) system to allow players to connect to a DM's initiative tracker. Players should use the <a href="inittrackerplayerview.html">Initiative Tracker Player View</a> page to connect to the DM's instance. As a DM, the usage is as follows:
<ol>
<li>Add the required number of players, and input (preferably unique) player names.</li>
<li>Click "${$btnAltGenAll}," which will generate a "server token" per player. You can click "${$btnAltCopyAll}" to copy them all as a single block of text, or click on the "Server Token" values to copy them individually. Distribute these tokens to your players (via a messaging service of your choice; we recommend <a href="https://discordapp.com/">Discord</a>). Each player should paste their token into the <a href="inittrackerplayerview.html">Initiative Tracker Player View</a>, following the instructions provided therein.</li>
<li>
Get a resulting "client token" from each player via a messaging service of your choice. Then, either:
<ol type="a">
<li>Click the "Accept Multiple Clients" button, and paste in text containing multiple client tokens. <b>This will try to find tokens in <i>any</i> text, ignoring everything else.</b> Pasting a chatroom log (containing, for example, usernames and timestamps mixed with tokens) is the expected usage.</li>
<li>Paste each token into the appropriate "Client Token" field and "Accept Client" on each. A token can be identified by the slugified player name in the first few characters.</li>
</ol>
</li>
</ol>
</p>
<p>Once a player's client has been "accepted," it will receive updates from the DM's tracker. <i>Please note that this system is highly experimental. Your experience may vary.</i></p>
</div>
</div>`.appendTo($wrpHelp);
UiUtil.addModalSep($modalInner);
const $wrpTop = UiUtil.$getAddModalRow($modalInner, "div");
const $btnAddClient = $(`<button class="btn btn-xs btn-primary" title="Add Client">Add Player</button>`).click(() => addClientRow());
const $btnCopyServers = $(`<button class="btn btn-xs btn-primary" title="Copy any available server tokens to the clipboard">Copy Server Tokens</button>`)
.click(async () => {
const targetRows = this._p2pMetaV0.rows.filter(it => !it.isDeleted && !it.$iptTokenClient.attr("disabled"));
if (!targetRows.length) {
JqueryUtil.doToast({
content: `No free server tokens to copy. Generate some!`,
type: "warning",
});
} else {
await MiscUtil.pCopyTextToClipboard(targetRows.map(it => it.$iptTokenServer.val()).join("\n\n"));
JqueryUtil.showCopiedEffect($btnGenServerTokens);
}
});
const $btnAcceptClients = $(`<button class="btn btn-xs btn-primary" title="Open a prompt into which text containing client tokens can be pasted">Accept Multiple Clients</button>`)
.click(() => {
const {$modalInner, doClose} = UiUtil.getShowModal({title: "Accept Multiple Clients"});
const $iptText = $(`<textarea class="form-control dm-init-pl__textarea block mb-2"></textarea>`)
.keydown(() => $iptText.removeClass("error-background"));
const $btnAccept = $(`<button class="btn btn-xs btn-primary block ve-text-center" title="Add Client">Accept Multiple Clients</button>`)
.click(async () => {
$iptText.removeClass("error-background");
const txt = $iptText.val();
if (!txt.trim() || !PeerUtilV0.containsAnyTokens(txt)) {
$iptText.addClass("error-background");
} else {
const connected = await PeerUtilV0.pConnectClientsToServers(this._p2pMetaV0.serverInfo, txt);
this._board.doBindAlertOnNavigation();
connected.forEach(serverInfo => {
serverInfo.rowMeta.$iptTokenClient.val(serverInfo._tempTokenToDisplay || "").attr("disabled", true);
serverInfo.rowMeta.$btnAcceptClientToken.attr("disabled", true);
delete serverInfo._tempTokenToDisplay;
});
doClose();
doUpdateExternalStates();
}
});
$$`<div>
<p>Paste text containing one or more client tokens, and click "Accept Multiple Clients"</p>
${$iptText}
<div class="ve-flex-vh-center">${$btnAccept}</div>
</div>`.appendTo($modalInner);
});
$$`
<div class="ve-flex w-100">
<div class="col-12">
<div class="ve-flex-inline-v-center mr-2">
<span class="mr-1">Add a player (client):</span>
${$btnAddClient}
</div>
<div class="ve-flex-inline-v-center mr-2">
<span class="mr-1">Copy all un-paired server tokens:</span>
${$btnCopyServers}
</div>
<div class="ve-flex-inline-v-center mr-2">
<span class="mr-1">Mass-accept clients:</span>
${$btnAcceptClients}
</div>
</div>
</div>
`.appendTo($wrpTop);
UiUtil.addModalSep($modalInner);
const $btnGenServerTokens = $(`<button class="btn btn-primary btn-xs">Generate All</button>`)
.click(() => this._playerWindowV0_pGetServerTokens({rowMetas: this._p2pMetaV0.rows}));
UiUtil.$getAddModalRow($modalInner, "div")
.append($$`
<div class="ve-flex w-100">
<div class="col-2 bold">Player Name</div>
<div class="col-3-5 bold">Server Token</div>
<div class="col-1 ve-text-center">${$btnGenServerTokens}</div>
<div class="col-3-5 bold">Client Token</div>
</div>
`);
const _get$rowTemplate = (
$iptName,
$iptTokenServer,
$btnGenServerToken,
$iptTokenClient,
$btnAcceptClientToken,
$btnDeleteClient,
) => $$`<div class="w-100 mb-2 ve-flex">
<div class="col-2 pr-1">${$iptName}</div>
<div class="col-3-5 px-1">${$iptTokenServer}</div>
<div class="col-1 px-1 ve-flex-vh-center">${$btnGenServerToken}</div>
<div class="col-3-5 px-1">${$iptTokenClient}</div>
<div class="col-1-5 px-1 ve-flex-vh-center">${$btnAcceptClientToken}</div>
<div class="col-0-5 pl-1 ve-flex-vh-center">${$btnDeleteClient}</div>
</div>`;
const clientRowMetas = [];
const addClientRow = () => {
const rowMeta = {id: CryptUtil.uid()};
clientRowMetas.push(rowMeta);
const $iptName = $(`<input class="form-control input-sm">`)
.keydown(evt => {
$iptName.removeClass("error-background");
if (evt.key === "Enter") $btnGenServerToken.click();
});
const $iptTokenServer = $(`<input class="form-control input-sm copyable code" readonly disabled>`)
.click(async () => {
await MiscUtil.pCopyTextToClipboard($iptTokenServer.val());
JqueryUtil.showCopiedEffect($iptTokenServer);
}).disableSpellcheck();
const $btnGenServerToken = $(`<button class="btn btn-xs btn-primary" title="Generate Server Token">Generate</button>`)
.click(() => this._playerWindowV0_pGetServerTokens({rowMetas: [rowMeta]}));
const $iptTokenClient = $(`<input class="form-control input-sm code" disabled>`)
.keydown(evt => {
$iptTokenClient.removeClass("error-background");
if (evt.key === "Enter") $btnAcceptClientToken.click();
}).disableSpellcheck();
const $btnAcceptClientToken = $(`<button class="btn btn-xs btn-primary" title="Accept Client Token" disabled>Accept Client</button>`)
.click(async () => {
const token = $iptTokenClient.val();
if (PeerUtilV0.isValidToken(token)) {
try {
await PeerUtilV0.pConnectClientsToServers([rowMeta.serverInfo], token);
this._board.doBindAlertOnNavigation();
$iptTokenClient.prop("disabled", true);
$btnAcceptClientToken.prop("disabled", true);
doUpdateExternalStates();
} catch (e) {
JqueryUtil.doToast({
content: `Failed to accept client token! Are you sure it was valid? (See the log for more details.)`,
type: "danger",
});
setTimeout(() => { throw e; });
}
} else $iptTokenClient.addClass("error-background");
});
const $btnDeleteClient = $(`<button class="btn btn-xs btn-danger"><span class="glyphicon glyphicon-trash"></span></button>`)
.click(() => {
rowMeta.$row.remove();
rowMeta.isDeleted = true;
if (rowMeta.serverInfo) {
rowMeta.serverInfo.server.close();
rowMeta.serverInfo.isDeleted = true;
}
const ix = clientRowMetas.indexOf(rowMeta);
if (~ix) clientRowMetas.splice(ix, 1);
if (!clientRowMetas.length) addClientRow();
});
rowMeta.$row = _get$rowTemplate(
$iptName,
$iptTokenServer,
$btnGenServerToken,
$iptTokenClient,
$btnAcceptClientToken,
$btnDeleteClient,
).appendTo($wrpRowsInner);
rowMeta.$iptName = $iptName;
rowMeta.$iptTokenServer = $iptTokenServer;
rowMeta.$btnGenServerToken = $btnGenServerToken;
rowMeta.$iptTokenClient = $iptTokenClient;
rowMeta.$btnAcceptClientToken = $btnAcceptClientToken;
this._p2pMetaV0.rows.push(rowMeta);
return rowMeta;
};
const $wrpRows = UiUtil.$getAddModalRow($modalInner, "div");
const $wrpRowsInner = $(`<div class="w-100"></div>`).appendTo($wrpRows);
if (this._p2pMetaV0.rows.length) this._p2pMetaV0.rows.forEach(row => row.$row.appendTo($wrpRowsInner));
else addClientRow();
}
async pHandleDoConnectLocalV0 ({clientView}) {
// generate a stub/fake row meta
const rowMeta = {
id: CryptUtil.uid(),
$row: $(),
$iptName: $(`<input value="local">`),
$iptTokenServer: $(),
$btnGenServerToken: $(),
$iptTokenClient: $(),
$btnAcceptClientToken: $(),
};
this._p2pMetaV0.rows.push(rowMeta);
const serverTokens = await this._playerWindowV0_pGetServerTokens({rowMetas: [rowMeta]});
const clientData = await PeerUtilV0.pInitialiseClient(
serverTokens[0],
msg => clientView.handleMessage(msg),
() => {}, // ignore local errors
);
clientView.clientData = clientData;
await PeerUtilV0.pConnectClientsToServers([rowMeta.serverInfo], clientData.textifiedSdp);
}
}

View File

@@ -0,0 +1,30 @@
export class InitiativeTrackerRoller {
static _getRollName (name) {
return `Initiative Tracker${name ? ` \u2014 ${name}` : ""}`;
}
async pGetRollInitiative ({mon, name, initiativeModifier}) {
name ??= mon?.name;
initiativeModifier ??= mon ? Parser.getAbilityModifier(mon.dex) : 0;
return Renderer.dice.pRoll2(`1d20${UiUtil.intToBonus(initiativeModifier)}`, {
isUser: false,
name: this.constructor._getRollName(name ?? mon?.name),
label: "Initiative",
}, {isResultUsed: true});
}
async pGetOrRollHp (mon, {isRollHp}) {
if (!isRollHp && mon.hp.average && !isNaN(mon.hp.average)) return Number(mon.hp.average);
if (isRollHp && mon.hp.formula) {
return Renderer.dice.pRoll2(mon.hp.formula, {
isUser: false,
name: this.constructor._getRollName(mon?.name),
label: "HP",
}, {isResultUsed: true});
}
return null;
}
}

View File

@@ -0,0 +1,475 @@
import {
IS_PLAYER_VISIBLE_ALL,
IS_PLAYER_VISIBLE_NONE,
} from "./dmscreen-initiativetracker-statcolumns.js";
import {InitiativeTrackerConditionAdd} from "./dmscreen-initiativetracker-conditionadd.js";
import {InitiativeTrackerUi} from "./dmscreen-initiativetracker-ui.js";
import {InitiativeTrackerConst} from "./dmscreen-initiativetracker-consts.js";
import {InitiativeTrackerSort} from "./dmscreen-initiativetracker-sort.js";
import {RenderableCollectionConditions} from "../../initiativetracker/initiativetracker-utils.js";
import {
InitiativeTrackerRowDataViewBase,
RenderableCollectionRowDataBase,
} from "./dmscreen-initiativetracker-rowsbase.js";
import {InitiativeTrackerRowStateBuilderActive} from "./dmscreen-initiativetracker-rowstatebuilder.js";
class _RenderableCollectionRowDataActive extends RenderableCollectionRowDataBase {
constructor (
{
comp,
$wrpRows,
roller,
networking,
rowStateBuilder,
},
) {
super({comp, prop: "rows", $wrpRows, roller, networking, rowStateBuilder});
}
async _pPopulateRow_pGetMonsterMeta ({comp}) {
const isMon = !!comp._state.source;
const mon = isMon
? await this._rowStateBuilder.pGetScaledCreature({isMon, ...comp._state})
: null;
const fluff = mon ? await Renderer.monster.pGetFluff(mon) : null;
return {
isMon,
mon,
fluff,
};
}
/* ----- */
_pPopulateRow_monster ({comp, $wrpLhs, isMon, mon, fluff}) {
if (!isMon) return;
const $dispOrdinal = $(`<span class="dm-init__number"></span>`);
comp._addHookBase("ordinal", () => $dispOrdinal.text(`(${comp._state.ordinal})`))();
comp._addHookBase("isShowOrdinal", () => $dispOrdinal.toggleVe(comp._state.isShowOrdinal))();
const $lnk = $(this._pPopulateRow_monster_getRenderedLink({comp}))
.attr("tabindex", "-1");
comp._addHookBase("customName", () => {
$lnk.text(comp._state.customName ? comp._state.customName : comp._state.displayName || comp._state.name);
})();
const $btnRename = $(`<button class="btn btn-default btn-xs dm-init-lockable dm-init__btn-creature" title="Rename (SHIFT to Reset)" tabindex="-1"><span class="glyphicon glyphicon-pencil"></span></button>`)
.click(async evt => {
if (this._comp._state.isLocked) return;
if (evt.shiftKey) return comp._state.customName = null;
const customName = await InputUiUtil.pGetUserString({title: "Enter Name"});
if (customName == null || !customName.trim()) return;
comp._state.customName = customName;
});
const $btnDuplicate = $(`<button class="btn btn-success btn-xs dm-init-lockable dm-init__btn-creature" title="Add Another (SHIFT for Roll New)" tabindex="-1"><span class="glyphicon glyphicon-plus"></span></button>`)
.click(async (evt) => {
if (this._comp._state.isLocked) return;
const isRollNew = !!evt.shiftKey;
const initiative = isRollNew ? await this._roller.pGetRollInitiative({mon}) : comp._state.initiative;
const isActive = isRollNew ? (initiative === comp._state.initiative) : comp._state.isActive;
const hpMax = isRollNew ? await this._roller.pGetOrRollHp(mon, {isRollHp: this._comp._state.isRollHp}) : comp._state.hpMax;
const similarCreatureRows = this._rowStateBuilder.getSimilarRows({rowEntity: comp._state});
this._comp._state[this._prop] = InitiativeTrackerSort.getSortedRows({
rows: [
...this._comp._state[this._prop],
await this._rowStateBuilder.pGetNewRowState({
isActive,
isPlayerVisible: comp._state.isPlayerVisible,
name: comp._state.name,
displayName: comp._state.displayName,
scaledCr: comp._state.scaledCr,
scaledSummonSpellLevel: comp._state.scaledSummonSpellLevel,
scaledSummonClassLevel: comp._state.scaledSummonClassLevel,
customName: comp._state.customName,
source: comp._state.source,
hpCurrent: hpMax, // Always reset to max HP
hpMax: hpMax,
initiative,
ordinal: Math.max(...similarCreatureRows.map(row => row.entity.ordinal)) + 1,
rowStatColData: isRollNew ? this._rowStateBuilder._getInitialRowStatColData({mon, fluff}) : MiscUtil.copyFast(comp._state.rowStatColData),
conditions: [],
}),
]
.filter(Boolean),
sortBy: this._comp._state.sort,
sortDir: this._comp._state.dir,
});
});
$$`<div class="dm-init__wrp-creature split">
<span class="dm-init__wrp-creature-link">
${$lnk}
${$dispOrdinal}
</span>
<div class="ve-flex-v-center btn-group mr-3p">
${$btnRename}
${$btnDuplicate}
</div>
</div>`.appendTo($wrpLhs);
}
_pPopulateRow_monster_getRenderedLink ({comp}) {
if (
comp._state.scaledCr == null
&& comp._state.scaledSummonSpellLevel == null
&& comp._state.scaledSummonClassLevel == null
) return Renderer.get().render(`{@creature ${comp._state.name}|${comp._state.source}}`);
const parts = [
comp._state.name,
comp._state.source,
comp._state.displayName,
comp._state.scaledCr != null
? `${VeCt.HASH_SCALED}=${Parser.numberToCr(comp._state.scaledCr)}`
: comp._state.scaledSummonSpellLevel != null
? `${VeCt.HASH_SCALED_SPELL_SUMMON}=${comp._state.scaledSummonSpellLevel}`
: comp._state.scaledSummonClassLevel != null
? `${VeCt.HASH_SCALED_CLASS_SUMMON}=${comp._state.scaledSummonClassLevel}`
: null,
];
return Renderer.get().render(`{@creature ${parts.join("|")}}`);
}
/* ----- */
_pPopulateRow_conditions ({comp, $wrpLhs}) {
const $btnAddCond = $(`<button class="btn btn-warning btn-xs dm-init__row-btn dm-init__row-btn-flag" title="Add Condition" tabindex="-1"><span class="glyphicon glyphicon-flag"></span></button>`)
.on("click", async () => {
const compAdd = new InitiativeTrackerConditionAdd({conditionsCustom: MiscUtil.copyFast(this._comp._state.conditionsCustom)});
const [isDataEntered, conditionToAdd] = await compAdd.pGetShowModalResults();
// Always update the set of custom conditions
this._comp._state.conditionsCustom = compAdd.getConditionsCustom();
if (!isDataEntered) return;
comp._state.conditions = [
...comp._state.conditions,
conditionToAdd,
];
});
const $wrpConds = $(`<div class="init__wrp_conds h-100"></div>`);
$$`<div class="split">
${$wrpConds}
${$btnAddCond}
</div>`.appendTo($wrpLhs);
const collectionConditions = new RenderableCollectionConditions({
comp: comp,
$wrpRows: $wrpConds,
});
comp._addHookBase("conditions", () => collectionConditions.render())();
}
/* ----- */
_pPopulateRow_initiative ({comp, $wrpRhs}) {
const $iptInitiative = ComponentUiUtil.$getIptNumber(
comp,
"initiative",
null,
{
isAllowNull: true,
fallbackOnNaN: null,
html: `<input class="form-control input-sm score dm-init-lockable dm-init__row-input ve-text-center dm-init__ipt--rhs">`,
},
)
.on("click", () => $iptInitiative.select())
.appendTo($wrpRhs);
}
/* ----- */
_pPopulateRow_btns ({comp, entity, $wrpRhs}) {
const $btnVisible = InitiativeTrackerUi.$getBtnPlayerVisible(
comp._state.isPlayerVisible,
() => comp._state.isPlayerVisible = $btnVisible.hasClass("btn-primary")
? IS_PLAYER_VISIBLE_ALL
: IS_PLAYER_VISIBLE_NONE,
false,
)
.title("Shown in player view")
.addClass("dm-init__row-btn")
.addClass("dm-init__btn_eye")
.appendTo($wrpRhs);
$(`<button class="btn btn-danger btn-xs dm-init__row-btn dm-init-lockable" title="Delete (SHIFT to Also Delete Similar)" tabindex="-1"><span class="glyphicon glyphicon-trash"></span></button>`)
.appendTo($wrpRhs)
.on("click", evt => {
if (this._comp._state.isLocked) return;
if (evt.shiftKey) {
return this._utils.doDeleteMultiple({
entities: this._rowStateBuilder.getSimilarRows({
rows: this._comp[this._prop],
rowEntity: entity.entity,
}),
});
}
this._utils.doDelete({entity});
});
}
/* -------------------------------------------- */
_doHandleTurnStart ({rows, direction, row, isSkipRoundStart}) {
const {isRoundStart = false} = isSkipRoundStart ? {} : this._doHandleRoundStart({rows, direction, row});
this._comp._getRenderedCollection({prop: this._prop})[row.id]?.cbOnTurnStart({state: row, direction});
return {isRoundStart};
}
_doHandleRoundStart ({rows, direction, row}) {
if (!rows.length) return {isRoundStart: false};
if (rows[0]?.id !== row?.id) return {isRoundStart: false};
if (direction === InitiativeTrackerConst.DIR_FORWARDS) ++this._comp._state.round;
const rendereds = this._comp._getRenderedCollection({prop: this._prop});
rows.forEach(row => rendereds[row.id]?.cbOnRoundStart({state: row, direction}));
return {isRoundStart: true};
}
/* -------------------------------------------- */
_getSortedRowsCopy ({rows}) {
return InitiativeTrackerSort.getSortedRows({
rows: MiscUtil.copyFast(rows),
sortBy: this._comp._state.sort,
sortDir: this._comp._state.dir,
});
}
doEnsureAtLeastOneRowActive ({isSilent = false} = {}) {
if (this._isAnyRowActive()) return;
const rows = this._getSortedRowsCopy({rows: (isSilent ? this._comp.__state : this._comp._state)[this._prop]});
if (!rows?.length) return;
const [rowActive] = rows;
this._doHandleRowSetActive({rows, rowActive, direction: InitiativeTrackerConst.DIR_NEUTRAL});
if (isSilent) this._comp.__state.rows = rows;
else this._comp._triggerCollectionUpdate(this._prop);
}
_isAnyRowActive () {
return this._comp._state[this._prop].some(it => it.entity.isActive);
}
_doHandleRowSetActive ({rows, rowActive, direction, isSkipRoundStart}) {
const similarRows = this._rowStateBuilder.getSimilarRows({
rows,
rowEntity: rowActive.entity,
});
if (!similarRows.some(row => row.id === rowActive.id)) throw new Error(`Active row should be "similar" to itself!`); // Should never occur
const isRoundStart = similarRows
.map(row => {
const {entity} = row;
if (
this._comp._state.sort === InitiativeTrackerConst.SORT_ORDER_NUM
&& entity.initiative !== rowActive.entity.initiative
) return false;
entity.isActive = true;
return this._doHandleTurnStart({rows, direction, row, isSkipRoundStart}).isRoundStart;
})
.some(Boolean);
return {isRoundStart};
}
_pDoShiftActiveRow_doInitialShift ({direction}) {
const rows = this._getSortedRowsCopy({rows: this._comp._state[this._prop]});
if (!this._isAnyRowActive()) return this.doEnsureAtLeastOneRowActive();
if (direction === InitiativeTrackerConst.DIR_BACKWARDS) rows.reverse();
const rowsActive = rows.filter(({entity}) => entity.isActive);
// If advancing, tick down conditions
if (direction === InitiativeTrackerConst.DIR_FORWARDS) {
rowsActive
.forEach(({entity}) => {
entity.conditions = entity.conditions
.filter(cond => !(cond.entity.turns != null && (--cond.entity.turns <= 0)));
});
}
rowsActive.forEach(({entity}) => entity.isActive = false);
const ixLastActive = rows.indexOf(rowsActive.last());
const ixNextActive = ixLastActive + 1 < rows.length ? ixLastActive + 1 : 0;
rows[ixNextActive].entity.isActive = true;
const {isRoundStart} = this._doHandleRowSetActive({rows, rowActive: rows[ixNextActive], direction});
return {
isRoundStart,
rows: InitiativeTrackerSort.getSortedRows({
rows,
sortBy: this._comp._state.sort,
sortDir: this._comp._state.dir,
}),
};
}
async _pDoShiftActiveRow_pDoRerollAndShift ({direction, rows}) {
await this._pMutRowsRerollInitiative({rows});
rows = InitiativeTrackerSort.getSortedRows({
rows,
sortBy: this._comp._state.sort,
sortDir: this._comp._state.dir,
});
rows.forEach(({entity}) => entity.isActive = false);
const rowActive = rows[0];
rowActive.entity.isActive = true;
// FIXME(Future) this results in turn-start triggering twice on rows which were at the top of the pre-reroll order
// and the post-reroll order (current turn-start callbacks are idempotent, so this is not relevant)
this._doHandleRowSetActive({rows, rowActive, direction, isSkipRoundStart: true});
return rows;
}
async pDoShiftActiveRow ({direction}) {
const {isRoundStart, rows} = this._pDoShiftActiveRow_doInitialShift({direction});
if (direction !== InitiativeTrackerConst.DIR_FORWARDS || !isRoundStart || !this._comp._state.isRerollInitiativeEachRound) return this._comp._state[this._prop] = rows;
this._comp._state[this._prop] = await this._pDoShiftActiveRow_pDoRerollAndShift({direction, rows});
}
/* -------------------------------------------- */
async _pMutRowsRerollInitiative ({rows}) {
rows = rows || this._comp._state[this._prop];
if (!this._comp._state.isRollGroups) {
return rows
.pSerialAwaitMap(async row => {
const {entity} = row;
const {mon, initiativeModifier} = await this._rowStateBuilder.pGetRowInitiativeMeta({row});
entity.initiative = await this._roller.pGetRollInitiative({mon, initiativeModifier, name: mon ? null : entity.name});
});
}
await Object.values(
await rows
.pSerialAwaitReduce(
async (accum, row) => {
const rowEntityHash = InitiativeTrackerRowStateBuilderActive.getSimilarRowEntityHash({rowEntity: row.entity});
const {initiativeModifier} = await this._rowStateBuilder.pGetRowInitiativeMeta({row});
// Add the initiative modifier to the key, such that e.g. creatures with non-standard initiative
// modifiers are rolled outwith the group
const k = [rowEntityHash, initiativeModifier].join("__");
(accum[k] ||= []).push(row);
return accum;
},
{},
),
)
.pSerialAwaitMap(async rows => {
const [row] = rows;
const {mon, initiativeModifier} = await this._rowStateBuilder.pGetRowInitiativeMeta({row});
const initiative = await this._roller.pGetRollInitiative({mon, initiativeModifier, name: mon ? null : row.entity.name});
rows.forEach(({entity}) => entity.initiative = initiative);
});
}
}
export class InitiativeTrackerRowDataViewActive extends InitiativeTrackerRowDataViewBase {
_TextHeaderLhs = "Creature/Status";
_ClsRenderableCollectionRowData = _RenderableCollectionRowDataActive;
_render_$getWrpHeaderRhs ({rdState}) {
return $$`<div class="dm-init__row-rhs">
<div class="dm-init__header dm-init__header--input dm-init__header--input-wide" title="Hit Points">HP</div>
<div class="dm-init__header dm-init__header--input" title="Initiative Score">#</div>
<div class="dm-init__spc-header-buttons"></div>
</div>`;
}
_render_bindHooksRows ({rdState}) {
const hkRowsSync = () => {
// Sort rows
this._comp.__state.rows = InitiativeTrackerSort.getSortedRows({
rows: this._comp._state.rows,
sortBy: this._comp._state.sort,
sortDir: this._comp._state.dir,
});
// Ensure a row is active
this._compRows.doEnsureAtLeastOneRowActive({isSilent: true});
// region Show/hide creature ordinals
const ordinalsToShow = new Set(
Object.entries(
this._rowStateBuilder.getSimilarRowCounts({rows: this._comp.__state.rows}),
)
.filter(([, v]) => v > 1)
.map(([name]) => name),
);
this._comp.__state.rows
.forEach(({entity}) => entity.isShowOrdinal = ordinalsToShow.has(InitiativeTrackerRowStateBuilderActive.getSimilarRowEntityHash({rowEntity: entity})));
// endregion
};
this._comp._addHookBase(this._prop, hkRowsSync)();
rdState.fnsCleanup.push(() => this._comp._removeHookBase(this._prop, hkRowsSync));
const hkRowsAsync = async () => {
try {
await this._compRowsLock.pLock();
await this._compRows.pRender();
// region Scroll active rows into view
const rendereds = this._comp._getRenderedCollection({prop: this._prop});
const renderedsActive = this._comp._state.rows
.filter(row => row.entity.isActive)
.map(row => rendereds[row.id])
.filter(Boolean);
if (!renderedsActive.length) return;
// First scroll the last active row into view to scroll down as far as necessary...
renderedsActive.last()?.$wrpRow?.[0]?.scrollIntoView({block: "nearest", inline: "nearest"});
// ...then scroll the first active row into view, as this is the one we prioritize
renderedsActive[0]?.$wrpRow?.[0]?.scrollIntoView({block: "nearest", inline: "nearest"});
// endregion
} finally {
this._compRowsLock.unlock();
}
};
this._comp._addHookBase(this._prop, hkRowsAsync)();
rdState.fnsCleanup.push(() => this._comp._removeHookBase(this._prop, hkRowsAsync));
}
/* -------------------------------------------- */
pDoShiftActiveRow (...args) { return this._compRows.pDoShiftActiveRow(...args); }
}

View File

@@ -0,0 +1,389 @@
import {
InitiativeTrackerStatColumnFactory,
} from "./dmscreen-initiativetracker-statcolumns.js";
import {InitiativeTrackerUtil} from "../../initiativetracker/initiativetracker-utils.js";
class _RenderableCollectionRowStatColData extends RenderableCollectionGenericRows {
constructor (
{
rootComp,
comp,
$wrpRows,
networking,
mon,
},
) {
super(comp, "rowStatColData", $wrpRows);
this._rootComp = rootComp;
this._networking = networking;
this._mon = mon;
}
_$getWrpRow () {
return $(`<div class="ve-flex-vh-center"></div>`);
}
_populateRow ({comp, $wrpRow, entity}) {
const statsColData = this._rootComp._state.statsCols.find(statsCol => statsCol.id === entity.id);
if (!statsColData) return {};
const meta = InitiativeTrackerStatColumnFactory.fromStateData({data: statsColData});
meta.$getRendered({comp, mon: this._mon, networking: this._networking}).appendTo($wrpRow);
return {
cbOnTurnStart: ({state, direction}) => {
meta.onTurnStart({state, direction, mon: this._mon});
},
cbOnRoundStart: ({state, direction}) => {
meta.onRoundStart({state, direction, mon: this._mon});
},
};
}
}
/** @abstract */
export class RenderableCollectionRowDataBase extends RenderableCollectionAsyncGenericRows {
constructor (
{
comp,
prop,
$wrpRows,
roller,
networking = null,
rowStateBuilder,
},
) {
super(comp, prop, $wrpRows);
this._roller = roller;
this._networking = networking;
this._rowStateBuilder = rowStateBuilder;
}
/* -------------------------------------------- */
_$getWrpRow () {
return $(`<div class="dm-init__row overflow-hidden pr-1"></div>`);
}
async _pPopulateRow ({comp, $wrpRow, entity}) {
const fnsCleanup = [];
const {isMon, mon, fluff} = await this._pPopulateRow_pGetMonsterMeta({comp});
comp._addHookBase("isActive", () => $wrpRow.toggleClass("dm-init__row-active", !!comp._state.isActive))();
this._pPopulateRow_bindParentRowStatColsIsEditableHook({comp, entity, mon, fluff, fnsCleanup});
const $wrpLhs = $(`<div class="dm-init__row-lhs"></div>`).appendTo($wrpRow);
this._pPopulateRow_player({comp, $wrpLhs, isMon});
this._pPopulateRow_monster({comp, $wrpLhs, isMon, mon, fluff});
this._pPopulateRow_conditions({comp, $wrpLhs});
this._pPopulateRow_statsCols({comp, $wrpRow, mon, fnsCleanup});
const $wrpRhs = $(`<div class="dm-init__row-rhs"></div>`).appendTo($wrpRow);
this._pPopulateRow_hp({comp, $wrpRhs});
this._pPopulateRow_initiative({comp, $wrpRhs});
this._pPopulateRow_btns({comp, entity, $wrpRhs});
return {
cbOnTurnStart: ({state, direction}) => {
Object.values(comp._getRenderedCollection({prop: "rowStatColData"}))
.forEach(rendered => {
if (!rendered.cbOnTurnStart) return;
const stateSub = state.entity.rowStatColData
.find(cell => cell.id === rendered.id);
rendered.cbOnTurnStart({state: stateSub, direction});
});
},
cbOnRoundStart: ({state, direction}) => {
Object.values(comp._getRenderedCollection({prop: "rowStatColData"}))
.forEach(rendered => {
if (!rendered.cbOnRoundStart) return;
const stateSub = state.entity.rowStatColData
.find(cell => cell.id === rendered.id);
rendered.cbOnRoundStart({state: stateSub, direction});
});
},
fnsCleanup,
};
}
/* ----- */
/**
* @abstract
* @return {object}
*/
async _pPopulateRow_pGetMonsterMeta ({comp}) {
throw new Error("Unimplemented!");
}
/* ----- */
_pPopulateRow_bindParentRowStatColsIsEditableHook ({comp, entity, mon, fluff, fnsCleanup}) {
const hkParentStatCols = () => {
const isRowExists = this._comp._state[this._prop]
.some(row => row.id === entity.id);
if (!isRowExists) return; // Avoid race condition (row removed, but async render has not yet cleaned it up)
const rowStatColDataNxt = [];
this._comp._state.statsCols
.forEach(data => {
const existing = comp._state.rowStatColData.find(cell => cell.id === data.id);
if (existing) {
// Copy the parent isEditable flag to the child
existing.entity.isEditable = data.isEditable;
return rowStatColDataNxt.push(existing);
}
const meta = InitiativeTrackerStatColumnFactory.fromStateData({data});
const initialState = meta.getInitialCellStateData({mon, fluff});
rowStatColDataNxt.push(initialState);
});
comp._state.rowStatColData = rowStatColDataNxt;
};
this._comp._addHookBase("statsCols", hkParentStatCols)();
fnsCleanup.push(() => this._comp._removeHookBase("statsCols", hkParentStatCols));
}
/* ----- */
_pPopulateRow_player ({comp, $wrpLhs, isMon}) {
if (isMon) return;
ComponentUiUtil.$getIptStr(
comp,
"name",
{
html: `<input class="form-control input-sm name dm-init__ipt-name dm-init-lockable dm-init__row-input" placeholder="Name">`,
},
).appendTo($wrpLhs);
}
/* ----- */
/**
* @abstract
* @return void
*/
_pPopulateRow_monster ({comp, $wrpLhs, isMon, mon, fluff}) {
throw new Error("Unimplemented!");
}
/* ----- */
/**
* @abstract
* @return void
*/
_pPopulateRow_conditions ({comp, $wrpLhs}) {
throw new Error("Unimplemented!");
}
/* ----- */
_pPopulateRow_statsCols ({comp, $wrpRow, mon, fnsCleanup}) {
const $wrp = $(`<div class="dm-init__row-mid"></div>`)
.appendTo($wrpRow);
const hkParentStatsAddCols = () => $wrp.toggleVe(!!this._comp._state.isStatsAddColumns);
this._comp._addHookBase("isStatsAddColumns", hkParentStatsAddCols)();
fnsCleanup.push(() => this._comp._removeHookBase("isStatsAddColumns", hkParentStatsAddCols));
const renderableCollection = new _RenderableCollectionRowStatColData({
rootComp: this._comp,
comp,
$wrpRows: $wrp,
networking: this._networking,
mon,
});
comp._addHookBase("rowStatColData", () => renderableCollection.render())();
}
/* ----- */
_pPopulateRow_hp ({comp, $wrpRhs}) {
const $iptHpCurrent = ComponentUiUtil.$getIptNumber(
comp,
"hpCurrent",
null,
{
isAllowNull: true,
fallbackOnNaN: null,
html: `<input class="form-control input-sm hp dm-init__row-input text-right w-40p mr-0 br-0">`,
},
)
.on("click", () => $iptHpCurrent.select());
const $iptHpMax = ComponentUiUtil.$getIptNumber(
comp,
"hpMax",
null,
{
isAllowNull: true,
fallbackOnNaN: null,
html: `<input class="form-control input-sm hp-max dm-init__row-input w-40p mr-0 bl-0">`,
},
)
.on("click", () => $iptHpMax.select());
const hkHpColors = () => {
const woundLevel = InitiativeTrackerUtil.getWoundLevel(100 * comp._state.hpCurrent / comp._state.hpMax);
if (~woundLevel) {
const woundMeta = InitiativeTrackerUtil.getWoundMeta(woundLevel);
$iptHpCurrent.css("color", woundMeta.color);
$iptHpMax.css("color", woundMeta.color);
} else {
$iptHpCurrent.css("color", "");
$iptHpMax.css("color", "");
}
};
comp._addHookBase("hpCurrent", hkHpColors);
comp._addHookBase("hpMax", hkHpColors);
hkHpColors();
$$`<div class="ve-flex relative mr-3p">
<div class="text-right">${$iptHpCurrent}</div>
<div class="dm-init__sep-fields-slash ve-flex-vh-center">/</div>
<div class="text-left">${$iptHpMax}</div>
</div>`
.appendTo($wrpRhs);
}
/* ----- */
/**
* @abstract
* @return void
*/
_pPopulateRow_initiative ({comp, $wrpRhs}) {
throw new Error("Unimplemented!");
}
/* ----- */
/**
* @abstract
* @return void
*/
_pPopulateRow_btns ({comp, entity, $wrpRhs}) {
throw new Error("Unimplemented!");
}
/* -------------------------------------------- */
pDoDeleteExistingRender (rendered) {
rendered.fnsCleanup.forEach(fn => fn());
}
}
/** @abstract */
export class InitiativeTrackerRowDataViewBase {
static _RenderState = class {
constructor () {
this.fnsCleanup = [];
}
};
_TextHeaderLhs;
_ClsRenderableCollectionRowData;
constructor ({comp, prop, roller, networking, rowStateBuilder}) {
this._comp = comp;
this._prop = prop;
this._roller = roller;
this._networking = networking;
this._rowStateBuilder = rowStateBuilder;
this._compRowsLock = new VeLock({name: "Row render"});
this._compRows = null;
}
/* -------------------------------------------- */
getRenderedView () {
const rdState = new this.constructor._RenderState();
const $ele = $$`<div class="dm-init__wrp-header-outer">
<div class="dm-init__wrp-header pr-1">
<div class="dm-init__row-lhs dm-init__header">
<div class="w-100">${this._TextHeaderLhs}</div>
</div>
${this._render_$getWrpHeaderStatsCols({rdState})}
${this._render_$getWrpHeaderRhs({rdState})}
</div>
${this._render_$getWrpRows({rdState})}
</div>`;
return {
$ele,
cbDoCleanup: () => rdState.fnsCleanup.forEach(fn => fn()),
};
}
_render_$getWrpHeaderStatsCols ({rdState}) {
const $wrpHeaderStatsCols = $(`<div class="dm-init__row-mid"></div>`);
const hkHeaderStatsCols = () => {
$wrpHeaderStatsCols.empty();
if (!this._comp._state.isStatsAddColumns) return;
this._comp._state.statsCols.forEach(data => {
const meta = InitiativeTrackerStatColumnFactory.fromStateData({data});
$wrpHeaderStatsCols.append(meta.$getRenderedHeader());
});
};
this._comp._addHookBase("isStatsAddColumns", hkHeaderStatsCols);
this._comp._addHookBase("statsCols", hkHeaderStatsCols);
hkHeaderStatsCols();
rdState.fnsCleanup.push(
() => this._comp._removeHookBase("isStatsAddColumns", hkHeaderStatsCols),
() => this._comp._removeHookBase("statsCols", hkHeaderStatsCols),
);
return $wrpHeaderStatsCols;
}
/**
* @abstract
* @return {jQuery}
*/
_render_$getWrpHeaderRhs ({rdState}) {
throw new Error("Unimplemented!");
}
_render_$getWrpRows ({rdState}) {
const $wrpRows = $(`<div class="dm-init__wrp-entries"></div>`);
this._compRows = new this._ClsRenderableCollectionRowData({
comp: this._comp,
$wrpRows,
roller: this._roller,
networking: this._networking,
rowStateBuilder: this._rowStateBuilder,
});
this._render_bindHooksRows({rdState});
return $wrpRows;
}
/**
* @abstract
* @return void
*/
_render_bindHooksRows ({rdState}) {
throw new Error("Unimplemented!");
}
}

View File

@@ -0,0 +1,82 @@
import {
InitiativeTrackerRowDataViewBase,
RenderableCollectionRowDataBase,
} from "./dmscreen-initiativetracker-rowsbase.js";
class _RenderableCollectionRowDataDefaultParty extends RenderableCollectionRowDataBase {
constructor (
{
comp,
$wrpRows,
roller,
rowStateBuilder,
},
) {
super({comp, prop: "rowsDefaultParty", $wrpRows, roller, networking: null, rowStateBuilder});
}
async _pPopulateRow_pGetMonsterMeta ({comp}) {
return {
isMon: false,
mon: null,
fluff: null,
};
}
/* ----- */
_pPopulateRow_monster ({comp, $wrpLhs, isMon, mon, fluff}) {
/* No-op */
}
/* ----- */
_pPopulateRow_conditions ({comp, $wrpLhs}) {
/* No-op */
}
/* ----- */
_pPopulateRow_initiative ({comp, $wrpRhs}) {
/* No-op */
}
/* ----- */
_pPopulateRow_btns ({comp, entity, $wrpRhs}) {
$(`<button class="btn btn-danger btn-xs dm-init__row-btn dm-init-lockable" tabindex="-1"><span class="glyphicon glyphicon-trash"></span></button>`)
.appendTo($wrpRhs)
.on("click", () => {
if (this._comp._state.isLocked) return;
this._utils.doDelete({entity});
});
}
}
export class InitiativeTrackerRowDataViewDefaultParty extends InitiativeTrackerRowDataViewBase {
_TextHeaderLhs = "Player";
_ClsRenderableCollectionRowData = _RenderableCollectionRowDataDefaultParty;
_render_$getWrpHeaderRhs ({rdState}) {
return $$`<div class="dm-init__row-rhs">
<div class="dm-init__header dm-init__header--input dm-init__header--input-wide" title="Hit Points">HP</div>
<div class="dm-init__spc-header-buttons--single"></div>
</div>`;
}
_render_bindHooksRows ({rdState}) {
const hkRowsAsync = async () => {
try {
await this._compRowsLock.pLock();
await this._compRows.pRender();
} finally {
this._compRowsLock.unlock();
}
};
this._comp._addHookBase(this._prop, hkRowsAsync)();
rdState.fnsCleanup.push(
() => this._comp._removeHookBase(this._prop, hkRowsAsync),
() => this._comp._detachCollection(this._prop),
);
}
}

View File

@@ -0,0 +1,284 @@
import {
INITIATIVE_APPLICABILITY_NOT_APPLICABLE,
InitiativeTrackerStatColumnFactory,
} from "./dmscreen-initiativetracker-statcolumns.js";
import {DmScreenUtil} from "../dmscreen-util.js";
import {InitiativeTrackerSort} from "./dmscreen-initiativetracker-sort.js";
/** @abstract */
class _InitiativeTrackerRowStateBuilderBase {
constructor ({comp, roller}) {
this._comp = comp;
this._roller = roller;
}
/* -------------------------------------------- */
/**
* @param {?array} rows Existing/partial rows, for calculating ordinal.
* @param {?boolean} isActive
* @param {?boolean} isPlayerVisible
* @param {?string} name
* @param {?string} displayName
* @param {?number} scaledCr
* @param {?number} scaledSummonSpellLevel
* @param {?number} scaledSummonClassLevel
* @param {?string} customName
* @param {?string} source
* @param {?number} hpCurrent
* @param {?number} hpMax
* @param {?number} initiative
* @param {?number} ordinal
* @param {?array} rowStatColData
* @param {?array} conditions
*/
async pGetNewRowState (
{
rows = null,
isActive = null,
isPlayerVisible = null,
name = null,
displayName = null,
scaledCr = null,
scaledSummonSpellLevel = null,
scaledSummonClassLevel = null,
customName = null,
source = null,
hpCurrent = null,
hpMax = null,
initiative = null,
ordinal = null,
rowStatColData = null,
conditions = null,
} = {},
) {
if (rowStatColData == null) rowStatColData = this._getInitialRowStatColData();
return {
id: CryptUtil.uid(),
entity: {
isActive: !!isActive,
isPlayerVisible: !!isPlayerVisible,
name,
displayName,
scaledCr,
scaledSummonSpellLevel,
scaledSummonClassLevel,
customName,
source,
hpCurrent,
hpMax,
initiative,
ordinal,
rowStatColData: rowStatColData ?? [],
conditions: conditions ?? [],
},
};
}
/**
* @param {?object} mon
* @param {?object} fluff
*/
_getInitialRowStatColData ({mon = null, fluff = null} = {}) {
return this._comp._state.statsCols
.map(data => {
return InitiativeTrackerStatColumnFactory.fromStateData({data})
.getInitialCellStateData({mon, fluff});
});
}
/* -------------------------------------------- */
async pGetRowInitiativeMeta ({row}) {
const out = {mon: null, initiativeModifier: null};
const {entity} = row;
const initiativeInfos = this._comp._state.statsCols
.map(data => {
const cell = entity.rowStatColData.find(cell => cell.id === data.id);
if (!cell) return null;
return {
id: cell.id,
...InitiativeTrackerStatColumnFactory.fromStateData({data})
.getInitiativeInfo({state: cell}),
};
})
.filter(Boolean)
.filter(info => info.applicability !== INITIATIVE_APPLICABILITY_NOT_APPLICABLE)
.sort(InitiativeTrackerSort.sortInitiativeInfos.bind(InitiativeTrackerSort));
const maxApplicability = Math.max(INITIATIVE_APPLICABILITY_NOT_APPLICABLE, ...initiativeInfos.map(info => info.applicability));
if (maxApplicability !== INITIATIVE_APPLICABILITY_NOT_APPLICABLE) {
const initiativeInfosApplicable = initiativeInfos.filter(info => info.applicability === maxApplicability);
out.initiativeModifier = Math.max(...initiativeInfosApplicable.map(info => info.initiative));
}
return out;
}
}
export class InitiativeTrackerRowStateBuilderActive extends _InitiativeTrackerRowStateBuilderBase {
_prop = "rows";
async pGetScaledCreature ({isMon, name, source, scaledCr, scaledSummonSpellLevel, scaledSummonClassLevel}) {
if (!isMon) return null;
return DmScreenUtil.pGetScaledCreature({name, source, scaledCr, scaledSummonSpellLevel, scaledSummonClassLevel});
}
/* -------------------------------------------- */
/**
* @inheritDoc
*/
async pGetNewRowState (
{
rows = null,
isActive = null,
isPlayerVisible = null,
name = null,
displayName = null,
scaledCr = null,
scaledSummonSpellLevel = null,
scaledSummonClassLevel = null,
customName = null,
source = null,
hpCurrent = null,
hpMax = null,
initiative = null,
ordinal = null,
rowStatColData = null,
conditions = null,
} = {},
) {
const isMon = name && source;
const mon = await this.pGetScaledCreature({isMon, name, source, scaledCr, scaledSummonSpellLevel, scaledSummonClassLevel});
if (isMon && !mon) return null;
const fluff = mon ? await Renderer.monster.pGetFluff(mon) : null;
if (isMon) {
if (hpCurrent == null && hpMax == null) {
hpCurrent = hpMax = await this._roller.pGetOrRollHp(mon, {isRollHp: this._comp._state.isRollHp});
}
if (initiative == null && this._comp._state.isRollInit) {
initiative = await this._roller.pGetRollInitiative({mon});
}
if (ordinal == null) {
const existingCreatures = this.getSimilarRows({
rows,
rowEntity: {
name,
customName,
scaledCr,
scaledSummonSpellLevel,
scaledSummonClassLevel,
source,
},
});
ordinal = existingCreatures.length + 1;
}
if (isPlayerVisible == null) isPlayerVisible = !this._comp._state.playerInitHideNewMonster;
}
if (!isMon) {
if (isPlayerVisible == null) isPlayerVisible = true;
}
if (rowStatColData == null) rowStatColData = this._getInitialRowStatColData({mon, fluff});
return {
id: CryptUtil.uid(),
entity: {
isActive: !!isActive,
isPlayerVisible: !!isPlayerVisible,
name,
displayName,
scaledCr,
scaledSummonSpellLevel,
scaledSummonClassLevel,
customName,
source,
hpCurrent,
hpMax,
initiative,
ordinal,
rowStatColData: rowStatColData ?? [],
conditions: conditions ?? [],
},
};
}
/* -------------------------------------------- */
async pGetRowInitiativeMeta ({row}) {
const out = await super.pGetRowInitiativeMeta({row});
const mon = await this.pGetScaledCreature(row.entity);
if (!mon) return out;
out.mon = mon;
out.initiativeModifier ||= Parser.getAbilityModifier(mon.dex);
return out;
}
/* -------------------------------------------- */
static _SIMILAR_ROW_PROPS = [
"name",
"customName",
"scaledCr",
"scaledSummonSpellLevel",
"scaledSummonClassLevel",
"source",
];
static getSimilarRowEntityHash ({rowEntity}) {
return this._SIMILAR_ROW_PROPS
.map(prop => JSON.stringify(rowEntity[prop] ?? null))
.join("__");
}
getSimilarRows (
{
rows = null,
rowEntity,
},
) {
const rowEntityHash = this.constructor.getSimilarRowEntityHash({rowEntity});
return (rows || this._comp._state[this._prop])
.filter(({entity}) => {
return this.constructor.getSimilarRowEntityHash({rowEntity: entity}) === rowEntityHash;
});
}
getSimilarRowCounts (
{
rows = null,
},
) {
return (rows || this._comp._state[this._prop])
.reduce(
(accum, {entity}) => {
const rowEntityHash = InitiativeTrackerRowStateBuilderActive.getSimilarRowEntityHash({rowEntity: entity});
accum[rowEntityHash] = (accum[rowEntityHash] || 0) + 1;
return accum;
},
{},
);
}
}
export class InitiativeTrackerRowStateBuilderDefaultParty extends _InitiativeTrackerRowStateBuilderBase {
_prop = "rowsDefaultParty";
}

View File

@@ -0,0 +1,144 @@
import {InitiativeTrackerDataSerializerBase} from "./dmscreen-initiativetracker-util.js";
export class InitiativeTrackerStatColumnDataSerializer extends InitiativeTrackerDataSerializerBase {
static _FIELD_MAPPINGS = {
"id": "id",
"isEditable": "e",
"isPlayerVisible": "v",
"populateWith": "p",
"abbreviation": "a",
};
}
export class InitiativeTrackerConditionCustomSerializer extends InitiativeTrackerDataSerializerBase {
static _FIELD_MAPPINGS = {
"id": "id",
"entity.name": "n",
"entity.color": "c",
"entity.turns": "t",
};
}
export class InitiativeTrackerRowStatsColDataSerializer extends InitiativeTrackerDataSerializerBase {
static _FIELD_MAPPINGS = {
"id": "id",
"entity.value": "v",
"entity.current": "cur",
"entity.max": "max",
};
}
export class InitiativeTrackerRowDataSerializer extends InitiativeTrackerDataSerializerBase {
static _FIELD_MAPPINGS = {
"id": "id",
// region Flattened `"nameMeta"`
"entity.name": "n",
"entity.displayName": "n_d",
"entity.scaledCr": "n_scr",
"entity.scaledSummonSpellLevel": "n_ssp",
"entity.scaledSummonClassLevel": "n_scl",
// region Used by player tracker
"entity.customName": "n_m",
// endregion
// endregion
"entity.hpCurrent": "h",
"entity.hpMax": "g",
"entity.initiative": "i",
"entity.isActive": "a",
"entity.source": "s",
"entity.conditions": "c",
"entity.isPlayerVisible": "v",
// region Used by player tracker
"entity.hpWoundLevel": "hh",
"entity.ordinal": "o",
// endregion
// region Specific handling
// "entity.rowStatColData": "k",
// endregion
};
static fromSerial (dataSerial) {
// Handle legacy data format
if (dataSerial.n instanceof Object) {
dataSerial.n_d = dataSerial.n.d || null;
dataSerial.n_scr = dataSerial.n.scr || null;
dataSerial.n_ssp = dataSerial.n.ssp || null;
dataSerial.n_scl = dataSerial.n.scl || null;
dataSerial.n_m = dataSerial.n.m || null;
dataSerial.n = dataSerial.n.n;
}
const out = super.fromSerial(dataSerial);
// Convert legacy data
out.id = out.id || CryptUtil.uid();
out.entity.rowStatColData = (dataSerial.k || [])
.map(rowStatColData => {
const out = InitiativeTrackerRowStatsColDataSerializer.fromSerial(rowStatColData);
// If the cell had no data, the `entity` prop may have been serialized away. Ensure it exists.
if (!out.entity) out.entity = {};
return out;
});
// Convert legacy data
if (out.entity.conditions?.length) {
out.entity.conditions = out.entity.conditions
.map(cond => {
if (cond.id) return cond;
return {
id: CryptUtil.uid(),
entity: {
...cond,
},
};
});
}
// Convert legacy data
if (out.entity.ordinal == null) out.entity.ordinal = 1;
// Convert legacy numbers
[
"scaledCr",
"scaledSummonSpellLevel",
"scaledSummonClassLevel",
"hpCurrent",
"hpMax",
"initiative",
"ordinal",
]
.forEach(prop => {
if (out.entity?.[prop] == null) return;
if (isNaN(out.entity?.[prop])) return delete out.entity[prop];
out.entity[prop] = Number(out.entity[prop]);
});
// Convert legacy booleans
[
"isActive",
]
.forEach(prop => {
if (out.entity?.[prop] == null) return;
out.entity[prop] = !!out.entity[prop];
});
return out;
}
static toSerial (data) {
const out = super.toSerial(data);
out.k = (data.entity.rowStatColData || [])
.map(rowStatColData => InitiativeTrackerRowStatsColDataSerializer.toSerial(rowStatColData));
return out;
}
}

View File

@@ -0,0 +1,219 @@
import {InitiativeTrackerUi} from "./dmscreen-initiativetracker-ui.js";
import {
GROUP_DISPLAY_NAMES,
InitiativeTrackerStatColumnFactory,
IS_PLAYER_VISIBLE_ALL,
IS_PLAYER_VISIBLE_NONE,
IS_PLAYER_VISIBLE_PLAYER_UNITS_ONLY,
} from "./dmscreen-initiativetracker-statcolumns.js";
class _RenderableCollectionStatsCols extends RenderableCollectionGenericRows {
constructor (
{
comp,
doClose,
$wrpRows,
},
) {
super(comp, "statsCols", $wrpRows);
this._doClose = doClose;
}
_populateRow ({comp, $wrpRow, entity}) {
$wrpRow.addClass("py-1p");
const meta = InitiativeTrackerStatColumnFactory.fromPopulateWith({populateWith: comp._state.populateWith});
const $iptAbv = ComponentUiUtil.$getIptStr(comp, "abbreviation");
const $cbIsEditable = ComponentUiUtil.$getCbBool(comp, "isEditable");
const $btnVisible = InitiativeTrackerUi.$getBtnPlayerVisible(
comp._state.isPlayerVisible,
() => comp._state.isPlayerVisible = $btnVisible.hasClass("btn-primary--half")
? IS_PLAYER_VISIBLE_PLAYER_UNITS_ONLY
: $btnVisible.hasClass("btn-primary")
? IS_PLAYER_VISIBLE_ALL
: IS_PLAYER_VISIBLE_NONE,
true,
);
const $btnDelete = this._utils.$getBtnDelete({entity});
const $padDrag = this._utils.$getPadDrag({$wrpRow});
$$($wrpRow)`
<div class="col-5 pr-1">${meta.constructor.NAME}</div>
<div class="col-3 pr-1">${$iptAbv}</div>
<div class="col-1-5 ve-text-center">${$cbIsEditable}</div>
<div class="col-1-5 ve-text-center">${$btnVisible}</div>
<div class="col-0-5 ve-flex-vh-center">${$btnDelete}</div>
<div class="col-0-5 ve-flex-vh-center">${$padDrag}</div>
`;
}
}
export class InitiativeTrackerSettings extends BaseComponent {
static _PROPS_TRACKED = [
"isRollInit",
"isRollHp",
"isRollGroups",
"isRerollInitiativeEachRound",
"playerInitShowExactPlayerHp",
"playerInitShowExactMonsterHp",
"playerInitHideNewMonster",
"playerInitShowOrdinals",
"isStatsAddColumns",
"statsCols",
];
constructor ({state}) {
super();
this._proxyAssignSimple(
"state",
{
...InitiativeTrackerSettings._PROPS_TRACKED
.mergeMap(prop => ({[prop]: state[prop]})),
statsCols: this._getStatColsCollectionFormat(state.statsCols),
},
);
}
/* -------------------------------------------- */
// Convert from classic "flat" format to renderable collection format
_getStatColsCollectionFormat (statsCols) {
return (statsCols || [])
.map(data => {
return InitiativeTrackerStatColumnFactory.fromStateData({data})
.getAsCollectionRowStateData();
});
}
// Convert from renderable collection format to classic "flat" format
_getStatColsDataFormat (statsCols) {
return (statsCols || [])
.map(data => {
return InitiativeTrackerStatColumnFactory.fromCollectionRowStateData({data})
.getAsStateData();
});
}
/* -------------------------------------------- */
getStateUpdate () {
const out = MiscUtil.copyFast(this._state);
out.statsCols = this._getStatColsDataFormat(out.statsCols);
return out;
}
/* -------------------------------------------- */
pGetShowModalResults () {
const {$modalInner, $modalFooter, pGetResolved, doClose} = UiUtil.getShowModal({
title: "Settings",
isUncappedHeight: true,
hasFooter: true,
});
UiUtil.addModalSep($modalInner);
this._pGetShowModalResults_renderSection_isRolls({$modalInner});
UiUtil.addModalSep($modalInner);
this._pGetShowModalResults_renderSection_playerView({$modalInner});
UiUtil.addModalSep($modalInner);
this._pGetShowModalResults_renderSection_additionalCols({$modalInner});
this._pGetShowModalResults_renderFooter({$modalFooter, doClose});
return pGetResolved();
}
/* -------------------------------------------- */
_pGetShowModalResults_renderSection_isRolls ({$modalInner}) {
UiUtil.$getAddModalRowCb2({$wrp: $modalInner, comp: this, prop: "isRollInit", text: "Roll initiative"});
UiUtil.$getAddModalRowCb2({$wrp: $modalInner, comp: this, prop: "isRollHp", text: "Roll hit points"});
UiUtil.$getAddModalRowCb2({$wrp: $modalInner, comp: this, prop: "isRollGroups", text: "Roll groups of creatures together"});
UiUtil.$getAddModalRowCb2({$wrp: $modalInner, comp: this, prop: "isRerollInitiativeEachRound", text: "Reroll initiative each round"});
}
_pGetShowModalResults_renderSection_playerView ({$modalInner}) {
UiUtil.$getAddModalRowCb2({$wrp: $modalInner, comp: this, prop: "playerInitShowExactPlayerHp", text: "Player View: Show exact player HP"});
UiUtil.$getAddModalRowCb2({$wrp: $modalInner, comp: this, prop: "playerInitShowExactMonsterHp", text: "Player View: Show exact monster HP"});
UiUtil.$getAddModalRowCb2({$wrp: $modalInner, comp: this, prop: "playerInitHideNewMonster", text: "Player View: Auto-hide new monsters"});
UiUtil.$getAddModalRowCb2({$wrp: $modalInner, comp: this, prop: "playerInitShowOrdinals", text: "Player View: Show ordinals", title: "For example, if you add two Goblins, one will be Goblin (1) and the other Goblin (2), rather than having identical names."});
}
_pGetShowModalResults_renderSection_additionalCols ({$modalInner}) {
UiUtil.$getAddModalRowCb2({$wrp: $modalInner, comp: this, prop: "isStatsAddColumns", text: "Additional Columns"});
this._pGetShowModalResults_renderSection_additionalCols_head({$modalInner});
this._pGetShowModalResults_renderSection_additionalCols_body({$modalInner});
}
_pGetShowModalResults_renderSection_additionalCols_head ({$modalInner}) {
const getAction = Cls => new ContextUtil.Action(
Cls.NAME,
() => {
this._state.statsCols = [...this._state.statsCols, new Cls().getAsCollectionRowStateData()];
},
);
const menuAddStatsCol = ContextUtil.getMenu(
InitiativeTrackerStatColumnFactory.getGroupedByUi()
.map(group => {
const [ClsHead] = group;
if (group.length === 1) return getAction(ClsHead);
return new ContextUtil.ActionSubMenu(
GROUP_DISPLAY_NAMES[ClsHead.GROUP],
group.map(Cls => getAction(Cls)),
);
}),
);
const $btnAddRow = $(`<button class="btn btn-default btn-xs bb-0 bbr-0 bbl-0" title="Add"><span class="glyphicon glyphicon-plus"></span></button>`)
.click(evt => ContextUtil.pOpenMenu(evt, menuAddStatsCol));
const $wrpTblStatsHead = $$`<div class="ve-flex-vh-center w-100 mb-2 bb-1p-trans">
<div class="col-5">Contains</div>
<div class="col-3">Abbreviation</div>
<div class="col-1-5 ve-text-center help" title="Only affects creatures. Players are always editable.">Editable</div>
<div class="col-1-5">&nbsp;</div>
<div class="col-1 ve-flex-v-center ve-flex-h-right">${$btnAddRow}</div>
</div>`
.appendTo($modalInner);
this._addHookBase("isStatsAddColumns", () => $wrpTblStatsHead.toggleVe(this._state.isStatsAddColumns))();
}
_pGetShowModalResults_renderSection_additionalCols_body ({$modalInner}) {
const $wrpRows = $(`<div class="pr-1 h-120p ve-flex-col overflow-y-auto relative"></div>`).appendTo($modalInner);
this._addHookBase("isStatsAddColumns", () => $wrpRows.toggleVe(this._state.isStatsAddColumns))();
const renderableCollectionStatsCols = new _RenderableCollectionStatsCols(
{
comp: this,
$wrpRows,
},
);
this._addHookBase("statsCols", () => {
renderableCollectionStatsCols.render();
})();
}
/* -------------------------------------------- */
_pGetShowModalResults_renderFooter ({$modalFooter, doClose}) {
const $btnSave = $(`<button class="btn btn-primary btn-sm w-100">Save</button>`)
.click(() => doClose(true));
$$($modalFooter)`<div class="w-100 py-3 no-shrink">
${$btnSave}
</div>`;
}
}

View File

@@ -0,0 +1,61 @@
import {InitiativeTrackerConst} from "./dmscreen-initiativetracker-consts.js";
export class InitiativeTrackerSort {
static _getSortMultiplier ({sortDir}) {
return sortDir === InitiativeTrackerConst.SORT_DIR_DESC ? -1 : 1;
}
static _sortRowsNameBasic ({sortDir}, rowA, rowB) {
return this._getSortMultiplier({sortDir}) * SortUtil.ascSortLower(
rowA.entity.customName || rowA.entity.name || "",
rowB.entity.customName || rowB.entity.name || "",
);
}
static _sortRowsInitiativeBasic ({sortDir}, rowA, rowB) {
return this._getSortMultiplier({sortDir}) * SortUtil.ascSort(
rowA.entity.initiative || 0,
rowB.entity.initiative || 0,
);
}
static _sortRowsOrdinal (rowA, rowB) {
// (Ordinals are always sorted ascending)
return SortUtil.ascSort(rowA.entity.ordinal || 0, rowB.entity.ordinal || 0);
}
static _sortRowsId (rowA, rowB) {
// (IDs are always sorted ascending)
return SortUtil.ascSort(rowA.id, rowB.id);
}
static _sortRowsName ({sortDir}, rowA, rowB) {
return this._sortRowsNameBasic({sortDir}, rowA, rowB)
|| this._sortRowsOrdinal(rowA, rowB)
|| this._sortRowsInitiativeBasic({sortDir}, rowA, rowB)
|| this._sortRowsId(rowA, rowB); // Fallback on ID to guarantee stable sort
}
static _sortRowsInitiative ({sortDir}, rowA, rowB) {
return this._sortRowsInitiativeBasic({sortDir}, rowA, rowB)
|| this._sortRowsNameBasic({sortDir}, rowA, rowB)
|| this._sortRowsOrdinal(rowA, rowB)
|| this._sortRowsId(rowA, rowB); // Fallback on ID to guarantee stable sort
}
static getSortedRows ({rows, sortBy, sortDir}) {
const fnSort = sortBy === InitiativeTrackerConst.SORT_ORDER_ALPHA
? this._sortRowsName.bind(this, {sortDir})
: this._sortRowsInitiative.bind(this, {sortDir});
return [...rows].sort(fnSort);
}
/* -------------------------------------------- */
static sortInitiativeInfos (a, b) {
return SortUtil.ascSort(b.applicability, a.applicability)
|| SortUtil.ascSort(b.initiative, a.initiative)
|| SortUtil.ascSort(a.id, b.id); // Fallback on ID to guarantee stable sort
}
}

View File

@@ -0,0 +1,718 @@
import {InitiativeTrackerConst} from "./dmscreen-initiativetracker-consts.js";
import {
InitiativeTrackerRowStatsColDataSerializer,
InitiativeTrackerStatColumnDataSerializer,
} from "./dmscreen-initiativetracker-serial.js";
import {InitiativeTrackerUtil} from "../../initiativetracker/initiativetracker-utils.js";
export const GROUP_BASE_STATS = "baseStats";
export const GROUP_SAVES = "saves";
export const GROUP_ABILITY_BONUS = "abilityBonus";
export const GROUP_ABILITY_SCORE = "abilityScore";
export const GROUP_SKILL = "skill";
export const GROUP_CHECKBOX = "checkbox";
export const GROUP_CUSTOM = "custom";
export const GROUP_DISPLAY_NAMES = {
[GROUP_BASE_STATS]: "General",
[GROUP_SAVES]: "Saving Throw",
[GROUP_ABILITY_BONUS]: "Ability Bonus",
[GROUP_ABILITY_SCORE]: "Ability Score",
[GROUP_SKILL]: "Skill",
[GROUP_CHECKBOX]: "Checkbox",
[GROUP_CUSTOM]: "Custom",
};
export const IS_PLAYER_VISIBLE_NONE = 0;
export const IS_PLAYER_VISIBLE_ALL = 1;
export const IS_PLAYER_VISIBLE_PLAYER_UNITS_ONLY = 2;
export const INITIATIVE_APPLICABILITY_NOT_APPLICABLE = 0;
const _INITIATIVE_APPLICABILITY_APPLICABLE = 1;
const _INITIATIVE_APPLICABILITY_EXACT = 2;
/** @abstract */
class _InitiativeTrackerStatColumnBase {
static _SERIALIZER_MAPPINGS = {
"entity.value": "v",
};
static _registerSerializerMappings () {
Object.entries(this._SERIALIZER_MAPPINGS)
.forEach(([kFull, kSerial]) => InitiativeTrackerRowStatsColDataSerializer.registerMapping({kFull, kSerial, isAllowDuplicates: true}));
return null;
}
static _ = this._registerSerializerMappings();
/* -------------------------------------------- */
/** Functions as an ID for the column type. */
static get POPULATE_WITH () { throw new Error("Unimplemented!"); }
/** UI group the column type belongs to. */
static GROUP;
static NAME;
static ABV_DEFAULT = "";
constructor (
{
id,
isEditable,
isPlayerVisible,
abbreviation,
} = {},
) {
this._id = id ?? CryptUtil.uid();
this._isEditable = isEditable ?? true;
this._isPlayerVisible = isPlayerVisible ?? false;
this._abbreviation = abbreviation ?? this.constructor.ABV_DEFAULT;
}
/**
* @param {?object} mon
* @param {?object} fluff
* @return {?*}
* @abstract
*/
_getInitialCellObj ({mon, fluff}) { throw new Error("Unimplemented!"); }
_getAsData () {
return {
id: this._id,
isEditable: this._isEditable,
isPlayerVisible: this._isPlayerVisible,
populateWith: this.constructor.POPULATE_WITH,
abbreviation: this._abbreviation,
};
}
getAsStateData () {
return this._getAsData();
}
getAsCollectionRowStateData () {
const data = this._getAsData();
const out = {
id: data.id,
entity: {
...data,
},
};
delete out.entity.id;
return out;
}
getPlayerFriendlyState ({cell}) {
return {id: cell.id, entity: cell.entity};
}
/* -------------------------------------------- */
/**
* @return {?object} `undefined` if the column should not auto-set at the start of the turn, or, a value the column should
* be auto-set to at the start of the turn.
*/
_getAutoTurnStartObject ({state, mon}) { return undefined; }
/**
* @return {?object} `undefined` if the column should not auto-set at the start of the round, or, a value the column should
* be auto-set to at the start of the round.
*/
_getAutoRoundStartObject ({state, mon}) { return undefined; }
_isNonNegativeDirection ({direction}) {
return ![InitiativeTrackerConst.DIR_FORWARDS, InitiativeTrackerConst.DIR_NEUTRAL].includes(direction);
}
onTurnStart ({state, direction, mon}) {
if (this._isNonNegativeDirection({direction})) return;
const obj = this._getAutoTurnStartObject({state, mon});
if (obj !== undefined) Object.assign(state.entity, obj);
}
onRoundStart ({state, direction, mon}) {
if (this._isNonNegativeDirection({direction})) return;
const obj = this._getAutoRoundStartObject({state, mon});
if (obj !== undefined) Object.assign(state.entity, obj);
}
$getRenderedHeader () {
return $(`<div class="dm-init__stat_head" ${this.constructor.NAME ? `title="${this.constructor.NAME}"` : ""}>${this._abbreviation}</div>`);
}
$getRendered ({comp, mon, networking = null}) {
const $ipt = ComponentUiUtil.$getIptStr(comp, "value")
.removeClass("input-xs")
.addClass("input-sm")
.addClass("dm-init__stat_ipt")
.addClass("ve-text-center")
.on("click", () => $ipt.select());
if (mon) {
comp._addHookBase("isEditable", () => {
$ipt.prop("disabled", !comp._state.isEditable);
})();
}
return $$`<div class="ve-flex-vh-center">${$ipt}</div>`;
}
/* -------------------------------------------- */
/**
* @param {?object} mon
* @param {?object} fluff
* @param {?object} obj
*/
getInitialCellStateData ({mon = null, fluff = null, obj = null} = {}) {
return {
id: this._id,
entity: {
...(obj ?? (this._getInitialCellObj({mon, fluff}) || {})),
isEditable: this._isEditable,
},
};
}
/* -------------------------------------------- */
getInitiativeInfo ({state}) {
return {
applicability: INITIATIVE_APPLICABILITY_NOT_APPLICABLE,
initiative: null,
};
}
}
class InitiativeTrackerStatColumn_HpFormula extends _InitiativeTrackerStatColumnBase {
static get POPULATE_WITH () { return "hpFormula"; }
static GROUP = GROUP_BASE_STATS;
static NAME = "HP Formula";
_getInitialCellObj ({mon, fluff}) {
if (!mon) return {value: null};
return {value: (mon.hp || {}).formula || ""};
}
}
class InitiativeTrackerStatColumn_ArmorClass extends _InitiativeTrackerStatColumnBase {
static get POPULATE_WITH () { return "armorClass"; }
static GROUP = GROUP_BASE_STATS;
static NAME = "Armor Class";
static ABV_DEFAULT = "AC";
_getInitialCellObj ({mon, fluff}) {
if (!mon) return {value: null};
return {value: mon.ac[0] ? (mon.ac[0].ac || mon.ac[0]) : null};
}
}
class InitiativeTrackerStatColumn_PassivePerception extends _InitiativeTrackerStatColumnBase {
static get POPULATE_WITH () { return "passivePerception"; }
static GROUP = GROUP_BASE_STATS;
static NAME = "Passive Perception";
static ABV_DEFAULT = "PP";
_getInitialCellObj ({mon, fluff}) {
if (!mon) return {value: null};
return {value: mon.passive};
}
}
class InitiativeTrackerStatColumn_Speed extends _InitiativeTrackerStatColumnBase {
static get POPULATE_WITH () { return "speed"; }
static GROUP = GROUP_BASE_STATS;
static NAME = "Speed";
static ABV_DEFAULT = "SPD";
_getInitialCellObj ({mon, fluff}) {
if (!mon) return {value: null};
return {
value: Math.max(0, ...Object.values(mon.speed || {})
.map(it => it.number ? it.number : it)
.filter(it => typeof it === "number")),
};
}
}
class InitiativeTrackerStatColumn_SpellDc extends _InitiativeTrackerStatColumnBase {
static get POPULATE_WITH () { return "spellDc"; }
static GROUP = GROUP_BASE_STATS;
static NAME = "Spell DC";
static ABV_DEFAULT = "DC";
_getInitialCellObj ({mon, fluff}) {
if (!mon) return {value: null};
return {
value: Math.max(
0,
...(mon.spellcasting || [])
.filter(it => it.headerEntries)
.map(it => {
return it.headerEntries
.map(it => {
const found = [0];
it
.replace(/DC (\d+)/g, (...m) => found.push(Number(m[1])))
.replace(/{@dc (\d+)}/g, (...m) => found.push(Number(m[1])));
return Math.max(...found);
})
.filter(Boolean);
})
.flat(),
),
};
}
}
class InitiativeTrackerStatColumn_Initiative extends _InitiativeTrackerStatColumnBase {
static get POPULATE_WITH () { return "initiative"; }
static GROUP = GROUP_BASE_STATS;
static NAME = "Initiative";
static ABV_DEFAULT = "INIT";
_getInitialCellObj ({mon, fluff}) {
if (!mon) return {value: null};
return {
value: Parser.getAbilityModifier(mon.dex),
};
}
/* -------------------------------------------- */
getInitiativeInfo ({state}) {
return {
applicability: _INITIATIVE_APPLICABILITY_EXACT,
initiative: isNaN(state.entity?.value) ? 0 : Number(state.entity.value),
};
}
}
class InitiativeTrackerStatColumn_LegendaryActions extends _InitiativeTrackerStatColumnBase {
static _SERIALIZER_MAPPINGS = {
"entity.current": "cur",
"entity.max": "max",
};
static _ = this._registerSerializerMappings();
/* -------------------------------------------- */
static get POPULATE_WITH () { return "legendaryActions"; }
static GROUP = GROUP_BASE_STATS;
static NAME = "Legendary Actions";
static ABV_DEFAULT = "LA";
getPlayerFriendlyState ({cell}) {
return {
...super.getPlayerFriendlyState({cell}),
entity: {
value: `${cell.entity.current || 0}/${cell.entity.max || 0}`,
},
};
}
_getInitialCellObj ({mon, fluff}) {
if (!mon) return {current: null, max: null};
const cnt = mon.legendaryActions ?? (mon.legendary ? 3 : null);
return {
current: cnt,
max: cnt,
};
}
_getAutoRoundStartObject ({state, mon}) {
return {current: state.entity.max};
}
$getRenderedHeader () {
return super.$getRenderedHeader()
.addClass("w-48p");
}
$getRendered ({comp, mon}) {
const $iptCurrent = ComponentUiUtil.$getIptNumber(
comp,
"current",
null,
{
isAllowNull: true,
fallbackOnNaN: null,
html: `<input class="form-control input-sm hp dm-init__row-input text-right w-24p mr-0 br-0">`,
},
)
.on("click", () => $iptCurrent.select());
const $iptMax = ComponentUiUtil.$getIptNumber(
comp,
"max",
null,
{
isAllowNull: true,
fallbackOnNaN: null,
html: `<input class="form-control input-sm hp-max dm-init__row-input w-24p mr-0 bl-0">`,
},
)
.on("click", () => $iptMax.select());
if (mon) {
comp._addHookBase("isEditable", () => {
$iptCurrent.prop("disabled", !comp._state.isEditable);
$iptMax.prop("disabled", !comp._state.isEditable);
})();
}
return $$`<div class="ve-flex relative mr-3p">
<div class="text-right">${$iptCurrent}</div>
<div class="dm-init__sep-fields-slash ve-flex-vh-center">/</div>
<div class="text-left">${$iptMax}</div>
</div>`;
}
}
class InitiativeTrackerStatColumn_Image extends _InitiativeTrackerStatColumnBase {
static _SERIALIZER_MAPPINGS = {
"entity.tokenUrl": "urlt",
"entity.imageHref": "hrefi",
};
static _ = this._registerSerializerMappings();
/* -------------------------------------------- */
static get POPULATE_WITH () { return "image"; }
static GROUP = GROUP_BASE_STATS;
static NAME = "Image";
static ABV_DEFAULT = "IMG";
getPlayerFriendlyState ({cell}) {
return {
...super.getPlayerFriendlyState({cell}),
entity: {
type: "image",
tokenUrl: cell.entity.tokenUrl,
imageHref: cell.entity.imageHref,
},
};
}
_getInitialCellObj ({mon, fluff}) {
if (!mon) {
return {
tokenUrl: UrlUtil.link(Renderer.get().getMediaUrl("img", "blank-friendly.webp")),
imageHref: null,
};
}
return {
tokenUrl: Renderer.monster.getTokenUrl(mon),
imageHref: fluff?.images?.[0].href,
};
}
$getRendered ({comp, mon, networking = null}) {
const $ele = $$`<div class="mr-3p ve-flex-vh-center w-40p">
<img src="${comp._state.tokenUrl}" class="w-30p h-30p" alt="Token Image">
</div>`;
if (networking != null) {
$ele.title("Click to Show to Connected Players");
$ele
.on("click", () => {
networking.sendShowImageMessageToClients({
imageHref: InitiativeTrackerUtil.getImageOrTokenHref({imageHref: comp._state.imageHref, tokenUrl: comp._state.tokenUrl}),
});
});
// If no networking, assume this is an "edit" modal, and avoid binding events
Renderer.monster.hover.bindFluffImageMouseover({mon, $ele});
}
return $ele;
}
}
class InitiativeTrackerStatColumn_Save extends _InitiativeTrackerStatColumnBase {
static _ATT;
static get POPULATE_WITH () { return `${this._ATT}Save`; }
static GROUP = GROUP_SAVES;
static get NAME () { return `${Parser.attAbvToFull(this._ATT)} Save`; }
static get ABV_DEFAULT () { return this._ATT.toUpperCase(); }
_getInitialCellObj ({mon, fluff}) {
if (!mon) return {value: null};
return {
value: mon.save?.[this.constructor._ATT] ? mon.save[this.constructor._ATT] : Parser.getAbilityModifier(mon[this.constructor._ATT]),
};
}
}
class InitiativeTrackerStatColumn_AbilityBonus extends _InitiativeTrackerStatColumnBase {
static _ATT;
static get POPULATE_WITH () { return `${this._ATT}Bonus`; }
static GROUP = GROUP_ABILITY_BONUS;
static get NAME () { return `${Parser.attAbvToFull(this._ATT)} Bonus`; }
static get ABV_DEFAULT () { return this._ATT.toUpperCase(); }
_getInitialCellObj ({mon, fluff}) {
if (!mon) return {value: null};
return {
value: Parser.getAbilityModifier(mon[this.constructor._ATT]),
};
}
/* -------------------------------------------- */
getInitiativeInfo ({state}) {
if (this.constructor._ATT !== "dex") return super.getInitiativeInfo({state});
return {
applicability: _INITIATIVE_APPLICABILITY_APPLICABLE,
initiative: isNaN(state.entity?.value) ? 0 : Number(state.entity.value),
};
}
}
class InitiativeTrackerStatColumn_AbilityScore extends _InitiativeTrackerStatColumnBase {
static _ATT;
static get POPULATE_WITH () { return `${this._ATT}Score`; }
static GROUP = GROUP_ABILITY_SCORE;
static get NAME () { return `${Parser.attAbvToFull(this._ATT)} Score`; }
static get ABV_DEFAULT () { return this._ATT.toUpperCase(); }
_getInitialCellObj ({mon, fluff}) {
if (!mon) return {value: null};
return {
value: mon[this.constructor._ATT],
};
}
/* -------------------------------------------- */
getInitiativeInfo ({state}) {
if (this.constructor._ATT !== "dex") return super.getInitiativeInfo({state});
return {
applicability: _INITIATIVE_APPLICABILITY_APPLICABLE,
initiative: isNaN(state.entity?.value) ? 0 : Parser.getAbilityModifier(Number(state.entity.value)),
};
}
}
class InitiativeTrackerStatColumn_Skill extends _InitiativeTrackerStatColumnBase {
static _SKILL;
static get POPULATE_WITH () { return this._SKILL.toCamelCase(); }
static GROUP = GROUP_SKILL;
static get NAME () { return this._SKILL.toTitleCase(); }
static get ABV_DEFAULT () { return Parser.skillToShort(this._SKILL).toUpperCase(); }
_getInitialCellObj ({mon, fluff}) {
if (!mon) return {value: null};
return {
value: mon.skill?.[this.constructor._SKILL]
? mon.skill[this.constructor._SKILL]
: Parser.getAbilityModifier(mon[Parser.skillToAbilityAbv(this.constructor._SKILL)]),
};
}
}
class _InitiativeTrackerStatColumnCheckboxBase extends _InitiativeTrackerStatColumnBase {
static GROUP = GROUP_CHECKBOX;
static _AUTO_VALUE = undefined;
_getInitialCellObj ({mon, fluff}) { return {value: false}; }
$getRendered ({comp, mon}) {
const $cb = ComponentUiUtil.$getCbBool(comp, "value")
.addClass("dm-init__stat_ipt");
if (mon) {
comp._addHookBase("isEditable", () => {
$cb.prop("disabled", !comp._state.isEditable);
})();
}
return $$`<label class="dm-init__wrp-stat-cb h-100 ve-flex-vh-center">${$cb}</label>`;
}
}
class _InitiativeTrackerStatColumnCheckboxTurnBase extends _InitiativeTrackerStatColumnCheckboxBase {
_getAutoTurnStartObject ({state, mon}) { return {value: this.constructor._AUTO_VALUE}; }
}
class _InitiativeTrackerStatColumnCheckboxRoundBase extends _InitiativeTrackerStatColumnCheckboxBase {
_getAutoRoundStartObject ({state, mon}) { return {value: this.constructor._AUTO_VALUE}; }
}
class InitiativeTrackerStatColumn_Checkbox extends _InitiativeTrackerStatColumnCheckboxBase {
static get POPULATE_WITH () { return "cbNeutral"; }
static NAME = "Checkbox";
}
class InitiativeTrackerStatColumn_CheckboxAutoTurnLow extends _InitiativeTrackerStatColumnCheckboxTurnBase {
static get POPULATE_WITH () { return "cbAutoLow"; }
static NAME = "Checkbox; clears at start of turn";
static _AUTO_VALUE = false;
}
class InitiativeTrackerStatColumn_CheckboxAutoTurnHigh extends _InitiativeTrackerStatColumnCheckboxTurnBase {
static get POPULATE_WITH () { return "cbAutoHigh"; }
static NAME = "Checkbox; ticks at start of turn";
static _AUTO_VALUE = true;
_getInitialCellObj ({mon, fluff}) { return {value: true}; }
}
class InitiativeTrackerStatColumn_CheckboxAutoRoundLow extends _InitiativeTrackerStatColumnCheckboxRoundBase {
static get POPULATE_WITH () { return "cbAutoRoundLow"; }
static NAME = "Checkbox; clears at start of round";
static _AUTO_VALUE = false;
}
class InitiativeTrackerStatColumn_CheckboxAutoRoundHigh extends _InitiativeTrackerStatColumnCheckboxRoundBase {
static get POPULATE_WITH () { return "cbAutoRoundHigh"; }
static NAME = "Checkbox; ticks at start of round";
static _AUTO_VALUE = true;
_getInitialCellObj ({mon, fluff}) { return {value: true}; }
}
export class InitiativeTrackerStatColumn_Custom extends _InitiativeTrackerStatColumnBase {
static get POPULATE_WITH () { return ""; }
static GROUP = GROUP_CUSTOM;
static NAME = "(Custom)";
_getInitialCellObj ({mon, fluff}) { return {value: ""}; }
}
export class InitiativeTrackerStatColumnFactory {
static _COL_CLS_LOOKUP = {};
static _initLookup () {
[
InitiativeTrackerStatColumn_HpFormula,
InitiativeTrackerStatColumn_ArmorClass,
InitiativeTrackerStatColumn_PassivePerception,
InitiativeTrackerStatColumn_Speed,
InitiativeTrackerStatColumn_SpellDc,
InitiativeTrackerStatColumn_Initiative,
InitiativeTrackerStatColumn_LegendaryActions,
InitiativeTrackerStatColumn_Image,
].forEach(Cls => this._initLookup_addCls(Cls));
Parser.ABIL_ABVS
.forEach(abv => {
this._initLookup_addCls(class extends InitiativeTrackerStatColumn_Save { static _ATT = abv; });
});
Parser.ABIL_ABVS
.forEach(abv => {
this._initLookup_addCls(class extends InitiativeTrackerStatColumn_AbilityBonus { static _ATT = abv; });
});
Parser.ABIL_ABVS
.forEach(abv => {
this._initLookup_addCls(class extends InitiativeTrackerStatColumn_AbilityScore { static _ATT = abv; });
});
Object.keys(Parser.SKILL_TO_ATB_ABV)
.sort(SortUtil.ascSort)
.forEach(skill => {
this._initLookup_addCls(class extends InitiativeTrackerStatColumn_Skill { static _SKILL = skill; });
});
[
InitiativeTrackerStatColumn_Checkbox,
InitiativeTrackerStatColumn_CheckboxAutoTurnLow,
InitiativeTrackerStatColumn_CheckboxAutoTurnHigh,
InitiativeTrackerStatColumn_CheckboxAutoRoundLow,
InitiativeTrackerStatColumn_CheckboxAutoRoundHigh,
].forEach(Cls => this._initLookup_addCls(Cls));
}
static _initLookup_addCls (Cls) { this._COL_CLS_LOOKUP[Cls.POPULATE_WITH] = Cls; }
/* -------------------------------------------- */
static getGroupedByUi () {
const out = [
[InitiativeTrackerStatColumn_Custom],
];
let groupPrev = GROUP_CUSTOM;
Object.values(this._COL_CLS_LOOKUP)
.forEach(Cls => {
if (groupPrev !== Cls.GROUP) out.push([]);
out.last().push(Cls);
groupPrev = Cls.GROUP;
});
return out;
}
/* -------------------------------------------- */
/**
* @param dataSerial
* @param data
* @return {_InitiativeTrackerStatColumnBase}
*/
static fromStateData ({dataSerial, data}) {
if (dataSerial && data) throw new Error(`Only one of "dataSerial" and "data" may be provided!`);
data = data ?? InitiativeTrackerStatColumnDataSerializer.fromSerial(dataSerial);
const Cls = this._COL_CLS_LOOKUP[data.populateWith] ?? InitiativeTrackerStatColumn_Custom;
return new Cls(data);
}
/**
* @param colName
* @return {_InitiativeTrackerStatColumnBase}
*/
static fromEncounterAdvancedColName ({colName}) {
colName = colName.toLowerCase().trim();
const Cls = Object.values(this._COL_CLS_LOOKUP)
.find(Cls => Cls.ABV_DEFAULT.toLowerCase() === colName)
|| InitiativeTrackerStatColumn_Custom;
return new Cls({
id: CryptUtil.uid(),
isEditable: true,
isPlayerVisible: IS_PLAYER_VISIBLE_PLAYER_UNITS_ONLY,
abbreviation: colName,
});
}
/**
* @param populateWith
* @return {_InitiativeTrackerStatColumnBase}
*/
static fromPopulateWith ({populateWith}) {
const Cls = this._COL_CLS_LOOKUP[populateWith] ?? InitiativeTrackerStatColumn_Custom;
return new Cls({});
}
/**
* @param data
* @return {_InitiativeTrackerStatColumnBase}
*/
static fromCollectionRowStateData ({data}) {
const flat = {id: data.id, ...data.entity};
return this.fromStateData({data: flat});
}
}
InitiativeTrackerStatColumnFactory._initLookup();

View File

@@ -0,0 +1,25 @@
export class InitiativeTrackerUi {
static $getBtnPlayerVisible (isVisible, fnOnClick, isTriState, ...additionalClasses) {
let isVisNum = Number(isVisible || false);
const getTitle = () => isVisNum === 0 ? `Hidden in player view` : isVisNum === 1 ? `Shown in player view` : `Shown in player view on player characters, hidden in player view on monsters`;
const getClasses = () => `${isVisNum === 0 ? `btn-default` : isVisNum === 1 ? `btn-primary` : `btn-primary btn-primary--half`} btn btn-xs ${additionalClasses.join(" ")}`;
const getIconClasses = () => isVisNum === 0 ? `glyphicon glyphicon-eye-close` : `glyphicon glyphicon-eye-open`;
const $dispIcon = $(`<span class="glyphicon ${getIconClasses()}"></span>`);
const $btnVisible = $$`<button class="${getClasses()}" title="${getTitle()}" tabindex="-1">${$dispIcon}</button>`
.on("click", () => {
if (isVisNum === 0) isVisNum++;
else if (isVisNum === 1) isVisNum = isTriState ? 2 : 0;
else if (isVisNum === 2) isVisNum = 0;
$btnVisible.title(getTitle());
$btnVisible.attr("class", getClasses());
$dispIcon.attr("class", getIconClasses());
fnOnClick();
});
return $btnVisible;
}
}

View File

@@ -0,0 +1,44 @@
export class InitiativeTrackerDataSerializerBase {
static _FIELD_MAPPINGS = {};
static _getFieldMappings () {
return Object.entries(this._FIELD_MAPPINGS)
.map(([kFull, kSerial]) => {
const kFullParts = kFull.split(".");
return {
kFullParts,
kSerial,
};
});
}
static registerMapping ({kFull, kSerial, isAllowDuplicates = false}) {
if (!isAllowDuplicates) {
if (this._FIELD_MAPPINGS[kFull]) throw new Error(`Serializer key "${kFull}" was already registered!`);
}
if (!isAllowDuplicates || this._FIELD_MAPPINGS[kFull] == null) {
if (Object.values(this._FIELD_MAPPINGS).some(k => k === kSerial)) throw new Error(`Serializer value "${kFull}" was already registered!`);
}
this._FIELD_MAPPINGS[kFull] = kSerial;
}
/* -------------------------------------------- */
static fromSerial (dataSerial) {
const out = {};
this._getFieldMappings()
.filter(({kSerial}) => dataSerial[kSerial] != null)
.forEach(({kFullParts, kSerial}) => MiscUtil.set(out, ...kFullParts, dataSerial[kSerial]));
return out;
}
static toSerial (data) {
return this._getFieldMappings()
.filter(({kFullParts}) => MiscUtil.get(data, ...kFullParts) != null)
.mergeMap(({kFullParts, kSerial}) => ({[kSerial]: MiscUtil.get(data, ...kFullParts)}));
}
}

View File

@@ -0,0 +1,661 @@
import {InitiativeTrackerConst} from "./dmscreen-initiativetracker-consts.js";
import {InitiativeTrackerNetworking} from "./dmscreen-initiativetracker-networking.js";
import {InitiativeTrackerSettings} from "./dmscreen-initiativetracker-settings.js";
import {InitiativeTrackerSettingsImport} from "./dmscreen-initiativetracker-importsettings.js";
import {InitiativeTrackerMonsterAdd} from "./dmscreen-initiativetracker-monsteradd.js";
import {InitiativeTrackerRoller} from "./dmscreen-initiativetracker-roller.js";
import {InitiativeTrackerEncounterConverter} from "./dmscreen-initiativetracker-encounterconverter.js";
import {
InitiativeTrackerStatColumnFactory,
IS_PLAYER_VISIBLE_ALL,
} from "./dmscreen-initiativetracker-statcolumns.js";
import {
InitiativeTrackerRowDataViewActive,
} from "./dmscreen-initiativetracker-rowsactive.js";
import {
InitiativeTrackerConditionCustomSerializer,
InitiativeTrackerRowDataSerializer,
InitiativeTrackerStatColumnDataSerializer,
} from "./dmscreen-initiativetracker-serial.js";
import {InitiativeTrackerSort} from "./dmscreen-initiativetracker-sort.js";
import {InitiativeTrackerUtil} from "../../initiativetracker/initiativetracker-utils.js";
import {DmScreenUtil} from "../dmscreen-util.js";
import {
InitiativeTrackerRowStateBuilderActive,
InitiativeTrackerRowStateBuilderDefaultParty,
} from "./dmscreen-initiativetracker-rowstatebuilder.js";
import {InitiativeTrackerDefaultParty} from "./dmscreen-initiativetracker-defaultparty.js";
import {ListUtilBestiary} from "../../utils-list-bestiary.js";
export class InitiativeTracker extends BaseComponent {
constructor ({board, savedState}) {
super();
this._board = board;
this._savedState = savedState;
this._networking = new InitiativeTrackerNetworking({board});
this._roller = new InitiativeTrackerRoller();
this._rowStateBuilderActive = new InitiativeTrackerRowStateBuilderActive({comp: this, roller: this._roller});
this._rowStateBuilderDefaultParty = new InitiativeTrackerRowStateBuilderDefaultParty({comp: this, roller: this._roller});
this._viewRowsActive = null;
this._viewRowsActiveMeta = null;
this._compDefaultParty = null;
this._creatureViewers = [];
}
render () {
if (this._viewRowsActiveMeta) this._viewRowsActiveMeta.cbDoCleanup();
this._resetHooks("state");
this._resetHooksAll("state");
this._setStateFromSerialized();
this._render_bindSortDirHooks();
const $wrpTracker = $(`<div class="dm-init dm__panel-bg dm__data-anchor"></div>`);
const sendStateToClientsDebounced = MiscUtil.debounce(
() => {
this._networking.sendStateToClients({fnGetToSend: this._getPlayerFriendlyState.bind(this)});
this._sendStateToCreatureViewers();
},
100, // long delay to avoid network spam
);
const doUpdateExternalStates = () => {
this._board.doSaveStateDebounced();
sendStateToClientsDebounced();
};
this._addHookAllBase(doUpdateExternalStates);
this._viewRowsActive = new InitiativeTrackerRowDataViewActive({
comp: this,
prop: "rows",
roller: this._roller,
networking: this._networking,
rowStateBuilder: this._rowStateBuilderActive,
});
this._viewRowsActiveMeta = this._viewRowsActive.getRenderedView();
this._viewRowsActiveMeta.$ele.appendTo($wrpTracker);
this._render_$getWrpFooter({doUpdateExternalStates}).appendTo($wrpTracker);
$wrpTracker.data("pDoConnectLocalV1", async () => {
const {token} = await this._networking.startServerV1({doUpdateExternalStates});
return token;
});
$wrpTracker.data("pDoConnectLocalV0", async (clientView) => {
await this._networking.pHandleDoConnectLocalV0({clientView});
sendStateToClientsDebounced();
});
$wrpTracker.data("getState", () => this._getSerializedState());
$wrpTracker.data("getSummary", () => {
const names = this._state.rows
.map(({entity}) => entity.name)
.filter(name => name && name.trim());
return `${this._state.rows.length} creature${this._state.rows.length === 1 ? "" : "s"} ${names.length ? `(${names.slice(0, 3).join(", ")}${names.length > 3 ? "..." : ""})` : ""}`;
});
$wrpTracker.data("pDoLoadEncounter", ({entityInfos, encounterInfo}) => this._pDoLoadEncounter({entityInfos, encounterInfo}));
$wrpTracker.data("getApi", () => this);
return $wrpTracker;
}
_render_$getWrpButtonsSort () {
const $btnSortAlpha = $(`<button title="Sort Alphabetically" class="btn btn-default btn-xs"><span class="glyphicon glyphicon-sort-by-alphabet"></span></button>`)
.on("click", () => {
if (this._state.sort === InitiativeTrackerConst.SORT_ORDER_ALPHA) return this._doReverseSortDir();
this._proxyAssignSimple(
"state",
{
sort: InitiativeTrackerConst.SORT_ORDER_ALPHA,
dir: InitiativeTrackerConst.SORT_DIR_ASC,
},
);
});
const $btnSortNumber = $(`<button title="Sort Numerically" class="btn btn-default btn-xs"><span class="glyphicon glyphicon-sort-by-order"></span></button>`)
.on("click", () => {
if (this._state.sort === InitiativeTrackerConst.SORT_ORDER_NUM) return this._doReverseSortDir();
this._proxyAssignSimple(
"state",
{
sort: InitiativeTrackerConst.SORT_ORDER_NUM,
dir: InitiativeTrackerConst.SORT_DIR_DESC,
},
);
});
const hkSortDir = () => {
$btnSortAlpha.toggleClass("active", this._state.sort === InitiativeTrackerConst.SORT_ORDER_ALPHA);
$btnSortNumber.toggleClass("active", this._state.sort === InitiativeTrackerConst.SORT_ORDER_NUM);
};
this._addHookBase("sort", hkSortDir);
this._addHookBase("dir", hkSortDir);
hkSortDir();
return $$`<div class="btn-group ve-flex">
${$btnSortAlpha}
${$btnSortNumber}
</div>`;
}
_render_$getWrpFooter ({doUpdateExternalStates}) {
const $btnAdd = $(`<button class="btn btn-primary btn-xs dm-init-lockable" title="Add Player"><span class="glyphicon glyphicon-plus"></span></button>`)
.on("click", async () => {
if (this._state.isLocked) return;
this._state.rows = [
...this._state.rows,
await this._rowStateBuilderActive.pGetNewRowState({
isPlayerVisible: true,
}),
]
.filter(Boolean);
});
const $btnAddMonster = $(`<button class="btn btn-success btn-xs dm-init-lockable mr-2" title="Add Monster"><span class="glyphicon glyphicon-print"></span></button>`)
.on("click", async () => {
if (this._state.isLocked) return;
const [isDataEntered, monstersToLoad] = await new InitiativeTrackerMonsterAdd({board: this._board, isRollHp: this._state.isRollHp})
.pGetShowModalResults();
if (!isDataEntered) return;
this._state.isRollHp = monstersToLoad.isRollHp;
const isGroupRollEval = monstersToLoad.count > 1 && this._state.isRollGroups;
const mon = isGroupRollEval
? await DmScreenUtil.pGetScaledCreature({
name: monstersToLoad.name,
source: monstersToLoad.source,
scaledCr: monstersToLoad.scaledCr,
scaledSummonSpellLevel: monstersToLoad.scaledSummonSpellLevel,
scaledSummonClassLevel: monstersToLoad.scaledSummonClassLevel,
})
: null;
const initiative = isGroupRollEval
? await this._roller.pGetRollInitiative({mon})
: null;
const rowsNxt = [...this._state.rows];
(await [...new Array(monstersToLoad.count)]
.pSerialAwaitMap(async () => {
const rowNxt = await this._rowStateBuilderActive.pGetNewRowState({
name: monstersToLoad.name,
source: monstersToLoad.source,
initiative,
rows: rowsNxt,
displayName: monstersToLoad.displayName,
customName: monstersToLoad.customName,
scaledCr: monstersToLoad.scaledCr,
scaledSummonSpellLevel: monstersToLoad.scaledSummonSpellLevel,
scaledSummonClassLevel: monstersToLoad.scaledSummonClassLevel,
});
if (!rowNxt) return;
rowsNxt.push(rowNxt);
}));
this._state.rows = rowsNxt;
});
const $btnSetPrevActive = $(`<button class="btn btn-default btn-xs" title="Previous Turn"><span class="glyphicon glyphicon-step-backward"></span></button>`)
.click(() => this._viewRowsActive.pDoShiftActiveRow({direction: InitiativeTrackerConst.DIR_BACKWARDS}));
const $btnSetNextActive = $(`<button class="btn btn-default btn-xs mr-2" title="Next Turn"><span class="glyphicon glyphicon-step-forward"></span></button>`)
.click(() => this._viewRowsActive.pDoShiftActiveRow({direction: InitiativeTrackerConst.DIR_FORWARDS}));
const $iptRound = ComponentUiUtil.$getIptInt(this, "round", 1, {min: 1})
.addClass("dm-init__rounds")
.removeClass("text-right")
.addClass("ve-text-center")
.title("Round");
const menuPlayerWindow = ContextUtil.getMenu([
new ContextUtil.Action(
"Standard",
async () => {
this._networking.handleClick_playerWindowV1({doUpdateExternalStates});
},
),
new ContextUtil.Action(
"Manual (Legacy)",
async () => {
this._networking.handleClick_playerWindowV0({doUpdateExternalStates});
},
),
]);
const $btnNetworking = $(`<button class="btn btn-primary btn-xs mr-2" title="Player View (SHIFT to Open &quot;Standard&quot; View)"><span class="glyphicon glyphicon-user"></span></button>`)
.click(evt => {
if (evt.shiftKey) return this._networking.handleClick_playerWindowV1({doUpdateExternalStates});
return ContextUtil.pOpenMenu(evt, menuPlayerWindow);
});
const $btnLock = $(`<button class="btn btn-danger btn-xs" title="Lock Tracker"><span class="glyphicon glyphicon-lock"></span></button>`)
.on("click", () => this._state.isLocked = !this._state.isLocked);
this._addHookBase("isLocked", () => {
$btnLock
.toggleClass("btn-success", !!this._state.isLocked)
.toggleClass("btn-danger", !this._state.isLocked)
.title(this._state.isLocked ? "Unlock Tracker" : "Lock Tracker");
$(".dm-init-lockable").toggleClass("disabled", !!this._state.isLocked);
$("input.dm-init-lockable").prop("disabled", !!this._state.isLocked);
})();
this._compDefaultParty = new InitiativeTrackerDefaultParty({comp: this, roller: this._roller, rowStateBuilder: this._rowStateBuilderDefaultParty});
const pHandleClickSettings = async () => {
const compSettings = new InitiativeTrackerSettings({state: MiscUtil.copyFast(this._state)});
await compSettings.pGetShowModalResults();
Object.assign(this._state, compSettings.getStateUpdate());
};
const menuConfigure = ContextUtil.getMenu([
new ContextUtil.Action(
"Settings",
() => pHandleClickSettings(),
),
null,
new ContextUtil.Action(
"Edit Default Party",
async () => {
await this._compDefaultParty.pGetShowModalResults();
},
),
]);
const $btnConfigure = $(`<button class="btn btn-default btn-xs mr-2" title="Configure (SHIFT to Open &quot;Settings&quot;)"><span class="glyphicon glyphicon-cog"></span></button>`)
.click(async evt => {
if (evt.shiftKey) return pHandleClickSettings();
return ContextUtil.pOpenMenu(evt, menuConfigure);
});
const menuImport = ContextUtil.getMenu([
...ListUtilBestiary.getContextOptionsLoadSublist({
pFnOnSelect: this._pDoLoadEncounter.bind(this),
}),
null,
new ContextUtil.Action(
"Import Settings",
async () => {
const compImportSettings = new InitiativeTrackerSettingsImport({state: MiscUtil.copyFast(this._state)});
await compImportSettings.pGetShowModalResults();
Object.assign(this._state, compImportSettings.getStateUpdate());
},
),
]);
const $btnLoad = $(`<button title="Import an encounter from the Bestiary" class="btn btn-success btn-xs dm-init-lockable"><span class="glyphicon glyphicon-upload"></span></button>`)
.click((evt) => {
if (this._state.isLocked) return;
ContextUtil.pOpenMenu(evt, menuImport);
});
const $btnReset = $(`<button title="Reset Tracker" class="btn btn-danger btn-xs dm-init-lockable"><span class="glyphicon glyphicon-trash"></span></button>`)
.click(async () => {
if (this._state.isLocked) return;
if (!await InputUiUtil.pGetUserBoolean({title: "Reset", htmlDescription: "Are you sure?", textYes: "Yes", textNo: "Cancel"})) return;
const stateNxt = {
rows: await this._compDefaultParty.pGetConvertedDefaultPartyActiveRows(),
};
const defaultState = this._getDefaultState();
["round", "sort", "dir"]
.forEach(prop => stateNxt[prop] = defaultState[prop]);
this._proxyAssignSimple("state", stateNxt);
});
return $$`<div class="dm-init__wrp-controls">
<div class="ve-flex">
<div class="btn-group ve-flex">
${$btnAdd}
${$btnAddMonster}
</div>
<div class="btn-group">${$btnSetPrevActive}${$btnSetNextActive}</div>
${$iptRound}
</div>
${this._render_$getWrpButtonsSort()}
<div class="ve-flex">
${$btnNetworking}
<div class="btn-group ve-flex-v-center">
${$btnLock}
${$btnConfigure}
</div>
<div class="btn-group ve-flex-v-center">
${$btnLoad}
${$btnReset}
</div>
</div>
</div>`;
}
_render_bindSortDirHooks () {
const hkSortDir = () => {
this._state.rows = InitiativeTrackerSort.getSortedRows({
rows: this._state.rows,
sortBy: this._state.sort,
sortDir: this._state.dir,
});
};
this._addHookBase("sort", hkSortDir);
this._addHookBase("dir", hkSortDir);
hkSortDir();
}
/* -------------------------------------------- */
_doReverseSortDir () {
this._state.dir = this._state.dir === InitiativeTrackerConst.SORT_DIR_ASC ? InitiativeTrackerConst.SORT_DIR_DESC : InitiativeTrackerConst.SORT_DIR_ASC;
}
/* -------------------------------------------- */
_getPlayerFriendlyState () {
const visibleStatsCols = this._state.statsCols
.filter(data => data.isPlayerVisible);
const rows = this._state.rows
.map(({entity}) => {
if (!entity.isPlayerVisible) return null;
const isMon = !!entity.source;
const out = {
name: entity.name,
initiative: entity.initiative,
isActive: entity.isActive,
conditions: entity.conditions || [],
rowStatColData: entity.rowStatColData
.map(cell => {
const mappedCol = visibleStatsCols.find(sc => sc.id === cell.id);
if (!mappedCol) return null;
if (mappedCol.isPlayerVisible === IS_PLAYER_VISIBLE_ALL || !isMon) {
const meta = InitiativeTrackerStatColumnFactory.fromStateData({data: mappedCol});
return meta.getPlayerFriendlyState({cell});
}
return {id: null, entity: {isUnknown: true}};
})
.filter(Boolean),
};
if (entity.customName) out.customName = entity.customName;
if (isMon ? !!this._state.playerInitShowExactMonsterHp : !!this._state.playerInitShowExactPlayerHp) {
out.hpCurrent = entity.hpCurrent;
out.hpMax = entity.hpMax;
} else {
out.hpWoundLevel = isNaN(entity.hpCurrent) || isNaN(entity.hpMax)
? -1
: InitiativeTrackerUtil.getWoundLevel(100 * entity.hpCurrent / entity.hpMax);
}
if (this._state.playerInitShowOrdinals && entity.isShowOrdinal) out.ordinal = entity.ordinal;
return out;
})
.filter(Boolean);
return {
type: "state",
payload: {
rows,
statsCols: visibleStatsCols
.map(({id, abbreviation}) => ({id, abbreviation})),
round: this._state.round,
},
};
}
/* -------------------------------------------- */
async _pDoLoadEncounter ({entityInfos, encounterInfo}) {
const rowsPrev = [...this._state.rows];
// Reset rows early, such that our ordinals are correct for creatures from the encounter
this._state.rows = [];
const isAddPlayers = this._state.importIsAddPlayers && !this._state.rowsDefaultParty.length;
const nxtState = await new InitiativeTrackerEncounterConverter({
roller: this._roller,
rowStateBuilderActive: this._rowStateBuilderActive,
importIsAddPlayers: isAddPlayers,
importIsRollGroups: this._state.importIsRollGroups,
isRollInit: this._state.isRollInit,
isRollHp: this._state.isRollHp,
isRollGroups: this._state.isRollGroups,
}).pGetConverted({entityInfos, encounterInfo});
const rowsFromDefaultParty = await this._compDefaultParty.pGetConvertedDefaultPartyActiveRows();
const idsDefaultParty = new Set(rowsFromDefaultParty.map(({id}) => id));
const rowsPrevNonDefaultParty = rowsPrev
.filter(({id}) => !idsDefaultParty.has(id));
const stateNxt = {
// region TODO(DMS) not ideal--merge columns instead? Note that this also clobbers default party info
isStatsAddColumns: nxtState.isStatsAddColumns,
statsCols: nxtState.statsCols
.map(it => it.getAsStateData()),
// endregion
rows: this._state.importIsAppend
? [
...rowsPrevNonDefaultParty,
...rowsFromDefaultParty,
...nxtState.rows,
]
: [
...rowsFromDefaultParty,
...nxtState.rows,
],
};
if (!this._state.importIsAppend) {
const defaultState = this._getDefaultState();
["round", "sort", "dir"]
.forEach(prop => stateNxt[prop] = defaultState[prop]);
}
this._proxyAssignSimple("state", stateNxt);
}
/* -------------------------------------------- */
doConnectCreatureViewer ({creatureViewer}) {
if (this._creatureViewers.includes(creatureViewer)) return this;
this._creatureViewers.push(creatureViewer);
creatureViewer.setCreatureState(this._getCreatureViewerFriendlyState());
return this;
}
static _CREATURE_VIEWER_STATE_PROPS = [
"name",
"source",
"scaledCr",
"scaledSummonSpellLevel",
"scaledSummonClassLevel",
];
_getCreatureViewerFriendlyState () {
const activeRowPrime = this._state.rows
.filter(({entity}) => entity.isActive)
.find(Boolean);
if (!activeRowPrime) {
return Object.fromEntries(this.constructor._CREATURE_VIEWER_STATE_PROPS.map(prop => [prop, null]));
}
return Object.fromEntries(this.constructor._CREATURE_VIEWER_STATE_PROPS.map(prop => [prop, activeRowPrime.entity[prop]]));
}
doDisconnectCreatureViewer ({creatureViewer}) {
this._creatureViewers = this._creatureViewers.filter(it => it !== creatureViewer);
}
_sendStateToCreatureViewers () {
if (!this._creatureViewers.length) return;
const creatureViewerFriendlyState = this._getCreatureViewerFriendlyState();
this._creatureViewers.forEach(it => it.setCreatureState(creatureViewerFriendlyState));
}
/* -------------------------------------------- */
_setStateFromSerialized () {
const stateNxt = {
// region Config
sort: this._savedState.s || InitiativeTrackerConst.SORT_ORDER_NUM,
dir: this._savedState.d || InitiativeTrackerConst.SORT_DIR_DESC,
statsCols: (this._savedState.c || [])
.map(dataSerial => this._setStateFromSerialized_statsCol({dataSerial}))
.filter(Boolean),
// endregion
// region Custom conditions
conditionsCustom: (this._savedState.cndc || [])
.map(dataSerial => InitiativeTrackerConditionCustomSerializer.fromSerial(dataSerial)),
// endregion
// region Rows
rows: (this._savedState.r || [])
.map(dataSerial => InitiativeTrackerRowDataSerializer.fromSerial(dataSerial))
.filter(Boolean),
rowsDefaultParty: (this._savedState.rdp || [])
.map(dataSerial => InitiativeTrackerRowDataSerializer.fromSerial(dataSerial))
.filter(Boolean),
// endregion
// region Round
round: isNaN(this._savedState.n) ? 1 : Number(this._savedState.n),
// endregion
// region Temporary
isLocked: false,
// endregion
};
// region Config
if (this._savedState.ri != null) stateNxt.isRollInit = this._savedState.ri;
if (this._savedState.m != null) stateNxt.isRollHp = this._savedState.m;
if (this._savedState.rg != null) stateNxt.isRollGroups = this._savedState.rg;
if (this._savedState.rri != null) stateNxt.isRerollInitiativeEachRound = this._savedState.rri;
if (this._savedState.g != null) stateNxt.importIsRollGroups = this._savedState.g;
if (this._savedState.p != null) stateNxt.importIsAddPlayers = this._savedState.p;
if (this._savedState.a != null) stateNxt.importIsAppend = this._savedState.a;
if (this._savedState.k != null) stateNxt.isStatsAddColumns = this._savedState.k;
if (this._savedState.piHp != null) stateNxt.playerInitShowExactPlayerHp = this._savedState.piHp;
if (this._savedState.piHm != null) stateNxt.playerInitShowExactMonsterHp = this._savedState.piHm;
if (this._savedState.piV != null) stateNxt.playerInitHideNewMonster = this._savedState.piV;
if (this._savedState.piO != null) stateNxt.playerInitShowOrdinals = this._savedState.piO;
// endregion
this._proxyAssignSimple("state", stateNxt);
}
_setStateFromSerialized_statsCol ({dataSerial}) {
if (!dataSerial) return null;
return InitiativeTrackerStatColumnFactory.fromStateData({dataSerial})
.getAsStateData();
}
_getSerializedState () {
return {
// region Config
s: this._state.sort,
d: this._state.dir,
ri: this._state.isRollInit,
m: this._state.isRollHp,
rg: this._state.isRollGroups,
rri: this._state.isRerollInitiativeEachRound,
g: this._state.importIsRollGroups,
p: this._state.importIsAddPlayers,
a: this._state.importIsAppend,
k: this._state.isStatsAddColumns,
piHp: this._state.playerInitShowExactPlayerHp,
piHm: this._state.playerInitShowExactMonsterHp,
piV: this._state.playerInitHideNewMonster,
piO: this._state.playerInitShowOrdinals,
c: (this._state.statsCols || [])
.map(data => InitiativeTrackerStatColumnDataSerializer.toSerial(data)),
// endregion
// region Custom conditions
cndc: (this._state.conditionsCustom || [])
.map(data => InitiativeTrackerConditionCustomSerializer.toSerial(data)),
// endregion
// region Rows
r: (this._state.rows || [])
.map(data => InitiativeTrackerRowDataSerializer.toSerial(data)),
rdp: (this._state.rowsDefaultParty || [])
.map(data => InitiativeTrackerRowDataSerializer.toSerial(data)),
// endregion
// region Round
n: this._state.round,
// endregion
};
}
_getDefaultState () {
return {
// region Config
sort: InitiativeTrackerConst.SORT_ORDER_NUM,
dir: InitiativeTrackerConst.SORT_DIR_DESC,
isRollInit: true,
isRollHp: false,
isRollGroups: false,
isRerollInitiativeEachRound: false,
importIsRollGroups: true,
importIsAddPlayers: true,
importIsAppend: false,
isStatsAddColumns: false,
playerInitShowExactPlayerHp: false,
playerInitShowExactMonsterHp: false,
playerInitHideNewMonster: true,
playerInitShowOrdinals: false,
statsCols: [],
// endregion
// region Custom conditions
conditionsCustom: [],
// endregion
// region Rows
rows: [],
rowsDefaultParty: [],
// endregion
// region Round
round: 1,
// endregion
// region Temporary
isLocked: false,
// endregion
};
}
/* -------------------------------------------- */
static $getPanelElement (board, savedState) {
return new this({board, savedState}).render();
}
}