Ajout de la partie "Système de jeu" avec toute la partie stockage de règles de notre jeu.

Ajout de possibilité de stocker des fiches de personnages associés à une campagne également (personnages joueurs pour le moment)
This commit is contained in:
2026-04-22 11:58:50 +02:00
parent bf38b6695f
commit 8f4dd3e9d6
63 changed files with 2840 additions and 36 deletions

View File

@@ -0,0 +1,103 @@
<div class="gse-page">
<div class="gse-header">
<button class="btn-back" (click)="back()">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
Retour à la liste
</button>
<h1>
<lucide-icon [img]="Dices" [size]="22"></lucide-icon>
{{ id ? 'Éditer le système' : 'Nouveau système de JDR' }}
</h1>
</div>
<div class="gse-form">
<div class="field">
<label>Nom *</label>
<input type="text" [(ngModel)]="name" name="name" placeholder="Ex: Nimble, D&D 5.1 SRD, Mon Homebrew..." />
</div>
<div class="field">
<label>Description courte</label>
<textarea [(ngModel)]="description" name="description" rows="2" placeholder="En une ligne, de quoi parle ce système ?"></textarea>
</div>
<div class="field">
<label>Auteur</label>
<input type="text" [(ngModel)]="author" name="author" placeholder="Ex: Hasbro, Homebrew, moi-même..." />
</div>
<!-- Sections de règles -->
<div class="sections-area">
<h2 class="sections-title">Règles du système</h2>
<p class="sections-hint">
Une section = un thème. L'IA injectera automatiquement les sections pertinentes
selon ce qu'elle génère (combat → Combat/Monstres, PNJ → Classes, arc → Lore/Factions).
</p>
<div class="section-list">
<div class="section-card" *ngFor="let section of sections; let i = index" [class.collapsed]="section.collapsed">
<div class="section-head">
<button type="button" class="btn-collapse" (click)="toggleCollapse(section)">
<lucide-icon [img]="section.collapsed ? ChevronRight : ChevronDown" [size]="16"></lucide-icon>
</button>
<input
type="text"
class="section-title-input"
[(ngModel)]="section.title"
[name]="'title-' + i"
placeholder="Nom de la section (ex: Combat)"
/>
<button type="button" class="btn-remove" (click)="removeSection(i)" title="Supprimer cette section">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</button>
</div>
<textarea
*ngIf="!section.collapsed"
class="section-content"
[(ngModel)]="section.content"
[name]="'content-' + i"
rows="6"
placeholder="Décrivez les règles de cette section..."
></textarea>
</div>
<div *ngIf="sections.length === 0" class="empty-hint">
Aucune section pour l'instant — ajoutez-en une avec les boutons ci-dessous.
</div>
</div>
<div class="add-row">
<span class="add-label">Ajouter une section :</span>
<button
type="button"
*ngFor="let name of suggestedSections"
class="chip"
[class.disabled]="isSectionUsed(name)"
(click)="addSuggested(name)"
[disabled]="isSectionUsed(name)">
<lucide-icon [img]="Plus" [size]="12"></lucide-icon>
{{ name }}
</button>
<button type="button" class="chip chip-custom" (click)="addBlank()">
<lucide-icon [img]="Plus" [size]="12"></lucide-icon>
Autre…
</button>
</div>
</div>
<div class="actions">
<button type="button" class="btn-primary" [disabled]="!name.trim()" (click)="submit()">
<lucide-icon [img]="Save" [size]="16"></lucide-icon>
{{ id ? 'Enregistrer' : 'Créer' }}
</button>
<button type="button" class="btn-secondary" (click)="back()">Annuler</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,256 @@
.gse-page {
padding: 2rem 3rem;
color: #e5e7eb;
max-width: 1000px;
margin: 0 auto;
}
.gse-header {
margin-bottom: 2rem;
h1 {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.75rem;
color: white;
margin: 0.75rem 0 0;
}
}
.btn-back {
background: transparent;
border: none;
color: #9ca3af;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0;
font-size: 0.85rem;
&:hover { color: #e5e7eb; }
}
.gse-form {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.field {
display: flex;
flex-direction: column;
label {
color: #e5e7eb;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.4rem;
}
input[type="text"], textarea {
background: #0b1220;
border: 1px solid #1f2937;
border-radius: 8px;
color: #e5e7eb;
padding: 0.6rem 0.75rem;
font-size: 0.95rem;
font-family: inherit;
&:focus {
outline: none;
border-color: #a78bfa;
}
}
textarea { resize: vertical; }
}
/* ─── Sections ──────────────────────────────────────────────── */
.sections-area {
border-top: 1px solid #1f2937;
padding-top: 1.5rem;
margin-top: 0.5rem;
}
.sections-title {
font-size: 1rem;
font-weight: 600;
color: white;
margin: 0 0 0.25rem;
}
.sections-hint {
color: #6b7280;
font-size: 0.85rem;
margin: 0 0 1rem;
}
.section-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1.25rem;
}
.section-card {
background: #0b1220;
border: 1px solid #1f2937;
border-radius: 10px;
overflow: hidden;
transition: border-color 0.15s;
&:focus-within { border-color: #374151; }
&.collapsed { background: #0a0f1a; }
}
.section-head {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.5rem 0.5rem 0.25rem;
}
.btn-collapse {
background: transparent;
border: none;
color: #6b7280;
cursor: pointer;
padding: 4px;
display: flex;
border-radius: 4px;
&:hover { color: #e5e7eb; }
}
.section-title-input {
flex: 1;
background: transparent;
border: none;
color: white;
font-size: 1rem;
font-weight: 500;
padding: 0.3rem 0.25rem;
&:focus { outline: none; }
&::placeholder { color: #4b5563; font-weight: 400; font-style: italic; }
}
.btn-remove {
background: transparent;
border: none;
color: #6b7280;
cursor: pointer;
padding: 4px 6px;
border-radius: 4px;
display: flex;
&:hover { color: #f87171; background: rgba(248, 113, 113, 0.08); }
}
.section-content {
width: 100%;
background: transparent;
border: none;
border-top: 1px solid #1f2937;
color: #d1d5db;
padding: 0.75rem 1rem;
font-size: 0.9rem;
font-family: inherit;
line-height: 1.5;
resize: vertical;
box-sizing: border-box;
&:focus { outline: none; }
}
.empty-hint {
color: #6b7280;
font-size: 0.85rem;
font-style: italic;
text-align: center;
padding: 1.5rem;
border: 1px dashed #1f2937;
border-radius: 10px;
}
/* ─── Chips d'ajout ─────────────────────────────────────────── */
.add-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem;
}
.add-label {
color: #9ca3af;
font-size: 0.85rem;
margin-right: 0.25rem;
}
.chip {
background: #111827;
border: 1px solid #1f2937;
color: #d1d5db;
padding: 0.3rem 0.7rem;
border-radius: 999px;
cursor: pointer;
font-size: 0.8rem;
display: inline-flex;
align-items: center;
gap: 0.3rem;
transition: all 0.15s;
&:hover:not(:disabled) {
border-color: #a78bfa;
color: white;
}
&.disabled, &:disabled {
opacity: 0.35;
cursor: not-allowed;
}
&.chip-custom {
border-style: dashed;
color: #a78bfa;
}
}
/* ─── Actions ──────────────────────────────────────────────── */
.actions {
display: flex;
gap: 0.75rem;
margin-top: 0.5rem;
}
.btn-primary {
background: #a78bfa;
color: #0b1220;
border: none;
padding: 0.6rem 1.25rem;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.4rem;
&:disabled { opacity: 0.5; cursor: not-allowed; }
&:hover:not(:disabled) { background: #c4b5fd; }
}
.btn-secondary {
background: transparent;
border: 1px solid #1f2937;
color: #9ca3af;
padding: 0.6rem 1.25rem;
border-radius: 8px;
cursor: pointer;
&:hover { border-color: #374151; color: #e5e7eb; }
}

View File

@@ -0,0 +1,170 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { LucideAngularModule, Save, ArrowLeft, Dices, Plus, Trash2, ChevronDown, ChevronRight } from 'lucide-angular';
import { GameSystemService } from '../../services/game-system.service';
/**
* Éditeur plein écran d'un GameSystem. Rôle double création/édition :
* - `/game-systems/create` → POST au submit
* - `/game-systems/:id/edit` → PUT au submit
*
* UI par sections : au lieu d'un gros textarea de markdown, on présente
* une liste de cartes repliables (une par section H2). Au load on parse
* le markdown existant, au submit on le reconstruit. Les noms suggérés
* (Combat, Classes…) guident sans imposer — c'est le même mapping que
* le backend utilise pour filtrer les sections à injecter dans l'IA.
*/
interface RuleSection {
title: string;
content: string;
collapsed: boolean;
}
/**
* Sections suggérées : mécaniques de jeu uniquement. Lore/factions/PNJ
* appartiennent au module Lore de LoreMind (décrit l'univers de l'utilisateur),
* pas au GameSystem (décrit les règles). Séparation volontaire.
*/
const SUGGESTED_SECTIONS = [
'Combat', 'Classes', 'Stats', 'Magie', 'Monstres', 'Progression'
];
@Component({
selector: 'app-game-system-edit',
standalone: true,
imports: [CommonModule, FormsModule, LucideAngularModule],
templateUrl: './game-system-edit.component.html',
styleUrls: ['./game-system-edit.component.scss']
})
export class GameSystemEditComponent implements OnInit {
readonly Save = Save;
readonly ArrowLeft = ArrowLeft;
readonly Dices = Dices;
readonly Plus = Plus;
readonly Trash2 = Trash2;
readonly ChevronDown = ChevronDown;
readonly ChevronRight = ChevronRight;
id: string | null = null;
name = '';
description = '';
author = '';
sections: RuleSection[] = [];
readonly suggestedSections = SUGGESTED_SECTIONS;
constructor(
private route: ActivatedRoute,
private router: Router,
private service: GameSystemService
) {}
ngOnInit(): void {
this.id = this.route.snapshot.paramMap.get('id');
if (this.id) {
this.service.getById(this.id).subscribe({
next: (gs) => {
this.name = gs.name;
this.description = gs.description ?? '';
this.author = gs.author ?? '';
this.sections = this.parseMarkdown(gs.rulesMarkdown ?? '');
},
error: () => this.back()
});
}
}
/** Noms de section déjà utilisés — pour désactiver les suggestions en doublon. */
isSectionUsed(title: string): boolean {
const lower = title.toLowerCase();
return this.sections.some(s => s.title.toLowerCase() === lower);
}
/** Ajoute une section avec un nom suggéré. */
addSuggested(title: string): void {
if (this.isSectionUsed(title)) return;
this.sections.push({ title, content: '', collapsed: false });
}
/** Ajoute une section vierge (titre à remplir). */
addBlank(): void {
this.sections.push({ title: '', content: '', collapsed: false });
}
removeSection(index: number): void {
this.sections.splice(index, 1);
}
toggleCollapse(section: RuleSection): void {
section.collapsed = !section.collapsed;
}
submit(): void {
if (!this.name.trim()) return;
const payload = {
name: this.name.trim(),
description: this.description.trim() || null,
author: this.author.trim() || null,
rulesMarkdown: this.serializeMarkdown(),
isPublic: false
};
const req = this.id
? this.service.update(this.id, payload)
: this.service.create(payload);
req.subscribe({
next: () => this.back(),
error: () => console.error('Erreur sauvegarde GameSystem')
});
}
back(): void {
this.router.navigate(['/game-systems']);
}
// --- Parse / Serialize markdown ------------------------------------------
/**
* Découpe un markdown par titres H2 en sections. Doit rester cohérent avec
* le parser Java côté backend (regex `^##\s+…$`). Le texte avant le premier
* H2 est volontairement perdu — le backend l'ignore aussi pour l'injection IA.
*/
private parseMarkdown(markdown: string): RuleSection[] {
if (!markdown) return [];
const sections: RuleSection[] = [];
const lines = markdown.split('\n');
let current: RuleSection | null = null;
const buffer: string[] = [];
const flush = () => {
if (current) {
current.content = buffer.join('\n').trim();
sections.push(current);
}
buffer.length = 0;
};
for (const line of lines) {
const match = line.match(/^##\s+(.+?)\s*$/);
if (match) {
flush();
current = { title: match[1].trim(), content: '', collapsed: false };
} else if (current) {
buffer.push(line);
}
}
flush();
return sections;
}
/** Reconstruit le markdown à partir des sections (ignore les sections vides). */
private serializeMarkdown(): string | null {
const valid = this.sections.filter(s => s.title.trim() || s.content.trim());
if (valid.length === 0) return null;
return valid
.map(s => `## ${s.title.trim() || 'Sans titre'}\n${s.content.trim()}`)
.join('\n\n');
}
}

View File

@@ -0,0 +1,39 @@
<div class="gs-page">
<div class="gs-hero">
<lucide-icon [img]="Dices" [size]="56" class="hero-icon"></lucide-icon>
<h1>Systèmes de JDR</h1>
<p class="hero-subtitle">Les règles que l'IA connaîtra pour vos campagnes</p>
</div>
<div class="gs-grid">
<div class="gs-card" *ngFor="let gs of gameSystems" (click)="edit(gs.id!)">
<div class="card-header">
<h2>{{ gs.name }}</h2>
<div class="card-actions">
<button class="icon-btn" (click)="delete(gs, $event)" title="Supprimer">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</button>
</div>
</div>
<p class="card-description">{{ gs.description || '(Pas de description)' }}</p>
<div class="card-footer">
<span *ngIf="gs.author" class="author">par {{ gs.author }}</span>
<span *ngIf="gs.isPublic" class="badge-public">public</span>
</div>
</div>
<div class="gs-card card-new" (click)="create()">
<div class="new-icon">
<lucide-icon [img]="Plus" [size]="20"></lucide-icon>
</div>
<h2>Nouveau système</h2>
<p class="card-description">Saisir les règles d'un JDR (markdown)</p>
</div>
</div>
<p class="tip">💡 Astuce : organisez vos règles par sections avec des titres <code>## Combat</code>, <code>## Classes</code>, <code>## Lore</code>… Le système injectera les sections pertinentes selon ce que l'IA doit générer.</p>
</div>

View File

@@ -0,0 +1,111 @@
.gs-page {
padding: 2rem 3rem;
color: #e5e7eb;
}
.gs-hero {
text-align: center;
margin-bottom: 2.5rem;
.hero-icon { color: #a78bfa; margin-bottom: 0.75rem; }
h1 { font-size: 2rem; margin: 0.5rem 0 0.25rem; color: white; }
.hero-subtitle { color: #9ca3af; margin: 0; }
}
.gs-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.25rem;
}
.gs-card {
background: #111827;
border: 1px solid #1f2937;
border-radius: 12px;
padding: 1.25rem;
cursor: pointer;
transition: border-color 0.2s, transform 0.2s;
&:hover {
border-color: #a78bfa;
transform: translateY(-2px);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.5rem;
h2 { color: white; font-size: 1.1rem; margin: 0; }
}
.card-description {
color: #9ca3af;
font-size: 0.9rem;
min-height: 2.5em;
}
.card-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 0.75rem;
font-size: 0.8rem;
.author { color: #6b7280; }
.badge-public {
background: #1e3a8a;
color: #bfdbfe;
padding: 2px 8px;
border-radius: 10px;
}
}
}
.icon-btn {
background: transparent;
border: none;
color: #6b7280;
cursor: pointer;
padding: 4px;
border-radius: 4px;
display: flex;
&:hover { color: #f87171; background: rgba(248, 113, 113, 0.1); }
}
.card-new {
border-style: dashed;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
.new-icon {
background: rgba(167, 139, 250, 0.1);
color: #a78bfa;
width: 44px;
height: 44px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 0.75rem;
}
h2 { margin: 0 0 0.25rem; color: white; }
}
.tip {
margin-top: 2rem;
color: #6b7280;
font-size: 0.85rem;
code {
background: #1f2937;
padding: 1px 6px;
border-radius: 4px;
color: #a78bfa;
}
}

View File

@@ -0,0 +1,56 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
import { LucideAngularModule, Dices, Plus, Pencil, Trash2 } from 'lucide-angular';
import { GameSystemService } from '../services/game-system.service';
import { GameSystem } from '../services/game-system.model';
@Component({
selector: 'app-game-systems',
standalone: true,
imports: [CommonModule, LucideAngularModule],
templateUrl: './game-systems.component.html',
styleUrls: ['./game-systems.component.scss']
})
export class GameSystemsComponent implements OnInit {
readonly Dices = Dices;
readonly Plus = Plus;
readonly Pencil = Pencil;
readonly Trash2 = Trash2;
gameSystems: GameSystem[] = [];
constructor(
private router: Router,
private gameSystemService: GameSystemService
) {}
ngOnInit(): void {
this.load();
}
load(): void {
this.gameSystemService.getAll().subscribe({
next: (data) => this.gameSystems = data,
error: () => this.gameSystems = []
});
}
create(): void {
this.router.navigate(['/game-systems/create']);
}
edit(id: string): void {
this.router.navigate(['/game-systems', id, 'edit']);
}
delete(system: GameSystem, event: MouseEvent): void {
event.stopPropagation();
if (!system.id) return;
if (!confirm(`Supprimer le système "${system.name}" ? Les campagnes qui l'utilisent ne seront plus associées à aucun système.`)) return;
this.gameSystemService.delete(system.id).subscribe({
next: () => this.load(),
error: () => console.error('Erreur suppression GameSystem')
});
}
}