/** * Sprites ingrédients — parsing de `ingredient-sprites.bentext` et résolution * alias → coordonnées pixel sur la planche. Pure, sans effet de bord. * * Format de `ingredient-sprites.bentext` : * --- (séparateur de blocs) * (coordonnées dans la grille, taille de cellule = INGREDIENT_SPRITE_CELL) * alias 1 | alias 2 (synonymes séparés par `|`, lignes supplémentaires acceptées) * --- * ... * * Sortie de `parseIngredientSpritesBentext` : * - byAlias : { [aliasLower]: { x, y, X, y: y } } * - preferredAliases : Set (alias des lignes « prioritaires ») * - csv : rendu CSV (compat / rétro pour les outils existants) */ export const INGREDIENT_SPRITE_CELL = 32; /** Lignes de tuiles « prioritaires » pour la carte variante (aligné sur l’ancienne grille CSV). */ export const PREFERRED_VARIANT_SPRITE_ROWS = new Set([8, 9, 10, 12, 13, 14, 15, 16]); const normalizeIngredientAliasKey = (s) => s.trim().toLowerCase().replace(/\s+/g, " "); const foldAccents = (s) => s.normalize("NFD").replace(/\p{M}+/gu, "").toLowerCase(); /** * Parse `ingredient-sprites.bentext` : blocs `---` puis ligne `row col`, puis alias (`|` = synonymes). */ const parseIngredientSpritesBentext = (raw) => { const byAlias = {}; const preferredAliases = new Set(); const grid = new Map(); const chunks = raw .replace(/^\uFEFF/, "") .trim() .split(/\r?\n---\r?\n/); for (const chunk of chunks) { const trimmedChunk = chunk.trim(); if (!trimmedChunk) continue; const lines = trimmedChunk .split(/\r?\n/) .map((l) => l.trim()) .filter((l) => l.length > 0); if (lines.length < 2) continue; const coordMatch = lines[0].match(/^(\d+)\s+(\d+)/); if (!coordMatch) { if (lines[0] === "---") continue; console.warn(`[build-recipes] Bloc sprite ignoré (coords invalides): ${lines[0].slice(0, 48)}`); continue; } const row = Number(coordMatch[1]); const col = Number(coordMatch[2]); const x = col * INGREDIENT_SPRITE_CELL; const y = row * INGREDIENT_SPRITE_CELL; const entry = { x, y, X: x, Y: y }; const aliasLines = lines.slice(1); const cellText = aliasLines.join("|"); const ck = `${row},${col}`; grid.set(ck, grid.has(ck) ? `${grid.get(ck)}|${cellText}` : cellText); const isPreferredRow = PREFERRED_VARIANT_SPRITE_ROWS.has(row); for (const line of aliasLines) { for (const part of line.split("|")) { const key = normalizeIngredientAliasKey(part); if (!key) continue; const prev = byAlias[key]; if (!prev) { byAlias[key] = entry; } else if (prev.x !== x || prev.y !== y) { console.warn( `[build-recipes] Alias sprite « ${key} » en conflit (${prev.x},${prev.y}) vs (${x},${y}) — première définition conservée.` ); continue; } if (isPreferredRow) preferredAliases.add(key); } } } let maxR = 0; let maxC = 0; for (const k of grid.keys()) { const [r, c] = k.split(",").map(Number); maxR = Math.max(maxR, r); maxC = Math.max(maxC, c); } const csvLines = []; for (let r = 0; r <= maxR; r++) { const cols = []; for (let c = 0; c <= maxC; c++) { cols.push(grid.get(`${r},${c}`) ?? ""); } csvLines.push(cols.join(",")); } const csv = `${csvLines.join("\n")}\n`; return { byAlias, preferredAliases, csv }; }; const resolveIconPxForIngredientName = (name, byAlias) => { if (!name || typeof name !== "string") return null; const n = normalizeIngredientAliasKey(name); if (byAlias[n]) return aliasToIconPx(byAlias, n); const nFold = foldAccents(n); for (const key of Object.keys(byAlias)) { if (foldAccents(key) === nFold) return aliasToIconPx(byAlias, key); } const segments = n.split(/[,;]|(?:\s+\/\s+)|(?:\s+et\s+)/i).map((s) => s.trim()).filter(Boolean); for (const seg of segments) { if (byAlias[seg]) return aliasToIconPx(byAlias, seg); const sf = foldAccents(seg); for (const key of Object.keys(byAlias)) { if (foldAccents(key) === sf) return aliasToIconPx(byAlias, key); } } const keysSorted = Object.keys(byAlias).sort((a, b) => b.length - a.length); for (const k of keysSorted) { if (k.length < 3) continue; if (n.includes(k) || nFold.includes(foldAccents(k))) return aliasToIconPx(byAlias, k); } return null; }; /** Lit les coordonnées pixel dans `byAlias` (API : `X`/`Y` ou `x`/`y`). */ const aliasToIconPx = (byAlias, alias) => { if (!byAlias || typeof alias !== "string") return null; const entry = byAlias[alias]; if (!entry || typeof entry !== "object") return null; const x = typeof entry.x === "number" ? entry.x : typeof entry.X === "number" ? entry.X : NaN; const y = typeof entry.y === "number" ? entry.y : typeof entry.Y === "number" ? entry.Y : NaN; if (!Number.isFinite(x) || !Number.isFinite(y)) return null; return { x, y }; }; /** Alias manifeste correspondant à des coordonnées exactes sur la planche (pour filtrer les tuiles). */ const findAliasForIconPx = (byAlias, px) => { if (!byAlias || !px || typeof px.x !== "number" || typeof px.y !== "number") return null; for (const [alias, entry] of Object.entries(byAlias)) { if (!entry || typeof entry !== "object") continue; const x = typeof entry.x === "number" ? entry.x : typeof entry.X === "number" ? entry.X : NaN; const y = typeof entry.y === "number" ? entry.y : typeof entry.Y === "number" ? entry.Y : NaN; if (px.x === x && px.y === y) return alias; } return null; }; const isPreferredVariantAlias = (preferredSet, manifestAlias) => { if (!manifestAlias || preferredSet.size === 0) return false; const lo = manifestAlias.trim().toLowerCase(); if (preferredSet.has(lo)) return true; return [...preferredSet].some((p) => p.toLowerCase() === lo); }; const buildIngredientCoverSprite = (iconPx, sheet) => { if (!iconPx || typeof iconPx.x !== "number" || typeof iconPx.y !== "number" || !sheet) { return null; } const col = Math.round(iconPx.x / INGREDIENT_SPRITE_CELL); const row = Math.round(iconPx.y / INGREDIENT_SPRITE_CELL); return { sheet: "/ingredients.png", col, row, cell: INGREDIENT_SPRITE_CELL, sheetWidth: sheet.width, sheetHeight: sheet.height }; }; export { parseIngredientSpritesBentext, resolveIconPxForIngredientName, aliasToIconPx, findAliasForIconPx, isPreferredVariantAlias, buildIngredientCoverSprite, normalizeIngredientAliasKey, foldAccents };