diff --git a/editor/app.js b/editor/app.js index 8946d2a768..45d88c810d 100644 --- a/editor/app.js +++ b/editor/app.js @@ -2533,33 +2533,279 @@ async function renderMapDetail(dirName) { header.closest('.npc-card').classList.toggle('expanded'); }); }); + + // Initialize interactive map markers + initInteractiveMap(map); } -// ── Map Preview Section ── +// ── Interactive Map Section ── function buildMapPreviewSection(map) { const fullUrl = getFullPreviewUrl(map._dirName); const thumbUrl = getPreviewUrl(map._dirName); + const dirName = map._dirName; + + // Collect all plottable entities + const npcs = (map.object_events || []).filter(e => + !(e.graphics_id || '').includes('ITEM_BALL') && !(e.graphics_id || '').includes('BERRY_TREE') + ); + const itemBalls = getMapItemBalls(map); + const hiddenItems = getMapHiddenItems(map); + const warps = map.warp_events || []; + const signs = (map.bg_events || []).filter(e => e.type === 'sign'); + const coordEvents = map.coord_events || []; + + // Legend items + const legendItems = []; + if (npcs.length) legendItems.push(` NPCs (${npcs.length})`); + if (itemBalls.length) legendItems.push(` Items (${itemBalls.length})`); + if (hiddenItems.length) legendItems.push(` Hidden (${hiddenItems.length})`); + if (warps.length) legendItems.push(` Doors (${warps.length})`); + if (signs.length) legendItems.push(` Signs (${signs.length})`); + return `
-

🗺 Map Preview

+

🗺 Interactive Map

-
- Map preview of ${escAttr(getMapDisplayName(map))} -
- Full-size map image (pixel-accurate rendering from tileset data) +
+
${legendItems.join('')}
+
+ Interactive map of ${escAttr(getMapDisplayName(map))} +
+
Click a marker to edit. Drag to reposition.
`; } +// Place interactive markers on the map image once it loads +function initInteractiveMap(map) { + const container = $('#imap-container'); + const img = $('#imap-img'); + const markersEl = $('#imap-markers'); + if (!container || !img || !markersEl) return; + + const dirName = map._dirName; + + function placeMarkers() { + markersEl.innerHTML = ''; + const imgW = img.naturalWidth; + const imgH = img.naturalHeight; + if (!imgW || !imgH) return; + + // Compute tile size: GBA maps use 16px tiles in the source images + const TILE = 16; + + // Collect all entities with positions + const entities = []; + + // NPCs (non-item, non-berry) + (map.object_events || []).forEach((evt, i) => { + if ((evt.graphics_id || '').includes('ITEM_BALL')) { + entities.push({ type: 'item', x: evt.x, y: evt.y, idx: i, evt, label: (evt.trainer_sight_or_berry_tree_id || '').replace('ITEM_', '').replace(/_/g, ' ') }); + } else if ((evt.graphics_id || '').includes('BERRY_TREE')) { + // Skip berry trees + } else { + const isTrainer = evt.trainer_type && evt.trainer_type !== 'TRAINER_TYPE_NONE'; + let name = (evt.graphics_id || '').replace('OBJ_EVENT_GFX_', '').replace(/_/g, ' '); + if (evt.script && evt.script !== '0x0') { + const parts = evt.script.split('_EventScript_'); + if (parts.length >= 2) name = parts[parts.length - 1].replace(/_/g, ' '); + } + entities.push({ type: isTrainer ? 'trainer' : 'npc', x: evt.x, y: evt.y, idx: i, evt, label: name }); + } + }); + + // Hidden items + (map.bg_events || []).forEach((evt, i) => { + if (evt.type === 'hidden_item') { + entities.push({ type: 'hidden', x: evt.x, y: evt.y, bgIdx: i, evt, label: (evt.item || '').replace('ITEM_', '').replace(/_/g, ' ') }); + } else if (evt.type === 'sign') { + entities.push({ type: 'sign', x: evt.x, y: evt.y, bgIdx: i, evt, label: 'Sign' }); + } + }); + + // Warps + (map.warp_events || []).forEach((evt, i) => { + const dest = (evt.dest_map || '').replace('MAP_', '').replace(/_/g, ' '); + entities.push({ type: 'warp', x: evt.x, y: evt.y, warpIdx: i, evt, label: dest || 'Warp' }); + }); + + for (const ent of entities) { + const marker = document.createElement('div'); + marker.className = `imap-marker imap-marker-${ent.type}`; + // Position as percentage relative to image size + // +7 offset: maps typically have a 7-tile border on each side + const px = ((ent.x + 7) * TILE + TILE / 2) / imgW * 100; + const py = ((ent.y + 7) * TILE + TILE / 2) / imgH * 100; + marker.style.left = px + '%'; + marker.style.top = py + '%'; + marker.title = `${ent.type.toUpperCase()}: ${ent.label} (${ent.x}, ${ent.y})`; + marker.dataset.entityType = ent.type; + marker.dataset.x = ent.x; + marker.dataset.y = ent.y; + + // Icon inside marker + const icons = { npc: '\u263A', trainer: '\u2694', item: '\u2666', hidden: '\u2733', warp: '\uD83D\uDEAA', sign: '\uD83D\uDCCB' }; + marker.innerHTML = icons[ent.type] || '\u2022'; + + // Click to open edit menu + marker.addEventListener('click', (e) => { + e.stopPropagation(); + openMarkerMenu(ent, map, marker); + }); + + // Drag to reposition + setupMarkerDrag(marker, ent, map, img, TILE); + + markersEl.appendChild(marker); + } + } + + if (img.complete && img.naturalWidth) { + placeMarkers(); + } else { + img.addEventListener('load', placeMarkers); + } + // Recompute on resize + const observer = new ResizeObserver(() => placeMarkers()); + observer.observe(container); + // Store cleanup ref + container._resizeObserver = observer; +} + +function setupMarkerDrag(marker, entity, map, img, TILE) { + let isDragging = false; + let startX, startY; + const DRAG_THRESHOLD = 4; + + marker.addEventListener('mousedown', (e) => { + if (e.button !== 0) return; + isDragging = false; + startX = e.clientX; + startY = e.clientY; + marker.classList.add('imap-marker-dragging'); + + const onMove = (e2) => { + const dx = e2.clientX - startX; + const dy = e2.clientY - startY; + if (Math.abs(dx) > DRAG_THRESHOLD || Math.abs(dy) > DRAG_THRESHOLD) { + isDragging = true; + // Move marker visually + const rect = img.getBoundingClientRect(); + const pxX = ((e2.clientX - rect.left) / rect.width) * 100; + const pxY = ((e2.clientY - rect.top) / rect.height) * 100; + marker.style.left = Math.max(0, Math.min(100, pxX)) + '%'; + marker.style.top = Math.max(0, Math.min(100, pxY)) + '%'; + } + }; + + const onUp = (e2) => { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + marker.classList.remove('imap-marker-dragging'); + + if (isDragging) { + // Calculate new tile coords + const rect = img.getBoundingClientRect(); + const relX = (e2.clientX - rect.left) / rect.width; + const relY = (e2.clientY - rect.top) / rect.height; + const newX = Math.round((relX * img.naturalWidth) / TILE - 7 - 0.5); + const newY = Math.round((relY * img.naturalHeight) / TILE - 7 - 0.5); + + if (newX !== entity.x || newY !== entity.y) { + entity.evt.x = newX; + entity.evt.y = newY; + entity.x = newX; + entity.y = newY; + const serialized = { ...map }; + delete serialized._dirName; + markChanged(`data/maps/${map._dirName}/map.json`, JSON.stringify(serialized, null, 2) + '\n'); + toast(`Moved ${entity.type} to (${newX}, ${newY})`); + renderMapDetail(map._dirName); + } + } + }; + + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + e.preventDefault(); + }); +} + +function openMarkerMenu(entity, map, markerEl) { + // Remove any existing popup + $$('.imap-popup').forEach(p => p.remove()); + + const popup = document.createElement('div'); + popup.className = 'imap-popup'; + + const dirName = map._dirName; + let buttonsHtml = ''; + + if (entity.type === 'npc' || entity.type === 'trainer') { + buttonsHtml = ` + + ${entity.type === 'trainer' ? `` : ''} + + `; + } else if (entity.type === 'item') { + const itemIdx = getMapItemBalls(map).indexOf(entity.evt); + buttonsHtml = ` + + + `; + } else if (entity.type === 'warp') { + buttonsHtml = ` + + + `; + } else if (entity.type === 'hidden') { + const hiddenIdx = (map.bg_events || []).filter(e => e.type === 'hidden_item').indexOf(entity.evt); + buttonsHtml = ` + + + `; + } else if (entity.type === 'sign') { + buttonsHtml = `Sign at (${entity.x}, ${entity.y})`; + } + + popup.innerHTML = ` +
+ ${escHtml(entity.label)} + ${entity.type} +
+
(${entity.x}, ${entity.y})
+
${buttonsHtml}
+ `; + + // Position near the marker + const container = $('#imap-container'); + const markerRect = markerEl.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + popup.style.left = (markerRect.left - containerRect.left + markerRect.width / 2) + 'px'; + popup.style.top = (markerRect.top - containerRect.top - 8) + 'px'; + + container.appendChild(popup); + + // Close on click elsewhere + const closeHandler = (e) => { + if (!popup.contains(e.target) && !markerEl.contains(e.target)) { + popup.remove(); + document.removeEventListener('click', closeHandler); + } + }; + setTimeout(() => document.addEventListener('click', closeHandler), 0); +} + // ── Wild Encounters Section ── function buildEncounterSection(enc, encounterRates, map) { const fieldTypes = ['land_mons', 'water_mons', 'rock_smash_mons', 'fishing_mons']; @@ -4782,6 +5028,12 @@ function collectNPCs() { } async function renderNPCs() { + // If viewing a specific NPC group, render detail + if (state.npcDetail) { + await renderNPCGroupDetail(state.npcDetail); + return; + } + const maps = await loadMaps(); try { await loadTrainers(); } catch {} @@ -4796,14 +5048,15 @@ async function renderNPCs() { (n._mapId || '').toLowerCase().includes(search); }); - // Group by graphics_id for a summary view + // Group by graphics_id const groups = {}; for (const n of filtered) { const key = n.graphics_id || 'UNKNOWN'; - if (!groups[key]) groups[key] = { graphics_id: key, count: 0, maps: new Set(), npcs: [] }; + if (!groups[key]) groups[key] = { graphics_id: key, count: 0, maps: new Set(), npcs: [], hasTrainer: false }; groups[key].count++; groups[key].maps.add(n._mapName); groups[key].npcs.push(n); + if (n.trainer_type && n.trainer_type !== 'TRAINER_TYPE_NONE') groups[key].hasTrainer = true; } const sortedGroups = Object.values(groups).sort((a, b) => b.count - a.count); @@ -4821,59 +5074,139 @@ async function renderNPCs() { const list = $('#npc-list'); - // Show individual NPC events in a table-like view - const maxShow = 200; - const toShow = filtered.slice(0, maxShow); + // Group view: each graphics_id gets a card. Click to expand into individual entries. + const maxGroups = 100; + const groupsToShow = sortedGroups.slice(0, maxGroups); - let rows = toShow.map((n, i) => { - const gfx = (n.graphics_id || '').replace('OBJ_EVENT_GFX_', '').replace(/_/g, ' '); - const isTrainer = n.trainer_type && n.trainer_type !== 'TRAINER_TYPE_NONE'; - const scriptShort = (n.script || '').replace(/_EventScript_/g, ' ').replace(/_/g, ' '); + let groupCards = groupsToShow.map(g => { + const gfx = g.graphics_id.replace('OBJ_EVENT_GFX_', '').replace(/_/g, ' '); + const mapList = [...g.maps].slice(0, 3).join(', ') + (g.maps.size > 3 ? ` +${g.maps.size - 3} more` : ''); + const trainerCount = g.npcs.filter(n => n.trainer_type && n.trainer_type !== 'TRAINER_TYPE_NONE').length; + + // If only 1 NPC, show inline with direct actions + if (g.count === 1) { + const n = g.npcs[0]; + const isTrainer = n.trainer_type && n.trainer_type !== 'TRAINER_TYPE_NONE'; + return ` +
+
+ ${getSpriteHtml(g.graphics_id, 40)} +
+
${escHtml(gfx)}${isTrainer ? ' Trainer' : ''}
+
${escHtml(n._mapName)} · (${n.x}, ${n.y})
+
+
+ + ${isTrainer ? `` : ''} + +
+
+
+ `; + } + + // Multiple NPCs: show as group card that can be clicked into return ` -
- ${getSpriteHtml(n.graphics_id, 36)} -
-
${escHtml(gfx)}${isTrainer ? ' TRAINER' : ''}
-
${escHtml(n.script || 'No script')}
-
-
- ${escHtml(n._mapName)} -
-
(${n.x}, ${n.y})
-
- - ${isTrainer ? `` : ''} +
+
+ ${getSpriteHtml(g.graphics_id, 40)} +
+
${escHtml(gfx)}
+
${g.count} instances across ${g.maps.size} map${g.maps.size !== 1 ? 's' : ''}
+
${escHtml(mapList)}
+
+
+ ${g.count} + ${trainerCount > 0 ? `${trainerCount} trainer${trainerCount !== 1 ? 's' : ''}` : ''} +
+
`; }).join(''); - if (filtered.length > maxShow) { - rows += `
Showing ${maxShow} of ${filtered.length} NPCs. Use search to narrow down.
`; + if (sortedGroups.length > maxGroups) { + groupCards += `
Showing ${maxGroups} of ${sortedGroups.length} NPC types. Use search to narrow down.
`; } - list.innerHTML = ` -
-
-
-
Name / Script
-
Map
-
Position
-
-
- ${rows || '
No NPCs found matching your search.
'} -
- `; + list.innerHTML = groupCards || '
No NPCs found matching your search.
'; $('#npc-search').addEventListener('input', async e => { const pos = e.target.selectionStart; state.search = e.target.value; + state.npcDetail = null; await renderNPCs(); const el = $('#npc-search'); if (el) { el.focus(); el.selectionStart = el.selectionEnd = pos; } }); } +// Detail view for a specific NPC graphics_id group +async function renderNPCGroupDetail(graphicsId) { + const allNPCs = collectNPCs(); + const groupNPCs = allNPCs.filter(n => n.graphics_id === graphicsId); + const gfx = graphicsId.replace('OBJ_EVENT_GFX_', '').replace(/_/g, ' '); + + content.innerHTML = ` + + +
+ `; + + const list = $('#npc-group-list'); + + // Group by map for better organization + const byMap = {}; + for (const n of groupNPCs) { + if (!byMap[n._mapDirName]) byMap[n._mapDirName] = { name: n._mapName, npcs: [] }; + byMap[n._mapDirName].npcs.push(n); + } + + let html = ''; + for (const [dirName, mapGroup] of Object.entries(byMap)) { + html += `
+
+ ${escHtml(mapGroup.name)} + ${mapGroup.npcs.length} +
`; + + for (const n of mapGroup.npcs) { + const isTrainer = n.trainer_type && n.trainer_type !== 'TRAINER_TYPE_NONE'; + let npcName = gfx; + if (n.script && n.script !== '0x0') { + const parts = n.script.split('_EventScript_'); + if (parts.length >= 2) npcName = parts[parts.length - 1].replace(/_/g, ' '); + } + + html += ` +
+ ${getSpriteHtml(n.graphics_id, 36)} +
+
${escHtml(npcName)}${isTrainer ? ' TRAINER' : ''}
+
${escHtml(n.script || 'No script')}
+
+
(${n.x}, ${n.y})
+
+ + ${isTrainer ? `` : ''} +
+
+ `; + } + html += '
'; + } + + list.innerHTML = html || '
No NPCs found.
'; +} + function editNPCFromList(dirName, script, x, y) { const map = state.maps.find(m => m._dirName === dirName); if (!map) return; @@ -4922,9 +5255,30 @@ function parsePokemonSpecies(text) { const growthM = body.match(/\.growthRate\s*=\s*(\w+)/); const eggM = body.match(/\.eggGroups\s*=\s*MON_EGG_GROUPS\((\w+)(?:,\s*(\w+))?\)/); const learnsetM = body.match(/\.levelUpLearnset\s*=\s*(\w+)/); + const evosM = body.match(/\.evolutions\s*=\s*EVOLUTION\((.+)\)\s*,/); if (nameM) mon.name = nameM[1]; if (learnsetM) mon.learnsetVar = learnsetM[1]; + + // Parse evolution data + if (evosM) { + mon.evolutions = []; + const evoStr = evosM[1]; + // Match each {EVO_METHOD, param, SPECIES_TARGET, ...} block + const evoBlockRegex = /\{(EVO_\w+),\s*([^,}]+),\s*(SPECIES_\w+)(?:,\s*CONDITIONS\(([^)]+)\))?\}/g; + let evoMatch; + while ((evoMatch = evoBlockRegex.exec(evoStr)) !== null) { + const evo = { + method: evoMatch[1], + param: evoMatch[2].trim(), + target: evoMatch[3], + }; + if (evoMatch[4]) { + evo.conditions = evoMatch[4].trim(); + } + mon.evolutions.push(evo); + } + } if (hpM) mon.baseHP = parseInt(hpM[1]); if (atkM) mon.baseAttack = parseInt(atkM[1]); if (defM) mon.baseDefense = parseInt(defM[1]); @@ -4978,6 +5332,32 @@ async function loadPokemonSpecies() { return state.pokemon; } +function formatEvoMethod(evo) { + const method = (evo.method || '').replace('EVO_', ''); + const param = (evo.param || '').replace('ITEM_', '').replace(/_/g, ' '); + const conditions = (evo.conditions || '').replace(/\{IF_/g, '').replace(/}/g, '').replace(/_/g, ' ').replace(/,\s*/g, ': '); + const labels = { + 'LEVEL': `Level ${param}`, + 'ITEM': `Use ${param}`, + 'TRADE': 'Trade', + 'TRADE_ITEM': `Trade holding ${param}`, + 'FRIENDSHIP': 'Friendship', + 'LEVEL_NIGHT': `Level ${param} (Night)`, + 'LEVEL_DAY': `Level ${param} (Day)`, + 'BEAUTY': `Beauty ${param}`, + 'ITEM_HOLD_DAY': `Hold ${param} (Day)`, + 'ITEM_HOLD_NIGHT': `Hold ${param} (Night)`, + 'MOVE': `Know ${param}`, + 'MOVE_TYPE': `Know ${param}-type move`, + 'SPECIFIC_MON_IN_PARTY': `${param} in party`, + 'LEVEL_RAIN': `Level ${param} (Rain)`, + 'LEVEL_DARK_TYPE_MON_IN_PARTY': `Level ${param} (Dark-type in party)`, + }; + let result = labels[method] || `${method} ${param}`.trim(); + if (conditions) result += ` [${conditions}]`; + return result; +} + function updatePokemonInFile(mon) { const filePath = `src/data/pokemon/species_info/${mon._file}`; let fileContent = pendingChanges[filePath] || originalContent[filePath]; @@ -5324,6 +5704,17 @@ async function renderPokemonPage() { const totalPages = Math.ceil(filtered.length / perPage); const pageItems = filtered.slice(page * perPage, (page + 1) * perPage); + // Build evolution lookup: species -> what evolves INTO it (prevolutions) + const prevolutionMap = {}; + for (const p of pokemon) { + if (p.evolutions) { + for (const evo of p.evolutions) { + if (!prevolutionMap[evo.target]) prevolutionMap[evo.target] = []; + prevolutionMap[evo.target].push({ from: p.id, fromName: p.name, method: evo.method, param: evo.param, conditions: evo.conditions }); + } + } + } + content.innerHTML = ` @@ -5488,6 +5902,19 @@ function editPokemon(id) {
+ ${mon.evolutions && mon.evolutions.length > 0 ? ` +
Evolution
+
+ ${mon.evolutions.map(e => { + const targetMon = (state.pokemon || []).find(p => p.id === e.target); + const targetName = targetMon ? targetMon.name : e.target.replace('SPECIES_', ''); + return `
+ + ${escHtml(targetName)} + ${escHtml(formatEvoMethod(e))} +
`; + }).join('')} +
` : ''}
BST: ${mon.bst || 0} · File: ${escHtml(mon._file || '')}
diff --git a/editor/style.css b/editor/style.css index a3f4fc7f6d..10529dade2 100644 --- a/editor/style.css +++ b/editor/style.css @@ -1788,3 +1788,283 @@ tbody tr:last-child td { border-bottom: none; } display: flex; align-items: center; } + +/* ─── Interactive Map ───────────────────────── */ +.imap-container { + position: relative; + display: inline-block; + width: 100%; + border-radius: 6px; + border: 1px solid var(--border); + overflow: hidden; + background: var(--bg); +} +.imap-container img { + display: block; + width: 100%; + height: auto; + image-rendering: pixelated; +} +.imap-markers { + position: absolute; + inset: 0; + pointer-events: none; +} +.imap-marker { + position: absolute; + width: 20px; + height: 20px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 10px; + transform: translate(-50%, -50%); + pointer-events: auto; + cursor: pointer; + border: 2px solid rgba(255,255,255,0.8); + box-shadow: 0 1px 4px rgba(0,0,0,0.5); + transition: transform 0.1s, box-shadow 0.1s; + z-index: 2; +} +.imap-marker:hover { + transform: translate(-50%, -50%) scale(1.3); + box-shadow: 0 2px 8px rgba(0,0,0,0.7); + z-index: 10; +} +.imap-marker-dragging { + opacity: 0.7; + z-index: 20 !important; + cursor: grabbing; +} +.imap-marker-npc { background: var(--accent); color: #fff; } +.imap-marker-trainer { background: var(--red); color: #fff; } +.imap-marker-item { background: var(--yellow); color: #333; } +.imap-marker-hidden { background: var(--purple); color: #fff; } +.imap-marker-warp { background: var(--cyan); color: #fff; font-size: 9px; } +.imap-marker-sign { background: var(--orange); color: #fff; font-size: 9px; } + +.imap-legend { + display: flex; + gap: 14px; + margin-bottom: 10px; + flex-wrap: wrap; + font-size: 12px; + color: var(--text-dim); +} +.imap-legend-item { + display: flex; + align-items: center; + gap: 5px; +} +.imap-legend-dot { + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; +} +.imap-dot-npc { background: var(--accent); } +.imap-dot-item { background: var(--yellow); } +.imap-dot-hidden { background: var(--purple); } +.imap-dot-warp { background: var(--cyan); } +.imap-dot-sign { background: var(--orange); } +.imap-dot-trainer { background: var(--red); } + +.imap-hint { + text-align: center; + font-size: 11px; + color: var(--text-dim); + margin-top: 8px; +} + +/* Interactive Map Popup */ +.imap-popup { + position: absolute; + transform: translate(-50%, -100%); + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 6px; + padding: 10px 14px; + min-width: 180px; + box-shadow: 0 4px 16px rgba(0,0,0,0.5); + z-index: 100; + pointer-events: auto; +} +.imap-popup::after { + content: ''; + position: absolute; + left: 50%; + top: 100%; + transform: translateX(-50%); + border: 6px solid transparent; + border-top-color: var(--border); +} +.imap-popup-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 4px; + font-size: 13px; +} +.imap-popup-type { + font-size: 9px; + text-transform: uppercase; + font-weight: 600; + padding: 1px 6px; + border-radius: 8px; + color: #fff; +} +.imap-popup-coords { + font-size: 11px; + color: var(--text-dim); + font-family: monospace; + margin-bottom: 8px; +} +.imap-popup-actions { + display: flex; + gap: 4px; + flex-wrap: wrap; +} + +/* ─── NPC Group Cards ───────────────────────── */ +.npc-group-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 6px; + margin-bottom: 8px; + transition: border-color 0.15s; + overflow: hidden; +} +.npc-group-card:hover { border-color: var(--accent); } +.npc-group-card:last-child { margin-bottom: 0; } +.npc-group-header { + display: flex; + align-items: center; + gap: 14px; + padding: 12px 16px; +} +.npc-group-info { + flex: 1; + min-width: 0; +} +.npc-group-name { + font-size: 14px; + font-weight: 600; + display: flex; + align-items: center; + gap: 8px; +} +.npc-group-meta { + font-size: 12px; + color: var(--text-dim); + margin-top: 2px; +} +.npc-group-maps { + font-size: 11px; + color: var(--text-dim); + margin-top: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.npc-group-badges { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} +.npc-group-count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 28px; + height: 28px; + border-radius: 14px; + background: var(--bg); + font-size: 13px; + font-weight: 700; + color: var(--accent); +} +.npc-group-arrow { + font-size: 12px; + color: var(--text-dim); + flex-shrink: 0; +} +.npc-group-actions { + display: flex; + gap: 4px; + flex-shrink: 0; +} + +/* NPC Group Detail — Map Section */ +.npc-group-map-section { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 6px; + margin-bottom: 12px; + overflow: hidden; +} +.npc-group-map-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 16px; + background: var(--bg-hover); + border-bottom: 1px solid var(--border); + font-size: 13px; + font-weight: 600; + color: var(--accent); +} +.npc-group-map-header:hover { text-decoration: underline; } +.npc-group-map-count { + font-size: 11px; + color: var(--text-dim); + font-weight: 400; +} + +/* ─── Evolution Display ─────────────────────── */ +.evo-pill { + display: inline-block; + padding: 1px 8px; + border-radius: 10px; + font-size: 11px; + font-weight: 500; + background: rgba(79,143,247,0.15); + color: var(--accent); + white-space: nowrap; + cursor: default; +} +.evo-pill-from { + background: rgba(168,85,247,0.15); + color: var(--purple); +} +.evo-chain-display { + display: flex; + flex-direction: column; + gap: 8px; +} +.evo-chain-entry { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + font-size: 13px; +} +.evo-chain-arrow { + color: var(--accent); + font-size: 16px; + flex-shrink: 0; +} +.evo-chain-target { + font-weight: 600; + color: var(--text); +} +.evo-chain-method { + font-size: 12px; + color: var(--text-dim); + margin-left: auto; +}