Progress on visual rework of the character editor

This commit is contained in:
Peaceultime 2025-07-09 23:58:24 +02:00
parent da5c1202ed
commit 996b9711e4
13 changed files with 360 additions and 288 deletions

View File

@ -31,7 +31,7 @@ function focusTraining(stat: MainStat, level: TrainingLevel, option: number)
<template>
<TrainingViewer :config="config" progress>
<template #default="{ stat, level, option }">
<div @click.capture="console.log" class="border border-light-40 dark:border-dark-40 hover:border-light-70 dark:hover:border-dark-70 cursor-pointer px-2 py-1 w-96" :class="{ '!border-accent-blue': selection !== undefined && selection?.stat == stat && selection?.level == level && selection?.option == option }">
<div @click.capture="console.log" class="border border-light-40 dark:border-dark-40 hover:border-light-70 dark:hover:border-dark-70 cursor-pointer px-2 py-1 w-[400px]" :class="{ '!border-accent-blue': selection !== undefined && selection?.stat == stat && selection?.level == level && selection?.option == option }">
<MarkdownRenderer :proses="{ 'a': PreviewA }" :content="config.training[stat][level][option].description.map(e => e.text).join('\n')" />
</div>
</template>

View File

@ -1,68 +1,50 @@
<script setup lang="ts">
import { clamp } from '#shared/general.util';
import { MAIN_STATS, mainStatTexts, type CharacterConfig } from '~/types/character';
const { config, progress = false } = defineProps<{
config: CharacterConfig,
progress?: boolean,
const { config } = defineProps<{
config: CharacterConfig
}>();
//const dragger = useTemplateRef('dragger');
const position = ref(0);
//const dragging = ref(false), offset = ref(0);
/* const dragend = () => {
window.removeEventListener('mousemove', dragmove);
window.removeEventListener('mouseup', dragend);
dragging.value = false;
};
const dragmove = (e: MouseEvent) => {
offset.value = clamp(offset.value - e.movementY, 0, 100);
if(dragger.value) dragger.value.scrollTop = offset.value;
};
const dragstart = () => {
window.addEventListener('mousemove', dragmove);
window.addEventListener('mouseup', dragend);
dragging.value = true;
};
onMounted(() => {
dragger.value?.addEventListener('mousedown', dragstart);
});
onUnmounted(() => {
dragger.value?.removeEventListener('mousedown', dragstart);
}); */
</script>
<template>
<div class="flex flex-col relative max-w-full">
<div class="sticky top-0 w-full bg-light-0 dark:bg-dark-0 z-20 flex pt-2 pb-6">
<div class="flex flex-row gap-3 items-center relative left-6 w-48">
<span v-for="(stat, i) of MAIN_STATS" :value="stat" class="block w-2.5 h-2.5 m-px outline outline-1 outline-transparent
hover:outline-light-70 dark:hover:outline-dark-70 rounded-full bg-light-40 dark:bg-dark-40 cursor-pointer" @click="position = i"></span>
<span :style="{ 'left': position * 1.5 + 'em' }" :data-text="mainStatTexts[MAIN_STATS[position]]" class="rounded-full w-3 h-3 bg-accent-blue absolute transition-[left]
after:content-[attr(data-text)] after:absolute after:-translate-x-1/2 after:top-4 after:p-px after:bg-light-0 dark:after:bg-dark-0 after:text-center"></span>
</div>
<div class="relative left-6">
<slot name="addin" :stat="MAIN_STATS[position]"></slot>
</div>
<div class="flex flex-1 gap-12 px-2 py-4 justify-center items-center sticky top-0 bg-light-0 dark:bg-dark-0 w-full z-10 min-h-20">
<div class="flex flex-shrink gap-3 items-center relative w-48 ms-12">
<span v-for="(stat, i) of MAIN_STATS" :value="stat" class="block w-2.5 h-2.5 m-px outline outline-1 outline-transparent
hover:outline-light-70 dark:hover:outline-dark-70 rounded-full bg-light-40 dark:bg-dark-40 cursor-pointer" @click="position = i"></span>
<span :style="{ 'left': position * 1.5 + 'em' }" :data-text="mainStatTexts[MAIN_STATS[position]]" class="rounded-full w-3 h-3 bg-accent-blue absolute transition-[left]
after:content-[attr(data-text)] after:absolute after:-translate-x-1/2 after:top-4 after:p-px after:bg-light-0 dark:after:bg-dark-0 after:text-center"></span>
</div>
<div class="flex flex-1 px-8 overflow-hidden max-w-full">
<div class="relative cursor-grab active:cursor-grabbing select-none transition-[left] flex flex-1 flex-row max-w-full" :style="{ 'left': `-${position * 100}%` }">
<div class="flex flex-shrink-0 flex-col gap-4 relative w-full overflow-y-auto px-20" v-for="(stat, name) in config.training" >
<div class="flex flex-row gap-2 justify-center relative" v-for="(options, level) in stat">
<template v-if="progress">
<div class="absolute left-0 right-0 -top-2 h-px border-t border-light-30 dark:border-dark-30 border-dashed">
<span class="absolute right-0 p-1 text-end">{{ level }}</span>
</div>
</template>
<div class="flex-1 flex">
<slot name="addin" :stat="MAIN_STATS[position]"></slot>
</div>
<span></span>
</div>
<div class="flex flex-1 px-8 overflow-hidden max-w-full">
<div class="relative cursor-grab active:cursor-grabbing select-none transition-[left] flex flex-1 flex-row max-w-full" :style="{ 'left': `-${position * 100}%` }">
<div class="flex flex-shrink-0 flex-col gap-4 relative w-full overflow-y-auto px-20" v-for="(stat, name) in config.training">
<template v-for="(options, level) of stat">
<div class="w-full flex h-px"><div class="border-t border-dashed border-light-50 dark:border-dark-50 w-full"></div><span class="relative left-4">{{ level }}</span></div>
<div class="flex flex-row gap-4 justify-center">
<template v-for="(option, i) in options">
<slot :stat="name" :level="level" :option="i"></slot>
</template>
</div>
</div>
</template>
</div>
<!-- <div class="flex flex-shrink-0 flex-col gap-4 relative w-full overflow-y-auto px-20" v-for="(stat, name) in config.training" >
<div class="flex flex-row gap-2 justify-center relative" v-for="(options, level) in stat">
<template v-if="progress">
<div class="absolute left-0 right-0 -top-2 h-px border-t border-light-30 dark:border-dark-30 border-dashed">
<span class="absolute right-0 p-1 text-end">{{ level }}</span>
</div>
</template>
<template v-for="(option, i) in options">
<slot :stat="name" :level="level" :option="i"></slot>
</template>
</div>
</div> -->
</div>
</div>
</template>

View File

@ -4,7 +4,7 @@
<span tabindex="0"><slot></slot></span>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent class="TooltipContent border border-light-30 dark:border-dark-30 px-2 py-1 bg-light-10 dark:bg-dark-10 text-light-70 dark:text-dark-70 z-50" :side="side" :side-offset="['left', 'right'].includes(side ?? '') ? 8 : 0">
<TooltipContent class="TooltipContent border border-light-30 dark:border-dark-30 px-2 py-1 bg-light-10 dark:bg-dark-10 text-light-70 dark:text-dark-70 z-50" :class="$attrs.class" :side="side" :align="align" :align-offset="-16" :side-offset="['left', 'right'].includes(side ?? '') ? 8 : 0">
{{ message }}
<TooltipArrow class="fill-light-30 dark:fill-dark-30"></TooltipArrow>
</TooltipContent>
@ -15,9 +15,10 @@
<script setup lang="ts">
const { message, delay = 300, side } = defineProps<{
message: string
delay?: number,
delay?: number
disabled?: boolean
side?: 'left' | 'right' | 'top' | 'bottom'
align: 'start' | 'center' | 'end'
}>();
</script>

View File

@ -0,0 +1,38 @@
<template>
<template v-if="model && model.people !== undefined">
<div class="flex flex-1 gap-12 px-2 py-4 justify-center items-center sticky top-0 bg-light-0 dark:bg-dark-0 w-full z-10">
<Label class="flex items-center justify-between gap-2">
<span class="pb-1 mx-2 md:p-0">Points restants</span>
<NumberFieldRoot disabled :v-model="0" class="flex justify-center border border-light-25 dark:border-dark-25 bg-light-10 dark:bg-dark-10 text-light-60 dark:text-dark-60">
<NumberFieldInput class="tabular-nums w-20 bg-transparent px-3 py-1 outline-none caret-light-50 dark:caret-dark-50" />
</NumberFieldRoot>
</Label>
<Button @click="emit('next')">Suivant</Button>
</div>
<div class="flex flex-row flex-wrap justify-center items-center flex-1 gap-12 mx-8 my-4 px-48">
<template v-for="ability of config.abilities">
<div class="flex flex-col border border-light-50 dark:border-dark-50 p-4 gap-2 w-[200px] relative">
<div class="flex justify-between">
<NumberFieldRoot :min="0" class="flex w-20 justify-center border border-light-35 dark:border-dark-35 bg-light-20 dark:bg-dark-20 hover:border-light-50 dark:hover:border-dark-50 has-[:focus]:shadow-raw transition-[box-shadow] has-[:focus]:shadow-light-40 dark:has-[:focus]:shadow-dark-40">
<NumberFieldInput class="tabular-nums w-20 bg-transparent px-3 py-1 outline-none caret-light-50 dark:caret-dark-50" />
</NumberFieldRoot>
<Tooltip side="bottom" :message="`${mainStatTexts[ability.max[0]]} (0) + ${mainStatTexts[ability.max[1]]} (0) + 0`"><span class="text-lg text-end cursor-pointer">/ {{ 0 }}</span></Tooltip>
</div>
<span class="text-xl text-center font-bold">{{ ability.name }}</span>
<span class="absolute -bottom-px -left-px h-[3px] bg-accent-blue" :style="{ width: `200px` }"></span>
</div>
</template>
</div>
</template>
</template>
<script setup lang="ts">
import { mainStatTexts, type Character, type CharacterConfig } from '~/types/character';
const { config } = defineProps<{
config: CharacterConfig,
}>();
const model = defineModel<Character>({ required: true });
const emit = defineEmits(['next']);
</script>

View File

@ -0,0 +1,38 @@
<template>
<template v-if="model && model.people !== undefined">
<div class="flex flex-1 gap-12 px-2 py-4 justify-center items-center sticky top-0 bg-light-0 dark:bg-dark-0 w-full z-10">
<Label class="flex items-center justify-between gap-2">
<span class="pb-1 mx-2 md:p-0">Physique</span>
<NumberFieldRoot disabled :v-model="0" class="flex justify-center border border-light-25 dark:border-dark-25 bg-light-10 dark:bg-dark-10 text-light-60 dark:text-dark-60">
<NumberFieldInput class="tabular-nums w-14 bg-transparent px-3 py-1 outline-none" />
</NumberFieldRoot>
</Label>
<Label class="flex items-center justify-between gap-2">
<span class="pb-1 mx-2 md:p-0">Mental</span>
<NumberFieldRoot disabled :v-model="0" class="flex justify-center border border-light-25 dark:border-dark-25 bg-light-10 dark:bg-dark-10 text-light-60 dark:text-dark-60">
<NumberFieldInput class="tabular-nums w-14 bg-transparent px-3 py-1 outline-none" />
</NumberFieldRoot>
</Label>
<Label class="flex items-center justify-between gap-2">
<span class="pb-1 mx-2 md:p-0">Caractère</span>
<NumberFieldRoot disabled :v-model="0" class="flex justify-center border border-light-25 dark:border-dark-25 bg-light-10 dark:bg-dark-10 text-light-60 dark:text-dark-60">
<NumberFieldInput class="tabular-nums w-14 bg-transparent px-3 py-1 outline-none" />
</NumberFieldRoot>
</Label>
<Button @click="emit('next')" :disabled="model.aspect === undefined">Enregistrer</Button>
</div>
<div class="flex flex-col flex-1 gap-4 mx-8 my-4">
</div>
</template>
</template>
<script setup lang="ts">
import type { Character, CharacterConfig } from '~/types/character';
const { config } = defineProps<{
config: CharacterConfig,
}>();
const model = defineModel<Character>({ required: true });
const emit = defineEmits(['next']);
</script>

View File

@ -0,0 +1,113 @@
<template>
<template v-if="model && model.people !== undefined">
<div class="flex flex-1 gap-12 px-2 py-4 justify-center items-center sticky top-0 bg-light-0 dark:bg-dark-0 w-full z-10">
<Label class="flex items-center justify-between gap-2">
<span class="pb-1 mx-2 md:p-0">Niveau</span>
<NumberFieldRoot :min="1" :max="20" v-model="model.level" @update:model-value="updateLevel" class="flex justify-center border border-light-35 dark:border-dark-35 bg-light-20 dark:bg-dark-20 hover:border-light-50 dark:hover:border-dark-50 has-[:focus]:shadow-raw transition-[box-shadow] has-[:focus]:shadow-light-40 dark:has-[:focus]:shadow-dark-40">
<NumberFieldInput class="tabular-nums w-20 bg-transparent px-3 py-1 outline-none caret-light-50 dark:caret-dark-50" />
</NumberFieldRoot>
</Label>
<Label class="flex items-center justify-between gap-2">
<span class="pb-1 mx-2 md:p-0">Attributions restantes</span>
<NumberFieldRoot disabled :v-model="0" class="flex justify-center border border-light-25 dark:border-dark-25 bg-light-10 dark:bg-dark-10 text-light-60 dark:text-dark-60">
<NumberFieldInput class="tabular-nums w-14 bg-transparent px-3 py-1 outline-none" />
</NumberFieldRoot>
</Label>
<Label class="flex items-center justify-between gap-2">
<span class="pb-1 mx-2 md:p-0">Vie</span>
<NumberFieldRoot disabled :v-model="0" class="flex justify-center border border-light-25 dark:border-dark-25 bg-light-10 dark:bg-dark-10 text-light-60 dark:text-dark-60">
<NumberFieldInput class="tabular-nums w-14 bg-transparent px-3 py-1 outline-none" />
</NumberFieldRoot>
</Label>
<Label class="flex items-center justify-between gap-2">
<span class="pb-1 mx-2 md:p-0">Mana</span>
<NumberFieldRoot disabled :v-model="0" class="flex justify-center border border-light-25 dark:border-dark-25 bg-light-10 dark:bg-dark-10 text-light-60 dark:text-dark-60">
<NumberFieldInput class="tabular-nums w-14 bg-transparent px-3 py-1 outline-none" />
</NumberFieldRoot>
</Label>
<Button @click="emit('next')">Suivant</Button>
</div>
<div class="flex flex-col flex-1 gap-4 mx-8 my-4">
<template v-for="(level, index) of config.peoples[model.people].options">
<div class="w-full flex h-px"><div class="border-t border-dashed border-light-50 dark:border-dark-50 w-full" :class="{ 'opacity-30': index > model.level }"></div><span class="sticky top-0">{{ index }}</span></div>
<div class="flex flex-row gap-4 justify-center" :class="{ 'opacity-30': index > model.level }">
<template v-for="(option, i) of level">
<div class="flex border border-light-50 dark:border-dark-50 px-4 py-2 w-[400px]" @click="chooseOption(parseInt(index as unknown as string, 10) as Level, i)"
:class="{ 'hover:border-light-70 dark:hover:border-dark-70 cursor-pointer': index <= model.level, '!border-accent-blue bg-accent-blue bg-opacity-20': model.leveling?.some(e => e[0] == index && e[1] === i) ?? false }">
<span class="text-wrap whitespace-pre">{{ raceOptionToText(option) }}</span>
</div>
</template>
</div>
</template>
</div>
</template>
</template>
<script setup lang="ts">
import type { Character, CharacterConfig, Level, RaceOption } from '~/types/character';
const { config } = defineProps<{
config: CharacterConfig,
}>();
const model = defineModel<Character>({ required: true });
const emit = defineEmits(['next']);
function raceOptionToText(option: RaceOption): string
{
const text = [];
if(option.training) text.push(`+${option.training} point${option.training > 1 ? 's' : ''} de statistique${option.training > 1 ? 's' : ''}.`);
if(option.shaping) text.push(`+${option.shaping} transformation${option.shaping > 1 ? 's' : ''} par jour.`);
if(option.modifier) text.push(`+${option.modifier} au modifieur de votre choix.`);
if(option.abilities) text.push(`+${option.abilities} point${option.abilities > 1 ? 's' : ''} de compétence${option.abilities > 1 ? 's' : ''}.`);
if(option.health) text.push(`+${option.health} PV max.`);
if(option.mana) text.push(`+${option.mana} mana max.`);
if(option.spellslots) text.push(`+${option.spellslots} sort${option.spellslots > 1 ? 's' : ''} maitrisé${option.spellslots > 1 ? 's' : ''}.`);
return text.join('\n');
}
function chooseOption(level: Level, choice: number)
{
const character = model.value;
if(level > character.level)
return;
if(character.leveling === undefined)
character.leveling = [[1, 0]];
if(level == 1)
return;
for(let i = 1; i < level; i++) //Check previous levels as a requirement
{
if(!character.leveling.some(e => e[0] == i))
return;
}
if(character.leveling.some(e => e[0] == level))
{
character.leveling.splice(character.leveling.findIndex(e => e[0] == level), 1, [level, choice]);
}
else
{
character.leveling.push([level, choice]);
}
model.value = character;
}
function updateLevel()
{
const character = model.value;
if(character.leveling) //Invalidate higher levels
{
for(let level = 20; level > character.level; level--)
{
const index = character.leveling.findIndex(e => e[0] == level);
if(index !== -1)
character.leveling.splice(index, 1);
}
}
model.value = character;
}
</script>

View File

@ -8,8 +8,9 @@
<div class="flex flex-1 gap-4 p-2 overflow-x-auto justify-center">
<div v-for="(people, i) of config.peoples" @click="model.people = i" class="flex flex-col flex-nowrap gap-2 p-2 border border-light-35 dark:border-dark-35
cursor-pointer hover:border-light-70 dark:hover:border-dark-70 w-[320px]" :class="{ '!border-accent-blue outline-2 outline outline-accent-blue': model.people === i }">
<span class="text-xl font-bold text-center">{{ people.name }}</span>
<Avatar :src="people.name" :text="`Image placeholder`" class="h-[320px]" />
<span class="text-xl font-bold text-center">{{ people.name }}</span>
<span class="w-full border-b border-light-50 dark:border-dark-50"></span>
<span class="text-wrap word-break">{{ people.description }}</span>
</div>
</div>

View File

@ -0,0 +1,69 @@
<template>
<TrainingViewer :config="config">
<template #addin="{ stat }">
<div class="flex flex-1 gap-12 px-2 py-4 justify-center items-center sticky top-0 bg-light-0 dark:bg-dark-0 w-full z-10">
<Label class="flex items-center justify-between gap-2">
<span class="pb-1 mx-2 md:p-0">Points restants</span>
<NumberFieldRoot disabled :v-model="0" class="flex justify-center border border-light-25 dark:border-dark-25 bg-light-10 dark:bg-dark-10 text-light-60 dark:text-dark-60">
<NumberFieldInput class="tabular-nums w-14 bg-transparent px-3 py-1 outline-none" />
</NumberFieldRoot>
</Label>
<Button @click="emit('next')">Suivant</Button>
</div>
</template>
<template #default="{ stat, level, option }">
<div class="border border-light-40 dark:border-dark-40 cursor-pointer px-2 py-1 w-[400px] hover:border-light-50 dark:hover:border-dark-50" @click="toggleOption(stat, parseInt(level as unknown as string, 10) as TrainingLevel, option)" :class="{ /*'opacity-30': level > maxTraining[stat] + 1, 'hover:border-light-60 dark:hover:border-dark-60': level <= maxTraining[stat] + 1, */'!border-accent-blue bg-accent-blue bg-opacity-20': level == 0 || (model.training[stat]?.some(e => e[0] == level && e[1] === option) ?? false) }">
<MarkdownRenderer :proses="{ 'a': PreviewA }" :content="config.training[stat][level][option].description.map(e => e.text).join('\n')" />
</div>
</template>
</TrainingViewer>
</template>
<script setup lang="ts">
import PreviewA from '~/components/prose/PreviewA.vue';
import { MAIN_STATS, type Character, type CharacterConfig, type MainStat, type TrainingLevel } from '~/types/character';
const { config } = defineProps<{
config: CharacterConfig,
}>();
const model = defineModel<Character>({ required: true, });
const maxTraining = Object.fromEntries(MAIN_STATS.map(e => [e, 0]));
const emit = defineEmits(['next']);
function toggleOption(stat: MainStat, level: TrainingLevel, choice: number)
{
const character = model.value;
if(level == 0)
return;
for(let i = 1; i < level; i++) //Check previous levels as a requirement
{
if(!character.training[stat].some(e => e[0] == i))
return;
}
if(character.training[stat].some(e => e[0] == level))
{
if(character.training[stat].some(e => e[0] == level && e[1] === choice))
{
for(let i = 15; i >= level; i --) //Invalidate higher levels
{
const index = character.training[stat].findIndex(e => e[0] == i);
if(index !== -1)
character.training[stat].splice(index, 1);
}
}
else
character.training[stat].splice(character.training[stat].findIndex(e => e[0] == level), 1, [level, choice]);
}
else //if(trainingPoints.value && trainingPoints.value > 0)
{
character.training[stat].push([level, choice]);
}
model.value = character;
}
</script>

Binary file not shown.

Binary file not shown.

View File

@ -1,41 +1,26 @@
<script lang="ts">
import config from '#shared/character-config.json';
function raceOptionToText(option: RaceOption): string
{
const text = [];
if(option.training) text.push(`+${option.training} point${option.training > 1 ? 's' : ''} de statistique${option.training > 1 ? 's' : ''}.`);
if(option.shaping) text.push(`+${option.shaping} transformation${option.shaping > 1 ? 's' : ''} par jour.`);
if(option.modifier) text.push(`+${option.modifier} au modifieur de votre choix.`);
if(option.abilities) text.push(`+${option.abilities} point${option.abilities > 1 ? 's' : ''} de compétence${option.abilities > 1 ? 's' : ''}.`);
if(option.health) text.push(`+${option.health} PV max.`);
if(option.mana) text.push(`+${option.mana} mana max.`);
if(option.spellslots) text.push(`+${option.spellslots} sort${option.spellslots > 1 ? 's' : ''} maitrisé${option.spellslots > 1 ? 's' : ''}.`);
return text.join('\n');
}
function getFeaturesOf(stat: MainStat, progression: DoubleIndex<TrainingLevel>[]): TrainingOption[]
{
const characterData = config as CharacterConfig;
return progression.map(e => characterData.training[stat][e[0]][e[1]]);
}
function abilitySpecialFeatures(type: "points" | "max", curiosity: DoubleIndex<TrainingLevel>[], value: number): number
{
if(type === 'points')
{
if(curiosity.find(e => e[0] == 6 && e[1] === 0))
return Math.max(6, value);
if(curiosity.find(e => e[0] == 6 && e[1] === 2))
return value + 1;
}
return value;
}
</script>
<script setup lang="ts">
import PreviewA from '~/components/prose/PreviewA.vue';
import { defaultCharacter, elementTexts, mainStatTexts, spellTypeTexts, type Ability, type Character, type CharacterConfig, type DoubleIndex, type Level, type MainStat, type RaceOption, type SpellConfig, type SpellElement, type SpellType, type TrainingLevel, type TrainingOption } from '~/types/character';
import config from '#shared/character-config.json';
import { ABILITIES, defaultCharacter, MAIN_STATS, type Ability, type Character, type CharacterConfig, type MainStat } from '~/types/character';
import { Icon } from '@iconify/vue/dist/iconify.js';
export interface EditorValues
{
health: number,
mana: number,
training: number,
trainingSpent: number,
abilities: Record<Ability, number>,
abilitiesMax: Record<Ability, number>,
modifiers: Record<MainStat, number>,
}
const stepTexts = {
0: 'Choisissez un peuple afin de définir la progression de votre personnage au fil des niveaux.',
1: 'Déterminez la progression de votre personnage en choisissant une option par niveau disponible.',
2: 'Spécialisez votre personnage en attribuant vos points d\'entrainement parmi les 7 branches disponibles.\nChaque paliers de 3 points augmentent votre modifieur.',
3: 'Diversifiez vos possibilités en affectant vos points dans les différentes compétences disponibles.',
4: 'Déterminez l\'Aspect qui vous corresponds et benéficiez de puissants bonus.'
};
definePageMeta({
guestsGoesTo: '/user/login',
@ -44,36 +29,18 @@ let id = useRouter().currentRoute.value.params.id;
const { add } = useToast();
const characterConfig = config as CharacterConfig;
const data = ref<Character>({ ...defaultCharacter });
const spellFilter = ref<{
ranks: Array<1 | 2 | 3 | 4>,
types: Array<SpellType>,
text: string,
elements: Array<SpellElement>,
tags: string[],
}>({
ranks: [],
types: [],
text: "",
elements: [],
tags: [],
});
const step = ref(0);
const raceOptions = computed(() => data.value.people !== undefined ? characterConfig.peoples[data.value.people!].options : undefined);
const selectedRaceOptions = computed(() => raceOptions.value ? data.value.leveling!.map(e => raceOptions.value![e[0]][e[1]]) : undefined);
const trainingPoints = computed(() => raceOptions.value ? data.value.leveling?.reduce((p, v) => p + (raceOptions.value![v[0]][v[1]].training ?? 0), 0) : 0);
const training = computed(() => Object.entries(characterConfig.training).map(e => [e[0], getFeaturesOf(e[0] as MainStat, data.value.training[e[0] as MainStat])]) as [MainStat, TrainingOption[]][]);
const maxTraining = computed(() => Object.entries(data.value.training).reduce((p, v) => { p[v[0] as MainStat] = v[1].reduce((_p, _v) => Math.max(_p, _v[0]) , 0); return p; }, {} as Record<MainStat, number>));
const trainingSpent = computed(() => Object.values(maxTraining.value).reduce((p, v) => p + v, 0));
const modifiers = computed(() => Object.entries(maxTraining.value).reduce((p, v) => { p[v[0] as MainStat] = Math.floor(v[1] / 3) + (data.value.modifiers ? (data.value.modifiers[v[0] as MainStat] ?? 0) : 0); return p; }, {} as Record<MainStat, number>))
const modifierPoints = computed(() => (selectedRaceOptions.value ? selectedRaceOptions.value.reduce((p, v) => p + (v?.modifier ?? 0), 0) : 0) + training.value.reduce((p, v) => p + v[1].reduce((_p, _v) => _p + (_v?.modifier ?? 0), 0), 0));
const modifierSpent = computed(() => Object.values(data.value.modifiers ?? {}).reduce((p, v) => p + v, 0));
const abilityPoints = computed(() => (selectedRaceOptions.value ? selectedRaceOptions.value.reduce((p, v) => p + (v?.abilities ?? 0), 0) : 0) + training.value.flatMap(e => e[1].filter(_e => _e.ability !== undefined)).reduce((p, v) => p + v.ability!, 0));
const abilityMax = computed(() => Object.entries(characterConfig.abilities).reduce((p, v) => { p[v[0] as Ability] = abilitySpecialFeatures("max", data.value.training.curiosity, Math.floor(maxTraining.value[v[1].max[0]] / 3) + Math.floor(maxTraining.value[v[1].max[1]] / 3)); return p; }, {} as Record<Ability, number>));
const abilitySpent = computed(() => Object.values(data.value.abilities ?? {}).reduce((p, v) => p + v[0], 0));
const spellranks = computed(() => training.value.flatMap(e => e[1].filter(_e => _e.spellrank !== undefined)).reduce((p, v) => { p[v.spellrank!]++; return p; }, { instinct: 0, precision: 0, knowledge: 0 } as Record<SpellType, 0 | 1 | 2 | 3>));
const spellsPoints = computed(() => training.value.flatMap(e => e[1].filter(_e => _e.spellslot !== undefined)).reduce((p, v) => p + (modifiers.value.hasOwnProperty(v.spellslot as MainStat) ? modifiers.value[v.spellslot as MainStat] : v.spellslot as number), 0));
const values: EditorValues = reactive({
health: 0,
mana: 0,
training: 0,
trainingSpent: 0,
abilities: Object.fromEntries(ABILITIES.map(e => [e, 0])) as Record<Ability, number>,
abilitiesMax: Object.fromEntries(ABILITIES.map(e => [e, 0])) as Record<Ability, number>,
modifiers: Object.fromEntries(MAIN_STATS.map(e => [e, 0])) as Record<MainStat, number>,
});
if(id !== 'new')
{
@ -87,98 +54,6 @@ if(id !== 'new')
data.value = Object.assign(defaultCharacter, data.value, character);
}
function selectRaceOption(level: Level, choice: number)
{
const character = data.value;
if(level > character.level)
return;
if(character.leveling === undefined)
character.leveling = [[1, 0]];
if(level == 1)
return;
for(let i = 1; i < level; i++) //Check previous levels as a requirement
{
if(!character.leveling.some(e => e[0] == i))
return;
}
if(character.leveling.some(e => e[0] == level))
{
character.leveling.splice(character.leveling.findIndex(e => e[0] == level), 1, [level, choice]);
}
else
{
character.leveling.push([level, choice]);
}
data.value = character;
}
function switchTrainingOption(stat: MainStat, level: TrainingLevel, choice: number)
{
const character = data.value;
if(level == 0)
return;
for(let i = 1; i < level; i++) //Check previous levels as a requirement
{
if(!character.training[stat].some(e => e[0] == i))
return;
}
if(character.training[stat].some(e => e[0] == level))
{
if(character.training[stat].some(e => e[0] == level && e[1] === choice))
{
for(let i = 15; i >= level; i --) //Invalidate higher levels
{
const index = character.training[stat].findIndex(e => e[0] == i);
if(index !== -1)
character.training[stat].splice(index, 1);
}
}
else
character.training[stat].splice(character.training[stat].findIndex(e => e[0] == level), 1, [level, choice]);
}
else if(trainingPoints.value && trainingPoints.value > 0)
{
character.training[stat].push([level, choice]);
}
data.value = character;
}
function updateLevel()
{
const character = data.value;
if(character.leveling) //Invalidate higher levels
{
for(let level = 20; level > character.level; level--)
{
const index = character.leveling.findIndex(e => e[0] == level);
if(index !== -1)
character.leveling.splice(index, 1);
}
}
data.value = character;
}
function filterSpells(spells: SpellConfig[])
{
const filter = spellFilter.value
let list = [...spells];
list = list.filter(e => spellranks.value[e.type] >= e.rank);
if(filter.text.length > 0) list = list.filter(e => e.name.toLowerCase().includes(filter.text.toLowerCase()));
if(filter.types.length > 0) list = list.filter(e => filter.types.includes(e.type));
if(filter.ranks.length > 0) list = list.filter(e => filter.ranks.includes(e.rank));
if(filter.elements.length > 0) list = list.filter(e => filter.elements.some(f => e.elements.includes(f)));
if(filter.tags.length > 0) list = list.filter(e => !e.tags || filter.tags.some(f => e.tags!.includes(f)));
return list;
}
async function save(leave: boolean)
{
if(data.value.name === '' || data.value.people === undefined || data.value.people === -1)
@ -188,6 +63,7 @@ async function save(leave: boolean)
}
if(id === 'new')
{
//@ts-ignore
id = await useRequestFetch()(`/api/character`, {
method: 'post',
body: data.value,
@ -201,6 +77,7 @@ async function save(leave: boolean)
}
else
{
//@ts-ignore
await useRequestFetch()(`/api/character/${id}`, {
method: 'post',
body: data.value,
@ -224,82 +101,33 @@ useShortcuts({
</Head>
<div class="flex flex-1 max-w-full flex-col align-center">
<StepperRoot class="flex flex-1 flex-col justify-start items-center px-8 w-full h-full overflow-y-hidden" v-model="step">
<div class="flex w-full flex-row gap-4 self-center items-center justify-center relative px-4 bg-light-0 dark:bg-dark-0 z-20">
<StepperItem :step="0"><StepperTrigger class="px-2 py-1 border-b border-transparent hover:border-accent-blue">Peuples</StepperTrigger></StepperItem>
<StepperItem :disabled="data.people === undefined" :step="1" class="group flex"><Icon icon="radix-icons:chevron-right" class="w-6 h-6 group-data-[disabled]:text-light-50 dark:group-data-[disabled]:text-dark-50 group-data-[disabled]:hover:border-transparent" /><StepperTrigger class="px-2 py-1 border-b border-transparent hover:border-accent-blue disabled:text-light-50 dark:disabled:text-dark-50 disabled:hover:border-transparent">Niveaux</StepperTrigger></StepperItem>
<StepperItem :disabled="data.people === undefined" :step="2" class="group flex"><Icon icon="radix-icons:chevron-right" class="w-6 h-6 group-data-[disabled]:text-light-50 dark:group-data-[disabled]:text-dark-50 group-data-[disabled]:hover:border-transparent" /><StepperTrigger class="px-2 py-1 border-b border-transparent hover:border-accent-blue disabled:text-light-50 dark:disabled:text-dark-50 disabled:hover:border-transparent">Entrainement</StepperTrigger></StepperItem>
<StepperItem :disabled="data.people === undefined" :step="3" class="group flex"><Icon icon="radix-icons:chevron-right" class="w-6 h-6 group-data-[disabled]:text-light-50 dark:group-data-[disabled]:text-dark-50 group-data-[disabled]:hover:border-transparent" /><StepperTrigger class="px-2 py-1 border-b border-transparent hover:border-accent-blue disabled:text-light-50 dark:disabled:text-dark-50 disabled:hover:border-transparent">Compétences</StepperTrigger></StepperItem>
<StepperItem :disabled="data.people === undefined" :step="4" class="group flex"><Icon icon="radix-icons:chevron-right" class="w-6 h-6 group-data-[disabled]:text-light-50 dark:group-data-[disabled]:text-dark-50 group-data-[disabled]:hover:border-transparent" /><StepperTrigger class="px-2 py-1 border-b border-transparent hover:border-accent-blue disabled:text-light-50 dark:disabled:text-dark-50 disabled:hover:border-transparent">Aspect</StepperTrigger></StepperItem>
<div class="flex w-full flex-row gap-4 items-center justify-between px-4 bg-light-0 dark:bg-dark-0 z-20">
<div></div>
<div class="flex w-full flex-row gap-4 items-center justify-center relative">
<StepperItem :step="0" class="group"><StepperTrigger class="px-2 py-1 border-b border-transparent hover:border-accent-blue group-data-[state=active]:text-accent-blue">Peuples</StepperTrigger></StepperItem>
<StepperItem :disabled="data.people === undefined" :step="1" class="group flex items-center"><Icon icon="radix-icons:chevron-right" class="w-6 h-6 group-data-[disabled]:text-light-50 dark:group-data-[disabled]:text-dark-50 group-data-[disabled]:hover:border-transparent me-4" /><StepperTrigger class="px-2 py-1 border-b border-transparent hover:border-accent-blue disabled:text-light-50 dark:disabled:text-dark-50 disabled:hover:border-transparent group-data-[state=active]:text-accent-blue">Niveaux</StepperTrigger></StepperItem>
<StepperItem :disabled="data.people === undefined" :step="2" class="group flex items-center"><Icon icon="radix-icons:chevron-right" class="w-6 h-6 group-data-[disabled]:text-light-50 dark:group-data-[disabled]:text-dark-50 group-data-[disabled]:hover:border-transparent me-4" /><StepperTrigger class="px-2 py-1 border-b border-transparent hover:border-accent-blue disabled:text-light-50 dark:disabled:text-dark-50 disabled:hover:border-transparent group-data-[state=active]:text-accent-blue">Entrainement</StepperTrigger></StepperItem>
<StepperItem :disabled="data.people === undefined" :step="3" class="group flex items-center"><Icon icon="radix-icons:chevron-right" class="w-6 h-6 group-data-[disabled]:text-light-50 dark:group-data-[disabled]:text-dark-50 group-data-[disabled]:hover:border-transparent me-4" /><StepperTrigger class="px-2 py-1 border-b border-transparent hover:border-accent-blue disabled:text-light-50 dark:disabled:text-dark-50 disabled:hover:border-transparent group-data-[state=active]:text-accent-blue">Compétences</StepperTrigger></StepperItem>
<StepperItem :disabled="data.people === undefined" :step="4" class="group flex items-center"><Icon icon="radix-icons:chevron-right" class="w-6 h-6 group-data-[disabled]:text-light-50 dark:group-data-[disabled]:text-dark-50 group-data-[disabled]:hover:border-transparent me-4" /><StepperTrigger class="px-2 py-1 border-b border-transparent hover:border-accent-blue disabled:text-light-50 dark:disabled:text-dark-50 disabled:hover:border-transparent group-data-[state=active]:text-accent-blue">Aspect</StepperTrigger></StepperItem>
</div>
<div>
<Tooltip class="max-w-96" side="bottom" align="end" :message="stepTexts[step]"><Icon icon="radix-icons:question-mark-circled" class="w-5 h-5" /></Tooltip>
</div>
</div>
<div class="flex-1 outline-none max-w-full w-full overflow-y-auto" v-show="step === 0">
<PeopleEditor v-model="data" :config="characterConfig" @next="step = 1" />
<PeopleSelector v-model="data" :config="characterConfig" @next="step = 1" />
</div>
<div class="flex-1 outline-none max-w-full w-full overflow-y-auto" v-show="step === 1">
<LevelEditor v-model="data" :config="characterConfig" @next="step = 2" />
</div>
<div class="flex-1 outline-none max-w-full w-full h-full max-h-full overflow-y-auto" v-show="step === 2">
<TrainingViewer :config="characterConfig" progress>
<template #default="{ stat, level, option }">
<div class="border border-light-40 dark:border-dark-40 cursor-pointer px-2 py-1 max-w-[26rem] hover:border-light-50 dark:hover:border-dark-50" @click="switchTrainingOption(stat, parseInt(level as unknown as string, 10) as TrainingLevel, index)" :class="{ 'opacity-30': level > maxTraining[stat] + 1, 'hover:border-light-60 dark:hover:border-dark-60': level <= maxTraining[stat] + 1, '!border-accent-blue bg-accent-blue bg-opacity-20': level == 0 || (data.training[stat]?.some(e => e[0] == level && e[1] === index) ?? false) }">
<MarkdownRenderer :proses="{ 'a': PreviewA }" :content="config.training[stat][level][option].description.map(e => e.text).join('\n')" />
</div>
</template>
<template #addin="{ stat }">
<div class="bg-light-0 dark:bg-dark-0 z-10">
<span class="text-xl" :class="{ 'text-light-red dark:text-dark-red': (trainingPoints ?? 0) < trainingSpent }">Points d'entrainement restants: {{ (trainingPoints ?? 0) - trainingSpent }}</span>
</div>
</template>
</TrainingViewer>
<TrainingEditor v-model="data" :config="characterConfig" @next="step = 3" />
</div>
<div class="flex-1 outline-none max-w-full w-fulloverflow-y-auto" v-show="step === 3">
<div class="flex flex-col gap-2 max-h-[50vh] px-4 relative overflow-y-auto">
<div class="sticky top-0 py-2 bg-light-0 dark:bg-dark-0 z-10 flex justify-between">
<span class="text-xl -mx-2" :class="{ 'text-light-red dark:text-dark-red': (abilityPoints ?? 0) < abilitySpent }">Points d'entrainement restants: {{ (abilityPoints ?? 0) - abilitySpent }}</span>
</div>
<div class="grid gap-4 grid-cols-6">
<div v-for="(ability, index) of characterConfig.abilities" class="flex flex-col items-center border border-light-30 dark:border-dark-30 p-2">
<div class="flex items-center justify-center gap-4">
<NumberFieldRoot :min="0" :default-value="data.abilities[index] ? data.abilities[index][0] : 0" @update:model-value="(value) => { data.abilities[index] = [value, data.abilities[index] ? data.abilities[index][1] : 0]; }" class="border border-light-35 dark:border-dark-35 bg-light-20 dark:bg-dark-20 data-[disabled]:border-light-20 dark:data-[disabled]:border-dark-20 data-[disabled]:bg-light-20 dark:data-[disabled]:bg-dark-20
data-[disabled]:text-light-70 dark:data-[disabled]:text-dark-70 hover:border-light-50 dark:hover:border-dark-50 has-[:focus]:shadow-raw transition-[box-shadow] has-[:focus]:shadow-light-40 dark:has-[:focus]:shadow-dark-40">
<NumberFieldInput class="tabular-nums w-8 bg-transparent px-3 py-1 outline-none caret-light-50 dark:caret-dark-50" />
</NumberFieldRoot>
<span class="font-bold col-span-4">/{{ abilityMax[index] }}</span>
</div>
<span class="text-xl font-bold flex-2">{{ ability.name }}</span>
<span class="text-sm text-light-70 dark:text-dark-70 flex-1">({{ mainStatTexts[ability.max[0]] }} + {{ mainStatTexts[ability.max[1]] }})</span>
</div>
</div>
</div>
<AbilityEditor v-model="data" :config="characterConfig" @next="step = 4" />
</div>
<div class="flex-1 outline-none max-w-full w-full overflow-y-auto" v-show="step === 4">
<div class="flex flex-col gap-2 max-h-[50vh] px-4 relative overflow-y-auto">
<div class="sticky top-0 py-2 bg-light-0 dark:bg-dark-0 z-10 flex gap-2 items-center">
<span class="text-xl pe-4" :class="{ 'text-light-red dark:text-dark-red': spellsPoints < (data.spells?.length ?? 0) }">Sorts: {{ data.spells?.length ?? 0 }}/{{ spellsPoints }}</span>
<TextInput label="Nom" v-model="spellFilter.text" />
<Combobox label="Rang" v-model="spellFilter.ranks" multiple :options="[['Rang 1', 1], ['Rang 2', 2], ['Rang 3', 3]]" />
<Combobox label="Type" v-model="spellFilter.types" multiple :options="[['Précision', 'precision'], ['Savoir', 'knowledge'], ['Instinct', 'instinct']]" />
<Combobox label="Element" v-model="spellFilter.elements" multiple :options="[['Feu', 'fire'], ['Glace', 'ice'], ['Foudre', 'thunder'], ['Terre', 'earth'], ['Arcane', 'arcana'], ['Air', 'air'], ['Nature', 'nature'], ['Lumière', 'light'], ['Psy', 'psyche']]" />
</div>
<div class="grid gap-4 grid-cols-2">
<div class="py-1 px-2 border border-light-30 dark:border-dark-30 flex flex-col hover:border-light-50 dark:hover:border-dark-50 cursor-pointer" v-for="spell of filterSpells(characterConfig.spells)" :class="{ '!border-accent-blue bg-accent-blue bg-opacity-20': data.spells?.find(e => e === spell.id) }"
@click="() => data.spells?.includes(spell.id) ? data.spells.splice(data.spells.findIndex((e: string) => e === spell.id), 1) : data.spells!.push(spell.id)">
<div class="flex flex-row justify-between">
<span class="text-lg font-bold">{{ spell.name }}</span>
<div class="flex flex-row items-center gap-6">
<div class="flex flex-row text-sm gap-2">
<span v-for="element of spell.elements" :class="elementTexts[element].class" class="border !border-opacity-50 rounded-full !bg-opacity-20 px-2 py-px">{{ elementTexts[element].text }}</span>
</div>
<div class="flex flex-row text-sm gap-1">
<span class="">Rang {{ spell.rank }}</span><span>/</span>
<span class="">{{ spellTypeTexts[spell.type] }}</span><span>/</span>
<span class="">{{ spell.cost }} mana</span><span>/</span>
<span class="">{{ typeof spell.speed === 'string' ? spell.speed : `${spell.speed} minutes` }}</span>
</div>
</div>
</div>
<MarkdownRenderer :content="spell.effect" />
</div>
</div>
</div>
<AspectSelector v-model="data" :config="characterConfig" @next="save(true)" />
</div>
</StepperRoot>
</div>

View File

@ -25,16 +25,18 @@ function copy()
<TabsTrigger value="spells" class="px-2 py-1 border-b border-transparent hover:border-accent-blue">Sorts</TabsTrigger>
<Tooltip message="Copier le JSON" side="right"><Button icon @click="copy" class="p-2"><Icon icon="radix-icons:clipboard-copy" /></Button></Tooltip>
</TabsList>
<TabsContent value="peoples" class="flex-1 outline-none max-w-full w-full">
</TabsContent>
<TabsContent value="training" class="flex-1 outline-none max-w-full w-full">
<TrainingEditor :config="config" />
</TabsContent>
<TabsContent value="abilities" class="flex-1 outline-none max-w-full w-full">
</TabsContent>
<TabsContent value="spells" class="flex-1 outline-none max-w-full w-full">
</TabsContent>
<div class="flex-1 outline-none max-w-full w-full">
<TabsContent value="peoples">
</TabsContent>
<TabsContent value="training">
<TrainingConfigEditor :config="config" />
</TabsContent>
<TabsContent value="abilities">
</TabsContent>
<TabsContent value="spells">
</TabsContent>
</div>
</TabsRoot>
</template>

View File

@ -84,7 +84,7 @@ export type Character = {
name: string;
people?: number;
level: number;
aspect?: number | null;
aspect?: number;
notes?: string | null;
health: number;
mana: number;