Ajout de la possibilité de faire des stats blocs pour tout ce qui est ennemis / créatures adverses.
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m32s
Build & Push Images / build (core) (push) Successful in 1m52s
Build & Push Images / build-switcher (push) Successful in 20s
Build & Push Images / build (web) (push) Successful in 1m48s

Dorénavant, l'IA est capable de prendre en compte le format des quêtes, chapitres, Arc.... pour proposer des blocs plus complets.
Les ennemis sont également référençables directement dans la campagne.
Les références vers les ennemis dans la partie "donjon" est en cours d'ajout
This commit is contained in:
2026-06-12 23:38:43 +02:00
parent 809e00ce49
commit 6035df262d
105 changed files with 2652 additions and 99 deletions

View File

@@ -0,0 +1,52 @@
<div class="picker">
<!-- Chips des ennemis déjà liés -->
@if (linkedEnemies.length > 0) {
<div class="linked-chips">
@for (e of linkedEnemies; track e.id) {
<span class="chip">
<a
class="chip-label"
[href]="enemyUrl(e.id!)"
target="_blank"
rel="noopener"
title="Ouvrir la fiche dans un nouvel onglet">
<lucide-icon [img]="Skull" [size]="12"></lucide-icon>
{{ label(e) }}
</a>
<button type="button" class="chip-remove" (click)="remove(e.id!)" title="Retirer le lien">
<lucide-icon [img]="X" [size]="12"></lucide-icon>
</button>
</span>
}
</div>
}
<!-- Input + dropdown de suggestions -->
<div class="search-wrapper">
<input
type="text"
class="search-input"
placeholder="Rechercher un ennemi du bestiaire..."
[(ngModel)]="query"
(focus)="dropdownOpen = true"
(blur)="onBlur()" />
@if (dropdownOpen && suggestions.length > 0) {
<ul class="suggestions">
@for (e of suggestions; track e.id) {
<li (mousedown)="add(e)">
{{ label(e) }}
</li>
}
</ul>
}
@if (dropdownOpen && query && suggestions.length === 0) {
<p class="empty-hint">
Aucun ennemi ne correspond
</p>
}
</div>
</div>

View File

@@ -0,0 +1,114 @@
.picker {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.linked-chips {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.chip {
display: inline-flex;
align-items: center;
background: #3a1c24;
color: #fda4af;
border-radius: 999px;
font-size: 0.82rem;
overflow: hidden;
.chip-label {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.25rem 0.6rem;
background: transparent;
border: none;
color: inherit;
cursor: pointer;
text-decoration: none;
&:hover { color: #fecdd3; }
}
.chip-remove {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.25rem 0.5rem;
background: transparent;
border: none;
border-left: 1px solid #2a2a3d;
color: inherit;
cursor: pointer;
opacity: 0.7;
&:hover { opacity: 1; color: #fca5a5; background: #2a2a3d; }
}
}
.search-wrapper {
position: relative;
}
.search-input {
width: 100%;
box-sizing: border-box;
background: #1a1a2e;
border: 1px solid #2a2a3d;
color: white;
padding: 0.65rem 0.9rem;
border-radius: 6px;
font-size: 0.88rem;
&:focus {
outline: none;
border-color: #6c63ff;
}
&::placeholder { color: #6b7280; }
}
.suggestions {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
list-style: none;
padding: 0.25rem 0;
margin: 0;
background: #1a1a2e;
border: 1px solid #2a2a3d;
border-radius: 6px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
max-height: 280px;
overflow-y: auto;
z-index: 10;
li {
padding: 0.55rem 0.9rem;
color: #d1d5db;
font-size: 0.88rem;
cursor: pointer;
&:hover { background: #20203a; color: white; }
}
}
.empty-hint {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
padding: 0.55rem 0.9rem;
margin: 0;
background: #1a1a2e;
border: 1px solid #2a2a3d;
border-radius: 6px;
color: #6b7280;
font-size: 0.82rem;
font-style: italic;
z-index: 10;
}

View File

@@ -0,0 +1,92 @@
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { LucideAngularModule, X, Skull } from 'lucide-angular';
import { Enemy } from '../../services/enemy.model';
/**
* Composant pour lier une scène (ou toute entité) à des fiches du bestiaire
* par leurs IDs. Pendant côté ennemis du LoreLinkPickerComponent.
*
* Usage :
* <app-enemy-link-picker
* [value]="enemyIds"
* [availableEnemies]="enemies"
* [campaignId]="campaignId"
* (valueChange)="enemyIds = $event"></app-enemy-link-picker>
*
* Design :
* - Fiches liées = chips cliquables (clic → fiche ennemi en nouvel onglet)
* - Input de recherche avec dropdown de suggestions filtrées (max 8 résultats)
*/
@Component({
selector: 'app-enemy-link-picker',
imports: [FormsModule, LucideAngularModule],
templateUrl: './enemy-link-picker.component.html',
styleUrls: ['./enemy-link-picker.component.scss']
})
export class EnemyLinkPickerComponent {
readonly X = X;
readonly Skull = Skull;
/** IDs des ennemis actuellement liés (contrôlés par le parent). */
@Input() value: string[] = [];
/** Bestiaire de la campagne dans lequel on peut piocher. */
@Input() availableEnemies: Enemy[] = [];
/** ID de la campagne, pour construire les URLs des chips cliquables. */
@Input() campaignId = '';
@Output() valueChange = new EventEmitter<string[]>();
/** Texte de recherche courant. */
query = '';
/** true tant que l'input a le focus (pour afficher le dropdown). */
dropdownOpen = false;
/** Ennemis actuellement liés (résolus en objets complets pour affichage). */
get linkedEnemies(): Enemy[] {
return this.value
.map(id => this.availableEnemies.find(e => e.id === id))
.filter((e): e is Enemy => !!e);
}
/** Ennemis proposables dans le dropdown — filtrés par query, exclut les déjà liés. */
get suggestions(): Enemy[] {
const q = this.query.trim().toLowerCase();
return this.availableEnemies
.filter(e => !this.value.includes(e.id!))
.filter(e => q === '' || e.name.toLowerCase().includes(q))
.slice(0, 8);
}
/** Libellé d'une suggestion / chip : nom + niveau s'il est renseigné. */
label(enemy: Enemy): string {
const level = enemy.level?.trim();
return level ? `${enemy.name} (${level})` : enemy.name;
}
/** Ajoute un ennemi aux liens. */
add(enemy: Enemy): void {
if (!enemy.id || this.value.includes(enemy.id)) return;
this.valueChange.emit([...this.value, enemy.id]);
this.query = '';
}
/** Retire un ennemi des liens. */
remove(enemyId: string): void {
this.valueChange.emit(this.value.filter(id => id !== enemyId));
}
/**
* URL vers la fiche ennemi — utilisée par un <a target="_blank"> dans le
* template : on consulte la fiche sans perdre la scène en cours d'édition.
*/
enemyUrl(enemyId: string): string {
return `/campaigns/${this.campaignId}/enemies/${enemyId}`;
}
/** Retarde la fermeture du dropdown pour laisser le temps au clic de se propager. */
onBlur(): void {
setTimeout(() => { this.dropdownOpen = false; }, 150);
}
}

View File

@@ -4,14 +4,21 @@ import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { BehaviorSubject, Subject, forkJoin, of } from 'rxjs';
import { catchError, debounceTime, distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators';
import { LucideAngularModule, Search, BookOpen, Folder, Users, FileText, Scroll } from 'lucide-angular';
import { LucideAngularModule, Search, BookOpen, Folder, Users, FileText, Scroll, Drama, User, Dices, Package, Skull } from 'lucide-angular';
import { GlobalSearchService } from '../../services/global-search.service';
import { LoreService } from '../../services/lore.service';
import { PageService } from '../../services/page.service';
import { TemplateService } from '../../services/template.service';
import { CampaignService } from '../../services/campaign.service';
import { NpcService } from '../../services/npc.service';
import { CharacterService } from '../../services/character.service';
import { RandomTableService } from '../../services/random-table.service';
import { ItemCatalogService } from '../../services/item-catalog.service';
import { EnemyService } from '../../services/enemy.service';
type ResultKind = 'lore' | 'node' | 'template' | 'page' | 'campaign';
type ResultKind =
| 'lore' | 'node' | 'template' | 'page' | 'campaign'
| 'npc' | 'character' | 'random-table' | 'item-catalog' | 'enemy';
interface SearchResult {
id: string;
@@ -26,8 +33,9 @@ interface SearchResult {
/**
* Command palette globale (Ctrl+K / Cmd+K).
* Agrège 4 endpoints search (Lore, LoreNode, Template, Page) côté frontend.
* Navigation clavier : ↑↓ ↵ Esc.
* Agrège les endpoints search de toutes les entités nommées : lore (lores,
* dossiers, templates, pages) ET campagne (campagnes, PNJ, PJ, ennemis, tables
* aléatoires, catalogues d'objets). Navigation clavier : ↑↓ ↵ Esc.
*/
@Component({
selector: 'app-global-search',
@@ -42,6 +50,11 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
readonly Users = Users;
readonly FileText = FileText;
readonly Scroll = Scroll;
readonly Drama = Drama;
readonly User = User;
readonly Dices = Dices;
readonly Package = Package;
readonly Skull = Skull;
@ViewChild('searchInput') searchInput?: ElementRef<HTMLInputElement>;
@@ -60,7 +73,12 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
private loreService: LoreService,
private pageService: PageService,
private templateService: TemplateService,
private campaignService: CampaignService
private campaignService: CampaignService,
private npcService: NpcService,
private characterService: CharacterService,
private randomTableService: RandomTableService,
private itemCatalogService: ItemCatalogService,
private enemyService: EnemyService
) {}
ngOnInit(): void {
@@ -86,13 +104,18 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
}
this.loading = true;
return forkJoin({
lores: this.loreService.searchLores(trimmed).pipe(catchError(() => of([]))),
nodes: this.loreService.searchLoreNodes(trimmed).pipe(catchError(() => of([]))),
templates: this.templateService.search(trimmed).pipe(catchError(() => of([]))),
pages: this.pageService.search(trimmed).pipe(catchError(() => of([]))),
campaigns: this.campaignService.search(trimmed).pipe(catchError(() => of([])))
lores: this.loreService.searchLores(trimmed).pipe(catchError(() => of([]))),
nodes: this.loreService.searchLoreNodes(trimmed).pipe(catchError(() => of([]))),
templates: this.templateService.search(trimmed).pipe(catchError(() => of([]))),
pages: this.pageService.search(trimmed).pipe(catchError(() => of([]))),
campaigns: this.campaignService.search(trimmed).pipe(catchError(() => of([]))),
npcs: this.npcService.search(trimmed).pipe(catchError(() => of([]))),
characters: this.characterService.search(trimmed).pipe(catchError(() => of([]))),
tables: this.randomTableService.search(trimmed).pipe(catchError(() => of([]))),
catalogs: this.itemCatalogService.search(trimmed).pipe(catchError(() => of([]))),
enemies: this.enemyService.search(trimmed).pipe(catchError(() => of([])))
}).pipe(
switchMap(({ lores, nodes, templates, pages, campaigns }) => of(this.buildResults(lores, nodes, templates, pages, campaigns))),
switchMap(r => of(this.buildResults(r))),
catchError(() => of<SearchResult[]>([]))
);
}),
@@ -111,11 +134,14 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
/**
* Construit la liste unifiée. Ordre d'affichage : pages d'abord (le plus recherché),
* puis noeuds, templates, campagnes, et lores — les entités racines apparaissent en dernier.
* puis les entités de campagne (PNJ, PJ, ennemis, tables, catalogues), les
* noeuds/templates, et enfin les racines (campagnes, lores).
*/
private buildResults(
lores: any[], nodes: any[], templates: any[], pages: any[], campaigns: any[]
): SearchResult[] {
private buildResults(r: {
lores: any[]; nodes: any[]; templates: any[]; pages: any[]; campaigns: any[];
npcs: any[]; characters: any[]; tables: any[]; catalogs: any[]; enemies: any[];
}): SearchResult[] {
const { lores, nodes, templates, pages, campaigns, npcs, characters, tables, catalogs, enemies } = r;
const pageResults: SearchResult[] = pages.map(p => ({
id: p.id,
kind: 'page' as ResultKind,
@@ -156,7 +182,52 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
tag: 'Campagne',
route: ['/campaigns', c.id]
}));
return [...pageResults, ...nodeResults, ...templateResults, ...campaignResults, ...loreResults];
const npcResults: SearchResult[] = npcs.map(n => ({
id: n.id,
kind: 'npc' as ResultKind,
title: n.name,
subtitle: (n.folder ?? '') as string,
tag: 'PNJ',
route: ['/campaigns', n.campaignId, 'npcs', n.id]
}));
const characterResults: SearchResult[] = characters.map(c => ({
id: c.id,
kind: 'character' as ResultKind,
title: c.name,
subtitle: '',
tag: 'PJ',
route: ['/campaigns', c.campaignId, 'playthroughs', c.playthroughId, 'characters', c.id]
}));
const tableResults: SearchResult[] = tables.map(t => ({
id: t.id,
kind: 'random-table' as ResultKind,
title: t.name,
subtitle: t.description ?? '',
tag: 'Table aléatoire',
route: ['/campaigns', t.campaignId, 'random-tables', t.id]
}));
const catalogResults: SearchResult[] = catalogs.map(c => ({
id: c.id,
kind: 'item-catalog' as ResultKind,
title: c.name,
subtitle: c.description ?? '',
tag: 'Catalogue d\'objets',
route: ['/campaigns', c.campaignId, 'item-catalogs', c.id]
}));
const enemyResults: SearchResult[] = enemies.map(e => ({
id: e.id,
kind: 'enemy' as ResultKind,
title: e.name,
subtitle: [e.level ? `Niv. ${e.level}` : '', e.folder ?? ''].filter(Boolean).join(' · '),
tag: 'Ennemi',
route: ['/campaigns', e.campaignId, 'enemies', e.id]
}));
return [
...pageResults,
...npcResults, ...characterResults, ...enemyResults, ...tableResults, ...catalogResults,
...nodeResults, ...templateResults,
...campaignResults, ...loreResults
];
}
private firstLine(text: string): string {
@@ -204,6 +275,11 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
case 'template': return this.Users;
case 'page': return this.FileText;
case 'campaign': return this.Scroll;
case 'npc': return this.Drama;
case 'character': return this.User;
case 'random-table': return this.Dices;
case 'item-catalog': return this.Package;
case 'enemy': return this.Skull;
default: return this.FileText;
}
}

View File

@@ -45,9 +45,18 @@
(ngModelChange)="patch(r, { description: $event })"
placeholder="Atmosphère, ce que voient les PJ en entrant…"></textarea>
</div>
<div class="field">
<label>Ennemis du bestiaire</label>
<app-enemy-link-picker
[value]="r.enemyIds ?? []"
[availableEnemies]="availableEnemies"
[campaignId]="campaignId"
(valueChange)="patch(r, { enemyIds: $event })">
</app-enemy-link-picker>
</div>
<div class="field-row">
<div class="field">
<label>Ennemis / créatures / boss</label>
<label>Ennemis / créatures / boss (texte libre)</label>
<textarea rows="3"
[ngModel]="r.enemies"
(ngModelChange)="patch(r, { enemies: $event })"

View File

@@ -3,6 +3,8 @@ import { Component, EventEmitter, Input, Output } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { LucideAngularModule, Plus, Trash2, ChevronDown, ChevronUp, GitBranch, X } from 'lucide-angular';
import { Room, RoomBranch } from '../../services/campaign.model';
import { Enemy } from '../../services/enemy.model';
import { EnemyLinkPickerComponent } from '../enemy-link-picker/enemy-link-picker.component';
/**
* Éditeur de pièces (Rooms) d'une Scene explorable.
@@ -15,13 +17,17 @@ import { Room, RoomBranch } from '../../services/campaign.model';
*/
@Component({
selector: 'app-rooms-editor',
imports: [FormsModule, LucideAngularModule],
imports: [FormsModule, LucideAngularModule, EnemyLinkPickerComponent],
templateUrl: './rooms-editor.component.html',
styleUrls: ['./rooms-editor.component.scss']
})
export class RoomsEditorComponent {
@Input() rooms: Room[] = [];
/** Bestiaire de la campagne (pour référencer des fiches dans une pièce). */
@Input() availableEnemies: Enemy[] = [];
/** ID de la campagne (URLs des chips du picker d'ennemis). */
@Input() campaignId = '';
@Output() roomsChange = new EventEmitter<Room[]>();
readonly Plus = Plus;