Plusieurs ajouts :
- 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:
@@ -0,0 +1,21 @@
|
||||
<div class="flags-manager">
|
||||
|
||||
<p class="flags-empty" *ngIf="!loading && rows.length === 0">
|
||||
Aucun fait référencé. Ajoutez une condition « Quand un fait est vrai » sur
|
||||
au moins une quête pour qu'il apparaisse ici.
|
||||
</p>
|
||||
|
||||
<ul class="flag-list" *ngIf="rows.length > 0">
|
||||
<li class="flag-row" *ngFor="let r of rows" [class.flag-on]="r.value">
|
||||
<label class="flag-toggle">
|
||||
<input type="checkbox" [checked]="r.value" (change)="toggle(r)" />
|
||||
<span class="flag-toggle-slider"></span>
|
||||
</label>
|
||||
<div class="flag-info">
|
||||
<span class="flag-name">{{ r.name }}</span>
|
||||
</div>
|
||||
<span class="flag-value">{{ r.value ? 'vrai' : 'faux' }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,133 @@
|
||||
.flags-manager {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.flag-add-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.flag-add-input {
|
||||
flex: 1;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border: 1px solid var(--color-border, #d8d8d8);
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.flags-empty {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted, #777);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.flag-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.flag-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.5rem 0.7rem;
|
||||
border: 1px solid var(--color-border, #e2e2e2);
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface, #fff);
|
||||
}
|
||||
|
||||
.flag-row.flag-on {
|
||||
border-color: rgba(52, 168, 83, 0.45);
|
||||
background: rgba(52, 168, 83, 0.05);
|
||||
}
|
||||
|
||||
.flag-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.flag-name {
|
||||
font-family: 'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.flag-desc {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted, #666);
|
||||
}
|
||||
|
||||
.flag-value {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted, #777);
|
||||
min-width: 40px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.flag-remove {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--color-danger, #c0392b);
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
border-radius: 6px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover { background: rgba(192, 57, 43, 0.08); }
|
||||
}
|
||||
|
||||
// Toggle switch
|
||||
.flag-toggle {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
flex: 0 0 36px;
|
||||
|
||||
input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
|
||||
&:checked + .flag-toggle-slider {
|
||||
background: var(--color-primary, #34a853);
|
||||
}
|
||||
|
||||
&:checked + .flag-toggle-slider::before {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.flag-toggle-slider {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: #ccc;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
transition: background 150ms ease;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
left: 2px;
|
||||
top: 2px;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
transition: transform 150ms ease;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Component, Input, OnInit, OnChanges, SimpleChanges } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { LucideAngularModule } from 'lucide-angular';
|
||||
import { CampaignFlagService } from '../../services/campaign-flag.service';
|
||||
import { PlaythroughFlagService } from '../../services/playthrough-flag.service';
|
||||
import { forkJoin } from 'rxjs';
|
||||
|
||||
/**
|
||||
* Toggle de l'état des faits d'une Partie.
|
||||
*
|
||||
* <p>La liste des faits est déduite des conditions FLAG_SET référencées par les
|
||||
* quêtes de la campagne. Pour chaque fait référencé, on affiche un toggle qui
|
||||
* met à jour la valeur du Playthrough courant.</p>
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-playthrough-flags-manager',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, LucideAngularModule],
|
||||
templateUrl: './playthrough-flags-manager.component.html',
|
||||
styleUrls: ['./playthrough-flags-manager.component.scss']
|
||||
})
|
||||
export class PlaythroughFlagsManagerComponent implements OnInit, OnChanges {
|
||||
|
||||
@Input() campaignId!: string;
|
||||
@Input() playthroughId!: string;
|
||||
|
||||
rows: { name: string; value: boolean }[] = [];
|
||||
loading = false;
|
||||
|
||||
constructor(
|
||||
private campaignFlagService: CampaignFlagService,
|
||||
private playthroughFlagService: PlaythroughFlagService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void { this.reload(); }
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['campaignId'] || changes['playthroughId']) this.reload();
|
||||
}
|
||||
|
||||
reload(): void {
|
||||
if (!this.campaignId || !this.playthroughId) {
|
||||
this.rows = [];
|
||||
return;
|
||||
}
|
||||
this.loading = true;
|
||||
forkJoin({
|
||||
referenced: this.campaignFlagService.listReferenced(this.campaignId),
|
||||
values: this.playthroughFlagService.list(this.playthroughId)
|
||||
}).subscribe({
|
||||
next: ({ referenced, values }) => {
|
||||
const valueByName: Record<string, boolean> = {};
|
||||
for (const v of values) valueByName[v.name] = v.value;
|
||||
this.rows = referenced.map(name => ({
|
||||
name,
|
||||
value: valueByName[name] === true
|
||||
}));
|
||||
this.loading = false;
|
||||
},
|
||||
error: () => { this.loading = false; }
|
||||
});
|
||||
}
|
||||
|
||||
toggle(row: { name: string; value: boolean }): void {
|
||||
const next = !row.value;
|
||||
this.playthroughFlagService.setFlag(this.playthroughId, row.name, next).subscribe({
|
||||
next: () => { row.value = next; }
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<div class="prereq-editor">
|
||||
|
||||
<p class="prereq-editor-empty" *ngIf="prerequisites.length === 0">
|
||||
Aucune condition — la quête est immédiatement disponible.
|
||||
</p>
|
||||
|
||||
<ul class="prereq-list" *ngIf="prerequisites.length > 0">
|
||||
<li class="prereq-row" *ngFor="let p of prerequisites; let i = index; trackBy: trackByIndex">
|
||||
|
||||
<ng-container [ngSwitch]="p.kind">
|
||||
|
||||
<!-- QUEST_COMPLETED -->
|
||||
<ng-container *ngSwitchCase="'QUEST_COMPLETED'">
|
||||
<span class="prereq-row-label">Quête terminée :</span>
|
||||
<select class="prereq-input"
|
||||
[ngModel]="asQuestCompleted(p).questId"
|
||||
(ngModelChange)="onQuestIdChange(i, $event)"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
[disabled]="availableQuests.length === 0">
|
||||
<option *ngIf="availableQuests.length === 0" value="">(aucune autre quête)</option>
|
||||
<option *ngFor="let q of availableQuests" [value]="q.id">{{ q.name }}</option>
|
||||
</select>
|
||||
</ng-container>
|
||||
|
||||
<!-- SESSION_REACHED -->
|
||||
<ng-container *ngSwitchCase="'SESSION_REACHED'">
|
||||
<span class="prereq-row-label">À partir de la session :</span>
|
||||
<input type="number"
|
||||
class="prereq-input prereq-input-number"
|
||||
min="1"
|
||||
[ngModel]="asSessionReached(p).minSessionNumber"
|
||||
(ngModelChange)="onMinSessionChange(i, $event)"
|
||||
[ngModelOptions]="{ standalone: true }" />
|
||||
</ng-container>
|
||||
|
||||
<!-- FLAG_SET -->
|
||||
<ng-container *ngSwitchCase="'FLAG_SET'">
|
||||
<span class="prereq-row-label">Quand le fait est vrai :</span>
|
||||
<input type="text"
|
||||
class="prereq-input"
|
||||
placeholder="nom_du_fait"
|
||||
[attr.list]="'prereq-flag-list-' + i"
|
||||
[ngModel]="asFlagSet(p).flagName"
|
||||
(ngModelChange)="onFlagNameChange(i, $event)"
|
||||
[ngModelOptions]="{ standalone: true }" />
|
||||
<datalist [attr.id]="'prereq-flag-list-' + i">
|
||||
<option *ngFor="let f of availableFlags" [value]="f"></option>
|
||||
</datalist>
|
||||
</ng-container>
|
||||
|
||||
</ng-container>
|
||||
|
||||
<button type="button" class="prereq-remove" (click)="removeAt(i)" title="Retirer ce prérequis">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="prereq-add">
|
||||
<button type="button" class="btn-secondary prereq-add-btn" (click)="toggleAddMenu()">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||
Ajouter une condition
|
||||
<lucide-icon [img]="ChevronDown" [size]="14"></lucide-icon>
|
||||
</button>
|
||||
|
||||
<div class="prereq-add-menu" *ngIf="addMenuOpen">
|
||||
<button type="button" (click)="addPrerequisite('QUEST_COMPLETED')">
|
||||
Après une autre quête
|
||||
</button>
|
||||
<button type="button" (click)="addPrerequisite('SESSION_REACHED')">
|
||||
À partir d'une session
|
||||
</button>
|
||||
<button type="button" (click)="addPrerequisite('FLAG_SET')">
|
||||
Quand un fait est vrai
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,108 @@
|
||||
.prereq-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.prereq-editor-empty {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted, #777);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.prereq-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.prereq-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.45rem 0.6rem;
|
||||
border: 1px solid var(--color-border, #e2e2e2);
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface, #fff);
|
||||
}
|
||||
|
||||
.prereq-row-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted, #555);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.prereq-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0.3rem 0.5rem;
|
||||
border: 1px solid var(--color-border, #d8d8d8);
|
||||
border-radius: 6px;
|
||||
font-size: 0.88rem;
|
||||
background: var(--color-bg, #fafafa);
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary, #2c6cd6);
|
||||
}
|
||||
}
|
||||
|
||||
.prereq-input-number {
|
||||
flex: 0 0 90px;
|
||||
}
|
||||
|
||||
.prereq-remove {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--color-danger, #c0392b);
|
||||
cursor: pointer;
|
||||
padding: 0.3rem;
|
||||
border-radius: 6px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover { background: rgba(192, 57, 43, 0.08); }
|
||||
}
|
||||
|
||||
.prereq-add {
|
||||
position: relative;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.prereq-add-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.4rem 0.7rem;
|
||||
}
|
||||
|
||||
.prereq-add-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 220px;
|
||||
background: var(--color-surface, #fff);
|
||||
border: 1px solid var(--color-border, #e2e2e2);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
overflow: hidden;
|
||||
|
||||
button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
text-align: left;
|
||||
padding: 0.55rem 0.75rem;
|
||||
font-size: 0.88rem;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover { background: rgba(66, 133, 244, 0.08); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { LucideAngularModule, Plus, Trash2, ChevronDown } from 'lucide-angular';
|
||||
import { Chapter, Prerequisite } from '../../services/campaign.model';
|
||||
|
||||
/**
|
||||
* Éditeur des prérequis (conditions de déblocage) d'une quête.
|
||||
* Composant standalone & contrôlé : reçoit la liste, émet la nouvelle liste à chaque changement.
|
||||
*
|
||||
* Combinaison ET (MVP). UI : liste de lignes + bouton "Ajouter une condition" qui ouvre
|
||||
* un menu à 3 entrées (quête, session, fait).
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-prerequisite-editor',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, LucideAngularModule],
|
||||
templateUrl: './prerequisite-editor.component.html',
|
||||
styleUrls: ['./prerequisite-editor.component.scss']
|
||||
})
|
||||
export class PrerequisiteEditorComponent {
|
||||
/** Liste courante. */
|
||||
@Input() prerequisites: Prerequisite[] = [];
|
||||
|
||||
/** Quêtes candidates pour QUEST_COMPLETED (typiquement les chapitres frères du Hub). */
|
||||
@Input() availableQuests: Chapter[] = [];
|
||||
|
||||
/** Flags déjà connus de la campagne (autocomplete pour FLAG_SET). */
|
||||
@Input() availableFlags: string[] = [];
|
||||
|
||||
/** Émis chaque fois que la liste change (toute édition / ajout / suppression). */
|
||||
@Output() prerequisitesChange = new EventEmitter<Prerequisite[]>();
|
||||
|
||||
readonly Plus = Plus;
|
||||
readonly Trash2 = Trash2;
|
||||
readonly ChevronDown = ChevronDown;
|
||||
|
||||
/** Ouvre/ferme le menu d'ajout. */
|
||||
addMenuOpen = false;
|
||||
|
||||
toggleAddMenu(): void { this.addMenuOpen = !this.addMenuOpen; }
|
||||
|
||||
addPrerequisite(kind: Prerequisite['kind']): void {
|
||||
let next: Prerequisite;
|
||||
switch (kind) {
|
||||
case 'QUEST_COMPLETED':
|
||||
next = { kind: 'QUEST_COMPLETED', questId: this.availableQuests[0]?.id ?? '' };
|
||||
break;
|
||||
case 'SESSION_REACHED':
|
||||
next = { kind: 'SESSION_REACHED', minSessionNumber: 1 };
|
||||
break;
|
||||
case 'FLAG_SET':
|
||||
next = { kind: 'FLAG_SET', flagName: this.availableFlags[0] ?? '' };
|
||||
break;
|
||||
}
|
||||
this.prerequisites = [...this.prerequisites, next];
|
||||
this.prerequisitesChange.emit(this.prerequisites);
|
||||
this.addMenuOpen = false;
|
||||
}
|
||||
|
||||
removeAt(index: number): void {
|
||||
this.prerequisites = this.prerequisites.filter((_, i) => i !== index);
|
||||
this.prerequisitesChange.emit(this.prerequisites);
|
||||
}
|
||||
|
||||
/** Le champ binding change : on émet une nouvelle liste (immutable update). */
|
||||
updateAt(index: number, patched: Prerequisite): void {
|
||||
this.prerequisites = this.prerequisites.map((p, i) => (i === index ? patched : p));
|
||||
this.prerequisitesChange.emit(this.prerequisites);
|
||||
}
|
||||
|
||||
// === Handlers spécifiques par type (pour garder les templates simples) ===
|
||||
|
||||
onQuestIdChange(index: number, questId: string): void {
|
||||
this.updateAt(index, { kind: 'QUEST_COMPLETED', questId });
|
||||
}
|
||||
onMinSessionChange(index: number, n: number): void {
|
||||
this.updateAt(index, { kind: 'SESSION_REACHED', minSessionNumber: Number(n) || 1 });
|
||||
}
|
||||
onFlagNameChange(index: number, flagName: string): void {
|
||||
this.updateAt(index, { kind: 'FLAG_SET', flagName });
|
||||
}
|
||||
|
||||
/** trackBy stable pour *ngFor (sinon les inputs reset à chaque édition). */
|
||||
trackByIndex(i: number): number { return i; }
|
||||
|
||||
// === Casts typés pour le template ===
|
||||
// Angular strict templates ne fait pas le narrowing d'une union discriminée à travers
|
||||
// *ngSwitchCase. Ces helpers castent vers la bonne variante pour rendre les inputs lisibles.
|
||||
asQuestCompleted(p: Prerequisite): { kind: 'QUEST_COMPLETED'; questId: string } {
|
||||
return p as { kind: 'QUEST_COMPLETED'; questId: string };
|
||||
}
|
||||
asSessionReached(p: Prerequisite): { kind: 'SESSION_REACHED'; minSessionNumber: number } {
|
||||
return p as { kind: 'SESSION_REACHED'; minSessionNumber: number };
|
||||
}
|
||||
asFlagSet(p: Prerequisite): { kind: 'FLAG_SET'; flagName: string } {
|
||||
return p as { kind: 'FLAG_SET'; flagName: string };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<span [class]="cssClass" [attr.aria-label]="label" [title]="label">
|
||||
<lucide-icon [img]="icon" [size]="14"></lucide-icon>
|
||||
<span class="status-label" *ngIf="!compact">{{ label }}</span>
|
||||
</span>
|
||||
@@ -0,0 +1,43 @@
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.status-label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// Verrouillée — gris discret
|
||||
.status-locked {
|
||||
background: rgba(128, 128, 128, 0.12);
|
||||
color: #6b6b6b;
|
||||
border-color: rgba(128, 128, 128, 0.25);
|
||||
}
|
||||
|
||||
// Disponible — vert calme (prête à être lancée)
|
||||
.status-available {
|
||||
background: rgba(52, 168, 83, 0.12);
|
||||
color: #2f7a47;
|
||||
border-color: rgba(52, 168, 83, 0.3);
|
||||
}
|
||||
|
||||
// En cours — bleu actif
|
||||
.status-in_progress {
|
||||
background: rgba(66, 133, 244, 0.14);
|
||||
color: #2c6cd6;
|
||||
border-color: rgba(66, 133, 244, 0.35);
|
||||
}
|
||||
|
||||
// Terminée — violet doux (clos, pas neutre)
|
||||
.status-completed {
|
||||
background: rgba(120, 80, 200, 0.12);
|
||||
color: #6d4fb5;
|
||||
border-color: rgba(120, 80, 200, 0.3);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { LucideAngularModule, Lock, Circle, Play, CheckCircle2, LucideIconData } from 'lucide-angular';
|
||||
import { QuestStatus } from '../../services/campaign.model';
|
||||
|
||||
/**
|
||||
* Badge visuel pour un QuestStatus (vue Hub).
|
||||
* Composant standalone, sans dépendance métier.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-quest-status-badge',
|
||||
standalone: true,
|
||||
imports: [CommonModule, LucideAngularModule],
|
||||
templateUrl: './quest-status-badge.component.html',
|
||||
styleUrls: ['./quest-status-badge.component.scss']
|
||||
})
|
||||
export class QuestStatusBadgeComponent {
|
||||
@Input() status: QuestStatus | undefined | null = 'AVAILABLE';
|
||||
|
||||
/** Variante visuelle compacte (sans label) — utile pour les listes denses. */
|
||||
@Input() compact = false;
|
||||
|
||||
get icon(): LucideIconData {
|
||||
switch (this.status) {
|
||||
case 'LOCKED': return Lock;
|
||||
case 'IN_PROGRESS': return Play;
|
||||
case 'COMPLETED': return CheckCircle2;
|
||||
case 'AVAILABLE':
|
||||
default: return Circle;
|
||||
}
|
||||
}
|
||||
|
||||
get label(): string {
|
||||
switch (this.status) {
|
||||
case 'LOCKED': return 'Verrouillée';
|
||||
case 'IN_PROGRESS': return 'En cours';
|
||||
case 'COMPLETED': return 'Terminée';
|
||||
case 'AVAILABLE':
|
||||
default: return 'Disponible';
|
||||
}
|
||||
}
|
||||
|
||||
get cssClass(): string {
|
||||
return `status-badge status-${(this.status ?? 'AVAILABLE').toLowerCase()}`;
|
||||
}
|
||||
}
|
||||
126
web/src/app/shared/rooms-editor/rooms-editor.component.html
Normal file
126
web/src/app/shared/rooms-editor/rooms-editor.component.html
Normal 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>
|
||||
159
web/src/app/shared/rooms-editor/rooms-editor.component.scss
Normal file
159
web/src/app/shared/rooms-editor/rooms-editor.component.scss
Normal 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;
|
||||
}
|
||||
133
web/src/app/shared/rooms-editor/rooms-editor.component.ts
Normal file
133
web/src/app/shared/rooms-editor/rooms-editor.component.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user