import type { CommandBus } from '@application/handlers/CommandBus'; import type { QueryBus } from '@application/handlers/QueryBus'; import type { Enclos } from '@domain/entities/Enclos'; import type { Dragodinde } from '@domain/entities/Dragodinde'; import type { GaugeType, StatType } from '@domain/value-objects/GaugeType'; import { GAUGE_DEFS, STAT_DEFS } from '@domain/value-objects/GaugeType'; import { tierNum, tierRate } from '@domain/value-objects/Tier'; import { computeGaugeLive, calcSerenEtaLive, calcLevelEtaLive, calcLevel200EtaLive, elapsedLive } from '@presentation/helpers/gauge-live'; import { esc, fmt, fmtClock } from '@presentation/helpers/format'; import { Toast } from './Toast'; import { ConfirmModal } from './ConfirmModal'; import { UndoManager } from '@presentation/services/UndoManager'; interface StatPillDef { key: StatType; icon: string; color: string; min: number; max: number; } const STAT_PILLS: StatPillDef[] = [ { key: 'serenite', icon: 'sentiment_satisfied_alt', color: '96,165,250', min: -5000, max: 5000 }, { key: 'endurance', icon: 'bolt', color: '250,204,21', min: 0, max: 20000 }, { key: 'maturite', icon: 'water_drop', color: '34,211,238', min: 0, max: 20000 }, { key: 'amour', icon: 'favorite', color: '248,113,113', min: 0, max: 20000 }, { key: 'xp', icon: 'star', color: '254,240,138', min: 1, max: 200 }, ]; export class DragodindeCard { private el: HTMLElement | null = null; private enclosId = 0; private ddId = 0; private lastTick = -1; constructor( private commandBus: CommandBus, private queryBus: QueryBus, private onReorder?: () => void, ) {} render(container: HTMLElement, enclosId: number, ddId: number): void { this.enclosId = enclosId; this.ddId = ddId; this.el = document.createElement('div'); this.el.className = 'dd-card enc-dd-card'; this.el.id = `ddc-${enclosId}-${ddId}`; this.el.draggable = true; this.el.addEventListener('dragstart', (e) => { e.dataTransfer!.effectAllowed = 'move'; e.dataTransfer?.setData('text/dd-id', String(ddId)); e.dataTransfer?.setData('text/enc-id', String(enclosId)); // Délai pour que le navigateur capture le snapshot avant d'appliquer l'opacité requestAnimationFrame(() => this.el!.classList.add('dragging')); }); this.el.addEventListener('dragend', () => { this.el!.classList.remove('dragging'); this.el!.classList.remove('drag-over'); }); this.el.addEventListener('dragover', (e) => { e.preventDefault(); e.dataTransfer!.dropEffect = 'move'; this.el!.classList.add('drag-over'); }); this.el.addEventListener('dragleave', (e) => { // Ignorer si on entre dans un élément enfant if (this.el!.contains(e.relatedTarget as Node)) return; this.el!.classList.remove('drag-over'); }); this.el.addEventListener('drop', (e) => { e.preventDefault(); this.el!.classList.remove('drag-over'); const srcDdId = e.dataTransfer?.getData('text/dd-id'); const srcEncId = e.dataTransfer?.getData('text/enc-id'); if (srcEncId === String(enclosId) && srcDdId && srcDdId !== String(ddId)) { this.commandBus.execute({ type: 'reorder-dragodinde', enclosId, fromDdId: Number(srcDdId), toDdId: ddId, }); this.onReorder?.(); } }); container.appendChild(this.el); this.renderInner(); } private renderInner(): void { if (!this.el) return; const enc = this.queryBus.execute({ type: 'get-enclos-detail', enclosId: this.enclosId }); const dd = enc.dragodindes.find(d => d.id === this.ddId); if (!dd) return; const eId = this.enclosId; const dId = this.ddId; /* stat pills */ const pillsHtml = STAT_PILLS.map(sp => { const val = dd.stats[sp.key]; const atMax = val >= sp.max; const atMin = sp.key === 'serenite' && val <= sp.min; const atLimit = atMax || atMin; return `
${sp.icon}
`; }).join(''); /* Mapping icônes Material Symbols pour boutons jauges */ const GAUGE_MS_ICONS: Partial> = { baffeur: 'remove', caresseur: 'add', foudroyeur: 'bolt', abreuvoir: 'water_drop', dragofesse: 'favorite', }; /* active gauge blocks */ const gaugeBlocksHtml = enc.activeGauges.map(gid => { const def = GAUGE_DEFS[gid]; if (gid === 'mangeoire') { return `
NIV. 1 XP 0%
--:--:--
→ NIV. 200 : —
`; } const msIcon = GAUGE_MS_ICONS[gid] ?? 'circle'; return `
${msIcon} ${def.label.toUpperCase()} --:--:--
`; }).join(''); this.el.innerHTML = `
${pillsHtml}
sentiment_satisfied_alt Cible
stars Niveau
${gaugeBlocksHtml}
`; this.bindEvents(dd); } private bindEvents(dd: Dragodinde): void { if (!this.el) return; const eId = this.enclosId; const dId = this.ddId; /* Delete button */ const delBtn = this.el.querySelector('.dd-del'); delBtn?.addEventListener('click', async () => { const ok = await ConfirmModal.show('Retirer la dragodinde', 'Retirer cette dragodinde de l\'enclos ?'); if (!ok) return; const hasSnap = await UndoManager.snapshotCurrent('Dragodinde retirée'); this.commandBus.execute({ type: 'remove-dragodinde', enclosId: eId, ddId: dId }); Toast.show('success', 'Dragodinde retirée.', hasSnap ? { label: 'Annuler', callback: () => UndoManager.undo() } : undefined); }); /* Name input */ const nameInput = this.el.querySelector('.dd-name'); if (nameInput) { nameInput.addEventListener('focus', () => { nameInput.dataset.prev = nameInput.value; nameInput.value = ''; }); nameInput.addEventListener('blur', () => { const v = nameInput.value.trim(); if (!v) nameInput.value = nameInput.dataset.prev || dd.name; else this.commandBus.execute({ type: 'rename-dragodinde', enclosId: eId, ddId: dId, name: v }); }); nameInput.addEventListener('keydown', (e) => { if (e.key === 'Escape') { nameInput.value = nameInput.dataset.prev || dd.name; nameInput.blur(); } else if (e.key === 'Enter') nameInput.blur(); }); } /* Stat pill inputs */ this.el.querySelectorAll('.pill-input').forEach(inp => { const stat = inp.dataset.stat as StatType; inp.addEventListener('focus', () => { inp.dataset.prev = inp.value; inp.value = ''; }); inp.addEventListener('input', () => { if (!inp.value) return; const v = Number(inp.value); if (!isNaN(v)) this.commandBus.execute({ type: 'update-dd-stat', enclosId: eId, ddId: dId, stat, value: v }); }); inp.addEventListener('blur', () => { if (inp.value === '') { inp.value = inp.dataset.prev || '0'; return; } const v = Number(inp.value); if (!isNaN(v)) this.commandBus.execute({ type: 'update-dd-stat', enclosId: eId, ddId: dId, stat, value: v }); }); inp.addEventListener('keydown', (e) => { if (e.key === 'Escape') { inp.value = inp.dataset.prev || '0'; inp.blur(); } else if (e.key === 'Enter') inp.blur(); }); }); /* Clear buttons */ this.el.querySelector(`#ser-clr-${eId}-${dId}`)?.addEventListener('click', () => { this.commandBus.execute({ type: 'update-dd-seren-target', enclosId: eId, ddId: dId, target: null }); const inp = this.el?.querySelector(`#ser-tgt-${eId}-${dId}`); if (inp) inp.value = ''; const btn = this.el?.querySelector(`#ser-clr-${eId}-${dId}`); if (btn) btn.style.visibility = 'hidden'; }); this.el.querySelector(`#lvl-clr-${eId}-${dId}`)?.addEventListener('click', () => { this.commandBus.execute({ type: 'update-dd-level-target', enclosId: eId, ddId: dId, target: null }); const inp = this.el?.querySelector(`#lvl-tgt-${eId}-${dId}`); if (inp) inp.value = ''; const btn = this.el?.querySelector(`#lvl-clr-${eId}-${dId}`); if (btn) btn.style.visibility = 'hidden'; }); /* Serenite target — clamp selon la jauge active (baffeur → négatif, caresseur → positif) */ const serTgt = this.el.querySelector(`#ser-tgt-${eId}-${dId}`); if (serTgt) { const clampSeren = (raw: number): number => { const enc = this.queryBus.execute({ type: 'get-enclos-detail', enclosId: eId }); if (enc.activeGauges.includes('baffeur')) return Math.min(0, Math.max(-5000, raw)); if (enc.activeGauges.includes('caresseur')) return Math.max(0, Math.min(5000, raw)); return Math.min(5000, Math.max(-5000, raw)); }; serTgt.addEventListener('focus', () => { serTgt.dataset.prev = serTgt.value; serTgt.value = ''; }); serTgt.addEventListener('input', () => { if (!serTgt.value) return; const v = Number(serTgt.value); if (isNaN(v)) return; const clamped = clampSeren(v); this.commandBus.execute({ type: 'update-dd-seren-target', enclosId: eId, ddId: dId, target: clamped }); }); serTgt.addEventListener('blur', () => { if (serTgt.value === '') { serTgt.value = serTgt.dataset.prev || ''; return; } const v = Number(serTgt.value); if (isNaN(v)) { serTgt.value = serTgt.dataset.prev || ''; return; } const clamped = clampSeren(v); this.commandBus.execute({ type: 'update-dd-seren-target', enclosId: eId, ddId: dId, target: clamped }); serTgt.value = String(clamped); }); serTgt.addEventListener('keydown', (e) => { if (e.key === 'Escape') { serTgt.value = serTgt.dataset.prev || ''; serTgt.blur(); } else if (e.key === 'Enter') serTgt.blur(); }); } /* Level target */ const lvlTgt = this.el.querySelector(`#lvl-tgt-${eId}-${dId}`); if (lvlTgt) { lvlTgt.addEventListener('focus', () => { lvlTgt.dataset.prev = lvlTgt.value; lvlTgt.value = ''; }); lvlTgt.addEventListener('input', () => { if (!lvlTgt.value) return; const v = Number(lvlTgt.value); this.commandBus.execute({ type: 'update-dd-level-target', enclosId: eId, ddId: dId, target: isNaN(v) ? null : v }); }); lvlTgt.addEventListener('blur', () => { if (lvlTgt.value === '') { lvlTgt.value = lvlTgt.dataset.prev || ''; return; } const v = Number(lvlTgt.value); this.commandBus.execute({ type: 'update-dd-level-target', enclosId: eId, ddId: dId, target: isNaN(v) ? null : v }); }); lvlTgt.addEventListener('keydown', (e) => { if (e.key === 'Escape') { lvlTgt.value = lvlTgt.dataset.prev || ''; lvlTgt.blur(); } else if (e.key === 'Enter') lvlTgt.blur(); }); } } update(enc: Enclos, dd: Dragodinde, el: number, started: boolean): void { if (!this.el) return; const eId = this.enclosId; const dId = this.ddId; /* Détection de tick (toutes les 10 sec). * Après complétion automatique, le temps réel continue pour que les animations * continuent sur toutes les jauges qui se vident en fond. */ const elForTick = started ? elapsedLive(enc) : 0; const tick = started ? Math.floor(elForTick / 10) : -1; if (!started) { this.lastTick = -1; } else if (this.lastTick === -1) { this.lastTick = tick; // initialise sans déclencher au démarrage } const newTick = started && tick !== this.lastTick; if (newTick) this.lastTick = tick; let allDone = enc.activeGauges.length > 0; /* Update active gauge blocks */ enc.activeGauges.forEach(gid => { const r = computeGaugeLive(enc, dd, gid, el, started); const def = GAUGE_DEFS[gid]; // Toutes les jauges comptent pour le badge "✓ TERMINÉ" if (!r.done) allDone = false; const lvEl = this.el!.querySelector(`#slv-${eId}-${dId}-${gid}`); if (lvEl) lvEl.textContent = r.liveText; /* Delta live-delta : pop à chaque tick. * Continue jusqu'au cap absolu de la stat (pas juste la cible). */ const sd = def.isXp ? null : STAT_DEFS[def.stat as keyof typeof STAT_DEFS]; const atAbsCap = def.isXp ? (r.estStat as number) >= 200 : (def.dir > 0 ? (r.estStat as number) >= sd!.max : (r.estStat as number) <= sd!.min); const deltaActive = started && !atAbsCap; const deltaEl = this.el!.querySelector(`#sdelta-${eId}-${dId}-${gid}`); if (deltaEl) { deltaEl.textContent = r.deltaText; if (newTick && deltaActive) { deltaEl.classList.remove('show'); void deltaEl.offsetWidth; // force reflow pour relancer l'animation deltaEl.classList.add('show'); } else if (!deltaActive) { deltaEl.classList.remove('show'); } } const cdEl = this.el!.querySelector(`#scd-${eId}-${dId}-${gid}`); if (cdEl) cdEl.textContent = r.done ? '✅' : (!isFinite(r.cntDown) ? '∞' : fmtClock(r.cntDown)); /* ETA + barre de progression niveau 200 (mangeoire uniquement) */ if (gid === 'mangeoire') { const eta200El = this.el!.querySelector(`#eta200-${eId}-${dId}`); if (eta200El) { const eta = calcLevel200EtaLive(enc, dd, el, started); eta200El.textContent = `→ NIV. 200 : ${eta || '—'}`; } const pct200 = Math.min(100, Math.max(0, ((r.estStat as number) - 1) / 199 * 100)); const barFillEl = this.el!.querySelector(`#eta200-bar-${eId}-${dId}`); if (barFillEl) barFillEl.style.width = `${pct200.toFixed(1)}%`; const pctEl = this.el!.querySelector(`#eta200-pct-${eId}-${dId}`); if (pctEl) pctEl.textContent = `${Math.round(pct200)}%`; } const pbEl = this.el!.querySelector(`#spb-${eId}-${dId}-${gid}`); if (pbEl) pbEl.style.width = `${r.progPct.toFixed(1)}%`; /* Mise à jour live du badge de stat correspondant. * Désactivée après complétion (__done__) : dd.stats est déjà à jour * et l'utilisateur doit pouvoir corriger les valeurs à la main. */ if (started && !enc.alerted['__done__']) { const pillInput = this.el!.querySelector(`#pstat-${eId}-${dId}-${def.stat}`); if (pillInput && document.activeElement !== pillInput) { pillInput.value = String(Math.round(r.estStat as number)); } const sp = STAT_PILLS.find(p => p.key === def.stat); if (sp) { const val = Math.round(r.estStat as number); const atLimit = val >= sp.max || (sp.key === 'serenite' && val <= sp.min); const pill = pillInput?.closest('.dd-stat-pill'); if (pill) { pill.classList.toggle('at-max', atLimit); pill.style.background = atLimit ? `rgba(${sp.color},0.18)` : ''; pill.style.boxShadow = atLimit ? `0 0 8px rgba(${sp.color},0.5)` : ''; } } } /* Pill delta : pop à chaque tick — uniquement endurance, maturite, amour */ const pillDeltaStats = ['endurance', 'maturite', 'amour']; if (pillDeltaStats.includes(def.stat)) { const pillDelta = this.el!.querySelector(`#pill-delta-${eId}-${dId}-${def.stat}`); if (pillDelta) { pillDelta.textContent = r.deltaText; if (newTick && started && !r.done) { pillDelta.classList.remove('show'); void pillDelta.offsetWidth; pillDelta.classList.add('show'); } else if (!started || r.done) { pillDelta.classList.remove('show'); } } } }); /* Done badge */ const doneBadge = this.el.querySelector(`#dd-done-${eId}-${dId}`); if (doneBadge) { doneBadge.style.display = (allDone && started && enc.activeGauges.length > 0) ? '' : 'none'; } /* Serenity ETA */ const serEta = this.el.querySelector(`#ser-eta-${eId}-${dId}`); if (serEta) serEta.innerHTML = calcSerenEtaLive(enc, dd, el, started); const serClr = this.el.querySelector(`#ser-clr-${eId}-${dId}`); if (serClr) serClr.style.visibility = dd.sereniteTarget == null ? 'hidden' : 'visible'; /* Level ETA */ const lvlEta = this.el.querySelector(`#lvl-eta-${eId}-${dId}`); if (lvlEta) lvlEta.innerHTML = calcLevelEtaLive(enc, dd, el, started); const lvlClr = this.el.querySelector(`#lvl-clr-${eId}-${dId}`); if (lvlClr) lvlClr.style.visibility = dd.levelTarget == null ? 'hidden' : 'visible'; } destroy(): void { this.el?.remove(); this.el = null; } }