Overhauled damage multipliers UX

Form and viewer revamp
custom damage types
Variables: `bludgeoning.resistance`
This commit is contained in:
Stefan Zermatten
2022-03-05 16:23:21 +02:00
parent 545050cfa3
commit 2442ae4fa0
17 changed files with 388 additions and 190 deletions

View File

@@ -133,6 +133,7 @@ let CreatureSchema = new SimpleSchema({
'computeErrors.$.details' : {
type: Object,
blackbox: true,
optional: true,
},
// Tabletop

View File

@@ -218,7 +218,7 @@ function linkDamageMultiplier(dependencyGraph, prop){
prop.damageTypes.forEach(damageType => {
// Remove all non-letter characters from the damage name
const damageName = damageType.replace(/[^a-z]/gi, '')
dependencyGraph.addLink(`${damageName}Multiplier`, prop._id, prop.type);
dependencyGraph.addLink(damageName, prop._id, prop.type);
});
}
@@ -242,7 +242,7 @@ function linkSkill(dependencyGraph, prop){
}
// Skills depend on the creature's proficiencyBonus
dependencyGraph.addLink(prop._id, 'proficiencyBonus', 'skillProficiencyBonus');
// Depends on base value
dependOnCalc({dependencyGraph, prop, key: 'baseValue'});
}

View File

@@ -54,6 +54,21 @@ function combineAggregations(computation, node){
function computeVariableProp(computation, node, prop){
if (!prop) return;
// Combine damage multipliers in all props so that they can't be overridden
if (node.data.immunity){
prop.immunity = node.data.immunity;
prop.immunities = node.data.immunities;
}
if (node.data.resistance){
prop.resistance = node.data.resistance;
prop.resistances = node.data.resistances;
}
if (node.data.vulnerability){
prop.vulnerability = node.data.vulnerability;
prop.vulnerabilities = node.data.vulnerabilities;
}
if (prop.type === 'attribute'){
computeVariableAsAttribute(computation, node, prop);
} else if (prop.type === 'skill'){
@@ -73,21 +88,16 @@ function combineMultiplierAggregator(node){
if (!aggregator) return;
// Combine
let value;
if (aggregator.immunityCount){
value = 0;
} else if (
aggregator.resistanceCount &&
!aggregator.vulnerabilityCount
){
value = 0.5;
} else if (
!aggregator.resistanceCount &&
aggregator.vulnerabilityCount
){
value = 2;
} else {
value = 1;
if (aggregator.immunities?.length){
node.data.immunity = true;
node.data.immunities = aggregator.immunities;
}
if (aggregator.resistances?.length){
node.data.resistance = true;
node.data.resistances = aggregator.resistances;
}
if (aggregator.vulnerabilities?.length){
node.data.vulnerability = true;
node.data.vulnerabilities = aggregator.vulnerabilities;
}
node.data.damageMultiplyValue = value;
}

View File

@@ -1,22 +1,36 @@
import { pick } from 'lodash';
export default function aggregateDamageMultipliers({node, linkedNode, link}){
if (link.data !== 'damageMultiplier') return;
const multiplierValue = linkedNode.data.value;
if (multiplierValue === undefined) return;
// Store an aggregator, its presence indicates damage multipliers target this
// variable
if (!node.data.multiplierAggregator) node.data.multiplierAggregator = {
immunityCount: 0,
resistanceCount: 0,
vulnerabilityCount: 0,
immunities: [],
resistances: [],
vulnerabilities: [],
}
// Store a short reference to the aggregator
const aggregator = node.data.multiplierAggregator;
// Sum the counts of each type of multiplier
// Make a stripped down copy of the multiplier to store in the aggregator
const keysToStore = ['_id', 'name'];
if (linkedNode.data.excludeTags?.length){
keysToStore.push('excludeTags');
}
if (linkedNode.data.includeTags?.length){
keysToStore.push('includeTags');
}
const storedMultiplier = pick(linkedNode.data, keysToStore);
// Store the multiplier in the appropriate field
if (multiplierValue === 0){
aggregator.immunityCount += 1;
aggregator.immunities.push(storedMultiplier);
} else if (multiplierValue === 0.5){
aggregator.resistanceCount += 1;
aggregator.resistances.push(storedMultiplier);
} else if (multiplierValue === 2){
aggregator.vulnerabilityCount += 1;
aggregator.vulnerabilities.push(storedMultiplier);
}
}

View File

@@ -6,6 +6,21 @@ import getAggregatorResult from './getAggregatorResult.js';
*/
export default function computeImplicitVariable(node){
const prop = {};
// Combine damage multipliers
if (node.data.immunity){
prop.immunity = node.data.immunity;
prop.immunities = node.data.immunities;
}
if (node.data.resistance){
prop.resistance = node.data.resistance;
prop.resistances = node.data.resistances;
}
if (node.data.vulnerability){
prop.vulnerability = node.data.vulnerability;
prop.vulnerabilities = node.data.vulnerabilities;
}
const result = getAggregatorResult(node);
if (result !== undefined){
prop.value = result;

View File

@@ -1,7 +1,7 @@
import getAggregatorResult from './getAggregatorResult.js';
export default function computeVariableAsAttribute(computation, node, prop){
let result = getAggregatorResult(node, prop) || 0;
let result = getAggregatorResult(node) || 0;
prop.total = result;
prop.value = prop.total - (prop.damage || 0);

View File

@@ -1,15 +1,10 @@
import stripFloatingPointOddities from '/imports/api/engine/computation/utility/stripFloatingPointOddities.js';
export default function getAggregatorResult(node){
// Work out the base value as the greater of the deining stat value or
// the damage multiplier value
// Work out the base value as the greater of the deining stat value
// This baseValue comes from aggregating definitions
let statBase = node.data.baseValue;
const damageMultiplyValue = node.data.damageMultiplyValue;
if (statBase === undefined || damageMultiplyValue > statBase){
statBase = damageMultiplyValue;
}
// get a reference to the aggregator
const aggregator = node.data.effectAggregator;

View File

@@ -14,8 +14,9 @@ export default function computeCreature(creatureId){
} catch (e){
computation.errors.push({
type: 'crash',
details: e.reason,
details: e.reason || e.message || e.toString(),
});
console.error(e);
} finally {
writeErrors(creatureId, computation.errors);
}

View File

@@ -1,5 +1,6 @@
import SimpleSchema from 'simpl-schema';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
import VARIABLE_NAME_REGEX from '/imports/constants/VARIABLE_NAME_REGEX.js';
/*
* DamageMultipliers are multipliers that affect how much damage is taken from
@@ -20,6 +21,7 @@ let DamageMultiplierSchema = new SimpleSchema({
'damageTypes.$': {
type: String,
max: STORAGE_LIMITS.calculation,
regEx: VARIABLE_NAME_REGEX,
},
// The value of the damage multiplier
value: {

View File

@@ -1,6 +1,7 @@
import SimpleSchema from 'simpl-schema';
import createPropertySchema from '/imports/api/properties/subSchemas/createPropertySchema.js';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
import VARIABLE_NAME_REGEX from '/imports/constants/VARIABLE_NAME_REGEX.js';
const DamageSchema = createPropertySchema({
// The roll that determines how much to damage the attribute
@@ -24,6 +25,7 @@ const DamageSchema = createPropertySchema({
type: String,
max: STORAGE_LIMITS.calculation,
defaultValue: 'slashing',
regEx: VARIABLE_NAME_REGEX,
},
});

View File

@@ -7,6 +7,7 @@
:menu-props="{auto: true, lazy: true}"
:search-input.sync="searchInput"
:disabled="isDisabled"
:multiple="multiple"
outlined
@change="customChange"
@focus="focused = true"
@@ -24,12 +25,32 @@
export default {
mixins: [SmartInput],
props: {
multiple: Boolean,
},
data(){ return {
searchInput: '',
}},
computed: {
// This component gets a longer default debounce time because it's all
// clicking no typing
debounceTime() {
if (Number.isFinite(this.debounce)){
return this.debounce;
} else if (Number.isFinite(this.context.debounceTime)){
return this.context.debounceTime;
} else {
return 1000;
}
},
},
methods: {
customChange(val){
this.change(val);
if (this.multiple){
this.input(val);
} else {
this.change(val);
}
this.searchInput = '';
},
}

View File

@@ -16,6 +16,7 @@ export default {
data(){ return {
error: false,
ackErrors: null,
rulesErrors: null,
focused: false,
loading: false,
dirty: false,
@@ -30,6 +31,7 @@ export default {
type: Number,
default: undefined,
},
rules: Array,
},
watch: {
focused(newFocus){
@@ -42,7 +44,11 @@ export default {
// Start the loading bar on defocus if the input is dirty
// It might be a lie, we aren't doing the work yet, but it feels laggy
// to defocus an element and then it starts working after a delay
if (!newFocus && this.dirty){
if (
!newFocus &&
this.dirty &&
!(this.rulesErrors && this.rulesErrors.length)
){
if (this.hasChangeListener) this.loading = true;
}
},
@@ -54,7 +60,10 @@ export default {
}
},
value(newValue){
if (!this.focused){
if (
!this.focused &&
!(this.rulesErrors && this.rulesErrors.length)
){
this.safeValue = newValue;
}
},
@@ -69,6 +78,22 @@ export default {
this.$emit('input', val);
this.inputValue = val;
this.dirty = true;
// Apply the rules if there are any
this.rulesErrors = null;
if (this.rules && this.rules.length){
this.rules.forEach(rule => {
const result = rule(val);
if (typeof result === 'string'){
if (!this.rulesErrors) this.rulesErrors = [];
this.rulesErrors.push(result);
}
});
}
if (this.rulesErrors){
return;
}
this.debouncedChange(val);
},
acknowledgeChange(error){
@@ -106,6 +131,9 @@ export default {
computed: {
errors(){
let errors = this.ackErrors ? [this.ackErrors] : [];
if (Array.isArray(this.rulesErrors)){
errors.push(...this.rulesErrors)
}
if (Array.isArray(this.errorMessages)){
errors.push(...this.errorMessages);
} else if (typeof this.errorMessages === 'string' && this.errorMessages){

View File

@@ -22,6 +22,11 @@
</v-card>
</div>
<damage-multiplier-card
:multipliers="multipliers"
@click-multiplier="clickProperty"
/>
<div
v-if="appliedBuffs.length"
class="buffs"
@@ -199,10 +204,6 @@
</v-card>
</div>
<div v-if="numKeys(creature.damageMultipliers)">
<damage-multiplier-card :model="creature.damageMultipliers" />
</div>
<div
v-if="savingThrows.length"
class="saving-throws"
@@ -367,7 +368,9 @@
import doCastSpell from '/imports/api/engine/actions/doCastSpell.js';
import {snackbar} from '/imports/ui/components/snackbars/SnackbarQueue.js';
const getProperties = function(creature, filter){
const getProperties = function(creature, filter, options = {
sort: {order: 1}
}){
if (!creature) return;
if (creature.settings.hideUnusedStats){
filter.hide = {$ne: true};
@@ -376,9 +379,8 @@
filter.removed = {$ne: true};
filter.inactive = {$ne: true};
filter.overridden = {$ne: true};
return CreatureProperties.find(filter, {
sort: {order: 1}
});
return CreatureProperties.find(filter, options);
};
const getAttributeOfType = function(creature, type){
@@ -421,7 +423,7 @@
}},
meteor: {
creature(){
return Creatures.findOne(this.creatureId);
return Creatures.findOne(this.creatureId, {fields: {settings: 1}});
},
abilities(){
return getAttributeOfType(this.creature, 'ability');
@@ -484,6 +486,13 @@
appliedBuffs(){
return getProperties(this.creature, {type: 'buff'});
},
multipliers(){
return getProperties(this.creature, {
type: 'damageMultiplier'
}, {
sort: {value: 1, order: 1}
});
},
attacks(){
let props = getProperties(this.creature, {type: 'attack'})
return props && props.map(attack => {
@@ -511,10 +520,6 @@
damageProperty.call({_id, operation: 'increment' ,value: -value});
}
},
numKeys(obj){
if (!obj) return 0;
return Object.keys(obj).length;
},
softRemove(_id){
softRemoveProperty.call({_id}, error => {
if (error) console.error(error);

View File

@@ -1,72 +1,96 @@
<template lang="html">
<v-card>
<v-list
three-line
>
<v-list-item v-if="weaknesses.length">
<v-list-item-content>
<v-list-item-title>
Vulnerabilities
</v-list-item-title>
<v-list-item-subtitle>
{{ weaknesses }}
</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
<v-list-item v-if="resistances.length">
<v-list-item-content>
<v-list-item-title>
Resistances
</v-list-item-title>
<v-list-item-subtitle>
{{ resistances }}
</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
<v-list-item v-if="immunities.length">
<v-list-item-content>
<v-list-item-title>
Immunities
</v-list-item-title>
<v-list-item-subtitle>
{{ immunities }}
</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
</v-list>
</v-card>
<div>
<v-card>
<v-list>
<v-list-item
v-for="multiplier in multipliers"
:key="multiplier._id"
:data-id="multiplier._id"
@click="$emit('click-multiplier', {_id: multiplier._id})"
>
<v-list-item-content>
<v-list-item-title>
{{ title(multiplier) }}
</v-list-item-title>
<v-list-item-subtitle v-if="multiplier.name">
{{ multiplier.name }}
</v-list-item-subtitle>
<v-list-item-subtitle class="d-flex flex-wrap align-center">
<v-chip
v-for="(damageType, index) in multiplier.damageTypes"
:key="index"
class="my-1 mr-1"
style="cursor: pointer"
:input-value="true"
outlined
small
label
>
{{ damageType }}
</v-chip>
</v-list-item-subtitle>
<v-list-item-subtitle
v-if="multiplier.includeTags && multiplier.includeTags.length"
class="d-flex flex-wrap align-center"
>
<div>
For:
</div>
<v-chip
v-for="(damageType, index) in multiplier.includeTags"
:key="index"
class="ma-1"
style="cursor: pointer"
:input-value="true"
small
outlined
>
{{ damageType }}
</v-chip>
</v-list-item-subtitle>
<v-list-item-subtitle
v-if="multiplier.excludeTags && multiplier.excludeTags.length"
class="d-flex flex-wrap align-center"
>
<div>
Except:
</div>
<v-chip
v-for="(damageType, index) in multiplier.excludeTags"
:key="index"
class="ma-1"
style="cursor: pointer"
:input-value="true"
small
outlined
>
{{ damageType }}
</v-chip>
</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
</v-list>
</v-card>
</div>
</template>
<script lang="js">
export default {
props: {
model:{
type: Object,
multipliers:{
type: Array,
required: true,
}
},
computed: {
weaknesses(){
return getKeysOfValue(this.model, 2).join(', ');
},
resistances(){
return getKeysOfValue(this.model, 0.5).join(', ');
},
immunities(){
return getKeysOfValue(this.model, 0).join(', ');
},
}
}
function getKeysOfValue(object, value){
let keys = [];
for (let key in object){
if (object[key] === value){
keys.push(key);
methods: {
title(prop){
switch (prop.value){
case 0: return 'Immunity';
case 0.5: return 'Resistance';
case 2: return 'Vulnerability';
}
}
}
return keys;
}
</script>

View File

@@ -23,6 +23,7 @@
label="Damage Type"
style="flex-basis: 200px;"
hint="Use the Healing type to restore hit points"
:rules="damageTypeRules"
:items="DAMAGE_TYPES"
:value="model.damageType"
:error-messages="errors.damageType"
@@ -46,7 +47,8 @@
multiple
chips
deletable-chips
hint="Used to let slots find this property in a library, should otherwise be left blank"
hint=""
:items="['magical', 'silvered']"
:value="model.tags"
:error-messages="errors.tags"
@change="change('tags', ...arguments)"
@@ -57,6 +59,7 @@
<script lang="js">
import DAMAGE_TYPES from '/imports/constants/DAMAGE_TYPES.js';
import propertyFormMixin from '/imports/ui/properties/forms/shared/propertyFormMixin.js';
import VARIABLE_NAME_REGEX from '/imports/constants/VARIABLE_NAME_REGEX.js';
export default {
mixins: [propertyFormMixin],
@@ -68,6 +71,14 @@ export default {
},
data(){return{
DAMAGE_TYPES,
damageTypeRules: [
value => {
if (!value) return 'Damage type is required';
if (!VARIABLE_NAME_REGEX.test(value)){
return `${value} is not a valid damage name`
}
}
],
}},
computed: {
targetOptions(){

View File

@@ -1,93 +1,102 @@
<template lang="html">
<div class="attribute-form">
<text-field
ref="focusFirst"
label="Name"
:value="model.name"
:error-messages="errors.name"
@change="change('name', ...arguments)"
/>
<div class="layout wrap">
<smart-select
label="Damage Type"
style="flex-basis: 300px;"
multiple
:items="damageTypes"
:value="model.damageTypes"
:error-messages="errors.damageTypes"
:menu-props="{auto: true, lazy: true}"
@change="change('damageTypes', ...arguments)"
/>
<smart-select
label="Value"
style="flex-basis: 300px;"
:items="values"
:value="model.value"
:error-messages="errors.value"
:menu-props="{auto: true, lazy: true}"
@change="change('value', ...arguments)"
/>
</div>
<smart-combobox
label="Tags"
multiple
chips
deletable-chips
hint="Used to let slots find this property in a library, should otherwise be left blank"
:value="model.tags"
@change="change('tags', ...arguments)"
/>
<v-row dense>
<v-col
cols="12"
md="6"
>
<text-field
ref="focusFirst"
label="Name"
:value="model.name"
:error-messages="errors.name"
@change="change('name', ...arguments)"
/>
</v-col>
<v-col
cols="12"
md="6"
>
<smart-select
label="Value"
style="flex-basis: 300px;"
:items="values"
:value="model.value"
:error-messages="errors.value"
:menu-props="{auto: true, lazy: true}"
@change="change('value', ...arguments)"
/>
</v-col>
</v-row>
<v-row dense>
<v-col cols="12">
<smart-combobox
label="Damage Types"
multiple
chips
deletable-chips
:rules="damageTypeRules"
:items="DAMAGE_TYPES"
:value="model.damageTypes"
:error-messages="errors.damageTypes"
:menu-props="{auto: true, lazy: true}"
@update:error="error"
@change="change('damageTypes', ...arguments)"
/>
</v-col>
</v-row>
<form-sections>
<form-section
name="Advanced"
>
<smart-combobox
label="Damage tags required"
hint="This damage multiplier will only apply to damage that has all of these tags"
multiple
chips
deletable-chips
:items="['magical', 'silvered']"
:value="model.includeTags"
@change="change('includeTags', ...arguments)"
/>
<smart-combobox
label="Damage tags excluded"
hint="Damage that includes any of these tags will bypass this damage multiplier"
multiple
chips
deletable-chips
:items="['magical', 'silvered']"
:value="model.excludeTags"
@change="change('excludeTags', ...arguments)"
/>
<smart-combobox
label="Tags"
multiple
chips
deletable-chips
hint=""
:value="model.tags"
@change="change('tags', ...arguments)"
/>
</form-section>
</form-sections>
</div>
</template>
<script lang="js">
import FormSection, { FormSections } from '/imports/ui/properties/forms/shared/FormSection.vue';
import propertyFormMixin from '/imports/ui/properties/forms/shared/propertyFormMixin.js';
import VARIABLE_NAME_REGEX from '/imports/constants/VARIABLE_NAME_REGEX.js';
import DAMAGE_TYPES from '/imports/constants/DAMAGE_TYPES.js';
export default {
components: {
FormSections,
FormSection,
},
mixins: [propertyFormMixin],
data(){return {
damageTypes: [
{
value: 'bludgeoning',
text: 'Bludgeoning',
}, {
value: 'piercing',
text: 'Piercing',
}, {
value: 'slashing',
text: 'Slashing',
}, {
value: 'acid',
text: 'Acid',
}, {
value: 'cold',
text: 'Cold',
}, {
value: 'fire',
text: 'Fire',
}, {
value: 'force',
text: 'Force',
}, {
value: 'lightning',
text: 'Lightning',
}, {
value: 'necrotic',
text: 'Necrotic',
}, {
value: 'poison',
text: 'Poison',
}, {
value: 'psychic',
text: 'Psychic',
}, {
value: 'radiant',
text: 'Radiant',
}, {
value: 'thunder',
text: 'Thunder',
},
],
DAMAGE_TYPES,
values: [
{
value: 0,
@@ -100,7 +109,23 @@
text: 'Vulnerability',
},
],
damageTypeRules: [
value => {
if (value && value.length){
for(let i = 0; i < value.length; i++){
if (!VARIABLE_NAME_REGEX.test(value[i])){
return `${value[i]} is not a valid damage name`
}
}
}
}
],
};},
methods: {
error(e){
console.log({e})
}
}
};
</script>

View File

@@ -2,13 +2,57 @@
<div>
<v-row dense>
<property-field
name="Operation"
name="Value"
:value="operation"
/>
<property-field
name="Damage types"
:value="model.damageTypes.join(', ')"
/>
>
<v-chip
v-for="(damageType, index) in model.damageTypes"
:key="index"
class="my-1 mr-1"
style="cursor: pointer"
:input-value="true"
outlined
small
label
>
{{ damageType }}
</v-chip>
</property-field>
<property-field
v-if="model.includeTags && model.includeTags.length"
name="Damage tags required"
>
<v-chip
v-for="(damageType, index) in model.includeTags"
:key="index"
class="ma-1"
style="cursor: pointer"
:input-value="true"
small
outlined
>
{{ damageType }}
</v-chip>
</property-field>
<property-field
v-if="model.excludeTags && model.excludeTags.length"
name="Damage tags excluded"
>
<v-chip
v-for="(damageType, index) in model.excludeTags"
:key="index"
class="ma-1"
style="cursor: pointer"
:input-value="true"
small
outlined
>
{{ damageType }}
</v-chip>
</property-field>
</v-row>
</div>
</template>