forked from xziino/ff14-mitigator
add amkis whishes, cactbot timeline updates and biinos easy fix
This commit is contained in:
parent
0a47b126cf
commit
695c0fb99e
9
.vscode/settings.json
vendored
Normal file
9
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"python-envs.pythonProjects": [
|
||||
{
|
||||
"path": ".",
|
||||
"envManager": "ms-python.python:system",
|
||||
"packageManager": "ms-python.python:pip"
|
||||
}
|
||||
]
|
||||
}
|
||||
226
css/planner.css
226
css/planner.css
@ -993,6 +993,39 @@
|
||||
opacity: 0.78;
|
||||
}
|
||||
|
||||
.timeline-inactive-gap {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
height: 20px;
|
||||
z-index: 2;
|
||||
pointer-events: auto;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.timeline-inactive-gap-fit,
|
||||
.timeline-inactive-gap-late {
|
||||
position: absolute;
|
||||
top: 9px;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.timeline-inactive-gap-fit {
|
||||
left: 0;
|
||||
border-top: 2px dotted rgba(180,190,205,.42);
|
||||
}
|
||||
|
||||
.timeline-inactive-gap-late {
|
||||
border-top: 2px dotted rgba(224,92,92,.70);
|
||||
}
|
||||
|
||||
.timeline-inactive-gap:hover .timeline-inactive-gap-fit {
|
||||
border-top-color: var(--gold);
|
||||
}
|
||||
|
||||
.timeline-inactive-gap:hover .timeline-inactive-gap-late {
|
||||
border-top-color: var(--red);
|
||||
}
|
||||
|
||||
.timeline-mitigation img {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
@ -1233,6 +1266,24 @@
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.timeline-menu-time {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 9px 9px;
|
||||
margin-bottom: 4px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--t3);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.timeline-menu-time input {
|
||||
width: 76px !important;
|
||||
padding: 5px 7px !important;
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
/* ── View Toggle ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.view-toggle-btns {
|
||||
@ -1248,17 +1299,176 @@
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cactbot-export-option {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: var(--t2);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
.cactbot-export-modal-box {
|
||||
max-width: 720px;
|
||||
max-height: min(86vh, 780px);
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
}
|
||||
.cactbot-export-option input {
|
||||
|
||||
.cactbot-modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
padding: 24px 28px 18px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.cactbot-modal-header .modal-title {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.cactbot-modal-subtitle {
|
||||
color: var(--t3);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.cactbot-export-options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
padding: 18px 28px 0;
|
||||
}
|
||||
|
||||
.cactbot-option-card,
|
||||
.cactbot-check-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r);
|
||||
background: var(--bg2);
|
||||
cursor: pointer;
|
||||
transition: border-color .15s, background .15s;
|
||||
}
|
||||
|
||||
.cactbot-option-card {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.cactbot-option-card:hover,
|
||||
.cactbot-check-card:hover {
|
||||
border-color: var(--borderem);
|
||||
background: var(--bg3);
|
||||
}
|
||||
|
||||
.cactbot-option-card input,
|
||||
.cactbot-check-card input {
|
||||
width: auto;
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cactbot-option-card span,
|
||||
.cactbot-check-card span {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cactbot-option-card strong,
|
||||
.cactbot-check-card strong {
|
||||
color: var(--t1);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cactbot-option-card small,
|
||||
.cactbot-check-card small {
|
||||
color: var(--t3);
|
||||
font-size: 11px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cactbot-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 16px 28px 0;
|
||||
}
|
||||
|
||||
.cactbot-field label,
|
||||
.cactbot-section-title {
|
||||
color: var(--t2);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.cactbot-field input {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.cactbot-check-grid,
|
||||
.cactbot-role-grid,
|
||||
.cactbot-player-columns {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cactbot-role-grid,
|
||||
.cactbot-player-columns {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.cactbot-player-columns {
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.cactbot-player-column {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cactbot-player-column-title {
|
||||
padding: 0 2px 2px;
|
||||
color: var(--gold);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.cactbot-check-card {
|
||||
padding: 9px 10px;
|
||||
}
|
||||
|
||||
.cactbot-check-card--role {
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.cactbot-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 9px;
|
||||
padding: 18px 28px 0;
|
||||
}
|
||||
|
||||
.cactbot-modal-actions {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
justify-content: flex-end;
|
||||
padding: 18px 28px 24px;
|
||||
margin-top: 8px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--bgcard);
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.cactbot-export-options,
|
||||
.cactbot-role-grid,
|
||||
.cactbot-player-columns {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.view-toggle-btn {
|
||||
|
||||
402
js/planner.js
402
js/planner.js
@ -165,6 +165,16 @@ function abilityEffectTooltip(ability, buffType, plan = null) {
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function timelineAssignmentTooltip(assignment, mechanic, start, durationSec, cooldownSec, plan = null) {
|
||||
return [
|
||||
abilityEffectTooltip(assignment.ability, assignment.buffType, plan),
|
||||
assignment.job ? `Caster: ${assignment.job}` : 'Caster: not assigned',
|
||||
`Active: ${durationSec}s (${fmtTimestamp(start)} - ${fmtTimestamp(start + durationSec * 1000)})`,
|
||||
`Cooldown: ${cooldownSec}s`,
|
||||
mechanic?.name ? `Mechanic: ${mechanic.name} (${fmtTimestamp(mechanic.timestamp)})` : null,
|
||||
].filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
async function ensureActionMetaLoaded() {
|
||||
if (actionMetaPromise) return actionMetaPromise;
|
||||
actionMetaPromise = (async () => {
|
||||
@ -449,7 +459,7 @@ function renderPlanDetail(plan) {
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="timeline-hint">Boss-Aktion klicken zum Zuweisen · Mitigation ziehen · Klick für Zeiten</div>
|
||||
<div class="timeline-hint">Boss-Aktion klicken zum Zuweisen · Mitigation ziehen · Modifier+Scroll zum Zoomen · Klick für Zeiten</div>
|
||||
<div id="planner-timeline">
|
||||
${renderTimelineHtml(plan)}
|
||||
</div>
|
||||
@ -466,10 +476,6 @@ function renderPlanDetail(plan) {
|
||||
<button class="view-toggle-btn active" data-view="mechanics">Mechaniken</button>
|
||||
<button class="view-toggle-btn" data-view="myspells">★ Meine Spells</button>
|
||||
</div>
|
||||
<label class="cactbot-export-option">
|
||||
<input type="checkbox" id="cactbot-export-split">
|
||||
<span>Separate Entries</span>
|
||||
</label>
|
||||
<button id="cactbot-export-btn" class="btn btn-sm" title="Cactbot Timeline exportieren">Cactbot Export</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -580,7 +586,7 @@ function cactbotExportFilename(plan) {
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '') || 'mitigation-plan';
|
||||
return `${base}-mitigations.txt`;
|
||||
return `${base}-mitigations`;
|
||||
}
|
||||
|
||||
function cactbotAssignmentLabel(assignment, plan) {
|
||||
@ -588,16 +594,63 @@ function cactbotAssignmentLabel(assignment, plan) {
|
||||
return assignment.job ? `${assignment.job} ${ability}` : ability;
|
||||
}
|
||||
|
||||
function cactbotFilterHints(plan) {
|
||||
const jobs = [...new Set((plan.jobComposition ?? []).filter(Boolean))].sort();
|
||||
if (!jobs.length) return [];
|
||||
return ['# Optional job filters - uncomment lines you do not want to see:', ...jobs.map(job => `# hideall ".*${job} .*"`)];
|
||||
function cactbotJsString(text) {
|
||||
return String(text ?? '')
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/'/g, "\\'")
|
||||
.replace(/\r?\n/g, '\\n');
|
||||
}
|
||||
|
||||
function cactbotTimelineText(plan, splitEntries = false) {
|
||||
function cactbotTemplateLiteral(text) {
|
||||
return String(text ?? '')
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/`/g, '\\`')
|
||||
.replace(/\$\{/g, '\\${');
|
||||
}
|
||||
|
||||
function cactbotRegexText(text) {
|
||||
return String(text ?? '')
|
||||
.replace(/[.*+?^${}()|[\]\\\/]/g, '\\$&')
|
||||
.replace(/'/g, "\\'");
|
||||
}
|
||||
|
||||
function cactbotExportSlots(plan) {
|
||||
return (plan.jobComposition ?? [])
|
||||
.map((job, idx) => ({
|
||||
idx,
|
||||
job,
|
||||
role: JOB_ROLE[job] ?? '',
|
||||
name: plan.playerRoster?.[idx]?.name ?? '',
|
||||
}))
|
||||
.filter(slot => slot.job);
|
||||
}
|
||||
|
||||
function cactbotRoleLabel(role) {
|
||||
return { healer: 'Healer', dps: 'DPS', tank: 'Tank' }[role] ?? role;
|
||||
}
|
||||
|
||||
function cactbotRoleOrder(role) {
|
||||
return { healer: 0, dps: 1, tank: 2 }[role] ?? 9;
|
||||
}
|
||||
|
||||
function cactbotSelectedJobs(plan, settings) {
|
||||
const selectedPlayers = new Set((settings.players ?? []).map(Number));
|
||||
const slots = cactbotExportSlots(plan);
|
||||
return new Set(slots
|
||||
.filter(slot => selectedPlayers.has(slot.idx))
|
||||
.map(slot => slot.job));
|
||||
}
|
||||
|
||||
function cactbotAssignmentIncluded(assignment, selectedJobs) {
|
||||
return !assignment.job || selectedJobs.has(assignment.job);
|
||||
}
|
||||
|
||||
function cactbotTimelineRows(plan, settings) {
|
||||
const selectedJobs = cactbotSelectedJobs(plan, settings);
|
||||
const byStart = new Map();
|
||||
for (const mechanic of visiblePlanMechanics(plan)) {
|
||||
const planned = sortedAssignments(plannedAssignmentsForMechanic(plan, mechanic));
|
||||
const planned = sortedAssignments(plannedAssignmentsForMechanic(plan, mechanic))
|
||||
.filter(assignment => cactbotAssignmentIncluded(assignment, selectedJobs));
|
||||
if (!planned.length) continue;
|
||||
|
||||
planned.forEach(assignment => {
|
||||
@ -613,19 +666,23 @@ function cactbotTimelineText(plan, splitEntries = false) {
|
||||
const rows = [];
|
||||
for (const [time, group] of byStart.entries()) {
|
||||
const assignments = [...group.assignments.values()];
|
||||
if (splitEntries) {
|
||||
if (settings.splitEntries) {
|
||||
assignments.forEach(text => rows.push({ time, text }));
|
||||
} else {
|
||||
rows.push({ time, text: assignments.join(', ') });
|
||||
}
|
||||
}
|
||||
rows.sort((a, b) => a.time - b.time || a.text.localeCompare(b.text));
|
||||
return rows;
|
||||
}
|
||||
|
||||
function cactbotTimelineText(plan, settings) {
|
||||
const rows = cactbotTimelineRows(plan, settings);
|
||||
const header = [
|
||||
`# Exported from FF14 Mitigator`,
|
||||
`# Plan: ${plan.name}`,
|
||||
plan.source?.fightName ? `# Fight: ${plan.source.fightName}` : null,
|
||||
`# Format: cactbot timeline entries`,
|
||||
...cactbotFilterHints(plan),
|
||||
].filter(Boolean);
|
||||
|
||||
return [
|
||||
@ -634,6 +691,40 @@ function cactbotTimelineText(plan, splitEntries = false) {
|
||||
].join('\n') + '\n';
|
||||
}
|
||||
|
||||
function cactbotUserConfigText(plan, settings) {
|
||||
const rows = cactbotTimelineRows(plan, settings);
|
||||
const timeline = rows.map(row => ` ${cactbotTime(row.time)} "${cactbotEscape(row.text)}"`).join('\n');
|
||||
const triggerRows = [...new Map(rows.map(row => [row.text, row])).values()];
|
||||
const triggers = triggerRows.map((row, idx) => {
|
||||
const idBase = `${plan.source?.fightName || plan.name}_${row.text}`
|
||||
.replace(/[^A-Za-z0-9]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '')
|
||||
.slice(0, 80) || `Mitigation_${idx + 1}`;
|
||||
return ` {
|
||||
id: '${cactbotJsString(idBase)}',
|
||||
regex: /^${cactbotRegexText(row.text)}$/,
|
||||
beforeSeconds: 1,
|
||||
infoText: {
|
||||
en: '${cactbotJsString(row.text)}',
|
||||
},
|
||||
}`;
|
||||
}).join(',\n');
|
||||
|
||||
return `Options.Triggers.push(
|
||||
{
|
||||
zoneId: ${settings.zoneId || 'ZoneId.MatchAll'},
|
||||
timeline: \`
|
||||
${cactbotTemplateLiteral(timeline)}
|
||||
\`,
|
||||
timelineTriggers: [
|
||||
${triggers}
|
||||
],
|
||||
triggers: [],
|
||||
},
|
||||
);
|
||||
`;
|
||||
}
|
||||
|
||||
function downloadTextFile(filename, text) {
|
||||
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
@ -646,17 +737,150 @@ function downloadTextFile(filename, text) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function initCactbotExport(planId) {
|
||||
document.getElementById('cactbot-export-btn')?.addEventListener('click', () => {
|
||||
function cactbotExportModalHtml(plan) {
|
||||
const slots = cactbotExportSlots(plan);
|
||||
const roles = [...new Set(slots.map(slot => slot.role).filter(Boolean))]
|
||||
.sort((a, b) => cactbotRoleOrder(a) - cactbotRoleOrder(b));
|
||||
return `
|
||||
<div id="cactbot-export-modal" class="modal-overlay" style="display:flex">
|
||||
<div class="modal-box cactbot-export-modal-box">
|
||||
<div class="cactbot-modal-header">
|
||||
<div>
|
||||
<div class="modal-title">Cactbot Export</div>
|
||||
<div class="cactbot-modal-subtitle">${escHtml(plan.name)}</div>
|
||||
</div>
|
||||
<button id="cactbot-export-cancel" class="plan-btn" type="button" title="Schließen">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="cactbot-export-options">
|
||||
<label class="cactbot-option-card">
|
||||
<input type="checkbox" id="cactbot-just-timeline">
|
||||
<span>
|
||||
<strong>Just timeline</strong>
|
||||
<small>Nur .txt Einträge statt UserConfig .js</small>
|
||||
</span>
|
||||
</label>
|
||||
<label class="cactbot-option-card">
|
||||
<input type="checkbox" id="cactbot-split-entries">
|
||||
<span>
|
||||
<strong>Separate entries</strong>
|
||||
<small>Eine Timeline-Zeile pro Aktion</small>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="cactbot-field">
|
||||
<label for="cactbot-zone-id">ZoneId</label>
|
||||
<input id="cactbot-zone-id" type="text" value="${escHtml(localStorage.getItem('ff14-cactbot-zone-id') || 'ZoneId.MatchAll')}">
|
||||
</div>
|
||||
|
||||
<div class="cactbot-section">
|
||||
<div class="cactbot-section-title">Roles</div>
|
||||
<div class="cactbot-role-grid">
|
||||
${roles.map(role => `
|
||||
<label class="cactbot-check-card cactbot-check-card--role">
|
||||
<input type="checkbox" class="cactbot-role-check" value="${escHtml(role)}" data-role="${escHtml(role)}" checked>
|
||||
<span>${escHtml(cactbotRoleLabel(role))}</span>
|
||||
</label>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cactbot-section">
|
||||
<div class="cactbot-section-title">Players</div>
|
||||
<div class="cactbot-player-columns">
|
||||
${roles.map(role => `
|
||||
<div class="cactbot-player-column" data-role-column="${escHtml(role)}">
|
||||
<div class="cactbot-player-column-title">${escHtml(cactbotRoleLabel(role))}</div>
|
||||
${slots.filter(slot => slot.role === role).map(slot => `
|
||||
<label class="cactbot-check-card">
|
||||
<input type="checkbox" class="cactbot-player-check" value="${escHtml(slot.idx)}" data-role="${escHtml(role)}" checked>
|
||||
<span>
|
||||
<strong>${escHtml(slot.job)}</strong>
|
||||
${slot.name ? `<small>${escHtml(slot.name)}</small>` : '<small>Slot ohne Namen</small>'}
|
||||
</span>
|
||||
</label>
|
||||
`).join('')}
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions cactbot-modal-actions">
|
||||
<button id="cactbot-export-confirm" class="btn btn-gold">Export</button>
|
||||
<button id="cactbot-export-cancel-footer" class="btn">Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function closeCactbotExportModal() {
|
||||
document.getElementById('cactbot-export-modal')?.remove();
|
||||
}
|
||||
|
||||
function cactbotExportSettingsFromModal() {
|
||||
const zoneId = document.getElementById('cactbot-zone-id')?.value.trim() || 'ZoneId.MatchAll';
|
||||
localStorage.setItem('ff14-cactbot-zone-id', zoneId);
|
||||
return {
|
||||
justTimeline: !!document.getElementById('cactbot-just-timeline')?.checked,
|
||||
splitEntries: !!document.getElementById('cactbot-split-entries')?.checked,
|
||||
zoneId,
|
||||
roles: [...document.querySelectorAll('.cactbot-role-check:checked')].map(input => input.value),
|
||||
players: [...document.querySelectorAll('.cactbot-player-check:checked')].map(input => input.value),
|
||||
};
|
||||
}
|
||||
|
||||
function showCactbotExportModal(planId) {
|
||||
closeCactbotExportModal();
|
||||
const plan = getPlan(planId);
|
||||
if (!plan) return;
|
||||
const splitEntries = !!document.getElementById('cactbot-export-split')?.checked;
|
||||
const text = cactbotTimelineText(plan, splitEntries);
|
||||
if (!text.split('\n').some(line => /^\d/.test(line))) {
|
||||
document.body.insertAdjacentHTML('beforeend', cactbotExportModalHtml(plan));
|
||||
const modal = document.getElementById('cactbot-export-modal');
|
||||
document.getElementById('cactbot-export-cancel')?.addEventListener('click', closeCactbotExportModal);
|
||||
document.getElementById('cactbot-export-cancel-footer')?.addEventListener('click', closeCactbotExportModal);
|
||||
modal?.addEventListener('click', e => { if (e.target === modal) closeCactbotExportModal(); });
|
||||
|
||||
const playersForRole = role => [...modal.querySelectorAll('.cactbot-player-check')]
|
||||
.filter(player => player.dataset.role === role);
|
||||
const roleInputFor = role => [...modal.querySelectorAll('.cactbot-role-check')]
|
||||
.find(input => input.dataset.role === role);
|
||||
const syncRoleState = role => {
|
||||
const players = playersForRole(role);
|
||||
const roleInput = roleInputFor(role);
|
||||
if (!roleInput || !players.length) return;
|
||||
const checkedCount = players.filter(player => player.checked).length;
|
||||
roleInput.checked = checkedCount === players.length;
|
||||
roleInput.indeterminate = checkedCount > 0 && checkedCount < players.length;
|
||||
};
|
||||
|
||||
modal?.querySelectorAll('.cactbot-role-check').forEach(input => {
|
||||
input.addEventListener('change', () => {
|
||||
playersForRole(input.dataset.role).forEach(player => { player.checked = input.checked; });
|
||||
input.indeterminate = false;
|
||||
});
|
||||
});
|
||||
modal?.querySelectorAll('.cactbot-player-check').forEach(input => {
|
||||
input.addEventListener('change', () => {
|
||||
syncRoleState(input.dataset.role);
|
||||
});
|
||||
});
|
||||
modal?.querySelectorAll('.cactbot-role-check').forEach(input => syncRoleState(input.dataset.role));
|
||||
|
||||
document.getElementById('cactbot-export-confirm')?.addEventListener('click', () => {
|
||||
const settings = cactbotExportSettingsFromModal();
|
||||
const text = settings.justTimeline ? cactbotTimelineText(plan, settings) : cactbotUserConfigText(plan, settings);
|
||||
if (!text.split('\n').some(line => /^\s*\d/.test(line))) {
|
||||
alert('Keine Mitigations zum Exportieren gefunden.');
|
||||
return;
|
||||
}
|
||||
downloadTextFile(cactbotExportFilename(plan), text);
|
||||
downloadTextFile(`${cactbotExportFilename(plan)}.${settings.justTimeline ? 'txt' : 'js'}`, text);
|
||||
closeCactbotExportModal();
|
||||
});
|
||||
}
|
||||
|
||||
function initCactbotExport(planId) {
|
||||
document.getElementById('cactbot-export-btn')?.addEventListener('click', () => {
|
||||
showCactbotExportModal(planId);
|
||||
});
|
||||
}
|
||||
|
||||
@ -766,21 +990,32 @@ function plannedAssignmentsForMechanic(plan, targetMechanic) {
|
||||
const targetTime = Number(targetMechanic.timestamp);
|
||||
const tolerance = 50;
|
||||
|
||||
for (const entry of canonicalAssignmentActivations(plan, { dedupeKey: canonicalMechanicKey })) {
|
||||
if (targetTime < entry.start - tolerance || targetTime > entry.end + tolerance) continue;
|
||||
const rows = timelinePlayerRows(plan);
|
||||
const seen = new Set();
|
||||
const includeActiveAssignment = entry => {
|
||||
if (targetTime < entry.start - tolerance || targetTime > entry.end + tolerance) return;
|
||||
|
||||
// Persönliche Mitigation zeigt nur auf der Mechanik, der sie direkt zugewiesen ist
|
||||
// (verhindert dass DRK-Cooldowns auf GNB-Tankbuster erscheinen und umgekehrt)
|
||||
if (TIMELINE_PERSONAL_ABILITIES.has(entry.assignment.ability)
|
||||
&& entry.mechanic.id !== targetMechanic.id) {
|
||||
continue;
|
||||
}
|
||||
const key = `${entry.assignment.ability}::${entry.assignment.job || '*'}::${entry.start}`;
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
|
||||
result.push({
|
||||
...entry.assignment,
|
||||
sourceMechanicId: entry.mechanic.id,
|
||||
sourceStart: entry.start,
|
||||
});
|
||||
};
|
||||
|
||||
for (const row of rows) {
|
||||
for (const entry of canonicalAssignmentActivations(plan, {
|
||||
dedupeKey: item => canonicalTimelineKey(item, row),
|
||||
includeEntry: item => {
|
||||
const assignment = item.assignment;
|
||||
if (assignment.ability !== row.ability) return false;
|
||||
const assignedJob = assignment.job ?? '';
|
||||
return !assignedJob || assignedJob === row.job;
|
||||
},
|
||||
})) includeActiveAssignment(entry);
|
||||
}
|
||||
|
||||
return result;
|
||||
@ -936,6 +1171,11 @@ function initTimelineOptions(planId) {
|
||||
|
||||
// ── Timeline ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function timelineZoom(plan) {
|
||||
const zoom = Number(plan?.timelineZoom ?? 1);
|
||||
return Math.max(0.5, Math.min(4, Number.isFinite(zoom) ? zoom : 1));
|
||||
}
|
||||
|
||||
function planDurationMs(plan) {
|
||||
const sourceDuration = (plan.source?.fightEnd ?? 0) - (plan.source?.fightStart ?? 0);
|
||||
const mechanicEnd = Math.max(0, ...visiblePlanMechanics(plan).map(m => (m.timestamp ?? 0) + 30000));
|
||||
@ -944,7 +1184,7 @@ function planDurationMs(plan) {
|
||||
|
||||
function timelineScale(plan) {
|
||||
const duration = planDurationMs(plan);
|
||||
const pxPerSecond = 8;
|
||||
const pxPerSecond = 8 * timelineZoom(plan);
|
||||
return { duration, width: Math.max(720, Math.ceil(duration / 1000 * pxPerSecond)) };
|
||||
}
|
||||
|
||||
@ -1369,7 +1609,7 @@ function renderTimelineHtml(plan) {
|
||||
|
||||
const playerRows = rows.map(row => {
|
||||
const blocks = [];
|
||||
for (const entry of canonicalAssignmentActivations(plan, {
|
||||
const rowEntries = canonicalAssignmentActivations(plan, {
|
||||
dedupeKey: item => canonicalTimelineKey(item, row),
|
||||
includeEntry: item => {
|
||||
const assignment = item.assignment;
|
||||
@ -1377,7 +1617,41 @@ function renderTimelineHtml(plan) {
|
||||
const assignedJob = assignment.job ?? '';
|
||||
return !assignedJob || assignedJob === row.job;
|
||||
},
|
||||
})) {
|
||||
}).sort((a, b) => a.start - b.start);
|
||||
|
||||
const gaps = [];
|
||||
for (let i = 1; i < rowEntries.length; i++) {
|
||||
const prev = rowEntries[i - 1];
|
||||
const next = rowEntries[i];
|
||||
const gapStart = Math.max(
|
||||
prev.start + prev.durationSec * 1000,
|
||||
prev.start + assignmentCooldownSeconds(prev.assignment) * 1000
|
||||
);
|
||||
const gapEnd = next.start;
|
||||
if (gapEnd <= gapStart) continue;
|
||||
const left = Math.max(0, Math.min(100, (gapStart / duration) * 100));
|
||||
const widthPct = Math.max(0.2, Math.min(100 - left, ((gapEnd - gapStart) / duration) * 100));
|
||||
const gapSeconds = cactbotTime(gapEnd - gapStart);
|
||||
const requiredWindowMs = Math.max(
|
||||
assignmentDurationSeconds(prev.assignment),
|
||||
assignmentCooldownSeconds(prev.assignment)
|
||||
) * 1000;
|
||||
const latestExtraStart = gapEnd - requiredWindowMs;
|
||||
const fitPct = Math.max(0, Math.min(100, ((latestExtraStart - gapStart) / (gapEnd - gapStart)) * 100));
|
||||
const lateText = fitPct < 100
|
||||
? `\nZu spät für einen weiteren Einsatz ab ${fmtTimestamp(Math.max(gapStart, latestExtraStart))}`
|
||||
: '';
|
||||
gaps.push(`
|
||||
<span class="timeline-inactive-gap"
|
||||
style="left:${left}%;width:${widthPct}%"
|
||||
title="${escHtml(`${localizedAbilityName(row.ability, plan)} nicht aktiv: ${gapSeconds}s (${fmtTimestamp(gapStart)} - ${fmtTimestamp(gapEnd)})${lateText}`)}">
|
||||
<span class="timeline-inactive-gap-fit" style="width:${fitPct}%"></span>
|
||||
<span class="timeline-inactive-gap-late" style="left:${fitPct}%;width:${100 - fitPct}%"></span>
|
||||
</span>
|
||||
`);
|
||||
}
|
||||
|
||||
for (const entry of rowEntries) {
|
||||
const item = entry;
|
||||
const m = item.mechanic;
|
||||
const a = item.assignment;
|
||||
@ -1395,6 +1669,7 @@ function renderTimelineHtml(plan) {
|
||||
: 'timeline-mitigation--buff';
|
||||
const icon = abilityIcon(a.ability);
|
||||
const abilityLabel = assignmentAbilityName(a, plan);
|
||||
const tooltip = timelineAssignmentTooltip(a, m, start, durationSec, cooldownSec, plan);
|
||||
blocks.push(`
|
||||
<button class="timeline-mitigation ${cls}${selected}${unresolved}"
|
||||
draggable="true"
|
||||
@ -1402,7 +1677,7 @@ function renderTimelineHtml(plan) {
|
||||
data-mechanic-id="${escHtml(m.id)}"
|
||||
data-ability="${escHtml(a.ability)}"
|
||||
data-job="${escHtml(a.job ?? '')}"
|
||||
title="${escHtml(abilityLabel)} · aktiv ${durationSec}s · CD ${cooldownSec}s${a.job ? '' : ' · mögliche Zuordnung'}">
|
||||
title="${escHtml(tooltip)}">
|
||||
<span class="timeline-mitigation-active"></span>
|
||||
${icon ? `<img src="${escHtml(icon)}" alt="">` : ''}
|
||||
<span>${escHtml(abilityLabel)}</span>
|
||||
@ -1422,7 +1697,7 @@ function renderTimelineHtml(plan) {
|
||||
<span class="timeline-row-ability-name">${escHtml(abilityDisplayName)}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="timeline-track" style="width:${width}px">${hitLines}${blocks.join('')}</div>
|
||||
<div class="timeline-track" style="width:${width}px">${hitLines}${gaps.join('')}${blocks.join('')}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
@ -1467,7 +1742,7 @@ function renderTimelineSettingsHtml(plan) {
|
||||
<div class="timeline-settings-meta">${escHtml(mechanic.name)} bei ${escHtml(fmtTimestamp(mechanic.timestamp))}</div>
|
||||
<label>
|
||||
<span>Start</span>
|
||||
<input class="timeline-setting-input" data-field="timestamp" type="number" min="0" step="1" value="${Math.round(((assignment.timestamp ?? mechanic.timestamp) || 0) / 1000)}">
|
||||
<input class="timeline-setting-input" data-field="timestamp" type="number" min="0" step="0.1" value="${(((assignment.timestamp ?? mechanic.timestamp) || 0) / 1000).toFixed(1).replace(/\.0$/, '')}">
|
||||
</label>
|
||||
<label>
|
||||
<span>Dauer</span>
|
||||
@ -1573,7 +1848,12 @@ function showTimelineMenu(x, y, items) {
|
||||
menu.id = 'timeline-context-menu';
|
||||
menu.className = 'timeline-context-menu';
|
||||
menu.innerHTML = items.length
|
||||
? items.map((item, idx) => `
|
||||
? items.map((item, idx) => item.type === 'timeInput' ? `
|
||||
<label class="timeline-menu-time">
|
||||
<span>${escHtml(item.label)}</span>
|
||||
<input type="number" min="0" step="0.1" value="${escHtml(item.value)}" data-idx="${idx}">
|
||||
</label>
|
||||
` : `
|
||||
<button class="timeline-menu-item${item.disabled ? ' disabled' : ''}${item.header ? ' timeline-menu-header' : ''}" data-idx="${idx}"${item.disabled ? ' disabled' : ''}>
|
||||
${item.icon ? `<img src="${escHtml(item.icon)}" alt="">` : ''}
|
||||
<span>${escHtml(item.label)}</span>
|
||||
@ -1594,6 +1874,13 @@ function showTimelineMenu(x, y, items) {
|
||||
if (!item?.keepOpen) closeTimelineMenu();
|
||||
});
|
||||
|
||||
menu.addEventListener('change', e => {
|
||||
const input = e.target.closest('.timeline-menu-time input');
|
||||
if (!input) return;
|
||||
const item = items[parseInt(input.dataset.idx, 10)];
|
||||
item?.onChange?.(input.value);
|
||||
});
|
||||
|
||||
const closeOutside = ev => {
|
||||
if (menu.contains(ev.target)) return;
|
||||
closeTimelineMenu();
|
||||
@ -1932,6 +2219,35 @@ function initTimeline(planId) {
|
||||
timelinePan = null;
|
||||
});
|
||||
|
||||
timeline.addEventListener('wheel', e => {
|
||||
if (!(e.ctrlKey || e.metaKey || e.altKey)) return;
|
||||
const scroll = e.target.closest('.timeline-scroll');
|
||||
if (!scroll) return;
|
||||
const plan = getPlan(planId);
|
||||
if (!plan) return;
|
||||
|
||||
e.preventDefault();
|
||||
const oldTrack = scroll.querySelector('.timeline-track');
|
||||
const oldWidth = oldTrack?.getBoundingClientRect().width || 1;
|
||||
const rect = scroll.getBoundingClientRect();
|
||||
const cursorX = e.clientX - rect.left;
|
||||
const contentX = scroll.scrollLeft + cursorX;
|
||||
const ratio = contentX / oldWidth;
|
||||
const currentZoom = timelineZoom(plan);
|
||||
const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15;
|
||||
const nextZoom = Math.max(0.5, Math.min(4, currentZoom * factor));
|
||||
if (Math.abs(nextZoom - currentZoom) < 0.001) return;
|
||||
|
||||
updatePlan(planId, { timelineZoom: nextZoom });
|
||||
refreshTimeline(planId);
|
||||
const nextScroll = timeline.querySelector('.timeline-scroll');
|
||||
const nextTrack = nextScroll?.querySelector('.timeline-track');
|
||||
const nextWidth = nextTrack?.getBoundingClientRect().width || oldWidth;
|
||||
if (nextScroll) {
|
||||
nextScroll.scrollLeft = Math.max(0, ratio * nextWidth - cursorX);
|
||||
}
|
||||
}, { passive: false });
|
||||
|
||||
timeline.addEventListener('click', e => {
|
||||
if (suppressNextTimelineClick) {
|
||||
e.preventDefault();
|
||||
@ -1983,7 +2299,19 @@ function initTimeline(planId) {
|
||||
const rows = timelinePlayerRows(plan ?? {});
|
||||
const found = findTimelineAssignment(plan, selectedTimelineAssignment);
|
||||
const timestamp = found ? assignmentStartMs(found.mechanic, found.assignment) : 0;
|
||||
const items = rows
|
||||
const items = [{
|
||||
type: 'timeInput',
|
||||
label: 'Start (s)',
|
||||
value: (timestamp / 1000).toFixed(1).replace(/\.0$/, ''),
|
||||
onChange: value => setTimelineAssignmentField(
|
||||
planId,
|
||||
block.dataset.mechanicId,
|
||||
block.dataset.ability,
|
||||
block.dataset.job,
|
||||
'timestamp',
|
||||
value
|
||||
),
|
||||
}, ...rows
|
||||
.filter(row => jobCanUseAbility(row.job, block.dataset.ability))
|
||||
.filter((row, idx, arr) => arr.findIndex(r => r.job === row.job) === idx)
|
||||
.map(row => ({
|
||||
@ -1995,7 +2323,7 @@ function initTimeline(planId) {
|
||||
job: block.dataset.job,
|
||||
}, found?.assignment ?? null),
|
||||
onClick: () => setTimelineAssignmentJob(planId, block.dataset.mechanicId, block.dataset.ability, block.dataset.job, row.job),
|
||||
}));
|
||||
}))];
|
||||
showTimelineMenu(e.clientX, e.clientY, items);
|
||||
return;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user