Merge branch 'version-2-print' into version-2-dev

This commit is contained in:
Stefan Zermatten
2022-11-08 17:00:52 +02:00
15 changed files with 2245 additions and 0 deletions

View File

@@ -0,0 +1,339 @@
<template>
<div class="character-sheet-printed fill-height">
<v-fade-transition mode="out-in">
<div
v-if="!$subReady.singleCharacter"
key="character-loading"
class="fill-height layout justify-center align-center"
>
<v-progress-circular
indeterminate
color="primary"
size="64"
/>
</div>
<div v-else-if="!creature">
<v-layout
column
align-center
justify-center
>
<h2 style="margin: 48px 28px 16px">
Character not found
</h2>
<h3>
Either this character does not exist, or you don't have permission
to view it.
</h3>
</v-layout>
</div>
<v-theme-provider
v-else
light
>
<div class="page pa-3">
<div class="px-3 d-flex align-center">
<div class="logo-background" />
<div class="creature-name mr-3">
{{ creature.name }}
</div>
<div class="text-right flex mr-4">
<div v-if="creature.alignment || background">
{{ creature.alignment }} {{ background }}
</div>
<dir v-if="race || creature.gender">
{{ race }} {{ creature.gender }}
</dir>
<div v-if="level && classes && classes.length === 1">
Level {{ level }} {{ classes[0].name }}
</div>
<div v-else-if="level">
Level {{ level }} ({{ classes.map(c => `${c.name} ${c.level}`).join(', ') }})
</div>
</div>
<qrcode-vue
style="height: 100px"
render-as="svg"
:value="creatureUrl"
/>
</div>
<div
class="text-right mt-3 mr-4"
style="font-size: 8pt; margin-bottom: -4px;"
>
{{ creatureUrl }}
</div>
<printed-stats :creature-id="creatureId" />
<printed-inventory :creature-id="creatureId" />
<printed-spells
v-if="!creature.settings.hideSpellsTab"
:creature-id="creatureId"
/>
</div>
</v-theme-provider>
</v-fade-transition>
</div>
</template>
<script lang="js">
import Creatures from '/imports/api/creature/creatures/Creatures.js';
import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js';
import PrintedStats from '/imports/ui/creature/character/printedCharacterSheet/PrintedStats.vue';
import PrintedInventory from '/imports/ui/creature/character/printedCharacterSheet/PrintedInventory.vue';
import PrintedSpells from '/imports/ui/creature/character/printedCharacterSheet/PrintedSpells.vue';
import { assertEditPermission } from '/imports/api/creature/creatures/creaturePermissions.js';
import CreatureVariables from '/imports/api/creature/creatures/CreatureVariables.js';
import QrcodeVue from 'qrcode.vue'
export default {
components: {
PrintedStats,
PrintedInventory,
PrintedSpells,
QrcodeVue,
},
computed: {
creatureId() {
return this.$route.params.id
},
creatureUrl() {
let props = this.$router.resolve({
name: 'characterSheet',
params: { id: this.creatureId},
});
return new URL(props?.href, document.baseURI).href
},
level() {
return this.variables?.level?.value;
},
highestLevels(){
let highestLevels = {};
let highestLevelsList = [];
this.classLevels.forEach(classLevel => {
let name = classLevel.variableName;
if (
!highestLevels[name] ||
highestLevels[name].level < classLevel.level
){
highestLevels[name] = classLevel;
}
});
for (let name in highestLevels){
highestLevelsList.push(highestLevels[name]);
}
highestLevelsList.sort((a, b) => a.level - b.level);
return highestLevelsList;
},
classes() {
return [
...this.highestLevels,
...this.classProperties
].sort((a, b) => a.order - b.order);
},
},
reactiveProvide: {
name: 'context',
include: ['creatureId', 'editPermission'],
},
watch: {
'creature.name'(value) {
this.$store.commit('setPageTitle', value ? ('Print ' + value) : 'Print Character Sheet');
},
},
mounted() {
this.$store.commit('setPageTitle',
(this.creature && this.creature.name) ?
('Print ' + this.creature.name) :
'Print Character Sheet'
);
this.nameObserver = Creatures.find({
creatureId: this.creatureId,
}, {
fields: { name: 1 },
}).observe({
added: ({ name }) =>
this.$store.commit('setPageTitle', name ? ('Print ' + name) : 'Print Character Sheet'),
changed: ({ name }) =>
this.$store.commit('setPageTitle', name ? ('Print ' + name) : 'Print Character Sheet'),
});
},
beforeDestroy() {
this.nameObserver.stop();
},
meteor: {
$subscribe: {
'singleCharacter'() {
return [this.creatureId];
},
},
creature() {
return Creatures.findOne(this.creatureId);
},
variables() {
return CreatureVariables.findOne({ _creatureId: this.creatureId }) || {};
},
race() {
if (this.variables?.race?.value?.valueType === 'string') return this.variables.race.value.value;
const prop = CreatureProperties.findOne({
'ancestors.id': this.creatureId,
tags: 'race',
removed: { $ne: true },
inactive: { $ne: true },
overridden: { $ne: true },
});
if (prop?.name) return prop.name;
return '';
},
background() {
if (this.variables?.background?.value?.valueType === 'string') return this.variables.background.value.value;
const prop = CreatureProperties.findOne({
'ancestors.id': this.creatureId,
tags: 'background',
removed: { $ne: true },
inactive: { $ne: true },
overridden: { $ne: true },
});
if (prop?.name) return prop.name;
return '';
},
classProperties(){
return CreatureProperties.find({
'ancestors.id': this.creatureId,
type: 'class',
removed: {$ne: true},
inactive: {$ne: true},
}, {
sort: {order: 1}
}).fetch();
},
classLevels() {
const classVariableNames = this.classProperties.map(c => c.variableName)
return CreatureProperties.find({
'ancestors.id': this.creatureId,
type: 'classLevel',
variableName: {$nin: classVariableNames},
removed: {$ne: true},
inactive: {$ne: true},
}, {
sort: {order: 1}
});
},
editPermission() {
try {
assertEditPermission(this.creature, Meteor.userId());
return true;
} catch (e) {
return false;
}
},
},
}
</script>
<style>
.character-sheet-printed {
background: white;
color: black;
font-size: 11pt;
}
.page {
padding: 4px;
}
.character-sheet-printed .inactive {
opacity: 1 !important;
}
.character-sheet-printed .creature-name {
font-size: 24pt;
background-color: white;
}
.character-sheet-printed .logo-background {
width: 60px;
height: 60px;
margin-right: 8px;
background-image: url(/crown-dice-logo-cropped-transparent.png);
background-size: contain;
background-position: 0 center;
print-color-adjust: exact;
-webkit-print-color-adjust: exact;
}
.character-sheet-printed .v-divider {
border-color: rgba(0,0,0,0.3);
max-width: unset;
}
.character-sheet-printed .double-border {
position: relative;
padding: 11px 10px;
page-break-inside: avoid;
}
.character-sheet-printed .double-border::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
border-image-source: url(/images/print/doubleLineImageBorder.png);
border-image-slice: 110 126 fill;
border-image-width: 16px;
border-image-repeat: stretch;
box-sizing: content-box;
z-index: -1;
}
.character-sheet-printed .octagon-border {
position: relative;
padding: 4px 20px;
page-break-inside: avoid;
}
.character-sheet-printed .octagon-border::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
border-image: url(/images/print/octagonBorder.png) 124 118 fill;
border-image-width: 22px;
z-index: -1;
}
.character-sheet-printed .stats .label {
font-size: 10pt;
font-variant: small-caps;
}
.character-sheet-printed .label {
font-size: 14pt;
font-variant: all-small-caps;
font-weight: 600;
}
.character-sheet-printed .span-all {
column-span: all;
}
@media screen {
.character-sheet-printed {
display: flex;
flex-direction: column;
align-items: center;
}
.character-sheet-printed .page {
width: 210mm;
}
}
@media print {
header {
display: none !important;
}
nav {
display: none !important;
}
.v-main {
padding: 0 !important;
}
}
</style>

View File

@@ -0,0 +1,270 @@
<template lang="html">
<div
class="inventory"
style="page-break-before: always;"
>
<column-layout wide-columns>
<div class="span-all">
<div class="double-border">
<div class="label text-center">
Inventory
</div>
<div class="d-flex inventory-stat">
<v-icon>$vuetify.icons.injustice</v-icon>
Weight Carried:
{{ weightCarried }} lb
</div>
<div class="d-flex inventory-stat">
<v-icon>$vuetify.icons.cash</v-icon>
Net worth:
<coin-value
class="ml-2"
:value="variables && variables.valueTotal && variables.valueTotal.value|| 0"
/>
</div>
<div class="d-flex inventory-stat">
<v-icon>$vuetify.icons.spell</v-icon>
Items attuned:
{{ variables.itemsAttuned && variables.itemsAttuned.value }}
</div>
</div>
</div>
<div class="span-all">
<div class="octagon-border label text-center">
Equipped
</div>
</div>
<div
v-for="item in equippedItems"
:key="item._id"
>
<printed-item
class="double-border"
:model="item"
/>
</div>
<div class="span-all">
<div class="octagon-border label text-center">
Carried
</div>
</div>
<div
v-for="item in carriedItems"
:key="item._id"
>
<printed-item
class="double-border"
:model="item"
/>
</div>
<template
v-for="container in containersWithoutAncestorContainers"
>
<div
:key="container._id"
class="span-all container-header"
>
<printed-container
class="octagon-border"
:model="container"
/>
</div>
<div
v-for="item in container.items"
:key="item._id"
>
<printed-item
class="double-border"
:model="item"
/>
</div>
</template>
</column-layout>
</div>
</template>
<script lang="js">
import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js';
import Creatures from '/imports/api/creature/creatures/Creatures.js';
import ColumnLayout from '/imports/ui/components/ColumnLayout.vue';
import getParentRefByTag from '/imports/api/creature/creatureProperties/methods/getParentRefByTag.js';
import BUILT_IN_TAGS from '/imports/constants/BUILT_IN_TAGS.js';
import CoinValue from '/imports/ui/components/CoinValue.vue';
import stripFloatingPointOddities from '/imports/api/engine/computation/utility/stripFloatingPointOddities.js';
import PrintedItem from '/imports/ui/creature/character/printedCharacterSheet/components/PrintedItem.vue';
import PrintedContainer from '/imports/ui/creature/character/printedCharacterSheet/components/PrintedContainer.vue';
import CreatureVariables from '/imports/api/creature/creatures/CreatureVariables.js';
export default {
components: {
ColumnLayout,
CoinValue,
PrintedItem,
PrintedContainer,
},
props: {
creatureId: {
type: String,
required: true,
},
},
data() {
return {
organize: false,
}
},
meteor: {
containers() {
return CreatureProperties.find({
'ancestors.id': this.creatureId,
type: 'container',
removed: { $ne: true },
inactive: { $ne: true },
}, {
sort: { order: 1 },
});
},
creature() {
return Creatures.findOne(this.creatureId, {
fields: {
color: 1,
variables: 1,
}
});
},
variables() {
return CreatureVariables.findOne({ _creatureId: this.creatureId }) || {};
},
containersWithoutAncestorContainers() {
return CreatureProperties.find({
'ancestors.id': {
$eq: this.creatureId,
$nin: this.containerIds
},
type: 'container',
removed: { $ne: true },
inactive: { $ne: true },
}, {
sort: { order: 1 },
}).map(c => {
c.items = CreatureProperties.find({
'parent.id': c._id,
type: { $in: ['item', 'container'] },
removed: { $ne: true },
equipped: { $ne: true },
deactivatedByAncestor: { $ne: true },
}, {
sort: { order: 1 },
}).fetch();
return c;
});
},
carriedItems() {
return CreatureProperties.find({
'ancestors.id': {
$eq: this.creatureId,
$nin: this.containerIds
},
type: 'item',
equipped: { $ne: true },
removed: { $ne: true },
deactivatedByAncestor: { $ne: true },
}, {
sort: { order: 1 },
});
},
equippedItems() {
return CreatureProperties.find({
'ancestors.id': {
$eq: this.creatureId,
},
type: 'item',
equipped: true,
removed: { $ne: true },
inactive: { $ne: true },
}, {
sort: { order: 1 },
});
},
equipmentParentRef() {
return getParentRefByTag(
this.creatureId, BUILT_IN_TAGS.equipment
) || getParentRefByTag(
this.creatureId, BUILT_IN_TAGS.inventory
) || {
id: this.creatureId,
collection: 'creatures'
};
},
carriedParentRef() {
return getParentRefByTag(
this.creatureId, BUILT_IN_TAGS.carried
) || getParentRefByTag(
this.creatureId, BUILT_IN_TAGS.inventory
) || {
id: this.creatureId,
collection: 'creatures'
};
},
},
computed: {
containerIds() {
return this.containers.map(container => container._id);
},
weightCarried() {
return stripFloatingPointOddities(
this.variables &&
this.variables.weightCarried &&
this.variables.weightCarried.value || 0
);
},
},
methods: {
clickProperty(_id) {
this.$store.commit('pushDialogStack', {
component: 'creature-property-dialog',
elementId: `tree-node-${_id}`,
data: { _id },
});
},
},
}
</script>
<style lang="css" scoped>
.octagon-border {
position: relative;
padding: 4px 20px;
page-break-inside: avoid;
}
.octagon-border::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
border-image: url(/images/print/octagonBorder.png) 124 118 fill;
border-image-width: 22px;
z-index: -1;
}
.label {
font-size: 14pt;
font-variant: small-caps;
flex-grow: 1;
}
.inventory-stat {
font-size: 12pt;
line-height: 32px;
}
.inventory-stat > .v-icon {
margin-right: 8px;
}
.container-header {
page-break-after: avoid;
page-break-inside: avoid;
}
</style>

View File

@@ -0,0 +1,128 @@
<template lang="html">
<div
class="spells"
style="page-break-before: always;"
>
<column-layout wide-columns>
<div class="span-all">
<div class="label text-center octagon-border">
Spells
</div>
</div>
<div
v-for="spell in spellsWithoutList"
:key="spell._id"
>
<printed-spell :model="spell" />
</div>
<template
v-for="spellList in spellListsWithoutAncestorSpellLists"
>
<div
:key="spellList._id"
class="span-all"
>
<printed-spell-list
:model="spellList"
/>
</div>
<div
v-for="spell in spellList.spells"
:key="spell._id"
>
<printed-spell :model="spell" />
</div>
</template>
</column-layout>
</div>
</template>
<script lang="js">
import ColumnLayout from '/imports/ui/components/ColumnLayout.vue';
import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js';
import PrintedSpell from '/imports/ui/creature/character/printedCharacterSheet/components/PrintedSpell.vue';
import PrintedSpellList from '/imports/ui/creature/character/printedCharacterSheet/components/PrintedSpellList.vue';
export default {
components: {
ColumnLayout,
PrintedSpell,
PrintedSpellList,
},
props: {
creatureId: {
type: String,
required: true,
}
},
data() {
return {
organize: false,
}
},
meteor: {
spellLists() {
return CreatureProperties.find({
'ancestors.id': this.creatureId,
type: 'spellList',
removed: { $ne: true },
inactive: { $ne: true },
}, {
sort: { order: 1 }
});
},
spellsWithoutList() {
return CreatureProperties.find({
'ancestors.id': {
$eq: this.creatureId,
$nin: this.spellListIds,
},
type: 'spell',
removed: { $ne: true },
deactivatedByAncestor: { $ne: true },
deactivatedByToggle: { $ne: true },
}, {
sort: {
level: 1,
order: 1,
}
});
},
spellListsWithoutAncestorSpellLists() {
return CreatureProperties.find({
'ancestors.id': {
$eq: this.creatureId,
$nin: this.spellListIds,
},
type: 'spellList',
removed: { $ne: true },
inactive: { $ne: true },
}, {
sort: { order: 1 }
}).map(sl => {
sl.spells = CreatureProperties.find({
'ancestors.id': sl._id,
type: 'spell',
removed: { $ne: true },
inactive: { $ne: true },
}, {
sort: {
level: 1,
order: 1,
}
}).fetch();
return sl;
});
},
},
computed: {
spellListIds() {
return this.spellLists?.map(spellList => spellList._id);
},
},
}
</script>
<style lang="css" scoped>
</style>

View File

@@ -0,0 +1,639 @@
<template lang="html">
<div class="stats">
<column-layout>
<div
v-if="abilities.length"
class="ability-scores"
>
<div class="layout flex column">
<div
v-for="ability in abilities"
:key="ability._id"
class="ability"
>
<div class="score">
<div class="double-border top big-number">
<template v-if="creature.settings.swapScoresAndMods">
{{ ability.total }}
</template>
<template v-else>
{{ numberToSignedString(ability.modifier) }}
</template>
</div>
<div class="bottom">
<template v-if="creature.settings.swapScoresAndMods">
{{ numberToSignedString(ability.modifier) }}
</template>
<template v-else>
{{ ability.total }}
</template>
</div>
</div>
<div class="double-border name label">
{{ ability.name }}
</div>
</div>
</div>
</div>
<div
v-for="toggle in toggles"
:key="toggle._id"
class="number-label"
>
<div class="box double-border" />
<div class="label double-border">
{{ toggle.name }}
</div>
</div>
<div
v-for="stat in stats"
:key="stat._id"
class="number-label"
:class="stat.variableName == 'armor' && 'shield-number-label'"
>
<div
:class="stat.variableName == 'armor' ? 'shield-border' : 'octagon-border'"
class="number big-number"
>
{{ stat.value }}
</div>
<div class="label double-border">
{{ stat.name }}
</div>
</div>
<div
v-for="modifier in modifiers"
:key="modifier._id"
class="number-label"
>
<div class="number octagon-border big-number">
{{ numberToSignedString(modifier.value) }}
</div>
<div class="label double-border">
{{ modifier.name }}
</div>
</div>
<div
v-for="check in checks"
:key="check._id"
class="number-label"
>
<div class="number octagon-border big-number">
{{ numberToSignedString(check.value) }}
</div>
<div class="label double-border">
{{ check.name }}
</div>
</div>
<div
v-for="healthBar in healthBars"
:key="healthBar._id"
class="m-2"
>
<div class="double-border">
<div class="label">
Total: {{ healthBar.total }}
</div>
<div style="height: 60px;" />
<div
style="text-align: center;"
class="label"
>
{{ healthBar.name }}
</div>
</div>
</div>
<div v-if="multipliers && multipliers.length">
<printed-damage-multipliers
class="double-border"
:multipliers="multipliers"
/>
</div>
<div
v-if="hitDice.length"
class="hit-dice m-2"
>
<div class="double-border">
<div>
<span class="label">
Total:
</span>
<span
v-for="hitDie in hitDice"
:key="hitDie._id"
style="margin-right: 4px;"
>
{{ hitDie.total }}{{ hitDie.hitDiceSize }}
</span>
</div>
<div style="height: 60px;" />
<div
style="text-align: center;"
class="label"
>
Hit Dice
</div>
</div>
</div>
<div
v-for="resource in resources"
:key="resource._id"
>
<div
class="double-border"
:class="resource.total <= 8 && 'mb-2'"
>
<div
v-if="resource.total <= 8"
class="label"
>
{{ resource.name }}
</div>
<div
v-if="resource.total > 8"
>
total: {{ resource.total }}
<div style="height: 60px;" />
</div>
<div
v-if="resource.total <= 8"
class="d-flex justify-end"
>
<div
v-for="i in resource.total"
:key="i"
class="resource-bubble"
/>
</div>
<div
v-if="resource.total > 8"
class="label text-center"
>
{{ resource.name }}
</div>
</div>
</div>
<div
v-if="spellSlots && spellSlots.length"
>
<div class="double-border">
<div class="label text-center">
Spell Slots
</div>
<div
v-for="spellSlot in spellSlots"
:key="spellSlot._id"
class="mb-7"
:class="spellSlot.total <= 8 && 'mb-7'"
>
<div class="label">
{{ spellSlot.name }}
</div>
<div
v-if="spellSlot.total > 8"
>
Total: {{ spellSlot.total }}
</div>
<div
v-else
class="d-flex"
>
<div
v-for="i in spellSlot.total"
:key="i"
class="resource-bubble"
/>
</div>
</div>
</div>
</div>
<div
v-if="savingThrows.length"
>
<div
class="double-border"
>
<printed-skill
v-for="save in savingThrows"
:key="save._id"
:model="save"
:data-id="save._id"
/>
<div class="label text-center">
Saving Throws
</div>
</div>
</div>
<div
v-if="skills.length"
>
<div
class="double-border"
>
<printed-skill
v-for="skill in skills"
:key="skill._id"
:model="skill"
:data-id="skill._id"
/>
<div class="label text-center">
Skills
</div>
</div>
</div>
<div
v-if="weapons && weapons.length"
>
<div
class="double-border"
>
<printed-skill
v-for="weapon in weapons"
:key="weapon._id"
hide-modifier
:model="weapon"
:data-id="weapon._id"
/>
<div class="label text-center">
Weapons
</div>
</div>
</div>
<div
v-if="armors && armors.length"
>
<div
class="double-border"
>
<printed-skill
v-for="armor in armors"
:key="armor._id"
hide-modifier
:model="armor"
:data-id="armor._id"
/>
<div class="label text-center">
Armor
</div>
</div>
</div>
<div
v-if="tools && tools.length"
>
<div
class="double-border"
>
<printed-skill
v-for="tool in tools"
:key="tool._id"
hide-modifier
:model="tool"
:data-id="tool._id"
/>
<div class="label text-center">
Tools
</div>
</div>
</div>
<div
v-if="languages && languages.length"
>
<div
class="double-border"
>
<printed-skill
v-for="language in languages"
:key="language._id"
hide-modifier
:model="language"
:data-id="language._id"
/>
<div class="label text-center">
Languages
</div>
</div>
</div>
<div
v-for="note in notes"
:key="note._id"
>
<div class="double-border">
<div class="label text-center">
{{ note.name }}
</div>
<property-description
text
:model="note.summary"
/>
</div>
</div>
<div
v-for="action in actions"
:key="action._id"
>
<div class="double-border">
<printed-action
:model="action"
/>
</div>
</div>
<div
v-for="feature in features"
:key="feature._id"
>
<div class="double-border">
<div class="label text-center">
{{ feature.name }}
</div>
<property-description
text
:model="feature.summary"
/>
</div>
</div>
</column-layout>
</div>
</template>
<script lang="js">
import Creatures from '/imports/api/creature/creatures/Creatures.js';
import ColumnLayout from '/imports/ui/components/ColumnLayout.vue';
import PrintedAction from '/imports/ui/creature/character/printedCharacterSheet/components/PrintedAction.vue';
import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js';
import numberToSignedString from '/imports/ui/utility/numberToSignedString.js';
import PrintedSkill from '/imports/ui/creature/character/printedCharacterSheet/components/PrintedSkill.vue';
import PrintedDamageMultipliers from '/imports/ui/creature/character/printedCharacterSheet/components/PrintedDamageMultipliers.vue';
import PropertyDescription from '/imports/ui/properties/viewers/shared/PropertyDescription.vue';
const getProperties = function (creature, filter, options = {
sort: { order: 1 }
}) {
if (!creature) return;
if (creature.settings.hideUnusedStats) {
filter.hide = { $ne: true };
}
filter['ancestors.id'] = creature._id;
filter.removed = { $ne: true };
filter.inactive = { $ne: true };
filter.overridden = { $ne: true };
filter.$nor = [
{ hideWhenTotalZero: true, total: 0 },
{ hideWhenValueZero: true, value: 0 },
];
return CreatureProperties.find(filter, options);
};
const getAttributeOfType = function (creature, type) {
return getProperties(creature, {
type: 'attribute',
attributeType: type,
});
};
const getSkillOfType = function (creature, type) {
return getProperties(creature, {
type: 'skill',
skillType: type,
});
}
export default {
components: {
ColumnLayout,
PrintedDamageMultipliers,
PrintedAction,
PrintedSkill,
PropertyDescription,
},
props: {
creatureId: {
type: String,
required: true,
},
},
data() {
return {
doCheckLoading: false,
}
},
meteor: {
creature() {
return Creatures.findOne(this.creatureId, { fields: { settings: 1 } });
},
abilities() {
return getAttributeOfType(this.creature, 'ability');
},
stats() {
return getAttributeOfType(this.creature, 'stat');
},
toggles() {
return CreatureProperties.find({
'ancestors.id': this.creatureId,
type: 'toggle',
removed: { $ne: true },
deactivatedByAncestor: { $ne: true },
showUI: true,
}, {
sort: { order: 1 }
});
},
healthBars() {
return getAttributeOfType(this.creature, 'healthBar');
},
modifiers() {
return getAttributeOfType(this.creature, 'modifier');
},
resources() {
return getAttributeOfType(this.creature, 'resource');
},
spellSlots() {
return getAttributeOfType(this.creature, 'spellSlot');
},
hasSpells() {
const cursor = getProperties(this.creature, {
type: 'spell',
})
return cursor && cursor.count();
},
hitDice() {
return getAttributeOfType(this.creature, 'hitDice');
},
checks() {
return getSkillOfType(this.creature, 'check');
},
savingThrows() {
return getSkillOfType(this.creature, 'save');
},
skills() {
return getSkillOfType(this.creature, 'skill');
},
tools() {
return getSkillOfType(this.creature, 'tool');
},
weapons() {
return getSkillOfType(this.creature, 'weapon');
},
armors() {
return getSkillOfType(this.creature, 'armor');
},
languages() {
return getSkillOfType(this.creature, 'language');
},
actions() {
return getProperties(this.creature, { type: 'action' }, {
sort: { actionType: 1, order: 1 }
});
},
appliedBuffs() {
return getProperties(this.creature, { type: 'buff' });
},
multipliers() {
return getProperties(this.creature, {
type: 'damageMultiplier'
}, {
sort: { value: 1, order: 1 }
});
},
features() {
return getProperties(this.creature, { type: 'feature' });
},
notes(){
return getProperties(this.creature, { type: 'note', summary: {$exists: true} });
},
},
methods: {
numberToSignedString,
},
};
</script>
<style lang="css" scoped>
.shield-border {
min-width: 64px !important;
display: flex;
align-items: center;
justify-content: center;
position: relative;
aspect-ratio: 0.87;
padding: 12px;
}
.shield-border::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
background: url(/images/print/shieldBorder.png);
print-color-adjust: exact;
-webkit-print-color-adjust: exact;
background-size: contain;
background-position: center;
background-repeat: no-repeat;
z-index: -1;
}
.shield-number-label {
align-items: center !important;
}
.big-number {
font-size: 20pt;
}
.ability {
display: flex;
align-items: start;
margin: 4px 0;
}
.ability .score {
display: flex;
flex-direction: column;
align-items: center;
}
.ability .top {
min-width: 64px;
text-align: center;
margin-bottom: -10px;
padding: 14px;
z-index: 1;
}
.ability .bottom {
font-size: 10pt;
position: relative;
padding: 0 16px;
z-index: 2;
}
.ability .bottom::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
border: solid white;
border-image-source: url(/images/print/upwardPointingBorder.png);
border-image-slice: 0 85 fill;
border-image-width: 0 16px;
border-image-outset: 0px 0px;
border-image-repeat: stretch;
box-sizing: content-box;
z-index: -1;
}
.ability .name {
margin-top: 10px;
margin-left: -16px;
padding-left: 20px;
}
.number-label {
display: flex;
align-items: flex-start;
}
.label {
font-size: 10pt;
font-variant: small-caps;
flex-grow: 1;
}
.number-label .label {
margin-top: 4px;
margin-left: -30px;
padding-left: 34px;
z-index: -1;
}
.number-label .number {
min-width: 72px;
text-align: center;
z-index: 1;
}
.number-label .box {
width: 48px;
height: 48px;
margin-left: 10px;
z-index: 1;
}
.resource-bubble {
margin-bottom: -20px;
margin-top: 4px;
margin-right: 4px;
background-color: white;
border: solid black 2px;
border-radius: 50%;
width: 24px;
height: 24px;
}
</style>

View File

@@ -0,0 +1,247 @@
<template lang="html">
<div
class="action-card"
:class="cardClasses"
>
<div class="label text-center">
{{ actionTypeName }}
</div>
<div class="d-flex align-center">
<div class="avatar">
<div
v-if="rollBonus"
>
<template v-if="rollBonus && !rollBonusTooLong">
{{ rollBonus }}
</template>
<property-icon
v-else
:model="model"
color="rgba(0,0,0,0.7)"
/>
</div>
<property-icon
v-else
:model="model"
color="rgba(0,0,0,0.7)"
/>
</div>
<div
class="action-header flex d-flex column justify-center pl-1"
>
<div class="action-title my-1">
{{ model.name || propertyName }}
</div>
</div>
</div>
<div
v-if="Number.isFinite(model.uses)"
class="action-sub-title d-flex align-center"
>
{{ model.uses }} uses
</div>
<div class="pb-3">
<div
v-if="model.resources && model.resources.attributesConsumed.length ||
model.resources.itemsConsumed.length"
class="resources my-2"
>
<div
v-for="attributeConsumed in model.resources.attributesConsumed"
:key="attributeConsumed._id"
class="layout align-center justify-start"
>
Cost: {{ attributeConsumed.quantity && attributeConsumed.quantity.value }} {{ attributeConsumed.statName || attributeConsumed.variableName }}
</div>
<div
v-for="itemConsumed in model.resources.itemsConsumed"
:key="itemConsumed._id"
>
<template v-if="itemConsumed.itemName">
Uses: {{ itemConsumed.quantity && itemConsumed.quantity.value || 0 }} {{ itemConsumed.itemName || itemConsumed.tag }}
</template>
</div>
</div>
<template v-if="model.summary">
<markdown-text :markdown="model.summary.value || model.summary.text" />
</template>
<v-divider v-if="children && children.length" />
<tree-node-list
v-if="children && children.length"
start-expanded
:children="children"
@selected="e => $emit('sub-click', e)"
/>
</div>
</div>
</template>
<script lang="js">
import { getPropertyName } from '/imports/constants/PROPERTIES.js';
import numberToSignedString from '/imports/ui/utility/numberToSignedString.js';
import AttributeConsumedView from '/imports/ui/properties/components/actions/AttributeConsumedView.vue';
import ItemConsumedView from '/imports/ui/properties/components/actions/ItemConsumedView.vue';
import PropertyIcon from '/imports/ui/properties/shared/PropertyIcon.vue';
import MarkdownText from '/imports/ui/components/MarkdownText.vue';
import TreeNodeList from '/imports/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,
TreeNodeList,
},
inject: {
context: {
default: {},
},
theme: {
default: {
isDark: false,
},
},
},
props: {
model: {
type: Object,
required: true,
},
},
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,
'elevation-8': this.hovering,
}
},
actionTypeName() {
return {
'action': 'Action',
'bonus': 'Bonus Action',
'attack': 'Attack',
'reaction': 'Reaction',
'free': 'Free Action',
'long': 'Long Action'
}[this.model.actionType] || this.model.actionType
}
},
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);
},
},
}
</script>
<style lang="css" scoped>
.action-card {
transition: box-shadow .4s cubic-bezier(0.25, 0.8, 0.25, 1),
transform 0.075s ease;
}
.avatar {
font-size: 18pt;
text-align: center;
min-width: 40px;
min-height: 40px;
}
.label {
font-size: 10pt;
font-variant: small-caps;
flex-grow: 1;
}
.action-title {
font-size: 16px;
font-weight: 400;
line-height: 24px;
position: relative;
text-align: left;
transition: .3s cubic-bezier(.25, .8, .5, 1);
width: 100%;
}
.resources {
font-size: 10pt;
}
.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;
}
</style>

View File

@@ -0,0 +1,135 @@
<template>
<div class="container">
<div class="d-flex justify-center">
<property-icon
class="ml-2"
color="rgba(0,0,0,0.7)"
:model="model"
/>
<div class="label">
{{ model.name }}
</div>
</div>
<div
v-if="model.value !== undefined || model.weight !== undefined"
class="weight-value my-2 d-flex justify-space-between"
>
<div class="value ml-4">
<div
v-if="model.value !== undefined"
>
<v-layout align-center>
<v-icon
class="mr-2"
small
>
$vuetify.icons.two_coins
</v-icon>
<coin-value
class="mr-2"
:value="model.value"
/>
</v-layout>
<v-layout
align-center
class="mb-2"
>
<v-icon
class="mr-2"
small
>
$vuetify.icons.cash
</v-icon>
<coin-value
:value="model.contentsValue"
/>
<span
class="ml-1"
>
contents
</span>
</v-layout>
</div>
</div>
<div class="weight ml-4">
<div
v-if="model.weight !== undefined"
>
<v-layout align-center>
<v-icon
class="mr-2"
small
>
$vuetify.icons.weight
</v-icon>
{{ model.weight }} lb
</v-layout>
<v-layout
align-center
class="mb-2"
>
<v-icon
class="mr-2"
small
>
$vuetify.icons.injustice
</v-icon>
{{ model.contentsWeight }} lb
<span
class="ml-1"
>
contents
</span>
</v-layout>
</div>
</div>
</div>
<property-description
text
:model="model.description"
/>
</div>
</template>
<script lang="js">
import treeNodeViewMixin from '/imports/ui/properties/treeNodeViews/treeNodeViewMixin.js';
import PROPERTIES from '/imports/constants/PROPERTIES.js';
import CoinValue from '/imports/ui/components/CoinValue.vue';
import PropertyDescription from '/imports/ui/properties/viewers/shared/PropertyDescription.vue';
import stripFloatingPointOddities from '/imports/api/engine/computation/utility/stripFloatingPointOddities.js';
export default {
components: {
CoinValue,
PropertyDescription,
},
mixins: [treeNodeViewMixin],
inject: {
context: { default: {} }
},
props: {
preparingSpells: Boolean,
},
data() {
return {
incrementLoading: false,
}
},
computed: {
hasClickListener() {
return this.$listeners && !!this.$listeners.click;
},
},
}
</script>
<style lang="css" scoped>
.item-avatar {
min-width: 32px;
}
</style>

View File

@@ -0,0 +1,71 @@
<template lang="html">
<div>
<div
v-for="(multiplier, multiplierIndex) in multipliers"
:key="multiplier._id"
:data-id="multiplier._id"
@click="$emit('click-multiplier', {_id: multiplier._id})"
>
<v-divider v-if="multiplierIndex" />
<div>
<div
v-if="multiplier.name"
class="label text-center"
>
{{ multiplier.name }}
</div>
<div class="font-weight-medium">
{{ title(multiplier) }}
</div>
<div class="d-flex flex-wrap align-center">
{{ multiplier.damageTypes.join(', ') }}
</div>
<div
v-if="multiplier.includeTags && multiplier.includeTags.length"
class="d-flex flex-wrap align-center"
>
<div>
For:
</div>
{{ multiplier.includeTags.join(', ') }}
</div>
<div
v-if="multiplier.excludeTags && multiplier.excludeTags.length"
class="d-flex flex-wrap align-center"
>
<div>
Except:
</div>
{{ multiplier.excludeTags.join(', ') }}
</div>
</div>
</div>
</div>
</template>
<script lang="js">
export default {
props: {
multipliers:{
type: Array,
required: true,
}
},
methods: {
title(prop){
switch (prop.value){
case 0: return 'Immunity';
case 0.5: return 'Resistance';
case 2: return 'Vulnerability';
}
}
}
}
</script>
<style lang="css" scoped>
.label {
font-size: 10pt;
font-variant: small-caps;
}
</style>

View File

@@ -0,0 +1,173 @@
<template>
<div class="item">
<div class="d-flex justify-space-between">
<div class="label">
{{ title }}
<template v-if="attunementText">
({{ attunementText }})
</template>
</div>
<property-icon
class="ml-2"
color="rgba(0,0,0,0.7)"
:model="model"
/>
</div>
<div
v-if="model.value !== undefined || model.weight !== undefined"
class="weight-value my-2 d-flex justify-space-between"
>
<div class="value ml-4">
<div
v-if="model.value !== undefined"
>
<v-layout
v-if="model.quantity > 1"
align-center
class="mb-2"
>
<v-icon
class="mr-2"
small
>
$vuetify.icons.cash
</v-icon>
<coin-value
:value="model.value * model.quantity"
/>
</v-layout>
<v-layout align-center>
<v-icon
class="mr-2"
small
>
$vuetify.icons.two_coins
</v-icon>
<coin-value
class="mr-2"
:value="model.value"
/>
<span
v-if="model.quantity > 1"
class="ml-1"
>
each
</span>
</v-layout>
</div>
</div>
<div class="weight ml-4">
<div
v-if="model.weight !== undefined"
>
<v-layout
v-if="model.quantity > 1"
align-center
class="mb-2"
>
<v-icon
class="mr-2"
small
>
$vuetify.icons.injustice
</v-icon>
{{ totalWeight }} lb
</v-layout>
<v-layout align-center>
<v-icon
class="mr-2"
small
>
$vuetify.icons.weight
</v-icon>
{{ model.weight }} lb
<span
v-if="model.quantity > 1"
class="ml-1"
>
each
</span>
</v-layout>
</div>
</div>
</div>
<property-description
text
:model="model.description"
/>
</div>
</template>
<script lang="js">
import treeNodeViewMixin from '/imports/ui/properties/treeNodeViews/treeNodeViewMixin.js';
import PROPERTIES from '/imports/constants/PROPERTIES.js';
import CoinValue from '/imports/ui/components/CoinValue.vue';
import PropertyDescription from '/imports/ui/properties/viewers/shared/PropertyDescription.vue';
import stripFloatingPointOddities from '/imports/api/engine/computation/utility/stripFloatingPointOddities.js';
export default {
components: {
CoinValue,
PropertyDescription,
},
mixins: [treeNodeViewMixin],
inject: {
context: { default: {} }
},
props: {
preparingSpells: Boolean,
},
data() {
return {
incrementLoading: false,
}
},
computed: {
hasClickListener() {
return this.$listeners && !!this.$listeners.click;
},
title() {
let model = this.model;
if (!model) return;
if (model.quantity !== 1) {
if (model.plural) {
return `${model.quantity} ${model.plural}`;
} else if (model.name) {
return `${model.quantity} ${model.name}`;
}
} else if (model.name) {
return model.name;
}
let prop = PROPERTIES[model.type]
return prop && prop.name;
},
totalValue() {
return stripFloatingPointOddities(this.model.value * this.model.quantity);
},
totalWeight() {
return stripFloatingPointOddities(this.model.weight * this.model.quantity);
},
attunementText() {
if (this.model.requiresAttunement) {
if (this.model.attuned) return 'Attuned';
return 'Requires attunement';
}
return undefined;
}
},
}
</script>
<style lang="css" scoped>
.item-avatar {
min-width: 32px;
}
.item .label {
font-size: 14pt;
font-variant: all-small-caps;
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,105 @@
<template lang="html">
<div
class="printed-skill pl-0 d-flex align-center"
>
<div class="d-flex align-center">
<div
v-if="!hideModifier"
class="d-flex align-center"
>
<proficiency-icon
:value="model.proficiency"
class="prof-icon"
/>
<div class="prof-mod ml-2 mr-4 text-right">
{{ displayedModifier }}
</div>
<v-icon
v-if="model.advantage > 0"
size="20px"
>
mdi-chevron-double-up
</v-icon>
<v-icon
v-if="model.advantage < 0"
size="20px"
>
mdi-chevron-double-down
</v-icon>
</div>
<proficiency-icon
v-else
:value="model.proficiency"
class="prof-icon mr-2"
/>
<div class="text-truncate">
{{ model.name }}
<template v-if="model.conditionalBenefits && model.conditionalBenefits.length">
*
</template>
<template v-if="'passiveBonus' in model">
({{ passiveScore }})
</template>
</div>
</div>
</div>
</template>
<script lang="js">
import numberToSignedString from '/imports/ui/utility/numberToSignedString.js';
import ProficiencyIcon from '/imports/ui/properties/shared/ProficiencyIcon.vue';
export default {
components: {
ProficiencyIcon,
},
inject: {
context: {
default: {},
},
},
props: {
model: {
type: Object,
required: true,
},
hideModifier: Boolean,
},
data() {
return {
checkLoading: false,
}
},
computed: {
displayedModifier() {
let mod = this.model.value;
if (this.model.fail) {
return 'fail';
} else {
return numberToSignedString(mod);
}
},
passiveScore() {
return 10 + this.model.value + this.model.passiveBonus;
}
},
}
</script>
<style lang="css" scoped>
.printed-skill{
min-height: 30px;
}
.prof-icon {
min-width: 30px;
}
.prof-mod {
min-width: 24px;
}
.v-icon.theme--light {
color: rgba(0, 0, 0, 0.7) !important;
}
</style>

View File

@@ -0,0 +1,82 @@
<template lang="html">
<div
class="double-border"
>
<div
v-if="model.name"
class="label"
>
{{ model.name }}
</div>
<div v-if="model.level">
{{ levelText }} {{ model.school }} {{ model.ritual ? '(ritual)' : '' }}
</div>
<div v-else>
{{ model.school }} cantrip
</div>
<div>
Casting Time: {{ model.castingTime }}
</div>
<div>
Range: {{ model.range }}
</div>
<div>
Components: {{ spellComponents }}
</div>
<div>
Duration: {{ model.duration }}
</div>
<property-description
text
:model="model.summary"
/>
<v-divider class="my-2" />
<property-description
text
:model="model.description"
/>
</div>
</template>
<script lang="js">
import PropertyDescription from '/imports/ui/properties/viewers/shared/PropertyDescription.vue';
const levelText = [
'cantrip', '1st-level', '2nd-level', '3rd-level', '4th-level', '5th-level',
'6th-level', '7th-level', '8th-level', '9th-level'
];
export default {
components: {
PropertyDescription,
},
props: {
model: {
type: Object,
required: true,
},
},
computed: {
levelText() {
return levelText[this.model.level]
},
spellComponents() {
let components = [];
if (this.model.ritual) components.push('Ritual');
if (this.model.concentration) components.push('Concentration');
if (this.model.verbal) components.push('Verbal');
if (this.model.somatic) components.push('Somatic');
if (this.model.material) components.push(`Material (${this.model.material})`);
return components.join(', ');
},
}
}
</script>
<style lang="css" scoped>
.label {
font-size: 14pt;
font-variant: all-small-caps;
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,39 @@
<template>
<div class="octagon-border">
<div class="label text-center">
{{ model.name }}
</div>
<div>
Spell Save DC: {{ model.dc && model.dc.value }}
</div>
<div>
Spell Attack Bonus: {{ model.attackRollBonus && model.attackRollBonus.value }}
</div>
<div>
Maximum prepared spells: {{ model.maxPrepared && model.maxPrepared.value }}
</div>
<property-description
text
:model="model.description"
/>
</div>
</template>
<script lang="js">
import PropertyDescription from '/imports/ui/properties/viewers/shared/PropertyDescription.vue';
export default {
components: {
PropertyDescription,
},
props: {
model: {
type: Object,
required: true,
},
},
computed: {
}
}
</script>

View File

@@ -12,6 +12,7 @@ const LibraryCollectionToolbar = () => import('/imports/ui/library/LibraryCollec
const CharacterSheetPage = () => import('/imports/ui/pages/CharacterSheetPage.vue');
const CharacterSheetToolbar = () => import('/imports/ui/creature/character/CharacterSheetToolbar.vue');
const CharacterSheetRightDrawer = () => import('/imports/ui/creature/character/CharacterSheetRightDrawer.vue');
const CharacterSheetPrinted = () => import('/imports/ui/creature/character/printedCharacterSheet/CharacterSheetPrinted.vue');
const SignIn = () => import('/imports/ui/pages/SignIn.vue');
const Register = () => import('/imports/ui/pages/Register.vue');
const IconAdmin = () => import('/imports/ui/icons/IconAdmin.vue');
@@ -177,6 +178,16 @@ RouterFactory.configure(router => {
meta: {
title: 'Character Sheet',
},
}, {
name: 'printCharacterSheet',
path: '/print-character/:id',
alias: '/print-character/:id/:urlName',
components: {
default: CharacterSheetPrinted,
},
meta: {
title: 'Print Character Sheet',
},
}, {
path: '/tabletops',
name: 'tabletops',

5
app/package-lock.json generated
View File

@@ -3169,6 +3169,11 @@
"yargs": "^15.3.1"
}
},
"qrcode.vue": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/qrcode.vue/-/qrcode.vue-1.7.0.tgz",
"integrity": "sha512-R7t6Y3fDDtcU7L4rtqwGUDP9xD64gJhIwpfjhRCTKmBoYF6SS49PIJHRJ048cse6OI7iwTwgyy2C46N9Ygoc6g=="
},
"qs": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz",

View File

@@ -44,6 +44,7 @@
"ngraph.path": "^1.4.0",
"pretty-bytes": "^6.0.0",
"qrcode": "^1.5.1",
"qrcode.vue": "^1.7.0",
"request": "^2.88.2",
"sharp": "^0.30.7",
"simpl-schema": "^1.13.1",

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB