Iterated on Tabletop UX

This commit is contained in:
ThaumRystra
2025-01-23 23:22:16 +02:00
parent a09a32eb9a
commit 5b68190570
19 changed files with 266 additions and 156 deletions

View File

@@ -1,9 +0,0 @@
import computeCreature from '/imports/api/engine/computeCreature';
/**
* Recomputes all ancestor creatures of this property
* @deprecated
*/
export default function recomputeCreaturesByProperty(property) {
computeCreature.call(property.root.id);
}

View File

@@ -1,17 +0,0 @@
import computeCreature from '/imports/api/engine/computeCreature';
export default function recomputeCreatureMixin(methodOptions) {
let runFunc = methodOptions.run;
methodOptions.run = function ({ charId }) {
const result = runFunc.apply(this, arguments);
if (
methodOptions.skipRecompute &&
methodOptions.skipRecompute.apply(this, arguments)
) {
return result;
}
computeCreature(charId);
return result;
};
return methodOptions;
}

View File

@@ -130,7 +130,6 @@ const ActionSchema = new SimpleSchema({
},
});
// @ts-expect-error Collections2 lacks TypeScript support
EngineActions.attachSchema(ActionSchema);
export default EngineActions;

View File

@@ -1,11 +1,11 @@
import EngineActions, { EngineAction } from '/imports/api/engine/action/EngineActions';
import mutationToPropUpdates from './mutationToPropUpdates';
import mutationToLogUpdates from '/imports/api/engine/action/functions/mutationToLogUpdates';
import { union } from 'lodash';
import { union, uniq } from 'lodash';
import CreatureLogs from '/imports/api/creature/log/CreatureLogs';
import bulkWrite from '/imports/api/engine/shared/bulkWrite';
import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties';
import Creatures from '/imports/api/creature/creatures/Creatures';
import computeCreature from '/imports/api/engine/computeCreature';
export default async function writeActionResults(action: EngineAction) {
if (!action._id) throw new Meteor.Error('type-error', 'Action does not have an _id');
@@ -32,14 +32,10 @@ export default async function writeActionResults(action: EngineAction) {
// Write the bulk updates
const bulkWritePromise = bulkWrite(creaturePropUpdates, CreatureProperties);
// Mark the creatures as dirty
const creaturePromise = Creatures.updateAsync({
_id: { $in: [action.creatureId, ...allTargetIds] },
}, {
$set: { dirty: true },
}, {
multi: true,
});
await Promise.all([engineActionPromise, logPromise, bulkWritePromise]);
return Promise.all([engineActionPromise, logPromise, bulkWritePromise, creaturePromise]);
// Recompute the creatures involved
const recomputePromises = uniq([action.creatureId, ...allTargetIds]).map(creatureId => computeCreature(creatureId));
return Promise.all(recomputePromises);
}

View File

@@ -1,5 +1,4 @@
import { ValidatedMethod } from 'meteor/mdg:validated-method';
import SimpleSchema from 'simpl-schema';
import EngineActions from '/imports/api/engine/action/EngineActions';
import { assertEditPermission } from '/imports/api/sharing/sharingPermissions';
import { getCreature } from '/imports/api/engine/loadCreatures';
@@ -10,7 +9,7 @@ import { RateLimiterMixin } from 'ddp-rate-limiter-mixin';
export const runAction = new ValidatedMethod({
name: 'actions.runAction',
validate: null,
validate: null, //TODO
mixins: [RateLimiterMixin],
rateLimit: {
numRequests: 10,
@@ -31,7 +30,6 @@ export const runAction = new ValidatedMethod({
await applyAction(action, userInput);
// Persist changes
const writePromise = writeActionResults(action);
return writePromise;
return writeActionResults(action);
},
});

View File

@@ -4,14 +4,15 @@ import {
createTestCreature,
getRandomIds,
removeAllCreaturesAndProps,
runActionById
runActionById,
TestCreature
} from '/imports/api/engine/action/functions/actionEngineTest.testFn';
const [
creatureId, silencedNoteId
] = getRandomIds(2);
const actionTestCreature = {
const actionTestCreature: TestCreature = {
_id: creatureId,
props: [
{

View File

@@ -1,4 +1,5 @@
import Creatures from '/imports/api/creature/creatures/Creatures';
import VERSION from '/imports/constants/VERSION';
export default function writeErrorsAndPropCount(creatureId, errors = [], propCount) {
if (errors.length) {
@@ -7,6 +8,7 @@ export default function writeErrorsAndPropCount(creatureId, errors = [], propCou
computeErrors: errors,
propCount,
lastComputedAt: new Date(),
computeVersion: VERSION,
}
});
} else {
@@ -14,6 +16,7 @@ export default function writeErrorsAndPropCount(creatureId, errors = [], propCou
$set: {
propCount,
lastComputedAt: new Date(),
computeVersion: VERSION,
}, $unset: { computeErrors: 1 }
});
}

View File

@@ -2,7 +2,7 @@
// in the UI because of incompatibility with latency compensation. If the
// duplicate redraws can be fixed, this is a strictly better way of processing
// writes
export default function bulkWrite<T>(bulkWriteOps, collection: Mongo.Collection<T>): void | Promise<any> {
export default async function bulkWrite<T>(bulkWriteOps, collection: Mongo.Collection<T>): Promise<any> {
if (!bulkWriteOps.length) return;
// bulkWrite is only available on the server
if (!Meteor.isServer) {

View File

@@ -17,10 +17,12 @@ type BaseDoActionParams = {
type DoTaskParams = BaseDoActionParams & {
task: Task;
propId?: undefined;
targetIds?: undefined;
}
type DoActionParams = BaseDoActionParams & {
propId: string;
targetIds: string[];
task?: undefined;
}
@@ -30,12 +32,16 @@ type DoActionParams = BaseDoActionParams & {
* the decisions the user makes, then applying the action as a method call to the server with the
* saved decisions, which will persist the action results.
*/
export default async function doAction({ propId, creatureId, $store, elementId, task }: DoActionParams | DoTaskParams): Promise<any | void> {
export default async function doAction({ propId, creatureId, $store, elementId, task, targetIds }: DoActionParams | DoTaskParams): Promise<any | void> {
if (!task) {
targetIds ??= [];
if (!propId) throw new Meteor.Error('no-prop-id', 'Either propId or task must be provided');
const prop = getSingleProperty(creatureId, propId);
if (!prop) throw new Meteor.Error('not-found', 'Property not found');
task = {
prop: getSingleProperty(creatureId, propId),
targetIds: [],
prop,
targetIds,
subtaskFn: undefined,
};
}
// Create the action
@@ -52,12 +58,15 @@ export default async function doAction({ propId, creatureId, $store, elementId,
// Get the inserted and cleaned action instance
const action = EngineActions.findOne(actionId);
console.log(action);
if (!action) throw new Meteor.Error('not-found', 'The action could not be found');
// Applying the action is deterministic, so we apply it, if it asks for user input, we escape and
// create a dialog that will re-apply the action, but with the ability to actually get input
// Either way, call the action method afterwards
try {
if (!action._id) throw new Meteor.Error('no-action-id', 'Action ID is required');
const finishedAction = await applyAction(
action, getErrorOnInputRequestProvider(action._id), { simulate: true }
);
@@ -96,7 +105,7 @@ const throwInputRequestedError = () => {
throw 'input-requested';
}
function getErrorOnInputRequestProvider(actionId) {
function getErrorOnInputRequestProvider(actionId: string) {
const errorOnInputRequest: InputProvider = {
targetIds: throwInputRequestedError,
nextStep: throwInputRequestedError,

View File

@@ -185,4 +185,3 @@ export default {
margin-bottom: 0;
}
</style>
resolveimport { toString } from '/imports/parser/toString';

View File

@@ -3,48 +3,60 @@
<div
v-for="(contentGroup, index) in contentByTargetId"
:key="index"
class="d-flex justify-space-between"
>
<h3
<div class="d-flex flex-wrap">
<div
v-for="(content, contentIndex) in contentGroup.content"
:key="contentIndex"
class="mx-2 my-1"
:class="{'full-width': !content.inline}"
>
<div
class="content-name text-body"
>
{{ content.name }}
</div>
<markdown-text
v-if="content.value"
class="content-value text-body-2"
:markdown="content.value"
/>
<div
v-else
style="min-height: 12px;"
/>
</div>
</div>
<div
v-if="contentGroup.targetIds.length"
class="content-target-ids"
class="content-target-ids d-flex flex-column justify-center"
>
<v-icon>mdi-chevron-right</v-icon>
<v-list-item-avatar
<v-tooltip
v-for="creature in contentGroup.targetCreatures"
:key="creature._id"
:color="model.color || 'grey'"
size="32"
left
>
<img
v-if="creature.avatarPicture"
:src="creature.avatarPicture"
:alt="creature.name"
>
<span v-else>
{{ creature.name && creature.name[0] || '?' }}
</span>
</v-list-item-avatar>
</h3>
<div
v-for="(content, contentIndex) in contentGroup.content"
:key="contentIndex"
class="content-line"
>
<h4
class="content-name"
style="min-height: 12px;"
>
{{ content.name }}
</h4>
<markdown-text
v-if="content.value"
class="content-value"
:markdown="content.value"
/>
<div
v-else
style="min-height: 12px;"
/>
<template #activator="{ on, attrs }">
<v-list-item-avatar
:color="model.color || 'grey'"
size="28"
class="ma-2"
v-bind="attrs"
v-on="on"
>
<img
v-if="creature.avatarPicture"
:src="creature.avatarPicture"
:alt="creature.name"
>
<span v-else>
{{ creature.name && creature.name[0] || '?' }}
</span>
</v-list-item-avatar>
</template>
<span>{{ creature.name }}</span>
</v-tooltip>
</div>
</div>
</div>
@@ -104,19 +116,12 @@ export default {
</script>
<style lang="css" scoped>
.content-line {
min-height: 24px;
margin-top: 8px;
margin-bottom: 2px;
}
/** change the first content line to have no margin top*/
.content-line:first-of-type {
margin-top: 0;
}
.content-line .details {
display: inline-block;
}
.full-width {
width: 100%;
}
.content-target-ids {
border-left: solid 1px hsl(0deg 0% 50% / 20%);
}
</style>
<style lang="css">

View File

@@ -34,7 +34,6 @@ const SingleLibraryToolbar = () => import('/imports/client/ui/library/SingleLibr
const Tabletops = () => import('/imports/client/ui/pages/Tabletops.vue');
const Tabletop = () => import('/imports/client/ui/pages/Tabletop.vue');
const TabletopToolbar = () => import('/imports/client/ui/tabletop/TabletopToolbar.vue');
const TabletopRightDrawer = () => import('/imports/client/ui/tabletop/TabletopRightDrawer.vue');
const Admin = () => import('/imports/client/ui/pages/Admin.vue');
const Maintenance = () => import('/imports/client/ui/pages/Maintenance.vue');
const Files = () => import('/imports/client/ui/pages/Files.vue');
@@ -216,7 +215,6 @@ RouterFactory.configure(router => {
components: {
default: Tabletop,
toolbar: TabletopToolbar,
rightDrawer: TabletopRightDrawer,
},
beforeEnter: ensureLoggedIn,
}, {

View File

@@ -135,7 +135,7 @@ export default {
},
targets: {
type: Array,
default: undefined,
required: true,
},
},
data() {
@@ -221,6 +221,7 @@ export default {
doAction({
propId: this.model._id,
creatureId: this.model.root.id,
targetIds: this.targets,
$store: this.$store,
elementId: 'do-action-button',
}).catch((e) => {
@@ -249,6 +250,7 @@ export default {
height .3s ease;
max-width: 100vw;
position: relative;
max-height: calc(100vh - 144px);
}
.action-card.tabletop-active {
margin-top: -100px;

View File

@@ -3,16 +3,6 @@
class="tabletop layout column"
style="height: 100%;"
>
<tabletop-map
class="play-area"
style="
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
"
/>
<v-container
fluid
>
@@ -22,6 +12,12 @@
style="flex-wrap: nowrap; overflow-x: auto; padding-bottom: 50px; min-width: 200px;"
@wheel="transformScroll($event)"
>
<v-btn
icon
@click="toggleDrawer"
>
<v-icon> mdi-menu </v-icon>
</v-btn>
<tabletop-creature-card
v-for="creature in creatures"
:key="creature._id"
@@ -71,9 +67,19 @@
</v-btn>
</div>
</v-row>
<div
class="d-flex align-stretch"
style="max-height: calc(100vh - 364px);"
>
<v-spacer />
<tabletop-log-stream
:tabletop-id="$route.params.id"
class="pl-4"
style="overflow: auto; max-width: 500px;"
/>
</div>
</v-container>
<v-footer
inset
class="pa-0"
style="
background: none;
@@ -89,12 +95,25 @@
<v-slide-y-reverse-transition mode="out-in">
<selected-creature-bar
:key="activeCreatureId"
ref="selectedCreatureBar"
:creature-id="activeCreatureId"
:targets="targets"
@active-action-change="activeActionId = $event"
@remove="removeCreature(activeCreatureId)"
/>
</v-slide-y-reverse-transition>
</v-footer>
<tabletop-map
class="play-area"
style="
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
z-index: -50;
"
/>
</div>
</template>
@@ -102,6 +121,7 @@
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 TabletopLogStream from '/imports/client/ui/tabletop/TabletopLogStream.vue';
import Creatures from '/imports/api/creature/creatures/Creatures';
import { snackbar } from '/imports/client/ui/components/snackbars/SnackbarQueue.js';
import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties';
@@ -110,6 +130,8 @@ import SelectedCreatureBar from '/imports/client/ui/tabletop/selectedCreatureBar
import addCreaturesFromLibraryToTabletop from '/imports/api/tabletop/methods/addCreaturesFromLibraryToTabletop';
import removeCreatureFromTabletop from '/imports/api/tabletop/methods/removeCreatureFromTabletop';
import { getFilter } from '/imports/api/parenting/parentingFunctions';
import { mapMutations } from 'vuex';
import doAction from '/imports/client/ui/creature/actions/doAction';
const getProperties = function (creatureId, selector = {}) {
return CreatureProperties.find({
@@ -132,6 +154,7 @@ export default {
TabletopCreatureCard,
TabletopMap,
SelectedCreatureBar,
TabletopLogStream,
},
props: {
model: {
@@ -154,9 +177,24 @@ export default {
activeCreatureId(id) {
this.$root.$emit('active-tabletop-character-change', id);
},
activeActionId(id) {
activeActionId() {
this.targets = [];
},
targets(val) {
if (val.length === 1 && this.activeAction?.target === 'singleTarget') {
doAction({
propId: this.activeActionId,
creatureId: this.activeCreatureId,
targetIds: this.targets,
$store: this.$store,
elementId: 'tabletop-action-card',
}).catch((e) => {
console.error(e);
snackbar({ text: e.message || e.reason || e.toString() });
});
this.$refs.selectedCreatureBar.selectedIcon = undefined;
}
},
},
meteor: {
creatures(){
@@ -165,10 +203,11 @@ export default {
actions(){
return getProperties(this.activeCreatureId, { type: 'action', actionType: { $ne: 'event'} });
},
activeAction() {
return CreatureProperties.findOne(this.activeActionId);
},
moreTargets() {
// Disable portrait targeting for now, they aren't used by the action engine yet
return false;
const activeAction = CreatureProperties.findOne(this.activeActionId);
const activeAction = this.activeAction;
if (!activeAction) return;
if (activeAction.target === 'singleTarget') {
return this.targets.length === 0;
@@ -186,6 +225,9 @@ export default {
},
},
methods: {
...mapMutations([
'toggleDrawer',
]),
addCreature() {
this.$store.commit('pushDialogStack', {
component: 'select-creatures-dialog',

View File

@@ -37,8 +37,9 @@
v-if="showTargetBtn"
:color="targeted ? 'accent' : ''"
:elevation="targeted ? 8 : 2"
fab
style="margin-top: -16px;"
small
fab
@click.stop.prevent="targeted ? $emit('untarget') : $emit('target')"
>
<v-icon>{{ targeted ? 'mdi-target' : 'mdi-target' }}</v-icon>
@@ -50,12 +51,10 @@
<script lang="js">
import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties';
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: {
@@ -87,21 +86,9 @@ export default {
},
meteor: {
healthBars() {
const folderIds = CreatureProperties.find({
'root.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({
'root.id': this.model._id,
'parentId': {
$nin: folderIds,
},
type: 'attribute',
attributeType: 'healthBar',
healthBarNoDamage: { $ne: true },

View File

@@ -0,0 +1,43 @@
<template lang="html">
<div
class="d-flex flex-column-reverse"
style="overflow: auto;"
>
<tabletop-log-stream-entry
v-for="log in logs"
:key="log._id"
:model="log"
/>
</div>
</template>
<script lang="js">
import CreatureLogs from '/imports/api/creature/log/CreatureLogs';
import TabletopLogStreamEntry from '/imports/client/ui/tabletop/TabletopLogStreamEntry.vue';
export default {
components: {
TabletopLogStreamEntry,
},
props: {
tabletopId: {
type: String,
default: undefined,
},
},
meteor: {
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: 100
});
},
},
}
</script>

View File

@@ -0,0 +1,72 @@
<template lang="html">
<div class="pa-2 pt-0 my-2 stream-entry">
<v-list-item
v-if="model.creatureId"
dense
class="pl-0"
>
<v-list-item-avatar
:color="model.color || 'grey'"
size="32"
>
<img
v-if="creature.avatarPicture"
:src="creature.avatarPicture"
:alt="creature.name"
>
<span v-else>
{{ creature.name && creature.name[0] || '?' }}
</span>
</v-list-item-avatar>
<v-list-item-content>
<v-list-item-title>
{{ creature.name }}
</v-list-item-title>
</v-list-item-content>
</v-list-item>
<tabletop-log-content
v-if="model.text || (model.content && model.content.length)"
:model="model.content"
:show-silenced="showSilenced"
class="pl-10"
/>
</div>
</template>
<script lang="js">
import TabletopLogContent from '/imports/client/ui/log/TabletopLogContent.vue';
import Creatures from '/imports/api/creature/creatures/Creatures';
// TODO move content filtering to this component so we can determine if any content was hidden
// then show a button to reveal silenced content at a lower opacity
export default {
components: {
TabletopLogContent,
},
props: {
model: {
type: Object,
required: true,
},
showName: Boolean,
},
data() {
return {
showSilenced: false,
};
},
meteor: {
creature() {
return Creatures.findOne(this.model.creatureId);
},
}
}
</script>
<style lang="css" scoped>
.stream-entry {
background-color: hsl(0deg 0% 50% / 0.05);
border-radius: 2px;
}
</style>

View File

@@ -1,32 +1,9 @@
<template lang="html">
<v-app-bar
app
dark
clipped-right
dense
color="secondary"
>
<v-app-bar-nav-icon @click="toggleDrawer" />
<v-toolbar-title>
Tabletop
</v-toolbar-title>
<v-spacer />
<v-app-bar-nav-icon @click="toggleRightDrawer" />
</v-app-bar>
<div />
</template>
<script lang="js">
import { mapMutations } from 'vuex';
export default {
methods: {
...mapMutations([
'toggleDrawer',
'toggleRightDrawer',
]),
}
}
</script>
<style lang="css" scoped>
</style>

View File

@@ -30,6 +30,7 @@
transition: 'opacity 0.2s ease',
}"
:model="selectedProp"
:targets="targets"
data-id="tabletop-action-card"
@close-menu="menuOpen = false"
@dialog-opened="menuOpen = false"
@@ -186,6 +187,10 @@ export default {
type: String,
default: undefined,
},
targets: {
type: Array,
required: true,
},
},
data() {
return {