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,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();
}
}