Overhaul of character action components, actions now consume resources

This commit is contained in:
Stefan Zermatten
2020-06-15 22:30:27 +02:00
parent dc18734d1f
commit 3f540d0f14
16 changed files with 502 additions and 179 deletions

View File

@@ -343,7 +343,45 @@ const adjustQuantity = new ValidatedMethod({
let currentProperty = CreatureProperties.findOne(_id);
// Check permissions
assertPropertyEditPermission(currentProperty, this.userId);
adjustQuantityWork({property: currentProperty, operation, value})
adjustQuantityWork({property: currentProperty, operation, value});
recomputeCreatures(currentProperty);
},
});
const selectAmmoItem = new ValidatedMethod({
name: 'creatureProperties.selectAmmoItem',
validate: new SimpleSchema({
actionId: SimpleSchema.RegEx.Id,
itemId: SimpleSchema.RegEx.Id,
itemConsumedIndex: Number,
}).validator(),
mixins: [RateLimiterMixin],
rateLimit: {
numRequests: 5,
timeInterval: 5000,
},
run({actionId, itemId, itemConsumedIndex}) {
let action = CreatureProperties.findOne(actionId);
// Check permissions
assertPropertyEditPermission(action, this.userId);
// Check that this index has a document to edit
let itemConsumed = action.resources.itemsConsumed[itemConsumedIndex];
if (!itemConsumed){
throw new Meteor.Error('Resouce not found',
'Could not set ammo, because the ammo document was not found');
}
let itemToLink = CreatureProperties.findOne(itemId);
if (!itemToLink){
throw new Meteor.Error('Item not found',
'Could not set ammo: the item was not found');
}
let path = `resources.itemsConsumed.${itemConsumedIndex}.itemId`;
CreatureProperties.update(actionId, {
$set: {[path]: itemId}
}, {
selector: action,
});
recomputeCreatures(action);
},
});
@@ -416,6 +454,7 @@ export {
updateProperty,
damageProperty,
adjustQuantity,
selectAmmoItem,
pushToProperty,
pullFromProperty,
softRemoveProperty,

View File

@@ -15,6 +15,8 @@ export default class ComputationMemo {
this.classes = {};
this.togglesById = {};
this.toggleIds = new Set();
// Equipped items that might be used as ammo
this.equipmentById = {};
// Properties that have calculations, but don't impact other properties
this.endStepPropsById = {};
// First note all the ids of all the toggles
@@ -40,6 +42,10 @@ export default class ComputationMemo {
) {
// Add all the stats
this.addStat(prop);
} else if (
prop.type === 'item'
) {
this.addEquipment(prop);
} else {
return true;
}
@@ -185,6 +191,10 @@ export default class ComputationMemo {
});
return targets;
}
addEquipment(prop){
prop = this.registerProperty(prop);
this.equipmentById[prop._id] = prop;
}
addEndStepProp(prop){
prop = this.registerProperty(prop);
this.endStepPropsById[prop._id] = prop;

View File

@@ -37,6 +37,7 @@ function computeAction(prop, memo){
if (attConsumed.variableName){
let stat = memo.statsByVariableName[attConsumed.variableName];
prop.resources.attributesConsumed[i].statId = stat && stat._id;
prop.resources.attributesConsumed[i].statName = stat && stat.name;
let available = stat && stat.currentValue || 0;
prop.resources.attributesConsumed[i].available = available;
if (available < attConsumed.quantity){
@@ -45,7 +46,22 @@ function computeAction(prop, memo){
}
});
// Items consumed
// TODO
prop.resources.itemsConsumed.forEach((itemConsumed, i) => {
let item = itemConsumed.itemId && memo.equipmentById[itemConsumed.itemId];
prop.resources.itemsConsumed[i].itemId = item && item._id;
let available = item && item.quantity || 0;
prop.resources.itemsConsumed[i].available = available;
let name = item && item.name;
if (item && item.quantity !== 1 && item.plural){
name = item.plural;
}
prop.resources.itemsConsumed[i].itemName = name;
prop.resources.itemsConsumed[i].itemIcon = item && item.icon;
prop.resources.itemsConsumed[i].itemColor = item && item.color;
if (!item || available < itemConsumed.quantity){
prop.insufficientResources = true;
}
});
}
function computeAttack(prop, memo){

View File

@@ -42,6 +42,7 @@ const calculationPropertyTypes = [
'proficiency',
'classLevel',
'toggle',
'item',
// End step types
'action',
'attack',

View File

@@ -1,5 +1,6 @@
import SimpleSchema from 'simpl-schema';
import ErrorSchema from '/imports/api/properties/subSchemas/ErrorSchema.js';
import { storedIconsSchema } from '/imports/api/icons/Icons.js'
/*
* Actions are things a character can do
@@ -133,6 +134,19 @@ const ComputedOnlyActionSchema = new SimpleSchema({
// This appears both in the computed and uncomputed schema because it can be
// set by both a computation or a form
'resources.itemsConsumed.$.itemId': {
type: String,
regEx: SimpleSchema.RegEx.Id,
optional: true,
},
'resources.itemsConsumed.$.itemName': {
type: String,
optional: true,
},
'resources.itemsConsumed.$.itemIcon': {
type: storedIconsSchema,
optional: true,
},
'resources.itemsConsumed.$.itemColor': {
type: String,
optional: true,
},
@@ -147,6 +161,12 @@ const ComputedOnlyActionSchema = new SimpleSchema({
regEx: SimpleSchema.RegEx.Id,
optional: true,
},
'resources.attributesConsumed.$.statName': {
type: String,
optional: true,
},
// True if the uses left is zero, or any item or attribute consumed is
// insufficient
insufficientResources: {
type: Boolean,
optional: true,

View File

@@ -34,8 +34,7 @@
drag_handle
</v-icon>
<!--{{node && node.order}}-->
<component
:is="treeNodeView"
<tree-node-view
:model="node"
:selected="selected"
/>
@@ -80,13 +79,13 @@
**/
import { canBeParent } from '/imports/api/parenting/parenting.js';
import { getPropertyIcon } from '/imports/constants/PROPERTIES.js';
import treeNodeViewIndex from '/imports/ui/properties/treeNodeViews/treeNodeViewIndex.js';
import TreeNodeView from '/imports/ui/properties/treeNodeViews/TreeNodeView.vue';
export default {
name: 'TreeNode',
components: {
...treeNodeViewIndex
},
components: {
TreeNodeView,
},
props: {
node: Object,
group: String,
@@ -100,10 +99,6 @@
expanded: false,
}},
computed: {
treeNodeView(){
let type = this.node.type;
return treeNodeViewIndex[type] || treeNodeViewIndex.default;
},
hasChildren(){
return this.children && this.children.length || this.lazy && !this.expanded;
},

View File

@@ -1,7 +1,6 @@
<template lang="html">
<v-btn
:loading="loading"
:disabled="loading"
outline
style="width: 160px;"
@click="rest"

View File

@@ -258,25 +258,16 @@
/>
</div>
<div
v-if="attacks.length"
class="actions"
v-for="attack in attacks"
:key="attack._id"
class="attacks"
>
<v-card>
<v-list
two-line
subheader
>
<v-subheader>Attacks</v-subheader>
<action-list-tile
v-for="attack in attacks"
:key="attack._id"
attack
:model="attack"
:data-id="attack._id"
@click="clickProperty({_id: attack._id})"
/>
</v-list>
</v-card>
<action-card
attack
:model="attack"
:data-id="attack._id"
@click="clickProperty({_id: attack._id})"
/>
</div>
</column-layout>
</div>
@@ -294,7 +285,6 @@
import SkillListTile from '/imports/ui/properties/components/skills/SkillListTile.vue';
import ResourceCard from '/imports/ui/properties/components/attributes/ResourceCard.vue';
import SpellSlotListTile from '/imports/ui/properties/components/attributes/SpellSlotListTile.vue';
import ActionListTile from '/imports/ui/properties/components/actions/ActionListTile.vue';
import ActionCard from '/imports/ui/properties/components/actions/ActionCard.vue';
import RestButton from '/imports/ui/creature/RestButton.vue';
import getActiveProperties from '/imports/api/creature/getActiveProperties.js';
@@ -337,7 +327,6 @@
SkillListTile,
ResourceCard,
SpellSlotListTile,
ActionListTile,
ActionCard,
},
props: {
@@ -390,14 +379,7 @@
return getSkillOfType(this.creature, 'language');
},
actions(){
let props = getProperties(this.creature, {type: 'action'}).map(action => {
action.children = getActiveProperties({
ancestorId: action._id,
options: {sort: {order: 1}},
});
return action;
});
return props;
return getProperties(this.creature, {type: 'action'});
},
attacks(){
let props = getProperties(this.creature, {type: 'attack'}).map(attack => {

View File

@@ -160,7 +160,6 @@ export default {
});
},
push({path, value, ack}){
console.log({path, value, ack});
pushToProperty.call({_id: this._id, path, value}, (error) =>{
if (error) console.warn(error);
ack && ack(error && error.reason || error);

View File

@@ -1,58 +1,106 @@
<template lang="html">
<v-card
class="action"
@click="$emit('click')"
ref="card"
class="action-card"
:class="cardClasses"
:elevation="hovering ? 8 : undefined"
>
<v-card-title
primary-title
class="layout row pa-2"
>
<div class="layout row align-center px-3">
<div class="avatar">
<v-btn
flat
icon
class="headline"
outline
style="margin-left: -4px; font-size: 18px;"
color="primary"
:loading="doActionLoading"
:disabled="model.insufficientResources"
@click.stop="doAction"
>
<template v-if="rollBonus">
<template v-if="attack && !rollBonusTooLong">
{{ rollBonus }}
</template>
<v-icon v-else>
$vuetify.icons.action
{{ actionTypeIcon }}
</v-icon>
</v-btn>
</div>
<div class="action-header flex">
<div class="action-title">
<div
class="action-header flex layout column justify-center pl-1"
style="height: 72px; cursor: pointer;"
@mouseover="hovering = true"
@mouseleave="hovering = false"
@click="$emit('click')"
>
<div
class="action-title my-1"
>
{{ model.name }}
</div>
<div class="action-type">
action type text
<div class="action-sub-title layout row align-center">
<div class="flex">
{{ model.actionType }}
</div>
<div v-if="model.uses">
{{ usesLeft }} uses
</div>
</div>
</div>
<div class="action-uses">
{{ usesLeft }}/{{ totalUses }}
</div>
</v-card-title>
<v-card-text
v-if="childText"
class="action-details"
v-html="childText"
/>
</div>
<div class="px-3 pb-3">
<attribute-consumed-view
v-for="attributeConsumed in model.resources.attributesConsumed"
:key="attributeConsumed._id"
class="action-child"
:model="attributeConsumed"
/>
<item-consumed-view
v-for="itemConsumed in model.resources.itemsConsumed"
:key="itemConsumed._id"
class="action-child"
:model="itemConsumed"
:action="model"
/>
<v-divider
v-if="
model.resources.attributesConsumed.length ||
model.resources.itemsConsumed.length
"
class="my-2"
/>
<tree-node-view
v-for="child in children"
:key="child._id"
class="action-child"
:model="child"
/>
</div>
</v-card>
</template>
<script>
import numberToSignedString from '/imports/ui/utility/numberToSignedString.js';
import evaluateString from '/imports/api/creature/computation/afterComputation/evaluateString.js';
import doAction from '/imports/api/creature/actions/doAction.js';
import getActiveProperties from '/imports/api/creature/getActiveProperties.js';
import TreeNodeView from '/imports/ui/properties/treeNodeViews/TreeNodeView.vue';
import AttributeConsumedView from '/imports/ui/properties/components/actions/AttributeConsumedView.vue';
import ItemConsumedView from '/imports/ui/properties/components/actions/ItemConsumedView.vue';
export default {
components: {
TreeNodeView,
AttributeConsumedView,
ItemConsumedView,
},
inject: {
context: {
default: {},
},
theme: {
default: {
isDark: false,
},
},
},
props: {
model: {
@@ -63,48 +111,118 @@ export default {
type: Boolean,
},
},
data(){return {
activated: undefined,
doActionLoading: false,
hovering: false,
}},
computed: {
hasClickListener(){
return this.$listeners && this.$listeners.click
},
rollBonus(){
if (!this.attack) return;
return numberToSignedString(this.model.rollBonusResult);
},
childText(){
let scope = this.context.creature && this.context.creature.variables;
if (!this.model.children || !this.model.children.length) return;
let textArray = [];
this.model.children.forEach(child => {
if (child.type === 'damage'){
let { result } = evaluateString(child.amount, scope);
textArray.push(`${result} ${child.damageType}`);
} else if (child.type === 'savingThrow'){
textArray.push(`DC ${child.dcResult} ${child.name}`);
}
});
return textArray.join(' ');
rollBonusTooLong(){
return this.rollBonus && this.rollBonus.length > 3;
},
totalUses(){
return Math.max(this.model.usesResult, 0);
},
usesLeft(){
return Math.max(this.model.usesResult - this.model.usesUsed, 0);
},
cardClasses() {
return {
'theme--dark': this.theme.isDark,
'theme--light': !this.theme.isDark,
'muted-text': this.model.insufficientResources,
'shrink': this.activated,
}
},
actionTypeIcon() {
return `$vuetify.icons.${this.model.actionType}`;
},
},
meteor: {
children(){
return getActiveProperties({
ancestorId: this.model._id,
filter: {'parent.id': this.model._id},
options: {sort: {order: 1}},
});
},
},
methods: {
click(e){
this.$emit('click', e);
},
doAction(){
this.doActionLoading = true;
this.shwing();
doAction.call({actionId: this.model._id}, error => {
this.doActionLoading = false;
if (error){
console.error(error);
}
});
},
shwing(){
this.activated = true;
setTimeout(() => {
this.activated = undefined;
}, 300);
}
}
}
</script>
<style lang="css" scoped>
.action-title {
font-size: 16px;
font-weight: 400;
height: 24px;
line-height: 24px;
position: relative;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: .3s cubic-bezier(.25,.8,.5,1);
width: 100%;
}
.action-sub-title {
color: #9e9e9e;
flex-grow: 0;
font-size: 12px;
line-height: 12px;
height: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: .3s cubic-bezier(.25,.8,.5,1);
width: 100%;
}
.action-child {
height: 32px;
}
.theme--light.muted-text {
color: rgba(0,0,0,.3) !important;
}
.theme--dark.muted-text {
color: hsla(0,0%,100%,.3) !important;
}
.action-card {
transition: transform 0.15s cubic;
}
</style>
<style lang="css">
.action-card.theme--light.muted-text .v-icon {
color: rgba(0,0,0,.3) !important;
}
.action-card.theme--dark.muted-text .v-icon {
color: hsla(0,0%,100%,.3) !important;
}
</style>

View File

@@ -1,100 +0,0 @@
<template lang="html">
<v-list-tile
class="ability-list-tile"
v-on="hasClickListener ? {click} : {}"
>
<v-list-tile-action
v-if="attack"
>
<v-btn
flat
icon
class="headline"
:disabled="model.insufficientResources"
@click.stop="doAction"
>
{{ rollBonus }}
</v-btn>
</v-list-tile-action>
<v-list-tile-content>
<v-list-tile-title>
{{ model.name }}
</v-list-tile-title>
<v-list-tile-sub-title
v-if="childText"
v-html="childText"
/>
</v-list-tile-content>
<v-list-tile-action v-if="model.usesResult">
<v-list-tile-action-text>
{{ usesLeft }}/{{ totalUses }}
</v-list-tile-action-text>
</v-list-tile-action>
</v-list-tile>
</template>
<script>
import numberToSignedString from '/imports/ui/utility/numberToSignedString.js';
import evaluateString from '/imports/api/creature/computation/afterComputation/evaluateString.js';
import doAction from '/imports/api/creature/actions/doAction.js';
export default {
inject: {
context: {
default: {},
},
},
props: {
model: {
type: Object,
required: true,
},
attack: {
type: Boolean,
},
},
computed: {
hasClickListener(){
return this.$listeners && this.$listeners.click
},
rollBonus(){
return numberToSignedString(this.model.rollBonusResult);
},
childText(){
let scope = this.context.creature && this.context.creature.variables;
if (!this.model.children || !this.model.children.length) return;
let textArray = [];
this.model.children.forEach(child => {
if (child.type === 'damage'){
let { result } = evaluateString(child.amount, scope);
textArray.push(`${result} ${child.damageType}`);
} else if (child.type === 'savingThrow'){
textArray.push(`DC ${child.dcResult} ${child.name}`);
}
});
return textArray.join(' ');
},
totalUses(){
return Math.max(this.model.usesResult, 0);
},
usesLeft(){
return Math.max(this.model.usesResult - this.model.usesUsed, 0);
},
},
methods: {
click(e){
this.$emit('click', e);
},
doAction(){
doAction.call({actionId: this.model._id}, error => {
if (error){
console.error(error);
}
});
},
}
}
</script>
<style lang="css" scoped>
</style>

View File

@@ -0,0 +1,34 @@
<template lang="html">
<div
class="layout row align-center justify-start"
:class="insufficient && 'error--text'"
>
<div
class="mr-2"
style="width: 24px; text-align: center;"
>
{{ model.quantity }}
</div>
<div
class="text-no-wrap text-truncate"
>
{{ model.statName }}
</div>
</div>
</template>
<script>
export default {
props: {
model: {
type: Object,
default: () => ({}),
},
},
computed: {
insufficient(){
return this.model.quantity > this.model.available;
},
},
}
</script>

View File

@@ -0,0 +1,108 @@
<template lang="html">
<div
:class="{
'theme--dark': theme.isDark,
'theme--light': !theme.isDark,
}"
>
<v-menu
transition="slide-y-transition"
lazy
>
<template #activator="{ on }">
<div
class="layout row align-center justify-start"
style="height: 100%;"
:class="{
'error--text': insufficient,
'clickable': context.editPermission,
}"
v-on="on"
>
<div
class="mr-2"
style="width: 24px; text-align: center;"
>
<template v-if="model.quantity === 1">
{{ model.available }}
</template>
<template v-else-if="model.quantity !== 0">
{{ model.available }} / {{ model.quantity }}
</template>
</div>
<svg-icon
v-if="model.itemIcon"
:shape="model.itemIcon.shape"
:color="model.itemColor"
/>
<div
class="text-no-wrap text-truncate flex"
>
<template v-if="model.itemId">
{{ model.itemName }}
</template>
<span
v-else
class="error--text"
>
Select ammo
</span>
</div>
<v-icon v-if="context.editPermission">
arrow_drop_down
</v-icon>
</div>
</template>
<select-item-to-consume
:action="action"
:item-consumed="model"
/>
</v-menu>
</div>
</template>
<script>
import SelectItemToConsume from '/imports/ui/properties/components/actions/SelectItemToConsume.vue';
export default {
components: {
SelectItemToConsume,
},
inject: {
context: {
default: {},
},
theme: {
default: {
isDark: false,
},
},
},
props: {
model: {
type: Object,
default: () => ({}),
},
action: {
type: Object,
required: true,
},
},
computed: {
insufficient(){
return this.model.quantity > this.model.available;
},
},
}
</script>
<style lang="css" scoped>
.clickable {
cursor: pointer;
}
.theme--light .clickable:hover {
background: rgba(0,0,0,.04);
}
.theme--dark .clickable:hover {
background: hsla(0,0%,100%,.08);
}
</style>

View File

@@ -0,0 +1,73 @@
<template lang="html">
<v-list v-if="items.length">
<v-list-tile
v-for="item in items"
:key="item._id"
@click="selectItem(item._id)"
>
<item-tree-node
:model="item"
:selected="itemConsumed.itemId === item._id"
/>
</v-list-tile>
</v-list>
<v-card v-else>
<v-card-text>
No equipped items found with the tag "{{ itemConsumed.tag }}"
</v-card-text>
</v-card>
</template>
<script>
import ItemTreeNode from '/imports/ui/properties/treeNodeViews/ItemTreeNode.vue';
import getActiveProperties from '/imports/api/creature/getActiveProperties.js';
import { selectAmmoItem } from '/imports/api/creature/CreatureProperties.js';
import { findIndex } from 'lodash';
export default {
components: {
ItemTreeNode
},
props: {
action: {
type: Object,
required: true,
},
itemConsumed: {
type: Object,
required: true,
},
},
meteor: {
items(){
return getActiveProperties({
ancestorId: this.action.ancestors[0].id,
filter: {
tags: this.itemConsumed.tag,
equipped: true,
},
options: {
fields: {equipped: false},
}
});
}
},
methods:{
selectItem(itemId){
let itemConsumedIndex = findIndex(
this.action.resources.itemsConsumed,
item => item._id === this.itemConsumed._id
);
selectAmmoItem.call({
actionId: this.action._id,
itemId,
itemConsumedIndex
}, error => {
if (error) console.error(error);
});
}
}
}
</script>
<style lang="css" scoped>
</style>

View File

@@ -83,7 +83,6 @@
this.addResourceLoading = false;
},
addAttributesConsumed(){
console.log(AttributeConsumedSchema.clean({}));
this.addResourceLoading = true;
this.$emit('push', {
path: ['attributesConsumed'],

View File

@@ -0,0 +1,30 @@
<template lang="html">
<component
:is="treeNodeView"
:model="model"
:selected="selected"
/>
</template>
<script>
import treeNodeViewIndex from '/imports/ui/properties/treeNodeViews/treeNodeViewIndex.js';
export default {
components: {
...treeNodeViewIndex
},
props: {
model: {
type: Object,
required: true,
},
selected: Boolean,
},
computed: {
treeNodeView(){
let type = this.model.type;
return treeNodeViewIndex[type] || treeNodeViewIndex.default;
},
}
}
</script>