Merge branch 'feature-tabletop' into develop

This commit is contained in:
ThaumRystra
2023-12-18 18:35:56 +02:00
41 changed files with 3233 additions and 275 deletions

View File

@@ -62,6 +62,34 @@ let CreatureSettingsSchema = new SimpleSchema({
},
});
let IconGroupSchema = new SimpleSchema({
name: {
type: String,
max: STORAGE_LIMITS.name,
optional: true,
},
iconIds: {
type: Array,
max: 4,
defaultValue: [],
},
'iconIds.$': {
type: String,
max: STORAGE_LIMITS.variableName,
},
});
let CreatureTabletopSettingsSchema = new SimpleSchema({
iconGroups: {
type: Array,
defaultValue: [],
max: 10,
},
'iconGroups.$': {
type: IconGroupSchema,
},
});
let CreatureSchema = new SimpleSchema({
// Strings
name: {
@@ -177,6 +205,10 @@ let CreatureSchema = new SimpleSchema({
type: SimpleSchema.Integer,
optional: true,
},
tabletopSettings: {
type: CreatureTabletopSettingsSchema,
optional: true,
},
// Settings
settings: {

View File

@@ -8,6 +8,7 @@ import { assertEditPermission } from '/imports/api/creature/creatures/creaturePe
import { parse, prettifyParseError } from '/imports/parser/parser';
import resolve, { toString } from '/imports/parser/resolve';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS';
import { assertUserInTabletop } from '/imports/api/tabletop/methods/shared/tabletopPermissions.js';
const PER_CREATURE_LOG_LIMIT = 100;
@@ -42,6 +43,12 @@ let CreatureLogSchema = new SimpleSchema({
regEx: SimpleSchema.RegEx.Id,
index: 1,
},
tabletopId: {
type: String,
regEx: SimpleSchema.RegEx.Id,
index: 1,
optional: true,
},
creatureName: {
type: String,
optional: true,
@@ -119,6 +126,7 @@ const insertCreatureLog = new ValidatedMethod({
'settings.discordWebhook': 1,
name: 1,
avatarPicture: 1,
tabletop: 1,
}
});
assertEditPermission(creature, this.userId);
@@ -128,7 +136,7 @@ const insertCreatureLog = new ValidatedMethod({
},
});
export function insertCreatureLogWork({ log, creature, method }) {
export function insertCreatureLogWork({ log, creature, tabletopId, method }) {
// Build the new log
if (typeof log === 'string') {
log = { content: [{ value: log }] };
@@ -142,13 +150,20 @@ export function insertCreatureLogWork({ log, creature, method }) {
}
});
log.date = new Date();
if (tabletopId) log.tabletopId = tabletopId;
if (creature && creature.tabletop) log.tabletopId = creature.tabletop;
// Insert it
let id = CreatureLogs.insert(log);
if (Meteor.isServer) {
method?.unblock();
removeOldLogs(creature._id);
logWebhook({ log, creature });
if (creature) {
removeOldLogs(creature._id);
logWebhook({ log, creature });
}
if (log.tabletopId) {
// Todo remove old tabletop logs
// Log webhook if it's different to creature webhook
}
}
return id;
}
@@ -170,24 +185,39 @@ const logRoll = new ValidatedMethod({
roll: {
type: String,
},
tabletopId: {
type: String,
regEx: SimpleSchema.RegEx.Id,
optional: true,
},
creatureId: {
type: String,
regEx: SimpleSchema.RegEx.Id,
optional: true,
},
}).validator(),
run({ roll, creatureId }) {
const creature = Creatures.findOne(creatureId, {
fields: {
readers: 1,
writers: 1,
owner: 1,
'settings.discordWebhook': 1,
name: 1,
avatarPicture: 1,
}
});
assertEditPermission(creature, this.userId);
const variables = CreatureVariables.findOne({ _creatureId: creatureId });
run({ roll, tabletopId, creatureId }) {
if (!creatureId && !tabletopId) throw new Meteor.Error('no-id',
'A creature id or tabletop id must be given'
);
let creature;
if (creatureId) {
creature = Creatures.findOne(creatureId, {
fields: {
readers: 1,
writers: 1,
owner: 1,
'settings.discordWebhook': 1,
name: 1,
avatarPicture: 1,
}
});
assertEditPermission(creature, this.userId);
}
if (tabletopId) {
assertUserInTabletop(tabletopId, this.userId);
}
const variables = CreatureVariables.findOne({ _creatureId: creatureId }) || {};
let logContent = []
let parsedResult = undefined;
try {
@@ -228,7 +258,7 @@ const logRoll = new ValidatedMethod({
date: new Date(),
};
let id = insertCreatureLogWork({ log, creature, method: this });
let id = insertCreatureLogWork({ log, creature, tabletopId, method: this });
return id;
},

View File

@@ -16,8 +16,7 @@ export default function computeAction(computation, node) {
}
});
prop.resources.itemsConsumed?.forEach(itemConsumed => {
if (!itemConsumed.itemId) return;
if (itemConsumed.available < itemConsumed.quantity?.value) {
if (!itemConsumed.itemId || itemConsumed.available < itemConsumed.quantity?.value) {
prop.insufficientResources = true;
}
});

View File

@@ -12,15 +12,17 @@ export const loadedCreatures: Map<string, LoadedCreature> = new Map(); // creatu
export function loadCreature(creatureId: string, subscription: Tracker.Computation) {
if (!creatureId) throw 'creatureId is required';
let creature = loadedCreatures.get(creatureId);
if (!creature || !creature.subs.has(subscription)) {
subscription.onStop(() => {
unloadCreature(creatureId, subscription);
});
}
if (creature) {
creature.subs.add(subscription);
} else {
creature = new LoadedCreature(subscription, creatureId);
loadedCreatures.set(creatureId, creature);
}
subscription.onStop(() => {
unloadCreature(creatureId, subscription);
});
}
function unloadCreature(creatureId, subscription) {
@@ -188,14 +190,15 @@ export function getPropertyChildren(creatureId, property) {
// This propertyId will always appear in the parent of the children
if (loadedCreatures.has(creatureId)) {
const creature = loadedCreatures.get(creatureId);
const props = [];
if (!creature) return [];
const props: CreatureProperty[] = [];
for (const prop of creature.properties.values()) {
if (prop.parent?.id === property._id) {
if (prop.parentId === property._id) {
props.push(prop);
}
}
const cloneProps = EJSON.clone(props);
return cloneProps.sort((a, b) => a.order - b.order);
return cloneProps.sort((a, b) => a.left - b.left);
} else {
return CreatureProperties.find({
'parent.id': property._id,

View File

@@ -0,0 +1,49 @@
import SimpleSchema from 'simpl-schema';
import ChildSchema from '/imports/api/parenting/ChildSchema.js';
let TabletopMaps = new Mongo.Collection('tabletopmaps');
let TabletopMapschema = new SimpleSchema({
name: {
type: String,
optional: true,
},
texture: {
type: String,
regEx: SimpleSchema.RegEx.Id,
},
position: {
type: Object,
optional: true,
},
'position.x': {
type: Number,
},
'position.y': {
type: Number,
},
width: {
type: Number,
},
height: {
type: Number,
},
rotation: {
type: Number,
max: 360,
min: 0,
},
// If this map was copied from a library map, this ID will be set
libraryMapId: {
type: String,
regEx: SimpleSchema.RegEx.Id,
optional: true,
},
});
const schema = new SimpleSchema({});
schema.extend(ChildSchema);
schema.extend(TabletopMapschema);
TabletopMaps.attachSchema(schema);
export default TabletopMaps;

View File

@@ -0,0 +1,43 @@
import SimpleSchema from 'simpl-schema';
import ChildSchema from '/imports/api/parenting/ChildSchema.js';
let TabletopObjects = new Mongo.Collection('tabletopObjects');
let TabletopObjectSchema = new SimpleSchema({
name: {
type: String,
optional: true,
},
texture: {
type: String,
regEx: SimpleSchema.RegEx.Id,
},
position: {
type: Object,
optional: true,
},
'position.x': {
type: Number,
},
'position.y': {
type: Number,
},
width: {
type: Number,
},
height: {
type: Number,
},
rotation: {
type: Number,
max: 360,
min: 0,
},
});
const schema = new SimpleSchema({});
schema.extend(ChildSchema);
schema.extend(TabletopObjectSchema);
TabletopObjects.attachSchema(schema);
export default TabletopObjects;

File diff suppressed because it is too large Load Diff

View File

@@ -12,8 +12,8 @@
<v-btn
v-bind="$attrs"
:class="buttonClass"
v-on="on"
@click.stop
v-on="noClick ? {} : on"
@click="e => { if (!noClick) e.stopPropagation(); }"
>
<slot />
</v-btn>
@@ -90,6 +90,7 @@ export default {
type: Number,
default: undefined,
},
noClick: Boolean,
},
data(){return {
open: false,

View File

@@ -30,18 +30,17 @@
<div
v-else
key="character-tabs"
class="fill-height"
class="card-background fill-height"
>
<v-tabs-items
:key=" '' +
creature.settings.hideSpellsTab +
creature.settings.showTreeTab
"
:value="$store.getters.tabById($route.params.id)"
class="card-background"
:value="$store.getters.tabById(creatureId)"
@change="e => $store.commit(
'setTabForCharacterSheet',
{id: $route.params.id, tab: e}
{id: creatureId, tab: e}
)"
>
<v-tab-item>
@@ -72,7 +71,7 @@
</div>
</v-fade-transition>
<character-sheet-fab
v-if="$vuetify.breakpoint.xsOnly"
v-if="!embedded && $vuetify.breakpoint.xsOnly"
direction="top"
fixed
bottom
@@ -81,15 +80,15 @@
:edit-permission="editPermission"
/>
<v-bottom-navigation
v-if="$vuetify.breakpoint.xsOnly && creature && creature.settings"
v-if="!embedded && $vuetify.breakpoint.xsOnly && creature && creature.settings"
app
shift
mandatory
class="bottom-nav-btns"
:value="$store.getters.tabById($route.params.id)"
:value="$store.getters.tabById(creatureId)"
@change="e => $store.commit(
'setTabForCharacterSheet',
{id: $route.params.id, tab: e}
{id: creatureId, tab: e}
)"
>
<v-btn>
@@ -144,6 +143,7 @@ import CreatureLogs from '/imports/api/creature/log/CreatureLogs';
import { snackbar } from '/imports/client/ui/components/snackbars/SnackbarQueue';
import CharacterSheetFab from '/imports/client/ui/creature/character/CharacterSheetFab.vue';
import ActionsTab from '/imports/client/ui/creature/character/characterSheetTabs/ActionsTab.vue';
import CharacterSheetInitiative from '/imports/client/ui/creature/character/CharacterSheetInitiative.vue';
export default {
components: {
@@ -156,13 +156,16 @@ export default {
BuildTab,
TreeTab,
CharacterSheetFab,
CharacterSheetInitiative,
},
props: {
creatureId: {
type: String,
required: true,
},
embedded: Boolean,
},
// @ts-ignore
reactiveProvide: {
name: 'context',
include: ['creatureId', 'editPermission'],
@@ -250,4 +253,9 @@ export default {
min-height: calc(100vh - 96px);
overflow: hidden;
}
.dialog-component .character-sheet .v-window-item {
min-height: unset;
overflow: unset;
}
</style>

View File

@@ -0,0 +1,46 @@
<template>
<div
class="d-flex character-sheet-initiative"
:style="{
left: `${$vuetify.application.left}px`,
right: `${$vuetify.application.right}px`,
bottom: $vuetify.breakpoint.xsOnly ? '60px' : '4px',
}"
>
<v-card
v-for="c in [1,2,3,4,5]"
:key="c"
:width="48"
:height="64"
class="mx-1"
>
<v-progress-linear :value="62" />
<v-img src="https://picsum.photos/200/300" />
</v-card>
</div>
</template>
<script lang="js">
export default {
}
</script>
<style scoped>
.character-sheet-initiative {
position: fixed;
bottom: 0;
padding-right: 56px;
z-index: 5;
overflow-x: auto;
overflow-y: hidden;
transition: .2s cubic-bezier(0.4, 0, 0.2, 1) transform,
.2s cubic-bezier(0.4, 0, 0.2, 1) background-color,
.2s cubic-bezier(0.4, 0, 0.2, 1) left,
.2s cubic-bezier(0.4, 0, 0.2, 1) right,
280ms cubic-bezier(0.4, 0, 0.2, 1) box-shadow,
.25s cubic-bezier(0.4, 0, 0.2, 1) max-width,
.25s cubic-bezier(0.4, 0, 0.2, 1) width;
}
</style>

View File

@@ -1,5 +1,10 @@
<template lang="html">
<div class="mini-character-sheet" />
<v-card
hover
@click="$emit('click')"
>
Character sheet
</v-card>
</template>
<script lang="js">

View File

@@ -2,6 +2,7 @@
import ActionDialog from '/imports/client/ui/creature/actions/ActionDialog.vue';
import InsertPropertyDialog from '/imports/client/ui/properties/InsertPropertyDialog.vue';
import CharacterCreationDialog from '/imports/client/ui/creature/character/CharacterCreationDialog.vue';
import CharacterSheetDialog from '/imports/client/ui/tabletop/CharacterSheetDialog.vue';
import CastSpellWithSlotDialog from '/imports/client/ui/properties/components/spells/CastSpellWithSlotDialog.vue';
import CreatureFormDialog from '/imports/client/ui/creature/CreatureFormDialog.vue';
import CreaturePropertyDialog from '/imports/client/ui/creature/creatureProperties/CreaturePropertyDialog.vue';
@@ -39,6 +40,7 @@ export default {
ArchiveDialog,
CastSpellWithSlotDialog,
CharacterCreationDialog,
CharacterSheetDialog,
CreatureFormDialog,
CreaturePropertyDialog,
CreaturePropertyFromLibraryDialog,

View File

@@ -80,6 +80,7 @@ export default {
components: {
CreatureFolderList
},
// @ts-ignore
meteor: {
$subscribe: {
'characterList': [],
@@ -97,8 +98,8 @@ export default {
{ title: 'Home', icon: 'mdi-home', to: '/' },
{ title: 'Characters', icon: 'mdi-account-group', to: '/character-list', requireLogin: true },
{ title: 'Library', icon: 'mdi-library-shelves', to: '/library', requireLogin: true },
//{title: 'Tabletops', icon: 'api', to: '/tabletops', requireLogin: true},
//{title: 'Friends', icon: 'people', to: '/friends', requireLogin: true},
{title: 'Tabletops', icon: 'mdi-table-furniture', to: '/tabletops', requireLogin: true},
{title: 'Friends', icon: 'mdi-account-multiple', to: '/friends', requireLogin: true},
{ title: 'Files', icon: 'mdi-file-multiple', to: '/my-files' },
{ title: 'Feedback', icon: 'mdi-bug', to: '/feedback' },
{ title: 'About', icon: 'mdi-sign-text', to: '/about' },

View File

@@ -25,8 +25,11 @@
:hint="inputHint"
:error-messages="inputError"
:disabled="!editPermission"
:loading="submitLoading"
@click:append-outer="submit"
@keyup.enter="submit"
@keyup.up="decrementHistory"
@keyup.down="incrementHistory"
/>
</v-card>
</div>
@@ -40,6 +43,7 @@ import { assertEditPermission } from '/imports/api/creature/creatures/creaturePe
import { parse, prettifyParseError } from '/imports/parser/parser';
import resolve, { toString } from '/imports/parser/resolve';
import LogEntry from '/imports/client/ui/log/LogEntry.vue';
import { Tracker } from 'meteor/tracker'
export default {
components: {
@@ -48,22 +52,70 @@ export default {
props: {
creatureId: {
type: String,
required: true,
default: undefined,
},
tabletopId: {
type: String,
default: undefined,
},
},
data(){return {
inputHint: undefined,
inputError: undefined,
input: undefined,
history: [],
historyIndex: 1,
submitLoading: false,
}},
watch: {
input(value){
this.input = value;
this.recalculate();
},
creatureId() {
Tracker.afterFlush(() => this.recalculate())
},
historyIndex(i) {
if (typeof this.history[i] === 'string') {
this.input = this.history[i];
}
}
},
methods: {
submit() {
if (!this.input) return;
if (this.submitLoading) return;
const log = {
roll: this.input,
};
if (this.tabletopId) log.tabletopId = this.tabletopId;
if (this.creatureId) log.creatureId = this.creatureId;
this.submitLoading = true;
logRoll.call(log, (error) => {
this.submitLoading = false;
if (!error) {
this.addHistory(this.input);
this.input = '';
this.inputError = undefined;
return;
}
this.inputError = error.message || error.toString();
console.error(error);
});
},
addHistory(string) {
// Don't add duplicates back to back in history
if (string === this.history[this.history.length - 1]) return;
this.history.push(string);
if (this.history.length > 50) this.history.shift();
this.historyIndex = this.history.length;
},
recalculate() {
this.inputHint = this.inputError = undefined;
if (!this.input) return;
let result;
try {
result = parse(value);
result = parse(this.input);
} catch (e){
if (e.constructor.name === 'EndOfInputError'){
this.inputError = '...';
@@ -83,26 +135,29 @@ export default {
return;
}
},
},
methods: {
submit(){
if (this.inputError || !this.input) return;
logRoll.call({
roll: this.input,
creatureId: this.creatureId,
}, (error) => {
if (error) console.error(error);
});
this.input = '';
incrementHistory() {
if (this.historyIndex < this.history.length) {
this.historyIndex += 1;
}
},
decrementHistory() {
if (this.historyIndex > 0) {
this.historyIndex -= 1;
}
}
},
// @ts-ignore
meteor: {
logs(){
return CreatureLogs.find({
creatureId: this.creatureId,
}, {
logs() {
const filter = {};
if (this.tabletopId) {
filter.tabletopId = this.tabletopId;
} else if (this.creatureId) {
filter.creatureId = this.creatureId;
}
return CreatureLogs.find(filter, {
sort: {date: -1},
limit: 20
limit: 100
});
},
creature(){

View File

@@ -2,6 +2,9 @@
<v-card
class="ma-2 log-entry"
>
<v-card-title v-if="showName && model.creatureName">
{{ model.creatureName }}
</v-card-title>
<v-card-text
v-if="model.text || (model.content && model.content.length)"
class="pa-2"
@@ -23,6 +26,7 @@ export default {
type: Object,
required: true,
},
showName: Boolean,
},
}
</script>

View File

@@ -0,0 +1,30 @@
<template
lang="html"
functional
>
<component
:is="model.type"
v-if="model && components[model.type]"
/>
<v-card v-else-if="model">
<v-card-title class="text--error">
A property card for the {{ model.type }} isn't defined. You should report this error.
</v-card-title>
</v-card>
</template>
<script lang="js">
import ActionCard from '/imports/client/ui/properties/components/actions/ActionCard.vue';
export default {
components: {
action: ActionCard,
},
props: {
model: {
type: Object,
default: undefined,
},
},
}
</script>

View File

@@ -14,7 +14,7 @@
class="mr-2"
:color="model.color || 'primary'"
:loading="doActionLoading"
:disabled="model.insufficientResources || !context.editPermission"
:disabled="model.insufficientResources || !context.editPermission || !!targetingError"
:roll-text="rollBonus"
:name="model.name"
:advantage="model.attackRoll && model.attackRoll.advantage"
@@ -36,7 +36,7 @@
class="mr-2"
:color="model.color || 'primary'"
:loading="doActionLoading"
:disabled="model.insufficientResources || !context.editPermission"
:disabled="model.insufficientResources || !context.editPermission || !!targetingError"
@click.stop="doAction"
>
<property-icon :model="model" />
@@ -53,12 +53,20 @@
{{ model.name || propertyName }}
</div>
<div class="action-sub-title layout align-center">
<div class="flex">
{{ model.actionType }}
</div>
<div v-if="Number.isFinite(model.usesLeft)">
{{ model.usesLeft }} uses
<div
v-if="targetingError"
class="flex error--text"
>
{{ targetingError }}
</div>
<template v-else>
<div class="flex">
{{ model.actionType }}
</div>
<div v-if="Number.isFinite(model.usesLeft)">
{{ model.usesLeft }} uses
</div>
</template>
</div>
</div>
</div>
@@ -149,6 +157,10 @@ export default {
type: Object,
required: true,
},
targets: {
type: Array,
default: undefined,
},
},
data() {
return {
@@ -185,6 +197,16 @@ export default {
actionTypeIcon() {
return `$vuetify.icons.${this.model.actionType}`;
},
targetingError(){
// Can always do an action without a target
if (!this.targets || !this.targets.length) return undefined;
if (this.targets.length > 1 && this.model.target !== 'multipleTargets'){
return 'Single target';
} else if (this.model.target === 'self' && this.targets[0] !== this.model.ancestors[0]._id){
return 'Can only target self';
}
return undefined;
}
},
meteor: {
children() {

View File

@@ -4,10 +4,11 @@
:class="insufficient && 'error--text'"
>
<div
v-if="model.quantity && model.quantity.value !== 1"
class="mr-2 text-no-wrap text-truncate"
style="min-width: 24px; text-align: center;"
>
{{ model.quantity && model.quantity.value }}
{{ model.quantity.value }}
</div>
<div
v-if="model.quantity && (typeof model.quantity.value !== 'string')"
@@ -15,6 +16,12 @@
>
{{ model.statName || model.variableName }}
</div>
<div
v-if="(typeof model.available) == 'number'"
class="text--disabled text-no-wrap text-truncate ml-1 flex-shrink-0"
>
({{ model.available }})
</div>
</div>
</template>

View File

@@ -27,28 +27,30 @@
:color="model.itemColor"
/>
<div
v-if="quantity !== 1"
class="mr-2 text-no-wrap"
style="min-width: 24px; text-align: center;"
>
<template v-if="quantity !== 1">
{{ model.available }} / {{ quantity }}
</template>
<template v-else>
{{ model.available }}
</template>
{{ quantity }}
</div>
<div
class="text-no-wrap text-truncate flex"
>
<template v-if="model.itemId">
{{ model.itemName }}
</template>
<span
v-else
class="error--text"
<template v-if="model.itemId">
<div
class="text-no-wrap text-truncate"
>
Select item
</span>
{{ model.itemName }}
</div>
<div
v-if="(typeof model.available) == 'number'"
class="text--disabled text-no-wrap text-truncate ml-1 flex-shrink-0"
>
({{ model.available }})
</div>
</template>
<div
v-else
class="error--text text-no-wrap text-truncate flex"
>
Select item
</div>
<v-icon
v-if="context.editPermission"

View File

@@ -19,34 +19,18 @@
<v-flex
style="height: 24px; flex-basis: 300px; flex-grow: 100;"
>
<div
column
align-center
<health-bar-progress
:model="model"
style="cursor: pointer;"
class="bar"
@click="edit"
>
<div
style="height: 24px; width: 100%; position: relative; transition: background-color 0.5s ease;"
:style="{
backgroundColor: barBackgroundColor
class="value"
:class="{
'white--text': isTextLight,
'black--text': !isTextLight,
}"
>
<div
class="filler"
style="height: 100%; transform-origin: left; transition: all 0.5s ease;"
:style="{
backgroundColor: barColor,
transform: `scaleX(${fillFraction})`,
}"
/>
<div
class="value"
:class="{
'white--text': isTextLight,
'black--text': !isTextLight,
}"
style="font-size: 15px;
style="font-size: 15px;
line-height: 24px;
font-weight: 600;
position: absolute;
@@ -55,11 +39,10 @@
right: 0;
bottom: 0;
text-align: center;"
>
{{ model.value }} / {{ model.total }}
</div>
>
{{ model.value }} / {{ model.total }}
</div>
</div>
</health-bar-progress>
<v-menu
v-model="editing"
absolute
@@ -85,11 +68,13 @@
<script lang="js">
import IncrementMenu from '/imports/client/ui/components/IncrementMenu.vue';
import isDarkColor from '/imports/client/ui/utility/isDarkColor';
import HealthBarProgress from '/imports/client/ui/properties/components/attributes/HealthBarProgress.vue';
import chroma from 'chroma-js';
export default {
components: {
IncrementMenu
IncrementMenu,
HealthBarProgress,
},
inject: {
theme: {

View File

@@ -0,0 +1,69 @@
<template lang="html">
<div
class="bar"
@click="e => $emit('click', e)"
>
<div
style="width: 100%; position: relative; transition: background-color 0.5s ease;"
:style="{
backgroundColor: barBackgroundColor,
height: `${height}px`,
}"
>
<div
class="filler"
style="height: 100%; transform-origin: left; transition: all 0.5s ease;"
:style="{
backgroundColor: barColor,
transform: `scaleX(${fillFraction})`,
}"
/>
<slot />
</div>
</div>
</template>
<script lang="js">
import chroma from 'chroma-js';
export default {
props: {
model: {
type: Object,
required: true,
},
height: {
type: Number,
default: 24,
},
},
computed: {
fillFraction() {
let fraction = this.model.value / this.model.total;
if (fraction < 0) fraction = 0;
if (fraction > 1) fraction = 1;
return fraction;
},
color() {
return this.model.color || this.$vuetify.theme.currentTheme.primary
},
barColor() {
const fraction = this.model.value / this.model.total;
if (!Number.isFinite(fraction)) return this.color;
if (fraction > 0.5) {
return this.color;
} else if (this.model.healthBarColorMid && this.model.healthBarColorLow) {
return chroma.mix(this.model.healthBarColorLow, this.model.healthBarColorMid, fraction * 2).hex();
} else if (this.model.healthBarColorMid) {
return this.model.healthBarColorMid;
}
return this.color;
},
barBackgroundColor() {
return chroma(this.barColor)
.darken(1.5)
.desaturate(1.5)
.hex();
},
},
}
</script>

View File

@@ -202,9 +202,7 @@ RouterFactory.configure(router => {
meta: {
title: 'Print Character Sheet',
},
},
/* Not ready for prime time <3
{
}, {
path: '/tabletops',
name: 'tabletops',
component: Tabletops,
@@ -218,9 +216,7 @@ RouterFactory.configure(router => {
rightDrawer: TabletopRightDrawer,
},
beforeEnter: ensureLoggedIn,
},
*/
{
}, {
path: '/friends',
components: {
default: NotImplemented,

View File

@@ -7,14 +7,22 @@
background: #151515;
}
.card-background .v-tabs-items.theme--dark {
background: #151515;
}
.theme--light .card-background {
background: #f6f6f6;
}
.card-background .v-tabs-items.theme--light {
background: #f6f6f6;
}
.theme--dark .card-raised-background {
background: #1d1d1d;
}
.theme--light .card-raised-background {
background: #fafafa;
}
}

View File

@@ -0,0 +1,97 @@
<template>
<dialog-base>
<template #toolbar>
<v-toolbar-title>
{{ creature && creature.name }}
</v-toolbar-title>
</template>
<template #unwrapped-content>
<character-sheet
show-menu-button
embedded
:creature-id="creatureId"
/>
</template>
<v-bottom-navigation
slot="actions"
shift
mandatory
class="bottom-nav-btns"
style="position: relative;"
:value="$store.getters.tabById(creatureId)"
@change="e => $store.commit(
'setTabForCharacterSheet',
{id: creatureId, tab: e}
)"
>
<v-btn>
<span>Stats</span>
<v-icon>mdi-chart-box</v-icon>
</v-btn>
<v-btn>
<span>Actions</span>
<v-icon>mdi-lightning-bolt</v-icon>
</v-btn>
<v-btn v-if="!creature.settings.hideSpellsTab">
<span>Spells</span>
<v-icon>mdi-fire</v-icon>
</v-btn>
<v-btn>
<span>Inventory</span>
<v-icon>mdi-cube</v-icon>
</v-btn>
<v-btn>
<span>Features</span>
<v-icon>mdi-text</v-icon>
</v-btn>
<v-btn>
<span>Journal</span>
<v-icon>mdi-book-open-variant</v-icon>
</v-btn>
<v-btn>
<span>Build</span>
<v-icon>mdi-wrench</v-icon>
</v-btn>
</v-bottom-navigation>
</dialog-base>
</template>
<script lang="js">
import DialogBase from '/imports/client/ui/dialogStack/DialogBase.vue';
import CharacterSheet from '/imports/client/ui/creature/character/CharacterSheet.vue';
import Creatures from '/imports/api/creature/creatures/Creatures.js';
export default {
components: {
DialogBase,
CharacterSheet,
},
props: {
creatureId: {
type: String,
required: true,
},
},
meteor: {
creature() {
if (!this.creatureId) return;
return Creatures.findOne(this.creatureId);
},
},
}
</script>
<style lang="css" scoped>
.bottom-nav-btns {
margin: -8px;
box-shadow: none !important;
flex-grow: 1;
max-width: unset !important;
}
.bottom-nav-btns > .v-btn{
min-width: 0 !important;
padding: 0 !important;
flex: 1 1 auto !important;
font-size: 0.6rem !important;
}
</style>

View File

@@ -0,0 +1,394 @@
<template lang="html">
<v-sheet
class="action-card overflow-y-auto"
rounded
:class="cardClasses"
:data-id="model._id"
>
<div class="layout align-center px-3">
<div
class="avatar"
:style="{ opacity: active ? '' : '0.5'}"
>
<roll-popup
v-if="rollBonus"
:icon="!active"
:outlined="!active"
:fab="active"
style="letter-spacing: normal;"
class="mr-2"
:no-click="!active"
:style="{
fontSize: active ? '24px' : '16px'
}"
:large="active"
:color="model.color || 'primary'"
:loading="doActionLoading"
:disabled="model.insufficientResources || !context.editPermission || !!targetingError"
:roll-text="rollBonus"
:name="model.name"
:advantage="model.attackRoll && model.attackRoll.advantage"
@roll="doAction"
>
<template v-if="rollBonus && !rollBonusTooLong">
{{ rollBonus }}
</template>
<property-icon
v-else
:model="model"
/>
</roll-popup>
<v-btn
v-else
:icon="!active"
:outlined="!active"
:fab="active"
style="letter-spacing: normal;"
class="mr-2"
:style="{
fontSize: active ? '24px' : '16px'
}"
:large="active"
:color="model.color || 'primary'"
:loading="doActionLoading"
:disabled="model.insufficientResources || !context.editPermission || !!targetingError"
v-on="active ? {
click: e => {
if (!active) return;
e.stopPropagation();
doAction({});
}
} : {}"
>
<template v-if="rollBonus && !rollBonusTooLong">
{{ rollBonus }}
</template>
<property-icon
v-else
:model="model"
/>
</v-btn>
</div>
<div
class="action-header flex layout column justify-center pl-1"
style="height: 72px; cursor: pointer;"
@mouseover="() => { if (active) hovering = true }"
@mouseleave="() => { if (active) hovering = false }"
@click="(e) => { if (active) { $emit('deactivate'); e.stopPropagation() } }"
>
<div class="action-title my-1">
{{ model.name || propertyName }}
</div>
<div class="action-sub-title layout align-center">
<div
v-if="targetingError"
class="flex error--text"
>
{{ targetingError }}
</div>
<template v-else>
<div class="flex">
{{ model.actionType }}
</div>
<div v-if="Number.isFinite(model.usesLeft)">
{{ model.usesLeft }} uses
</div>
</template>
</div>
</div>
<v-btn
v-if="active"
icon
class="flex-grow-0"
@click.stop="openPropertyDetails(model._id)"
>
<v-icon>mdi-window-restore</v-icon>
</v-btn>
</div>
<div class="px-3 pb-3">
<template
v-if="model.resources && model.resources.attributesConsumed.length ||
model.resources.itemsConsumed.length"
>
<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="active && model.summary"
class="my-2"
/>
</template>
<template v-if="active && model.summary">
<markdown-text :markdown="model.summary.value || model.summary.text" />
</template>
<v-divider v-if="active && children && children.length" />
<tree-node-list
v-if="active && children && children.length"
start-expanded
:children="children"
@selected="e => $emit('sub-click', e)"
/>
</div>
<card-highlight :active="hovering" />
<div
style="position: absolute; top: 0; left: 0; right: 0; bottom: 0; cursor: pointer"
:style="{pointerEvents: active ? 'none' : ''}"
@mouseover="() => { if (!active) hovering = true }"
@mouseleave="hovering = false"
@click="() => { if (!active) $emit('activate') }"
/>
</v-sheet>
</template>
<script lang="js">
import { getPropertyName } from '/imports/constants/PROPERTIES.js';
import numberToSignedString from '/imports/api/utility/numberToSignedString.js';
import doAction from '/imports/api/engine/actions/doAction.js';
import AttributeConsumedView from '/imports/client/ui/properties/components/actions/AttributeConsumedView.vue';
import ItemConsumedView from '/imports/client/ui/properties/components/actions/ItemConsumedView.vue';
import PropertyIcon from '/imports/client/ui/properties/shared/PropertyIcon.vue';
import RollPopup from '/imports/client/ui/components/RollPopup.vue';
import MarkdownText from '/imports/client/ui/components/MarkdownText.vue';
import { snackbar } from '/imports/client/ui/components/snackbars/SnackbarQueue.js';
import CardHighlight from '/imports/client/ui/components/CardHighlight.vue';
import TreeNodeList from '/imports/client/ui/components/tree/TreeNodeList.vue';
import { nodeArrayToTree } from '/imports/api/parenting/nodesToTree.js';
import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js';
import { some } from 'lodash';
export default {
components: {
AttributeConsumedView,
ItemConsumedView,
MarkdownText,
PropertyIcon,
RollPopup,
CardHighlight,
TreeNodeList,
},
inject: {
context: {
default: {},
},
theme: {
default: {
isDark: false,
},
},
},
props: {
model: {
type: Object,
required: true,
},
targets: {
type: Array,
default: undefined,
},
active: Boolean,
},
data() {
return {
activated: undefined,
doActionLoading: false,
hovering: false,
}
},
computed: {
rollBonus() {
if (!this.model.attackRoll) return;
return numberToSignedString(this.model.attackRoll.value);
},
rollBonusTooLong() {
return this.rollBonus && this.rollBonus.length > 3;
},
propertyName() {
return getPropertyName(this.model.type);
},
cardClasses() {
return {
'theme--dark': this.theme.isDark,
'theme--light': !this.theme.isDark,
'muted-text': this.model.insufficientResources,
'active': this.activated,
'tabletop-active': this.active,
'elevation-8': this.hovering,
}
},
actionTypeIcon() {
return `$vuetify.icons.${this.model.actionType}`;
},
targetingError() {
if (!this.active) return;
const targets = this.targets || [];
if (this.model.target === 'singleTarget' && targets.length === 0) {
return 'Select target';
} else if (targets.length > 1 && this.model.target !== 'multipleTargets'){
return 'Single target only';
} else if (this.model.target === 'self' && targets.length > 0){
return 'Can only target self';
}
return undefined;
}
},
meteor: {
children() {
const indicesOfTerminatingProps = [];
const decendants = CreatureProperties.find({
'ancestors.id': this.model._id,
'removed': { $ne: true },
}, {
sort: {order: 1}
}).map(prop => {
// Get all the props we don't want to show the decendants of and
// where they might appear in the ancestor list
if (prop.type === 'buff' || prop.type === 'folder') {
indicesOfTerminatingProps.push({
id: prop._id,
ancestorIndex: prop.ancestors.length,
});
}
return prop;
}).filter(prop => {
// Filter out folders entirely
if (prop.type === 'folder') return false;
// Filter out decendants of terminating props
return !some(indicesOfTerminatingProps, buffIndex => {
return prop.ancestors[buffIndex.ancestorIndex]?.id === buffIndex.id;
});
});
return nodeArrayToTree(decendants);
},
},
methods: {
click(e) {
this.$emit('click', e);
},
doAction({ advantage }) {
this.doActionLoading = true;
this.shwing();
doAction.call({
actionId: this.model._id,
targetIds: this.targets,
scope: {
$attackAdvantage: advantage,
}
}, error => {
this.doActionLoading = false;
this.$emit('deactivate');
if (error) {
console.error(error);
snackbar({ text: error.reason });
}
});
},
shwing() {
this.activated = true;
setTimeout(() => {
this.activated = undefined;
}, 150);
},
openPropertyDetails() {
this.$store.commit('pushDialogStack', {
component: 'creature-property-dialog',
elementId: `${this.model._id}`,
data: {_id: this.model._id},
});
},
}
}
</script>
<style lang="css" scoped>
.action-card {
transition: box-shadow .4s cubic-bezier(0.25, 0.8, 0.25, 1),
transform 0.075s ease,
width .3s ease,
margin-top .3s ease,
height .3s ease;
max-width: 100vw;
position: relative;
}
.action-card.tabletop-active {
margin-top: -100px;
width: 320px;
height: 300px;
}
.action-card.active {
transform: scale(0.92);
}
.action-card-container {
transition: width .3s ease;
}
.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;
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;
}
.action-card .property-description>p:last-of-type {
margin-bottom: 0;
}
.action-card .v-btn--icon {
transition: all .3s ease, height .3s ease;
}
</style>

View File

@@ -1,55 +0,0 @@
<template lang="html">
<div class="action-cards">
<action-card
v-for="action in actions"
:key="action._id"
:model="action"
/>
</div>
</template>
<script lang="js">
import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties';
import ActionCard from '/imports/client/ui/properties/components/actions/ActionCard.vue';
function getProperties(ancestorId, type){
if (!ancestorId) return [];
return CreatureProperties.find({
'ancestors.id': ancestorId,
type,
removed: {$ne: true},
inactive: {$ne: true},
}, {
sort: {order: 1}
});
}
export default {
components: {
ActionCard,
},
props: {
creatureId: {
type: String,
default: undefined,
},
},
data(){ return {
actionType: 'action',
}},
meteor: {
actions(){
return getProperties(this.creatureId, 'action');
},
attacks(){
return getProperties(this.creatureId, 'attack');
},
spells(){
return getProperties(this.creatureId, 'spell');
},
}
}
</script>
<style lang="css" scoped>
</style>

View File

@@ -1,56 +1,134 @@
<template lang="html">
<v-container
class="tabletop"
fluid
<div
class="tabletop layout column"
style="height: 100%;"
>
<v-row
dense
class="initiative-row"
style="flex-wrap: nowrap; overflow-x: auto;"
<tabletop-map
class="play-area"
style="
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
"
/>
<v-container
fluid
>
<tabletop-creature-card
v-for="creature in creatures"
:key="creature._id"
:model="creature"
/>
<v-card
class="layout column justify-center align-center"
style="height: 150px; min-width: 120px;"
data-id="select-creatures"
hover
@click="addCreature"
<v-row
dense
class="initiative-row flex-grow-0"
style="flex-wrap: nowrap; overflow-x: auto; padding-bottom: 50px;"
@wheel="transformScroll($event)"
>
<div class="flex layout justify-center align-center">
<v-icon>mdi-plus</v-icon>
<tabletop-creature-card
v-for="creature in creatures"
:key="creature._id"
:model="creature"
:active="activeCreatureId === creature._id"
:targeted="targets.includes(creature._id)"
:show-target-btn="targets.includes(creature._id) || moreTargets"
v-on="(!activeActionId || (targets.includes(creature._id) || moreTargets)) ? {
click: () => {
if (activeActionId) {
if (targets.includes(creature._id)) {
untarget(creature._id)
} else {
if (moreTargets) targets.push(creature._id);
}
} else {
activeCreatureId = creature._id;
targets = [];
activeActionId = undefined;
}
}
} : {}"
@target="targets.push(creature._id)"
@untarget="untarget(creature._id)"
/>
<div
class="layout column ma-1 flex-grow-0"
>
<v-btn
data-id="select-creatures"
class="mb-2"
@click="addCreature"
>
<v-icon left>
mdi-plus
</v-icon>
Add Character
</v-btn>
<v-btn disabled>
<v-icon left>
mdi-plus
</v-icon>
Add Creature
</v-btn>
</div>
<v-card-title>
Add<br>creature
</v-card-title>
</v-card>
</v-row>
<tabletop-map class="play-area" />
<section class="action-row">
<mini-character-sheet />
<tabletop-action-cards />
</section>
</v-container>
</v-row>
</v-container>
<v-footer
inset
class="pa-0"
style="
background: none;
box-shadow: none;
position: absolute;
left: 0;
bottom:0;
right: 0;
overflow-x: auto;
"
>
<v-slide-y-reverse-transition mode="out-in">
<selected-creature-bar
:key="activeCreatureId"
:creature-id="activeCreatureId"
/>
</v-slide-y-reverse-transition>
</v-footer>
</div>
</template>
<script lang="js">
import addCreaturesToTabletop from '/imports/api/tabletop/methods/addCreaturesToTabletop';
import TabletopCreatureCard from '/imports/client/ui/tabletop/TabletopCreatureCard.vue';
import TabletopMap from '/imports/client/ui/tabletop/TabletopMap.vue';
import Creatures from '/imports/api/creature/creatures/Creatures';
import TabletopActionCards from '/imports/client/ui/tabletop/TabletopActionCards.vue';
import Creatures from '/imports/api/creature/creatures/Creatures.js';
import MiniCharacterSheet from '/imports/client/ui/creature/character/MiniCharacterSheet.vue';
import { snackbar } from '/imports/client/ui/components/snackbars/SnackbarQueue';
import { snackbar } from '/imports/client/ui/components/snackbars/SnackbarQueue.js';
import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js';
import { assertEditPermission } from '/imports/api/creature/creatures/creaturePermissions.js';
import ActionCard from '/imports/client/ui/tabletop/TabletopActionCard.vue';
import SelectedCreatureBar from '/imports/client/ui/tabletop/selectedCreatureBar/SelectedCreatureBar.vue';
const getProperties = function (creatureId, selector = {}) {
return CreatureProperties.find({
'ancestors.id': {
$eq: creatureId,
},
inactive: { $ne: true },
removed: { $ne: true },
overridden: { $ne: true },
$nor: [
{ hideWhenTotalZero: true, total: 0 },
{ hideWhenValueZero: true, value: 0 },
],
...selector,
}, {
sort: { order: 1 }
});
}
export default {
components: {
TabletopCreatureCard,
TabletopMap,
TabletopActionCards,
ActionCard,
MiniCharacterSheet,
SelectedCreatureBar,
},
props: {
model: {
@@ -58,9 +136,20 @@ export default {
required: true,
},
},
reactiveProvide: {
name: 'context',
include: ['editPermission'],
},
data() {
return {
activeCreature: undefined,
activeCreatureId: undefined,
activeActionId: undefined,
targets: [],
}
},
watch: {
activeCreatureId(id) {
this.$root.$emit('active-tabletop-character-change', id);
}
},
meteor: {
@@ -69,9 +158,29 @@ export default {
return [this.model._id];
},
},
creatures() {
creatures(){
return Creatures.find({ tabletop: this.model._id });
},
actions(){
return getProperties(this.activeCreatureId, { type: 'action', actionType: { $ne: 'event'} });
},
moreTargets(){
const activeAction = CreatureProperties.findOne(this.activeActionId);
if (!activeAction) return;
if (activeAction.target === 'singleTarget') {
return this.targets.length === 0;
} else if (activeAction.target === 'multipleTargets') {
return true;
}
},
editPermission(){
try {
assertEditPermission(this.activeCreatureId, Meteor.userId());
return true;
} catch (e) {
return false;
}
},
},
methods: {
addCreature() {
@@ -91,16 +200,53 @@ export default {
});
},
});
},
openCharacterSheetDialog(){
this.$store.commit('pushDialogStack', {
component: 'character-sheet-dialog',
elementId: 'mini-character-sheet',
data: {
creatureId: this.activeCreatureId,
},
});
},
clickProperty({_id}){
this.$store.commit('pushDialogStack', {
component: 'creature-property-dialog',
elementId: `${_id}`,
data: {_id},
});
},
transformScroll(event) {
if (!event.deltaY) {
return;
}
event.currentTarget.scrollLeft += event.deltaY;
event.preventDefault();
},
untarget(id){
const index = this.targets.indexOf(id);
if (index > -1) {
this.targets.splice(index, 1);
}
}
}
},
}
</script>
<style lang="css" scoped>
.initiative-row>.v-card {
flex-grow: 0;
flex-shrink: 0;
height: 162px;
width: 100px;
margin: 4px;
}
.action-row > div {
flex-grow: 0;
flex-shrink: 0;
height: 120px;
width: 200px;
margin: 4px;
}
</style>

View File

@@ -1,37 +1,146 @@
<template lang="html">
<v-card
style="height: 150px; min-width: 120px;"
:style="`height: ${height}px; width: ${width}px; overflow: hidden;`"
class="tabletop-creature-card"
:class="{ active }"
:hover="hasClickListener"
:elevation="active ? 8 : 2"
@mouseover="() => { if (hasClickListener) hover = true; }"
@mouseleave="hover = false"
v-on="hasClickListener ? {click: () => $emit('click')} : {}"
>
<v-img
:src="model.picture"
aspect-ratio="1"
:src="model.picture || '/images/ui/missing-portrait.png'"
:lazy-src="loadingImg"
:height="height"
:width="width"
class="align-end"
:class="{placeholder: !model.picture}"
gradient="to bottom, rgba(0,0,0,.1), rgba(0,0,0,.5)"
position="top center"
/>
<div
class="small-title"
>
{{ model.name }}
<v-card-title
class="small-title"
v-text="model.name"
/>
<health-bar-progress
v-for="bar in healthBars"
:key="bar._id"
:model="bar"
:height="4"
style="opacity: 0.7; margin-top: 2px"
/>
</v-img>
<card-highlight :active="hover" />
<div class="d-flex justify-center">
<v-scale-transition>
<v-btn
v-if="showTargetBtn"
:color="targeted ? 'accent' : ''"
:elevation="targeted ? 8 : 2"
fab
small
@click.stop="targeted ? $emit('untarget') : $emit('target')"
>
<v-icon>{{ targeted ? 'mdi-target' : 'mdi-target' }}</v-icon>
</v-btn>
</v-scale-transition>
</div>
</v-card>
</template>
<script lang="js">
import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js';
import CardHighlight from '/imports/client/ui/components/CardHighlight.vue';
import HealthBarProgress from '/imports/client/ui/properties/components/attributes/HealthBarProgress.vue';
export default {
components: {
CardHighlight,
HealthBarProgress,
},
props: {
model: {
type: Object,
required: true,
},
height: {
type: Number,
default: 100,
},
width: {
type: Number,
default: 75,
},
active: Boolean,
targeted: Boolean,
showTargetBtn: Boolean,
},
data(){return {
hover: false,
loadingImg: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAXdJREFUWEftlq1vAkEQxedSB7pYDklBNsgmyCa4Cv5GBI6k8tJKUglULrWcBlvySA4B+/HeAcV07O3t/ObtvNnNHsyc3TEyAGRm+T0Yfs3WFwE0mk3bbbe12WsDtDsda+e5PbZaR4C1c/a9XEowtQC6vZ499ftniaCECiEDoPLnwSBa5WdRWLnZUErIAK+jkeHcY4HkgGBCAmCqr5KyKkgAobP3VXoTAJw9VGBitVhQjpAUeBkOD7Zj4sc5+5rPk0slAOUIkBwQqZAAUD1UYOImALAfbMjE+2xGjWhJASRmGpFtQOwnAzAqTCcTRqTDGhkAP8UGklK9BIDKq9svZUVcShjHZVkmnUApoNjvVHvAfBRFsCGTAJckr2AAAVf4Igqg+D7VdaHJGAVQRm8KIKRCFOBtPE7tK3333ZBBAOXuZyl8Fv1TAF8fBAGYkctWXq3zPdWCANdswJgdgwDM41NVwOeEf4BoE6be/3WO4PSdeARQN7vm+j0BlQ9wWvLB6AAAAABJRU5ErkJggg==',
}
},
computed: {
hasClickListener() {
return this.$listeners && !!this.$listeners.click;
},
},
meteor: {
healthBars() {
const folderIds = CreatureProperties.find({
'ancestors.id': this.model._id,
type: 'folder',
groupStats: true,
hideStatsGroup: true,
removed: { $ne: true },
inactive: { $ne: true },
}, { fields: { _id: 1 } }).map(folder => folder._id);
// Get the properties that need to be shown as a health bar
return CreatureProperties.find({
'ancestors.id': this.model._id,
'parent.id': {
$nin: folderIds,
},
type: 'attribute',
attributeType: 'healthBar',
healthBarNoDamage: { $ne: true },
inactive: { $ne: true } ,
removed: { $ne: true },
}, {
sort: {
order: 1,
},
});
}
}
}
</script>
<style lang="css" scoped>
.small-title {
font-size: 14px;
padding: 4px;
font-size: 12px;
padding: 4px 4px 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
display: block;
line-height: normal;
}
.active {
transform: scale(1.2);
margin-left: 12px !important;
margin-right: 12px !important;
transform-origin: top center;
}
.tabletop-creature-card {
transition: all .15s ease;
}
</style>
<style lang="css">
.tabletop-creature-card .v-btn {
transition: all .3s ease;
}
</style>

View File

@@ -1,52 +1,38 @@
<template lang="html">
<div class="tabletop-log">
<div class="messages layout column justify-end align-end">
<div
v-for="message in messages"
:key="message._id"
class="message"
>
{{ message.content }}
</div>
</div>
<v-textarea
v-model="messageContent"
@keyup.enter.prevent="sendMessage"
/>
</div>
<character-log
:tabletop-id="tabletopId"
:creature-id="activeCreatureId"
/>
</template>
<script lang="js">
import Messages, { sendMessage } from '/imports/api/tabletop/Messages';
import { insertTabletopLog } from '/imports/api/creature/log/CreatureLogs';
import CharacterLog from '/imports/client/ui/log/CharacterLog.vue';
export default {
components: {
CharacterLog,
},
inject: {
context: {
default: {},
},
},
props: {
tabletopId: {
type: String,
required: true,
default: undefined,
},
},
data(){ return {
messageContent: '',
}},
meteor: {
messages() {
return Messages.find({
tabletopId: this.tabletopId,
}, {
sort: {
timeStamp: 1,
},
});
data() {
return {
activeCreatureId: undefined,
}
},
methods: {
sendMessage(){
sendMessage.call({
content: this.messageContent,
tabletopId: this.tabletopId,
});
this.messageContent = '';
},
mounted() {
this.$root.$on('active-tabletop-character-change', (id) => {
this.activeCreatureId = id;
});
},
}
</script>

View File

@@ -1,11 +1,134 @@
<template lang="html">
<div class="tabletop-map" />
<template>
<div>
<canvas
ref="map"
class="tabletop-map"
/>
</div>
</template>
<script lang="js">
import * as THREE from 'three';
import { Tracker } from 'meteor/tracker'
import TabletopObjects from '/imports/api/tabletop/TabletopObjects.js';
import TabletopMaps from '/imports/api/tabletop/TabletopMaps.js';
import { OrbitControls } from '/imports/api/tabletop/three/OrbitControls.js';
import { DragControls } from 'three/examples/jsm/controls/DragControls.js';
import { MapControls } from 'three/examples/jsm/controls/MapControls.js';
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader';
const maps = [
{
name: 'first map',
position: { x: 0, y: 0 },
width: 25,
height: 25,
texture: '/images/battlemap.webp',
}
];
export default {
mounted(){
const scene = new THREE.Scene();
const perspectiveCam = new THREE.PerspectiveCamera( 75, window.innerWidth / window.innerHeight, 0.1, 1000 );
perspectiveCam.position.z = 5;
const orthoCam = new THREE.OrthographicCamera( -2, 2, 2, -2, 0, 1000 );
orthoCam.position.z = 500
const activeCamera = orthoCam;
const renderer = new THREE.WebGLRenderer({ canvas: this.$refs.map });
renderer.shadowMap.enabled = true;
activeCamera.up.set( 0, 0, 1 ); // Use z as upwards
const controls = new MapControls( activeCamera, renderer.domElement );
maps.forEach(map => {
const texture = new THREE.TextureLoader().load( map.texture );
const material = new THREE.MeshStandardMaterial({ map: texture });
material.map.needsUpdate = true;
const plane = new THREE.Mesh(new THREE.PlaneGeometry(map.width, map.height), material);
plane.overdraw = true;
plane.receiveShadow = true;
scene.add(plane);
});
// Example model
const loader = new STLLoader()
loader.load(
'/models/example-mini.stl',
function (geometry) {
const material = new THREE.MeshStandardMaterial({
color: 0xb2ffc8,
});
const mesh = new THREE.Mesh(geometry, material);
mesh.castShadow = true;
scene.add(mesh)
const light = new THREE.PointLight()
light.position.set(0, 0, 50)
light.castShadow = true;
scene.add(light)
const directionalLight = new THREE.DirectionalLight(0xffffff, 2);
directionalLight.position.set(20, 50, 100)
directionalLight.castShadow = true;
scene.add( directionalLight );
const dragControls = new DragControls([mesh], activeCamera, renderer.domElement);
dragControls.addEventListener( 'dragstart', function ( event ) {
controls.enabled = false;
});
dragControls.addEventListener( 'dragend', function ( event ) {
controls.enabled = true;
});
},
(xhr) => {
console.log((xhr.loaded / xhr.total) * 100 + '% loaded')
},
(error) => {
console.log(error)
}
)
//controls.enabled = false;
//});
/*
const axesHelper = new THREE.AxesHelper( 5 );
scene.add( axesHelper );
*/
function resizeCanvasToDisplaySize() {
const canvas = renderer.domElement;
// look up the size the canvas is being displayed
const width = canvas.clientWidth;
const height = canvas.clientHeight - 50;
// adjust displayBuffer size to match
if (canvas.width !== width || canvas.height !== height) {
// you must pass false here or three.js sadly fights the browser
perspectiveCam.aspect = width / height;
orthoCam.left= width / -200;
orthoCam.right = width / 200;
orthoCam.top = height / 200;
orthoCam.bottom = height / -200;
perspectiveCam.updateProjectionMatrix();
orthoCam.updateProjectionMatrix();
controls.update();
renderer.setSize(width, height, false);
}
}
function animate() {
resizeCanvasToDisplaySize();
renderer.render( scene, activeCamera );
requestAnimationFrame( animate );
}
animate();
},
}
</script>
<style lang="css" scoped>
</style>
<style>
.tabletop-map {
height: 100%;
width: 100%;
}
</style>

View File

@@ -0,0 +1,53 @@
<template lang="html">
<v-btn
icon
:plain="!selected"
large
tile
:outlined="selected"
:color="prop && prop.color"
@click.prevent="$emit('click', $event)"
@mouseenter="$emit('mouseenter', $event)"
@mouseleave="$emit('mouseleave', $event)"
>
<property-icon
v-if="prop"
:model="prop"
:color="prop.color"
/>
<v-icon v-else-if="icon">
{{ icon }}
</v-icon>
<v-icon v-else>
mdi-help
</v-icon>
</v-btn>
</template>
<script lang="js">
import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js';
import PropertyIcon from '/imports/client/ui/properties/shared/PropertyIcon.vue';
export default {
components: {
PropertyIcon,
},
props: {
propId: {
type: String,
default: undefined,
},
icon: {
type: String,
default: undefined,
},
selected: Boolean,
},
meteor: {
prop() {
if (!this.propId) return;
return CreatureProperties.findOne(this.propId);
},
},
}
</script>

View File

@@ -0,0 +1,432 @@
<template lang="html">
<div
v-if="creatureId"
class="selected-creature-bar d-flex pa-3 align-end"
style="gap: 8px;"
>
<!--
<tabletop-buff-icons
creature-id="creatureId"
@select-icon="selectIcon"
/>
<tabletop-portrait
creature-id="creatureId"
@select-icon="selectIcon"
/>
-->
<v-menu
v-model="menuOpen"
v-click-outside="{
handler: clickOutsideMenu,
include: menuClickOutsideInclude,
}"
:position-x="menuX"
:position-y="menuY"
absolute
top
:nudge-left="150"
origin="center bottom"
:close-on-click="false"
:content-class="`tabletop-prop-menu rows-${rows}`"
:close-on-content-click="false"
>
<tabletop-action-card
v-if="selectedProp && selectedProp.type === 'action'"
style="width: 300px;"
:style="{
width: '300px',
opacity: selectedIcon ? 1 : 0.7,
transition: 'opacity 0.2s ease',
}"
:model="selectedProp"
/>
<v-card
v-else-if="activeIcon && activeIcon.tab"
style="width: 300px"
>
<v-card-title>
<v-icon left>
{{ activeIcon.icon }}
</v-icon>
{{ activeIcon.tabName }}
</v-card-title>
</v-card>
</v-menu>
<v-card
v-if="iconGroups.buffs"
class="buffs-card"
>
<div
v-for="(row, rowIndex) in iconGroups.buffs.rows"
:key="rowIndex"
class="d-flex"
>
<template
v-for="(icon, iconIndex) in row"
>
<creature-bar-icon
:key="icon.propId || iconIndex"
:prop-id="icon.propId"
:icon="icon.icon"
:selected="selectedIcon === icon"
:data-id="icon.propId || icon.standardId"
@click="e => selectIcon(e, icon)"
@mouseenter="e => hoverIcon(e, icon)"
@mouseleave="unHoverIcon(icon)"
/>
</template>
</div>
</v-card>
<v-card
class="creature-portrait"
:width="90"
:height="120"
>
<v-img
v-if="creature.picture"
:height="120"
:src="creature.picture"
position="top center"
/>
<div
v-else
class="fill-height d-flex align-center justify-center"
style="opacity: 0.2;"
>
<v-icon
size="90"
>
mdi-account
</v-icon>
</div>
</v-card>
<v-card
v-for="group in iconGroups"
:key="group.name"
>
<div
v-for="(row, rowIndex) in group.rows"
:key="rowIndex"
class="d-flex"
>
<template
v-for="(icon, iconIndex) in row"
>
<creature-bar-icon
:key="icon.propId || iconIndex"
:prop-id="icon.propId"
:icon="icon.icon"
:selected="selectedIcon === icon"
:data-id="icon.propId || icon.standardId"
@click="e => selectIcon(e, icon)"
@mouseenter="e => hoverIcon(e, icon)"
@mouseleave="unHoverIcon(icon)"
/>
</template>
</div>
</v-card>
<!--<tabletop-actions
creature-id="creatureId"
@select-icon="selectIcon"
/>
<tabletop-detail-popover />
-->
</div>
</template>
<script lang="js">
import Creatures from '/imports/api/creature/creatures/Creatures.js';
import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js';
import TabletopActionCard from '/imports/client/ui/tabletop/TabletopActionCard.vue';
import CreatureBarIcon from '/imports/client/ui/tabletop/selectedCreatureBar/CreatureBarIcon.vue';
//import TabletopPortrait from '/imports/client/ui/tabletop/selectedCreatureBar/TabletopPortrait.vue';
//import TabletopBuffIcons from '/imports/client/ui/tabletop/selectedCreatureBar/TabletopBuffIcons.vue';
//import TabletopActions from '/imports/client/ui/tabletop/selectedCreatureBar/TabletopActions.vue';
//import TabletopGroupedFolders from '/imports/client/ui/tabletop/selectedCreatureBar/TabletopGroupedFolders.vue';
//import TabletopResources from '/imports/client/ui/tabletop/selectedCreatureBar/TabletopResources.vue';
//import TabletopCreatureSheetTabs from '/imports/client/ui/tabletop/selectedCreatureBar/TabletopCreatureSheetTabs.vue';
//import TabletopDetailPopover from '/imports/client/ui/tabletop/selectedCreatureBar/TabletopDetailPopover.vue';
function splitToNChunks(inputArray, n) {
let result = [];
const array = [...inputArray] // Create shallow copy, because splice mutates array
for (let i = n; i > 0; i--) {
result.push(array.splice(0, Math.ceil(array.length / i)));
}
return result;
}
export default {
components: {
//TabletopPortrait,
//TabletopBuffIcons,
//TabletopActions,
//TabletopGroupedFolders,
//TabletopResources,
//TabletopCreatureSheetTabs,
CreatureBarIcon,
TabletopActionCard,
},
props: {
creatureId: {
type: String,
default: undefined,
},
},
data() {
return {
rows: 2,
hoveredIcon: undefined,
selectedIcon: undefined,
menuOpen: false,
menuX: 200,
menuY: window.innerHeight - 216,
};
},
computed: {
activeIcon() {
return this.selectedIcon || this.hoveredIcon;
}
},
watch: {
menuOpen(val) {
if (!val && this.selectIcon) {
this.selectedIcon = undefined;
}
},
},
methods: {
log(e) {
console.log(e);
},
hoverIcon(e, icon) {
if (this.selectedIcon) return;
// this.menuX = e.clientX - (e.clientX % 44);
const { left, right } = e.target.getBoundingClientRect();
const x = ( left + right ) / 2
this.menuX = x;
this.hoveredIcon = icon;
this.menuOpen = true;
},
unHoverIcon(icon) {
if (this.hoveredIcon === icon) {
this.hoveredIcon = undefined;
if (!this.selectedIcon) {
this.menuOpen = false;
}
}
},
selectIcon(e, icon) {
if (icon.tab) {
this.openCharacterSheet(icon.tab, icon.standardId);
return;
}
if (this.selectedIcon === icon) {
this.selectedIcon = undefined;
this.menuOpen = false;
return;
}
const { left, right } = e.target.getBoundingClientRect();
const x = ( left + right ) / 2
this.menuX = x;
this.selectedIcon = icon;
this.menuOpen = true;
},
clickOutsideMenu () {
this.menuOpen = false;
},
menuClickOutsideInclude() {
return [
document.querySelector('.selected-creature-bar'),
document.querySelector('.tabletop-prop-menu')
];
},
openCharacterSheet(tab, elementId) {
this.$store.commit(
'setTabForCharacterSheet',
{ id: this.creatureId, tab }
);
this.$store.commit('pushDialogStack', {
component: 'character-sheet-dialog',
elementId,
data: {
creatureId: this.creatureId,
},
});
},
},
meteor: {
creature() {
if (!this.creatureId) return;
return Creatures.findOne(this.creatureId)
},
selectedProp() {
const propId = this.activeIcon?.propId;
if (!propId) return;
return CreatureProperties.findOne(propId);
},
iconGroups() {
if (!this.creature) return;
const iconGroups = [];
// Get the standard icons
const standardIconsById = {
'cast-spell': {standardId: 'cast-spell', groupName: 'Standard Actions', icon: 'mdi-fire' },
'make-check': {standardId: 'make-check', groupName: 'Standard Actions', icon: 'mdi-radiobox-marked' },
'roll-dice': {standardId: 'roll-dice', groupName: 'Standard Actions', icon: 'mdi-dice-d20' },
'tab-stats': {standardId: 'tab-stats', groupName: 'Tabs', icon: 'mdi-chart-box', tab: 'stats', tabName: 'Stats' },
'tab-actions': {standardId: 'tab-actions', groupName: 'Tabs', icon: 'mdi-lightning-bolt', tab: 'actions', tabName: 'Actions' },
'tab-spells': this.creature?.settings?.hideSpellsTab ? undefined : {standardId: 'tab-spells', groupName: 'Tabs', icon: 'mdi-fire', tab: 'spells', tabName: 'Spells' },
'tab-inventory': {standardId: 'tab-inventory', groupName: 'Tabs', icon: 'mdi-cube', tab: 'inventory', tabName: 'Inventory' },
'tab-features': {standardId: 'tab-features', groupName: 'Tabs', icon: 'mdi-text', tab: 'features', tabName: 'Features' },
'tab-journal': {standardId: 'tab-journal', groupName: 'Tabs', icon: 'mdi-book-open-variant', tab: 'journal', tabName: 'Journal' },
'tab-build': {standardId: 'tab-build', groupName: 'Tabs', icon: 'mdi-wrench', tab: 'build', tabName: 'Build' },
};
// Get the folders that could hide a property
const folderIds = CreatureProperties.find({
'ancestors.id': this.creatureId,
type: 'folder',
groupStats: true,
hideStatsGroup: true,
removed: { $ne: true },
inactive: { $ne: true },
}, { fields: { _id: 1 } }).map(folder => folder._id);
// Get the properties that need to be shown as an icon
const filter = {
'ancestors.id': this.creatureId,
'parent.id': {
$nin: folderIds,
},
$and: [
{
$or: [
{ type: 'action' },
{ type: 'folder', groupStats: true },
{ type: 'attribute' },
{ type: 'toggle' },
{ type: 'buff' }
],
},
{
$or: [
{ inactive: { $ne: true } },
{ type: 'toggle' },
]
}
],
removed: { $ne: true },
};
if (this.creature.settings?.hideUnusedStats) {
filter.hide = { $ne: true };
}
// Get all the properties we wish to display, with just their IDs, and store them
const propsById = {};
const props = [];
CreatureProperties.find(filter, {
sort: { order: -1 },
fields: { _id: 1, type: 1 },
}).forEach(prop => {
props.push(prop);
propsById[prop._id] = prop;
});
// Using the creature's custom icon groups, collect the props into groups
this.creature.tabletopSettings?.iconGroups.forEach(group => {
const iconList = [];
group.iconIds?.forEach(id => {
if (propsById[id]) {
const prop = propsById[id];
prop._placedInGroup = true;
iconList.push({ propId: prop._id });
} else if (standardIconsById[id]) {
const standardIcon = standardIconsById[id];
standardIcon._placedInGroup = true;
iconList.push(standardIcon);
}
});
iconGroups.push({
name: group.name,
iconList,
});
});
// Default groups
let groupsByName = {};
let defaultGroups = [];
// Add default groups for props that have not yet been collected into custom groups
props.forEach(prop => {
if (prop._placedInGroup) return;
let groupName;
switch (prop.type) {
case 'buff': groupName = 'Buffs'; break;
case 'action': groupName = 'Actions'; break;
case 'resource': groupName = 'Resources'; break;
case 'folder': groupName = 'Folders'; break;
}
if (!groupName) return;
if (!groupsByName[groupName]) {
groupsByName[groupName] = { name: groupName, iconList: [] };
if (groupName !== 'Buffs') { // don't add buffs to the default groups, it is handled differently
defaultGroups.push(groupsByName[groupName]);
}
}
groupsByName[groupName].iconList.push({ propId: prop._id });
});
// Add default groups for standard icons
for (let key in standardIconsById) {
const standardIcon = standardIconsById[key];
if (!standardIcon) continue;
if (standardIcon._placedInGroup) continue;
const groupName = standardIcon.groupName || 'no';
if (!groupsByName[groupName]) {
groupsByName[groupName] = { name: groupName, iconList: [] };
defaultGroups.push(groupsByName[groupName]);
}
groupsByName[groupName].iconList.push(standardIcon);
}
iconGroups.push(...defaultGroups);
// Store a specific reference to buffs outside of the list order
iconGroups.buffs = groupsByName['Buffs'];
// Divide the icons into rows
iconGroups.forEach(group => {
group.rows = splitToNChunks(group.iconList, this.rows);
});
if (iconGroups.buffs) {
iconGroups.buffs.rows = splitToNChunks(iconGroups.buffs.iconList, this.rows);
}
return iconGroups;
}
},
}
</script>
<style lang="css">
.tabletop-prop-menu {
top: unset !important;
transition: all 0.2s ease;
}
.tabletop-prop-menu.rows-1 {
bottom: 80px;
}
.tabletop-prop-menu.rows-2 {
bottom: 124px;
}
.tabletop-prop-menu.rows-3 {
bottom: 168px;
}
.tabletop-prop-menu.rows-4 {
bottom: 212px;
}
</style>

View File

@@ -1,7 +1,7 @@
import Vue from 'vue';
//import Vuetify from 'vuetify/lib';
import Vuetify from 'vuetify/lib/framework';
import { Scroll, Ripple } from 'vuetify/lib/directives';
import { Scroll, Ripple, ClickOutside } from 'vuetify/lib/directives';
import SVG_ICONS from '/imports/constants/SVG_ICONS';
import SvgIconByName from '/imports/client/ui/icons/SvgIconByName.vue';
import themes from '/imports/client/ui/themes';
@@ -11,6 +11,7 @@ Vue.use(Vuetify, {
directives: {
Scroll,
Ripple,
ClickOutside,
},
});

View File

@@ -56,6 +56,7 @@ const store = new Vuex.Store({
setTabForCharacterSheet(state, { tab, id }) {
// Convert tab names to tab numbers
if (typeof tab === 'string') {
const tabInput = tab;
const creature = Creatures.findOne(id);
if (creature?.settings?.hideSpellsTab) {
tab = tabsWithoutSpells.indexOf(tab);
@@ -63,9 +64,9 @@ const store = new Vuex.Store({
tab = tabs.indexOf(tab);
}
if (!(tab > -1)) {
throw 'Could not find requested tab';
console.warn(`could not find a tab called ${tabInput}`);
tab = 0;
}
console.log('resolved: ', tab);
}
Vue.set(state.characterSheetTabs, id, tab);
},

View File

@@ -1,6 +1,9 @@
import Tabletops from '/imports/api/tabletop/Tabletops';
import Creatures from '/imports/api/creature/creatures/Creatures';
import Messages from '/imports/api/tabletop/Messages';
import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties';
import CreatureLogs from '/imports/api/creature/log/CreatureLogs';
import CreatureVariables from '/imports/api/creature/creatures/CreatureVariables';
import { loadCreature } from '/imports/api/engine/loadCreatures';
Meteor.publish('tabletops', function () {
var userId = this.userId;
@@ -21,6 +24,7 @@ Meteor.publish('tabletop', function (tabletopId) {
return [];
}
this.autorun(function () {
const self = this;
let tabletopCursor = Tabletops.find({
_id: tabletopId,
$or: [
@@ -32,6 +36,7 @@ Meteor.publish('tabletop', function (tabletopId) {
if (!tabletop) {
return [];
}
// Warning, this leaks data to users of the same tabletop who may not have
// read permission of this specific creature, so publish as few fields as
// possible
@@ -39,22 +44,32 @@ Meteor.publish('tabletop', function (tabletopId) {
tabletop: tabletopId,
}, {
fields: {
_id: 1,
name: 1,
picture: 1,
avatarPicture: 1,
variables: 1,
tabletop: 1,
initiativeRoll: 1,
settings: 1,
},
});
let recentMessages = Messages.find({
const creatureIds = creatureSummaries.map(c => c._id);
creatureIds.forEach(creatureId => {
loadCreature(creatureId, self);
});
const variables = CreatureVariables.find({
_creatureId: { $in: creatureIds }
});
let properties = CreatureProperties.find({
'ancestors.id': { $in: creatureIds },
removed: { $ne: true },
});
const logs = CreatureLogs.find({
tabletopId,
}, {
sort: {
timeStamp: -1,
},
limit: 100,
sort: { date: -1 },
});
return [tabletopCursor, creatureSummaries, recentMessages]
return [tabletopCursor, creatureSummaries, properties, logs, variables]
})
});

13
app/package-lock.json generated
View File

@@ -3463,7 +3463,7 @@
"lodash.omit": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.omit/-/lodash.omit-4.5.0.tgz",
"integrity": "sha512-XeqSp49hNGmlkj2EJlfrQFIzQ6lXdNro9sddtQzcJY8QaoC2GO0DT7xaIokHeyM+mIT0mPMlPvkYzg2xCuHdZg=="
"integrity": "sha1-brGa5aHuHdnfC5aeZs4Lf6MLXmA="
},
"lodash.template": {
"version": "4.5.0",
@@ -5367,6 +5367,11 @@
"integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
"dev": true
},
"three": {
"version": "0.156.1",
"resolved": "https://registry.npmjs.org/three/-/three-0.156.1.tgz",
"integrity": "sha512-kP7H0FK9d/k6t/XvQ9FO6i+QrePoDcNhwl0I02+wmUJRNSLCUIDMcfObnzQvxb37/0Uc9TDT0T1HgsRRrO6SYQ=="
},
"to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -5540,9 +5545,9 @@
}
},
"vue": {
"version": "2.6.14",
"resolved": "https://registry.npmjs.org/vue/-/vue-2.6.14.tgz",
"integrity": "sha512-x2284lgYvjOMj3Za7kqzRcUSxBboHqtgRE2zlos1qWaOye5yUmHn42LB1250NJBLRwEcdrB0JRwyPTEPhfQjiQ=="
"version": "2.6.10",
"resolved": "https://registry.npmjs.org/vue/-/vue-2.6.10.tgz",
"integrity": "sha512-ImThpeNU9HbdZL3utgMCq0oiMzAkt1mcgy3/E6zWC/G6AaQoeuFdsl9nDhTDU3X1R6FK7nsIUuRACVcjI+A2GQ=="
},
"vue-eslint-parser": {
"version": "7.11.0",

View File

@@ -53,8 +53,9 @@
"simpl-schema": "^1.13.1",
"source-map-support": "^0.5.21",
"speakingurl": "^14.0.1",
"three": "^0.156.1",
"vivagraphjs": "^0.12.0",
"vue": "2.6.14",
"vue": "2.6.10",
"vue-meteor-tracker": "^2.0.0",
"vue-reactive-provide": "^0.3.0",
"vue-router": "^3.6.5",

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.