442 lines
16 KiB
JavaScript
442 lines
16 KiB
JavaScript
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 = `
|
|
<div class="dock-backdrop"></div>
|
|
<section class="dock-shell" role="dialog" aria-modal="true" aria-label="Dock">
|
|
<header class="dock-header">
|
|
<div>
|
|
<span class="dock-kicker">SKYPORT DOCK</span>
|
|
<h1 data-dock="port">PORT</h1>
|
|
<p data-dock="port-meta">Trade • contracts • repairs</p>
|
|
</div>
|
|
<button class="dock-leave" type="button">SET SAIL</button>
|
|
</header>
|
|
|
|
<div class="dock-resources">
|
|
<span>COIN <strong data-dock="credits">0</strong></span>
|
|
<span>CARGO <strong data-dock="cargo">0/0</strong></span>
|
|
<span>SALVAGE <strong data-dock="salvage">0</strong></span>
|
|
<span>MISSION <strong data-dock="mission">NONE</strong></span>
|
|
<span>WANTED <strong data-dock="wanted">0</strong></span>
|
|
</div>
|
|
|
|
<nav class="dock-nav" aria-label="Dock services">
|
|
<button type="button" data-dock-tab="missions">MISSIONS</button>
|
|
<button type="button" data-dock-tab="trade">TRADE</button>
|
|
<button type="button" data-action="open-shipyard">SHIPYARD</button>
|
|
</nav>
|
|
|
|
<main class="dock-content">
|
|
<section class="dock-home" data-panel="home">
|
|
<button class="dock-service" type="button" data-dock-tab="missions">
|
|
<strong>MISSIONS</strong>
|
|
<span>Take courier contracts and pirate bounties.</span>
|
|
</button>
|
|
<button class="dock-service" type="button" data-dock-tab="trade">
|
|
<strong>TRADE</strong>
|
|
<span>Buy low, carry cargo, sell at another skyport.</span>
|
|
</button>
|
|
<button class="dock-service" type="button" data-action="open-shipyard">
|
|
<strong>SHIPYARD</strong>
|
|
<span>Upgrade the Sparrow and manage recovered salvage.</span>
|
|
</button>
|
|
<div class="dock-repair-strip">
|
|
<div>
|
|
<strong>HULL SERVICE</strong>
|
|
<span data-dock="repair-status">Hull already at full strength.</span>
|
|
</div>
|
|
<button type="button" data-action="repair-hull">REPAIR</button>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="dock-panel" data-panel="missions" hidden>
|
|
<div class="dock-section-head">
|
|
<div><strong>CONTRACT BOARD</strong><span>One active mission at a time.</span></div>
|
|
<button type="button" data-dock-tab="home">BACK</button>
|
|
</div>
|
|
<div class="dock-active-mission" data-dock="active-mission"></div>
|
|
<div class="dock-list" data-dock="mission-list"></div>
|
|
</section>
|
|
|
|
<section class="dock-panel" data-panel="trade" hidden>
|
|
<div class="dock-section-head">
|
|
<div><strong>MARKET</strong><span>Prices vary by port exports and demand.</span></div>
|
|
<button type="button" data-dock-tab="home">BACK</button>
|
|
</div>
|
|
<div class="dock-list" data-dock="market-list"></div>
|
|
</section>
|
|
</main>
|
|
|
|
<div class="dock-message" data-dock="message"></div>
|
|
</section>
|
|
`;
|
|
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-overlay [hidden] { display: none !important; }
|
|
.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-repair-strip {
|
|
grid-column: 1 / -1;
|
|
display:flex;
|
|
align-items:center;
|
|
justify-content:space-between;
|
|
gap:12px;
|
|
padding:12px 14px;
|
|
border:1px solid rgba(194, 142, 61, .46);
|
|
border-radius:10px;
|
|
background:rgba(4, 28, 35, .9);
|
|
}
|
|
.dock-repair-strip > div { display:grid; gap:3px; }
|
|
.dock-repair-strip strong { color:#fff0bd; font-size:14px; }
|
|
.dock-repair-strip span { color:#9fb5bb; font:600 10px/1.3 system-ui,sans-serif; }
|
|
.dock-repair-strip button {
|
|
min-width:110px;
|
|
min-height:42px;
|
|
padding:7px 12px;
|
|
border:1px solid #9a6a2b;
|
|
border-radius:8px;
|
|
background:#103b3d;
|
|
color:#f5dda0;
|
|
font:700 10px/1 system-ui,sans-serif;
|
|
touch-action:manipulation;
|
|
}
|
|
.dock-repair-strip button:disabled { opacity:.36; }
|
|
.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) => {
|
|
const active = panel.dataset.panel === name;
|
|
panel.hidden = !active;
|
|
panel.setAttribute('aria-hidden', active ? 'false' : 'true');
|
|
});
|
|
};
|
|
|
|
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.shortage > 0.5
|
|
? `SHORTAGE +${Math.round(item.shortage * 8)}%`
|
|
: 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 hint = document.createElement('p');
|
|
const opportunity = item.opportunity;
|
|
hint.textContent = opportunity
|
|
? opportunity.margin > 0
|
|
? `BEST KNOWN: ${opportunity.portName} • SELL ¢${opportunity.sellPrice} • +¢${opportunity.margin}/UNIT`
|
|
: `BEST KNOWN: ${opportunity.portName} • no positive margin`
|
|
: 'No known destination price.';
|
|
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, hint, 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.wanted.textContent = dock.wanted || 0;
|
|
|
|
const repairButton = overlay.querySelector('[data-action="repair-hull"]');
|
|
const repair = dock.repair;
|
|
if (repair) {
|
|
fields['repair-status'].textContent = repair.missing > 0
|
|
? `Repair ${repair.missing} hull for ¢${repair.cost}.`
|
|
: 'Hull already at full strength.';
|
|
repairButton.disabled = repair.missing <= 0
|
|
|| (state.progression?.credits || 0) < repair.cost;
|
|
repairButton.textContent = repair.missing > 0 ? `REPAIR ¢${repair.cost}` : 'FULL HULL';
|
|
}
|
|
|
|
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('[data-action="repair-hull"]')
|
|
.addEventListener('click', () => dispatchAction('repair-hull'));
|
|
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) => {
|
|
// Keep the latest snapshot for fallback/return, but do not rebuild interactive
|
|
// dock lists every frame. Replacing buttons between pointerdown and click
|
|
// breaks touch interaction on mobile.
|
|
latestState = event.detail;
|
|
});
|
|
window.addEventListener('keydown', (event) => {
|
|
if (event.key === 'Escape' && !overlay.hidden) dispatchAction('close-dock');
|
|
});
|