diff --git a/src/dock-ui.js b/src/dock-ui.js new file mode 100644 index 0000000..29675c6 --- /dev/null +++ b/src/dock-ui.js @@ -0,0 +1,372 @@ +const dispatchAction = (action, extra = {}) => { + window.dispatchEvent(new CustomEvent('airship:action', { + detail: { action, ...extra }, + })); +}; + +const overlay = document.createElement('div'); +overlay.id = 'dock-overlay'; +overlay.hidden = true; +overlay.innerHTML = ` +
+ +`; +document.body.appendChild(overlay); + +const style = document.createElement('style'); +style.textContent = ` + #dock-overlay { + position: fixed; + inset: 0; + z-index: 430; + color: #f0d9a0; + font-family: Georgia, 'Times New Roman', serif; + } + #dock-overlay[hidden] { display: none; } + .dock-backdrop { + position: absolute; + inset: 0; + background: rgba(2, 10, 15, .9); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + } + .dock-shell { + position: relative; + width: min(980px, calc(100vw - 18px)); + max-height: calc(100dvh - 18px); + min-height: min(720px, calc(100dvh - 18px)); + margin: max(9px, env(safe-area-inset-top)) auto; + padding: 14px; + overflow: auto; + border: 1px solid rgba(196, 140, 54, .64); + border-radius: 14px; + background: radial-gradient(circle at 50% 0%, rgba(20, 72, 84, .33), transparent 36%), #06161e; + box-shadow: 0 18px 60px rgba(0,0,0,.62); + overscroll-behavior: contain; + } + .dock-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 8px 10px 13px; + border-bottom: 1px solid rgba(196, 140, 54, .34); + } + .dock-kicker { color: #adcfca; font: 700 10px/1 system-ui, sans-serif; letter-spacing: .18em; } + .dock-header h1 { margin: 3px 0 2px; color: #fff0bd; font-size: clamp(26px, 5vw, 46px); line-height: .95; } + .dock-header p { margin: 0; color: #9eb2b9; font: 600 11px/1.2 system-ui, sans-serif; } + .dock-leave, + .dock-nav button, + .dock-section-head button { + min-height: 44px; + border: 1px solid #9a6a2b; + border-radius: 9px; + background: linear-gradient(#174a50, #0a2930); + color: #ffe5a7; + font: 700 11px/1 Georgia, serif; + touch-action: manipulation; + } + .dock-leave { min-width: 112px; padding: 8px 14px; } + .dock-resources { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 7px; + padding: 10px 0; + } + .dock-resources span { + padding: 6px 11px; + border: 1px solid rgba(196, 140, 54, .38); + border-radius: 999px; + background: rgba(3, 20, 27, .82); + font-size: 11px; + } + .dock-resources strong { color: #fff0bd; } + .dock-nav { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; + margin: 2px 0 12px; + } + .dock-nav button { min-height: 52px; font-size: 13px; } + .dock-content { min-height: 420px; } + .dock-home { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; + } + .dock-service { + min-height: 250px; + padding: 18px; + border: 1px solid rgba(194, 142, 61, .56); + border-radius: 12px; + background: linear-gradient(180deg, rgba(13, 63, 72, .86), rgba(5, 25, 32, .96)); + color: #f0d9a0; + text-align: left; + touch-action: manipulation; + } + .dock-service strong { display:block; margin-bottom:8px; color:#fff1bc; font-size:22px; } + .dock-service span { color:#aac0c6; font:600 12px/1.45 system-ui,sans-serif; } + .dock-panel { display: grid; gap: 10px; } + .dock-section-head { display:flex; align-items:center; justify-content:space-between; gap:10px; } + .dock-section-head > div { display:grid; gap:2px; } + .dock-section-head strong { color:#fff0bd; font-size:18px; } + .dock-section-head span { color:#9fb3b9; font:600 10px/1.2 system-ui,sans-serif; } + .dock-section-head button { min-width:76px; padding:7px 10px; } + .dock-list { display:grid; gap:8px; } + .dock-card, + .dock-active-mission { + display:grid; + gap:5px; + padding:11px 12px; + border:1px solid rgba(190, 139, 59, .42); + border-radius:10px; + background:rgba(3, 21, 28, .88); + } + .dock-active-mission:empty { display:none; } + .dock-card-head { display:flex; align-items:flex-start; justify-content:space-between; gap:10px; } + .dock-card strong { color:#fff0bd; } + .dock-card small, + .dock-card p { margin:0; color:#9fb5bb; font:600 10px/1.35 system-ui,sans-serif; } + .dock-card-actions { display:flex; flex-wrap:wrap; gap:7px; margin-top:3px; } + .dock-card button, + .dock-active-mission button { + min-height:38px; + padding:6px 12px; + border:1px solid #9a6a2b; + border-radius:8px; + background:#103b3d; + color:#f5dda0; + font:700 10px/1 system-ui,sans-serif; + touch-action:manipulation; + } + .dock-card button:disabled, + .dock-active-mission button:disabled { opacity:.36; } + .dock-market-row { + display:grid; + grid-template-columns:minmax(120px,1.2fr) .8fr .8fr auto; + gap:8px; + align-items:center; + } + .dock-price { color:#fff0bd; font:700 11px/1 system-ui,sans-serif; } + .dock-tag { color:#98d3ab; font:700 9px/1 system-ui,sans-serif; } + .dock-message { min-height:1.3em; padding-top:9px; color:#fff0bd; text-align:center; font:700 11px/1.2 system-ui,sans-serif; } + body.dock-open #mobile-controls { visibility:hidden; } + + @media (max-width: 720px) { + .dock-shell { + width: calc(100vw - 8px); + max-height: calc(100dvh - 8px); + min-height: calc(100dvh - 8px); + margin: 4px auto; + padding: 8px; + border-radius: 9px; + } + .dock-header h1 { font-size: clamp(24px, 8vw, 34px); } + .dock-home { grid-template-columns:1fr; } + .dock-service { min-height:105px; padding:13px; } + .dock-service strong { font-size:18px; } + .dock-content { min-height:0; } + .dock-nav { position:sticky; top:-8px; z-index:2; padding:6px 0; background:#06161e; } + .dock-market-row { grid-template-columns:1fr 1fr; } + .dock-market-row .dock-card-actions { grid-column:1 / -1; } + } +`; +document.head.appendChild(style); + +const fields = Object.fromEntries( + [...overlay.querySelectorAll('[data-dock]')].map((el) => [el.dataset.dock, el]), +); +const panels = [...overlay.querySelectorAll('[data-panel]')]; +let latestState = null; + +const setPanel = (name = 'home') => { + panels.forEach((panel) => { panel.hidden = panel.dataset.panel !== name; }); +}; + +const actionButton = (label, action, extra = {}, disabled = false) => { + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = label; + button.disabled = disabled; + button.addEventListener('click', () => dispatchAction(action, extra)); + return button; +}; + +const renderMission = (state) => { + const dock = state?.dock || {}; + const active = dock.activeMission; + fields['active-mission'].replaceChildren(); + if (active) { + const title = document.createElement('strong'); + title.textContent = `ACTIVE: ${active.title}`; + const progress = document.createElement('small'); + progress.textContent = `${active.progressText} • Reward ¢${active.reward}`; + fields['active-mission'].append(title, progress); + if (active.ready) { + fields['active-mission'].append(actionButton('CLAIM REWARD', 'complete-mission')); + } + } + + fields['mission-list'].replaceChildren(); + for (const mission of dock.missionOffers || []) { + const card = document.createElement('article'); + card.className = 'dock-card'; + const head = document.createElement('div'); + head.className = 'dock-card-head'; + const title = document.createElement('strong'); + title.textContent = mission.title; + const reward = document.createElement('span'); + reward.className = 'dock-price'; + reward.textContent = `¢${mission.reward}`; + head.append(title, reward); + const desc = document.createElement('p'); + desc.textContent = mission.description; + const actions = document.createElement('div'); + actions.className = 'dock-card-actions'; + actions.append(actionButton( + active ? 'MISSION ACTIVE' : 'ACCEPT', + 'accept-mission', + { missionId: mission.id }, + Boolean(active), + )); + card.append(head, desc, actions); + fields['mission-list'].append(card); + } +}; + +const renderMarket = (state) => { + const dock = state?.dock || {}; + fields['market-list'].replaceChildren(); + for (const item of dock.market || []) { + const card = document.createElement('article'); + card.className = 'dock-card dock-market-row'; + const name = document.createElement('div'); + const strong = document.createElement('strong'); + strong.textContent = item.name; + const tag = document.createElement('small'); + tag.className = 'dock-tag'; + tag.textContent = item.exported ? 'LOCAL EXPORT' : item.imported ? 'HIGH DEMAND' : 'MARKET'; + name.append(strong, tag); + const buy = document.createElement('span'); + buy.className = 'dock-price'; + buy.textContent = `BUY ¢${item.buyPrice}`; + const sell = document.createElement('span'); + sell.className = 'dock-price'; + sell.textContent = `SELL ¢${item.sellPrice} • HOLD ${item.held || 0}`; + const actions = document.createElement('div'); + actions.className = 'dock-card-actions'; + actions.append( + actionButton('BUY 1', 'buy-trade-good', { goodId: item.goodId }, !item.canBuy), + actionButton('SELL 1', 'sell-trade-good', { goodId: item.goodId }, !item.canSell), + ); + card.append(name, buy, sell, actions); + fields['market-list'].append(card); + } +}; + +const render = (state) => { + if (!state?.dock) return; + latestState = state; + const dock = state.dock; + fields.port.textContent = dock.portName || 'DOCK'; + fields['port-meta'].textContent = `${String(dock.portType || 'trade').toUpperCase()} • ${dock.portExports?.join(', ') || 'mixed goods'}`; + fields.credits.textContent = state.progression?.credits ?? 0; + fields.cargo.textContent = `${dock.cargoUsed || 0}/${dock.cargoCapacity || 0}`; + fields.salvage.textContent = state.progression?.inventoryCount || 0; + fields.mission.textContent = dock.activeMission ? dock.activeMission.type.toUpperCase() : 'NONE'; + fields.message.textContent = String(state.status || '').replace(/\n/g, ' '); + renderMission(state); + renderMarket(state); +}; + +const show = (state) => { + render(state); + setPanel('home'); + overlay.hidden = false; + document.body.classList.add('dock-open'); +}; +const hide = () => { + overlay.hidden = true; + document.body.classList.remove('dock-open'); +}; + +for (const button of overlay.querySelectorAll('[data-dock-tab]')) { + button.addEventListener('click', () => setPanel(button.dataset.dockTab)); +} +for (const button of overlay.querySelectorAll('[data-action="open-shipyard"]')) { + button.addEventListener('click', () => dispatchAction('open-shipyard')); +} +overlay.querySelector('.dock-leave').addEventListener('click', () => dispatchAction('close-dock')); +overlay.querySelector('.dock-backdrop').addEventListener('click', () => dispatchAction('close-dock')); + +window.addEventListener('airship:dock-open', (event) => show(event.detail)); +window.addEventListener('airship:dock-state', (event) => render(event.detail)); +window.addEventListener('airship:dock-hide', () => hide()); +window.addEventListener('airship:dock-close', () => hide()); +window.addEventListener('airship:dock-return', (event) => show(event.detail || latestState)); +window.addEventListener('airship:hud-state', (event) => { + latestState = event.detail; + if (!overlay.hidden) render(event.detail); +}); +window.addEventListener('keydown', (event) => { + if (event.key === 'Escape' && !overlay.hidden) dispatchAction('close-dock'); +});