Refonte du système JDR + système de personnage joueurs / non joueurs :
Some checks failed
E2E Tests / e2e (push) Failing after 21s
Some checks failed
E2E Tests / e2e (push) Failing after 21s
- Système de templating dans le game system : en effet, les templates sont liés au game system car les fiches personnages ne sont pas forcément les même selon les jeux (perso Dnd possède + de compétences que Nimble par exemple) - changement des fiches personnages pour adapter le templating au niveau des campagnes et remplir des pages de perso
This commit is contained in:
@@ -205,19 +205,23 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait une ligne de résumé depuis le markdown (1re ligne non-vide, non-titre).
|
||||
* Générique : utilisé pour les fiches PJ comme PNJ (mêmes besoins d'aperçu carte).
|
||||
* Extrait une ligne de resume pour la fiche PJ/PNJ — 1re valeur de template
|
||||
* non-vide (apres refonte 2026-04-30, remplace l'ancien parsing markdown).
|
||||
*/
|
||||
personaSnippet(p: { markdownContent?: string | null }): string {
|
||||
if (!p.markdownContent) return '(Fiche vide)';
|
||||
const firstMeaningful = p.markdownContent
|
||||
.split('\n')
|
||||
.map(l => l.trim())
|
||||
.find(l => l && !l.startsWith('#'));
|
||||
if (!firstMeaningful) return '(Fiche vide)';
|
||||
return firstMeaningful.length > 80
|
||||
? firstMeaningful.substring(0, 77) + '…'
|
||||
: firstMeaningful;
|
||||
personaSnippet(p: { values?: Record<string, string> }): string {
|
||||
const values = p.values ?? {};
|
||||
for (const v of Object.values(values)) {
|
||||
if (!v) continue;
|
||||
const firstMeaningful = v
|
||||
.split('\n')
|
||||
.map(l => l.trim())
|
||||
.find(l => l && !l.startsWith('#'));
|
||||
if (!firstMeaningful) continue;
|
||||
return firstMeaningful.length > 80
|
||||
? firstMeaningful.substring(0, 77) + '…'
|
||||
: firstMeaningful;
|
||||
}
|
||||
return '(Fiche vide)';
|
||||
}
|
||||
|
||||
/** Alias gardé pour compatibilité avec les anciens templates. */
|
||||
|
||||
@@ -35,18 +35,38 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field content-field">
|
||||
<label>Fiche (markdown)</label>
|
||||
<p class="hint">
|
||||
Tout en markdown libre : stats, classe, backstory, équipement, objectifs personnels…
|
||||
L'IA lira ces infos pour rester cohérente quand vous générez des scènes impliquant ce PJ.
|
||||
</p>
|
||||
<textarea
|
||||
[(ngModel)]="markdownContent"
|
||||
name="markdownContent"
|
||||
rows="22"
|
||||
placeholder="# Thorin Grand-Hache **Race :** Nain **Classe :** Guerrier niveau 4 **PV :** 35 / 35 ## Stats - Force : 16 - Dextérité : 12 ... ## Backstory Originaire des montagnes du Nord, Thorin a fui son clan après..."
|
||||
></textarea>
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label>Portrait (ID image)</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="portraitImageId"
|
||||
name="portraitImageId"
|
||||
placeholder="ID de l'image portrait"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Bandeau / Header (ID image)</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="headerImageId"
|
||||
name="headerImageId"
|
||||
placeholder="ID de l'image bandeau"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint">
|
||||
Les portraits et bandeaux acceptent un ID d'image (MVP). Picker visuel a venir.
|
||||
</p>
|
||||
|
||||
<div class="template-fields">
|
||||
<app-dynamic-fields-form
|
||||
[fields]="templateFields"
|
||||
[values]="values"
|
||||
[imageValues]="imageValues"
|
||||
(valuesChange)="values = $event"
|
||||
(imageValuesChange)="imageValues = $event">
|
||||
</app-dynamic-fields-form>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
|
||||
@@ -4,22 +4,27 @@ import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { LucideAngularModule, Save, ArrowLeft, User, Trash2, Sparkles } from 'lucide-angular';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
import { Character } from '../../../services/character.model';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { GameSystemService } from '../../../services/game-system.service';
|
||||
import { TemplateField } from '../../../services/template-field.model';
|
||||
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||
import { DynamicFieldsFormComponent } from '../../../shared/dynamic-fields-form/dynamic-fields-form.component';
|
||||
|
||||
/**
|
||||
* Éditeur plein écran d'une fiche de personnage (PJ).
|
||||
* Double rôle création/édition :
|
||||
* - `/campaigns/:campaignId/characters/create` → POST
|
||||
* - `/campaigns/:campaignId/characters/:characterId/edit` → PUT
|
||||
* Editeur plein ecran d'une fiche de personnage (PJ).
|
||||
* Refonte 2026-04-30 : remplace le markdown libre par un formulaire dynamique
|
||||
* pilote par le characterTemplate du GameSystem associe a la campagne.
|
||||
*
|
||||
* MVP : name + markdown libre. Évolution prévue vers un template dérivé
|
||||
* du GameSystem de la campagne (stats structurées).
|
||||
* Comportements :
|
||||
* - Si la campagne n'a pas de GameSystem ou si son template est vide, affiche
|
||||
* uniquement les champs universels (nom, portrait, header).
|
||||
* - Le picker d'images dedie portrait/header est hors scope MVP — pour l'instant
|
||||
* saisie manuelle d'IDs d'images.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-character-edit',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, LucideAngularModule, AiChatDrawerComponent],
|
||||
imports: [CommonModule, FormsModule, LucideAngularModule, AiChatDrawerComponent, DynamicFieldsFormComponent],
|
||||
templateUrl: './character-edit.component.html',
|
||||
styleUrls: ['./character-edit.component.scss']
|
||||
})
|
||||
@@ -30,12 +35,11 @@ export class CharacterEditComponent implements OnInit {
|
||||
readonly Trash2 = Trash2;
|
||||
readonly Sparkles = Sparkles;
|
||||
|
||||
/** État drawer chat IA focalisé sur ce PJ. */
|
||||
chatOpen = false;
|
||||
readonly chatQuickSuggestions = [
|
||||
'Propose une backstory cohérente avec l\'univers',
|
||||
'Suggère 3 objectifs personnels pour ce personnage',
|
||||
'Aide-moi à équilibrer les stats de combat'
|
||||
'Propose une backstory coherente avec l\'univers',
|
||||
'Suggere 3 objectifs personnels pour ce personnage',
|
||||
'Aide-moi a equilibrer les stats de combat'
|
||||
];
|
||||
|
||||
toggleChat(): void { this.chatOpen = !this.chatOpen; }
|
||||
@@ -44,13 +48,19 @@ export class CharacterEditComponent implements OnInit {
|
||||
characterId: string | null = null;
|
||||
|
||||
name = '';
|
||||
markdownContent = '';
|
||||
portraitImageId = '';
|
||||
headerImageId = '';
|
||||
values: Record<string, string> = {};
|
||||
imageValues: Record<string, string[]> = {};
|
||||
templateFields: TemplateField[] = [];
|
||||
private order = 0;
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private service: CharacterService
|
||||
private service: CharacterService,
|
||||
private campaignService: CampaignService,
|
||||
private gameSystemService: GameSystemService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -58,11 +68,18 @@ export class CharacterEditComponent implements OnInit {
|
||||
this.campaignId = params.get('campaignId');
|
||||
this.characterId = params.get('characterId');
|
||||
|
||||
if (this.campaignId) {
|
||||
this.loadTemplateForCampaign(this.campaignId);
|
||||
}
|
||||
|
||||
if (this.characterId) {
|
||||
this.service.getById(this.characterId).subscribe({
|
||||
next: (c) => {
|
||||
this.name = c.name;
|
||||
this.markdownContent = c.markdownContent ?? '';
|
||||
this.portraitImageId = c.portraitImageId ?? '';
|
||||
this.headerImageId = c.headerImageId ?? '';
|
||||
this.values = c.values ?? {};
|
||||
this.imageValues = c.imageValues ?? {};
|
||||
this.order = c.order ?? 0;
|
||||
},
|
||||
error: () => this.back()
|
||||
@@ -70,21 +87,35 @@ export class CharacterEditComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
private loadTemplateForCampaign(campaignId: string): void {
|
||||
this.campaignService.getCampaignById(campaignId).subscribe({
|
||||
next: (campaign) => {
|
||||
if (!campaign.gameSystemId) {
|
||||
this.templateFields = [];
|
||||
return;
|
||||
}
|
||||
this.gameSystemService.getById(campaign.gameSystemId).subscribe({
|
||||
next: (gs) => { this.templateFields = gs.characterTemplate ?? []; },
|
||||
error: () => { this.templateFields = []; }
|
||||
});
|
||||
},
|
||||
error: () => { this.templateFields = []; }
|
||||
});
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (!this.name.trim() || !this.campaignId) return;
|
||||
const payload = {
|
||||
name: this.name.trim(),
|
||||
portraitImageId: this.portraitImageId.trim() || null,
|
||||
headerImageId: this.headerImageId.trim() || null,
|
||||
values: this.values,
|
||||
imageValues: this.imageValues,
|
||||
campaignId: this.campaignId
|
||||
};
|
||||
const req = this.characterId
|
||||
? this.service.update(this.characterId, {
|
||||
id: this.characterId,
|
||||
name: this.name.trim(),
|
||||
markdownContent: this.markdownContent || null,
|
||||
campaignId: this.campaignId,
|
||||
order: this.order
|
||||
})
|
||||
: this.service.create({
|
||||
name: this.name.trim(),
|
||||
markdownContent: this.markdownContent || null,
|
||||
campaignId: this.campaignId
|
||||
});
|
||||
? this.service.update(this.characterId, { ...payload, id: this.characterId, order: this.order })
|
||||
: this.service.create(payload);
|
||||
req.subscribe({
|
||||
next: () => this.back(),
|
||||
error: () => console.error('Erreur sauvegarde Character')
|
||||
@@ -93,7 +124,7 @@ export class CharacterEditComponent implements OnInit {
|
||||
|
||||
deleteCharacter(): void {
|
||||
if (!this.characterId) return;
|
||||
if (!confirm(`Supprimer la fiche de "${this.name}" ? Cette action est irréversible.`)) return;
|
||||
if (!confirm(`Supprimer la fiche de "${this.name}" ? Cette action est irreversible.`)) return;
|
||||
this.service.delete(this.characterId).subscribe({
|
||||
next: () => this.back(),
|
||||
error: () => console.error('Erreur suppression Character')
|
||||
|
||||
@@ -35,18 +35,28 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field content-field">
|
||||
<label>Fiche (markdown)</label>
|
||||
<p class="hint">
|
||||
Tout en markdown libre : apparence, motivations, faction, secrets, stats, notes MJ…
|
||||
À terme, l'IA pourra exploiter ces infos pour incarner le PNJ avec cohérence dans les scènes.
|
||||
</p>
|
||||
<textarea
|
||||
[(ngModel)]="markdownContent"
|
||||
name="markdownContent"
|
||||
rows="22"
|
||||
placeholder="# Borin le forgeron **Race :** Nain **Faction :** Clan Feuillefer **Statut :** Vivant ## Apparence Barbe rousse tressée, tablier de cuir brûlé... ## Motivations Venger son clan décimé par les orcs il y a 10 hivers. ## Notes MJ (secret) Connaît l'emplacement du marteau de Durin..."
|
||||
></textarea>
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label>Portrait (ID image)</label>
|
||||
<input type="text" [(ngModel)]="portraitImageId" name="portraitImageId" placeholder="ID de l'image portrait" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Bandeau / Header (ID image)</label>
|
||||
<input type="text" [(ngModel)]="headerImageId" name="headerImageId" placeholder="ID de l'image bandeau" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint">
|
||||
Les portraits et bandeaux acceptent un ID d'image (MVP). Picker visuel a venir.
|
||||
</p>
|
||||
|
||||
<div class="template-fields">
|
||||
<app-dynamic-fields-form
|
||||
[fields]="templateFields"
|
||||
[values]="values"
|
||||
[imageValues]="imageValues"
|
||||
(valuesChange)="values = $event"
|
||||
(imageValuesChange)="imageValues = $event">
|
||||
</app-dynamic-fields-form>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
|
||||
@@ -4,21 +4,22 @@ import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { LucideAngularModule, Save, ArrowLeft, Drama, Trash2, Sparkles } from 'lucide-angular';
|
||||
import { NpcService } from '../../../services/npc.service';
|
||||
import { CampaignService } from '../../../services/campaign.service';
|
||||
import { GameSystemService } from '../../../services/game-system.service';
|
||||
import { TemplateField } from '../../../services/template-field.model';
|
||||
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||
import { DynamicFieldsFormComponent } from '../../../shared/dynamic-fields-form/dynamic-fields-form.component';
|
||||
|
||||
/**
|
||||
* Éditeur plein écran d'une fiche de PNJ.
|
||||
* Double rôle création/édition :
|
||||
* - `/campaigns/:campaignId/npcs/create` → POST
|
||||
* - `/campaigns/:campaignId/npcs/:npcId/edit` → PUT
|
||||
*
|
||||
* MVP : name + markdown libre. L'Assistant IA est branché en mode édition
|
||||
* (focus entityType="npc") pour proposer apparence, motivations, secrets...
|
||||
* Editeur plein ecran d'une fiche de PNJ.
|
||||
* Refonte 2026-04-30 : meme principe que CharacterEditComponent — markdown
|
||||
* libre remplace par un formulaire dynamique pilote par le npcTemplate du
|
||||
* GameSystem associe a la campagne.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-npc-edit',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, LucideAngularModule, AiChatDrawerComponent],
|
||||
imports: [CommonModule, FormsModule, LucideAngularModule, AiChatDrawerComponent, DynamicFieldsFormComponent],
|
||||
templateUrl: './npc-edit.component.html',
|
||||
styleUrls: ['./npc-edit.component.scss']
|
||||
})
|
||||
@@ -29,12 +30,11 @@ export class NpcEditComponent implements OnInit {
|
||||
readonly Trash2 = Trash2;
|
||||
readonly Sparkles = Sparkles;
|
||||
|
||||
/** État drawer chat IA focalisé sur ce PNJ. */
|
||||
chatOpen = false;
|
||||
readonly chatQuickSuggestions = [
|
||||
'Propose une apparence et une posture marquantes',
|
||||
'Suggère 2 motivations et un secret pour ce PNJ',
|
||||
'Imagine 3 répliques signatures qui le caractérisent'
|
||||
'Suggere 2 motivations et un secret pour ce PNJ',
|
||||
'Imagine 3 repliques signatures qui le caracterisent'
|
||||
];
|
||||
|
||||
toggleChat(): void { this.chatOpen = !this.chatOpen; }
|
||||
@@ -43,13 +43,19 @@ export class NpcEditComponent implements OnInit {
|
||||
npcId: string | null = null;
|
||||
|
||||
name = '';
|
||||
markdownContent = '';
|
||||
portraitImageId = '';
|
||||
headerImageId = '';
|
||||
values: Record<string, string> = {};
|
||||
imageValues: Record<string, string[]> = {};
|
||||
templateFields: TemplateField[] = [];
|
||||
private order = 0;
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private service: NpcService
|
||||
private service: NpcService,
|
||||
private campaignService: CampaignService,
|
||||
private gameSystemService: GameSystemService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -57,11 +63,18 @@ export class NpcEditComponent implements OnInit {
|
||||
this.campaignId = params.get('campaignId');
|
||||
this.npcId = params.get('npcId');
|
||||
|
||||
if (this.campaignId) {
|
||||
this.loadTemplateForCampaign(this.campaignId);
|
||||
}
|
||||
|
||||
if (this.npcId) {
|
||||
this.service.getById(this.npcId).subscribe({
|
||||
next: (n) => {
|
||||
this.name = n.name;
|
||||
this.markdownContent = n.markdownContent ?? '';
|
||||
this.portraitImageId = n.portraitImageId ?? '';
|
||||
this.headerImageId = n.headerImageId ?? '';
|
||||
this.values = n.values ?? {};
|
||||
this.imageValues = n.imageValues ?? {};
|
||||
this.order = n.order ?? 0;
|
||||
},
|
||||
error: () => this.back()
|
||||
@@ -69,21 +82,35 @@ export class NpcEditComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
private loadTemplateForCampaign(campaignId: string): void {
|
||||
this.campaignService.getCampaignById(campaignId).subscribe({
|
||||
next: (campaign) => {
|
||||
if (!campaign.gameSystemId) {
|
||||
this.templateFields = [];
|
||||
return;
|
||||
}
|
||||
this.gameSystemService.getById(campaign.gameSystemId).subscribe({
|
||||
next: (gs) => { this.templateFields = gs.npcTemplate ?? []; },
|
||||
error: () => { this.templateFields = []; }
|
||||
});
|
||||
},
|
||||
error: () => { this.templateFields = []; }
|
||||
});
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (!this.name.trim() || !this.campaignId) return;
|
||||
const payload = {
|
||||
name: this.name.trim(),
|
||||
portraitImageId: this.portraitImageId.trim() || null,
|
||||
headerImageId: this.headerImageId.trim() || null,
|
||||
values: this.values,
|
||||
imageValues: this.imageValues,
|
||||
campaignId: this.campaignId
|
||||
};
|
||||
const req = this.npcId
|
||||
? this.service.update(this.npcId, {
|
||||
id: this.npcId,
|
||||
name: this.name.trim(),
|
||||
markdownContent: this.markdownContent || null,
|
||||
campaignId: this.campaignId,
|
||||
order: this.order
|
||||
})
|
||||
: this.service.create({
|
||||
name: this.name.trim(),
|
||||
markdownContent: this.markdownContent || null,
|
||||
campaignId: this.campaignId
|
||||
});
|
||||
? this.service.update(this.npcId, { ...payload, id: this.npcId, order: this.order })
|
||||
: this.service.create(payload);
|
||||
req.subscribe({
|
||||
next: () => this.back(),
|
||||
error: () => console.error('Erreur sauvegarde Npc')
|
||||
@@ -92,7 +119,7 @@ export class NpcEditComponent implements OnInit {
|
||||
|
||||
deleteNpc(): void {
|
||||
if (!this.npcId) return;
|
||||
if (!confirm(`Supprimer la fiche de "${this.name}" ? Cette action est irréversible.`)) return;
|
||||
if (!confirm(`Supprimer la fiche de "${this.name}" ? Cette action est irreversible.`)) return;
|
||||
this.service.delete(this.npcId).subscribe({
|
||||
next: () => this.back(),
|
||||
error: () => console.error('Erreur suppression Npc')
|
||||
|
||||
Reference in New Issue
Block a user