From 695c0fb99eef13b57671b9d74d6e20fdd6f86209 Mon Sep 17 00:00:00 2001 From: Akurosia Kamo Date: Mon, 8 Jun 2026 17:00:08 +0200 Subject: [PATCH] add amkis whishes, cactbot timeline updates and biinos easy fix --- .vscode/settings.json | 9 + css/planner.css | 230 ++++++++++++++++++++++-- js/planner.js | 406 ++++++++++++++++++++++++++++++++++++++---- 3 files changed, 596 insertions(+), 49 deletions(-) create mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..7766bb5 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,9 @@ +{ + "python-envs.pythonProjects": [ + { + "path": ".", + "envManager": "ms-python.python:system", + "packageManager": "ms-python.python:pip" + } + ] +} diff --git a/css/planner.css b/css/planner.css index f23059b..a5a5e24 100644 --- a/css/planner.css +++ b/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 { - width: auto; - margin: 0; + +.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 { diff --git a/js/planner.js b/js/planner.js index c0d3279..13562ad 100644 --- a/js/planner.js +++ b/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) { -
Boss-Aktion klicken zum Zuweisen · Mitigation ziehen · Klick für Zeiten
+
Boss-Aktion klicken zum Zuweisen · Mitigation ziehen · Modifier+Scroll zum Zoomen · Klick für Zeiten
${renderTimelineHtml(plan)}
@@ -466,10 +476,6 @@ function renderPlanDetail(plan) { - @@ -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', () => { - 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))) { +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 ` + `; +} + +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; + 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(` + + + + + `); + } + + 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(`