import { CommerceDialog } from './CommerceDialog.js'; import { NpcDialog } from './NpcDialog.js'; import { SectorMapApp } from './SectorMapApp.js'; const MODULE_ID = 'mgt2-compendium-amiral-denisov'; const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api; const IMAGE_EXTENSIONS = new Set(['png', 'webp', 'jpg', 'jpeg', 'gif', 'svg', 'avif', 'bmp', 'tiff', 'tif']); class BatchNpcDialog extends HandlebarsApplicationMixin(ApplicationV2) { static DEFAULT_OPTIONS = { id: 'mgt2-batch-npc', classes: ['mgt2-npc-dialog'], position: { width: 540, height: 'auto' }, window: { title: 'Création de PNJs par lots', resizable: true }, form: { submitOnClose: false, closeOnSubmit: false }, }; static PARTS = { main: { template: `modules/${MODULE_ID}/templates/batch-npc-dialog.hbs`, root: true, }, }; constructor(options = {}) { super(options); this._formData = { batch: { directory: '', folderName: 'PNJs importés', openSheets: false, }, }; } async _prepareContext() { return { ...this._formData }; } async _onRender(context, options) { await super._onRender(context, options); const html = $('#' + this.id).find('.window-content'); this._applyTheme(html); html.find('[data-action="browse-directory"]').on('click', async () => { // Foundry v14: FilePicker avec pickFolders pour sélectionner un répertoire try { const fp = new FilePicker({ type: 'folder', current: this._formData.batch.directory || '', callback: (path) => { this._formData.batch.directory = path; html.find('[name="batch.directory"]').val(path); }, pickFolders: true, field: null, button: null, }); fp.render(true); return; } catch (_e) { /* fallback v13 */ } // Fallback v13: selection d'un fichier → extraction du dossier parent const fp = new FilePicker({ type: 'image', current: this._formData.batch.directory || '', callback: (path) => { const dir = path.split('/').slice(0, -1).join('/'); this._formData.batch.directory = dir; html.find('[name="batch.directory"]').val(dir); }, }); fp.render(true); }); html.find('[data-action="batch-create"]').on('click', async () => { this._readForm(html); await this._executeBatch(html); }); } _readForm(html) { this._formData.batch.directory = (html.find('[name="batch.directory"]').val()?.trim() || '').replace(/\/+$/, ''); this._formData.batch.folderName = html.find('[name="batch.folderName"]').val()?.trim() || 'PNJs importés'; this._formData.batch.openSheets = html.find('[name="batch.openSheets"]').is(':checked'); } _applyTheme(html) { html.find('h3').css({ color: '#5f4300', 'border-bottom-color': '#b78f26', 'text-shadow': 'none' }); html.find('legend').css({ color: '#7a5c00', 'text-shadow': 'none' }); } async _executeBatch(html) { const { directory, folderName, openSheets } = this._formData.batch; if (!directory) { ui.notifications.warn('Veuillez spécifier un répertoire source.'); return; } const $progress = html.find('#batch-progress'); const $fill = html.find('#batch-progress-fill'); const $text = html.find('#batch-progress-text'); $progress.show(); try { $text.text('Analyse du répertoire...'); let files; try { const result = await FilePicker.browse('data', directory); files = result.files || []; } catch (e) { $progress.hide(); ui.notifications.error(`Impossible de lire le répertoire « ${directory} » : ${e.message}`); return; } const imageFiles = files.filter(f => { const ext = f.split('.').pop()?.toLowerCase(); return ext && IMAGE_EXTENSIONS.has(ext); }); if (imageFiles.length === 0) { $progress.hide(); ui.notifications.warn(`Aucune image trouvée dans « ${directory} ».`); return; } $text.text(`${imageFiles.length} image(s) trouvée(s). Vérification des doublons...`); const normalizePath = (p) => p.replace(/^[\\/]+/, '').replace(/[\\/]+/g, '/').toLowerCase(); const existingImages = new Set(); for (const actor of game.actors) { if (actor.img && actor.img !== 'icons/svg/mystery-man.svg') { existingImages.add(normalizePath(actor.img)); } } const toCreate = imageFiles.filter(f => !existingImages.has(normalizePath(f))); if (toCreate.length === 0) { $progress.hide(); ui.notifications.info('Tous les PNJs existent déjà (aucune nouvelle image à traiter).'); return; } let folder = null; if (folderName) { folder = game.folders.find(f => f.name === folderName && f.type === 'Actor'); if (!folder) { folder = await Folder.create({ name: folderName, type: 'Actor', parent: null }); } } $text.text(`Création de ${toCreate.length} PNJ(s)...`); const chunkSize = 10; let created = 0; const allCreated = []; for (let i = 0; i < toCreate.length; i += chunkSize) { const chunk = toCreate.slice(i, i + chunkSize); const actorsData = chunk.map(path => { const name = path.split('/').pop().replace(/\.[^/.]+$/, ''); return { name, type: 'npc', img: path, folder: folder?.id || null, token: { name, img: path, actorLink: true, disposition: 0, sight: { enabled: false }, }, system: { settings: { hideUntrained: true, lockCharacteristics: true }, characteristics: {}, hits: {}, skills: {}, }, flags: { [MODULE_ID]: { batchImported: true, sourceImage: path }, }, }; }); const actors = await Actor.create(actorsData, { renderSheet: false }); allCreated.push(...actors); created += actors.length; const pct = Math.min(100, Math.round((i + chunkSize) / toCreate.length * 100)); $fill.css('width', pct + '%'); $text.text(`${created}/${toCreate.length} PNJ(s) créé(s)...`); } ui.actors?.render(false); $fill.css('width', '100%'); $text.text(`${created} PNJ(s) créé(s) avec succès dans le dossier « ${folderName} » !`); ui.notifications.info(`${created} PNJ(s) créé(s) à partir des images.`); if (openSheets && allCreated.length > 0) { const lastActor = allCreated[allCreated.length - 1]; if (lastActor) lastActor.sheet?.render(true); } } catch (error) { console.error(`${MODULE_ID} | Batch NPC creation error:`, error); $text.text(`Erreur : ${error.message}`); ui.notifications.error(`Erreur lors de la création : ${error.message}`); } } } Hooks.on('renderActorDirectory', (app, html, data) => { if (!game.user?.isGM) return; const $html = html instanceof jQuery ? html : $(html); if ($html.find('.mgt2-batch-npc-btn').length) return; const btn = $(``); const shortcuts = [ { icon: 'fa-balance-scale', label: 'Commerce', abbr: 'Commerce', action: 'commerce', cls: 'mgt2-shortcut-commerce', title: 'Ouvrir le commerce' }, { icon: 'fa-user', label: 'PNJ rapide', abbr: 'PNJ', action: 'npc', cls: 'mgt2-shortcut-pnj', title: 'Générer un PNJ rapide' }, { icon: 'fa-random', label: 'Rencontre', abbr: 'Rencontre', action: 'encounter', cls: 'mgt2-shortcut-rencontre', title: 'Générer une rencontre' }, { icon: 'fa-briefcase', label: 'Mission', abbr: 'Mission', action: 'mission', cls: 'mgt2-shortcut-mission', title: 'Générer une mission' }, { icon: 'fa-image', label: 'Fermer portraits', abbr: 'Fermer img', action: 'closePortraits', cls: 'mgt2-shortcut-closeimg', title: 'Fermer les portraits affichés aux joueurs' }, ]; const grid = $(`
`); shortcuts.forEach(s => { const b = $(``).on('click', () => { if (s.action === 'commerce') new CommerceDialog().render({ force: true }); else if (s.action === 'closePortraits') { game.socket.emit(`module.${MODULE_ID}`, { _type: 'closeImagePopout' }); ui.notifications.info('Fermeture des portraits envoyée aux joueurs'); } else new NpcDialog({ initialTab: s.action }).render({ force: true }); }); grid.append(b); }); const footer = $html.find('.directory-footer'); if (footer.length) { footer.append(btn); footer.append(grid); } else { $html.find('.directory-header').after(btn); $html.find('.directory-header').after(grid); } btn.on('click', () => { new BatchNpcDialog().render({ force: true }); }); }); Hooks.on('renderSceneDirectory', (app, html, data) => { if (!game.user?.isGM) return; const $html = html instanceof jQuery ? html : $(html); if ($html.find('.mgt2-sector-btn').length) return; const sectorBtn = $(``).on('click', () => { new SectorMapApp().render({ force: true }); }); const canvasBtn = $(``).on('click', () => { const pivot = canvas.stage?.pivot; const scale = canvas.stage?.scale?.x; if (!pivot || !scale) { ui.notifications.warn('Canvas pas encore prêt'); return; } const players = game.users.filter(u => !u.isGM && u.active).map(u => u.id); if (!players.length) { ui.notifications.warn('Aucun joueur connecté'); return; } game.socket.emit(`module.${MODULE_ID}`, { _type: 'canvasSync', x: pivot.x, y: pivot.y, scale, }); ui.notifications.info(`Vue synchronisée chez ${players.length} joueur(s)`); }); const grid = $(`
`); grid.append(sectorBtn, canvasBtn); const footer = $html.find('.directory-footer'); if (footer.length) footer.append(grid); else $html.find('.directory-header').after(grid); });