TierListMaker:修订间差异
来自卡厄思梦境WIKI
无编辑摘要 |
无编辑摘要 |
||
| 第1行: | 第1行: | ||
<includeonly> | <includeonly> | ||
<style> | <style> | ||
. | .tierlist-wrapper { | ||
max-width: 1200px; | |||
margin: 0 auto; | |||
padding: 8px; | |||
} | |||
.tierlist-controls { | |||
display: flex; | |||
gap: 8px; | |||
align-items: center; | |||
flex-wrap: wrap; | |||
margin-bottom: 8px; | |||
} | |||
.control-btn { | |||
display: inline-block; | |||
padding: 6px 12px; | |||
background: #3498db; | |||
color: #fff; | |||
border-radius: 4px; | |||
cursor: pointer; | |||
user-select: none; | |||
} | |||
.control-btn:hover { background: #2980b9; } | |||
.control-tips { color: #666; font-size: 12px; } | |||
.tierlist-table { | |||
width: 100%; | |||
table-layout: fixed; | |||
} | |||
.tierlist-table th.tier-th { | |||
width: 120px; | |||
text-align: center; | |||
vertical-align: middle; | |||
color: #fff; | |||
position: relative; | |||
padding: 0; | |||
} | } | ||
. | .tier-label { | ||
padding: 6px 8px; | |||
font-size: 18px; | |||
font-weight: bold; | |||
line-height: 1.2; | |||
outline: none; | |||
cursor: text; | |||
} | } | ||
. | .tierlist-table td { | ||
padding: 4px; | |||
} | } | ||
. | .tier-dropzone { | ||
min-height: 116px; | |||
border: 2px dashed #ddd; | |||
border-radius: 6px; | |||
background: #fafafa; | |||
display: flex; | |||
flex-wrap: wrap; | |||
align-items: flex-start; | |||
gap: 6px; | |||
padding: 6px; | |||
} | } | ||
. | .tier-dropzone.over { | ||
border-color: #3498db; | |||
background: #f0f8ff; | |||
} | } | ||
. | .char-pool { | ||
margin-top: 10px; | |||
border: 2px solid #ddd; | |||
border-radius: 6px; | |||
padding: 6px; | |||
background: #fff; | |||
} | } | ||
. | .pool-title { | ||
font-weight: bold; | |||
margin-bottom: 6px; | |||
} | } | ||
. | .pool-dropzone.over { | ||
border-color: #3498db; | |||
background: #f0f8ff; | |||
} | } | ||
. | .avatar-frame { | ||
position: relative; | |||
display: inline-block; | |||
vertical-align: top; | |||
border: 3px solid #ccc; | |||
border-radius: 5px; | |||
overflow: hidden; | |||
box-shadow: 0 2px 5px rgba(0,0,0,0.1); | |||
background: #f5f5f5; | |||
transition: transform 0.3s ease, box-shadow 0.3s ease; | |||
cursor: grab; | |||
} | } | ||
. | .avatar-frame.dragging { opacity: 0.6; } | ||
.avatar-frame:hover { | |||
transform: scale(1.05); | |||
box-shadow: 0 4px 8px rgba(0,0,0,0.2); | |||
z-index: 10; | |||
} | } | ||
. | .avatar-frame img { | ||
display: block; | |||
width: 100px; | |||
height: 100px; | |||
object-fit: cover; | |||
} | } | ||
.color- | .avatar-name { | ||
position: absolute; | |||
left: 0; | |||
bottom: 0; | |||
padding: 2px 8px; | |||
color: white; | |||
font-size: 12px; | |||
font-weight: bold; | |||
text-shadow: 0 0 2px black, 0 0 2px black; | |||
white-space: nowrap; | |||
max-width: 100%; | |||
overflow: hidden; | |||
text-overflow: ellipsis; | |||
border-top-right-radius: 3px; | |||
} | } | ||
. | .color-palette { | ||
position: absolute; | |||
z-index: 99999; | |||
background: #fff; | |||
border: 1px solid #ccc; | |||
padding: 6px; | |||
border-radius: 6px; | |||
box-shadow: 0 4px 10px rgba(0,0,0,0.15); | |||
display: none; | |||
} | } | ||
. | .color-palette .colors { | ||
display: grid; | |||
grid-template-columns: repeat(6, 20px); | |||
gap: 6px; | |||
} | } | ||
.color-swatch { | |||
width: 20px; | |||
height: 20px; | |||
border-radius: 4px; | |||
border: 1px solid #ccc; | |||
cursor: pointer; | |||
} | } | ||
. | .color-palette .actions { | ||
margin-top: 6px; | |||
display: flex; | |||
gap: 8px; | |||
} | } | ||
. | .palette-close { | ||
padding: 4px 8px; | |||
background: #eee; | |||
border-radius: 4px; | |||
cursor: pointer; | |||
} | } | ||
</style> | </style> | ||
<script> | <script> | ||
let | (function(){ | ||
// Utility: ideal text color for background | |||
function idealTextColor(hex) { | |||
// | const c = hex.replace('#',''); | ||
const | let r, g, b; | ||
{ | if (c.length === 3) { | ||
r = parseInt(c[0] + c[0], 16); | |||
g = parseInt(c[1] + c[1], 16); | |||
b = parseInt(c[2] + c[2], 16); | |||
} else { | |||
]; | r = parseInt(c.substring(0,2), 16); | ||
g = parseInt(c.substring(2,4), 16); | |||
b = parseInt(c.substring(4,6), 16); | |||
function | } | ||
const | // relative luminance | ||
const | const luminance = (0.2126*r + 0.7152*g + 0.0722*b) / 255; | ||
return luminance > 0.55 ? '#000' : '#fff'; | |||
} | |||
function applyThColor(th, color) { | |||
th.setAttribute('data-color', color); | |||
th.style.backgroundColor = color; | |||
th.style.color = idealTextColor(color); | |||
} | |||
function setupThColors() { | |||
const ths = document.querySelectorAll('#tierlist-table th.tier-th'); | |||
ths.forEach(th => { | |||
const color = th.getAttribute('data-color') || '#888888'; | |||
applyThColor(th, color); | |||
th.addEventListener('click', (e) => { | |||
openColorPalette(th, e); | |||
}); | |||
}); | |||
} | |||
// Color palette | |||
const paletteColors = [ | |||
'#e74c3c','#f1963b','#ffef03','#c2d402','#2ecc71','#3498db', | |||
'#9b59b6','#34495e','#7f8c8d','#ffffff','#000000','#f39c12', | |||
'#1abc9c','#8e44ad','#d35400','#27ae60','#2980b9','#bdc3c7' | |||
]; | |||
let paletteEl = null; | |||
function buildPalette() { | |||
paletteEl = document.createElement('div'); | |||
paletteEl.className = 'color-palette'; | |||
const colorsWrap = document.createElement('div'); | |||
colorsWrap.className = 'colors'; | |||
paletteColors.forEach(col => { | |||
const s = document.createElement('div'); | |||
s.className = 'color-swatch'; | |||
s.style.backgroundColor = col; | |||
s.addEventListener('click', () => { | |||
if (paletteEl._targetTh) { | |||
applyThColor(paletteEl._targetTh, col); | |||
} | |||
paletteEl.style.display = 'none'; | |||
}); | |||
colorsWrap.appendChild(s); | |||
}); | |||
const actions = document.createElement('div'); | |||
actions.className = 'actions'; | |||
const closeBtn = document.createElement('div'); | |||
closeBtn.className = 'palette-close'; | |||
closeBtn.textContent = '关闭'; | |||
closeBtn.addEventListener('click', () => paletteEl.style.display = 'none'); | |||
actions.appendChild(closeBtn); | |||
paletteEl.appendChild(colorsWrap); | |||
paletteEl.appendChild(actions); | |||
document.body.appendChild(paletteEl); | |||
document.addEventListener('click', (e) => { | |||
if (!paletteEl.contains(e.target)) paletteEl.style.display = 'none'; | |||
}); | |||
} | |||
function openColorPalette(th, evt) { | |||
if (!paletteEl) buildPalette(); | |||
const rect = th.getBoundingClientRect(); | |||
paletteEl.style.left = (window.scrollX + rect.left + 10) + 'px'; | |||
paletteEl.style.top = (window.scrollY + rect.bottom + 8) + 'px'; | |||
paletteEl.style.display = 'block'; | |||
paletteEl._targetTh = th; | |||
} | |||
// Drag & Drop | |||
let draggedEl = null; | |||
function setupDraggable(el) { | |||
if (!el || el._draggableSetup) return; | |||
el._draggableSetup = true; | |||
el.setAttribute('draggable', 'true'); | |||
el.addEventListener('dragstart', (e) => { | |||
draggedEl = el; | |||
el.classList.add('dragging'); | |||
e.dataTransfer.effectAllowed = 'move'; | |||
// ensure id | |||
if (!el.dataset.id) { | |||
const img = el.querySelector('img'); | |||
if (img && img.src) { | |||
const m = img.src.match(/face_character_([^./?]+)\.png/i); | |||
if (m) el.dataset.id = m[1]; | |||
} | |||
} | |||
}); | |||
el.addEventListener('dragend', () => { | |||
el.classList.remove('dragging'); | |||
draggedEl = null; | |||
}); | |||
} | |||
function setupDropzone(zone) { | |||
zone.addEventListener('dragover', (e) => { | |||
if (draggedEl) e.preventDefault(); | |||
zone.classList.add('over'); | |||
}); | |||
zone.addEventListener('dragleave', () => { | |||
zone.classList.remove('over'); | |||
}); | |||
zone.addEventListener('drop', (e) => { | |||
e.preventDefault(); | |||
zone.classList.remove('over'); | |||
if (!draggedEl) return; | |||
// move element | |||
zone.appendChild(draggedEl); | |||
// normalize styling when moved | |||
draggedEl.style.pointerEvents = 'auto'; | |||
}); | }); | ||
} | |||
function initDragAndDrop() { | |||
// make existing avatars draggable | |||
document.querySelectorAll('.avatar-frame').forEach(setupDraggable); | |||
// dropzones | |||
document.querySelectorAll('.tier-dropzone').forEach(setupDropzone); | |||
const pool = document.getElementById('char-pool'); | |||
setupDropzone(pool); | |||
} | |||
// Observe dynamically added avatars (from ask or later) | |||
const poolObserver = new MutationObserver((mutations) => { | |||
mutations.forEach((m) => { | |||
m.addedNodes.forEach((node) => { | |||
if (node.nodeType === 1) { | |||
if (node.classList && node.classList.contains('avatar-frame')) { | |||
setupDraggable(node); | |||
} else { | |||
node.querySelectorAll && node.querySelectorAll('.avatar-frame').forEach(setupDraggable); | |||
} | |||
} | |||
}); | |||
}); | }); | ||
} | }); | ||
function startObservers() { | |||
const pool = document.getElementById('char-pool'); | |||
function | if (pool) { | ||
const | poolObserver.observe(pool, { childList: true, subtree: true }); | ||
} | |||
} | } | ||
} | } | ||
// Add row | |||
// | function addRow() { | ||
function | |||
const table = document.getElementById('tierlist-table'); | const table = document.getElementById('tierlist-table'); | ||
const | const tr = document.createElement('tr'); | ||
const th = document.createElement('th'); | |||
th.className = 'tier-th'; | |||
const | applyThColor(th, '#7f8c8d'); | ||
const label = document.createElement('div'); | |||
label.className = 'tier-label'; | |||
label.setAttribute('contenteditable', 'true'); | |||
label.textContent = 'NEW'; | |||
th.appendChild(label); | |||
const td = document.createElement('td'); | |||
const dropzone = document.createElement('div'); | |||
dropzone.className = 'tier-dropzone'; | |||
dropzone.setAttribute('data-tier', label.textContent || 'NEW'); | |||
td.appendChild(dropzone); | |||
tr.appendChild(th); | |||
tr.appendChild(td); | |||
table.tBodies[0].appendChild(tr); | |||
// Enable interactions | |||
th.addEventListener('click', (e) => openColorPalette(th, e)); | |||
setupDropzone(dropzone); | |||
// Keep data-tier synced with label edits | |||
label.addEventListener('input', () => { | |||
dropzone.setAttribute('data-tier', label.textContent.trim()); | |||
}); | }); | ||
} | |||
// Export to PNG by drawing canvas (no external libs) | |||
function exportPNG() { | |||
const table = document.getElementById('tierlist-table'); | |||
// | const tableRect = table.getBoundingClientRect(); | ||
const thWidth = table.querySelector('th.tier-th')?.getBoundingClientRect().width || 120; | |||
// Collect rows | |||
const rows = Array.from(table.querySelectorAll('tr')); | |||
const rowInfo = rows.map(row => { | |||
const th = row.querySelector('th.tier-th'); | |||
const dz = row.querySelector('.tier-dropzone'); | |||
const thRect = th.getBoundingClientRect(); | |||
const dzRect = dz.getBoundingClientRect(); | |||
const height = Math.max(thRect.height, dzRect.height); | |||
const | const label = th.querySelector('.tier-label')?.textContent || ''; | ||
const color = th.getAttribute('data-color') || '#888'; | |||
const textColor = idealTextColor(color); | |||
// collect avatars in this dropzone | |||
const avatars = Array.from(dz.querySelectorAll('.avatar-frame')).map(av => { | |||
const img = av.querySelector('img'); | |||
const rect = av.getBoundingClientRect(); | |||
return { | |||
imgSrc: img ? img.src : null, | |||
x: rect.left - tableRect.left, | |||
y: rect.top - tableRect.top, | |||
w: img ? img.width : 100, | |||
h: img ? img.height : 100, | |||
name: av.querySelector('.avatar-name')?.textContent || '' | |||
}; | |||
}); | |||
return { thRect, dzRect, height, label, color, textColor, avatars }; | |||
}); | }); | ||
const totalHeight = rowInfo.reduce((acc, r) => acc + r.height, 0); | |||
const width = tableRect.width; | |||
const canvas = document.createElement('canvas'); | |||
canvas.width = Math.ceil(width); | |||
canvas.height = Math.ceil(totalHeight); | |||
const ctx = canvas.getContext('2d'); | |||
// Background | |||
ctx.fillStyle = '#ffffff'; | |||
ctx.fillRect(0, 0, canvas.width, canvas.height); | |||
const | // Load all images | ||
const loadTasks = []; | |||
rowInfo.forEach(r => { | |||
r.avatars.forEach(a => { | |||
if (a.imgSrc) { | |||
loadTasks.push(new Promise((resolve) => { | |||
const img = new Image(); | |||
img.onload = () => resolve({ a, img }); | |||
img.onerror = () => resolve({ a, img: null }); | |||
img.src = a.imgSrc; | |||
// | })); | ||
} | |||
}); | |||
}); | }); | ||
Promise.all(loadTasks).then(results => { | |||
const imgMap = new Map(); | |||
} | results.forEach(({ a, img }) => { | ||
if (img) imgMap.set(a.imgSrc, img); | |||
// | }); | ||
// Draw rows | |||
let yCursor = 0; | |||
rows.forEach((row, idx) => { | |||
const info = rowInfo[idx]; | |||
// Left header | |||
ctx.fillStyle = info.color; | |||
ctx.fillRect(0, yCursor, thWidth, info.height); | |||
// Label text centered | |||
ctx.fillStyle = info.textColor; | |||
ctx.font = 'bold 20px sans-serif'; | |||
ctx.textAlign = 'center'; | |||
ctx.textBaseline = 'middle'; | |||
ctx.fillText(info.label, thWidth / 2, yCursor + info.height / 2); | |||
// Draw avatars (absolute positions relative to table) | |||
info.avatars.forEach(a => { | |||
const img = imgMap.get(a.imgSrc); | |||
if (img) { | |||
const x = Math.max(thWidth + 6, Math.round(a.x)); | |||
const y = Math.round(a.y); | |||
// If rect outside current row vertical span, clamp within this row | |||
const rowTop = yCursor; | |||
const rowBottom = yCursor + info.height - a.h - 6; | |||
const yDraw = Math.min(Math.max(y, rowTop + 6), rowBottom); | |||
// Frame background | |||
ctx.fillStyle = '#f5f5f5'; | |||
ctx.fillRect(x - 3, yDraw - 3, a.w + 6, a.h + 6); | |||
// Border | |||
ctx.strokeStyle = '#cccccc'; | |||
ctx.lineWidth = 3; | |||
ctx.strokeRect(x - 3, yDraw - 3, a.w + 6, a.h + 6); | |||
// Image | |||
ctx.drawImage(img, x, yDraw, a.w, a.h); | |||
// Name bar | |||
if (a.name) { | |||
ctx.fillStyle = 'rgba(0,0,0,0.6)'; | |||
ctx.fillRect(x, yDraw + a.h - 18, a.w, 18); | |||
ctx.fillStyle = '#ffffff'; | |||
ctx.font = 'bold 12px sans-serif'; | |||
ctx.textAlign = 'left'; | |||
ctx.textBaseline = 'middle'; | |||
const nameText = a.name.length > 18 ? a.name.slice(0, 17) + '…' : a.name; | |||
ctx.fillText(nameText, x + 6, yDraw + a.h - 9); | |||
} | |||
} | |||
}); | }); | ||
yCursor += info.height; | |||
}); | |||
// Download | |||
try { | |||
const url = canvas.toDataURL('image/png'); | |||
const a = document.createElement('a'); | |||
a.href = url; | |||
a.download = 'TierList.png'; | |||
document.body.appendChild(a); | |||
a.click(); | |||
document.body.removeChild(a); | |||
} catch (e) { | |||
// Fallback: open in new tab | |||
const url = canvas.toDataURL('image/png'); | |||
window.open(url, '_blank'); | |||
} | |||
}); | }); | ||
} | } | ||
function init() { | |||
setupThColors(); | |||
initDragAndDrop(); | initDragAndDrop(); | ||
startObservers(); | |||
// Keep data-tier in sync when user edits labels on existing rows | |||
// | document.querySelectorAll('#tierlist-table tr').forEach(tr => { | ||
const | const label = tr.querySelector('.tier-label'); | ||
const dz = tr.querySelector('.tier-dropzone'); | |||
}); | if (label && dz) { | ||
label.addEventListener('input', () => { | |||
dz.setAttribute('data-tier', label.textContent.trim()); | |||
}); | |||
} | |||
}); | }); | ||
// Controls | |||
const addRowBtn = document.getElementById('add-row'); | |||
const saveBtn = document.getElementById('save-png'); | |||
addRowBtn && addRowBtn.addEventListener('click', addRow); | |||
saveBtn && saveBtn.addEventListener('click', exportPNG); | |||
} | |||
if (document.readyState === 'complete' || document.readyState === 'interactive') { | |||
setTimeout(init, 0); | |||
} else { | |||
document.addEventListener('DOMContentLoaded', init); | |||
} | |||
})(); | |||
</script> | </script> | ||
</includeonly> | </includeonly> | ||