// screens_shipyard.jsx — Shipyard Screen (Option A: Split Dashboard)
window.S = window.S || {};
(() => {
const { useState, useEffect, useMemo } = React;
const { SHIPS, EQUIPMENT, PORTS } = window.D;
const L = window.L;
const A = window.E.A;
const { T, panelStyle, Bar, Pill, Btn, StatBlock, SectionTitle, EmptyState, TutorialPopup, BackButton,
IconShield, IconCannon, IconSailboat, IconSparkles, IconChest, IconHammer, IconCog, IconShip,ShipSideSprite,
} = window.UI;
const { shouldShowTutorial, markTutorialSeen } = window.L;
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// CONSTANTS
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// ── Visual equipment helper ──────────────────────────────────────────
// Only these equipment items have a visible effect on the ship sprite.
// When more equipment gets visual effects, add the keys here.
const VISUAL_EQUIPMENT = ["war_pennants", "extra_sails", "lateen_rig"];
const getVisualEquipment = (state) => {
const allEquipped = [
...(state.ship.equipment?.hull || []),
...(state.ship.equipment?.armament || []),
...(state.ship.equipment?.rigging || []),
...(state.ship.equipment?.special || []),
];
return allEquipped.filter(key => VISUAL_EQUIPMENT.includes(key));
};
const SLOT_LABELS = {
hull: { label: "Hull", Icon: IconShield },
armament: { label: "Armament", Icon: IconCannon },
rigging: { label: "Rigging", Icon: IconSailboat },
special: { label: "Special", Icon: IconSparkles },
};
const TABS = { EQUIP: "equip", SHIPS: "ships", LOCKER: "locker" };
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// STAT PREVIEW HELPER
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
function StatDelta({ label, before, after }) {
const diff = after - before;
const arrow = diff > 0 ? " ↑" : diff < 0 ? " ↓" : " =";
const color = diff > 0 ? T.greenBr : diff < 0 ? T.redBr : T.textDim;
return (
{label}
{before} → {after}{arrow}
);
}
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// MAIN COMPONENT
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
function ShipyardScreen({ state, dispatch }) {
// --- Rep / services check ---
const perk = L.getRepPerk(state.reputation[state.currentPort] ?? 50);
if (perk.servicesBlocked) {
return (
);
}
// --- Core data ---
const repCost = Math.floor(L.shipRepairCost(state) * (perk.repairMult || 1));
const currentShip = SHIPS[state.ship.type];
const effectiveStats = L.getShipStats(state);
// --- UI state ---
const [activeTab, setActiveTab] = useState(TABS.SHIPS);
const [slotFilter, setSlotFilter] = useState("all");
const [selectedEquip, setSelectedEquip] = useState(null);
const [selectedShip, setSelectedShip] = useState(null);
const [showTutorial, setShowTutorial] = useState(() => shouldShowTutorial(state,"shipyard"));
// --- Responsive ---
const [isNarrow, setIsNarrow] = useState(window.innerWidth < 700);
useEffect(() => {
const h = () => setIsNarrow(window.innerWidth < 700);
window.addEventListener("resize", h);
return () => window.removeEventListener("resize", h);
}, []);
// --- Accordion (mobile) ---
const [equippedOpen, setEquippedOpen] = useState(!isNarrow);
// Clear selection on tab switch
const switchTab = (tab) => {
setActiveTab(tab);
setSelectedEquip(null);
setSelectedShip(null);
setSlotFilter("all");
};
// --- Locker items ---
const lockerItems = useMemo(() => (state.equipmentInventory || []).map(key => {
const item = EQUIPMENT[key];
if (!item) return null;
const validation = L.canInstallEquipment(state, key);
return { key, ...item, validation, canAfford: state.gold >= item.installFee };
}).filter(Boolean), [state]);
const hasLocker = lockerItems.length > 0;
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// PREVIEW PANEL (equipment or ship)
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
const previewEquipStats = (equipmentKey) => {
const newEquip = { ...state.ship.equipment };
const item = EQUIPMENT[equipmentKey];
if (!item) return effectiveStats;
newEquip[item.slot] = [...(newEquip[item.slot] || []), equipmentKey];
const tempState = { ...state, ship: { ...state.ship, equipment: newEquip } };
return L.getShipStats(tempState);
};
const renderPreviewPanel = () => {
// --- Equipment preview ---
if (selectedEquip) {
const item = EQUIPMENT[selectedEquip];
if (!item) return null;
const after = previewEquipStats(selectedEquip);
const validation = L.canInstallEquipment(state, selectedEquip);
const isFromLocker = (state.equipmentInventory || []).includes(selectedEquip);
const totalCost = isFromLocker ? item.installFee : item.cost + item.installFee;
const canAfford = state.gold >= totalCost;
return (
{isFromLocker
? React.createElement(React.Fragment, null,
React.createElement(IconChest, { size: 12, color: T.gold }),
" Install from Locker"
)
: React.createElement(React.Fragment, null,
React.createElement(IconHammer, { size: 12, color: T.gold }),
" Preview: Buy & Install"
)
}
setSelectedEquip(null)} style={{ flexShrink: 0 }}>✕
{item.name}
{item.desc}{item.downsideDesc ? ` ${item.downsideDesc}` : ""}
{!validation.ok ? (
🔒 {validation.reason}
) : !canAfford ? (
Need {totalCost - state.gold}g more
) : (
{
if (isFromLocker) {
dispatch({ type: A.INSTALL_EQUIPMENT, equipmentKey: selectedEquip });
} else {
dispatch({ type: A.BUY_EQUIPMENT, equipmentKey: selectedEquip });
}
setSelectedEquip(null);
}}>
{isFromLocker ? `Install (${item.installFee}g)` : `Buy & Install (${totalCost}g)`}
)}
);
}
// --- Ship comparison preview ---
if (selectedShip && selectedShip !== state.ship.type) {
const s = SHIPS[selectedShip];
const cur = currentShip;
const shipReq = L.meetsRequirement(state, s);
const canBuy = shipReq.allowed && state.gold >= s.cost;
const lack = shipReq.allowed && state.gold < s.cost ? s.cost - state.gold : 0;
return (
Compare: {s.name} vs {cur.name}
setSelectedShip(null)} style={{ flexShrink: 0 }}>✕
{/* Selected ship sprite */}
⚠ Buying a new ship clears all installed equipment. Remove items to your locker first.
{!shipReq.allowed ? (
🔒 {shipReq.reason}
) : lack > 0 ? (
Need {lack.toLocaleString()}g more
) : (
{
dispatch({ type: A.BUY_SHIP, shipType: selectedShip });
setSelectedShip(null);
}}>
Purchase ({s.cost.toLocaleString()}g)
)}
);
}
return null;
};
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// LEFT PANEL — Ship Stats + Equipped
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
const renderLeftPanel = () => (
{/* Ship sprite */}
{/* Current Vessel Stats */}
CURRENT VESSEL — {currentShip.name}
{/* Repair */}
Hull: {state.ship.hull} / {effectiveStats.maxHull}
= 0.6 ? T.greenBr :
state.ship.hull / effectiveStats.maxHull >= 0.3 ? T.gold :
T.redBr
}
h={8} />
dispatch({ type: A.REPAIR })}
disabled={state.ship.hull >= effectiveStats.maxHull || state.gold < repCost}>
Full Repair ({repCost}g)
{/* Equipped Items */}
isNarrow && setEquippedOpen(v => !v)}
>
EQUIPPED
{isNarrow && {equippedOpen ? "▾" : "▸"}}
{(isNarrow ? equippedOpen : true) && (
{Object.entries(SLOT_LABELS).map(([slotKey, slotInfo]) => {
const SlotIcon = slotInfo.Icon;
const slotMax = currentShip.slots?.[slotKey] || 0;
if (slotMax === 0) return null;
const installed = state.ship.equipment?.[slotKey] || [];
return (
{slotInfo.label} ({installed.length}/{slotMax})
{installed.length === 0 ? (
empty
) : installed.map(key => {
const item = EQUIPMENT[key];
if (!item) return null;
return (
{item.name}
{!item.removable && (Struct.)}
{item.removable && (
dispatch({ type: A.REMOVE_EQUIPMENT, equipmentKey: key })}
disabled={state.gold < item.installFee}
style={{ fontSize: 9, padding: "2px 5px", minHeight: 24 }}>
−{item.installFee}g
)}
);
})}
);
})}
)}
);
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// RIGHT PANEL — Tabs, Preview, Grid
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
const renderEquipmentTab = () => {
// Slot filter buttons – using icon components
const filterButtons = [
{ key: "all", label: "All" },
...Object.entries(SLOT_LABELS).map(([k, v]) => ({
key: k,
label: React.createElement('span', { style: { display: 'inline-flex', alignItems: 'center' } },
React.createElement(v.Icon, { size: 10, color: T.textDim, style: { marginRight: 3 } }),
v.label
)
})),
];
const shopItems = Object.entries(EQUIPMENT).map(([key, item]) => {
const validation = L.canInstallEquipment(state, key);
const totalCost = item.cost + item.installFee;
const canAfford = state.gold >= totalCost;
return { key, ...item, validation, canAfford, totalCost };
}).filter(item => slotFilter === "all" || item.slot === slotFilter);
return (
{/* Slot filters */}
{filterButtons.map(f => (
{ setSlotFilter(f.key); setSelectedEquip(null); }}>
{f.label}
))}
{/* Equipment grid */}
{shopItems.map(item => {
const isSelected = selectedEquip === item.key;
const SlotIcon = SLOT_LABELS[item.slot] ? SLOT_LABELS[item.slot].Icon : null;
return (
setSelectedEquip(isSelected ? null : item.key)}
style={{
...panelStyle({
background: isSelected ? T.panelAlt : T.panel,
borderColor: isSelected ? T.gold : T.border,
cursor: "pointer",
transition: "border-color 0.15s",
}),
opacity: item.validation.ok ? 1 : 0.55,
}}>
{item.name}
{item.totalCost}g
{item.desc}{item.downsideDesc ? ` ${item.downsideDesc}` : ""}
{SlotIcon && (
)}
{SLOT_LABELS[item.slot]?.label || item.slot}
{item.requiredFame > 0 || item.requiredHull > 0 ? (
· Fame {item.requiredFame} · Hull {item.requiredHull}+
) : null}
{!item.validation.ok && (
🔒 {item.validation.reason}
)}
);
})}
);
};
const renderShipsTab = () => {
return (
{Object.entries(SHIPS).map(([key, s]) => {
const isCur = key === state.ship.type;
const shipReq = L.meetsRequirement(state, s);
const isSelected = selectedShip === key;
return (
{
if (!isCur) setSelectedShip(isSelected ? null : key);
}}
style={{
...panelStyle({
background: isCur ? T.greenBg : (isSelected ? T.panelAlt : T.panel),
borderColor: isCur ? T.greenBr : (isSelected ? T.gold : T.border),
cursor: isCur ? "default" : "pointer",
transition: "border-color 0.15s",
}),
opacity: shipReq.allowed ? 1 : 0.55,
}}>
{s.name}
{isCur
?
:
{s.cost.toLocaleString()}g
}
{s.desc}
{[["Crew", s.maxCrew], ["Guns", s.cannons], ["Spd", s.speed], ["Hull", s.maxHull]].map(([l, v]) =>
)}
{!shipReq.allowed && (
🔒 {shipReq.reason}
)}
);
})}
);
};
const renderLockerTab = () => {
if (lockerItems.length === 0) {
return ;
}
return (
{lockerItems.map(item => {
const isSelected = selectedEquip === item.key;
const SlotIcon = SLOT_LABELS[item.slot] ? SLOT_LABELS[item.slot].Icon : null;
return (
setSelectedEquip(isSelected ? null : item.key)}
style={{
...panelStyle({
background: isSelected ? T.panelAlt : T.panel,
borderColor: isSelected ? T.gold : T.border,
cursor: "pointer",
transition: "border-color 0.15s",
}),
opacity: item.validation.ok ? 1 : 0.55,
}}>
{item.name}
Install: {item.installFee}g
{item.desc}{item.downsideDesc ? ` ${item.downsideDesc}` : ""}
{SlotIcon && (
)}
{SLOT_LABELS[item.slot]?.label || item.slot}
{!item.validation.ok && (
🔒 {item.validation.reason}
)}
);
})}
);
};
const renderRightPanel = () => (
{/* Tab bar */}
switchTab(TABS.EQUIP)}> Equipment
switchTab(TABS.SHIPS)}> Ships
{hasLocker && (
switchTab(TABS.LOCKER)}> Locker ({lockerItems.length})
)}
{/* Stat preview panel */}
{renderPreviewPanel()}
{/* Tab content */}
{activeTab === TABS.EQUIP && renderEquipmentTab()}
{activeTab === TABS.SHIPS && renderShipsTab()}
{activeTab === TABS.LOCKER && renderLockerTab()}
);
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// ROOT RENDER
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
return (
{/* Tutorial */}
{showTutorial && (
{
markTutorialSeen("shipyard", disableAll);
setShowTutorial(false);
}}
>
This is where you upgrade your ship — or buy a new one entirely.
- Left panel shows your current ship stats and equipped items
- Use the tabs to browse Equipment, Ships, or your Locker
- Click any item to see a stat preview before buying
- Ships are locked behind fame requirements
- Buying a new ship clears all equipment — remove items first
A bigger ship means more crew, more cargo, more firepower — but also higher wages.
)}
{/* Main layout */}
{renderLeftPanel()}
{renderRightPanel()}
);
}
Object.assign(window.S, { ShipyardScreen });
})();