forked from xziino/ff14-mitigator
Merge remote-tracking branch 'Akurosia/akus_schabernack4'
This commit is contained in:
commit
8b00d1d2a8
@ -42,6 +42,7 @@ query GetReportData($reportCode: String!) {
|
||||
endTime
|
||||
fights {
|
||||
id
|
||||
encounterID
|
||||
name
|
||||
startTime
|
||||
endTime
|
||||
@ -75,9 +76,7 @@ function localized_graphql_uri(string $language): string {
|
||||
return preg_replace('#https://[^/]+#', 'https://' . $host, GRAPHQL_URI);
|
||||
}
|
||||
|
||||
// Fight names must be stable regardless of language — always use the English endpoint.
|
||||
// Localization only matters for ability/player names in analysis.php.
|
||||
$ch = curl_init(GRAPHQL_URI);
|
||||
$ch = curl_init(localized_graphql_uri($language));
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $payload,
|
||||
@ -85,6 +84,7 @@ curl_setopt_array($ch, [
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Content-Type: application/json',
|
||||
'Authorization: Bearer ' . $_SESSION['access_token'],
|
||||
'Accept-Language: ' . ($language === 'jp' ? 'ja' : $language),
|
||||
],
|
||||
CURLOPT_SSL_VERIFYPEER => !DEV_MODE,
|
||||
]);
|
||||
|
||||
150
assets/jsons/Action.json
Normal file
150
assets/jsons/Action.json
Normal file
@ -0,0 +1,150 @@
|
||||
{
|
||||
"185": {
|
||||
"cast": 20,
|
||||
"recast": 25
|
||||
},
|
||||
"188": {
|
||||
"cast": 0,
|
||||
"recast": 300
|
||||
},
|
||||
"3540": {
|
||||
"cast": 0,
|
||||
"recast": 900
|
||||
},
|
||||
"3613": {
|
||||
"cast": 0,
|
||||
"recast": 600
|
||||
},
|
||||
"7385": {
|
||||
"cast": 0,
|
||||
"recast": 1200
|
||||
},
|
||||
"7388": {
|
||||
"cast": 0,
|
||||
"recast": 900
|
||||
},
|
||||
"7405": {
|
||||
"cast": 0,
|
||||
"recast": 1200
|
||||
},
|
||||
"7432": {
|
||||
"cast": 0,
|
||||
"recast": 300
|
||||
},
|
||||
"7535": {
|
||||
"cast": 0,
|
||||
"recast": 600
|
||||
},
|
||||
"7549": {
|
||||
"cast": 0,
|
||||
"recast": 900
|
||||
},
|
||||
"7560": {
|
||||
"cast": 0,
|
||||
"recast": 900
|
||||
},
|
||||
"16012": {
|
||||
"cast": 0,
|
||||
"recast": 1200
|
||||
},
|
||||
"16160": {
|
||||
"cast": 0,
|
||||
"recast": 900
|
||||
},
|
||||
"16471": {
|
||||
"cast": 0,
|
||||
"recast": 900
|
||||
},
|
||||
"16536": {
|
||||
"cast": 0,
|
||||
"recast": 1200
|
||||
},
|
||||
"16538": {
|
||||
"cast": 0,
|
||||
"recast": 1200
|
||||
},
|
||||
"16548": {
|
||||
"cast": 0,
|
||||
"recast": 30
|
||||
},
|
||||
"16556": {
|
||||
"cast": 0,
|
||||
"recast": 300
|
||||
},
|
||||
"16559": {
|
||||
"cast": 0,
|
||||
"recast": 1200
|
||||
},
|
||||
"16889": {
|
||||
"cast": 0,
|
||||
"recast": 1200
|
||||
},
|
||||
"24291": {
|
||||
"cast": 0,
|
||||
"recast": 15
|
||||
},
|
||||
"24292": {
|
||||
"cast": 0,
|
||||
"recast": 15
|
||||
},
|
||||
"24298": {
|
||||
"cast": 0,
|
||||
"recast": 300
|
||||
},
|
||||
"24305": {
|
||||
"cast": 0,
|
||||
"recast": 1200
|
||||
},
|
||||
"24310": {
|
||||
"cast": 0,
|
||||
"recast": 1200
|
||||
},
|
||||
"24311": {
|
||||
"cast": 0,
|
||||
"recast": 1200
|
||||
},
|
||||
"25751": {
|
||||
"cast": 0,
|
||||
"recast": 250
|
||||
},
|
||||
"25789": {
|
||||
"cast": 0,
|
||||
"recast": 15
|
||||
},
|
||||
"25799": {
|
||||
"cast": 0,
|
||||
"recast": 600
|
||||
},
|
||||
"25857": {
|
||||
"cast": 0,
|
||||
"recast": 1200
|
||||
},
|
||||
"25868": {
|
||||
"cast": 0,
|
||||
"recast": 1200
|
||||
},
|
||||
"34685": {
|
||||
"cast": 0,
|
||||
"recast": 1200
|
||||
},
|
||||
"34686": {
|
||||
"cast": 0,
|
||||
"recast": 10
|
||||
},
|
||||
"36920": {
|
||||
"cast": 0,
|
||||
"recast": 1200
|
||||
},
|
||||
"37011": {
|
||||
"cast": 0,
|
||||
"recast": 10
|
||||
},
|
||||
"37025": {
|
||||
"cast": 0,
|
||||
"recast": 10
|
||||
},
|
||||
"37034": {
|
||||
"cast": 0,
|
||||
"recast": 15
|
||||
}
|
||||
}
|
||||
321
css/planner.css
321
css/planner.css
@ -4,6 +4,12 @@
|
||||
grid-template-columns: 280px 1fr;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
#plan-detail-panel,
|
||||
#plan-content {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ── Plan Sidebar ────────────────────────────────────────────────────────────── */
|
||||
@ -659,3 +665,318 @@
|
||||
}
|
||||
.folder-picker-option:hover { background: var(--bg2); color: var(--t1); }
|
||||
.folder-picker-option.active { color: var(--gold); }
|
||||
|
||||
/* ── Planner Timeline ───────────────────────────────────────────────────────── */
|
||||
.timeline-hint {
|
||||
font-size: 12px;
|
||||
color: var(--t3);
|
||||
}
|
||||
|
||||
.timeline-empty,
|
||||
.timeline-settings-empty {
|
||||
font-size: 13px;
|
||||
color: var(--t3);
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.timeline-scroll {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg1);
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.timeline-grid {
|
||||
width: calc(180px + var(--timeline-width));
|
||||
display: grid;
|
||||
grid-template-columns: 180px var(--timeline-width);
|
||||
}
|
||||
|
||||
.timeline-row,
|
||||
.timeline-axis {
|
||||
display: grid;
|
||||
grid-template-columns: 180px var(--timeline-width);
|
||||
min-height: 38px;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.timeline-row-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 10px;
|
||||
background: var(--bgcard);
|
||||
border-right: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 12px;
|
||||
color: var(--t2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.timeline-boss-row {
|
||||
min-height: var(--boss-row-height, 52px);
|
||||
}
|
||||
|
||||
.timeline-boss-row .timeline-row-label {
|
||||
color: var(--gold);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.timeline-track,
|
||||
.timeline-axis-track {
|
||||
position: relative;
|
||||
min-height: inherit;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background:
|
||||
repeating-linear-gradient(
|
||||
to right,
|
||||
transparent 0,
|
||||
transparent 79px,
|
||||
rgba(255,255,255,0.07) 80px
|
||||
);
|
||||
}
|
||||
|
||||
.timeline-hit-line {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(200,168,75,.38);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.timeline-player-row .timeline-track {
|
||||
background-color: rgba(255,255,255,0.015);
|
||||
}
|
||||
|
||||
.timeline-job {
|
||||
width: 36px;
|
||||
text-align: center;
|
||||
border-radius: 3px;
|
||||
padding: 2px 0;
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.timeline-player-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.timeline-boss-action {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
transform: translateX(-50%);
|
||||
max-width: 150px;
|
||||
padding: 5px 8px;
|
||||
border: 1px solid rgba(224,92,92,.35);
|
||||
border-radius: var(--r);
|
||||
background: rgba(224,92,92,.14);
|
||||
color: var(--t1);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.timeline-boss-action:hover {
|
||||
border-color: rgba(224,92,92,.7);
|
||||
background: rgba(224,92,92,.22);
|
||||
}
|
||||
|
||||
.timeline-mitigation {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
width: var(--cd-width);
|
||||
min-width: 28px;
|
||||
height: 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0 6px;
|
||||
border: 1px solid var(--borderem);
|
||||
border-radius: var(--r);
|
||||
background: var(--bg2);
|
||||
color: var(--t1);
|
||||
font-size: 11px;
|
||||
cursor: grab;
|
||||
z-index: 4;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.timeline-mitigation-active {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: var(--active-width);
|
||||
background: currentColor;
|
||||
opacity: 0.22;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.timeline-mitigation:active { cursor: grabbing; }
|
||||
.timeline-mitigation.selected {
|
||||
outline: 2px solid var(--gold);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.timeline-mitigation--candidate {
|
||||
border-style: dashed;
|
||||
opacity: 0.78;
|
||||
}
|
||||
|
||||
.timeline-mitigation img {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.timeline-mitigation span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.timeline-mitigation .timeline-mitigation-active {
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.timeline-mitigation--buff { border-color: rgba(200,168,75,.5); color: var(--gold); background: rgba(200,168,75,.12); }
|
||||
.timeline-mitigation--debuff { border-color: rgba(224,92,92,.5); color: var(--red); background: rgba(224,92,92,.12); }
|
||||
.timeline-mitigation--shield { border-color: rgba(74,158,255,.5); color: var(--blue); background: rgba(74,158,255,.12); }
|
||||
|
||||
.timeline-axis {
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
.timeline-axis-track {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.timeline-tick {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
transform: translateX(-50%);
|
||||
font-size: 11px;
|
||||
color: var(--t3);
|
||||
}
|
||||
|
||||
.timeline-settings-panel {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.timeline-settings-title {
|
||||
font-size: 13px;
|
||||
color: var(--t1);
|
||||
font-weight: 600;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.timeline-settings-meta {
|
||||
font-size: 12px;
|
||||
color: var(--t3);
|
||||
width: 100%;
|
||||
margin-top: -6px;
|
||||
}
|
||||
|
||||
.timeline-settings-panel label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--t3);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.timeline-setting-input {
|
||||
width: 86px !important;
|
||||
font-size: 13px !important;
|
||||
padding: 5px 7px !important;
|
||||
}
|
||||
|
||||
.timeline-context-menu {
|
||||
position: fixed;
|
||||
z-index: 300;
|
||||
min-width: 190px;
|
||||
max-width: 280px;
|
||||
padding: 5px;
|
||||
border: 1px solid var(--borderem);
|
||||
border-radius: var(--r);
|
||||
background: var(--bgcard);
|
||||
box-shadow: 0 10px 24px rgba(0,0,0,0.45);
|
||||
}
|
||||
|
||||
.timeline-menu-item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 7px 9px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--t2);
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.timeline-menu-item:hover {
|
||||
background: var(--bg2);
|
||||
color: var(--t1);
|
||||
}
|
||||
|
||||
.timeline-menu-item.disabled,
|
||||
.timeline-menu-item:disabled {
|
||||
opacity: 0.38;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.timeline-menu-item img {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.timeline-menu-item span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.timeline-menu-empty {
|
||||
padding: 8px 10px;
|
||||
color: var(--t3);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.planner-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.plan-sidebar {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
|
||||
@ -138,13 +138,24 @@
|
||||
return String(name ?? '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function currentFightName() {
|
||||
const fight = (window.App?.fights ?? []).find(f => f.id === window.App?.fightId);
|
||||
return normalizeFightName(fight?.name);
|
||||
function fightEncounterId(fight) {
|
||||
return parseInt(fight?.encounterID ?? fight?.encounterId ?? 0, 10) || 0;
|
||||
}
|
||||
|
||||
function isSameFightName(fight) {
|
||||
const name = currentFightName();
|
||||
function currentFight() {
|
||||
return (window.App?.fights ?? []).find(f => f.id === window.App?.fightId) ?? null;
|
||||
}
|
||||
|
||||
function isSameEncounter(fight) {
|
||||
const selectedFight = currentFight();
|
||||
const selectedEncounterId = fightEncounterId(selectedFight);
|
||||
const encounterId = fightEncounterId(fight);
|
||||
|
||||
if (selectedEncounterId && encounterId) {
|
||||
return encounterId === selectedEncounterId;
|
||||
}
|
||||
|
||||
const name = normalizeFightName(selectedFight?.name);
|
||||
return name !== '' && normalizeFightName(fight?.name) === name;
|
||||
}
|
||||
|
||||
@ -341,7 +352,7 @@
|
||||
let allSameReportFights = [];
|
||||
|
||||
function populateRefFightSelect() {
|
||||
const visible = allSameReportFights.filter(f => f.id !== window.App.fightId && isSameFightName(f));
|
||||
const visible = allSameReportFights.filter(f => f.id !== window.App.fightId && isSameEncounter(f));
|
||||
refFightSelect.innerHTML = '<option value="">Kein Vergleich</option>';
|
||||
visible.forEach(f => {
|
||||
const hp = f.kill ? 'Kill' : (f.fightPercentage != null ? f.fightPercentage.toFixed(2) + '%' : '?');
|
||||
@ -410,7 +421,7 @@
|
||||
extReportCode = code;
|
||||
updateRefFflogsLink();
|
||||
|
||||
const visibleExt = fights.filter(isSameFightName);
|
||||
const visibleExt = fights.filter(isSameEncounter);
|
||||
refExtFightSelect.innerHTML = '<option value="">— Fight auswählen —</option>';
|
||||
visibleExt.forEach(f => {
|
||||
const hp = f.kill ? 'Kill' : (f.fightPercentage != null ? f.fightPercentage.toFixed(2) + '%' : '?');
|
||||
|
||||
564
js/planner.js
564
js/planner.js
@ -96,6 +96,10 @@ function copyPlan(id) {
|
||||
|
||||
let activePlanId = null;
|
||||
let collapsedFolders = new Set();
|
||||
let selectedTimelineAssignment = null;
|
||||
let actionMetaPromise = null;
|
||||
let actionMetaById = {};
|
||||
let actionMetaByName = {};
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@ -129,6 +133,48 @@ function plannerLanguage() {
|
||||
return window.App?.language || localStorage.getItem('ff14-mitigator-language') || 'en';
|
||||
}
|
||||
|
||||
async function ensureActionMetaLoaded() {
|
||||
if (actionMetaPromise) return actionMetaPromise;
|
||||
actionMetaPromise = (async () => {
|
||||
let compact = {};
|
||||
let full = {};
|
||||
try {
|
||||
const res = await fetch('assets/jsons/Action.json', { cache: 'no-cache' });
|
||||
if (res.ok) compact = await res.json();
|
||||
} catch { }
|
||||
try {
|
||||
const res = await fetch('assets/mitigation-actions.json', { cache: 'no-cache' });
|
||||
if (res.ok) full = await res.json();
|
||||
} catch { }
|
||||
|
||||
const byId = {};
|
||||
const byName = {};
|
||||
for (const [id, action] of Object.entries(full ?? {})) {
|
||||
const description = action.Description_en ?? '';
|
||||
const durations = [...description.matchAll(/Duration:\s*(\d+)s/gi)].map(m => parseInt(m[1], 10));
|
||||
const meta = {
|
||||
id,
|
||||
name: action.Name_en ?? '',
|
||||
cast: (parseInt(compact?.[id]?.cast ?? action.Cast100ms ?? 0, 10) || 0) / 10,
|
||||
recast: (parseInt(compact?.[id]?.recast ?? action.Recast100ms ?? 0, 10) || 0) / 10,
|
||||
duration: durations.find(Number.isFinite) ?? 15,
|
||||
};
|
||||
byId[id] = meta;
|
||||
if (meta.name) byName[meta.name] = meta;
|
||||
}
|
||||
for (const [id, action] of Object.entries(compact ?? {})) {
|
||||
byId[id] = {
|
||||
...(byId[id] ?? { id }),
|
||||
cast: (parseInt(action.cast ?? 0, 10) || 0) / 10,
|
||||
recast: (parseInt(action.recast ?? 0, 10) || 0) / 10,
|
||||
};
|
||||
}
|
||||
actionMetaById = byId;
|
||||
actionMetaByName = byName;
|
||||
})();
|
||||
return actionMetaPromise;
|
||||
}
|
||||
|
||||
function sameMechanic(existing, incoming, source) {
|
||||
const fightStart = source?.fightStart ?? 0;
|
||||
const incomingRel = incoming.timestamp - fightStart;
|
||||
@ -138,6 +184,12 @@ function sameMechanic(existing, incoming, source) {
|
||||
return Math.abs(existing.timestamp - incomingRel) < 1500;
|
||||
}
|
||||
|
||||
function visiblePlanMechanics(plan) {
|
||||
return [...(plan?.mechanics ?? [])]
|
||||
.filter(m => m?.id && String(m.name ?? '').trim() !== '' && Number.isFinite(Number(m.timestamp)))
|
||||
.sort((a, b) => a.timestamp - b.timestamp);
|
||||
}
|
||||
|
||||
// ── Rendering: Plan List ──────────────────────────────────────────────────────
|
||||
|
||||
function planItemHtml(p) {
|
||||
@ -313,6 +365,8 @@ function renderPlanDetail(plan) {
|
||||
noplan.style.display = 'none';
|
||||
content.style.display = '';
|
||||
|
||||
const visibleMechanics = visiblePlanMechanics(plan);
|
||||
|
||||
content.innerHTML = `
|
||||
<div class="card section-gap">
|
||||
<div class="plan-detail-header">
|
||||
@ -320,7 +374,7 @@ function renderPlanDetail(plan) {
|
||||
<span id="plan-name-display" class="plan-name-text">${escHtml(plan.name)}</span>
|
||||
<button id="plan-name-edit-btn" class="plan-btn" title="Umbenennen">✎</button>
|
||||
</div>
|
||||
<div class="plan-detail-meta">Erstellt ${fmtDate(plan.createdAt)} · ${plan.mechanics.length} Mechaniken</div>
|
||||
<div class="plan-detail-meta">Erstellt ${fmtDate(plan.createdAt)} · ${visibleMechanics.length} Mechaniken</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -334,6 +388,19 @@ function renderPlanDetail(plan) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card section-gap">
|
||||
<div class="card-title-row">
|
||||
<div class="card-title">Zeitstrahl</div>
|
||||
<div class="timeline-hint">Boss-Aktion klicken zum Zuweisen · Mitigation ziehen · Klick für Zeiten</div>
|
||||
</div>
|
||||
<div id="planner-timeline">
|
||||
${renderTimelineHtml(plan)}
|
||||
</div>
|
||||
<div id="timeline-settings">
|
||||
${renderTimelineSettingsHtml(plan)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-title-row">
|
||||
<div class="card-title">Mechaniken</div>
|
||||
@ -351,8 +418,10 @@ function renderPlanDetail(plan) {
|
||||
document.getElementById('name-import-open-btn')?.addEventListener('click', () => {
|
||||
showNameImportModal(plan.id);
|
||||
});
|
||||
initTimeline(plan.id);
|
||||
initMechanicClicks(plan.id);
|
||||
renderInfoPanel(plan);
|
||||
ensureActionMetaLoaded().then(() => refreshTimeline(plan.id));
|
||||
}
|
||||
|
||||
function avgNonTankMaxHp(plan) {
|
||||
@ -367,7 +436,8 @@ function avgNonTankMaxHp(plan) {
|
||||
}
|
||||
|
||||
function renderMechanicListHtml(plan) {
|
||||
if (plan.mechanics.length === 0) {
|
||||
const mechanics = visiblePlanMechanics(plan);
|
||||
if (mechanics.length === 0) {
|
||||
return `
|
||||
<div class="empty" style="padding:30px 0">
|
||||
<div class="empty-icon" style="font-size:26px">📋</div>
|
||||
@ -382,7 +452,7 @@ function renderMechanicListHtml(plan) {
|
||||
const activeJobSet = new Set(plan.jobComposition.filter(j => j));
|
||||
const avgHp = avgNonTankMaxHp(plan);
|
||||
|
||||
return plan.mechanics.map(m => {
|
||||
return mechanics.map(m => {
|
||||
const sorted = sortedAssignments(m.assignments);
|
||||
const assignHtml = sorted.length === 0
|
||||
? '<span class="mechanic-no-assign">Keine Zuweisung</span>'
|
||||
@ -475,13 +545,488 @@ function initJobSlots(planId) {
|
||||
});
|
||||
}
|
||||
|
||||
// ── Timeline ─────────────────────────────────────────────────────────────────
|
||||
|
||||
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));
|
||||
return Math.max(sourceDuration, mechanicEnd, 60000);
|
||||
}
|
||||
|
||||
function timelineScale(plan) {
|
||||
const duration = planDurationMs(plan);
|
||||
const pxPerSecond = 8;
|
||||
return { duration, width: Math.max(720, Math.ceil(duration / 1000 * pxPerSecond)) };
|
||||
}
|
||||
|
||||
function timelinePlayerRows(plan) {
|
||||
const roster = plan.playerRoster ?? [];
|
||||
return (plan.jobComposition ?? []).map((job, idx) => ({
|
||||
idx,
|
||||
job,
|
||||
name: roster[idx]?.name ?? '',
|
||||
role: JOB_ROLE[job] ?? '',
|
||||
})).filter(row => row.job);
|
||||
}
|
||||
|
||||
function selectedAssignmentMatches(mechanicId, assignment) {
|
||||
return selectedTimelineAssignment
|
||||
&& selectedTimelineAssignment.mechanicId === mechanicId
|
||||
&& selectedTimelineAssignment.ability === assignment.ability
|
||||
&& selectedTimelineAssignment.job === (assignment.job ?? '');
|
||||
}
|
||||
|
||||
function jobCanUseAbility(job, ability) {
|
||||
return (JOB_ABILITIES[job] ?? []).some(a => a.name === ability);
|
||||
}
|
||||
|
||||
function timelineRowsForAssignment(rows, assignment) {
|
||||
const assignedJob = assignment.job ?? '';
|
||||
if (assignedJob) return rows.filter(row => row.job === assignedJob);
|
||||
return rows.filter(row => jobCanUseAbility(row.job, assignment.ability));
|
||||
}
|
||||
|
||||
function assignmentStartMs(mechanic, assignment) {
|
||||
return Number.isFinite(Number(assignment?.timestamp)) ? Number(assignment.timestamp) : mechanic.timestamp;
|
||||
}
|
||||
|
||||
function findNearestMechanic(plan, timestamp) {
|
||||
const mechanics = visiblePlanMechanics(plan);
|
||||
if (!mechanics.length) return null;
|
||||
return mechanics.reduce((best, mechanic) =>
|
||||
Math.abs(mechanic.timestamp - timestamp) < Math.abs(best.timestamp - timestamp) ? mechanic : best
|
||||
, mechanics[0]);
|
||||
}
|
||||
|
||||
function timelineTimestampFromEvent(plan, track, event) {
|
||||
const rect = track.getBoundingClientRect();
|
||||
const x = Math.max(0, Math.min(rect.width, event.clientX - rect.left));
|
||||
return (x / rect.width) * planDurationMs(plan);
|
||||
}
|
||||
|
||||
function layoutBossActions(mechanics, duration) {
|
||||
const lanes = [];
|
||||
const minGapPct = 9;
|
||||
return mechanics.map(mechanic => {
|
||||
const left = (mechanic.timestamp / duration) * 100;
|
||||
let lane = lanes.findIndex(lastLeft => left - lastLeft >= minGapPct);
|
||||
if (lane === -1) {
|
||||
lane = lanes.length;
|
||||
lanes.push(left);
|
||||
} else {
|
||||
lanes[lane] = left;
|
||||
}
|
||||
return { mechanic, left, lane };
|
||||
});
|
||||
}
|
||||
|
||||
function assignmentOverlapsJob(plan, job, ability, timestamp, ignore = null) {
|
||||
for (const mechanic of visiblePlanMechanics(plan)) {
|
||||
for (const assignment of mechanic.assignments ?? []) {
|
||||
if (assignment.ability !== ability) continue;
|
||||
if ((assignment.job ?? '') !== job && !(!(assignment.job ?? '') && jobCanUseAbility(job, ability))) continue;
|
||||
if (ignore && ignore.mechanicId === mechanic.id && ignore.ability === ability && ignore.job === (assignment.job ?? '')) continue;
|
||||
|
||||
const start = assignmentStartMs(mechanic, assignment);
|
||||
const cooldownMs = Math.max(assignmentCooldownSeconds(assignment), assignmentDurationSeconds(assignment)) * 1000;
|
||||
const end = start + cooldownMs;
|
||||
if (timestamp >= start && timestamp < end) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function actionMetaForAssignment(assignment) {
|
||||
const id = String(assignment?.actionId ?? assignment?.extraAbilityGameID ?? '');
|
||||
return (id && actionMetaById[id]) || actionMetaByName[assignment?.ability] || null;
|
||||
}
|
||||
|
||||
function assignmentCooldownSeconds(assignment) {
|
||||
const own = Number(assignment?.cooldownSeconds);
|
||||
if (Number.isFinite(own) && own >= 0) return own;
|
||||
return actionMetaForAssignment(assignment)?.recast ?? 0;
|
||||
}
|
||||
|
||||
function assignmentDurationSeconds(assignment) {
|
||||
const own = Number(assignment?.durationSeconds);
|
||||
if (Number.isFinite(own) && own > 0) return own;
|
||||
return actionMetaForAssignment(assignment)?.duration ?? (assignment?.buffType === 'shield' ? 20 : 15);
|
||||
}
|
||||
|
||||
function renderTimelineHtml(plan) {
|
||||
const mechanics = visiblePlanMechanics(plan);
|
||||
if (!mechanics.length) {
|
||||
return '<div class="timeline-empty">Importiere Mechaniken aus dem Analyse-Tab, um den Zeitstrahl zu nutzen.</div>';
|
||||
}
|
||||
|
||||
const { duration, width } = timelineScale(plan);
|
||||
const rows = timelinePlayerRows(plan);
|
||||
const marks = [];
|
||||
const tick = 10000;
|
||||
for (let t = 0; t <= duration; t += tick) {
|
||||
marks.push(`<span class="timeline-tick" style="left:${(t / duration) * 100}%">${fmtTimestamp(t)}</span>`);
|
||||
}
|
||||
|
||||
const bossActions = layoutBossActions(mechanics, duration);
|
||||
const laneCount = Math.max(1, ...bossActions.map(item => item.lane + 1));
|
||||
const hitLines = mechanics.map(m => `
|
||||
<span class="timeline-hit-line" style="left:${(m.timestamp / duration) * 100}%"></span>
|
||||
`).join('');
|
||||
|
||||
const bossItems = bossActions.map(({ mechanic: m, left, lane }) => `
|
||||
<button class="timeline-boss-action"
|
||||
style="left:${left}%;top:${8 + lane * 30}px"
|
||||
data-mechanic-id="${escHtml(m.id)}"
|
||||
title="${escHtml(fmtTimestamp(m.timestamp))} · ${escHtml(m.name)}">
|
||||
${escHtml(m.name)}
|
||||
</button>
|
||||
`).join('');
|
||||
|
||||
const playerRows = rows.map(row => {
|
||||
const blocks = [];
|
||||
for (const m of mechanics) {
|
||||
for (const a of sortedAssignments(m.assignments ?? [])) {
|
||||
if (!timelineRowsForAssignment(rows, a).some(target => target.job === row.job)) continue;
|
||||
const start = Number.isFinite(Number(a.timestamp)) ? Number(a.timestamp) : m.timestamp;
|
||||
const durationSec = assignmentDurationSeconds(a);
|
||||
const cooldownSec = assignmentCooldownSeconds(a);
|
||||
const left = Math.max(0, Math.min(100, (start / duration) * 100));
|
||||
const widthPct = Math.max(1.2, Math.min(100 - left, (durationSec * 1000 / duration) * 100));
|
||||
const cdWidthPct = cooldownSec > 0 ? Math.max(widthPct, Math.min(100 - left, (cooldownSec * 1000 / duration) * 100)) : widthPct;
|
||||
const activeWidthPct = Math.min(100, (widthPct / cdWidthPct) * 100);
|
||||
const selected = selectedAssignmentMatches(m.id, a) ? ' selected' : '';
|
||||
const unresolved = a.job ? '' : ' timeline-mitigation--candidate';
|
||||
const cls = a.buffType === 'debuff' ? 'timeline-mitigation--debuff'
|
||||
: a.buffType === 'shield' ? 'timeline-mitigation--shield'
|
||||
: 'timeline-mitigation--buff';
|
||||
const icon = MITIG_ICONS[a.ability] ?? '';
|
||||
const ability = assignmentAbilityName(a, plan);
|
||||
blocks.push(`
|
||||
<button class="timeline-mitigation ${cls}${selected}${unresolved}"
|
||||
draggable="true"
|
||||
style="left:${left}%;--cd-width:${cdWidthPct}%;--active-width:${activeWidthPct}%"
|
||||
data-mechanic-id="${escHtml(m.id)}"
|
||||
data-ability="${escHtml(a.ability)}"
|
||||
data-job="${escHtml(a.job ?? '')}"
|
||||
title="${escHtml(ability)} · aktiv ${durationSec}s · CD ${cooldownSec}s${a.job ? '' : ' · mögliche Zuordnung'}">
|
||||
<span class="timeline-mitigation-active"></span>
|
||||
${icon ? `<img src="${escHtml(icon)}" alt="">` : ''}
|
||||
<span>${escHtml(ability)}</span>
|
||||
</button>
|
||||
`);
|
||||
}
|
||||
}
|
||||
return `
|
||||
<div class="timeline-row timeline-player-row" data-row-idx="${row.idx}" data-job="${escHtml(row.job)}">
|
||||
<div class="timeline-row-label">
|
||||
<span class="timeline-job role-${escHtml(row.role)}">${escHtml(row.job)}</span>
|
||||
<span class="timeline-player-name">${escHtml(row.name || `Slot ${row.idx + 1}`)}</span>
|
||||
</div>
|
||||
<div class="timeline-track" style="width:${width}px">${hitLines}${blocks.join('')}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
return `
|
||||
<div class="timeline-scroll">
|
||||
<div class="timeline-grid" style="--timeline-width:${width}px">
|
||||
<div class="timeline-row timeline-boss-row" style="--boss-row-height:${Math.max(52, 18 + laneCount * 30)}px">
|
||||
<div class="timeline-row-label">Boss</div>
|
||||
<div class="timeline-track" style="width:${width}px">${hitLines}${bossItems}</div>
|
||||
</div>
|
||||
${playerRows}
|
||||
<div class="timeline-axis">
|
||||
<div></div>
|
||||
<div class="timeline-axis-track" style="width:${width}px">${marks.join('')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function findTimelineAssignment(plan, selected = selectedTimelineAssignment) {
|
||||
if (!plan || !selected) return null;
|
||||
const mechanic = (plan.mechanics ?? []).find(m => m.id === selected.mechanicId);
|
||||
if (!mechanic) return null;
|
||||
const assignment = (mechanic.assignments ?? []).find(a =>
|
||||
a.ability === selected.ability && (a.job ?? '') === selected.job
|
||||
);
|
||||
return assignment ? { mechanic, assignment } : null;
|
||||
}
|
||||
|
||||
function renderTimelineSettingsHtml(plan) {
|
||||
const found = findTimelineAssignment(plan);
|
||||
if (!found) {
|
||||
return '<div class="timeline-settings-empty">Mitigation im Zeitstrahl auswählen, um Dauer und Cooldown anzupassen.</div>';
|
||||
}
|
||||
|
||||
const { mechanic, assignment } = found;
|
||||
const ability = assignmentAbilityName(assignment, plan);
|
||||
const jobLabel = assignment.job || 'Nicht zugewiesen';
|
||||
return `
|
||||
<div class="timeline-settings-panel">
|
||||
<div class="timeline-settings-title">${escHtml(jobLabel)} · ${escHtml(ability)}</div>
|
||||
<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)}">
|
||||
</label>
|
||||
<label>
|
||||
<span>Dauer</span>
|
||||
<input class="timeline-setting-input" data-field="durationSeconds" type="number" min="1" step="1" value="${assignmentDurationSeconds(assignment)}">
|
||||
</label>
|
||||
<label>
|
||||
<span>Cooldown</span>
|
||||
<input class="timeline-setting-input" data-field="cooldownSeconds" type="number" min="0" step="1" value="${assignmentCooldownSeconds(assignment)}">
|
||||
</label>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function refreshTimeline(planId) {
|
||||
const plan = getPlan(planId);
|
||||
if (!plan) return;
|
||||
const timeline = document.getElementById('planner-timeline');
|
||||
const settings = document.getElementById('timeline-settings');
|
||||
if (timeline) timeline.innerHTML = renderTimelineHtml(plan);
|
||||
if (settings) settings.innerHTML = renderTimelineSettingsHtml(plan);
|
||||
}
|
||||
|
||||
function setTimelineAssignmentField(planId, mechanicId, ability, job, field, value) {
|
||||
const plan = getPlan(planId);
|
||||
if (!plan) return;
|
||||
const mechanic = plan.mechanics.find(m => m.id === mechanicId);
|
||||
const assignment = mechanic?.assignments?.find(a => a.ability === ability && (a.job ?? '') === job);
|
||||
if (!assignment) return;
|
||||
|
||||
if (field === 'timestamp') assignment.timestamp = Math.max(0, Math.round(Number(value) * 1000));
|
||||
if (field === 'durationSeconds') assignment.durationSeconds = Math.max(1, Math.round(Number(value)));
|
||||
if (field === 'cooldownSeconds') assignment.cooldownSeconds = Math.max(0, Math.round(Number(value)));
|
||||
updatePlan(planId, { mechanics: plan.mechanics });
|
||||
refreshTimeline(planId);
|
||||
}
|
||||
|
||||
function setTimelineAssignmentJob(planId, mechanicId, ability, job, nextJob) {
|
||||
const plan = getPlan(planId);
|
||||
if (!plan || !jobCanUseAbility(nextJob, ability)) return;
|
||||
const mechanic = plan.mechanics.find(m => m.id === mechanicId);
|
||||
const assignment = mechanic?.assignments?.find(a => a.ability === ability && (a.job ?? '') === job);
|
||||
if (!assignment) return;
|
||||
const timestamp = assignmentStartMs(mechanic, assignment);
|
||||
if (assignmentOverlapsJob(plan, nextJob, ability, timestamp, { mechanicId, ability, job })) return;
|
||||
assignment.job = nextJob;
|
||||
selectedTimelineAssignment = { mechanicId, ability, job: nextJob };
|
||||
updatePlan(planId, { mechanics: plan.mechanics });
|
||||
refreshMechanicList(planId);
|
||||
}
|
||||
|
||||
function removeTimelineAssignment(planId, mechanicId, ability, job) {
|
||||
const plan = getPlan(planId);
|
||||
if (!plan) return;
|
||||
const mechanic = plan.mechanics.find(m => m.id === mechanicId);
|
||||
if (!mechanic) return;
|
||||
mechanic.assignments = (mechanic.assignments ?? []).filter(a => !(a.ability === ability && (a.job ?? '') === job));
|
||||
if (selectedTimelineAssignment?.mechanicId === mechanicId && selectedTimelineAssignment?.ability === ability && selectedTimelineAssignment?.job === job) {
|
||||
selectedTimelineAssignment = null;
|
||||
}
|
||||
updatePlan(planId, { mechanics: plan.mechanics });
|
||||
refreshMechanicList(planId);
|
||||
}
|
||||
|
||||
function addTimelineAssignment(planId, mechanicId, ability, job, buffType, timestamp) {
|
||||
const plan = getPlan(planId);
|
||||
if (!plan || !jobCanUseAbility(job, ability)) return;
|
||||
const mechanic = plan.mechanics.find(m => m.id === mechanicId);
|
||||
if (!mechanic) return;
|
||||
mechanic.assignments = mechanic.assignments ?? [];
|
||||
mechanic.assignments.push({
|
||||
ability,
|
||||
abilityName: plan.mitigationNames?.[ability],
|
||||
actionId: actionMetaByName[ability]?.id ?? null,
|
||||
job,
|
||||
buffType,
|
||||
timestamp: Math.max(0, Math.round(timestamp)),
|
||||
});
|
||||
selectedTimelineAssignment = { mechanicId, ability, job };
|
||||
updatePlan(planId, { mechanics: plan.mechanics });
|
||||
refreshMechanicList(planId);
|
||||
}
|
||||
|
||||
function closeTimelineMenu() {
|
||||
document.getElementById('timeline-context-menu')?.remove();
|
||||
}
|
||||
|
||||
function showTimelineMenu(x, y, items) {
|
||||
closeTimelineMenu();
|
||||
const menu = document.createElement('div');
|
||||
menu.id = 'timeline-context-menu';
|
||||
menu.className = 'timeline-context-menu';
|
||||
menu.innerHTML = items.length
|
||||
? items.map((item, idx) => `
|
||||
<button class="timeline-menu-item${item.disabled ? ' disabled' : ''}" data-idx="${idx}"${item.disabled ? ' disabled' : ''}>
|
||||
${item.icon ? `<img src="${escHtml(item.icon)}" alt="">` : ''}
|
||||
<span>${escHtml(item.label)}</span>
|
||||
</button>
|
||||
`).join('')
|
||||
: '<div class="timeline-menu-empty">Keine verfügbare Fähigkeit</div>';
|
||||
document.body.appendChild(menu);
|
||||
|
||||
const rect = menu.getBoundingClientRect();
|
||||
menu.style.left = Math.min(x, window.innerWidth - rect.width - 8) + 'px';
|
||||
menu.style.top = Math.min(y, window.innerHeight - rect.height - 8) + 'px';
|
||||
|
||||
menu.addEventListener('click', e => {
|
||||
const btn = e.target.closest('.timeline-menu-item');
|
||||
if (!btn || btn.disabled) return;
|
||||
items[parseInt(btn.dataset.idx, 10)]?.onClick?.();
|
||||
closeTimelineMenu();
|
||||
document.removeEventListener('click', closeOutside, true);
|
||||
document.removeEventListener('contextmenu', closeOutside, true);
|
||||
});
|
||||
|
||||
const closeOutside = ev => {
|
||||
if (menu.contains(ev.target)) return;
|
||||
closeTimelineMenu();
|
||||
document.removeEventListener('click', closeOutside, true);
|
||||
document.removeEventListener('contextmenu', closeOutside, true);
|
||||
};
|
||||
setTimeout(() => {
|
||||
document.addEventListener('click', closeOutside, true);
|
||||
document.addEventListener('contextmenu', closeOutside, true);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function updateTimelineAssignmentPosition(planId, mechanicId, ability, job, rowJob, timestamp) {
|
||||
const plan = getPlan(planId);
|
||||
if (!plan) return;
|
||||
const mechanic = plan.mechanics.find(m => m.id === mechanicId);
|
||||
const assignment = mechanic?.assignments?.find(a => a.ability === ability && (a.job ?? '') === job);
|
||||
if (!assignment) return;
|
||||
if (!jobCanUseAbility(rowJob, ability)) return;
|
||||
assignment.timestamp = Math.max(0, Math.round(timestamp));
|
||||
assignment.job = rowJob;
|
||||
selectedTimelineAssignment = { mechanicId, ability, job: rowJob };
|
||||
updatePlan(planId, { mechanics: plan.mechanics });
|
||||
refreshTimeline(planId);
|
||||
refreshMechanicList(planId, false);
|
||||
}
|
||||
|
||||
function initTimeline(planId) {
|
||||
const timeline = document.getElementById('planner-timeline');
|
||||
const settings = document.getElementById('timeline-settings');
|
||||
if (!timeline || !settings) return;
|
||||
|
||||
timeline.addEventListener('click', e => {
|
||||
closeTimelineMenu();
|
||||
const boss = e.target.closest('.timeline-boss-action');
|
||||
if (boss) {
|
||||
showAbilityModal(planId, boss.dataset.mechanicId);
|
||||
return;
|
||||
}
|
||||
const block = e.target.closest('.timeline-mitigation');
|
||||
if (block) {
|
||||
selectedTimelineAssignment = {
|
||||
mechanicId: block.dataset.mechanicId,
|
||||
ability: block.dataset.ability,
|
||||
job: block.dataset.job,
|
||||
};
|
||||
refreshTimeline(planId);
|
||||
const plan = getPlan(planId);
|
||||
const rows = timelinePlayerRows(plan ?? {});
|
||||
const found = findTimelineAssignment(plan, selectedTimelineAssignment);
|
||||
const timestamp = found ? assignmentStartMs(found.mechanic, found.assignment) : 0;
|
||||
const items = rows
|
||||
.filter(row => jobCanUseAbility(row.job, block.dataset.ability))
|
||||
.map(row => ({
|
||||
label: `${row.job}${row.name ? ` · ${row.name}` : ''}`,
|
||||
icon: MITIG_ICONS[block.dataset.ability] ?? '',
|
||||
disabled: assignmentOverlapsJob(plan, row.job, block.dataset.ability, timestamp, {
|
||||
mechanicId: block.dataset.mechanicId,
|
||||
ability: block.dataset.ability,
|
||||
job: block.dataset.job,
|
||||
}),
|
||||
onClick: () => setTimelineAssignmentJob(planId, block.dataset.mechanicId, block.dataset.ability, block.dataset.job, row.job),
|
||||
}));
|
||||
showTimelineMenu(e.clientX, e.clientY, items);
|
||||
return;
|
||||
}
|
||||
|
||||
const track = e.target.closest('.timeline-player-row .timeline-track');
|
||||
const row = e.target.closest('.timeline-player-row');
|
||||
if (!track || !row) return;
|
||||
const plan = getPlan(planId);
|
||||
if (!plan) return;
|
||||
const timestamp = timelineTimestampFromEvent(plan, track, e);
|
||||
const mechanic = findNearestMechanic(plan, timestamp);
|
||||
if (!mechanic) return;
|
||||
const items = (JOB_ABILITIES[row.dataset.job] ?? [])
|
||||
.filter(ab => !assignmentOverlapsJob(plan, row.dataset.job, ab.name, timestamp))
|
||||
.map(ab => ({
|
||||
label: `${plan.mitigationNames?.[ab.name] ?? ab.name} · ${fmtTimestamp(timestamp)}`,
|
||||
icon: MITIG_ICONS[ab.name] ?? '',
|
||||
onClick: () => addTimelineAssignment(planId, mechanic.id, ab.name, row.dataset.job, ab.buffType, timestamp),
|
||||
}));
|
||||
showTimelineMenu(e.clientX, e.clientY, items);
|
||||
});
|
||||
|
||||
timeline.addEventListener('contextmenu', e => {
|
||||
const block = e.target.closest('.timeline-mitigation');
|
||||
if (!block) return;
|
||||
e.preventDefault();
|
||||
closeTimelineMenu();
|
||||
removeTimelineAssignment(planId, block.dataset.mechanicId, block.dataset.ability, block.dataset.job);
|
||||
});
|
||||
|
||||
timeline.addEventListener('dragstart', e => {
|
||||
const block = e.target.closest('.timeline-mitigation');
|
||||
if (!block) return;
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', JSON.stringify({
|
||||
mechanicId: block.dataset.mechanicId,
|
||||
ability: block.dataset.ability,
|
||||
job: block.dataset.job,
|
||||
}));
|
||||
});
|
||||
|
||||
timeline.addEventListener('dragover', e => {
|
||||
if (e.target.closest('.timeline-player-row .timeline-track')) e.preventDefault();
|
||||
});
|
||||
|
||||
timeline.addEventListener('drop', e => {
|
||||
const track = e.target.closest('.timeline-player-row .timeline-track');
|
||||
const row = e.target.closest('.timeline-player-row');
|
||||
if (!track || !row) return;
|
||||
e.preventDefault();
|
||||
let data;
|
||||
try { data = JSON.parse(e.dataTransfer.getData('text/plain')); }
|
||||
catch { return; }
|
||||
const plan = getPlan(planId);
|
||||
if (!plan) return;
|
||||
const rect = track.getBoundingClientRect();
|
||||
const x = Math.max(0, Math.min(rect.width, e.clientX - rect.left));
|
||||
const timestamp = (x / rect.width) * planDurationMs(plan);
|
||||
updateTimelineAssignmentPosition(planId, data.mechanicId, data.ability, data.job, row.dataset.job, timestamp);
|
||||
});
|
||||
|
||||
settings.addEventListener('change', e => {
|
||||
const input = e.target.closest('.timeline-setting-input');
|
||||
if (!input || !selectedTimelineAssignment) return;
|
||||
setTimelineAssignmentField(
|
||||
planId,
|
||||
selectedTimelineAssignment.mechanicId,
|
||||
selectedTimelineAssignment.ability,
|
||||
selectedTimelineAssignment.job,
|
||||
input.dataset.field,
|
||||
input.value
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Mechanic list helpers ─────────────────────────────────────────────────────
|
||||
|
||||
function refreshMechanicList(planId) {
|
||||
function refreshMechanicList(planId, includeTimeline = true) {
|
||||
const plan = getPlan(planId);
|
||||
if (!plan) return;
|
||||
const el = document.getElementById('mechanic-list');
|
||||
if (el) el.innerHTML = renderMechanicListHtml(plan);
|
||||
if (includeTimeline) refreshTimeline(planId);
|
||||
renderInfoPanel(plan);
|
||||
}
|
||||
|
||||
@ -505,7 +1050,7 @@ function renderInfoPanel(plan) {
|
||||
const noJobAbilities = new Set();
|
||||
const missingJobPairs = new Set();
|
||||
|
||||
for (const m of plan.mechanics) {
|
||||
for (const m of visiblePlanMechanics(plan)) {
|
||||
for (const a of m.assignments) {
|
||||
if (!a.job) {
|
||||
noJobAbilities.add(a.ability);
|
||||
@ -1007,6 +1552,7 @@ function aoeEventsToMechanics(aoeEvents, fightStart, phases, players, withMitiga
|
||||
assignments.push({
|
||||
ability: key,
|
||||
abilityName: mitigationDisplayName(m) || mitigationNames[key],
|
||||
actionId: m.extraAbilityGameID ?? null,
|
||||
job: guessJob(key, players),
|
||||
buffType: m.buffType ?? '',
|
||||
});
|
||||
@ -1266,7 +1812,13 @@ function toggleAbilityAssignment(abilityName, job, buffType) {
|
||||
mechanic.assignments[idx].job = job;
|
||||
}
|
||||
} else {
|
||||
mechanic.assignments.push({ ability: abilityName, abilityName: plan.mitigationNames?.[abilityName], job, buffType });
|
||||
mechanic.assignments.push({
|
||||
ability: abilityName,
|
||||
abilityName: plan.mitigationNames?.[abilityName],
|
||||
actionId: actionMetaByName[abilityName]?.id ?? null,
|
||||
job,
|
||||
buffType,
|
||||
});
|
||||
}
|
||||
|
||||
updatePlan(abilityModalPlanId, { mechanics: plan.mechanics });
|
||||
|
||||
235
scripts/update_action_json.php
Normal file
235
scripts/update_action_json.php
Normal file
@ -0,0 +1,235 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
ini_set('memory_limit', '1024M');
|
||||
|
||||
const ACTION_SOURCE_URL = 'https://ff14.akurosiakamo.de/extras/json/xivapi_data/Action.json';
|
||||
|
||||
$rootDir = dirname(__DIR__);
|
||||
$mitigationSource = $rootDir . '/api/analysis.php';
|
||||
$outputFile = $rootDir . '/assets/jsons/Action.json';
|
||||
|
||||
function fail(string $message, int $code = 1): void
|
||||
{
|
||||
fwrite(STDERR, $message . PHP_EOL);
|
||||
exit($code);
|
||||
}
|
||||
|
||||
function extract_constant_array_literal(string $php, string $constantName): string
|
||||
{
|
||||
$needle = 'const ' . $constantName . ' =';
|
||||
$start = strpos($php, $needle);
|
||||
|
||||
if ($start === false) {
|
||||
fail('Could not find const ' . $constantName . ' in api/analysis.php');
|
||||
}
|
||||
|
||||
$arrayStart = strpos($php, '[', $start);
|
||||
if ($arrayStart === false) {
|
||||
fail('Could not find array literal for ' . $constantName);
|
||||
}
|
||||
|
||||
$depth = 0;
|
||||
$length = strlen($php);
|
||||
$inString = false;
|
||||
$stringQuote = '';
|
||||
$escaped = false;
|
||||
|
||||
for ($i = $arrayStart; $i < $length; $i++) {
|
||||
$char = $php[$i];
|
||||
|
||||
if ($inString) {
|
||||
if ($escaped) {
|
||||
$escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($char === '\\') {
|
||||
$escaped = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($char === $stringQuote) {
|
||||
$inString = false;
|
||||
$stringQuote = '';
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($char === '\'' || $char === '"') {
|
||||
$inString = true;
|
||||
$stringQuote = $char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($char === '[') {
|
||||
$depth++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($char === ']') {
|
||||
$depth--;
|
||||
|
||||
if ($depth === 0) {
|
||||
return substr($php, $arrayStart, $i - $arrayStart + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fail('Could not parse array literal for ' . $constantName);
|
||||
}
|
||||
|
||||
function read_mitigation_action_ids(string $sourceFile): array
|
||||
{
|
||||
if (!is_file($sourceFile)) {
|
||||
fail('Missing mitigation source file: ' . $sourceFile);
|
||||
}
|
||||
|
||||
$php = file_get_contents($sourceFile);
|
||||
if ($php === false) {
|
||||
fail('Could not read mitigation source file: ' . $sourceFile);
|
||||
}
|
||||
|
||||
$literal = extract_constant_array_literal($php, 'MITIGATION_ABILITIES');
|
||||
$abilities = eval('return ' . $literal . ';');
|
||||
|
||||
if (!is_array($abilities)) {
|
||||
fail('MITIGATION_ABILITIES did not parse as an array');
|
||||
}
|
||||
|
||||
$ids = [];
|
||||
foreach ($abilities as $name => $ability) {
|
||||
$id = (int)($ability['extraAbilityGameID'] ?? 0);
|
||||
if ($id <= 0) {
|
||||
fwrite(STDERR, 'Skipping mitigation without extraAbilityGameID: ' . $name . PHP_EOL);
|
||||
continue;
|
||||
}
|
||||
|
||||
$ids[$id] = true;
|
||||
}
|
||||
|
||||
if (!$ids) {
|
||||
fail('No extraAbilityGameID values found in MITIGATION_ABILITIES');
|
||||
}
|
||||
|
||||
ksort($ids, SORT_NUMERIC);
|
||||
return array_keys($ids);
|
||||
}
|
||||
|
||||
function download_url(string $url): string
|
||||
{
|
||||
$lastError = '';
|
||||
$allowInsecureDownload = getenv('FF14_MITIGATOR_INSECURE_DOWNLOAD') === '1';
|
||||
|
||||
if (function_exists('curl_init')) {
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_CONNECTTIMEOUT => 15,
|
||||
CURLOPT_TIMEOUT => 120,
|
||||
CURLOPT_USERAGENT => 'ff14-mitigator-action-cache/1.0',
|
||||
CURLOPT_SSL_VERIFYPEER => !$allowInsecureDownload,
|
||||
CURLOPT_SSL_VERIFYHOST => $allowInsecureDownload ? 0 : 2,
|
||||
]);
|
||||
|
||||
$body = curl_exec($ch);
|
||||
$error = curl_error($ch);
|
||||
$status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($body !== false && $status >= 200 && $status < 300) {
|
||||
return $body;
|
||||
}
|
||||
|
||||
$lastError = 'cURL HTTP ' . $status . ($error ? ': ' . $error : '');
|
||||
}
|
||||
|
||||
$wrappers = stream_get_wrappers();
|
||||
if (in_array('https', $wrappers, true)) {
|
||||
$context = stream_context_create([
|
||||
'http' => [
|
||||
'timeout' => 120,
|
||||
'user_agent' => 'ff14-mitigator-action-cache/1.0',
|
||||
],
|
||||
'ssl' => [
|
||||
'verify_peer' => !$allowInsecureDownload,
|
||||
'verify_peer_name' => !$allowInsecureDownload,
|
||||
],
|
||||
]);
|
||||
|
||||
$body = file_get_contents($url, false, $context);
|
||||
if ($body !== false) {
|
||||
return $body;
|
||||
}
|
||||
|
||||
$lastError = trim($lastError . '; file_get_contents failed', '; ');
|
||||
}
|
||||
|
||||
if ($lastError !== '') {
|
||||
fail('Could not download Action.json. ' . $lastError);
|
||||
}
|
||||
|
||||
fail('This PHP installation has neither cURL nor the HTTPS stream wrapper enabled.');
|
||||
}
|
||||
|
||||
function action_field(array $action, string $field): ?int
|
||||
{
|
||||
if (array_key_exists($field, $action) && is_numeric($action[$field])) {
|
||||
return (int)$action[$field];
|
||||
}
|
||||
|
||||
$fields = $action['fields'] ?? null;
|
||||
if (is_array($fields) && array_key_exists($field, $fields) && is_numeric($fields[$field])) {
|
||||
return (int)$fields[$field];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$actionIds = read_mitigation_action_ids($mitigationSource);
|
||||
$wanted = array_fill_keys(array_map('strval', $actionIds), true);
|
||||
|
||||
$json = download_url(ACTION_SOURCE_URL);
|
||||
$actions = json_decode($json, true);
|
||||
|
||||
if (!is_array($actions)) {
|
||||
fail('Downloaded Action.json is not valid JSON: ' . json_last_error_msg());
|
||||
}
|
||||
|
||||
$filtered = [];
|
||||
foreach ($wanted as $id => $_) {
|
||||
$action = $actions[$id] ?? null;
|
||||
if (!is_array($action)) {
|
||||
fwrite(STDERR, 'Missing action in downloaded Action.json: ' . $id . PHP_EOL);
|
||||
continue;
|
||||
}
|
||||
|
||||
$filtered[$id] = [
|
||||
'cast' => action_field($action, 'Cast100ms'),
|
||||
'recast' => action_field($action, 'Recast100ms'),
|
||||
];
|
||||
}
|
||||
|
||||
if (!$filtered) {
|
||||
fail('No matching mitigation actions found in downloaded Action.json');
|
||||
}
|
||||
|
||||
ksort($filtered, SORT_NUMERIC);
|
||||
|
||||
$outputDir = dirname($outputFile);
|
||||
if (!is_dir($outputDir) && !mkdir($outputDir, 0775, true) && !is_dir($outputDir)) {
|
||||
fail('Could not create output directory: ' . $outputDir);
|
||||
}
|
||||
|
||||
$encoded = json_encode($filtered, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
if ($encoded === false) {
|
||||
fail('Could not encode filtered Action.json: ' . json_last_error_msg());
|
||||
}
|
||||
|
||||
if (file_put_contents($outputFile, $encoded . PHP_EOL, LOCK_EX) === false) {
|
||||
fail('Could not write output file: ' . $outputFile);
|
||||
}
|
||||
|
||||
echo 'Saved ' . count($filtered) . ' actions to ' . $outputFile . PHP_EOL;
|
||||
Loading…
x
Reference in New Issue
Block a user