document.addEventListener('DOMContentLoaded', () => { const DEFAULT_GRADIENT = { from: '#667eea', to: '#764ba2' }; let townsData = []; const grid = document.getElementById('towns-list'); const noResults = document.getElementById('no-results'); const scaleFilters = document.getElementById('scale-filters'); const typeFilters = document.getElementById('type-filters'); const recruitFilters = document.getElementById('recruit-filters'); const searchInput = document.getElementById('town-search'); // Modal Elements const modal = document.getElementById('town-modal'); const closeModalBtn = modal.querySelector('.close-modal'); // Initial State let currentFilters = { scale: 'all', townType: 'all', recruitment: 'all', search: '' }; let currentDetailItem = null; // Generate stable anchor ID for a town function generateTownId(item) { var raw = (item.title || ''); var hash = 0; for (var i = 0; i < raw.length; i++) { hash = ((hash << 5) - hash) + raw.charCodeAt(i); hash |= 0; } return 't' + Math.abs(hash).toString(36); } // Handle URL hash: auto-open town modal function handleHashNavigation() { var hash = location.hash.replace('#', ''); if (!hash) return; for (var i = 0; i < townsData.length; i++) { if (generateTownId(townsData[i]) === hash) { openModal(townsData[i]); return; } } } // 1. Fetch Data fetch('data/towns.json') .then(response => response.json()) .then(data => { townsData = data; renderGrid(); handleHashNavigation(); }) .catch(err => { console.error('Error loading towns:', err); grid.innerHTML = '
无法加载城镇数据。
'; }); // 2. Event Listeners // Scale Filter scaleFilters.addEventListener('click', (e) => { if (e.target.tagName === 'BUTTON') { Array.from(scaleFilters.children).forEach(btn => btn.classList.remove('active')); e.target.classList.add('active'); currentFilters.scale = e.target.dataset.filter; renderGrid(); } }); // Type Filter typeFilters.addEventListener('click', (e) => { if (e.target.tagName === 'BUTTON') { Array.from(typeFilters.children).forEach(btn => btn.classList.remove('active')); e.target.classList.add('active'); currentFilters.townType = e.target.dataset.filter; renderGrid(); } }); // Recruit Filter recruitFilters.addEventListener('click', (e) => { if (e.target.tagName === 'BUTTON') { Array.from(recruitFilters.children).forEach(btn => btn.classList.remove('active')); e.target.classList.add('active'); currentFilters.recruitment = e.target.dataset.filter; renderGrid(); } }); // Search searchInput.addEventListener('input', (e) => { currentFilters.search = e.target.value.toLowerCase().trim(); renderGrid(); }); // Modal Close closeModalBtn.addEventListener('click', () => { modal.style.display = 'none'; document.body.style.overflow = 'auto'; history.replaceState(null, '', location.pathname + location.search); }); window.addEventListener('click', (e) => { if (e.target === modal) { modal.style.display = 'none'; document.body.style.overflow = 'auto'; history.replaceState(null, '', location.pathname + location.search); } }); // 3. Render Functions function renderGrid() { grid.innerHTML = ''; const filtered = townsData.filter(item => { const matchScale = currentFilters.scale === 'all' || item.scale === currentFilters.scale; const matchType = currentFilters.townType === 'all' || item.townType === currentFilters.townType; const matchRecruit = currentFilters.recruitment === 'all' || item.recruitment === currentFilters.recruitment; const matchSearch = !currentFilters.search || item.title.toLowerCase().includes(currentFilters.search); return matchScale && matchType && matchRecruit && matchSearch; }); if (filtered.length === 0) { noResults.classList.remove('is-hidden'); return; } else { noResults.classList.add('is-hidden'); } filtered.forEach(item => { const card = document.createElement('div'); card.className = 'town-card'; card.onclick = () => openModal(item); const hasLogo = item.logo && item.logo.trim() !== ''; const gradient = getTownGradient(item); // Build card icon badges (scale + type + recruitment) let iconsHtml = ''; iconsHtml += ''; card.innerHTML = '无
'; return; } list.forEach(function(block) { if (block.type === 'text') { var p = document.createElement('p'); p.innerText = block.content; container.appendChild(p); } else if (block.type === 'image') { var img = document.createElement('img'); img.src = block.content; img.loading = 'lazy'; container.appendChild(img); } else if (block.type === 'video') { var bv = parseBVNumber(block.content); if (bv) { var wrapper = document.createElement('div'); wrapper.className = 'video-embed-wrapper'; var iframe = document.createElement('iframe'); iframe.src = 'https://player.bilibili.com/player.html?bvid=' + bv + '&autoplay=0&high_quality=1'; iframe.allowFullscreen = true; iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-popups'); iframe.loading = 'lazy'; wrapper.appendChild(iframe); container.appendChild(wrapper); } else { var p = document.createElement('p'); p.className = 'text-secondary'; p.innerText = '无效的视频 BV 号'; container.appendChild(p); } } }); } function parseBVNumber(input) { if (!input) return null; input = input.trim(); var bvPattern = /^(BV[A-Za-z0-9]+)$/; var directMatch = input.match(bvPattern); if (directMatch) return directMatch[1]; var urlPattern = /bilibili\.com\/video\/(BV[A-Za-z0-9]+)/; var urlMatch = input.match(urlPattern); if (urlMatch) return urlMatch[1]; var generalPattern = /(BV[A-Za-z0-9]{10,})/; var generalMatch = input.match(generalPattern); if (generalMatch) return generalMatch[1]; return null; } // Helpers function getScaleText(scale) { var map = { 'small': '小型(5人以下)', 'medium': '中型(2-10人)', 'large': '大型(10人以上)' }; return map[scale] || scale; } function getScaleIcon(scale) { var map = { 'small': 'fa-user', 'medium': 'fa-users', 'large': 'fa-city' }; return map[scale] || 'fa-users'; } function getTownTypeText(type) { var map = { 'building': '建筑', 'adventure': '冒险', 'industry': '工业' }; return map[type] || type; } function getTownTypeIcon(type) { var map = { 'building': 'fa-building', 'adventure': 'fa-dragon', 'industry': 'fa-industry' }; return map[type] || 'fa-building'; } function getRecruitText(recruitment) { var map = { 'welcome': '欢迎加入', 'closed': '暂不招人', 'maybe': '可以考虑' }; return map[recruitment] || recruitment; } function getRecruitIcon(recruitment) { var map = { 'welcome': 'fa-door-open', 'closed': 'fa-door-closed', 'maybe': 'fa-question-circle' }; return map[recruitment] || 'fa-info-circle'; } function normalizeHexColor(value, fallback) { if (!value || typeof value !== 'string') return fallback; var trimmed = value.trim(); if (/^#[0-9a-fA-F]{6}$/.test(trimmed)) return trimmed; return fallback; } function getTownGradient(item) { var gradient = item && item.gradient ? item.gradient : {}; return { from: normalizeHexColor(gradient.from, DEFAULT_GRADIENT.from), to: normalizeHexColor(gradient.to, DEFAULT_GRADIENT.to) }; } function buildGradientBackgroundValue(gradient) { return 'linear-gradient(135deg, ' + gradient.from + ' 0%, ' + gradient.to + ' 100%)'; } function buildGradientBackgroundStyle(gradient) { return 'background:' + buildGradientBackgroundValue(gradient) + ';'; } // Share town link document.getElementById('btn-share-town').addEventListener('click', function() { if (!currentDetailItem) return; var anchorId = generateTownId(currentDetailItem); var url = location.origin + location.pathname + '#' + anchorId; var btn = document.getElementById('btn-share-town'); navigator.clipboard.writeText(url).then(function() { btn.innerHTML = ' 已复制链接'; btn.classList.add('shared'); setTimeout(function() { btn.innerHTML = ' 分享'; btn.classList.remove('shared'); }, 2000); }).catch(function() { var tmp = document.createElement('input'); tmp.value = url; document.body.appendChild(tmp); tmp.select(); document.execCommand('copy'); document.body.removeChild(tmp); btn.innerHTML = ' 已复制链接'; setTimeout(function() { btn.innerHTML = ' 分享'; }, 2000); }); }); // Open editor from detail modal document.getElementById('btn-edit-town').addEventListener('click', function() { if (currentDetailItem) { modal.style.display = 'none'; document.body.style.overflow = 'auto'; openEditor(currentDetailItem); } }); // ========== Editor Modal Logic ========== var editorModal = document.getElementById('town-editor-modal'); var jsonOutputModal = document.getElementById('town-json-output-modal'); var closeEditorModalBtn = editorModal.querySelector('.close-editor-modal'); var closeJsonModalBtn = jsonOutputModal.querySelector('.close-json-modal'); // Open empty editor for new town document.getElementById('btn-add-town').addEventListener('click', function() { openEditor(null); }); // Close editor modal closeEditorModalBtn.addEventListener('click', function() { editorModal.style.display = 'none'; document.body.style.overflow = 'auto'; }); window.addEventListener('click', function(e) { if (e.target === editorModal) { editorModal.style.display = 'none'; document.body.style.overflow = 'auto'; } if (e.target === jsonOutputModal) { jsonOutputModal.style.display = 'none'; } }); closeJsonModalBtn.addEventListener('click', function() { jsonOutputModal.style.display = 'none'; }); // State for editor var editorFounders = []; var editorMembers = []; var editorIntroduction = []; // Initialize custom selects editorModal.querySelectorAll('.custom-select').forEach(function(select) { var trigger = select.querySelector('.custom-select-trigger'); var options = select.querySelectorAll('.custom-option'); var input = select.querySelector('input[type="hidden"]'); var text = select.querySelector('.custom-select-text'); trigger.addEventListener('click', function(e) { e.stopPropagation(); var isOpen = select.classList.contains('open'); editorModal.querySelectorAll('.custom-select').forEach(function(s) { s.classList.remove('open'); }); if (!isOpen) { select.classList.add('open'); } }); options.forEach(function(option) { option.addEventListener('click', function(e) { e.stopPropagation(); options.forEach(function(opt) { opt.classList.remove('selected'); }); option.classList.add('selected'); text.innerText = option.innerText; input.value = option.dataset.value; input.dispatchEvent(new Event('change')); select.classList.remove('open'); }); }); }); document.addEventListener('click', function() { editorModal.querySelectorAll('.custom-select').forEach(function(s) { s.classList.remove('open'); }); }); function setCustomSelectValue(id, value) { var input = document.getElementById(id); if (!input) return; var select = input.closest('.custom-select'); var option = select.querySelector('.custom-option[data-value="' + value + '"]'); if (option) { input.value = value; select.querySelector('.custom-select-text').innerText = option.innerText; select.querySelectorAll('.custom-option').forEach(function(opt) { opt.classList.remove('selected'); }); option.classList.add('selected'); } } function openEditor(item) { var gradient = getTownGradient(item || {}); editorFounders = item ? item.founders.slice() : []; editorMembers = item ? item.members.slice() : []; editorIntroduction = item ? item.introduction.map(function(i) { return {type: i.type, content: i.content}; }) : []; document.getElementById('editor-town-title').value = item ? item.title : ''; document.getElementById('editor-town-logo').value = item ? (item.logo || '') : ''; document.getElementById('editor-town-gradient-from').value = gradient.from; document.getElementById('editor-town-gradient-to').value = gradient.to; setCustomSelectValue('editor-town-scale', item ? item.scale : 'small'); setCustomSelectValue('editor-town-type', item ? item.townType : 'building'); setCustomSelectValue('editor-town-recruit', item ? item.recruitment : 'welcome'); document.getElementById('editor-town-x').value = item ? item.coordinates.x : ''; document.getElementById('editor-town-y').value = item ? item.coordinates.y : ''; document.getElementById('editor-town-z').value = item ? item.coordinates.z : ''; renderTagsList('editor-founders-tags', editorFounders); renderTagsList('editor-members-tags', editorMembers); renderSortableList('editor-introduction-list', editorIntroduction); updatePreview(); editorModal.style.display = 'block'; document.body.style.overflow = 'hidden'; } // --- Tags input helpers --- function renderTagsList(containerId, list) { var container = document.getElementById(containerId); container.innerHTML = ''; list.forEach(function(name, idx) { var tag = document.createElement('span'); tag.className = 'editor-tag'; tag.innerHTML = escapeHtml(name) + ' '; container.appendChild(tag); }); } function commitTagInput(inputId, list, tagsContainerId) { var input = document.getElementById(inputId); var value = input.value.trim(); if (value && list.indexOf(value) === -1) { list.push(value); renderTagsList(tagsContainerId, list); updatePreview(); } input.value = ''; } // Founders tags document.getElementById('editor-founders-tags').addEventListener('click', function(e) { var removeBtn = e.target.closest('.editor-tag-remove'); if (removeBtn) { var idx = parseInt(removeBtn.dataset.idx); editorFounders.splice(idx, 1); renderTagsList('editor-founders-tags', editorFounders); updatePreview(); } }); document.getElementById('editor-founder-input').addEventListener('keydown', function(e) { if (e.isComposing) return; if (e.key === 'Enter' || e.key === ' ' || e.code === 'Space') { e.preventDefault(); commitTagInput('editor-founder-input', editorFounders, 'editor-founders-tags'); } }); document.getElementById('editor-founder-input').addEventListener('blur', function() { commitTagInput('editor-founder-input', editorFounders, 'editor-founders-tags'); }); document.getElementById('editor-founders-wrapper').addEventListener('click', function() { document.getElementById('editor-founder-input').focus(); }); // Members tags document.getElementById('editor-members-tags').addEventListener('click', function(e) { var removeBtn = e.target.closest('.editor-tag-remove'); if (removeBtn) { var idx = parseInt(removeBtn.dataset.idx); editorMembers.splice(idx, 1); renderTagsList('editor-members-tags', editorMembers); updatePreview(); } }); document.getElementById('editor-member-input').addEventListener('keydown', function(e) { if (e.isComposing) return; if (e.key === 'Enter' || e.key === ' ' || e.code === 'Space') { e.preventDefault(); commitTagInput('editor-member-input', editorMembers, 'editor-members-tags'); } }); document.getElementById('editor-member-input').addEventListener('blur', function() { commitTagInput('editor-member-input', editorMembers, 'editor-members-tags'); }); document.getElementById('editor-members-wrapper').addEventListener('click', function() { document.getElementById('editor-member-input').focus(); }); // --- Sortable Lists (drag-and-drop) --- var dragState = { listId: null, fromIdx: null }; function renderSortableList(listId, items) { var container = document.getElementById(listId); container.innerHTML = ''; items.forEach(function(item, idx) { var div = document.createElement('div'); div.className = 'sortable-item'; div.draggable = true; div.dataset.idx = idx; div.dataset.listId = listId; var typeBadgeClass = item.type === 'text' ? 'badge-text' : item.type === 'image' ? 'badge-image' : 'badge-video'; var typeBadgeLabel = item.type === 'text' ? '文字' : item.type === 'image' ? '图片' : '视频'; var contentHtml; if (item.type === 'text') { contentHtml = ''; } else if (item.type === 'image') { contentHtml = ''; } else { contentHtml = ''; } div.innerHTML = '' + '' + typeBadgeLabel + '' + contentHtml + ''; container.appendChild(div); div.addEventListener('dragstart', onDragStart); div.addEventListener('dragover', onDragOver); div.addEventListener('dragenter', onDragEnter); div.addEventListener('dragleave', onDragLeave); div.addEventListener('drop', onDrop); div.addEventListener('dragend', onDragEnd); var contentEl = div.querySelector('.item-content'); contentEl.addEventListener('input', function() { items[idx].content = contentEl.value; updatePreview(); }); div.querySelector('.remove-item-btn').addEventListener('click', function() { items.splice(idx, 1); renderSortableList(listId, items); updatePreview(); }); }); } function onDragStart(e) { var item = e.target.closest('.sortable-item'); if (!item) return; dragState.listId = item.dataset.listId; dragState.fromIdx = parseInt(item.dataset.idx); item.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', ''); } function onDragOver(e) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; } function onDragEnter(e) { var item = e.target.closest('.sortable-item'); if (item && item.dataset.listId === dragState.listId) { item.classList.add('drag-over'); } } function onDragLeave(e) { var item = e.target.closest('.sortable-item'); if (item) { item.classList.remove('drag-over'); } } function onDrop(e) { e.preventDefault(); var item = e.target.closest('.sortable-item'); if (!item || item.dataset.listId !== dragState.listId) return; var toIdx = parseInt(item.dataset.idx); var fromIdx = dragState.fromIdx; if (fromIdx === toIdx) return; var listId = dragState.listId; var items = editorIntroduction; var moved = items.splice(fromIdx, 1)[0]; items.splice(toIdx, 0, moved); renderSortableList(listId, items); updatePreview(); } function onDragEnd() { document.querySelectorAll('.sortable-item').forEach(function(el) { el.classList.remove('dragging', 'drag-over'); }); dragState = { listId: null, fromIdx: null }; } // --- Add item buttons --- editorModal.querySelectorAll('.add-item-btn').forEach(function(btn) { btn.addEventListener('click', function() { var type = btn.dataset.type; var newItem = { type: type, content: '' }; editorIntroduction.push(newItem); renderSortableList('editor-introduction-list', editorIntroduction); updatePreview(); }); }); // --- Live Preview --- ['editor-town-title', 'editor-town-logo', 'editor-town-scale', 'editor-town-type', 'editor-town-recruit', 'editor-town-x', 'editor-town-y', 'editor-town-z'].forEach(function(id) { var el = document.getElementById(id); el.addEventListener('input', updatePreview); el.addEventListener('change', updatePreview); }); ['editor-town-gradient-from', 'editor-town-gradient-to'].forEach(function(id) { var el = document.getElementById(id); el.addEventListener('input', updatePreview); el.addEventListener('change', updatePreview); }); function updatePreview() { var preview = document.getElementById('town-editor-preview-area'); var title = document.getElementById('editor-town-title').value || '未命名城镇'; var logo = document.getElementById('editor-town-logo').value.trim(); var scale = document.getElementById('editor-town-scale').value; var townType = document.getElementById('editor-town-type').value; var recruit = document.getElementById('editor-town-recruit').value; var x = document.getElementById('editor-town-x').value || '0'; var y = document.getElementById('editor-town-y').value || '64'; var z = document.getElementById('editor-town-z').value || '0'; var gradient = { from: normalizeHexColor(document.getElementById('editor-town-gradient-from').value, DEFAULT_GRADIENT.from), to: normalizeHexColor(document.getElementById('editor-town-gradient-to').value, DEFAULT_GRADIENT.to) }; var hasLogo = logo !== ''; var html = '主世界: X: ' + escapeHtml(x) + ', Y: ' + escapeHtml(y) + ', Z: ' + escapeHtml(z) + '
'; html += '' + (escapeHtml(block.content) || '空文字') + '
'; } else if (block.type === 'image') { html += block.content ? '空图片
'; } else if (block.type === 'video') { html += renderVideoPreviewHtml(block.content); } }); } else { html += '无
'; } html += '请输入有效的 BV 号或 bilibili 视频地址
'; } // --- Utility --- function escapeHtml(text) { if (!text) return ''; var div = document.createElement('div'); div.appendChild(document.createTextNode(text)); return div.innerHTML; } });