Overhauled spell casting UX

This commit is contained in:
Stefan Zermatten
2022-02-27 22:14:32 +02:00
parent 49fa9cc470
commit e6c7d79d7d
17 changed files with 557 additions and 244 deletions

View File

@@ -87,7 +87,7 @@ const doAction = new ValidatedMethod({
export default doAction;
export function doActionWork({
creature, targets, properties, ancestors, method, methodScope = {}
creature, targets, properties, ancestors, method, methodScope = {}, log
}){
// get the docs
const ancestorScope = getAncestorScope(ancestors);
@@ -97,7 +97,7 @@ export function doActionWork({
}
// Create the log
let log = CreatureLogSchema.clean({
if (!log) log = CreatureLogSchema.clean({
creatureId: creature._id,
creatureName: creature.name,
});

View File

@@ -0,0 +1,142 @@
import SimpleSchema from 'simpl-schema';
import { ValidatedMethod } from 'meteor/mdg:validated-method';
import { RateLimiterMixin } from 'ddp-rate-limiter-mixin';
import getRootCreatureAncestor from '/imports/api/creature/creatureProperties/getRootCreatureAncestor.js';
import Creatures from '/imports/api/creature/creatures/Creatures.js';
import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js';
import { assertEditPermission } from '/imports/api/creature/creatures/creaturePermissions.js';
import { damagePropertyWork } from '/imports/api/creature/creatureProperties/methods/damageProperty.js';
import { doActionWork } from '/imports/api/engine/actions/doAction.js';
import computeCreature from '/imports/api/engine/computeCreature.js';
import { CreatureLogSchema } from '/imports/api/creature/log/CreatureLogs.js';
const doAction = new ValidatedMethod({
name: 'creatureProperties.doCastSpell',
validate: new SimpleSchema({
spellId: SimpleSchema.RegEx.Id,
slotId: {
type: String,
regEx: SimpleSchema.RegEx.Id,
optional: true,
},
targetIds: {
type: Array,
defaultValue: [],
maxCount: 20,
optional: true,
},
'targetIds.$': {
type: String,
regEx: SimpleSchema.RegEx.Id,
},
scope: {
type: Object,
blackbox: true,
optional: true,
},
}).validator(),
mixins: [RateLimiterMixin],
rateLimit: {
numRequests: 10,
timeInterval: 5000,
},
run({spellId, slotId, targetIds = [], scope = {}}) {
let spell = CreatureProperties.findOne(spellId);
// Check permissions
let creature = getRootCreatureAncestor(spell);
assertEditPermission(creature, this.userId);
// Get all the targets and make sure we can edit them
let targets = [];
targetIds.forEach(targetId => {
let target = Creatures.findOne(targetId);
assertEditPermission(target, this.userId);
targets.push(target);
});
// Fetch all the action's ancestor creatureProperties
const ancestorIds = [];
spell.ancestors.forEach(ref => {
if (ref.collection === 'creatureProperties') {
ancestorIds.push(ref.id);
}
});
// Get cursor of ancestors
const ancestors = CreatureProperties.find({
_id: {$in: ancestorIds},
}, {
sort: {order: 1},
});
// Get cursor of the properties
const properties = CreatureProperties.find({
$or: [{_id: spell._id}, {'ancestors.id': spell._id}],
removed: {$ne: true},
}, {
sort: {order: 1},
});
// Spend the appropriate slot
let slotLevel = spell.level || 0;
let slot;
if (slotId && !spell.castWithoutSpellSlots){
slot = CreatureProperties.findOne(slotId);
if (!slot){
throw new Meteor.Error('No slot',
'Slot not found to cast spell');
}
if (!slot.value){
throw new Meteor.Error('No slot',
'Slot depleted');
}
if (slot.attributeType !== 'spellSlot'){
throw new Meteor.Error('Not a slot',
'The given property is not a valid spell slot');
}
if (!slot.spellSlotLevel?.value){
throw new Meteor.Error('No slot level',
'Slot does not have a spell slot level');
}
if (slot.spellSlotLevel.value < spell.level){
throw new Meteor.Error('Slot too small',
'Slot is not large enough to cast spell');
}
slotLevel = slot.spellSlotLevel.value;
damagePropertyWork({
property: slot,
operation: 'increment',
value: 1,
});
}
scope['slotLevel'] = slotLevel;
// Post the slot level spent to the log
const log = CreatureLogSchema.clean({
creatureId: creature._id,
creatureName: creature.name,
});
if (slot?.spellSlotLevel?.value){
log.content.push({
name: `Casting using a level ${slotLevel} spell slot`
});
} else if (slotLevel) {
log.content.push({
name: `Casting at level ${slotLevel}`
});
}
// Do the action
doActionWork({creature, targets, properties, ancestors, method: this, methodScope: scope, log});
// Recompute all involved creatures
computeCreature(creature._id);
targets.forEach(target => {
computeCreature(target._id);
});
},
});
export default doAction;

View File

@@ -0,0 +1,2 @@
import './doCastSpell.js';
import './doCheck.js';

View File

@@ -1,54 +0,0 @@
import SimpleSchema from 'simpl-schema';
import { ValidatedMethod } from 'meteor/mdg:validated-method';
import { RateLimiterMixin } from 'ddp-rate-limiter-mixin';
import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js';
import Creatures from '/imports/api/creature/creatures/Creatures.js';
import getRootCreatureAncestor from '/imports/api/creature/creatureProperties/getRootCreatureAncestor.js';
import { assertEditPermission } from '/imports/api/creature/creatures/creaturePermissions.js';
import computeCreature from '/imports/api/engine/computeCreature.js';
import doAction from '../doAction.js';
const commitAction = new ValidatedMethod({
name: 'creatureProperties.doAction',
validate: new SimpleSchema({
actionId: SimpleSchema.RegEx.Id,
targetIds: {
type: Array,
defaultValue: [],
maxCount: 20,
optional: true,
},
'targetIds.$': {
type: String,
regEx: SimpleSchema.RegEx.Id,
},
}).validator(),
mixins: [RateLimiterMixin],
rateLimit: {
numRequests: 10,
timeInterval: 5000,
},
run({actionId, targetIds = []}) {
let action = CreatureProperties.findOne(actionId);
// Check permissions
let creature = getRootCreatureAncestor(action);
assertEditPermission(creature, this.userId);
let targets = [];
targetIds.forEach(targetId => {
let target = Creatures.findOne(targetId);
assertEditPermission(target, this.userId);
targets.push(target);
});
doAction({action, creature, targets, method: this});
// recompute creatures
computeCreature(creature._id);
targets.forEach(target => {
computeCreature(target._id);
});
},
});
export default commitAction;

View File

@@ -5,11 +5,21 @@ import writeScope from './computation/writeComputation/writeScope.js';
import writeErrors from './computation/writeComputation/writeErrors.js';
export default function computeCreature(creatureId){
if (Meteor.isClient) return;
const computation = buildCreatureComputation(creatureId);
computeCreatureComputation(computation);
writeAlteredProperties(computation);
writeScope(creatureId, computation.scope);
writeErrors(creatureId, computation.errors);
try {
computeCreatureComputation(computation);
writeAlteredProperties(computation);
writeScope(creatureId, computation.scope);
writeErrors(creatureId, computation.errors);
} catch (e){
computation.errors.push({
type: 'crash',
details: e.reason,
});
} finally {
writeErrors(creatureId, [...computation.errors]);
}
}
// For now just recompute the whole creature, TODO only recompute a single

View File

@@ -7,10 +7,15 @@
>
<template #activator="{ on }">
<v-btn
icon
:outlined="!!label"
:icon="!label"
:min-width="label && 108"
v-on="on"
>
<v-icon>mdi-format-paint</v-icon>
{{ label }}
<v-icon :right="!!label">
mdi-format-paint
</v-icon>
</v-btn>
</template>
<v-card class="overflow-hidden">
@@ -122,6 +127,10 @@
type: String,
default: undefined,
},
label: {
type: String,
default: undefined,
}
},
data(){ return {
colors: [

View File

@@ -8,21 +8,22 @@
>
<template #activator="{ on }">
<div class="layout align-center">
<v-label>{{ label }}</v-label>
<v-btn
:loading="loading"
large
icon
outlined
:min-width="108"
v-on="on"
>
{{ label }}
<svg-icon
v-if="safeValue && safeValue.shape"
large
right
class="ml-2"
:shape="safeValue.shape"
/>
<v-icon
v-else
large
right
>
mdi-select-search
</v-icon>

View File

@@ -162,11 +162,14 @@
</div>
<div
v-if="spellSlots && spellSlots.length"
v-if="spellSlots && spellSlots.length || hasSpells"
class="spell-slots"
>
<v-card>
<v-card
data-id="spell-slot-card"
>
<v-list
v-if="spellSlots && spellSlots.length"
two-line
subheader
>
@@ -180,6 +183,19 @@
@cast="castSpellWithSlot(spellSlot._id)"
/>
</v-list>
<div
v-if="hasSpells"
class="d-flex justify-end"
>
<v-btn
color="accent"
style="width: 100%;"
outlined
@click="castSpell"
>
Cast a spell
</v-btn>
</div>
</v-card>
</div>
@@ -348,7 +364,8 @@
import RestButton from '/imports/ui/creature/RestButton.vue';
import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js';
import ToggleCard from '/imports/ui/properties/components/toggles/ToggleCard.vue';
//import castSpellWithSlot from '/imports/api/creature/actions/castSpellWithSlot.js';
import doCastSpell from '/imports/api/engine/actions/doCastSpell.js';
import {snackbar} from '/imports/ui/components/snackbars/SnackbarQueue.js';
const getProperties = function(creature, filter){
if (!creature) return;
@@ -432,6 +449,11 @@
spellSlots(){
return getAttributeOfType(this.creature, 'spellSlot');
},
hasSpells(){
return getProperties(this.creature, {
type: 'spell',
}).count();
},
hitDice(){
return getAttributeOfType(this.creature, 'hitDice');
},
@@ -498,18 +520,19 @@
if (error) console.error(error);
});
},
castSpellWithSlot(slotId){
castSpell(){
this.$store.commit('pushDialogStack', {
component: 'cast-spell-with-slot-dialog',
elementId: `spell-slot-cast-btn-${slotId}`,
elementId: 'spell-slot-card',
data: {
creatureId: this.creatureId,
slotId,
},
callback({spellId, slotId} = {}){
if (!spellId) return;
castSpellWithSlot.call({spellId, slotId}, error => {
if (error) console.error(error);
doCastSpell.call({spellId, slotId}, error => {
if (!error) return;
snackbar({text: error.reason});
console.error(error);
});
},
});

View File

@@ -1,10 +1,15 @@
<template lang="html">
<v-list-item
:key="model._id"
class="spell-slot-list-tile"
v-bind="$attrs"
:disabled="disabled"
v-on="hasClickListener ? {click} : {}"
>
<v-list-item-content>
<v-list-item-title v-if="Number.isFinite(model.total)">
<v-list-item-title
v-if="Number.isFinite(model.total)"
>
<div
v-if="model.total > 4"
class="layout value"
@@ -27,6 +32,7 @@
<v-icon
v-for="i in model.total"
:key="i"
:disabled="disabled"
>
{{
i > model.value ?
@@ -38,24 +44,13 @@
</v-list-item-title>
<v-list-item-title v-else>
<code>
{{model.total}}
{{ model.total }}
</code>
</v-list-item-title>
<v-list-item-subtitle>
{{ model.name }}
</v-list-item-subtitle>
</v-list-item-content>
<v-list-item-avatar v-if="!hideCastButton">
<v-btn
icon
text
class="primary--text"
:data-id="`spell-slot-cast-btn-${model._id}`"
@click.stop="$emit('cast')"
>
<v-icon>$vuetify.icons.spell</v-icon>
</v-btn>
</v-list-item-avatar>
</v-list-item>
</template>
@@ -69,6 +64,7 @@ export default {
},
dark: Boolean,
hideCastButton: Boolean,
disabled: Boolean,
},
computed: {
hasClickListener(){

View File

@@ -73,27 +73,35 @@
>
Slot
</div>
<v-list-item
v-if="!(selectedSpell && selectedSpell.level > 0)"
key="cantrip-dummy-slot"
class="spell-slot-list-tile"
:class="{ 'primary--text': selectedSlotId === undefined}"
@click="selectedSlotId = undefined"
<v-list-item-group
key="slot-list"
v-model="selectedSlotId"
>
<v-list-item-content>
<v-list-item-title class="text-h6">
Cantrip
</v-list-item-title>
</v-list-item-content>
</v-list-item>
<spell-slot-list-tile
v-for="spellSlot in spellSlots"
:key="spellSlot._id"
:model="spellSlot"
:class="{ 'primary--text': selectedSlotId === spellSlot._id }"
hide-cast-button
@click="selectedSlotId = spellSlot._id"
/>
<v-list-item
key="cantrip-dummy-slot"
class="spell-slot-list-tile"
:class="{ 'primary--text': selectedSlotId === 'no-slot' }"
value="no-slot"
:disabled="!canCastSpellWithSlot(selectedSpell, 'no-slot')"
@click="selectedSlotId = 'no-slot'"
>
<v-list-item-content>
<v-list-item-title>
Cast without spell slot
</v-list-item-title>
</v-list-item-content>
</v-list-item>
<spell-slot-list-tile
v-for="spellSlot in spellSlots"
:key="spellSlot._id"
:model="spellSlot"
:class="{ 'primary--text': selectedSlotId === spellSlot._id }"
:value="spellSlot._id"
:disabled="!canCastSpellWithSlot(selectedSpell, spellSlot._id, spellSlot)"
hide-cast-button
@click="selectedSlotId = spellSlot._id"
/>
</v-list-item-group>
</template>
<template slot="right">
<div
@@ -102,25 +110,31 @@
>
Spell
</div>
<template v-for="spell in computedSpells">
<v-subheader
v-if="spell.isSubheader"
:key="`${spell.level}-header`"
class="item"
>
{{ spell.level === 0 ? 'Cantrips' : `Level ${spell.level}` }}
</v-subheader>
<spell-list-tile
v-else
:key="spell._id"
hide-handle
show-info-button
:class="{ 'primary--text': selectedSpellId === spell._id}"
:model="spell"
@click="selectedSpellId = spell._id"
@show-info="spellDialog(spell._id)"
/>
</template>
<v-list-item-group
key="slot-list"
v-model="selectedSpellId"
>
<template v-for="spell in computedSpells">
<v-subheader
v-if="spell.isSubheader"
:key="`${spell.level}-header`"
class="item"
>
{{ spell.level === 0 ? 'Cantrips' : `Level ${spell.level}` }}
</v-subheader>
<spell-list-tile
v-else
:key="spell._id"
hide-handle
show-info-button
:model="spell"
:value="spell._id"
:class="{ 'primary--text': selectedSpellId === spell._id }"
:disabled="!canCastSpellWithSlot(spell, selectedSlotId, selectedSlot)"
@show-info="spellDialog(spell._id)"
/>
</template>
</v-list-item-group>
</template>
</split-list-layout>
<template slot="actions">
@@ -135,10 +149,7 @@
text
:disabled="!canCast"
class="primary--text"
@click="$store.dispatch('popDialogStack', {
spellId: selectedSpellId,
slotId: selectedSlotId,
})"
@click="cast"
>
Cast
</v-btn>
@@ -153,6 +164,16 @@ import CreatureProperties from '/imports/api/creature/creatureProperties/Creatur
import spellsWithSubheaders from '/imports/ui/properties/components/spells/spellsWithSubheaders.js';
import SpellSlotListTile from '/imports/ui/properties/components/attributes/SpellSlotListTile.vue';
import SpellListTile from '/imports/ui/properties/components/spells/SpellListTile.vue';
import { find } from 'lodash';
const slotFilter = {
type: 'attribute',
attributeType: 'spellSlot',
removed: {$ne: true},
inactive: {$ne: true},
overridden: {$ne: true},
'spellSlotLevel.value': {$gte: 1},
};
export default {
components: {
@@ -179,6 +200,8 @@ export default {
searchString: undefined,
selectedSlotId: this.slotId,
selectedSpellId: this.spellId,
selectedSlot: undefined,
selectedSpell: undefined,
searchValue: undefined,
searchError: undefined,
filterMenuOpen: false,
@@ -195,16 +218,10 @@ export default {
return spellsWithSubheaders(this.spells);
},
canCast(){
let spell = this.selectedSpell;
let slot = this.selectedSlot;
if (!spell) return false;
if (spell.level === 0){
return this.selectedSlotId === undefined;
} else if (!slot) {
return false
} else {
return slot.spellSlotLevelValue >= spell.level;
}
if (!this.selectedSpell || !this.selectedSlotId) return false;
return this.canCastSpellWithSlot(
this.selectedSpell, this.selectedSlotId, this.selectedSlot
);
},
filtersApplied(){
for (let key in this.booleanFilters){
@@ -216,19 +233,61 @@ export default {
},
},
watch: {
selectedSpell(spell){
if (!spell) return;
if(spell.level === 0){
this.selectedSlotId = undefined;
}
selectedSpellId: {
handler(spellId){
this.selectedSpell = CreatureProperties.findOne(spellId)
},
immediate: true
},
selectedSlot(slot){
if (!slot) return;
if (!this.selectedSpell) return;
if(slot.spellSlotLevelValue > 0 && this.selectedSpell.level === 0){
this.selectedSpellId = undefined;
}
selectedSpell: {
handler(spell){
if (!spell) return;
if(spell.level === 0 || spell.castWithoutSpellSlots){
this.selectedSlotId = 'no-slot';
} else if (
!this.selectedSlotId ||
this.selectedSlotId == 'no-slot' ||
this.selectedSlot.spellSlotLevel.value < spell.level
) {
const newSlot = find(
CreatureProperties.find({
'ancestors.id': this.creatureId,
...slotFilter
}, {
sort: {'spellSlotLevel.value': 1, order: 1},
}).fetch(),
slot => {
return this.canCastSpellWithSlot(spell, slot._id, slot)
}
);
if (newSlot){
this.selectedSlotId = newSlot._id;
}
}
},
immediate: true,
},
selectedSlotId: {
handler(slotId){
this.selectedSlot = CreatureProperties.findOne(slotId);
},
immediate: true
},
selectedSlot:{
handler(slot){
if (!slot) return;
if (!this.selectedSpell) return;
if(this.selectedSpell.level > slot.spellSlotLevel.value){
this.selectedSpellId = undefined;
}
},
immediate: true,
},
},
mounted(){
if (this.selectedSpellId){
this.$vuetify.goTo('.spell.v-list-item--active', {container: '.right'});
}
},
methods: {
clearBooleanFilters(){
@@ -247,10 +306,36 @@ export default {
this.searchValue = val;
setTimeout(ack, 200);
},
canCastSpellWithSlot(spell, slotId, slot){
if (slot && !slot.value) return false;
if (!spell) return true;
if (!slotId) return true;
if (
spell.castWithoutSpellSlots &&
spell.insufficientResources
) return false;
return (!spell.level || spell.castWithoutSpellSlots) ? (
// Cantrips and no-slot spells
slotId && slotId === 'no-slot'
) : (
// Leveled spells
slotId !== 'no-slot' &&
slot && spell && (
spell.level <= slot.spellSlotLevel.value
)
)
},
cast(){
let selectedSlotId = this.selectedSlotId;
if (selectedSlotId === 'no-slot') selectedSlotId = undefined;
this.$store.dispatch('popDialogStack', {
spellId: this.selectedSpellId,
slotId: selectedSlotId,
})
}
},
meteor: {
spells(){
let slotLevel = this.selectedSlot && this.selectedSlot.spellSlotLevelValue || 0;
let filter = {
'ancestors.id': this.creatureId,
removed: {$ne: true},
@@ -259,8 +344,8 @@ export default {
{prepared: true},
{alwaysPrepared: true},
],
level: {$lte: slotLevel},
};
// Apply the filters from the filter menu
for (let key in this.booleanFilters){
if (this.booleanFilters[key].enabled){
@@ -284,27 +369,13 @@ export default {
});
},
spellSlots(){
let filter = {
return CreatureProperties.find({
'ancestors.id': this.creatureId,
type: 'attribute',
attributeType: 'spellSlot',
removed: {$ne: true},
inactive: {$ne: true},
currentValue: {$gte: 1},
};
if (this.selectedSpell){
filter.spellSlotLevelValue = {$gte: this.selectedSpell.level};
}
return CreatureProperties.find(filter, {
sort: {order: 1},
...slotFilter
}, {
sort: {'spellSlotLevel.value': 1, order: 1},
});
},
selectedSlot(){
return CreatureProperties.findOne(this.selectedSlotId);
},
selectedSpell(){
return CreatureProperties.findOne(this.selectedSpellId);
},
},
}
</script>

View File

@@ -1,6 +1,8 @@
<template lang="html">
<v-list-item
class="spell"
v-bind="$attrs"
:disabled="disabled"
v-on="hasClickListener ? {click} : {}"
>
<v-list-item-avatar class="spell-avatar">
@@ -8,6 +10,7 @@
class="mr-2"
:model="model"
:color="model.color"
:disabled="disabled"
/>
</v-list-item-avatar>
<v-list-item-content>
@@ -38,6 +41,7 @@
v-else-if="showInfoButton"
icon
class="info-icon"
:disabled="disabled"
:data-id="`spell-info-btn-${model._id}`"
@click.stop="$emit('show-info')"
>
@@ -53,13 +57,14 @@ import updateCreatureProperty from '/imports/api/creature/creatureProperties/met
export default {
mixins: [treeNodeViewMixin],
inject: {
context: { default: {} }
},
props: {
preparingSpells: Boolean,
hideHandle: Boolean,
showInfoButton: Boolean,
},
inject: {
context: { default: {} }
disabled: Boolean,
},
computed: {
hasClickListener(){

View File

@@ -4,7 +4,7 @@
justify="center"
class="mb-3"
>
<v-col cols="1">
<v-col cols="12">
<icon-color-menu
:model="model"
:errors="errors"

View File

@@ -1,5 +1,17 @@
<template lang="html">
<div class="spell-form">
<v-row
justify="center"
class="mb-3"
>
<v-col cols="12">
<icon-color-menu
:model="model"
:errors="errors"
@change="e => $emit('change', e)"
/>
</v-col>
</v-row>
<div class="layout wrap justify-space-between">
<smart-switch
label="Always prepared"
@@ -18,6 +30,15 @@
:error-messages="errors.prepared"
@change="change('prepared', ...arguments)"
/>
<smart-switch
v-show="model.level"
label="Cast without spell slots"
style="width: 400px; flex-grow: 0;"
class="mx-2"
:value="model.castWithoutSpellSlots"
:error-messages="errors.castWithoutSpellSlots"
@change="change('castWithoutSpellSlots', ...arguments)"
/>
</div>
<text-field
ref="focusFirst"
@@ -59,6 +80,15 @@
:error-messages="errors.range"
@change="change('range', ...arguments)"
/>
<smart-select
label="Target"
style="flex-basis: 300px;"
:items="targetOptions"
:value="model.target"
:error-messages="errors.target"
:menu-props="{auto: true, lazy: true}"
@change="change('target', ...arguments)"
/>
<div class="layout wrap justify-space-between">
<smart-checkbox
label="Verbal"
@@ -105,24 +135,70 @@
@change="({path, value, ack}) =>
$emit('change', {path: ['description', ...path], value, ack})"
/>
<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"
:error-messages="errors.tags"
@change="change('tags', ...arguments)"
/>
<form-sections>
<form-section name="Resources">
<resources-form
:model="model.resources"
@change="({path, value, ack}) => $emit('change', {path: ['resources', ...path], value, ack})"
@push="({path, value, ack}) => $emit('push', {path: ['resources', ...path], value, ack})"
@pull="({path, ack}) => $emit('pull', {path: ['resources', ...path], ack})"
/>
</form-section>
<form-section
name="Casting"
v-if="model.level && model.castWithoutSpellSlots"
name="Limit Uses"
>
<action-form
v-bind="$props"
v-on="$listeners"
<v-row dense>
<v-col
cols="12"
md="6"
>
<computed-field
label="Uses"
hint="How many times this action can be used before needing to be reset"
class="mr-2"
:model="model.uses"
:error-messages="errors.uses"
@change="({path, value, ack}) =>
$emit('change', {path: ['uses', ...path], value, ack})"
/>
</v-col>
<v-col
cols="12"
md="6"
>
<text-field
label="Uses used"
type="number"
hint="How many times this action has already been used: should be 0 in most cases"
style="flex-basis: 300px;"
:value="model.usesUsed"
:error-messages="errors.uses"
@change="change('usesUsed', ...arguments)"
/>
</v-col>
</v-row>
<smart-select
label="Reset"
clearable
hint="When number of uses used should be reset to zero"
style="flex-basis: 300px;"
:items="resetOptions"
:value="model.reset"
:error-messages="errors.reset"
:menu-props="{auto: true, lazy: true}"
@change="change('reset', ...arguments)"
/>
</form-section>
<form-section name="Advanced">
<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)"
/>
</form-section>
</form-sections>
@@ -131,14 +207,16 @@
<script lang="js">
import FormSection, { FormSections } from '/imports/ui/properties/forms/shared/FormSection.vue';
import ActionForm from '/imports/ui/properties/forms/ActionForm.vue'
import propertyFormMixin from '/imports/ui/properties/forms/shared/propertyFormMixin.js';
import IconColorMenu from '/imports/ui/properties/forms/shared/IconColorMenu.vue';
import ResourcesForm from '/imports/ui/properties/forms/ResourcesForm.vue';
export default {
components: {
FormSections,
FormSection,
ActionForm,
IconColorMenu,
ResourcesForm,
},
mixins: [propertyFormMixin],
data(){return {
@@ -202,6 +280,27 @@
value: 9,
},
],
targetOptions: [
{
text: 'Self',
value: 'self',
}, {
text: 'Single target',
value: 'singleTarget',
}, {
text: 'Multiple targets',
value: 'multipleTargets',
},
],
resetOptions: [
{
text: 'Short rest',
value: 'shortRest',
}, {
text: 'Long rest',
value: 'longRest',
}
],
};},
};
</script>

View File

@@ -1,45 +1,23 @@
<template lang="html">
<v-menu offset-y>
<template #activator="{ on, attrs }">
<v-badge
icon="mdi-pencil"
overlap
>
<v-btn
icon
:color="model.color"
outlined
v-bind="attrs"
v-on="on"
>
<property-icon
:model="model"
:color="model.color"
/>
</v-btn>
</v-badge>
</template>
<v-list>
<v-list-item>
<v-list-item-title>
<icon-picker
label="Icon"
:value="model.icon"
:error-messages="errors.icon"
@change="(value, ack) =>$emit('change', {path: ['icon'], value, ack})"
/>
</v-list-item-title>
</v-list-item>
<v-list-item>
<v-list-item-title>
<color-picker
:value="model.color"
@input="value =>$emit('change', {path: ['color'], value})"
/>
</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<div
class="d-flex justify-center flex-wrap"
>
<div class="mx-1">
<color-picker
label="Color"
:value="model.color"
@input="value =>$emit('change', {path: ['color'], value})"
/>
</div>
<div class="mx-1">
<icon-picker
label="Icon"
:value="model.icon"
:error-messages="errors.icon"
@change="(value, ack) =>$emit('change', {path: ['icon'], value, ack})"
/>
</div>
</div>
</template>
<script lang="js">

View File

@@ -3,10 +3,12 @@
v-if="model.icon"
:shape="model.icon.shape"
:color="color"
:class="{disabled}"
/>
<v-icon
v-else
:color="color"
:class="{disabled}"
>
{{ icon }}
</v-icon>
@@ -25,6 +27,7 @@ export default {
type: String,
default: undefined,
},
disabled: Boolean,
},
computed: {
icon(){
@@ -33,3 +36,9 @@ export default {
},
}
</script>
<style lang="css" scoped>
.svg-icon.disabled, .v-icon.disabled {
opacity: 0.2;
}
</style>

View File

@@ -5,13 +5,14 @@
>
<property-field
v-if="context.creatureId"
name="Apply action"
:name="model.type === 'spell'? 'Cast spell' : 'Apply action'"
center
>
<v-btn
outlined
style="font-size: 18px;"
class="ma-2"
data-id="do-action-button"
:color="model.color || 'primary'"
icon
:loading="doActionLoading"
@@ -109,12 +110,13 @@
<script lang="js">
import propertyViewerMixin from '/imports/ui/properties/viewers/shared/propertyViewerMixin.js';
import numberToSignedString from '/imports/ui/utility/numberToSignedString.js';
import doAction from '/imports/api/engine/actions/doAction.js';
import AttributeConsumedView from '/imports/ui/properties/components/actions/AttributeConsumedView.vue';
import ItemConsumedView from '/imports/ui/properties/components/actions/ItemConsumedView.vue';
import PropertyIcon from '/imports/ui/properties/shared/PropertyIcon.vue';
import updateCreatureProperty from '/imports/api/creature/creatureProperties/methods/updateCreatureProperty.js';
import doCastSpell from '/imports/api/engine/actions/doCastSpell.js';
import {snackbar} from '/imports/ui/components/snackbars/SnackbarQueue.js';
export default {
components: {
@@ -173,13 +175,33 @@ export default {
},
methods: {
doAction(){
this.doActionLoading = true;
doAction.call({actionId: this.model._id}, error => {
this.doActionLoading = false;
if (error){
console.error(error);
}
});
if (this.model.type === 'action'){
this.doActionLoading = true;
doAction.call({actionId: this.model._id}, error => {
this.doActionLoading = false;
if (error){
snackbar({text: error.reason});
console.error(error);
}
});
} else if (this.model.type === 'spell') {
this.$store.commit('pushDialogStack', {
component: 'cast-spell-with-slot-dialog',
elementId: 'do-action-button',
data: {
creatureId: this.context.creatureId,
spellId: this.model._id,
},
callback({spellId, slotId} = {}){
if (!spellId) return;
doCastSpell.call({spellId, slotId}, error => {
if (!error) return;
snackbar({text: error.reason});
console.error(error);
});
},
});
}
},
resetUses(){
updateCreatureProperty.call({

View File

@@ -9,7 +9,7 @@ import '/imports/server/publications/index.js';
import '/imports/server/cron/deleteSoftRemovedDocuments.js';
import '/imports/api/parenting/organizeMethods.js';
import '/imports/api/users/patreon/updatePatreonOnLogin.js';
import '/imports/api/engine/actions/doCheck.js';
import '/imports/api/engine/actions/index.js';
import '/imports/migrations/server/index.js';
import '/imports/migrations/methods/index.js'
import '/imports/constants/MAINTENANCE_MODE.js';