Big improvements in UX for tabletop actions

This commit is contained in:
ThaumRystra
2025-01-24 16:13:36 +02:00
parent 5b68190570
commit 0b499b9c98
15 changed files with 225 additions and 205 deletions

View File

@@ -43,12 +43,17 @@ let CreatureLogSchema = new SimpleSchema({
type: String,
index: 1,
},
// The tabletops this log is associated with
// The tabletop this log is associated with
tabletopId: {
type: String,
optional: true,
index: 1,
},
// The action that caused this log entry
actionId: {
type: String,
optional: true,
},
creatureName: {
type: String,
optional: true,

View File

@@ -27,10 +27,12 @@ export default async function writeActionResults(action: EngineAction) {
content: logContents,
creatureId: action.creatureId,
tabletopId: action.tabletopId,
actionId: action._id,
});
// Write the bulk updates
const bulkWritePromise = bulkWrite(creaturePropUpdates, CreatureProperties);
// Write the bulk updates, force them to sequential mode means we immediately get the results
// in the subscription, rather than waiting for oplog tailing to catch up
const bulkWritePromise = bulkWrite(creaturePropUpdates, CreatureProperties, true);
await Promise.all([engineActionPromise, logPromise, bulkWritePromise]);

View File

@@ -116,24 +116,27 @@ export default async function applyDamagePropTask(
if (increment > currentValue && !targetProp.ignoreLowerLimit) increment = currentValue;
// Can't decrease damage below zero
if (-increment > currentDamage && !targetProp.ignoreUpperLimit) increment = -currentDamage;
damage = currentDamage + increment;
newValue = targetProp.total - damage;
// Write the results
result.mutations.push({
targetIds: [targetId],
updates: [{
propId: targetProp._id,
inc: { damage: increment, value: -increment },
type: targetProp.type,
}],
contents: [{
name: increment >= 0 ? 'Attribute damaged' : 'Attribute restored',
value: `${numberToSignedString(-increment)} ${getPropertyTitle(targetProp)}`,
inline: true,
...task.silent && { silenced: true },
}]
});
if (targetId === action.creatureId) setScope(result, targetProp, newValue, damage);
// Only increment if the increment is non-zero
if (increment !== 0) {
damage = currentDamage + increment;
newValue = targetProp.total - damage;
// Write the results
result.mutations.push({
targetIds: [targetId],
updates: [{
propId: targetProp._id,
inc: { damage: increment, value: -increment },
type: targetProp.type,
}],
contents: [{
name: increment >= 0 ? 'Attribute damaged' : 'Attribute restored',
value: `${numberToSignedString(-increment)} ${getPropertyTitle(targetProp)}`,
inline: true,
...task.silent && { silenced: true },
}]
});
if (targetId === action.creatureId) setScope(result, targetProp, newValue, damage);
}
}
await applyTriggers(action, targetProp, [targetId], 'damageTriggerIds.after', userInput);
await applyTriggers(action, targetProp, [targetId], 'damageTriggerIds.afterChildren', userInput);

View File

@@ -2,10 +2,10 @@
// 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 async function bulkWrite<T>(bulkWriteOps, collection: Mongo.Collection<T>): Promise<any> {
export default async function bulkWrite<T>(bulkWriteOps, collection: Mongo.Collection<T>, forceSequential?: true): Promise<any> {
if (!bulkWriteOps.length) return;
// bulkWrite is only available on the server
if (!Meteor.isServer) {
if (!Meteor.isServer || forceSequential) {
return writePropertiesSequentially(bulkWriteOps, collection);
}
return collection.rawCollection().bulkWrite(

View File

@@ -1,20 +1,11 @@
<template lang="html">
<div class="d-flex flex-column">
<v-toolbar
class="base-dialog-toolbar"
>
<v-btn
icon
@click="cancel"
<div class="overflow-visible">
<v-slide-x-reverse-transition hide-on-leave>
<v-card
:key="`${activeInput}`"
elevation="6"
class="action-dialog"
>
<v-icon>mdi-arrow-left</v-icon>
</v-btn>
<v-toolbar-title>
Action
</v-toolbar-title>
</v-toolbar>
<div class="action-dialog-content">
<div class="action-dialog-layout d-flex">
<component
:is="activeInput"
v-if="activeInput"
@@ -26,43 +17,23 @@
/>
<div
v-else
class="action-input"
/>
<div
class="log-preview card-raised-background d-flex flex-column align-end justify-end"
style="flex-basis: 256px;"
class="log-preview card-raised-background"
>
<v-card
v-if="allLogContent && allLogContent.length"
class="ma-2 log-entry"
>
<v-card-text
class="pa-2"
>
<log-content :model="allLogContent" />
</v-card-text>
</v-card>
<tabletop-log-stream-entry :model="simulatedLog" />
</div>
</div>
</div>
<div class="action-dialog-actions pa-2 d-flex justify-end">
<v-btn
v-if="actionDone"
text
color="accent"
@click="finishAction"
>
Done
</v-btn>
<v-btn
v-else
text
color="accent"
@click="continueAction"
>
Next
</v-btn>
</div>
<v-btn
v-if="!activeInput"
large
text
color="accent"
style="width: 100%"
class="done-button"
@click="finishAction"
>
Done
</v-btn>
</v-card>
</v-slide-x-reverse-transition>
</div>
</template>
@@ -79,6 +50,9 @@ import LogContent from '/imports/client/ui/log/LogContent.vue';
//import RollInput from '/imports/client/ui/creature/actions/input/RollInput.vue';
import TargetsInput from '/imports/client/ui/creature/actions/input/TargetsInput.vue';
import CastSpellInput from '/imports/client/ui/creature/actions/input/CastSpellInput.vue';
import { runAction } from '/imports/api/engine/action/methods/runAction';
import TabletopLogStreamEntry from '/imports/client/ui/tabletop/TabletopLogStreamEntry.vue';
import mutationToLogUpdates from '/imports/api/engine/action/functions/mutationToLogUpdates';
export default {
components: {
@@ -90,6 +64,7 @@ export default {
//RollInput,
TargetsInput,
CastSpellInput,
TabletopLogStreamEntry,
},
props: {
actionId: {
@@ -100,6 +75,10 @@ export default {
type: Object,
default: undefined,
},
actionFinishedCallback: {
type: Function,
default: undefined,
}
},
data() {
return {
@@ -112,6 +91,7 @@ export default {
activeInputParams: {},
userInput: undefined,
userInputReady: true,
actionPromise: undefined,
};
},
computed: {
@@ -121,17 +101,19 @@ export default {
resultJson() {
return JSON.stringify(this.actionResult, null, 2);
},
allLogContent() {
simulatedLog() {
const action = this.actionResult;
const contents = [];
action?.results?.forEach(result => {
result.mutations?.forEach(mutation => {
mutation.contents?.forEach(logContent => {
contents.push(logContent);
});
const content = [];
action?.results.forEach(result => {
result.mutations.forEach(mutation => {
content.push(...mutationToLogUpdates(mutation));
});
});
return contents;
return {
content,
creatureId: action?.creatureId,
tabletopId: action?.tabletopId,
}
},
},
meteor: {
@@ -144,7 +126,7 @@ export default {
this.startAction({ stepThrough: false });
},
methods: {
startAction({ stepThrough }) {
async startAction({ stepThrough }) {
this.actionBusy = true;
this.actionResult = {
...this.action,
@@ -152,13 +134,15 @@ export default {
_isSimulation: undefined,
taskCount: undefined,
};
applyAction(
this.actionResult, this, { simulate: true, stepThrough}
).then(() => {
this.actionDone = true;
this.actionBusy = false;
this.activeInput = undefined;
await applyAction(this.actionResult, this, { simulate: true, stepThrough });
const actionResult = await runAction.callAsync({
actionId: this.actionResult._id,
decisions: this.actionResult._decisions
});
this.actionDone = true;
this.actionBusy = false;
this.activeInput = undefined;
if (this.actionFinishedCallback) this.actionFinishedCallback(actionResult);
},
stepAction() {
if (this.actionResult) {
@@ -172,7 +156,7 @@ export default {
}
this.resumeActionFn?.();
},
finishAction() {
async finishAction() {
this.$store.dispatch('popDialogStack', this.actionResult);
},
promiseInput() {
@@ -196,6 +180,8 @@ export default {
},
// inputProvider methods
async targetIds(target) {
// Only get targets if we are on a tabletop
if (this.$router.currentRoute.name !== 'tabletop') return [];
this.userInput = [];
this.activeInputParams = {
target,
@@ -251,43 +237,19 @@ export default {
</script>
<style lang="css" scoped>
.base-dialog-toolbar {
z-index: 2;
border-radius: 2px 2px 0 0;
}
.action-dialog-content {
container-type: size;
flex-grow: 1;
overflow: auto;
}
.action-dialog-content, .action-dialog-layout {
height: 100%;
.action-dialog {
max-height: min(100%, 800px);
max-width: min(100%, 1000px);
min-width: 300px;
}
.action-input {
flex-grow: 1;
height: 100%;
overflow-y: auto;
}
.log-preview {
flex-basis: 256px;
height: 100%;
overflow-y: auto;
flex-basis: 300px;
}
@container (max-width: 600px) {
.action-dialog-layout {
flex-direction: column;
}
.action-input {
height: unset;
}
.log-preview {
flex-basis: 300px;
}
}
</style>

View File

@@ -12,6 +12,7 @@ type BaseDoActionParams = {
creatureId: string;
$store: Store<any>;
elementId: string;
callback?: (action: EngineAction) => void;
}
type DoTaskParams = BaseDoActionParams & {
@@ -32,7 +33,7 @@ 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, targetIds }: DoActionParams | DoTaskParams): Promise<any | void> {
export default async function doAction({ propId, creatureId, $store, elementId, task, targetIds, callback }: DoActionParams | DoTaskParams): Promise<any | void> {
if (!task) {
targetIds ??= [];
if (!propId) throw new Meteor.Error('no-prop-id', 'Either propId or task must be provided');
@@ -58,8 +59,6 @@ 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
@@ -73,26 +72,21 @@ export default async function doAction({ propId, creatureId, $store, elementId,
return callActionMethod(finishedAction);
} catch (e) {
if (e !== 'input-requested') throw e;
return new Promise<void>((resolve, reject) => {
return new Promise<void>((resolve) => {
$store.commit('pushDialogStack', {
component: 'action-dialog',
elementId,
data: {
actionId,
task,
actionFinishedCallback: resolve,
},
async callback(action: EngineAction) {
try {
if (action) await callActionMethod(action);
resolve();
}
catch (e) {
reject(e);
}
return elementId;
callback(action) {
resolve();
return callback?.(action);
},
});
})
});
}
}

View File

@@ -1,5 +1,8 @@
<template>
<div class="choice-input">
<v-card-title>
{{ target === 'singleTarget' ? 'Target' : 'Targets' }}
</v-card-title>
<v-list-item
v-for="creature in creatures"
:key="creature._id"
@@ -37,9 +40,14 @@
</v-list-item-content>
</v-list-item>
<v-btn
large
text
color="accent"
class="mt-4"
style="width: 100%"
@click="$emit('continue');"
>
Done
{{ !value.length ? 'No target' : 'Continue' }}
</v-btn>
</div>
</template>
@@ -73,6 +81,10 @@ export default {
newValue = [...this.value, id];
}
this.$emit('input', newValue);
// If we are selecting a single creature, we are done
if (this.target === 'singleTarget') {
this.$emit('continue');
}
},
},
meteor: {
@@ -87,4 +99,4 @@ export default {
}
}
};
</script>
</script>

View File

@@ -139,11 +139,10 @@ import CharacterTab from '/imports/client/ui/creature/character/characterSheetTa
import BuildTab from '/imports/client/ui/creature/character/characterSheetTabs/BuildTab.vue';
import TreeTab from '/imports/client/ui/creature/character/characterSheetTabs/TreeTab.vue';
import { assertEditPermission } from '/imports/api/creature/creatures/creaturePermissions';
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';
import CreatureLogs from '/imports/api/creature/log/CreatureLogs';
export default {
components: {
@@ -156,7 +155,6 @@ export default {
BuildTab,
TreeTab,
CharacterSheetFab,
CharacterSheetInitiative,
},
props: {
creatureId: {
@@ -165,7 +163,7 @@ export default {
},
embedded: Boolean,
},
// @ts-ignore
// @ts-expect-error reactive provide not typed
reactiveProvide: {
name: 'context',
include: ['creatureId', 'editPermission'],
@@ -197,29 +195,29 @@ export default {
changed: ({ name }) =>
this.$store.commit('setPageTitle', name || 'Character Sheet'),
});
let that = this;
this.logObserver = CreatureLogs.find({
creatureId: this.creatureId,
}).observe({
added({ content }) {
if (!that.$subReady.singleCharacter) return;
if (that.$store.state.rightDrawer) return;
snackbar({ content });
},
});
if (this.$route.name === 'characterSheet') {
let that = this;
this.logObserver = CreatureLogs.find({
creatureId: this.creatureId,
}).observe({
added({ content }) {
if (!that.$subReady.singleCharacter) return;
if (that.$store.state.rightDrawer) return;
if (that.$store.state.dialogStack.dialogs.length) return;
snackbar({ content });
},
});
}
},
beforeDestroy() {
this.nameObserver.stop();
this.logObserver.stop();
this.nameObserver?.stop();
this.logObserver?.stop();
},
meteor: {
$subscribe: {
'singleCharacter'() {
return [this.creatureId];
},
'otherTabletopCreatures'() {
return [this.creatureId];
},
},
creature() {
return Creatures.findOne(this.creatureId, {

View File

@@ -65,7 +65,7 @@
// Use in combination with browser's animation speed override to do slow-mod debugging
const animationSpeed = 1;
const unsizedDialogs = new Set(['image-preview-dialog']);
const unsizedDialogs = new Set(['image-preview-dialog', 'action-dialog']);
export default {
components: {
@@ -129,7 +129,7 @@
const num = length - 1;
const left = (num - index) * -OFFSET;
const top = (num - index) * -OFFSET;
return `left: calc(${left}px + 50%); top: calc(${top}px + 50%)`;
return `left: calc(${left}px + 50%); top: calc(${top}px + 50%);${index < num ? ' filter: brightness(0.7);': ''}`;
},
getTopElementByDataId(elementId, offset = 0){
let stackLength = this.$store.state.dialogStack.dialogs.length - offset;
@@ -166,7 +166,16 @@
// Instantly mock the source
target.style.transition = 'none';
mockElement({ source, target });
// If we are using unsized dialogs, first let it layout with no opacity, then mock and
// carry on, otherwise it has no size
if (target.classList.contains('unsized-dialog')) {
target.style.opacity = '0';
await new Promise(requestAnimationFrame);
mockElement({ source, target });
target.style.opacity = '1';
} else {
mockElement({ source, target });
}
// Wait one frame before hiding the source so we know our mock is in place
await new Promise(requestAnimationFrame);

View File

@@ -1,41 +1,51 @@
<template lang="html">
<v-container
v-if="!$subReady.tabletop"
key="Tabletop"
fluid
class="fill-height"
align="center"
justify="center"
>
<v-row>
<v-col cols="1">
<v-progress-circular indeterminate />
</v-col>
</v-row>
</v-container>
<tabletop-component
v-else-if="tabletop"
:model="tabletop"
/>
<v-container
v-else
fluid
class="fill-height"
align="center"
justify="center"
>
<v-row
class="pa-4"
<v-fade-transition mode="out-in">
<v-container
v-if="!$subReady.tabletop"
key="Loading"
fluid
class="fill-height"
align="center"
justify="center"
>
<v-col
cols="12"
md="8"
<v-row justify="center">
<v-col cols="1">
<v-progress-circular
:size="100"
:width="10"
color="primary"
style="opacity: 0.5"
indeterminate
/>
</v-col>
</v-row>
</v-container>
<tabletop-component
v-else-if="tabletop"
key="Tabletop"
:model="tabletop"
/>
<v-container
v-else
key="Not Found"
fluid
class="fill-height"
align="center"
justify="center"
>
<v-row
class="pa-4"
>
<p>This tabletop was not found</p>
<p>Either it does not exist, or you do not have permission to view it</p>
</v-col>
</v-row>
</v-container>
<v-col
cols="12"
md="8"
>
<p>This tabletop was not found</p>
<p>Either it does not exist, or you do not have permission to view it</p>
</v-col>
</v-row>
</v-container>
</v-fade-transition>
</template>
<script lang="js">

View File

@@ -224,6 +224,7 @@ export default {
targetIds: this.targets,
$store: this.$store,
elementId: 'do-action-button',
callback: action => action?._id || this.model._id,
}).catch((e) => {
console.error(e);
snackbar({ text: e.message || e.reason || e.toString() });

View File

@@ -8,8 +8,8 @@
>
<v-row
dense
class="initiative-row flex-grow-0"
style="flex-wrap: nowrap; overflow-x: auto; padding-bottom: 50px; min-width: 200px;"
class="initiative-row flex-grow-0 overflow-x-auto"
style="flex-wrap: nowrap; padding-bottom: 64px; min-width: 200px;"
@wheel="transformScroll($event)"
>
<v-btn
@@ -69,7 +69,7 @@
</v-row>
<div
class="d-flex align-stretch"
style="max-height: calc(100vh - 364px);"
style="max-height: calc(100vh - 364px); margin-top: -24px;"
>
<v-spacer />
<tabletop-log-stream
@@ -188,9 +188,12 @@ export default {
targetIds: this.targets,
$store: this.$store,
elementId: 'tabletop-action-card',
callback: action => action?._id || this.activeActionId,
}).catch((e) => {
console.error(e);
snackbar({ text: e.message || e.reason || e.toString() });
}).finally(() => {
this.doActionLoading = false;
});
this.$refs.selectedCreatureBar.selectedIcon = undefined;
}

View File

@@ -28,7 +28,7 @@
:key="bar._id"
:model="bar"
:height="4"
style="opacity: 0.7; margin-top: 2px"
style="opacity: 0.8; margin-top: 2px"
/>
</v-img>
<div class="d-flex justify-center">
@@ -37,7 +37,6 @@
v-if="showTargetBtn"
:color="targeted ? 'accent' : ''"
:elevation="targeted ? 8 : 2"
style="margin-top: -16px;"
small
fab
@click.stop.prevent="targeted ? $emit('untarget') : $emit('target')"

View File

@@ -6,6 +6,8 @@
<tabletop-log-stream-entry
v-for="log in logs"
:key="log._id"
class="stream-entry"
:class="{'hidden': hideAction(log.actionId)}"
:model="log"
/>
</div>
@@ -14,6 +16,7 @@
<script lang="js">
import CreatureLogs from '/imports/api/creature/log/CreatureLogs';
import TabletopLogStreamEntry from '/imports/client/ui/tabletop/TabletopLogStreamEntry.vue';
import dialogStackStore from '/imports/client/ui/dialogStack/dialogStackStore.js';
export default {
components: {
@@ -25,6 +28,17 @@ export default {
default: undefined,
},
},
computed: {
openActionDialogs(){
const dialogs = this.$store.state.dialogStack.dialogs;
return new Set(dialogs.map(dialog => dialog.data?.actionId).filter(actionId => !!actionId));
},
},
methods: {
hideAction(actionId){
return this.openActionDialogs.has(actionId);
},
},
meteor: {
logs() {
const filter = {};
@@ -41,3 +55,14 @@ export default {
},
}
</script>
<style lang="css" scoped>
.stream-entry {
background-color: hsl(0deg 0% 50% / 0.05);
border-radius: 2px;
}
.hidden {
opacity: 0;
}
</style>

View File

@@ -1,7 +1,10 @@
<template lang="html">
<div class="pa-2 pt-0 my-2 stream-entry">
<div
class="pa-2 pt-0 my-1 rounded-sm"
:data-id="model.actionId"
>
<v-list-item
v-if="model.creatureId"
v-if="model.creatureId && creature"
dense
class="pl-0"
>
@@ -64,9 +67,3 @@ export default {
}
</script>
<style lang="css" scoped>
.stream-entry {
background-color: hsl(0deg 0% 50% / 0.05);
border-radius: 2px;
}
</style>