// screens_voyage.jsx — Voyage-zone screens (responsive) window.S = window.S || {}; (() => { const { useState, useRef, useEffect } = React; const { PORTS, SHIPS, FACTIONS, EQUIPMENT } = window.D; const L = window.L; const A = window.E.A; const { T, panelStyle, Bar, Pill, Btn, StatBlock, SectionTitle, LogList, EmptyState, TutorialPopup, BackButton, Tooltip, IconSailboat, IconPlay, IconAnchor, IconCompass, IconFood, IconWater, Panel, PortCard, PortModal } = window.UI; const { FactionPill, RepPill, ShipSprite } = window.UI; const { shouldShowTutorial, markTutorialSeen } = window.L; // ── MAP SCREEN ─────────────────────────────────────────────────────── function MapScreen({ state, dispatch }) { const [hov, setHov] = useState(null); const [selectedPort, setSelectedPort] = useState(null); // <-- NEW const [showTutorial, setShowTutorial] = React.useState(() => shouldShowTutorial(state, "map")); const W = 760, H = 460; // ── Zoom / Pan state ─────────────────────────────────────── const mapRef = useRef(null); const [transform, setTransform] = useState({ x: 0, y: 0, scale: 1 }); const [isDragging, setIsDragging] = useState(false); const dragStart = useRef({ x: 0, y: 0, mouseX: 0, mouseY: 0 }); const lastPinchDist = useRef(0); // ── Map size responsive to viewport ──────────────────────── const [mapSize, setMapSize] = useState({ width: 760, height: 460 }); useEffect(() => { const computeSize = () => { const hudHeight = 150; const controlsHeight = 60; const padding = 40; const maxHeight = window.innerHeight - hudHeight - controlsHeight - padding; const maxWidth = window.innerWidth - padding * 2; const aspect = 760 / 460; let w, h; if (maxWidth / maxHeight > aspect) { h = maxHeight; w = h * aspect; } else { w = maxWidth; h = w / aspect; } setMapSize({ width: Math.floor(w), height: Math.floor(h) }); }; computeSize(); window.addEventListener("resize", computeSize); return () => window.removeEventListener("resize", computeSize); }, []); // At-sea detection const atSea = state.route && state.route.totalDays > 0 && state.sailingDaysLeft > 0; const seaPos = atSea ? L.getSeaPosition(state.route) : null; const remainingEndurance = atSea ? state.route.enduranceBudget - state.route.enduranceSpent : 0; const playerPos = atSea ? seaPos : (state.currentPort ? PORTS[state.currentPort] : null); const getAtSeaUnreachableReason = (portKey, days) => { const port = PORTS[portKey]; if (!port) return null; if (port.hidden && !state.discoveredPorts?.includes(portKey)) return null; if (port.minHull) { const effectiveHull = L.getShipStats(state).maxHull; if (effectiveHull < port.minHull) return `Requires a heavier vessel`; } if (days > remainingEndurance) return `Out of range (${days} days, only ${remainingEndurance} remaining)`; return null; }; // ── Stable wheel zoom listener ──────────────────────────────── useEffect(() => { const el = mapRef.current; if (!el) return; const onWheel = (e) => { e.preventDefault(); const rect = el.getBoundingClientRect(); const mouseX = e.clientX - rect.left; const mouseY = e.clientY - rect.top; const direction = e.deltaY > 0 ? -1 : 1; const factor = 1.15; setTransform(prev => { const newScale = Math.max(1, Math.min(3, prev.scale * (direction > 0 ? factor : 1 / factor))); const scaleChange = newScale / prev.scale; return { scale: newScale, x: mouseX - (mouseX - prev.x) * scaleChange, y: mouseY - (mouseY - prev.y) * scaleChange, }; }); }; el.addEventListener('wheel', onWheel, { passive: false }); return () => el.removeEventListener('wheel', onWheel); }, []); // ── Prevent page scroll during touch pan/pinch ────────────── useEffect(() => { const el = mapRef.current; if (!el) return; const preventScroll = (e) => e.preventDefault(); el.addEventListener('touchmove', preventScroll, { passive: false }); return () => el.removeEventListener('touchmove', preventScroll); }, []); // ── Pan (mouse) ───────────────────────────────────────────── const handleMouseDown = (e) => { if (e.button !== 0) return; setIsDragging(true); dragStart.current = { x: transform.x, y: transform.y, mouseX: e.clientX, mouseY: e.clientY }; e.preventDefault(); }; const handleMouseMove = (e) => { if (!isDragging) return; const dx = e.clientX - dragStart.current.mouseX; const dy = e.clientY - dragStart.current.mouseY; setTransform(prev => ({ ...prev, x: dragStart.current.x + dx, y: dragStart.current.y + dy })); }; const handleMouseUp = () => setIsDragging(false); // ── Touch pan / pinch ─────────────────────────────────────── const handleTouchStart = (e) => { if (e.touches.length === 1) { setIsDragging(true); dragStart.current = { x: transform.x, y: transform.y, mouseX: e.touches[0].clientX, mouseY: e.touches[0].clientY }; } else if (e.touches.length === 2) { lastPinchDist.current = Math.hypot( e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY ); } }; const handleTouchMove = (e) => { if (isDragging && e.touches.length === 1) { const dx = e.touches[0].clientX - dragStart.current.mouseX; const dy = e.touches[0].clientY - dragStart.current.mouseY; setTransform(prev => ({ ...prev, x: dragStart.current.x + dx, y: dragStart.current.y + dy })); } else if (e.touches.length === 2 && lastPinchDist.current > 0) { const newDist = Math.hypot( e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY ); const factor = newDist / lastPinchDist.current; const newScale = Math.max(1, Math.min(3, transform.scale * factor)); const scaleChange = newScale / transform.scale; const cx = (e.touches[0].clientX + e.touches[1].clientX) / 2; const cy = (e.touches[0].clientY + e.touches[1].clientY) / 2; const rect = mapRef.current.getBoundingClientRect(); const mx = cx - rect.left, my = cy - rect.top; setTransform(prev => ({ scale: newScale, x: mx - (mx - prev.x) * scaleChange, y: my - (my - prev.y) * scaleChange, })); lastPinchDist.current = newDist; } }; const handleTouchEnd = () => { setIsDragging(false); lastPinchDist.current = 0; }; return (
{showTutorial && ( { markTutorialSeen("map", disableAll); setShowTutorial(false); }}>

Click any port to open its details. From there you can set sail.

  • See distance, reputation, trade opportunities
  • Compare with your current port

Grey ports are out of range — you'll need a bigger ship.

)} {/* Map container */}
{state.activeMission && (() => { const fr = PORTS[state.currentPort]; const to = PORTS[state.activeMission.targetPort]; return fr && to ? : null; })()} {Object.entries(PORTS).filter(([key]) => state.discoveredPorts?.includes(key)).map(([key, p]) => { const isHov = hov === key; const isMissionTarget = state.activeMission?.targetPort === key; const fColor = FACTIONS[p.faction]?.color ?? T.textDim; const rep = state.reputation[key] ?? 20; let days, reachable; if (atSea) { days = L.travelDaysFromPosition(seaPos, key, state); reachable = L.canReachFromPosition(seaPos, key, state, remainingEndurance) && key !== state.route.destinationPort; } else { days = L.travelDays(state.currentPort, key, state); reachable = L.canReach(state, key) && key !== state.currentPort; } return ( { // Only open modal if port is discovered and reachable (or we want to show info anyway?) // We'll allow click on any discovered port, even if unreachable, to show why. if (state.discoveredPorts?.includes(key)) { setSelectedPort(key); } }} onMouseEnter={() => setHov(key)} onMouseLeave={() => setHov(null)} style={{ cursor: "pointer" }} > {isMissionTarget && ( )} {isHov && } {p.name.toUpperCase()} ); })} {playerPos && ( )} {[["N",0,-15],["E",15,4],["S",0,18],["W",-15,4]].map(([d,dx,dy]) => {d})} {state.wind.speed}KT {/* ── Tooltip overlay is removed — replaced by modal ── */}
setTransform(p => ({ ...p, scale: Math.max(1, p.scale / 1.2) }))}>− setTransform(p => ({ ...p, scale: Math.min(3, p.scale * 1.2) }))}>+
{Object.entries(FACTIONS).map(([k, f]) => (
{f.label}
))} Click a port to see details · Tap again to sail
{/* ── Port Modal ────────────────────────────────────────────── */} {selectedPort && ( setSelectedPort(null)} /> )}
); } // ── SAILING SCREEN (responsive single‑column on narrow) ──────── function SailingScreen({ state, dispatch }) { const from = PORTS[state.currentPort] ?? { x: 380, y: 230 }; const to = PORTS[state.destination] ?? { x: 380, y: 230 }; const progress = state.sailingDaysTotal > 0 ? 1 - (state.sailingDaysLeft / state.sailingDaysTotal) : 0; const shipX = from.x + (to.x - from.x) * progress; const shipY = from.y + (to.y - from.y) * progress; const hdgDeg = Math.atan2(to.y - from.y, to.x - from.x) * 180 / Math.PI; const arrived = state.sailingDaysLeft <= 0; const W = 760, H = 460; const consumption = L.getProvisionConsumptionPerDay(state); const daysLeft = L.getDaysOfProvisions(state.hold?.items || {}, consumption); const loadPct = L.getHoldLoadPct(state.hold?.items, L.getHoldCapacity(state)); const speedMult = L.getHoldSpeedMultiplier(loadPct); const [showTutorial, setShowTutorial] = React.useState(() => shouldShowTutorial(state, "sailing")); const reachableFromSea = L.getReachablePortsFromSea(state); const canChangeCourse = reachableFromSea.length > 0; // ── Sailing gates for Change Course button ────────────────────── const isDinghy = state.ship.type === "dinghy"; const minCrew = L.getMinViableCrew(state.ship.type); const isHullBlocked = state.ship.hull === 0; const isCrewBlocked = !isDinghy && state.crew.roster.length < minCrew; const sailDisabled = isHullBlocked || isCrewBlocked; let sailTooltip = ""; if (isHullBlocked) sailTooltip = "The ship is in too poor a state to change course now. Let it drift in its current direction."; else if (isCrewBlocked) sailTooltip = "With no man to crew the ship, we can't alter course now."; const courseChangeDisabled = sailDisabled || !canChangeCourse; let courseChangeTooltip = sailDisabled ? sailTooltip : !canChangeCourse ? "No alternate port is reachable from your current position under present conditions." : "Plot a new heading from your current position, if your ship can reach it."; const [isNarrow, setIsNarrow] = React.useState(window.innerWidth < 640); React.useEffect(() => { const handle = () => setIsNarrow(window.innerWidth < 640); window.addEventListener("resize", handle); return () => window.removeEventListener("resize", handle); }, []); return (
{showTutorial && ( { markTutorialSeen("sailing", disableAll); setShowTutorial(false); }} >

Click Advance Day to sail toward your destination. Each day:

  • Your crew consumes food and water
  • Crew wages are deducted
  • Random events may happen — storms, encounters, opportunities

When you arrive, click Enter Port to dock.

)} {/* Voyage map wrapped in Panel with hand‑drawn border */} {PORTS[state.currentPort]?.name?.toUpperCase()} {PORTS[state.destination]?.name?.toUpperCase()} {[["N",0,-15],["E",15,4],["S",0,18],["W",-15,4]].map(([d,dx,dy]) => {d})} {state.wind.speed}KT {/* Right column panels */}
En route to {PORTS[state.destination]?.name}
{arrived ? "Arrived — ready to dock" : `${state.sailingDaysLeft} day${state.sailingDaysLeft !== 1 ? "s" : ""} remaining`}
dispatch({ type: A.ADVANCE_DAY })} disabled={arrived}> Advance Day dispatch({ type: A.ENTER_PORT })} disabled={!arrived}> Enter Port {!arrived && (
dispatch({ type: A.NAVIGATE, screen: "map" })} disabled={courseChangeDisabled}> Change Course {courseChangeDisabled && (
⚠ {courseChangeTooltip}
)}
)}
{!arrived && !canChangeCourse && (
No alternate port is reachable from your current position under present conditions.
)}
Wind {state.wind.speed}kt at {state.wind.angle}° {state.activeMission ? ` · Mission: ${state.activeMission.name}` : ""} {speedMult > 1 && ( {' '}— {speedMult >= 1.33 ? "very heavy load" : "heavy load"} )}
PROVISIONS
Food: {state.hold?.items?.food ?? 0} ({daysLeft.food} days)
Water: {state.hold?.items?.water ?? 0} ({daysLeft.water} days)
Crew consumes {consumption.food} food + {consumption.water} water / day
CAPTAIN'S LOG
); } Object.assign(window.S, { MapScreen, SailingScreen }); })();