Plusieurs ajouts :
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled

- Possibilité de configurer des lieux dans une scène : permet de configurer un donjon par exemple avec les pièces, les trésors par pièce, la narration..... Mise en place également de conditions permettant de conditionner le déblocage des quêtes.
- Possibilité de transformé un arc en instance non linéaire afin de faire un hub. Permet de jouer de préparer des campagnes type Dragon of Icespire peak plus facilement.
- Configuration de partie : chaque partie va contenir les séances, ce qui permettra de suivre le déblocage des conditions pour les quêtes.

Passage en 0.9.1-beta
This commit is contained in:
2026-06-03 15:44:48 +02:00
parent e3fc96a1bc
commit 504c4b7b6c
147 changed files with 5347 additions and 392 deletions

View File

@@ -0,0 +1,126 @@
<div class="rooms-editor">
<p class="rooms-empty" *ngIf="rooms.length === 0">
Aucune pièce. La scène se comporte comme un beat narratif classique.
Ajoutez une première pièce pour la convertir en lieu explorable (donjon, crypte…).
</p>
<ul class="room-list" *ngIf="rooms.length > 0">
<li class="room-card" *ngFor="let r of rooms; let i = index; trackBy: trackById">
<!-- Header repliable -->
<header class="room-head" (click)="toggleExpanded(r.id)">
<button type="button" class="room-toggle" type="button">
<lucide-icon [img]="isExpanded(r.id) ? ChevronUp : ChevronDown" [size]="16"></lucide-icon>
</button>
<span class="room-index">#{{ i + 1 }}</span>
<input type="text" class="room-name-input"
[ngModel]="r.name"
(ngModelChange)="patch(r, { name: $event })"
(click)="$event.stopPropagation()"
placeholder="Nom de la pièce" />
<input type="number" class="room-floor-input"
[ngModel]="r.floor"
(ngModelChange)="patch(r, { floor: $event === null || $event === '' ? null : +$event })"
(click)="$event.stopPropagation()"
placeholder="Ét." title="Étage (0 = RdC)" />
<div class="room-actions" (click)="$event.stopPropagation()">
<button type="button" class="btn-icon" (click)="moveUp(r)" [disabled]="i === 0" title="Monter"></button>
<button type="button" class="btn-icon" (click)="moveDown(r)" [disabled]="i === rooms.length - 1" title="Descendre"></button>
<button type="button" class="btn-icon-danger" (click)="removeRoom(r)" title="Supprimer la pièce">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</button>
</div>
</header>
<!-- Corps déplié -->
<div class="room-body" *ngIf="isExpanded(r.id)">
<div class="field">
<label>Description (lue/résumée aux joueurs)</label>
<textarea rows="3"
[ngModel]="r.description"
(ngModelChange)="patch(r, { description: $event })"
placeholder="Atmosphère, ce que voient les PJ en entrant…"></textarea>
</div>
<div class="field-row">
<div class="field">
<label>Ennemis / créatures / boss</label>
<textarea rows="3"
[ngModel]="r.enemies"
(ngModelChange)="patch(r, { enemies: $event })"
placeholder="2 gobelins, 1 ogre boss (PV 60, AC 14)…"></textarea>
</div>
<div class="field">
<label>Loot / trésors</label>
<textarea rows="3"
[ngModel]="r.loot"
(ngModelChange)="patch(r, { loot: $event })"
placeholder="50 po, potion de soin, clé en argent…"></textarea>
</div>
</div>
<div class="field-row">
<div class="field">
<label>Pièges / dangers</label>
<textarea rows="2"
[ngModel]="r.traps"
(ngModelChange)="patch(r, { traps: $event })"
placeholder="Trappe (DC 15 Perception), flèches empoisonnées…"></textarea>
</div>
<div class="field">
<label>Notes MJ (privé)</label>
<textarea rows="2"
[ngModel]="r.gmNotes"
(ngModelChange)="patch(r, { gmNotes: $event })"
placeholder="Le boss connaît les PJ par leur nom, indice…"></textarea>
</div>
</div>
<!-- Branches inter-pièces -->
<div class="field">
<label>
<lucide-icon [img]="GitBranch" [size]="14"></lucide-icon>
Sorties / portes vers d'autres pièces
</label>
<ul class="branch-list" *ngIf="(r.branches?.length ?? 0) > 0">
<li class="branch-row" *ngFor="let b of r.branches; let bi = index">
<input type="text" class="branch-label"
[ngModel]="b.label"
(ngModelChange)="updateBranch(r, bi, { label: $event })"
placeholder="Porte nord" />
<span class="branch-arrow"></span>
<select class="branch-target"
[ngModel]="b.targetRoomId"
(ngModelChange)="updateBranch(r, bi, { targetRoomId: $event })"
[disabled]="otherRooms(r).length === 0">
<option *ngIf="otherRooms(r).length === 0" value="">(aucune autre pièce)</option>
<option *ngFor="let o of otherRooms(r)" [value]="o.id">{{ o.name }}</option>
</select>
<input type="text" class="branch-condition"
[ngModel]="b.condition"
(ngModelChange)="updateBranch(r, bi, { condition: $event })"
placeholder="Condition (optionnelle)" />
<button type="button" class="btn-icon-danger" (click)="removeBranch(r, bi)">
<lucide-icon [img]="X" [size]="14"></lucide-icon>
</button>
</li>
</ul>
<button type="button" class="btn-secondary btn-small"
(click)="addBranch(r)"
[disabled]="otherRooms(r).length === 0">
<lucide-icon [img]="Plus" [size]="13"></lucide-icon>
Ajouter une sortie
</button>
</div>
</div>
</li>
</ul>
<button type="button" class="btn-primary" (click)="addRoom()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Ajouter une pièce
</button>
</div>

View File

@@ -0,0 +1,159 @@
.rooms-editor { display: flex; flex-direction: column; gap: 0.75rem; }
.rooms-empty {
margin: 0;
font-size: 0.85rem;
color: var(--color-text-muted, #777);
font-style: italic;
}
.room-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 0.5rem; }
.room-card {
border: 1px solid var(--color-border, #e2e2e2);
border-radius: 10px;
background: var(--color-surface, #fff);
overflow: hidden;
}
.room-head {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.55rem 0.7rem;
cursor: pointer;
background: rgba(66, 133, 244, 0.04);
&:hover { background: rgba(66, 133, 244, 0.07); }
}
.room-toggle {
background: transparent;
border: none;
display: inline-flex;
align-items: center;
color: var(--color-text-muted, #666);
cursor: pointer;
padding: 0.25rem;
}
.room-index {
font-size: 0.78rem;
color: var(--color-text-muted, #777);
font-family: 'JetBrains Mono', ui-monospace, monospace;
}
.room-name-input {
flex: 1;
padding: 0.35rem 0.55rem;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
font-size: 0.95rem;
font-weight: 600;
&:hover, &:focus {
background: var(--color-bg, #fafafa);
border-color: var(--color-border, #d8d8d8);
outline: none;
}
}
.room-floor-input {
width: 56px;
padding: 0.3rem 0.4rem;
border: 1px solid var(--color-border, #d8d8d8);
border-radius: 6px;
font-size: 0.85rem;
text-align: center;
}
.room-actions {
display: flex;
gap: 0.25rem;
}
.btn-icon, .btn-icon-danger {
background: transparent;
border: none;
padding: 0.3rem 0.45rem;
border-radius: 6px;
cursor: pointer;
display: inline-flex;
align-items: center;
font-size: 0.85rem;
&:hover { background: rgba(0,0,0,0.05); }
&:disabled { opacity: 0.35; cursor: not-allowed; }
}
.btn-icon-danger {
color: var(--color-danger, #c0392b);
&:hover { background: rgba(192, 57, 43, 0.08); }
}
.room-body {
padding: 0.85rem 0.85rem 1rem;
display: flex;
flex-direction: column;
gap: 0.7rem;
border-top: 1px solid var(--color-border, #eaeaea);
}
.field { display: flex; flex-direction: column; gap: 0.3rem; }
.field label {
font-size: 0.82rem;
color: var(--color-text-muted, #555);
font-weight: 500;
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
.field textarea, .field input[type="text"] {
padding: 0.4rem 0.55rem;
border: 1px solid var(--color-border, #d8d8d8);
border-radius: 6px;
font-family: inherit;
font-size: 0.88rem;
resize: vertical;
}
.field-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.7rem;
}
// Branches
.branch-list {
list-style: none;
padding: 0;
margin: 0 0 0.4rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.branch-row {
display: grid;
grid-template-columns: 1.2fr auto 1.5fr 1.5fr auto;
align-items: center;
gap: 0.4rem;
padding: 0.35rem 0.45rem;
border: 1px solid var(--color-border, #ececec);
border-radius: 6px;
background: var(--color-bg, #fafafa);
}
.branch-arrow { font-weight: 700; color: var(--color-text-muted, #888); }
.branch-label, .branch-condition, .branch-target {
padding: 0.3rem 0.45rem;
border: 1px solid var(--color-border, #d8d8d8);
border-radius: 6px;
font-size: 0.85rem;
}
.btn-small {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.3rem 0.6rem;
font-size: 0.82rem;
}

View File

@@ -0,0 +1,133 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { LucideAngularModule, Plus, Trash2, ChevronDown, ChevronUp, GitBranch, X } from 'lucide-angular';
import { Room, RoomBranch } from '../../services/campaign.model';
/**
* Éditeur de pièces (Rooms) d'une Scene explorable.
*
* Contrôlé : reçoit la liste, émet la nouvelle liste à chaque mutation.
* Une pièce est repliée par défaut (juste le nom visible) ; un clic l'expand
* pour éditer narration / ennemis / loot / pièges / notes MJ / étage.
*
* Les branches (graphe inter-pièces) sont éditables dans la card expand.
*/
@Component({
selector: 'app-rooms-editor',
standalone: true,
imports: [CommonModule, FormsModule, LucideAngularModule],
templateUrl: './rooms-editor.component.html',
styleUrls: ['./rooms-editor.component.scss']
})
export class RoomsEditorComponent {
@Input() rooms: Room[] = [];
@Output() roomsChange = new EventEmitter<Room[]>();
readonly Plus = Plus;
readonly Trash2 = Trash2;
readonly ChevronDown = ChevronDown;
readonly ChevronUp = ChevronUp;
readonly GitBranch = GitBranch;
readonly X = X;
/** Set des IDs de pièces expandées (UI state local). */
private expanded = new Set<string>();
isExpanded(id: string): boolean { return this.expanded.has(id); }
toggleExpanded(id: string): void {
if (this.expanded.has(id)) this.expanded.delete(id);
else this.expanded.add(id);
}
/** Génère un UUID v4 simplifié pour l'ID stable d'une pièce. */
private newId(): string {
return 'room-' + Math.random().toString(36).slice(2) + '-' + Date.now().toString(36);
}
addRoom(): void {
const nextOrder = this.rooms.length > 0
? Math.max(...this.rooms.map(r => r.order ?? 0)) + 1
: 0;
const newRoom: Room = {
id: this.newId(),
name: `Salle ${this.rooms.length + 1}`,
order: nextOrder,
branches: []
};
this.rooms = [...this.rooms, newRoom];
this.expanded.add(newRoom.id);
this.emit();
}
removeRoom(room: Room): void {
this.rooms = this.rooms
.filter(r => r.id !== room.id)
.map(r => ({
...r,
// Nettoyer les branches qui pointaient vers la pièce supprimée
branches: (r.branches ?? []).filter(b => b.targetRoomId !== room.id)
}));
this.expanded.delete(room.id);
this.emit();
}
/** Patch un champ d'une pièce et émet. */
patch(room: Room, patch: Partial<Room>): void {
this.rooms = this.rooms.map(r => r.id === room.id ? { ...r, ...patch } : r);
this.emit();
}
// === Branches inter-pièces ===
addBranch(room: Room): void {
const others = this.rooms.filter(r => r.id !== room.id);
const branch: RoomBranch = {
label: 'Porte',
targetRoomId: others[0]?.id ?? '',
condition: ''
};
this.patch(room, { branches: [...(room.branches ?? []), branch] });
}
removeBranch(room: Room, index: number): void {
const next = (room.branches ?? []).filter((_, i) => i !== index);
this.patch(room, { branches: next });
}
updateBranch(room: Room, index: number, patch: Partial<RoomBranch>): void {
const next = (room.branches ?? []).map((b, i) => i === index ? { ...b, ...patch } : b);
this.patch(room, { branches: next });
}
/** Pièces candidates pour cibler une branche (toutes sauf celle d'origine). */
otherRooms(room: Room): Room[] {
return this.rooms.filter(r => r.id !== room.id);
}
/** Réorganise l'ordre via boutons up/down (simple : swap avec voisin). */
moveUp(room: Room): void {
const idx = this.rooms.findIndex(r => r.id === room.id);
if (idx <= 0) return;
const arr = [...this.rooms];
[arr[idx - 1], arr[idx]] = [arr[idx], arr[idx - 1]];
this.rooms = arr.map((r, i) => ({ ...r, order: i }));
this.emit();
}
moveDown(room: Room): void {
const idx = this.rooms.findIndex(r => r.id === room.id);
if (idx === -1 || idx >= this.rooms.length - 1) return;
const arr = [...this.rooms];
[arr[idx], arr[idx + 1]] = [arr[idx + 1], arr[idx]];
this.rooms = arr.map((r, i) => ({ ...r, order: i }));
this.emit();
}
trackById(_: number, r: Room): string { return r.id; }
private emit(): void {
this.roomsChange.emit(this.rooms);
}
}