diff options
Diffstat (limited to 'src/components/Editor/Quest')
| -rw-r--r-- | src/components/Editor/Quest/QuestOptionsPanel.vue | 154 | ||||
| -rw-r--r-- | src/components/Editor/Quest/QuestTasksOptionsPanel.vue | 75 | ||||
| -rw-r--r-- | src/components/Editor/Quest/Task/TaskConfiguration.vue | 191 | ||||
| -rw-r--r-- | src/components/Editor/Quest/Task/TaskConfigurationRow.vue | 156 |
4 files changed, 576 insertions, 0 deletions
diff --git a/src/components/Editor/Quest/QuestOptionsPanel.vue b/src/components/Editor/Quest/QuestOptionsPanel.vue new file mode 100644 index 0000000..3495d60 --- /dev/null +++ b/src/components/Editor/Quest/QuestOptionsPanel.vue @@ -0,0 +1,154 @@ +<script setup lang="ts"> +import { useSessionStore, type EditorQuest } from '@/stores/session'; +import { computed } from 'vue'; +import EditorOptionsPanel from '@/components/Editor/EditorOptionsPanel.vue'; +import Checkbox from '@/components/Control/Checkbox.vue'; + +const props = defineProps<{ + questId: string; +}>(); + +const sessionStore = useSessionStore(); + +const quest = computed(() => { + return sessionStore.getQuestById(props.questId) as EditorQuest; +}); +const knownCategories = computed(() => { + return sessionStore.session.categories.map((category) => category.id); +}); +const knownQuests = computed(() => { + return sessionStore.session.quests.map((quest) => quest.id); +}); +</script> + +<template> + <EditorOptionsPanel v-if="quest"> + <div id="options"> + <div class="option-group"> + <label for="quest-category">Category</label> + <multiselect + id="quest-category" + v-model="quest.options.category" + :options="knownCategories" + :searchable="true" + placeholder="No category"></multiselect> + </div> + + <div class="option-group"> + <label for="quest-requirements">Requirements</label> + <multiselect + id="quest-requirements" + v-model="quest.options.requirements" + :options="knownQuests" + :searchable="true" + :taggable="true" + :multiple="true" + placeholder="Add requirement"></multiselect> + <p class="description"> + This quest will only be available if the player has completed all of the quests listed above. + </p> + </div> + + <h2>Quest options</h2> + + <div class="option-group"> + <Checkbox id="quest-permissionrequired" label="Require permission to start quest" + description="Players must have permission to start the quest." v-model="quest.options.permissionRequired" /> + </div> + + <div class="option-group"> + <Checkbox id="quest-cancellable" label="Allow players to cancel quest" + description="Players can cancel the quest after they have started it." v-model="quest.options.cancellable" /> + </div> + + <div class="option-group"> + <Checkbox id="quest-countstowardslimit" label="Count towards quest limit" + description="Quest will count towards the player's quest started limit." + v-model="quest.options.countsTowardsLimit" /> + </div> + + <div class="option-group"> + <Checkbox id="quest-repeatable" label="Allow players to repeat quest" + description="Quest can be completed again after it has been completed once." + v-model="quest.options.repeatable" /> + </div> + + <div class="option-group"> + <Checkbox id="quest-autostart" label="Automatically start quest" + description="Quest will start automatically when the player has unlocked it." + v-model="quest.options.autostart" /> + </div> + + + <h2>Cooldown</h2> + + <div class="option-group"> + <Checkbox id="quest-cooldown" label="Enable cooldown" + description="Players will have to wait a certain amount of time before they can start the quest again." + v-model="quest.options.cooldown.enabled" /> + </div> + + <div class="option-group"> + <label for="quest-cooldown-time"> + Cooldown (in seconds) + </label> + <input id="quest-cooldown-time" type="number" v-model="quest.options.cooldown.time" + :disabled="!quest.options.cooldown.enabled" /> + <p class="description"> + Common values are: <code>3600</code> (1 hour), <code>86400</code> (24 hours), <code>604800</code> (7 days), + <code>2592000</code> (30 days) + </p> + </div> + + <h2>Time limit</h2> + + <div class="option-group"> + <Checkbox id="quest-timelimit" label="Enable time limit" + description="Players will be required to complete the quest within a certain amount of time, otherwise it will be automatically cancelled." + v-model="quest.options.timeLimit.enabled" /> + </div> + + <div class="option-group"> + <label for="quest-timelimit-time"> + Time limit (in seconds) + </label> + <input id="quest-timelimit-time" type="number" v-model="quest.options.timeLimit.time" + :disabled="!quest.options.timeLimit.enabled" /> + <p class="description"> + Common values are: <code>3600</code> (1 hour), <code>86400</code> (24 hours), <code>604800</code> (7 days), + <code>2592000</code> (30 days) + </p> + </div> + </div> + </EditorOptionsPanel> +</template> + +<style src="vue-multiselect/dist/vue-multiselect.css" /> + +<style> +#options { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.option-group { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.description { + font-size: 0.8em; +} + +label { + font-weight: 700; +} + +h2 { + border-bottom: 1px solid var(--color-border); +} + +</style> + diff --git a/src/components/Editor/Quest/QuestTasksOptionsPanel.vue b/src/components/Editor/Quest/QuestTasksOptionsPanel.vue new file mode 100644 index 0000000..12b1263 --- /dev/null +++ b/src/components/Editor/Quest/QuestTasksOptionsPanel.vue @@ -0,0 +1,75 @@ +<script setup lang="ts"> +import { useSessionStore, type EditorQuest } from '@/stores/session'; +import { computed } from 'vue'; +import EditorOptionsPanel from '@/components/Editor/EditorOptionsPanel.vue'; +import TaskConfiguration from '@/components/Editor/Quest/Task/TaskConfiguration.vue'; +import Button from '@/components/Control/Button.vue'; + +const props = defineProps<{ + questId: string; +}>(); + +const sessionStore = useSessionStore(); + +const quest = computed(() => { + return sessionStore.getQuestById(props.questId) as EditorQuest; +}); +</script> + +<template> + <EditorOptionsPanel v-if="quest"> + <div id="options"> + <h2>Tasks <code>({{ Object.keys(quest.tasks).length }})</code></h2> + + <TaskConfiguration v-for="(task, taskId) in quest.tasks" :key="taskId" :taskId="String(taskId)" :quest="quest" /> + + <div id="controls"> + <Button + id="add-task" + :icon="['fas', 'fa-plus']" + type="solid" + label="Add task" + /> + </div> + </div> + </EditorOptionsPanel> +</template> + + +<style scoped> +#options { + display: flex; + flex-direction: column; + gap: 1rem; + + #controls { + display: flex; + justify-content: flex-end; + gap: 1rem; + } +} + +.option-group { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.description { + font-size: 0.8em; +} + +label { + font-weight: 700; +} + +h2 { + border-bottom: 1px solid var(--color-border); + + code { + font-size: 0.8em; + color: var(--color-text-mute); + } +} +</style> + diff --git a/src/components/Editor/Quest/Task/TaskConfiguration.vue b/src/components/Editor/Quest/Task/TaskConfiguration.vue new file mode 100644 index 0000000..5c6613a --- /dev/null +++ b/src/components/Editor/Quest/Task/TaskConfiguration.vue @@ -0,0 +1,191 @@ +<script setup lang="ts"> +import { useSessionStore, type EditorQuest } from '@/stores/session'; +import { computed } from 'vue'; +import Button from '@/components/Control/Button.vue'; +import TaskConfigurationRow from '@/components/Editor/Quest/Task/TaskConfigurationRow.vue'; + +const props = defineProps<{ + taskId: string; + quest: EditorQuest; +}>(); + +const sessionStore = useSessionStore(); + +const taskType = computed(() => props.quest.tasks[props.taskId].config.type); +const taskDefintion = computed(() => sessionStore.getTaskDefinitionByTaskType(taskType.value)); + +const taskConfig = computed(() => { + return Object.keys(props.quest.tasks[props.taskId].config).filter((fieldName) => fieldName !== 'type').reduce((acc, fieldName) => { + acc[fieldName] = props.quest.tasks[props.taskId].config[fieldName]; + return acc; + }, {} as { [key: string]: any }); +}); + +const requiredFields = computed(() => { + return Object.keys(taskDefintion.value.configuration).filter((fieldName) => taskDefintion.value.configuration[fieldName].required); +}); + +const givenRequiredFields = computed(() => { + return requiredFields.value.filter((fieldName) => taskConfig.value[fieldName]); +}); + +const missingFields = computed(() => { + return requiredFields.value.filter((fieldName) => !props.quest.tasks[props.taskId].config[fieldName]); +}); + +const remainingGivenFields = computed(() => { + return Object.keys(taskConfig.value).filter((fieldName) => !requiredFields.value.includes(fieldName)); +}); + +const configKeysOptions = computed(() => Object.keys(taskDefintion.value.configuration).filter((key) => !Object.keys(taskConfig.value).some((fieldName) => fieldName === key))); +// const configKeysOptions = computed(() => { +// const keys = Object.keys(taskDefintion.value.configuration).filter((key) => !Object.keys(taskConfig.value).some((fieldName) => fieldName === key)); +// +// return keys.map((key) => { +// return { +// value: key, +// description: taskDefintion.value.configuration[key].description, +// note: taskDefintion.value.configuration[key].note, +// }; +// }); +// }); + +const onAddOption = (option: any) => { + sessionStore.getQuestById(props.quest.id)!.tasks[props.taskId].config[option] = taskDefintion.value.configuration[option].default || null; +}; + +const updateValue = (fieldName: string, value: any) => { + sessionStore.getQuestById(props.quest.id)!.tasks[props.taskId].config[fieldName] = value; +}; + +const deleteValue = (fieldName: string) => { + delete sessionStore.getQuestById(props.quest.id)!.tasks[props.taskId].config[fieldName]; +}; + +</script> + +<template> + <div id="task-configuration-table"> + <div id="task-header"> + <p id="task-id"> + <span id="task-name"> + {{ props.taskId }} + </span> + <code> + ({{ taskType }}) + </code> + </p> + <div id="task-controls"> + <Button + :icon="['fas', 'fa-pen']" + :label="'Change'" + ></Button> + <Button + :icon="['fas', 'fa-trash']" + :label="'Delete'" + ></Button> + </div> + </div> + <div id="task-configuration"> + <div v-if="!taskDefintion" class="error"> + <font-awesome-icon id="error-icon" :icon="['fas', 'fa-triangle-exclamation']"/> + <p id="error-message"> + Unable to edit task <code>{{ props.taskId }}</code>. + </p> + <p id="error-description"> + The quests web editor does not know how to configure task + type <code>{{ taskType }}</code> as it has no task definition. + </p> + </div> + + <div v-if="taskDefintion"> + <TaskConfigurationRow + v-for="fieldName in [...givenRequiredFields, ...missingFields, ...remainingGivenFields]" + :key="fieldName" + :required="requiredFields.includes(fieldName)" + :configKey="fieldName" + :initialValue="taskConfig[fieldName]" + :taskType="taskType" + :type="(taskDefintion.configuration[fieldName].type as string)" + @update="newValue => updateValue(fieldName, newValue)" + @delete="() => deleteValue(fieldName)" + /> + <div id="add-option"> + <multiselect + class="multiselect" + :options="configKeysOptions" + :searchable="true" + @select="onAddOption" + placeholder="Add option..."> + </multiselect> + </div> + </div> + </div> + </div> +</template> + +<style scoped> +.error { + padding: 0.5rem 0.5rem 0.5rem calc(0.5rem + 20px); + + #error-icon { + float: left; + margin: 5px 0 0 -20px; + } + + #error-message { + font-weight: 700; + } + +} + +#task-configuration-table { + display: flex; + flex-direction: column; + border: 1px solid var(--color-border); + + #task-header { + display: flex; + justify-content: space-between; + border-bottom: 1px solid var(--color-border); + background-color: var(--color-background-soft); + padding: 0.5rem; + + #task-id { + font-size: 1.2em; + + #task-name { + font-weight: 700; + } + + code { + font-size: 0.8em; + color: var(--color-text-mute); + } + } + + #task-controls { + display: flex; + gap: 1rem; + } + } +} + +#add-option { + width: 100%; + border-right: 1px solid var(--color-border); + border-top: 1px solid var(--color-border); +} + +.multiselect::v-deep .multiselect__tags { + border: none !important; + border-radius: 0px !important; + background: transparent !important; +} + +.multiselect::v-deep .multiselect__select { + background: transparent !important; +} + +</style> + diff --git a/src/components/Editor/Quest/Task/TaskConfigurationRow.vue b/src/components/Editor/Quest/Task/TaskConfigurationRow.vue new file mode 100644 index 0000000..fb872a8 --- /dev/null +++ b/src/components/Editor/Quest/Task/TaskConfigurationRow.vue @@ -0,0 +1,156 @@ +<script setup lang="ts"> +import { useSessionStore } from '@/stores/session'; +import { computed, ref, toRefs, watch } from 'vue'; +import TrueFalseSwitch from '@/components/Control/TrueFalseSwitch.vue'; +import materials from '@/data/materials.json'; + +const props = defineProps({ + taskType: { + type: String, + required: true, + }, + configKey: { + type: String, + required: true, + }, + initialValue: null, + type: String, + required: Boolean, +}); +const emit = defineEmits(['update', 'delete']); + +const sessionStore = useSessionStore(); + +const definition = computed(() => { + const def = sessionStore.getTaskDefinitionByTaskType(props.taskType).configuration[props.configKey]; + return { description: def.description, note: def.note }; +}); + +const { description, note } = toRefs(definition.value); +const showDescription = ref(false); +const currentValue = ref(props.initialValue); + +const error = computed(() => currentValue.value === undefined || currentValue.value === null || currentValue.value === ''); +const updateValue = (value: any) => { + currentValue.value = value; +}; + +watch(currentValue, () => { + emit('update', currentValue.value); +}); + +</script> + +<template> + <div id="task-configuration-row"> + <div id="key"> + <div id="delete" @click="emit('delete')" v-if="!props.required" class="delete"> + <font-awesome-icon :icon="['fas', 'fa-xmark']" /> + </div> + <p id="name" @click="showDescription = !showDescription">{{ props.configKey }}</p> + </div> + <div id="value"> + <div id="value-container"> + <input v-if="props.type === 'string'" v-model="currentValue" /> + <input v-else-if="props.type === 'number'" type="number" v-model="currentValue" /> + <TrueFalseSwitch v-else-if="props.type === 'boolean'" :value="!!currentValue" @update="updateValue" /> + <multiselect v-else-if="props.type === 'material-list'" :value="currentValue" :options="materials" + :multiple="true" :taggable="true" :searchable="true" placeholder="Enter material name"></multiselect> + </div> + <div v-if="showDescription || error" id="task-configuration-row-info"> + <p v-if="error" class="error">A value is required.</p> + <p>{{ description }} <i>{{ note }}</i></p> + </div> + </div> + </div> +</template> + +<style scoped> +#task-configuration-row { + display: flex; + flex-direction: row; + transition: background-color 0.3s; + border-bottom: 1px solid var(--color-border); + + #key { + width: 25%; + background-color: var(--color-background); + display: flex; + flex-direction: row; + align-items: center; + user-select: none; + + #delete { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 100%; + cursor: pointer; + color: var(--color-text-mute); + border-right: 1px solid var(--color-border); + background-color: var(--color-background-soft); + transition: color 0.3s; + + &:hover { + color: var(--color-false); + } + } + #name { + display: flex; + align-items: center; + font-size: 0.8rem; + padding: 0.5rem; + width: 100%; + height: 100%; + font-family: monospace; + cursor: pointer; + transition: background-color 0.3s; + + &:hover { + background-color: var(--color-hover); + } + } + } + + #value { + width: 75%; + background-color: var(--color-background); + border-left: 1px solid var(--color-border); + } +} + +#task-configuration-row:hover { + background-color: var(--color-hover); +} + +#task-configuration-row-info { + padding: 0.25rem 0.5rem; + font-size: 0.8em; + background-color: var(--color-background); + border-top: 1px solid var(--color-border); +} + +input { + width: 100%; + padding: 0.5rem; + border-radius: 0; + border: none; + font-family: monospace; + font-size: 0.8rem; + height: 40px; +} + +.error { + color: var(--color-false); +} + +.multiselect::v-deep .multiselect__tags { + border: unset !important; + border-radius: 0px !important; +} + +.multiselect::v-deep .multiselect__select { + background: unset !important; +} +</style>
\ No newline at end of file |
