Finalize CharacterBuilder

This commit is contained in:
Clément Pons 2025-07-22 17:46:16 +02:00
parent 3ef98df5d2
commit 7d6f9162ed
13 changed files with 1830 additions and 145 deletions

BIN
db.sqlite

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,6 +1,5 @@
import { relations } from 'drizzle-orm';
import { int, text, sqliteTable as table, primaryKey, blob } from 'drizzle-orm/sqlite-core';
import { ABILITIES, MAIN_STATS } from '../shared/character.util';
export const usersTable = table("users", {
id: int().primaryKey({ autoIncrement: true }),
@ -66,7 +65,7 @@ export const characterTable = table("character", {
export const characterTrainingTable = table("character_training", {
character: int().notNull().references(() => characterTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
stat: text({ enum: MAIN_STATS }).notNull(),
stat: text({ enum: ["strength","dexterity","constitution","intelligence","curiosity","charisma","psyche"] }).notNull(),
level: int().notNull(),
choice: int().notNull(),
}, (table) => [primaryKey({ columns: [table.character, table.stat, table.level] })]);
@ -79,14 +78,14 @@ export const characterLevelingTable = table("character_leveling", {
export const characterAbilitiesTable = table("character_abilities", {
character: int().notNull().references(() => characterTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
ability: text({ enum: ABILITIES }).notNull(),
ability: text({ enum: ["athletics","acrobatics","intimidation","sleightofhand","stealth","survival","investigation","history","religion","arcana","understanding","perception","performance","medecine","persuasion","animalhandling","deception"] }).notNull(),
value: int().notNull().default(0),
max: int().notNull().default(0),
}, (table) => [primaryKey({ columns: [table.character, table.ability] })]);
export const characterModifiersTable = table("character_modifiers", {
character: int().notNull().references(() => characterTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
modifier: text({ enum: MAIN_STATS }).notNull(),
modifier: text({ enum: ["strength","dexterity","constitution","intelligence","curiosity","charisma","psyche"] }).notNull(),
value: int().notNull().default(0),
}, (table) => [primaryKey({ columns: [table.character, table.modifier] })]);
@ -95,6 +94,12 @@ export const characterSpellsTable = table("character_spell", {
value: text().notNull(),
}, (table) => [primaryKey({ columns: [table.character, table.value] })]);
export const characterChoicesTable = table("character_choices", {
character: int().notNull().references(() => characterTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
id: text().notNull(),
choice: int().notNull(),
}, (table) => [primaryKey({ columns: [table.character, table.id, table.choice] })]);
export const usersRelation = relations(usersTable, ({ one, many }) => ({
data: one(usersDataTable, { fields: [usersTable.id], references: [usersDataTable.id], }),
session: many(userSessionsTable),
@ -119,7 +124,8 @@ export const characterRelation = relations(characterTable, ({ one, many }) => ({
levels: many(characterLevelingTable),
abilities: many(characterAbilitiesTable),
modifiers: many(characterModifiersTable),
spells: many(characterSpellsTable)
spells: many(characterSpellsTable),
choices: many(characterChoicesTable)
}));
export const characterTrainingRelation = relations(characterTrainingTable, ({ one }) => ({
@ -137,3 +143,6 @@ export const characterModifierRelation = relations(characterModifiersTable, ({ o
export const characterSpellsRelation = relations(characterSpellsTable, ({ one }) => ({
character: one(characterTable, { fields: [characterSpellsTable.character], references: [characterTable.id] })
}));
export const characterChoicesRelation = relations(characterChoicesTable, ({ one }) => ({
character: one(characterTable, { fields: [characterChoicesTable.character], references: [characterTable.id] })
}));

View File

@ -0,0 +1,7 @@
CREATE TABLE `character_choices` (
`character` integer NOT NULL,
`id` text NOT NULL,
`choice` integer NOT NULL,
PRIMARY KEY(`character`, `id`, `choice`),
FOREIGN KEY (`character`) REFERENCES `character`(`id`) ON UPDATE cascade ON DELETE cascade
);

View File

@ -0,0 +1,810 @@
{
"version": "6",
"dialect": "sqlite",
"id": "8f89d284-71da-46ae-a282-538f8a901294",
"prevId": "854c13bd-59bb-40bd-a046-69632b59557e",
"tables": {
"character_abilities": {
"name": "character_abilities",
"columns": {
"character": {
"name": "character",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ability": {
"name": "ability",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"max": {
"name": "max",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
}
},
"indexes": {},
"foreignKeys": {
"character_abilities_character_character_id_fk": {
"name": "character_abilities_character_character_id_fk",
"tableFrom": "character_abilities",
"tableTo": "character",
"columnsFrom": [
"character"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {
"character_abilities_character_ability_pk": {
"columns": [
"character",
"ability"
],
"name": "character_abilities_character_ability_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"character_choices": {
"name": "character_choices",
"columns": {
"character": {
"name": "character",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"id": {
"name": "id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"choice": {
"name": "choice",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"character_choices_character_character_id_fk": {
"name": "character_choices_character_character_id_fk",
"tableFrom": "character_choices",
"tableTo": "character",
"columnsFrom": [
"character"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {
"character_choices_character_id_choice_pk": {
"columns": [
"character",
"id",
"choice"
],
"name": "character_choices_character_id_choice_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"character_leveling": {
"name": "character_leveling",
"columns": {
"character": {
"name": "character",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"level": {
"name": "level",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"choice": {
"name": "choice",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"character_leveling_character_character_id_fk": {
"name": "character_leveling_character_character_id_fk",
"tableFrom": "character_leveling",
"tableTo": "character",
"columnsFrom": [
"character"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {
"character_leveling_character_level_pk": {
"columns": [
"character",
"level"
],
"name": "character_leveling_character_level_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"character_modifiers": {
"name": "character_modifiers",
"columns": {
"character": {
"name": "character",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"modifier": {
"name": "modifier",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
}
},
"indexes": {},
"foreignKeys": {
"character_modifiers_character_character_id_fk": {
"name": "character_modifiers_character_character_id_fk",
"tableFrom": "character_modifiers",
"tableTo": "character",
"columnsFrom": [
"character"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {
"character_modifiers_character_modifier_pk": {
"columns": [
"character",
"modifier"
],
"name": "character_modifiers_character_modifier_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"character_spell": {
"name": "character_spell",
"columns": {
"character": {
"name": "character",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"character_spell_character_character_id_fk": {
"name": "character_spell_character_character_id_fk",
"tableFrom": "character_spell",
"tableTo": "character",
"columnsFrom": [
"character"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {
"character_spell_character_value_pk": {
"columns": [
"character",
"value"
],
"name": "character_spell_character_value_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"character": {
"name": "character",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"owner": {
"name": "owner",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"people": {
"name": "people",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"level": {
"name": "level",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"aspect": {
"name": "aspect",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"health": {
"name": "health",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"mana": {
"name": "mana",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"visibility": {
"name": "visibility",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'private'"
},
"thumbnail": {
"name": "thumbnail",
"type": "blob",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"character_owner_users_id_fk": {
"name": "character_owner_users_id_fk",
"tableFrom": "character",
"tableTo": "users",
"columnsFrom": [
"owner"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"character_training": {
"name": "character_training",
"columns": {
"character": {
"name": "character",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"stat": {
"name": "stat",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"level": {
"name": "level",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"choice": {
"name": "choice",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"character_training_character_character_id_fk": {
"name": "character_training_character_character_id_fk",
"tableFrom": "character_training",
"tableTo": "character",
"columnsFrom": [
"character"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {
"character_training_character_stat_level_pk": {
"columns": [
"character",
"stat",
"level"
],
"name": "character_training_character_stat_level_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"email_validation": {
"name": "email_validation",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"timestamp": {
"name": "timestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"project_content": {
"name": "project_content",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "blob",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"project_files": {
"name": "project_files",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"path": {
"name": "path",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"owner": {
"name": "owner",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"navigable": {
"name": "navigable",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"private": {
"name": "private",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"order": {
"name": "order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"timestamp": {
"name": "timestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"project_files_path_unique": {
"name": "project_files_path_unique",
"columns": [
"path"
],
"isUnique": true
}
},
"foreignKeys": {
"project_files_owner_users_id_fk": {
"name": "project_files_owner_users_id_fk",
"tableFrom": "project_files",
"tableTo": "users",
"columnsFrom": [
"owner"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user_permissions": {
"name": "user_permissions",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"permission": {
"name": "permission",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"user_permissions_id_users_id_fk": {
"name": "user_permissions_id_users_id_fk",
"tableFrom": "user_permissions",
"tableTo": "users",
"columnsFrom": [
"id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {
"user_permissions_id_permission_pk": {
"columns": [
"id",
"permission"
],
"name": "user_permissions_id_permission_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user_sessions": {
"name": "user_sessions",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"timestamp": {
"name": "timestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"user_sessions_user_id_users_id_fk": {
"name": "user_sessions_user_id_users_id_fk",
"tableFrom": "user_sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {
"user_sessions_id_user_id_pk": {
"columns": [
"id",
"user_id"
],
"name": "user_sessions_id_user_id_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users_data": {
"name": "users_data",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"signin": {
"name": "signin",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"lastTimestamp": {
"name": "lastTimestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"users_data_id_users_id_fk": {
"name": "users_data_id_users_id_fk",
"tableFrom": "users_data",
"tableTo": "users",
"columnsFrom": [
"id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"hash": {
"name": "hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"state": {
"name": "state",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
}
},
"indexes": {
"users_username_unique": {
"name": "users_username_unique",
"columns": [
"username"
],
"isUnique": true
},
"users_email_unique": {
"name": "users_email_unique",
"columns": [
"email"
],
"isUnique": true
},
"users_hash_unique": {
"name": "users_hash_unique",
"columns": [
"hash"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@ -99,6 +99,13 @@
"when": 1753097020642,
"tag": "0013_wakeful_lake",
"breakpoints": true
},
{
"idx": 14,
"version": "6",
"when": 1753175811770,
"tag": "0014_careless_nick_fury",
"breakpoints": true
}
]
}

View File

@ -49,11 +49,12 @@ export default defineEventHandler(async (e) => {
health: character.health,
mana: character.mana,
training: character.training.reduce((p, v) => { if(!(v.stat in p)) p[v.stat] = []; p[v.stat].push([v.level as TrainingLevel, v.choice]); return p; }, {} as Record<MainStat, DoubleIndex<TrainingLevel>[]>),
leveling: character.levels.map(e => [e.level as Level, e.choice] as DoubleIndex<Level>),
abilities: group(character.abilities.map(e => ({ ...e, value: [e.value, e.max] as [number, number] })), "ability", "value"),
training: character.training.reduce((p, v) => { p[v.stat] ??= {}; p[v.stat][v.level as TrainingLevel] = v.choice; return p; }, {} as Record<MainStat, Partial<Record<TrainingLevel, number>>>),
leveling: character.levels.reduce((p, v) => { p[v.level as Level] = v.choice; return p; }, {} as Partial<Record<Level, number>>),
abilities: group(character.abilities.map(e => ({ ...e, value: e.value })), "ability", "value"),
spells: character.spells.map(e => e.value),
modifiers: group(character.modifiers, "modifier", "value"),
choices: {},
owner: character.owner,
username: character.user.username,

View File

@ -1,6 +1,6 @@
import { eq } from 'drizzle-orm';
import useDatabase from '~/composables/useDatabase';
import { characterAbilitiesTable, characterLevelingTable, characterModifiersTable, characterSpellsTable, characterTable, characterTrainingTable } from '~/db/schema';
import { characterAbilitiesTable, characterChoicesTable, characterLevelingTable, characterModifiersTable, characterSpellsTable, characterTable, characterTrainingTable } from '~/db/schema';
import { CharacterValidation } from '#shared/character.util';
import { type Ability, type MainStat } from '~/types/character';
@ -54,23 +54,23 @@ export default defineEventHandler(async (e) => {
tx.delete(characterModifiersTable).where(eq(characterModifiersTable.character, id)).run();
tx.delete(characterSpellsTable).where(eq(characterSpellsTable.character, id)).run();
tx.delete(characterAbilitiesTable).where(eq(characterAbilitiesTable.character, id)).run();
tx.delete(characterChoicesTable).where(eq(characterChoicesTable.character, id)).run();
if(body.data.leveling.length > 0) tx.insert(characterLevelingTable).values(body.data.leveling.map(e => ({ character: id, level: e[0], choice: e[1] }))).run();
const leveling = Object.entries(body.data.leveling).filter(e => e[1] !== undefined).map(e => ({ character: id, level: parseInt(e[0]), choice: e[1]! }));
if(leveling.length > 0) tx.insert(characterLevelingTable).values(leveling).run();
const training = Object.entries(body.data.training).flatMap(e => e[1].map(_e => ({ character: id, stat: e[0] as MainStat, level: _e[0], choice: _e[1] })));
const training = Object.entries(body.data.training).flatMap(e => Object.entries(e[1]).filter(_e => _e[1] !== undefined).map(_e => ({ character: id, stat: e[0] as MainStat, level: parseInt(_e[0]), choice: _e[1]! })));
if(training.length > 0) tx.insert(characterTrainingTable).values(training).run();
const modifiers = Object.entries(body.data.modifiers).map((e) => ({ character: id, modifier: e[0] as MainStat, value: e[1] }));
const modifiers = Object.entries(body.data.modifiers).filter(e => e[1] !== undefined).map((e) => ({ character: id, modifier: e[0] as MainStat, value: e[1] }));
if(modifiers.length > 0) tx.insert(characterModifiersTable).values(modifiers).run();
if(body.data.spells.length > 0) tx.insert(characterSpellsTable).values(body.data.spells.map(e => ({ character: id, value: e }))).run();
const abilities = Object.entries(body.data.abilities).map(e => ({ character: id, ability: e[0] as Ability, value: e[1][0], max: e[1][1] }));
const abilities = Object.entries(body.data.abilities).filter(e => e[1] !== undefined).map(e => ({ character: id, ability: e[0] as Ability, value: e[1], max: 0 }));
if(abilities.length > 0) tx.insert(characterAbilitiesTable).values(abilities).run();
});
await useStorage('cache').removeItem(`nitro:functions:character:${id}.json`);
setResponseStatus(e, 200);
return;
});

View File

@ -186,36 +186,214 @@
"options": {
"1": [
{
"description": "+35 points de statistiques.\n+14 PV max."
"description": "+35 points de statistiques.\n+14 PV max.",
"effect": [
{
"category": "value",
"operation": "add",
"property": "training",
"value": 35
},
{
"category": "value",
"operation": "add",
"property": "health",
"value": 14
}
]
}
],
"2": [
{
"description": "+1 point de statistique.\n+3 PV max.\n+2 mana max."
"description": "+1 point de statistique.\n+3 PV max.\n+2 mana max.",
"effect": [
{
"category": "value",
"operation": "add",
"property": "training",
"value": 1
},
{
"description": "+1 point de compétence.\n+6 PV max.\n+3 mana max."
"category": "value",
"operation": "add",
"property": "health",
"value": 3
},
{
"category": "value",
"operation": "add",
"property": "mana",
"value": 2
}
]
},
{
"description": "+1 point de compétence.\n+6 PV max.\n+3 mana max.",
"effect": [
{
"category": "value",
"operation": "add",
"property": "ability",
"value": 1
},
{
"category": "value",
"operation": "add",
"property": "health",
"value": 6
},
{
"category": "value",
"operation": "add",
"property": "mana",
"value": 3
}
]
}
],
"3": [
{
"description": "+2 points de statistiques.\n+1 point de compétence.\n+3 PV max.\n+1 mana max."
"description": "+2 points de statistiques.\n+1 point de compétence.\n+3 PV max.\n+1 mana max.",
"effect": [
{
"category": "value",
"operation": "add",
"property": "training",
"value": 2
},
{
"category": "value",
"operation": "add",
"property": "ability",
"value": 1
},
{
"category": "value",
"operation": "add",
"property": "health",
"value": 3
},
{
"category": "value",
"operation": "add",
"property": "mana",
"value": 1
}
]
}
],
"4": [
{
"description": "+1 point de statistique.\n+2 points de compétences.\n+4 PV max.\n+2 mana max."
"description": "+1 point de statistique.\n+2 points de compétences.\n+4 PV max.\n+2 mana max.",
"effect": [
{
"category": "value",
"operation": "add",
"property": "training",
"value": 1
},
{
"category": "value",
"operation": "add",
"property": "ability",
"value": 2
},
{
"category": "value",
"operation": "add",
"property": "health",
"value": 4
},
{
"category": "value",
"operation": "add",
"property": "mana",
"value": 2
}
]
}
],
"5": [
{
"description": "+1 point de statistique.\n+2 points de compétences.\n+4 PV max.\n+2 mana max."
"description": "+1 point de statistique.\n+2 points de compétences.\n+4 PV max.\n+2 mana max.",
"effect": [
{
"category": "value",
"operation": "add",
"property": "training",
"value": 1
},
{
"description": "+1 point de statistique.\n+1 transformation par jour.\n+8 PV max.\n+4 mana max."
"category": "value",
"operation": "add",
"property": "ability",
"value": 2
},
{
"description": "+2 points de statistiques.\n+7 PV max.\n+2 mana max."
"category": "value",
"operation": "add",
"property": "health",
"value": 4
},
{
"category": "value",
"operation": "add",
"property": "mana",
"value": 2
}
]
},
{
"description": "+1 point de statistique.\n+1 transformation par jour.\n+8 PV max.\n+4 mana max.",
"effect": [
{
"category": "value",
"operation": "add",
"property": "training",
"value": 1
},
{
"category": "value",
"operation": "add",
"property": "transformation",
"value": 2
},
{
"category": "value",
"operation": "add",
"property": "health",
"value": 8
},
{
"category": "value",
"operation": "add",
"property": "mana",
"value": 4
}
]
},
{
"description": "+2 points de statistiques.\n+7 PV max.\n+2 mana max.",
"effect": [
{
"category": "value",
"operation": "add",
"property": "training",
"value": 2
},
{
"category": "value",
"operation": "add",
"property": "health",
"value": 7
},
{
"category": "value",
"operation": "add",
"property": "mana",
"value": 2
}
]
}
],
"6": [
@ -1133,8 +1311,8 @@
"features": [
{
"category": "value",
"type": "set",
"property": "defense.hardcap",
"operation": "set",
"property": "defense/hardcap",
"value": 3
}
]
@ -1161,13 +1339,13 @@
"features": [
{
"category": "value",
"type": "set",
"property": "defense.hardcap",
"operation": "set",
"property": "defense/hardcap",
"value": 6
},
{
"category": "value",
"type": "set",
"operation": "set",
"property": "speed",
"value": false
}
@ -1194,20 +1372,20 @@
"features": [
{
"category": "value",
"type": "set",
"property": "defense.hardcap",
"operation": "set",
"property": "defense/hardcap",
"value": 9999
},
{
"category": "value",
"type": "set",
"operation": "set",
"property": "speed",
"value": 0
},
{
"category": "value",
"type": "add",
"property": "mastery.strength",
"operation": "add",
"property": "mastery/strength",
"value": 1
}
]
@ -1232,20 +1410,26 @@
"features": [
{
"category": "value",
"type": "set",
"operation": "set",
"property": "speed",
"value": 3
},
{
"category": "value",
"type": "add",
"property": "mastery.armor",
"operation": "add",
"property": "mastery/armor",
"value": 1
},
{
"category": "value",
"type": "add",
"property": "defense.activeparry",
"operation": "add",
"property": "defense/activeparry",
"value": 1
},
{
"category": "value",
"operation": "add",
"property": "modifier/strength",
"value": 1
}
]
@ -1270,26 +1454,26 @@
"features": [
{
"category": "value",
"type": "set",
"operation": "set",
"property": "speed",
"value": 6
},
{
"category": "value",
"type": "add",
"property": "mastery.strength",
"operation": "add",
"property": "mastery/strength",
"value": 1
},
{
"category": "value",
"type": "add",
"property": "defense.activeparry",
"operation": "add",
"property": "defense/activeparry",
"value": 1
},
{
"category": "value",
"type": "add",
"property": "defense.passiveparry",
"operation": "add",
"property": "defense/passiveparry",
"value": 1
}
]
@ -1306,8 +1490,8 @@
"features": [
{
"category": "value",
"type": "add",
"property": "mastery.strength",
"operation": "add",
"property": "mastery/strength",
"value": 1
}
]
@ -1336,8 +1520,8 @@
"features": [
{
"category": "value",
"type": "add",
"property": "mastery.shield",
"operation": "add",
"property": "mastery/shield",
"value": 1
}
]
@ -1354,8 +1538,14 @@
"features": [
{
"category": "value",
"type": "add",
"property": "mastery.strength",
"operation": "add",
"property": "mastery/strength",
"value": 1
},
{
"category": "value",
"operation": "add",
"property": "modifier/strength",
"value": 1
}
]
@ -1371,6 +1561,12 @@
{
"category": "misc",
"text": "En infligeant des dégâts critique, vous pouvez choisir d'ignorer l'armure adverse."
},
{
"category": "value",
"operation": "add",
"property": "modifier/strength",
"value": 1
}
]
},
@ -1384,8 +1580,14 @@
"features": [
{
"category": "value",
"type": "add",
"property": "mastery.armor",
"operation": "add",
"property": "mastery/armor",
"value": 1
},
{
"category": "value",
"operation": "add",
"property": "modifier/strength",
"value": 1
}
]
@ -1416,8 +1618,8 @@
"features": [
{
"category": "value",
"type": "add",
"property": "mastery.strength",
"operation": "add",
"property": "mastery/strength",
"value": 1
}
]
@ -1432,8 +1634,8 @@
"features": [
{
"category": "value",
"type": "add",
"property": "mastery.armor",
"operation": "add",
"property": "mastery/armor",
"value": 1
}
]
@ -1450,8 +1652,8 @@
"features": [
{
"category": "value",
"type": "add",
"property": "mastery.armor",
"operation": "add",
"property": "mastery/armor",
"value": 1
}
]
@ -1467,8 +1669,8 @@
"features": [
{
"category": "value",
"type": "add",
"property": "mastery.armor",
"operation": "add",
"property": "mastery/armor",
"value": 1
}
]
@ -1500,6 +1702,12 @@
{
"category": "misc",
"text": "Au prix d'un point de [[1. Règles/99. Annexes/3. Fatigue et repos#Fatigue temporaire|fatigue temporaire]], durant votre tour, les dégâts que vous infligerez avec une [[1. Règles/99. Annexes/4. Équipement#Les armes|arme standard]], [[1. Règles/99. Annexes/4. Équipement#Les armes lourdes|lourdes]] ou [[1. Règles/99. Annexes/4. Équipement#Les armes à deux mains|à deux mains]] vous permet de lancer un second dé de dégâts de votre arme. *Ce dé peut être doublé en cas de dégâts critique.*"
},
{
"category": "value",
"operation": "add",
"property": "modifier/strength",
"value": 1
}
]
},
@ -1514,6 +1722,12 @@
{
"category": "misc",
"text": "Après avoir pris un adversaire en tenaille, si un allié parvient à le toucher, vous obtenez également un [[1. Règles/1. Introduction/2. Glossaire#Avantage et désavantage|avantage]] sur votre **première** attaque contre cet adversaire."
},
{
"category": "value",
"operation": "add",
"property": "modifier/strength",
"value": 1
}
]
},
@ -1527,8 +1741,14 @@
"features": [
{
"category": "value",
"type": "add",
"property": "mastery.shield",
"operation": "add",
"property": "mastery/shield",
"value": 1
},
{
"category": "value",
"operation": "add",
"property": "modifier/strength",
"value": 1
}
]
@ -1634,6 +1854,12 @@
{
"category": "misc",
"text": "Vous êtes capable de tenir une [[1. Règles/99. Annexes/4. Équipement#Les armes à deux mains|arme à deux mains]] dans une seule main. Vous ne pouvez cependant pas tenir d'arme dans votre autre main, *même en ayant progressé dans l'[[1. Règles/99. Annexes/1. Les évolutions de valeur.canvas#Les armes multiples|arbre des armes multiples]]*."
},
{
"category": "value",
"operation": "add",
"property": "modifier/strength",
"value": 1
}
]
},
@ -1648,6 +1874,12 @@
{
"category": "misc",
"text": "Au prix d'un point de [[1. Règles/99. Annexes/3. Fatigue et repos#Fatigue temporaire|fatigue temporaire]], durant tout un tour, faire une attaque ne demande que 1 point d'action."
},
{
"category": "value",
"operation": "add",
"property": "modifier/strength",
"value": 1
}
]
},
@ -1663,6 +1895,12 @@
"category": "action",
"text": "Vous pouvez frapper, puis vous [[1. Règles/3. Le combat/2. Actions en combat#S'interposer|interposer]] en 3 points d'action.",
"cost": 3
},
{
"category": "value",
"operation": "add",
"property": "modifier/strength",
"value": 1
}
]
}
@ -1723,7 +1961,14 @@
"category": "reaction"
}
],
"features": []
"features": [
{
"category": "value",
"operation": "add",
"property": "modifier/strength",
"value": 1
}
]
}
],
"15": [
@ -1734,7 +1979,14 @@
"disposable": false
}
],
"features": []
"features": [
{
"category": "value",
"operation": "add",
"property": "modifier/strength",
"value": 1
}
]
},
{
"description": [
@ -1743,7 +1995,14 @@
"disposable": false
}
],
"features": []
"features": [
{
"category": "value",
"operation": "add",
"property": "modifier/strength",
"value": 1
}
]
},
{
"description": [
@ -1752,7 +2011,14 @@
"disposable": false
}
],
"features": []
"features": [
{
"category": "value",
"operation": "add",
"property": "modifier/strength",
"value": 1
}
]
}
]
},
@ -2996,7 +3262,7 @@
"features": [
{
"category": "asset",
"type": "add",
"operation": "add",
"kind": "spells",
"asset": "special-1"
}
@ -5056,5 +5322,63 @@
],
"id": "special-1"
}
],
"aspects": [
{
"name": "Digride",
"description": "",
"stat": "dexterity",
"alignment": { "loyalty": "loyal", "kindness": "evil" },
"magic": true,
"difficulty": 10,
"physic": { "min": 12, "max": 22 },
"mental": { "min": 8, "max": 15 },
"personality": { "min": 12, "max": 20 },
"options": [
]
},
{
"name": "Akkatom",
"description": "",
"stat": "strength",
"alignment": { "loyalty": "loyal", "kindness": "good" },
"magic": true,
"difficulty": 9,
"physic": { "min": 18, "max": 25 },
"mental": { "min": 8, "max": 12 },
"personality": { "min": 8, "max": 12 },
"options": [
]
},
{
"name": "Nolcalir",
"description": "",
"stat": "intelligence",
"alignment": { "loyalty": "loyal", "kindness": "neutral" },
"magic": true,
"difficulty": 9,
"physic": { "min": 8, "max": 20 },
"mental": { "min": 8, "max": 20 },
"personality": { "min": 5, "max": 18 },
"options": [
]
},
{
"name": "Brukaur",
"description": "",
"stat": "constitution",
"alignment": { "loyalty": "chaotic", "kindness": "neutral" },
"magic": false,
"difficulty": 9,
"physic": { "min": 18, "max": 25 },
"mental": { "min": 3, "max": 13 },
"personality": { "min": 8, "max": 15 },
"options": [
]
}
]
}

View File

@ -1,10 +1,11 @@
import type { Ability, Character, CharacterConfig, CompiledCharacter, DoubleIndex, Feature, FeatureItem, Level, MainStat, SpellElement, SpellType, TrainingLevel } from "~/types/character";
import type { Ability, Alignment, Character, CharacterConfig, CompiledCharacter, DoubleIndex, Feature, FeatureItem, Level, MainStat, SpellElement, SpellType, TrainingLevel } from "~/types/character";
import { z } from "zod/v4";
import characterConfig from './character-config.json';
import { button, loading } from "./proses";
import { button, fakeA, loading } from "./proses";
import { div, dom, icon, text } from "./dom.util";
import { popper } from "./floating.util";
import { clamp } from "./general.util";
import markdownUtil from "./markdown.util";
const config = characterConfig as CharacterConfig;
@ -25,8 +26,8 @@ export const defaultCharacter: Character = {
health: 0,
mana: 0,
training: MAIN_STATS.reduce((p, v) => { p[v] = [[0, 0]]; return p; }, {} as Record<MainStat, DoubleIndex<TrainingLevel>[]>),
leveling: [[1, 0]],
training: MAIN_STATS.reduce((p, v) => { p[v] = { 0: 0 }; return p; }, {} as Record<MainStat, Partial<Record<TrainingLevel, number>>>),
leveling: { 1: 0 },
abilities: {},
spells: [],
modifiers: {},
@ -35,6 +36,80 @@ export const defaultCharacter: Character = {
owner: -1,
visibility: "private",
};
const defaultCompiledCharacter: (character: Character) => CompiledCharacter = (character: Character) => ({
id: character.id,
owner: character.owner,
username: character.username,
name: character.name,
health: 0,
mana: 0,
race: character.people!,
modifier: MAIN_STATS.reduce((p, v) => { p[v] = 0; return p; }, {} as Record<MainStat, number>),
level: character.level,
values: {
health: character.health,
mana: character.mana
},
features: {
action: [],
reaction: [],
freeaction: [],
passive: [],
},
abilities: {
athletics: 0,
acrobatics: 0,
intimidation: 0,
sleightofhand: 0,
stealth: 0,
survival: 0,
investigation: 0,
history: 0,
religion: 0,
arcana: 0,
understanding: 0,
perception: 0,
performance: 0,
medecine: 0,
persuasion: 0,
animalhandling: 0,
deception: 0
},
spellslots: 0,
artslots: 0,
spellranks: {
instinct: 0,
knowledge: 0,
precision: 0,
arts: 0,
},
spells: character.spells ?? [],
speed: false,
defense: {
hardcap: Infinity,
static: 6,
activeparry: 0,
activedodge: 0,
passiveparry: 0,
passivedodge: 0,
},
mastery: {
strength: 0,
dexterity: 0,
shield: 0,
armor: 0,
multiattack: 1,
magicpower: 0,
magicspeed: 0,
magicelement: 0,
magicinstinct: 0,
},
bonus: {},
resistance: {},//Object.fromEntries(MAIN_STATS.map(e => [e as MainStat, [0, 0]])) as Record<MainStat, [number, number]>,
initiative: 0,
aspect: "",
notes: character.notes ?? "",
});
export const mainStatTexts: Record<MainStat, string> = {
"strength": "Force",
@ -58,6 +133,18 @@ export const elementTexts: Record<SpellElement, { class: string, text: string }>
psyche: { class: 'text-light-purple dark:text-dark-purple border-light-purple dark:border-dark-purple bg-light-purple dark:bg-dark-purple', text: 'Psy' },
};
export function alignmentToString(alignment: Alignment): string
{
switch(alignment.loyalty)
{
case 'chaotic':
return alignment.kindness === 'evil' ? 'Chaotique mauvais' : alignment.kindness === 'neutral' ? 'Chaotique neutre' : 'Chaotique bon';
case 'loyal':
return alignment.kindness === 'evil' ? 'Loyal mauvais' : alignment.kindness === 'neutral' ? 'Loyal neutre' : 'Loyal bon';
case 'neutral':
return alignment.kindness === 'evil' ? 'Neutre mauvais' : alignment.kindness === 'neutral' ? 'Neutre' : 'Neutre bon';
}
}
export const spellTypeTexts: Record<SpellType, string> = { "instinct": "Instinct", "knowledge": "Savoir", "precision": "Précision", "arts": "Oeuvres" };
export const CharacterValidation = z.object({
@ -69,20 +156,12 @@ export const CharacterValidation = z.object({
notes: z.string().nullable().optional(),
health: z.number().default(0),
mana: z.number().default(0),
training: z.object(MAIN_STATS.reduce((p, v) => {
p[v] = z.array(z.tuple([z.number().min(0).max(15), z.number()]));
return p;
}, {} as Record<MainStat, z.ZodArray<z.ZodTuple<[z.ZodNumber, z.ZodNumber], null>>>)),
leveling: z.array(z.tuple([z.number().min(1).max(20), z.number()])),
abilities: z.object(ABILITIES.reduce((p, v) => {
p[v] = z.tuple([z.number(), z.number()]);
return p;
}, {} as Record<Ability, z.ZodTuple<[z.ZodNumber, z.ZodNumber], null>>)).partial(),
training: z.record(z.enum(MAIN_STATS), z.record(z.enum(TRAINING_LEVELS.map(String)), z.number().optional())),
leveling: z.record(z.enum(LEVELS.map(String)), z.number().optional()),
abilities: z.record(z.enum(ABILITIES), z.number().optional()),
spells: z.string().array(),
modifiers: z.object(MAIN_STATS.reduce((p, v) => {
p[v] = z.number();
return p;
}, {} as Record<MainStat, z.ZodNumber>)).partial(),
modifiers: z.record(z.enum(MAIN_STATS), z.number().optional()),
choices: z.record(z.string(), z.array(z.number())),
owner: z.number(),
username: z.string().optional(),
visibility: z.enum(["public", "private"]),
@ -97,13 +176,15 @@ const stepTexts: Record<number, string> = {
4: 'Déterminez l\'Aspect qui vous corresponds et benéficiez de puissants bonus.'
};
type PropertySum = { list: Array<string | number>, value: number, _dirty: boolean };
type Property = { value: number | string, operation: "set" | "add" };
type PropertySum = { list: Array<Property>, value: number, _dirty: boolean };
export class CharacterBuilder
{
private _container: HTMLDivElement;
private _content?: HTMLDivElement;
private _stepsHeader: HTMLDivElement[] = [];
private _stepsContent: BuilderTab[] = [];
private _helperText!: Text;
private id?: string;
private _character!: Character;
@ -126,18 +207,20 @@ export class CharacterBuilder
document.title = `d[any] - Edition de ${character.name ?? 'nouveau personnage'}`;
if(character.people)
if(character.people !== undefined)
{
const people = config.peoples[character.people]!;
character.leveling.forEach(e => {
const feature = people.options[e[0]][e[1]]!;
this._result = defaultCompiledCharacter(this._character);
Object.entries(character.leveling).forEach(e => {
const feature = people.options[parseInt(e[0]) as Level][e[1]]!;
feature.effect.map(e => this.apply(e));
});
MAIN_STATS.forEach(stat => {
character.training[stat].forEach(option => {
config.training[stat][option[0]][option[1]]!.features?.forEach(this.apply.bind(this));
Object.entries(character.training[stat]).forEach(option => {
config.training[stat][parseInt(option[0]) as TrainingLevel][option[1]]!.features?.forEach(this.apply.bind(this));
})
});
}
@ -152,6 +235,8 @@ export class CharacterBuilder
{
this._character = Object.assign({}, defaultCharacter);
this._result = defaultCompiledCharacter(this._character);
document.title = `d[any] - Edition de nouveau personnage`;
this.render();
@ -184,17 +269,21 @@ export class CharacterBuilder
this._stepsContent = [
new PeoplePicker(this),
new LevelPicker(this),
new TrainingPicker(this),
new AbilityPicker(this),
new AspectPicker(this),
];
this._helperText = text("Choisissez un peuple afin de définir la progression de votre personnage au fil des niveaux.")
this._content = div('flex-1 outline-none max-w-full w-full overflow-y-auto');
this._container.appendChild(div('flex flex-1 flex-col justify-start items-center px-8 w-full h-full overflow-y-hidden', [
div("flex w-full flex-row gap-4 items-center justify-between px-4 bg-light-0 dark:bg-dark-0 z-20", [
div(), div("flex w-full flex-row gap-4 items-center justify-center relative", this._stepsHeader), div(undefined, [popper(icon("radix-icons:question-mark-circled", { height: 20, width: 20 }), {
div(), div("flex w-full flex-row gap-4 items-center justify-center relative", this._stepsHeader), div(undefined, [ popper(icon("radix-icons:question-mark-circled", { height: 20, width: 20 }), {
arrow: true,
offset: 8,
content: [ text("Choisissez un peuple afin de définir la progression de votre personnage au fil des niveaux.") ],
content: [ this._helperText ],
placement: "bottom-end",
class: "max-w-96 fixed hidden 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"
})]),
}) ]),
]),
this._content,
]));
@ -212,13 +301,15 @@ export class CharacterBuilder
this._stepsContent[step]!.update();
this._content?.replaceChildren(...this._stepsContent[step]!.dom);
this._helperText.textContent = stepTexts[step]!;
}
async save(leave: boolean = true)
{
if(this.id === 'new')
{
//@ts-ignore
this.id = this._character.id = await useRequestFetch()(`/api/character`, {
this.id = this._character.id = this._result.id = await useRequestFetch()(`/api/character`, {
method: 'post',
body: this._character,
onResponseError: (e) => {
@ -232,7 +323,7 @@ export class CharacterBuilder
else
{
//@ts-ignore
await useRequestFetch()(`/api/character/${id}`, {
await useRequestFetch()(`/api/character/${this._character.id}`, {
method: 'post',
body: this._character,
onResponseError: (e) => {
@ -279,25 +370,37 @@ export class CharacterBuilder
let sum = 0;
for(let i = 0; i < buffer.list.length; i++)
{
if(typeof buffer.list[i] === 'string')
if(typeof buffer.list[i]!.value === 'string')
{
if(this._buffer[buffer.list[i]!]!._dirty)
if(this._buffer[buffer.list[i]!.value]!._dirty)
{
//Put it back in queue since its dependencies haven't been resolved yet
queue.push(property);
return;
}
else
sum += this._buffer[buffer.list[i]!]!.value;
{
if(buffer.list[i]?.operation === 'add')
sum += this._buffer[buffer.list[i]!.value]!.value;
else if(buffer.list[i]?.operation === 'set')
sum = this._buffer[buffer.list[i]!.value]!.value;
}
}
else
sum += buffer.list[i] as number;
{
if(buffer.list[i]?.operation === 'add')
sum += buffer.list[i]!.value as number;
else if(buffer.list[i]?.operation === 'set')
sum = buffer.list[i]!.value as number;
}
}
const path = property.split("/");
const object = path.slice(0, -1).reduce((p, v) => p[v], this._result as any);
const object = path.length === 1 ? this._result : path.slice(0, -1).reduce((p, v) => p[v], this._result as any);
if(object.hasOwnProperty(path.slice(-1)[0]!))
object[path.slice(-1)[0]!] = sum;
this._buffer[property]!.value = sum;
this._buffer[property]!._dirty = false;
}
@ -309,15 +412,15 @@ export class CharacterBuilder
if(this._character.leveling) //Invalidate higher levels
{
for(let level = 20; level > this._character.level; level--)
for(let _level = 20; _level > this._character.level; _level--)
{
const index = this._character.leveling.findIndex(e => e[0] == level);
if(index !== -1)
const level = _level as Level;
if(this._character.leveling.hasOwnProperty(level))
{
const option = this._character.leveling[level]!;
this._character.leveling.splice(index, 1);
delete this._character.leveling[level];
this.remove(config.peoples[this._character.people!]!.options[option[0]][option[1]]!);
this.remove(config.peoples[this._character.people!]!.options[level][option]);
}
}
}
@ -329,7 +432,7 @@ export class CharacterBuilder
if(this._character.leveling === undefined) //Add level 1 if missing
{
this._character.leveling = [[1, 0]];
this._character.leveling = { 1: 0 };
this.add(config.peoples[this._character.people!]!.options[1][0]!);
}
@ -338,43 +441,80 @@ export class CharacterBuilder
for(let i = 1; i < level; i++) //Check previous levels as a requirement
{
if(!this._character.leveling.some(e => e[0] == i))
if(!this._character.leveling.hasOwnProperty(i))
return;
}
const option = this._character.leveling.find(e => e[0] == level);
if(option && option[1] !== choice) //If the given level is already selected, switch to the new choice
if(this._character.leveling.hasOwnProperty(level) && this._character.leveling[level] !== choice) //If the given level is already selected, switch to the new choice
{
this._character.leveling.splice(this._character.leveling.findIndex(e => e[0] == level), 1, [level, choice]);
this.remove(config.peoples[this._character.people!]!.options[level][this._character.leveling[level]!]);
this.add(config.peoples[this._character.people!]!.options[level][choice]);
this.remove(config.peoples[this._character.people!]!.options[option[0]][option[1]]!);
this.add(config.peoples[this._character.people!]!.options[level][choice]!);
this._character.leveling[level] = choice;
}
else if(!option)
else if(!this._character.leveling.hasOwnProperty(level))
{
this._character.leveling.push([level, choice]);
this._character.leveling[level] = choice;
this.add(config.peoples[this._character.people!]!.options[level][choice]!);
}
}
toggleTrainingOption(stat: MainStat, level: TrainingLevel, option: number)
toggleTrainingOption(stat: MainStat, level: TrainingLevel, choice: number)
{
if(level == 0) //Cannot remove the initial level
return;
}
private add(feature: Feature)
for(let i = 1; i < level; i++) //Check previous levels as a requirement
{
feature.effect.forEach(this.apply.bind(this));
if(!this._character.training[stat].hasOwnProperty(i as TrainingLevel))
return;
}
private remove(feature: Feature)
{
}
private apply(feature: FeatureItem)
if(this._character.training[stat].hasOwnProperty(level))
{
if(this._character.training[stat][level] === choice)
{
for(let i = 15; i >= level; i --) //Invalidate higher levels
{
if(this._character.training[stat].hasOwnProperty(i))
{
config.training[stat][i as TrainingLevel][this._character.training[stat][i as TrainingLevel]!]?.features?.forEach(this.undo.bind(this));
delete this._character.training[stat][i as TrainingLevel];
}
}
}
else
{
config.training[stat][level][this._character.training[stat][level]!]?.features?.forEach(this.undo.bind(this));
this._character.training[stat][level] = choice;
config.training[stat][level][choice]?.features?.forEach(this.apply.bind(this));
}
}
else
{
this._character.training[stat][level] = choice;
config.training[stat][level][choice]?.features?.forEach(this.apply.bind(this));
}
}
private add(feature?: Feature)
{
feature?.effect.forEach(this.apply.bind(this));
}
private remove(feature?: Feature)
{
feature?.effect.forEach(this.undo.bind(this));
}
private apply(feature?: FeatureItem)
{
if(!feature)
return;
switch(feature.category)
{
case "feature":
this._result.features[feature.kind].push(feature.text);
this._result.features[feature.kind] ??= [];
this._result.features[feature.kind]!.push(feature.text);
return;
case "list":
@ -387,10 +527,7 @@ export class CharacterBuilder
case "value":
this._buffer[feature.property] ??= { list: [], value: 0, _dirty: true };
if(feature.operation === 'add')
this._buffer[feature.property]!.list.push(feature.value);
else if(feature.operation === 'set')
this._buffer[feature.property]!.list = [feature.value];
this._buffer[feature.property]!.list.push({ operation: feature.operation, value: feature.value });
this._buffer[feature.property]!._dirty = true;
@ -399,6 +536,42 @@ export class CharacterBuilder
const choice = this._character.choices[feature.id]!;
choice.forEach(e => this.apply(feature.options[e]!));
return;
default:
return;
}
}
private undo(feature?: FeatureItem)
{
if(!feature)
return;
switch(feature.category)
{
case "feature":
this._result.features[feature.kind] = this._result.features[feature.kind]!.filter(e => e !== feature.text);
return;
case "list":
if(feature.action === 'remove' && !this._result[feature.list].includes(feature.item))
this._result[feature.list].push(feature.item);
else
this._result[feature.list] = this._result[feature.list].filter(e => e !== feature.item);
return;
case "value":
this._buffer[feature.property] ??= { list: [], value: 0, _dirty: true };
this._buffer[feature.property]!.list.splice(this._buffer[feature.property]!.list.findIndex(e => e.operation === feature.operation && e.value === feature.value), 1);
this._buffer[feature.property]!._dirty = true;
return;
case "choice":
const choice = this._character.choices[feature.id]!;
choice.forEach(e => this.undo(feature.options[e]!));
delete this._character.choices[feature.id];
return;
default:
return;
@ -439,7 +612,6 @@ class PeoplePicker implements BuilderTab
data-[disabled]:bg-light-20 dark:data-[disabled]:bg-dark-20 data-[disabled]:border-light-20 dark:data-[disabled]:border-dark-20 relative py-[2px]`, attributes: { "data-state": "unckecked" }, listeners: {
click: (e: Event) => {
this._builder.character.visibility = this._builder.character.visibility === "private" ? "public" : "private";
console.log(this._builder.character.visibility);
this._visibilityInput.setAttribute('data-state', this._builder.character.visibility === "private" ? "checked" : "unchecked");
}
}}, [ div('block w-[18px] h-[18px] translate-x-[2px] will-change-transform transition-transform bg-light-50 dark:bg-dark-50 group-data-[state=checked]:translate-x-[26px] group-data-[disabled]:bg-light-30 dark:group-data-[disabled]:bg-dark-30 group-data-[disabled]:border-light-30 dark:group-data-[disabled]:border-dark-30') ]);
@ -533,9 +705,10 @@ class LevelPicker implements BuilderTab
this._healthText = text("0"), this._manaText = text("0");
this._options = Object.entries(config.peoples[this._builder.character.people!]!.options).map(
(level) => [ div("w-full h-px", [div("border-t border-dashed border-light-50 dark:border-dark-50 w-full"), div("sticky top-0", [ text(level[0]) ])]),
div("flex flex-row gap-4 justify-center", level[1].map((option, j) => dom("div", { class: ["flex border border-light-50 dark:border-dark-50 px-4 py-2 w-[400px]", { 'hover:border-light-70 dark:hover:border-dark-70 cursor-pointer': (level[0] as any as Level) <= this._builder.character.level, '!border-accent-blue bg-accent-blue bg-opacity-20': this._builder.character.leveling?.some(e => e[0] == (level[0] as any as Level) && e[1] === j) ?? false }], listeners: { click: e => {
this._builder.toggleLevelOption(level[0] as any as Level, j);
(level) => [ div("w-full flex h-px", [div("border-t border-dashed border-light-50 dark:border-dark-50 w-full"), dom('span', { class: "relative left-4" }, [ text(level[0]) ])]),
div("flex flex-row gap-4 justify-center", level[1].map((option, j) => dom("div", { class: ["flex border border-light-50 dark:border-dark-50 px-4 py-2 w-[400px]", { 'hover:border-light-70 dark:hover:border-dark-70 cursor-pointer': (level[0] as any as Level) <= this._builder.character.level, '!border-accent-blue bg-accent-blue bg-opacity-20': this._builder.character.leveling[level[0] as any as Level] === j }], listeners: { click: e => {
this._builder.toggleLevelOption(parseInt(level[0]) as Level, j);
this.update();
}}}, [ dom('span', { class: "text-wrap whitespace-pre", text: option.description }) ])))
]);
@ -556,7 +729,7 @@ class LevelPicker implements BuilderTab
dom("span", { text: "Mana" }),
this._manaText,
]),
button(text('Suivant'), () => this._builder.display(1), 'h-[35px] px-[15px]'),
button(text('Suivant'), () => this._builder.display(2), 'h-[35px] px-[15px]'),
]), div('flex flex-col flex-1 gap-4 mx-8 my-4', this._options.flatMap(e => [...e]))];
}
update()
@ -564,7 +737,7 @@ class LevelPicker implements BuilderTab
const values = this._builder.values;
this._levelInput.value = this._builder.character.level.toString();
this._pointsInput.value = (this._builder.character.level - this._builder.character.leveling.length).toString();
this._pointsInput.value = (this._builder.character.level - Object.keys(this._builder.character.leveling).length).toString();
this._healthText.textContent = values.health?.toString() ?? '0';
this._manaText.textContent = values.mana?.toString() ?? '0';
@ -574,18 +747,358 @@ class LevelPicker implements BuilderTab
{
this._builder.updateLevel(this._builder.character.level as Level);
this._pointsInput.value = (this._builder.character.level - Object.keys(this._builder.character.leveling).length).toString();
this._options.forEach((e, i) => {
e[0]?.classList.toggle("opacity-30", ((i + 1) as Level) > this._builder.character.level);
e[1]?.classList.toggle("opacity-30", ((i + 1) as Level) > this._builder.character.level);
e[1]?.childNodes.forEach((option, j) => {
'hover:border-light-70 dark:hover:border-dark-70 cursor-pointer'.split(" ").forEach(_e => (option as HTMLDivElement).classList.toggle(_e, ((i + 1) as Level) <= this._builder.character.level));
'!border-accent-blue bg-accent-blue bg-opacity-20'.split(" ").forEach(_e => (option as HTMLDivElement).classList.toggle(_e, this._builder.character.leveling?.some(e => e[0] == ((i + 1) as Level) && e[1] === j) ?? false));
'!border-accent-blue bg-accent-blue bg-opacity-20'.split(" ").forEach(_e => (option as HTMLDivElement).classList.toggle(_e, this._builder.character.leveling[((i + 1) as Level)] === j));
})
});
}
validate(): boolean
{
return this._builder.character.people !== undefined;
return this._builder.character.level - Object.keys(this._builder.character.leveling).length >= 0;
}
get dom()
{
return this._content;
}
}
class TrainingPicker implements BuilderTab
{
private _builder: CharacterBuilder;
private _content: Array<Node | string>;
private _pointsInput: HTMLInputElement;
private _healthText: Text;
private _manaText: Text;
private _options: Record<MainStat, HTMLDivElement[][]>;
private _tab: number = 0;
private _statIndicator: HTMLSpanElement;
private _statContainer: HTMLDivElement;
constructor(builder: CharacterBuilder)
{
const statRenderBlock = (stat: MainStat) => {
return Object.entries(config.training[stat]).map(
(level) => [ div("w-full flex h-px", [div("border-t border-dashed border-light-50 dark:border-dark-50 w-full"), dom('span', { class: "relative" }, [ text(level[0]) ])]),
div("flex flex-row gap-4 justify-center", level[1].map((option, j) => dom("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"], listeners: { click: e => {
this._builder.toggleTrainingOption(stat, parseInt(level[0]) as TrainingLevel, j);
this.update();
}}}, [ markdownUtil(option.description.map(e => e.text).join('\n'), undefined, { tags: { a: fakeA } }) ])))
])
}
this._builder = builder;
this._pointsInput = dom("input", { class: `w-14 mx-4 text-light-70 dark:text-dark-70 tabular-nums bg-light-10 dark:bg-dark-10 appearance-none outline-none ps-3 pe-1 py-1 focus:shadow-raw transition-[box-shadow] border bg-light-20 bg-dark-20 border-light-20 dark:border-dark-20`, attributes: { type: "number", disabled: true }});
this._healthText = text("0"), this._manaText = text("0");
this._options = MAIN_STATS.reduce((p, v) => { p[v] = statRenderBlock(v); return p; }, {} as Record<MainStat, HTMLDivElement[][]>);
this._statIndicator = dom('span', { 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' });
this._statContainer = div('relative select-none transition-[left] flex flex-1 flex-row max-w-full', Object.values(this._options).map(e => div('flex flex-shrink-0 flex-col gap-4 relative w-full overflow-y-auto px-8', e.flatMap(_e => [..._e]))));
this._content = [ div("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('flex flex-shrink gap-3 items-center relative w-48 ms-12', [
...MAIN_STATS.map((e, i) => dom('span', { listeners: { click: () => this.switchTab(i) }, 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' })),
this._statIndicator,
]),
div('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', [
dom("label", { class: "flex justify-center items-center my-2" }, [
dom("span", { class: "md:text-base text-sm", text: "Points restantes" }),
this._pointsInput,
]),
div("flex justify-center items-center gap-2 my-2 md:text-base text-sm", [
dom("span", { text: "Vie" }),
this._healthText,
]),
div("flex justify-center items-center gap-2 my-2 md:text-base text-sm", [
dom("span", { text: "Mana" }),
this._manaText,
]),
button(text('Suivant'), () => this._builder.display(3), 'h-[35px] px-[15px]'),
]), dom('span')
]), div('flex flex-1 px-6 overflow-hidden max-w-full', [ this._statContainer ])];
this.switchTab(0);
}
switchTab(tab: number)
{
this._tab = tab;
this._statIndicator.setAttribute('data-text', mainStatTexts[MAIN_STATS[tab] as MainStat]);
this._statIndicator.style.left = `${tab * 1.5}em`;
this._statContainer.style.left = `-${tab * 100}%`;
}
update()
{
const values = this._builder.values;
const training = Object.values(this._builder.character.training).reduce((p, v) => p + Object.values(v).filter(e => e !== undefined).length, 0);
this._pointsInput.value = ((values.training ?? 0) - training).toString();
this._healthText.textContent = values.health?.toString() ?? '0';
this._manaText.textContent = values.mana?.toString() ?? '0';
Object.keys(this._options).forEach(stat => {
const max = Object.keys(this._builder.character.training[stat as MainStat]).length;
this._options[stat as MainStat].forEach((e, i) => {
e[0]?.classList.toggle("opacity-30", (i as TrainingLevel) > max);
e[1]?.classList.toggle("opacity-30", (i as TrainingLevel) > max);
e[1]?.childNodes.forEach((option, j) => {
'!border-accent-blue bg-accent-blue bg-opacity-20'.split(" ").forEach(_e => (option as HTMLDivElement).classList.toggle(_e, i == 0 || (this._builder.character.training[stat as MainStat][i as TrainingLevel] === j)));
})
})
});
}
validate(): boolean
{
const values = this._builder.values;
const training = Object.values(this._builder.character.training).reduce((p, v) => p + Object.values(v).filter(e => e !== undefined).length, 0);
return (values.training ?? 0) - training >= 0;
}
get dom()
{
return this._content;
}
}
class AbilityPicker implements BuilderTab
{
private _builder: CharacterBuilder;
private _content: Array<Node | string>;
private _pointsInput: HTMLInputElement;
private _options: HTMLDivElement[];
private _tooltips: Text[] = [];
private _maxs: HTMLElement[] = [];
constructor(builder: CharacterBuilder)
{
const numberInput = (value?: number, update?: (value: number) => number | undefined) => {
const input = dom("input", { class: `w-14 mx-4 caret-light-50 dark:caret-dark-50 text-light-100 dark:text-dark-100 placeholder:text-light-50 dark:placeholder:text-dark-50 bg-light-20 dark:bg-dark-20 appearance-none outline-none px-3 py-1 focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 dark:focus:shadow-dark-40 border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50 data-[disabled]:bg-light-20 dark:data-[disabled]:bg-dark-20 data-[disabled]:border-light-20 dark:data-[disabled]:border-dark-20`, listeners: {
input: (e: Event) => {
input.value = (update && update(parseInt(input.value))?.toString()) ?? input.value;
},
keydown: (e: KeyboardEvent) => {
let value = isNaN(parseInt(input.value)) ? '0' : input.value;
switch(e.key)
{
case "ArrowUp":
value = clamp(parseInt(value) + 1, 0, 99).toString();
break;
case "ArrowDown":
value = clamp(parseInt(value) - 1, 0, 99).toString();
break;
default:
break;
}
if(input.value !== value)
{
input.value = (update && update(parseInt(value))?.toString()) ?? value;
}
}
}});
input.value = value?.toString() ?? "0";
return input;
};
function pushAndReturn<T extends any>(arr: Array<T>, value: T): T
{
arr.push(value);
return value;
}
this._builder = builder;
this._pointsInput = dom("input", { class: `w-14 mx-4 text-light-70 dark:text-dark-70 tabular-nums bg-light-10 dark:bg-dark-10 appearance-none outline-none ps-3 pe-1 py-1 focus:shadow-raw transition-[box-shadow] border bg-light-20 bg-dark-20 border-light-20 dark:border-dark-20`, attributes: { type: "number", disabled: true }});
this._options = ABILITIES.map((e, i) => div('flex flex-col border border-light-50 dark:border-dark-50 p-4 gap-2 w-[200px] relative', [
div('flex justify-between', [ numberInput(this._builder.character.abilities[e], (value) => {
const values = this._builder.values;
const max = (values[`abilities/${e}/max`] ?? 0) + (values[`modifier/${config.abilities[e].max[0]}`] ?? 0) + (values[`modifier/${config.abilities[e].max[1]}`] ?? 0);
this._builder.character.abilities[e] = clamp(value, 0, max);
Object.assign((this._options[i]?.lastElementChild as HTMLSpanElement | undefined)?.style ?? {}, { width: `${(max === 0 ? 0 : (this._builder.character.abilities[e] ?? 0) / max) * 100}%` });
this._tooltips[i]!.textContent = `${mainStatTexts[config.abilities[e].max[0]]} (${values[`modifier/${config.abilities[e].max[0]}`] ?? 0}) + ${mainStatTexts[config.abilities[e].max[1]]} (${values[`modifier/${config.abilities[e].max[1]}`] ?? 0}) + ${values[`abilities/${e}/max`] ?? 0}`;
this._maxs[i]!.textContent = `/ ${max ?? 0}`;
const abilities = Object.values(this._builder.character.abilities).reduce((p, v) => p + v, 0);
this._pointsInput.value = ((values.ability ?? 0) - abilities).toString();
return this._builder.character.abilities[e];
}), popper(pushAndReturn(this._maxs, dom('span', { class: 'text-lg text-end cursor-pointer', text: '' })), {
arrow: true,
offset: 6,
placement: 'bottom-end',
class: 'max-w-96 fixed hidden 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',
content: [ pushAndReturn(this._tooltips, text('')) ]
})]),
dom('span', { class: "text-xl text-center font-bold", text: config.abilities[e].name }),
dom('span', { class: "absolute -bottom-px -left-px h-[3px] bg-accent-blue" }),
]));
this._content = [ div("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", [
dom("label", { class: "flex justify-center items-center my-2" }, [
dom("span", { class: "md:text-base text-sm", text: "Points restantes" }),
this._pointsInput,
]),
button(text('Suivant'), () => this._builder.display(4), 'h-[35px] px-[15px]'),
]), div('flex flex-row flex-wrap justify-center items-center flex-1 gap-12 mx-8 my-4 px-48', this._options)];
}
update()
{
const values = this._builder.values;
const abilities = Object.values(this._builder.character.abilities).reduce((p, v) => p + v, 0);
this._pointsInput.value = ((values.ability ?? 0) - abilities).toString();
ABILITIES.forEach((e, i) => {
const max = (values[`abilities/${e}/max`] ?? 0) + (values[`modifier/${config.abilities[e].max[0]}`] ?? 0) + (values[`modifier/${config.abilities[e].max[1]}`] ?? 0);
Object.assign((this._options[i]?.lastElementChild as HTMLSpanElement | undefined)?.style ?? {}, { width: `${(max === 0 ? 0 : (this._builder.character.abilities[e] ?? 0) / max) * 100}%` });
this._tooltips[i]!.textContent = `${mainStatTexts[config.abilities[e].max[0]]} (${values[`modifier/${config.abilities[e].max[0]}`] ?? 0}) + ${mainStatTexts[config.abilities[e].max[1]]} (${values[`modifier/${config.abilities[e].max[1]}`] ?? 0}) + ${values[`abilities/${e}/max`] ?? 0}`;
this._maxs[i]!.textContent = `/ ${max ?? 0}`;
return this._builder.character.abilities[e];
})
}
validate(): boolean
{
const values = this._builder.values;
const abilities = Object.values(this._builder.character.abilities).reduce((p, v) => p + v, 0);
return (values.ability ?? 0) - abilities >= 0;
}
get dom()
{
return this._content;
}
}
class AspectPicker implements BuilderTab
{
private _builder: CharacterBuilder;
private _content: Array<Node | string>;
private _physicInput: HTMLInputElement;
private _mentalInput: HTMLInputElement;
private _personalityInput: HTMLInputElement;
private _filter: boolean = true;
private _options: HTMLDivElement[];
constructor(builder: CharacterBuilder)
{
this._builder = builder;
this._physicInput = dom("input", { class: `w-14 mx-4 text-light-70 dark:text-dark-70 tabular-nums bg-light-10 dark:bg-dark-10 appearance-none outline-none ps-3 pe-1 py-1 focus:shadow-raw transition-[box-shadow] border bg-light-20 bg-dark-20 border-light-20 dark:border-dark-20`, attributes: { type: "number", disabled: true }});
this._mentalInput = dom("input", { class: `w-14 mx-4 text-light-70 dark:text-dark-70 tabular-nums bg-light-10 dark:bg-dark-10 appearance-none outline-none ps-3 pe-1 py-1 focus:shadow-raw transition-[box-shadow] border bg-light-20 bg-dark-20 border-light-20 dark:border-dark-20`, attributes: { type: "number", disabled: true }});
this._personalityInput = dom("input", { class: `w-14 mx-4 text-light-70 dark:text-dark-70 tabular-nums bg-light-10 dark:bg-dark-10 appearance-none outline-none ps-3 pe-1 py-1 focus:shadow-raw transition-[box-shadow] border bg-light-20 bg-dark-20 border-light-20 dark:border-dark-20`, attributes: { type: "number", disabled: true }});
this._options = config.aspects.map((e, i) => dom('div', { attributes: { "data-aspect": i.toString() }, listeners: { click: () => {
this._builder.character.aspect = i;
this._options.forEach(_e => _e.setAttribute('data-state', 'inactive'));
this._options[i]?.setAttribute('data-state', 'active');
}}, class: 'group flex flex-col w-[360px] border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50 cursor-pointer' }, [
div('bg-light-10 dark:bg-dark-10 border-b border-light-35 dark:border-dark-35 p-2 flex flex-col gap-2 group-data-[state=active]:bg-accent-blue group-data-[state=active]:bg-opacity-10', [
div('flex flex-row gap-8 ps-4 items-center', [
div("flex flex-1 flex-col gap-2 justify-center", [ div('text-lg font-bold', [ text(e.name) ]), dom('span', { class: 'border-b w-full border-light-50 dark:border-dark-50 group-data-[state=active]:border-b-[4px] group-data-[state=active]:border-accent-blue' }) ]),
div('rounded-full w-[96px] h-[96px] border border-light-50 dark:border-dark-50 bg-light-100 dark:bg-dark-100 !bg-opacity-10')
])
]),
div('flex justify-stretch items-stretch py-2 px-4 gap-4', [
div('flex flex-col flex-1 items-stretch gap-4', [
div('flex flex-1 justify-between', [ text('Difficulté'), div('text-sm font-bold', [ text(e.difficulty.toString()) ]) ]),
div('flex flex-1 justify-between', [ text('Bonus'), div('text-sm font-bold', [ text(e.stat === 'special' ? 'Special' : mainStatTexts[e.stat]) ]) ])
]),
div('w-px h-full bg-light-50 dark:bg-dark-50'),
div('flex flex-col items-center justify-between py-2', [
div('text-sm italic', [ text(alignmentToString(e.alignment)) ]),
div(['text-sm font-bold', { "text-light-purple dark:text-dark-purple italic": e.magic, "text-light-orange dark:text-dark-orange": !e.magic }], [ text(e.magic ? 'Magie autorisée' : 'Magie interdite') ]),
]),
])
]));
const filterSwitch = dom("div", { class: `group mx-3 w-12 h-6 select-none transition-all border border-light-35 dark:border-dark-35 bg-light-20 dark:bg-dark-20 outline-none data-[state=checked]:bg-light-35 dark:data-[state=checked]:bg-dark-35 hover:border-light-50 dark:hover:border-dark-50 focus:shadow-raw focus:shadow-light-40 dark:focus:shadow-dark-40 data-[disabled]:bg-light-20 dark:data-[disabled]:bg-dark-20 data-[disabled]:border-light-20 dark:data-[disabled]:border-dark-20 relative py-[2px]`, attributes: { "data-state": this._filter ? "ckecked" : "unchecked" }, listeners: {
click: (e: Event) => {
this._filter = !this._filter;
filterSwitch.setAttribute('data-state', this._filter ? "ckecked" : "unchecked");
this.update();
}
}}, [ div('block w-[18px] h-[18px] translate-x-[2px] will-change-transform transition-transform bg-light-50 dark:bg-dark-50 group-data-[state=checked]:translate-x-[26px] group-data-[disabled]:bg-light-30 dark:group-data-[disabled]:bg-dark-30 group-data-[disabled]:border-light-30 dark:group-data-[disabled]:border-dark-30') ]);
this._content = [ div("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", [
dom("label", { class: "flex justify-center items-center my-2" }, [
dom("span", { class: "md:text-base text-sm", text: "Physique" }),
this._physicInput,
]),
dom("label", { class: "flex justify-center items-center my-2" }, [
dom("span", { class: "md:text-base text-sm", text: "Mental" }),
this._mentalInput,
]),
dom("label", { class: "flex justify-center items-center my-2" }, [
dom("span", { class: "md:text-base text-sm", text: "Caractère" }),
this._personalityInput,
]),
dom("label", { class: "flex justify-center items-center my-2" }, [
dom("span", { class: "md:text-base text-sm", text: "Filtrer ?" }),
filterSwitch,
]),
button(text('Enregistrer'), () => this._builder.save(), 'h-[35px] px-[15px]'),
]), div('flex flex-row flex-wrap justify-center items-center flex-1 gap-8 mx-8 my-4 px-8', this._options)];
}
update()
{
const physic = Object.values(this._builder.character.training['strength']).length + Object.values(this._builder.character.training['dexterity']).length + Object.values(this._builder.character.training['constitution']).length;
const mental = Object.values(this._builder.character.training['intelligence']).length + Object.values(this._builder.character.training['curiosity']).length;
const personality = Object.values(this._builder.character.training['charisma']).length + Object.values(this._builder.character.training['psyche']).length;
this._physicInput.value = physic.toString();
this._mentalInput.value = mental.toString();
this._personalityInput.value = personality.toString();
(this._content[1] as HTMLElement).replaceChildren(...this._options.filter(e => {
const index = parseInt(e.getAttribute('data-aspect')!);
const aspect = config.aspects[index]!;
e.setAttribute('data-state', this._builder.character.aspect === index ? 'active' : 'inactive');
if(!this._filter)
return true;
if(physic > aspect.physic.max || physic < aspect.physic.min)
return false;
if(mental > aspect.mental.max || mental < aspect.mental.min)
return false;
if(personality > aspect.personality.max || personality < aspect.personality.min)
return false;
return true;
}));
}
validate(): boolean
{
const physic = Object.values(this._builder.character.training['strength']).length + Object.values(this._builder.character.training['dexterity']).length + Object.values(this._builder.character.training['constitution']).length;
const mental = Object.values(this._builder.character.training['intelligence']).length + Object.values(this._builder.character.training['curiosity']).length;
const personality = Object.values(this._builder.character.training['charisma']).length + Object.values(this._builder.character.training['psyche']).length;
if(this._builder.character.aspect === undefined)
return false;
const aspect = config.aspects[this._builder.character.aspect]!
if(physic > aspect.physic.max || physic < aspect.physic.min)
return false;
if(mental > aspect.mental.max || mental < aspect.mental.min)
return false;
if(personality > aspect.personality.max || personality < aspect.personality.min)
return false;
return true;
}
get dom()
{

View File

@ -46,7 +46,7 @@ export function popper(container: HTMLElement, properties?: PopperProperties): H
strategy: 'fixed',
middleware: [
properties?.offset ? FloatingUI.offset(properties?.offset) : undefined,
FloatingUI.autoPlacement({ rootBoundary: rect }),
FloatingUI.shift({ rootBoundary: rect }),
properties?.offset ? FloatingUI.shift({ padding: properties?.offset, rootBoundary: rect }) : undefined,
properties?.offset && properties?.arrow ? FloatingUI.arrow({ element: arrow, padding: 8 }) : undefined,
FloatingUI.hide({ rootBoundary: rect }),

26
types/character.d.ts vendored
View File

@ -9,6 +9,7 @@ export type Category = typeof CATEGORIES[number];
export type SpellElement = typeof SPELL_ELEMENTS[number];
export type DoubleIndex<T extends number | string> = [T, number];
export type Alignment = { loyalty: 'loyal' | 'neutral' | 'chaotic', kindness: 'good' | 'neutral' | 'evil' };
export type Character = {
id: number;
@ -21,9 +22,9 @@ export type Character = {
health: number;
mana: number;
training: Record<MainStat, DoubleIndex<TrainingLevel>[]>;
leveling: DoubleIndex<Level>[];
abilities: Partial<Record<Ability, [number, number]>>; //First is the ability, second is the max increment
training: Record<MainStat, Partial<Record<TrainingLevel, number>>>;
leveling: Partial<Record<Level, number>>;
abilities: Partial<Record<Ability, number>>; //First is the ability, second is the max increment
spells: string[]; //Spell ID
modifiers: Partial<Record<MainStat, number>>;
@ -38,10 +39,11 @@ export type CharacterValues = {
mana: number;
};
export type CharacterConfig = {
peoples: Race[],
peoples: RaceConfig[],
training: Record<MainStat, Record<TrainingLevel, TrainingOption[]>>;
abilities: Record<Ability, AbilityConfig>;
spells: SpellConfig[];
aspects: AspectConfig[];
};
export type SpellConfig = {
id: string;
@ -59,11 +61,23 @@ export type AbilityConfig = {
name: string;
description: string;
};
export type Race = {
export type RaceConfig = {
name: string;
description: string;
options: Record<Level, Feature[]>;
};
export type AspectConfig = {
name: string;
description: string;
stat: MainStat | 'special';
alignment: Alignment;
magic: boolean;
difficulty: number;
physic: { min: number, max: number };
mental: { min: number, max: number };
personality: { min: number, max: number };
options: FeatureEffect[];
};
export type FeatureEffect = {
category: "value";
@ -169,7 +183,7 @@ export type CompiledCharacter = {
modifier: Record<MainStat, number>;
abilities: Partial<Record<Ability, number>>;
level: number;
features: { [K in Extract<FeatureEffect, { category: "feature" }>["kind"]]: string[] };
features: { [K in Extract<FeatureEffect, { category: "feature" }>["kind"]]?: string[] };
notes: string;
};