Added spellcasting to the stats page, click the icon next to a spell slot to cast

This commit is contained in:
Stefan Zermatten
2021-01-19 16:10:34 +02:00
parent 1b3b6362f7
commit a4e6dd1d66
11 changed files with 445 additions and 117 deletions

View File

@@ -22,6 +22,7 @@ import { storedIconsSchema } from '/imports/api/icons/Icons.js';
import { reorderDocs } from '/imports/api/parenting/order.js'; import { reorderDocs } from '/imports/api/parenting/order.js';
import '/imports/api/creature/actions/doAction.js'; import '/imports/api/creature/actions/doAction.js';
import '/imports/api/creature/actions/castSpellWithSlot.js';
import '/imports/api/creature/creatureProperties/manageEquipment.js'; import '/imports/api/creature/creatureProperties/manageEquipment.js';
let CreatureProperties = new Mongo.Collection('creatureProperties'); let CreatureProperties = new Mongo.Collection('creatureProperties');

View File

@@ -0,0 +1,75 @@
import SimpleSchema from 'simpl-schema';
import { ValidatedMethod } from 'meteor/mdg:validated-method';
import { RateLimiterMixin } from 'ddp-rate-limiter-mixin';
import CreatureProperties, { getCreature, damagePropertyWork } from '/imports/api/creature/CreatureProperties.js';
import { assertEditPermission } from '/imports/api/creature/creaturePermissions.js';
import { recomputeCreatureByDoc } from '/imports/api/creature/computation/recomputeCreature.js';
import { doActionWork } from '/imports/api/creature/actions/doAction.js';
const castSpellWithSlot = new ValidatedMethod({
name: 'creatureProperties.castSpellWithSlot',
validate: new SimpleSchema({
spellId: SimpleSchema.RegEx.Id,
slotId: {
type: String,
regEx: SimpleSchema.RegEx.Id,
optional: true,
},
targetId: {
type: String,
regEx: SimpleSchema.RegEx.Id,
optional: true,
},
}).validator(),
mixins: [RateLimiterMixin],
rateLimit: {
numRequests: 10,
timeInterval: 5000,
},
run({spellId, slotId, targetId}) {
let spell = CreatureProperties.findOne(spellId);
// Check permissions
let creature = getCreature(spell);
assertEditPermission(creature, this.userId);
let target = undefined;
if (targetId) {
target = getCreature(targetId);
assertEditPermission(target, this.userId);
}
let slotLevel = spell.level || 0;
if (slotLevel !== 0){
let slot = CreatureProperties.findOne(slotId);
if (!slot){
throw new Meteor.Error('No slot',
'Slot not found to cast spell');
}
if (!slot.currentValue){
throw new Meteor.Error('No slot',
'Slot depleted');
}
if (!(slot.spellSlotLevelValue >= spell.level)){
throw new Meteor.Error('Slot too small',
'Slot is not large enough to cast spell');
}
slotLevel = slot.spellSlotLevelValue;
damagePropertyWork({
property: slot,
operation: 'increment',
value: 1,
});
}
doActionWork({
action: spell,
context: {slotLevel},
creature,
target,
});
// Note this only recomputes the top-level creature, not the nearest one
recomputeCreatureByDoc(creature);
if (target){
recomputeCreatureByDoc(target);
}
},
});
export default castSpellWithSlot;

View File

@@ -41,8 +41,7 @@ const doAction = new ValidatedMethod({
}, },
}); });
function doActionWork({action, creature, target}){ export function doActionWork({action, creature, target, context = {}}){
let actionContext = {};
let decendantForest = nodesToTree({ let decendantForest = nodesToTree({
collection: CreatureProperties, collection: CreatureProperties,
ancestorId: action._id, ancestorId: action._id,
@@ -53,9 +52,9 @@ function doActionWork({action, creature, target}){
}]; }];
applyProperties({ applyProperties({
forest: startingForest, forest: startingForest,
actionContext: context,
creature, creature,
target, target,
actionContext
}); });
} }

View File

@@ -149,15 +149,18 @@
class="spell-slots" class="spell-slots"
> >
<v-card> <v-card>
<v-list> <v-list
two-line
subheader
>
<v-subheader>Spell Slots</v-subheader> <v-subheader>Spell Slots</v-subheader>
<spell-slot-list-tile <spell-slot-list-tile
v-for="spellSlot in spellSlots" v-for="spellSlot in spellSlots"
:key="spellSlot._id" :key="spellSlot._id"
v-bind="spellSlot" :model="spellSlot"
:data-id="spellSlot._id" :data-id="spellSlot._id"
@click="clickProperty({_id: spellSlot._id})" @click="clickProperty({_id: spellSlot._id})"
@change="e => incrementChange(spellSlot._id, e)" @cast="castSpellWithSlot(spellSlot._id)"
/> />
</v-list> </v-list>
</v-card> </v-card>
@@ -320,6 +323,7 @@
import ActionCard from '/imports/ui/properties/components/actions/ActionCard.vue'; import ActionCard from '/imports/ui/properties/components/actions/ActionCard.vue';
import RestButton from '/imports/ui/creature/RestButton.vue'; import RestButton from '/imports/ui/creature/RestButton.vue';
import getActiveProperties from '/imports/api/creature/getActiveProperties.js'; import getActiveProperties from '/imports/api/creature/getActiveProperties.js';
import castSpellWithSlot from '/imports/api/creature/actions/castSpellWithSlot.js';
const getProperties = function(creature, filter,){ const getProperties = function(creature, filter,){
if (!creature) return; if (!creature) return;
@@ -448,6 +452,22 @@
softRemoveProperty.call({_id}, error => { softRemoveProperty.call({_id}, error => {
if (error) console.error(error); if (error) console.error(error);
}); });
},
castSpellWithSlot(slotId){
this.$store.commit('pushDialogStack', {
component: 'cast-spell-with-slot-dialog',
elementId: `spell-slot-cast-btn-${slotId}`,
data: {
creatureId: this.creatureId,
slotId,
},
callback({spellId, slotId} = {}){
if (!spellId) return;
castSpellWithSlot.call({spellId, slotId}, error => {
if (error) console.error(error);
});
},
});
} }
}, },
}; };

View File

@@ -1,3 +1,4 @@
import CastSpellWithSlotDialog from '/imports/ui/properties/components/spells/CastSpellWithSlotDialog.vue';
import CreatureFormDialog from '/imports/ui/creature/CreatureFormDialog.vue'; import CreatureFormDialog from '/imports/ui/creature/CreatureFormDialog.vue';
import CreaturePropertyCreationDialog from '/imports/ui/creature/creatureProperties/CreaturePropertyCreationDialog.vue'; import CreaturePropertyCreationDialog from '/imports/ui/creature/creatureProperties/CreaturePropertyCreationDialog.vue';
import CreaturePropertyDialog from '/imports/ui/creature/creatureProperties/CreaturePropertyDialog.vue' import CreaturePropertyDialog from '/imports/ui/creature/creatureProperties/CreaturePropertyDialog.vue'
@@ -19,6 +20,7 @@ import TierTooLowDialog from '/imports/ui/user/TierTooLowDialog.vue';
import UsernameDialog from '/imports/ui/user/UsernameDialog.vue'; import UsernameDialog from '/imports/ui/user/UsernameDialog.vue';
export default { export default {
CastSpellWithSlotDialog,
CreatureFormDialog, CreatureFormDialog,
CreaturePropertyCreationDialog, CreaturePropertyCreationDialog,
CreaturePropertyDialog, CreaturePropertyDialog,

View File

@@ -1,86 +1,56 @@
<template lang="html"> <template lang="html">
<v-list-tile <v-list-tile
class="spell-slot-list-tile" class="spell-slot-list-tile"
:class="{hover}" v-on="hasClickListener ? {click} : {}"
> >
<v-list-tile-action> <v-list-tile-content>
<div <v-list-tile-title>
v-if="value > 4"
class="layout row align-center"
>
<div class="buttons layout column justify-center">
<v-btn
icon
small
:disabled="
currentValue >= value ||
context.editPermission === false
"
@click="increment(1)"
>
<v-icon>arrow_drop_up</v-icon>
</v-btn>
<v-btn
icon
small
:disabled="
currentValue <= 0 ||
context.editPermission === false
"
@click="increment(-1)"
>
<v-icon>arrow_drop_down</v-icon>
</v-btn>
</div>
<div <div
v-if="model.value > 4"
class="layout row value" class="layout row value"
style="align-items: baseline;" style="align-items: baseline;"
> >
<div class="display-1"> <div
{{ currentValue }} style="font-weight: 500; font-size: 24px"
class="current-value"
>
{{ model.currentValue }}
</div> </div>
<div class="title ml-2 max-value"> <div class="ml-2 max-value">
/{{ value }} /{{ model.value }}
</div> </div>
</div> </div>
</div> <div
v-else
<div class="layout row align-center slot-bubbles"
v-else
class="layout row align-center justify-end slot-buttons"
>
<v-btn
v-for="i in value"
:key="i"
icon
small
:disabled="
!(i === currentValue || i === currentValue + 1) ||
context.editPermission === false
"
@click="increment(i > currentValue ? 1 : -1)"
> >
<v-icon> <v-icon
v-for="i in model.value"
:key="i"
>
{{ {{
i > currentValue ? i > model.currentValue ?
'radio_button_unchecked' : 'radio_button_unchecked' :
'radio_button_checked' 'radio_button_checked'
}} }}
</v-icon> </v-icon>
</v-btn> </div>
</div>
</v-list-tile-action>
<v-list-tile-content
class="content ml-2"
@click="click"
@mouseover="hover = true"
@mouseleave="hover = false"
>
<v-list-tile-title>
{{ name }}
</v-list-tile-title> </v-list-tile-title>
<v-list-tile-sub-title>
{{ model.name }}
</v-list-tile-sub-title>
</v-list-tile-content> </v-list-tile-content>
<v-list-tile-avatar v-if="!hideCastButton">
<v-btn
icon
flat
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-tile-avatar>
</v-list-tile> </v-list-tile>
</template> </template>
@@ -88,18 +58,13 @@
import numberToSignedString from '/imports/ui/utility/numberToSignedString.js'; import numberToSignedString from '/imports/ui/utility/numberToSignedString.js';
export default { export default {
props: { props: {
_id: String, model: {
name: String, type: Object,
color: String, required: true,
value: Number, },
damage: { dark: Boolean,
type: Number, hideCastButton: Boolean,
default: 0,
},
}, },
data(){ return{
hover: false,
}},
inject: { inject: {
context: { default: {} } context: { default: {} }
}, },
@@ -107,15 +72,15 @@ export default {
currentValue(){ currentValue(){
return this.value - this.damage; return this.value - this.damage;
}, },
hasClickListener(){
return this.$listeners && !!this.$listeners.click;
},
}, },
methods: { methods: {
signed: numberToSignedString, signed: numberToSignedString,
click(e){ click(e){
this.$emit('click', e); this.$emit('click', e);
}, },
increment(value){
this.$emit('change', {type: 'increment', value})
},
}, },
}; };
</script> </script>
@@ -124,22 +89,9 @@ export default {
.spell-slot-list-tile { .spell-slot-list-tile {
background: inherit; background: inherit;
} }
.spell-slot-list-tile >>> .v-list__tile {
height: 56px;
}
.v-list__tile__action { .v-list__tile__action {
width: 112px; width: 112px;
flex-shrink: 0; flex-shrink: 0;
}
.slot-buttons > .v-btn {
margin: 0;
flex-shrink: 1;
}
.buttons {
height: 100%;
}
.buttons > .v-btn {
margin: 0;
} }
.spell-slot-list-tile.hover { .spell-slot-list-tile.hover {
background: #f5f5f5 !important; background: #f5f5f5 !important;
@@ -156,4 +108,7 @@ export default {
.theme--dark .max-value { .theme--dark .max-value {
color: rgba(255, 255, 255, 0.54); color: rgba(255, 255, 255, 0.54);
} }
.primary--text .v-icon, .primary--text .max-value, .primary--text .current-value, .primary--text .v-list__tile__sub-title {
color: #b71c1c
}
</style> </style>

View File

@@ -0,0 +1,71 @@
<template lang="html">
<div
v-if="$vuetify.breakpoint.smAndUp"
class="layout row split"
>
<v-list
class="left"
subheader
two-line
dense
>
<v-slide-x-transition
group
leave-absolute
>
<slot name="left" />
</v-slide-x-transition>
</v-list>
<v-divider
class="mx-3"
vertical
/>
<v-list
class="right"
subheader
two-line
dense
>
<v-slide-x-transition
group
leave-absolute
>
<slot name="right" />
</v-slide-x-transition>
</v-list>
</div>
<v-list
v-else
class="small"
subheader
two-line
dense
>
<v-slide-x-transition
group
leave-absolute
>
<slot name="left" />
<slot name="right" />
</v-slide-x-transition>
</v-list>
</template>
<script>
export default {
}
</script>
<style lang="css" scoped>
.split{
height: 100%;
}
.left, .right {
height: 100%;
overflow: auto;
flex-basis: 250px;
}
.right, .small {
flex-grow: 1;
}
</style>

View File

@@ -0,0 +1,201 @@
<template lang="html">
<dialog-base>
<template slot="toolbar">
<v-toolbar-title>
Cast a Spell
</v-toolbar-title>
<v-spacer />
<v-input icon="search" />
</template>
<split-list-layout>
<template slot="left">
<div
key="slot-title"
class="title my-3"
>
Slot
</div>
<v-list-tile
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-tile-content>
<v-list-tile-title class="title">
Cantrip
</v-list-tile-title>
</v-list-tile-content>
</v-list-tile>
<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"
/>
</template>
<template slot="right">
<div
key="spell-title"
class="title my-3"
>
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
:class="{ 'primary--text': selectedSpellId === spell._id}"
:model="spell"
@click="selectedSpellId = spell._id"
/>
</template>
</template>
</split-list-layout>
<template slot="actions">
<v-spacer />
<v-btn
flat
@click="$store.dispatch('popDialogStack')"
>
Cancel
</v-btn>
<v-btn
flat
:disabled="!canCast"
class="primary--text"
@click="$store.dispatch('popDialogStack', {
spellId: selectedSpellId,
slotId: selectedSlotId,
})"
>
Cast
</v-btn>
</template>
</dialog-base>
</template>
<script>
import DialogBase from '/imports/ui/dialogStack/DialogBase.vue';
import SplitListLayout from '/imports/ui/properties/components/attributes/SplitListLayout.vue';
import CreatureProperties from '/imports/api/creature/CreatureProperties.js';
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';
export default {
components: {
DialogBase,
SplitListLayout,
SpellSlotListTile,
SpellListTile,
},
props: {
creatureId: {
type: String,
required: true,
},
slotId: {
type: String,
default: undefined,
},
spellId: {
type: String,
default: undefined,
},
},
data(){ return {
searchString: undefined,
selectedSlotId: this.slotId,
selectedSpellId: this.spellId,
}},
computed: {
computedSpells(){
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;
}
}
},
watch: {
selectedSpell(spell){
if (!spell) return;
if(spell.level === 0){
this.selectedSlotId = undefined;
}
},
selectedSlot(slot){
if (!slot) return;
if (!this.selectedSpell) return;
if(slot.spellSlotLevelValue > 0 && this.selectedSpell.level === 0){
this.selectedSpellId = undefined;
}
},
},
meteor: {
spells(){
let slotLevel = this.selectedSlot && this.selectedSlot.spellSlotLevelValue || 0;
return CreatureProperties.find({
'ancestors.id': this.creatureId,
removed: {$ne: true},
inactive: {$ne: true},
prepared: true,
level: {$lte: slotLevel},
}, {
sort: {order: 1}
});
},
spellSlots(){
let filter = {
'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},
});
},
selectedSlot(){
return CreatureProperties.findOne(this.selectedSlotId);
},
selectedSpell(){
return CreatureProperties.findOne(this.selectedSpellId);
}
},
}
</script>
<style lang="css" scoped>
.v-list {
flex-basis: 200px;
}
.v-list.spells {
flex-grow: 1;
}
</style>

View File

@@ -40,24 +40,7 @@
import draggable from 'vuedraggable'; import draggable from 'vuedraggable';
import SpellListTile from '/imports/ui/properties/components/spells/SpellListTile.vue'; import SpellListTile from '/imports/ui/properties/components/spells/SpellListTile.vue';
import { organizeDoc } from '/imports/api/parenting/organizeMethods.js'; import { organizeDoc } from '/imports/api/parenting/organizeMethods.js';
import spellsWithSubheaders from '/imports/ui/properties/components/spells/spellsWithSubheaders.js';
function spellsWithSubheaders(spells = []){
let result = [];
let lastSpell = undefined;
let sortedSpells = [...spells].sort((a, b) => a.level - b.level)
sortedSpells.forEach(spell => {
if (spell.isSubheader) return;
if (!lastSpell || spell.level > lastSpell.level){
result.push({
isSubheader: true,
level: spell.level,
});
}
result.push(spell);
lastSpell = spell;
});
return result;
}
export default { export default {
components: { components: {

View File

@@ -27,7 +27,7 @@
@change="setPrepared" @change="setPrepared"
/> />
<v-icon <v-icon
v-else v-else-if="!hideHandle"
style="height: 100%; width: 40px; cursor: move;" style="height: 100%; width: 40px; cursor: move;"
class="handle" class="handle"
> >
@@ -45,6 +45,7 @@ export default {
mixins: [treeNodeViewMixin], mixins: [treeNodeViewMixin],
props: { props: {
preparingSpells: Boolean, preparingSpells: Boolean,
hideHandle: Boolean,
}, },
computed: { computed: {
hasClickListener(){ hasClickListener(){
@@ -82,4 +83,7 @@ export default {
.spell { .spell {
background-color: inherit; background-color: inherit;
} }
.primary--text .v-icon, .primary--text .v-list__tile__sub-title {
color: #b71c1c
}
</style> </style>

View File

@@ -0,0 +1,17 @@
export default function spellsWithSubheaders(spells = []){
let result = [];
let lastSpell = undefined;
let sortedSpells = [...spells].sort((a, b) => a.level - b.level)
sortedSpells.forEach(spell => {
if (spell.isSubheader) return;
if (!lastSpell || spell.level > lastSpell.level){
result.push({
isSubheader: true,
level: spell.level,
});
}
result.push(spell);
lastSpell = spell;
});
return result;
}