314 lines
19 KiB
TypeScript
314 lines
19 KiB
TypeScript
import { z } from "zod/v4";
|
|
import type { User } from "~/types/auth";
|
|
import characterConfig from '#shared/character-config.json';
|
|
import type { Campaign } from "~/types/campaign";
|
|
import { div, dom, icon, span, text, type RedrawableHTML } from "#shared/dom.util";
|
|
import { button, foldable, loading, numberpicker, tabgroup, Toaster } from "#shared/components.util";
|
|
import { CharacterCompiler, colorByRarity, stateFactory, subnameFactory } from "#shared/character.util";
|
|
import { modal, tooltip } from "#shared/floating.util";
|
|
import markdown from "#shared/markdown.util";
|
|
import { preview } from "#shared/proses";
|
|
import { Socket } from "#shared/websocket.util";
|
|
import { reactive } from "#shared/reactive";
|
|
import type { Character, CharacterConfig } from "~/types/character";
|
|
import { MarkdownEditor } from "./editor.util";
|
|
import { getText } from "./i18n";
|
|
|
|
const config = characterConfig as CharacterConfig;
|
|
|
|
export const CampaignValidation = z.object({
|
|
id: z.number(),
|
|
name: z.string().nonempty(),
|
|
public_notes: z.string(),
|
|
dm_notes: z.string(),
|
|
settings: z.object(),
|
|
});
|
|
|
|
class CharacterPrinter
|
|
{
|
|
compiler?: CharacterCompiler;
|
|
container: RedrawableHTML;
|
|
name: string;
|
|
id: number;
|
|
constructor(character: number, name: string)
|
|
{
|
|
this.id = character;
|
|
this.name = name;
|
|
this.container = div('flex flex-col gap-2 px-1', [ div('flex flex-row justify-between items-center', [ span('text-bold text-xl', name), loading('small') ]) ]);
|
|
useRequestFetch()(`/api/character/${character}`).then((character) => {
|
|
if(character)
|
|
{
|
|
this.compiler = new CharacterCompiler(character);
|
|
const compiled = this.compiler.compiled;
|
|
const armor = this.compiler.armor;
|
|
|
|
this.container.replaceChildren(div('flex flex-row justify-between items-center', [
|
|
span('text-bold text-xl', compiled.name),
|
|
div('flex flex-row gap-2 items-baseline', [ span('text-sm font-bold', 'PV'), span('text-lg', `${(compiled.health ?? 0) - (compiled.variables.health ?? 0)}/${compiled.health ?? 0}`) ]),
|
|
]), div('flex flex-row justify-between items-center', [
|
|
div('flex flex-col gap-px items-center justify-center', [ span('text-sm font-bold', 'Armure'), span('text-lg', armor === undefined ? `-` : `${armor.current}/${armor.max}`) ]),
|
|
div('flex flex-col gap-px items-center justify-center', [ span('text-sm font-bold', 'Mana'), span('text-lg', `${(compiled.mana ?? 0) - (compiled.variables.mana ?? 0)}/${compiled.mana ?? 0}`) ]),
|
|
div('flex flex-col gap-px items-center justify-center', [ span('text-sm font-bold', 'Fatigue'), span('text-lg', `${compiled.variables.exhaustion ?? 0}`) ]),
|
|
]));
|
|
}
|
|
else throw new Error();
|
|
}).catch((e) => {
|
|
console.error(e);
|
|
this.container.replaceChildren(span('text-sm italic text-light-red dark:text-dark-red', 'Données indisponible'));
|
|
})
|
|
}
|
|
}
|
|
export class CampaignSheet
|
|
{
|
|
private user: ComputedRef<User | null>;
|
|
private campaign?: Campaign;
|
|
private characters!: Array<CharacterPrinter>;
|
|
|
|
container: RedrawableHTML = div('flex flex-col flex-1 h-full w-full items-center justify-start gap-6');
|
|
ws?: Socket;
|
|
|
|
constructor(id: string, user: ComputedRef<User | null>)
|
|
{
|
|
this.user = user;
|
|
const load = div("flex justify-center items-center w-full h-full", [ loading('large') ]);
|
|
this.container.replaceChildren(load);
|
|
useRequestFetch()(`/api/campaign/${id}`).then((campaign) => {
|
|
if(campaign)
|
|
{
|
|
this.campaign = reactive(campaign);
|
|
this.characters = reactive(campaign.characters.map(e => new CharacterPrinter(e.character!.id, e.character!.name)));
|
|
/* this.ws = new Socket(`/ws/campaign/${id}`, true);
|
|
|
|
this.ws.handleMessage<{ id: number, name: string, action: 'ADD' | 'REMOVE' }>('character', (character) => {
|
|
if(character.action === 'ADD')
|
|
{
|
|
this.characters.push(new CharacterPrinter(character.id, character.name));
|
|
}
|
|
else if(character.action === 'REMOVE')
|
|
{
|
|
const idx = this.characters.findIndex(e => e.compiler?.character.id !== character.id);
|
|
|
|
idx !== -1 && this.characters.splice(idx, 1);
|
|
}
|
|
});
|
|
this.ws.handleMessage<{ id: number, name: string, action: 'ADD' | 'REMOVE' }>('player', (player) => {
|
|
if(player.action === 'ADD')
|
|
{
|
|
this.campaign?.members.push({ member: { id: player.id, username: player.name } });
|
|
}
|
|
else if(player.action === 'REMOVE')
|
|
{
|
|
const idx = this.campaign?.members.findIndex(e => e.member.id !== player.id);
|
|
|
|
idx && idx !== -1 && this.characters.splice(idx, 1);
|
|
}
|
|
});
|
|
this.ws.handleMessage<void>('hardsync', () => {
|
|
useRequestFetch()(`/api/campaign/${id}`).then((campaign) => {
|
|
this.campaign = reactive(campaign);
|
|
this.characters = reactive(campaign.characters.map(e => new CharacterPrinter(e.character!.id, e.character!.name)));
|
|
});
|
|
}); */
|
|
|
|
document.title = `d[any] - Campagne ${campaign.name}`;
|
|
this.render();
|
|
|
|
load.remove();
|
|
}
|
|
else throw new Error();
|
|
}).catch((e) => {
|
|
console.error(e);
|
|
this.container.replaceChildren(div('flex flex-col items-center justify-center flex-1 h-full gap-4', [
|
|
span('text-2xl font-bold tracking-wider', 'Campagne introuvable'),
|
|
span(undefined, 'Cette campagne n\'existe pas ou est privé.'),
|
|
div('flex flex-row gap-4 justify-center items-center', [
|
|
button(text('Mes campagnes'), () => useRouter().push({ name: 'campaign' }), 'px-2 py-1'),
|
|
button(text('Créer une campagne'), () => useRouter().push({ name: 'campaign-create' }), 'px-2 py-1')
|
|
])
|
|
]));
|
|
});
|
|
}
|
|
save()
|
|
{
|
|
if(!this.campaign)
|
|
return;
|
|
|
|
return useRequestFetch()(`/api/campaign/${this.campaign.id}`, {
|
|
method: 'POST',
|
|
body: {
|
|
name: this.campaign.name,
|
|
status: this.campaign.status,
|
|
public_notes: this.campaign.public_notes,
|
|
dm_notes: this.campaign.dm_notes,
|
|
},
|
|
}).then(() => {}).catch(() => {
|
|
Toaster.add({ type: 'error', content: 'Impossible de mettre à jour les données', duration: 5000, timer: true });
|
|
});
|
|
}
|
|
saveVariables()
|
|
{
|
|
|
|
}
|
|
private render()
|
|
{
|
|
const campaign = this.campaign;
|
|
|
|
if(!campaign)
|
|
return;
|
|
|
|
const charPicker = this.characterPicker();
|
|
|
|
this.container.replaceChildren(div('grid grid-cols-3 gap-2', [
|
|
div('flex flex-row gap-2 items-center py-2', [
|
|
div('w-8 h-8 relative flex items-center justify-center border border-light-40 dark:border-dark-40 box-content rounded-full', [ tooltip(icon('radix-icons:person', { width: 24, height: 24, class: 'text-light-70 dark:text-dark-70' }), campaign.owner.username, 'bottom') ]),
|
|
div('border-l h-full w-0 border-light-40 dark:border-dark-40'),
|
|
div('flex flex-row gap-1', { list: campaign.members, render: (member, _c) => _c ?? div('w-8 h-8 relative flex items-center justify-center border border-light-40 dark:border-dark-40 box-content rounded-full', [ tooltip(icon('radix-icons:person', { width: 24, height: 24, class: 'text-light-70 dark:text-dark-70' }), member.member.username, 'bottom') ]) }),
|
|
]),
|
|
div('flex flex-1 flex-col items-center justify-center gap-2', [
|
|
span('text-2xl font-serif font-bold italic', campaign.name),
|
|
span('italic text-light-70 dark:text-dark-70 text-sm', campaign.status === 'PREPARING' ? 'En préparation' : campaign.status === 'PLAYING' ? 'En jeu' : 'Archivé'),
|
|
]),
|
|
div('flex flex-1 flex-col items-center justify-center', [
|
|
div('border border-light-35 dark:border-dark-35 p-1 flex flex-row items-center gap-2', [
|
|
dom('pre', { class: 'ps-1 w-[400px] truncate' }, [ text(`d-any.com/campaign/join/${ encodeURIComponent(campaign.link) }`) ]),
|
|
button(icon(() => 'radix-icons:clipboard', { width: 16, height: 16 }), () => {}, 'p-1'),
|
|
]),
|
|
]),
|
|
]),
|
|
div('flex flex-row gap-4 flex-1 h-0', [
|
|
div('flex flex-col gap-2', [
|
|
div('flex flex-row items-center gap-4 w-[320px]', [ span('font-bold text-lg', 'Etat'), div('border-t border-light-40 dark:border-dark-40 border-dashed flex-1') ]),
|
|
div('flex flex-col gap-2', { list: this.characters, render: (e, _c) => _c ?? e.container }),
|
|
div('px-8 py-4 w-full flex', [
|
|
button([
|
|
icon('radix-icons:plus-circled', { width: 24, height: 24 }),
|
|
span('text-sm', 'Ajouter un personnage'),
|
|
], () => charPicker.show(), 'flex flex-col flex-1 gap-2 p-4 items-center justify-center text-light-60 dark:text-dark-60'),
|
|
])
|
|
]),
|
|
div('flex h-full border-l border-light-40 dark:border-dark-40'),
|
|
div('flex flex-col', [
|
|
tabgroup([
|
|
{ id: 'campaign', title: [ text('Campagne') ], content: () => {
|
|
const editor = new MarkdownEditor();
|
|
editor.content = campaign.public_notes;
|
|
editor.onChange = (v) => campaign.public_notes = v;
|
|
|
|
return [
|
|
this.user.value && this.user.value.id === campaign.owner.id ? div('flex flex-col gap-4 p-1', [ div('flex flex-row justify-between items-center', [ span('text-xl font-bold', 'Notes destinées aux joueurs'), div('flex flex-row gap-2', [ tooltip(button(icon('radix-icons:paper-plane', { width: 16, height: 16 }), () => this.save(), 'p-1 items-center justify-center'), 'Enregistrer', 'right') ]) ]), div('border border-light-35 dark:border-dark-35 bg-light20 dark:bg-dark-20 p-1 h-64', [editor.dom])]) : markdown(campaign.public_notes, '', { tags: { a: preview }, class: 'px-2' }),
|
|
];
|
|
} },
|
|
{ id: 'inventory', title: [ text('Inventaire') ], content: () => this.items() },
|
|
{ id: 'settings', title: [ text('Paramètres') ], content: () => [
|
|
|
|
] },
|
|
{ id: 'ressources', title: [ text('Ressources') ], content: () => [
|
|
|
|
] }
|
|
], { focused: 'campaign', class: { container: 'max-w-[900px] w-[900px] h-full', content: 'overflow-auto p-2', tabbar: 'gap-4 border-b border-light-30 dark:border-dark-30' } }),
|
|
])
|
|
]))
|
|
}
|
|
characterPicker()
|
|
{
|
|
const current = reactive({
|
|
characters: [] as Character[],
|
|
loading: true,
|
|
});
|
|
const _modal = modal([
|
|
div('flex flex-col gap-4 items-center min-w-[480px] min-h-24', [
|
|
span('text-xl font-bold', 'Mes personnages'),
|
|
div('grid grid-cols-3 gap-2', { list: () => current.characters, render: (e, _c) => _c ?? div('border border-light-40 dark:border-dark-40 p-2 flex flex-col w-[140px]', [
|
|
span('font-bold', e.name),
|
|
span('', `Niveau ${e.level}`),
|
|
button(text('Ajouter'), () => useRequestFetch()(`/api/character/${e.id}/campaign/${this.campaign!.id}`, { method: 'POST' }).then(() => this.ws!.send('character', { id: e.id, name: e.name, action: 'ADD', })).catch(e => Toaster.add({ duration: 15000, content: e.message ?? e, title: 'Une erreur est survenue', type: 'error' })).finally(_modal.close)),
|
|
]), fallback: () => div('flex justify-center items-center col-span-3', [current.loading ? loading('large') : span('text-light-60 dark:text-dark-60 text-sm italic', 'Vous n\'avez pas de personnage disponible')]) }),
|
|
]),
|
|
], { closeWhenOutside: true, priority: true, class: { container: 'max-w-[560px]' }, open: false });
|
|
|
|
return { show: () => {
|
|
current.loading = true;
|
|
|
|
useRequestFetch()(`/api/character`).then((list) => {
|
|
current.characters = list?.filter(e => !this.characters.find(_e => _e.compiler?.character.id === e.id)) ?? [];
|
|
}).catch(e => Toaster.add({ duration: 15000, content: e.message ?? e, title: 'Une erreur est survenue', type: 'error' })).finally(() => {
|
|
current.loading = false;
|
|
});
|
|
|
|
_modal.open();
|
|
}, hide: _modal.close }
|
|
}
|
|
items()
|
|
{
|
|
if(!this.campaign)
|
|
return [];
|
|
|
|
const items = this.campaign.items;
|
|
|
|
const money = {
|
|
readonly: dom('div', { listeners: { click: () => { money.readonly.replaceWith(money.edit); money.edit.focus(); } }, class: 'cursor-pointer border border-transparent hover:border-light-40 dark:hover:border-dark-40 px-2 py-px flex flex-row gap-1 items-center' }, [ span('text-lg font-bold', () => this.campaign!.money.toLocaleString(undefined, { useGrouping: true })), icon('ph:coin', { width: 16, height: 16 }) ]),
|
|
edit: numberpicker({ defaultValue: this.campaign.money, change: v => { this.campaign!.money = v; this.saveVariables(); money.edit.replaceWith(money.readonly); }, blur: v => { this.campaign!.money = v; this.saveVariables(); money.edit.replaceWith(money.readonly); }, min: 0, class: 'w-24' }),
|
|
};
|
|
|
|
return [
|
|
div('flex flex-col gap-2', [
|
|
div('flex flex-row justify-between items-center', [
|
|
div('flex flex-row justify-end items-center gap-8', [
|
|
div('flex flex-row gap-1 items-center', [ span('italic text-sm', 'Argent'), this.user.value && this.user.value.id === this.campaign.owner.id ? money.readonly : div('cursor-pointer px-2 py-px flex flex-row gap-1 items-center', [ span('text-lg font-bold', () => this.campaign!.money.toLocaleString(undefined, { useGrouping: true })), icon('ph:coin', { width: 16, height: 16 }) ]) ]),
|
|
])
|
|
]),
|
|
div('flex flex-col flex-1 divide-y divide-light-35 dark:divide-dark-35', { list: this.campaign.items, render: (e, _c) => {
|
|
if(_c) return _c;
|
|
|
|
const item = config.items[e.id];
|
|
|
|
if(!item) return;
|
|
|
|
const itempower = () => (item.powercost ?? 0) + (e.enchantments?.reduce((_p, _v) => (config.enchantments[_v]?.power ?? 0) + _p, 0) ?? 0);
|
|
|
|
const price = div(() => ['flex flex-row min-w-16 gap-2 justify-between items-center px-2', { 'cursor-help': e.amount > 1 && !!item.price }], [ icon('ph:coin', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }), span(() => ({ 'underline decoration-1 decoration-dotted underline-offset-2': e.amount > 1 && !!item.price }), () => item.price ? `${item.price * e.amount}` : '-') ]);
|
|
const weight = div(() => ['flex flex-row min-w-16 gap-2 justify-between items-center px-2', { 'cursor-help': e.amount > 1 && !!item.weight }], [ icon('mdi:weight', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }), span(() => ({ 'underline decoration-1 decoration-dotted underline-offset-2': e.amount > 1 && !!item.weight }), () => item.weight ? `${item.weight * e.amount}` : '-') ]);
|
|
return foldable(() => [
|
|
markdown(getText(item.description)),
|
|
div('flex flex-row justify-center gap-1', [
|
|
button(text('Offrir'), () => {
|
|
|
|
}, 'px-2 text-sm h-5 box-content'),
|
|
button(icon('radix-icons:minus', { width: 12, height: 12 }), () => {
|
|
const idx = items.findIndex(_e => _e === e);
|
|
if(idx === -1) return;
|
|
|
|
items[idx]!.amount--;
|
|
if(items[idx]!.amount <= 0) items.splice(idx, 1);
|
|
|
|
this.saveVariables();
|
|
}, 'p-1'),
|
|
button(icon('radix-icons:plus', { width: 12, height: 12 }), () => {
|
|
const idx = items.findIndex(_e => _e === e);
|
|
if(idx === -1) return;
|
|
|
|
if(item.equippable) items.push(stateFactory(item));
|
|
else if(items.find(_e => _e === e)) items.find(_e => _e === e)!.amount++;
|
|
else items.push(stateFactory(item));
|
|
|
|
this.saveVariables();
|
|
}, 'p-1')
|
|
]) ], [div('flex flex-row justify-between', [
|
|
div('flex flex-row items-center gap-4', [div('flex flex-row items-center gap-4', [ span([colorByRarity[item.rarity], 'text-lg'], item.name), div('flex flex-row gap-2 text-light-60 dark:text-dark-60 text-sm italic', subnameFactory(item).map(e => span('', e))) ]),]),
|
|
div('flex flex-row items-center divide-x divide-light-50 dark:divide-dark-50 divide-dashed px-2', [
|
|
e.amount > 1 && !!item.price ? tooltip(price, `Prix unitaire: ${item.price}`, 'bottom') : price,
|
|
div('flex flex-row min-w-16 gap-2 justify-between items-center px-2', [ icon('radix-icons:cross-2', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }), span('', () => e.amount ?? '-') ]),
|
|
div('flex flex-row min-w-16 gap-2 justify-between items-center px-2', [ icon('game-icons:bolt-drop', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }), span(() => ({ 'text-red': !!item.capacity && itempower() > item.capacity }), () => item.capacity ? `${itempower()}/${item.capacity ?? 0}` : '-') ]),
|
|
e.amount > 1 && !!item.weight ? tooltip(weight, `Poids unitaire: ${item.weight}`, 'bottom') : weight,
|
|
div('flex flex-row min-w-16 gap-2 justify-between items-center px-2', [ icon('game-icons:battery-pack', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }), span('', () => item.charge ? `${item.charge}` : '-') ]),
|
|
]),
|
|
])], { open: false, class: { icon: 'px-2', container: 'p-1 gap-2', content: 'px-4 pb-1 flex flex-col' } })
|
|
}})
|
|
])
|
|
];
|
|
}
|
|
settings()
|
|
{
|
|
|
|
}
|
|
} |