diff --git a/css/planner.css b/css/planner.css index c517d57..191674b 100644 --- a/css/planner.css +++ b/css/planner.css @@ -137,27 +137,52 @@ color: var(--t3); } -/* ── Job Slots Placeholder ───────────────────────────────────────────────────── */ -.job-slots-placeholder { - padding: 20px; - text-align: center; - color: var(--t3); - font-size: 13px; - background: var(--bg2); - border: 1px dashed var(--border); - border-radius: var(--r); +/* ── Job Slots ────────────────────────────────────────────────────────────────── */ +.job-slots-grid { + display: flex; + gap: 8px; } +.job-slot { + flex: 1; + min-width: 0; +} + +.job-slot select { + width: 100%; + font-size: 13px; + padding: 5px 6px; + border-left: 2px solid var(--border); +} + +.job-slot--tank select { border-left-color: var(--blue); } +.job-slot--healer select { border-left-color: var(--green); } +.job-slot--dps select { border-left-color: rgba(200,168,75,.5); } + /* ── Mechanic Cards ──────────────────────────────────────────────────────────── */ .mechanic-card { display: grid; grid-template-columns: 52px 1fr; gap: 14px; - padding: 12px 0; + padding: 12px 6px; border-bottom: 1px solid var(--border); align-items: start; + cursor: pointer; + transition: background 0.12s; + border-radius: var(--r); + margin: 0 -6px; } .mechanic-card:last-child { border-bottom: none; } +.mechanic-card:hover { background: var(--bg2); } + +.mechanic-edit-hint { + font-size: 11px; + color: var(--t3); + margin-top: 2px; + opacity: 0; + transition: opacity 0.12s; +} +.mechanic-card:hover .mechanic-edit-hint { opacity: 1; } .mechanic-time { font-family: var(--font-d); @@ -309,3 +334,45 @@ gap: 8px; margin-top: 6px; } + +/* ── Ability Assignment Modal ────────────────────────────────────────────────── */ +.ability-modal-box { + max-width: 640px; + max-height: 80vh; + overflow-y: auto; +} + +.ability-job-group { + margin-bottom: 14px; +} + +.ability-job-label { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 6px; +} + +.ability-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.ability-chip { + background: none; + border: 1px solid var(--borderem); + border-radius: var(--r); + padding: 4px 10px; + font-size: 12px; + color: var(--t2); + cursor: pointer; + transition: all 0.12s; +} +.ability-chip:hover { background: var(--bg3); color: var(--t1); } + +.ability-chip.badge-assign-buff.ability-chip--active { background: rgba(200,168,75,.18); border-color: rgba(200,168,75,.6); color: var(--gold); } +.ability-chip.badge-assign-debuff.ability-chip--active { background: rgba(224,92,92,.18); border-color: rgba(224,92,92,.6); color: var(--red); } +.ability-chip.badge-assign-shield.ability-chip--active { background: rgba(74,158,255,.18); border-color: rgba(74,158,255,.6); color: var(--blue); } + +.ability-chip--other-job { opacity: 0.45; } diff --git a/js/planner.js b/js/planner.js index deca3bd..322b0c1 100644 --- a/js/planner.js +++ b/js/planner.js @@ -175,8 +175,8 @@ function renderPlanDetail(plan) {
Jobaufstellung
-
- Jobaufstellung wird in einem späteren Schritt konfigurierbar +
+ ${renderJobSlotsHtml(plan)}
@@ -184,16 +184,20 @@ function renderPlanDetail(plan) {
Mechaniken
- ${renderMechanicList(plan)} +
+ ${renderMechanicListHtml(plan)} +
`; document.getElementById('plan-name-edit-btn')?.addEventListener('click', () => { startRename(plan.id, plan.name); }); + initJobSlots(plan.id); + initMechanicClicks(plan.id); } -function renderMechanicList(plan) { +function renderMechanicListHtml(plan) { if (plan.mechanics.length === 0) { return `
@@ -207,7 +211,7 @@ function renderMechanicList(plan) { } return plan.mechanics.map(m => ` -
+
${escHtml(fmtTimestamp(m.timestamp))}
${m.phase ? `
${escHtml(m.phase)}
` : ''} @@ -230,11 +234,70 @@ function renderMechanicList(plan) { }
${m.notes ? `
${escHtml(m.notes)}
` : ''} +
Klicken zum Bearbeiten
`).join(''); } +// ── Job Slots ───────────────────────────────────────────────────────────────── + +function renderJobSlotsHtml(plan) { + return Array.from({ length: 8 }, (_, i) => { + const job = plan.jobComposition[i] ?? ''; + const role = JOB_ROLE[job] ?? ''; + return ` +
+ +
`; + }).join(''); +} + +function initJobSlots(planId) { + const grid = document.getElementById('job-slots-grid'); + if (!grid) return; + grid.addEventListener('change', e => { + const sel = e.target.closest('.job-slot-select'); + if (!sel) return; + const plan = getPlan(planId); + if (!plan) return; + const comp = [...plan.jobComposition]; + comp[parseInt(sel.dataset.idx, 10)] = sel.value; + updatePlan(planId, { jobComposition: comp }); + const slot = sel.closest('.job-slot'); + if (slot) { + const role = JOB_ROLE[sel.value] ?? ''; + slot.className = 'job-slot' + (role ? ` job-slot--${role}` : ''); + } + }); +} + +// ── Mechanic list helpers ───────────────────────────────────────────────────── + +function refreshMechanicList(planId) { + const plan = getPlan(planId); + if (!plan) return; + const el = document.getElementById('mechanic-list'); + if (el) el.innerHTML = renderMechanicListHtml(plan); +} + +function initMechanicClicks(planId) { + const list = document.getElementById('mechanic-list'); + if (!list) return; + list.addEventListener('click', e => { + const card = e.target.closest('.mechanic-card'); + if (!card) return; + showAbilityModal(planId, card.dataset.mechanicId); + }); +} + // ── Rename ──────────────────────────────────────────────────────────────────── function startRename(id, currentName) { @@ -348,6 +411,102 @@ const TANK_JOBS = new Set(['PLD', 'WAR', 'DRK', 'GNB']); const MELEE_JOBS = new Set(['MNK', 'DRG', 'NIN', 'SAM', 'RPR', 'VPR']); const CASTER_JOBS = new Set(['BLM', 'SMN', 'RDM', 'PCT']); +const JOB_ROLE = { + 'PLD': 'tank', 'WAR': 'tank', 'DRK': 'tank', 'GNB': 'tank', + 'WHM': 'healer', 'SCH': 'healer', 'AST': 'healer', 'SGE': 'healer', + 'MNK': 'dps', 'DRG': 'dps', 'NIN': 'dps', 'SAM': 'dps', + 'RPR': 'dps', 'VPR': 'dps', 'BRD': 'dps', 'MCH': 'dps', + 'DNC': 'dps', 'BLM': 'dps', 'SMN': 'dps', 'RDM': 'dps', 'PCT': 'dps', +}; + +const ALL_JOBS = [ + { group: 'Tank', jobs: ['PLD', 'WAR', 'DRK', 'GNB'] }, + { group: 'Healer', jobs: ['WHM', 'SCH', 'AST', 'SGE'] }, + { group: 'Melee', jobs: ['MNK', 'DRG', 'NIN', 'SAM', 'RPR', 'VPR'] }, + { group: 'Ranged', jobs: ['BRD', 'MCH', 'DNC'] }, + { group: 'Caster', jobs: ['BLM', 'SMN', 'RDM', 'PCT'] }, +]; + +const JOB_ABILITIES = { + 'PLD': [ + { name: 'Passage of Arms', buffType: 'buff' }, + { name: 'Divine Veil', buffType: 'shield' }, + { name: 'Guardian', buffType: 'shield' }, + { name: 'Reprisal', buffType: 'debuff' }, + ], + 'WAR': [ + { name: 'Shake It Off', buffType: 'shield' }, + { name: 'Bloodwhetting', buffType: 'shield' }, + { name: 'Reprisal', buffType: 'debuff' }, + ], + 'DRK': [ + { name: 'Dark Missionary', buffType: 'buff' }, + { name: 'Reprisal', buffType: 'debuff' }, + ], + 'GNB': [ + { name: 'Heart of Light', buffType: 'buff' }, + { name: 'Reprisal', buffType: 'debuff' }, + ], + 'WHM': [ + { name: 'Temperance', buffType: 'buff' }, + { name: 'Divine Benison', buffType: 'shield' }, + { name: 'Divine Caress', buffType: 'shield' }, + ], + 'SCH': [ + { name: 'Sacred Soil', buffType: 'buff' }, + { name: 'Expedient', buffType: 'buff' }, + { name: 'Fey Illumination', buffType: 'buff' }, + { name: 'Galvanize', buffType: 'shield' }, + { name: 'Seraphic Veil', buffType: 'shield' }, + { name: 'Catalyze', buffType: 'shield' }, + { name: 'Addle', buffType: 'debuff' }, + ], + 'AST': [ + { name: 'Collective Unconscious', buffType: 'buff' }, + { name: 'Neutral Sect', buffType: 'shield' }, + { name: 'Intersection', buffType: 'shield' }, + { name: 'the Spire', buffType: 'shield' }, + ], + 'SGE': [ + { name: 'Kerachole', buffType: 'buff' }, + { name: 'Holos', buffType: 'buff' }, + { name: 'Holosakos', buffType: 'shield' }, + { name: 'Panhaima', buffType: 'shield' }, + { name: 'Eukrasian Prognosis', buffType: 'shield' }, + { name: 'Eukrasian Prognosis II', buffType: 'shield' }, + { name: 'Eukrasian Diagnosis', buffType: 'shield' }, + { name: 'Differential Diagnosis', buffType: 'shield' }, + { name: 'Haima', buffType: 'shield' }, + { name: 'Addle', buffType: 'debuff' }, + ], + 'BRD': [{ name: 'Troubadour', buffType: 'buff' }], + 'MCH': [{ name: 'Tactician', buffType: 'buff' }], + 'DNC': [ + { name: 'Shield Samba', buffType: 'buff' }, + { name: 'Improvised Finish', buffType: 'shield' }, + ], + 'MNK': [{ name: 'Feint', buffType: 'debuff' }], + 'DRG': [{ name: 'Feint', buffType: 'debuff' }], + 'NIN': [{ name: 'Feint', buffType: 'debuff' }], + 'SAM': [{ name: 'Feint', buffType: 'debuff' }], + 'RPR': [{ name: 'Feint', buffType: 'debuff' }], + 'VPR': [{ name: 'Feint', buffType: 'debuff' }], + 'BLM': [{ name: 'Addle', buffType: 'debuff' }], + 'SMN': [ + { name: 'Addle', buffType: 'debuff' }, + { name: 'Radiant Aegis', buffType: 'shield' }, + ], + 'RDM': [ + { name: 'Addle', buffType: 'debuff' }, + { name: 'Magick Barrier', buffType: 'buff' }, + ], + 'PCT': [ + { name: 'Addle', buffType: 'debuff' }, + { name: 'Tempera Coat', buffType: 'shield' }, + { name: 'Tempera Grassa', buffType: 'shield' }, + ], +}; + function guessJob(abilityName, players) { if (ABILITY_JOB_MAP[abilityName]) return ABILITY_JOB_MAP[abilityName]; const jobs = (players ?? []).map(p => JOB_FROM_TYPE[p.type] ?? ''); @@ -438,6 +597,115 @@ function doImport(data, withMitigations, whereMode, mergeId, newName) { return updatePlan(mergeId, { mechanics: merged }); } +// ── Ability Assignment Modal ────────────────────────────────────────────────── + +let abilityModalPlanId = null; +let abilityModalMechanicId = null; + +function showAbilityModal(planId, mechanicId) { + abilityModalPlanId = planId; + abilityModalMechanicId = mechanicId; + renderAbilityModalContent(); + document.getElementById('planner-ability-modal').style.display = 'flex'; +} + +function hideAbilityModal() { + document.getElementById('planner-ability-modal').style.display = 'none'; + abilityModalPlanId = null; + abilityModalMechanicId = null; +} + +function renderAbilityModalContent() { + const plan = getPlan(abilityModalPlanId); + if (!plan) return; + const mechanic = plan.mechanics.find(m => m.id === abilityModalMechanicId); + if (!mechanic) return; + + document.getElementById('ability-modal-title').textContent = mechanic.name; + + const activeJobs = [...new Set(plan.jobComposition.filter(j => j))]; + const content = document.getElementById('ability-modal-content'); + + if (activeJobs.length === 0) { + content.innerHTML = ` +

+ Bitte zuerst die Jobaufstellung konfigurieren. +

`; + return; + } + + content.innerHTML = activeJobs.map(job => { + const abilities = JOB_ABILITIES[job] ?? []; + if (!abilities.length) return ''; + const role = JOB_ROLE[job] ?? 'dps'; + + const chips = abilities.map(ab => { + const assigned = mechanic.assignments.find(a => a.ability === ab.name); + const isActive = !!assigned; + const byOtherJob = isActive && assigned.job !== job; + const cls = ab.buffType === 'debuff' ? 'badge-assign-debuff' + : ab.buffType === 'shield' ? 'badge-assign-shield' + : 'badge-assign-buff'; + const activeClass = isActive ? ' ability-chip--active' : ''; + const otherClass = byOtherJob ? ' ability-chip--other-job' : ''; + const title = byOtherJob ? `Bereits von ${escHtml(assigned.job)} zugewiesen` : ''; + return ``; + }).join(''); + + return ` +
+
+ ${escHtml(job)} +
+
${chips}
+
`; + }).join(''); +} + +function toggleAbilityAssignment(abilityName, job, buffType) { + const plan = getPlan(abilityModalPlanId); + if (!plan) return; + const mechanic = plan.mechanics.find(m => m.id === abilityModalMechanicId); + if (!mechanic) return; + + const idx = mechanic.assignments.findIndex(a => a.ability === abilityName); + if (idx !== -1) { + if (mechanic.assignments[idx].job === job) { + mechanic.assignments.splice(idx, 1); + } else { + mechanic.assignments[idx].job = job; + } + } else { + mechanic.assignments.push({ ability: abilityName, job, buffType }); + } + + updatePlan(abilityModalPlanId, { mechanics: plan.mechanics }); + refreshMechanicList(abilityModalPlanId); + renderAbilityModalContent(); +} + +function initAbilityModal() { + const overlay = document.getElementById('planner-ability-modal'); + if (!overlay) return; + + document.getElementById('ability-modal-close')?.addEventListener('click', hideAbilityModal); + overlay.addEventListener('click', e => { if (e.target === overlay) hideAbilityModal(); }); + document.addEventListener('keydown', e => { + if (e.key === 'Escape' && abilityModalPlanId) hideAbilityModal(); + }); + + document.getElementById('ability-modal-content')?.addEventListener('click', e => { + const btn = e.target.closest('.ability-chip'); + if (!btn) return; + toggleAbilityAssignment(btn.dataset.ability, btn.dataset.job, btn.dataset.buffType); + }); +} + // ── Import Modal ────────────────────────────────────────────────────────────── let pendingImportData = null; @@ -553,6 +821,7 @@ window.plannerTab = { document.addEventListener('DOMContentLoaded', () => { initNewPlanForm(); initImportModal(); + initAbilityModal(); renderPlanList(); renderPlanDetail(null); }); diff --git a/templates/page.php b/templates/page.php index 1f2eadb..17e3bc9 100644 --- a/templates/page.php +++ b/templates/page.php @@ -80,6 +80,17 @@
+ + +