// screens_combat.jsx — Combat & resolution screens // Event, Intercept, Battle, Plunder — the screens where the world acts on you // and you must resolve the current situation before navigating freely. // // Depends on: window.D, window.L, window.E, window.UI, window.S // Exposes: EventScreen, InterceptScreen, BattleScreen, PlunderScreen on window.S // // Loaded after screens_voyage.jsx so it can pick up MapScreen/SailingScreen // dependencies if needed (currently none — fully self-contained). window.S = window.S || {}; (() => { const { useState, useRef, useEffect } = React; const { PORTS, SHIPS, FACTIONS } = window.D; const L = window.L; const A = window.E.A; const { T, panelStyle, Bar, Pill, Btn, SectionTitle, EmptyState, TutorialPopup, BackButton, IconSailboat, IconAnchor, IconSwords, IconCannon, IconTarget, IconGrapple, IconWind, IconSkull, getGoodIcon, useFlashOnChange, TransferLayout, ShipSideSprite, FactionPill, ShipSprite, } = window.UI; const { shouldShowTutorial, markTutorialSeen } = window.L; // -- HELPERS ---------------- // Detect if the player's action missed or failed const MISS_PHRASES = [ "splashes harmlessly", "goes wide", "overcorrect and miss", "flies past the enemy", "Your grapple fails", "repels your boarders", "thrown back", ]; const isPlayerMissOrFail = (text) => { if (!text) return false; return MISS_PHRASES.some(phrase => text.includes(phrase)); }; // Equipment that has visual representation on the ship sprite 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)); }; // ── EVENT SCREEN ───────────────────────────────────────────────────── function EventScreen({ state, dispatch }) { const ev = state.activeEvent; if (!ev) return null; const typeColor = { hazard: T.redBr, choice: T.gold, reward: T.greenBr, crew: T.blueBr, faction: T.purpleBr, }; return (
Day {state.day}
{ev.title}

{ev.desc}

{ev.choices.map((c, i) => (
dispatch({ type: A.RESOLVE_EVENT, choiceIndex: i })} style={{ ...panelStyle({ background: T.panelAlt, cursor: "pointer", transition: "border-color 0.15s" }) }} onMouseEnter={e => e.currentTarget.style.borderColor = T.borderBr} onMouseLeave={e => e.currentTarget.style.borderColor = T.border} >
{c.label}
{c.outcome.log}
))}
); } // ── INTERCEPT SCREEN ────────────────────────────────────────────────── const InterceptScreen = ({ state, dispatch }) => { const ctx = state.encounterContext; if (!ctx) return null; const { enemy, flavourText, options } = ctx; const enemyShip = SHIPS[enemy.ship] || {}; return (
⚠ ENCOUNTER

{flavourText}

{enemy.name} {enemyShip.name ?? enemy.ship}
{[ ["Hull", `${enemy.hull}/${enemy.maxHull || enemy.hull}`], ["Cannons", enemy.cannons], ["Crew", enemy.crew], ["Speed", enemyShip.speed ?? "?"], ].map(([l, v]) => (
{l}
{v}
))}
CHOOSE YOUR RESPONSE:
{options.map(opt => (
opt.available && dispatch(opt.action)} style={{ width: "100%", textAlign: "left", opacity: opt.available ? 1 : 0.45 }} > {opt.label} {!opt.available && opt.reason && (
✗ {opt.reason}
)} {opt.id === "flee" && opt.available && opt.speedCheck && (
Speed check: your {opt.speedCheck.player} vs their {opt.speedCheck.enemy}
)}
))}
); }; // ── BATTLE SCREEN ───────────────────────────────────────────────────── function BattleScreen({ state, dispatch }) { const bs = state.battleState; if (!bs) return null; const done = ["victory", "defeat", "fled"].includes(bs.phase); const playerPct = bs.playerHull / SHIPS[state.ship.type].maxHull; const enemyPct = bs.enemyHull / bs.enemy.hull; const [showTutorial, setShowTutorial] = React.useState( () => shouldShowTutorial(state, "battle") ); const [pulsedAction, setPulsedAction] = useState(null); const [missFlash, setMissFlash] = useState(false); const prevLogLen = useRef(state.battleState?.log?.length || 0); useEffect(() => { const bs = state.battleState; if (!bs) return; const newLen = bs.log.length; if (newLen > prevLogLen.current) { const latest = bs.log[newLen - 1] || ""; if (isPlayerMissOrFail(latest)) { setMissFlash(true); const timer = setTimeout(() => setMissFlash(false), 600); return () => clearTimeout(timer); } } prevLogLen.current = newLen; }, [state.battleState?.log?.length]); // ── Responsive breakpoint for sprites ──────────────────────── const [isNarrowBattle, setIsNarrowBattle] = React.useState(window.innerWidth < 700); React.useEffect(() => { const handle = () => setIsNarrowBattle(window.innerWidth < 700); window.addEventListener("resize", handle); return () => window.removeEventListener("resize", handle); }, []); return (
{showTutorial && ( { markTutorialSeen("battle", disableAll); setShowTutorial(false); }} >

Choose an action each round:

Watch your hull and crew. If your hull reaches zero, you lose.

)}
NAVAL BATTLE — ROUND {bs.round}
{/* ── Ship panels with sprites ────────────────────────────── */} {(() => { // Resolve ship types and visual configs for proportional sizing const playerType = state.ship.type; const enemyType = L.guessShipType(bs.enemy); const playerVisual = window.D.SHIP_VISUALS?.[playerType]; const enemyVisual = window.D.SHIP_VISUALS?.[enemyType]; const playerLen = playerVisual?.hullLength || 400; const enemyLen = enemyVisual?.hullLength || 400; const maxLen = Math.max(playerLen, enemyLen); const playerSize = playerLen / maxLen; const enemySize = enemyLen / maxLen; // Responsive sprite canvas size const baseW = isNarrowBattle ? 150 : 270; const baseH = isNarrowBattle ? 100 : 175; return (
{/* Player ship panel */}
{state.ship.name}
Hull: {bs.playerHull} / {SHIPS[state.ship.type].maxHull}
= 0.6 ? T.greenBr : playerPct >= 0.3 ? T.gold : T.redBr} h={10} /> {bs.convoyHull !== undefined && ( <>
Convoy Hull: {bs.convoyHull} / 50
= 0.6 ? T.greenBr : bs.convoyHull / 50 >= 0.3 ? T.gold : T.redBr} h={8} /> )}
{state.crew.roster.length} crew · {L.getShipStats(state).cannons} cannons
{/* Lightning bolt center */}
{/* Enemy ship panel */}
{bs.enemy.name}
Hull: {bs.enemyHull} / {bs.enemy.hull}
= 0.6 ? T.greenBr : enemyPct >= 0.3 ? T.gold : T.redBr} h={10} />
{bs.enemyCrew} crew · {bs.enemy.cannons} cannons
); })()} {/* Landscape suggestion for very narrow screens */} {isNarrowBattle && window.innerWidth < 400 && (
Tip: rotate your phone to landscape for a better battle view
)} {/* ── Log panel ──────────────────────────────────────────── */}
{[...bs.log].reverse().map((e, i) => { const isLatest = i === 0; const isMissFlash = isLatest && missFlash && isPlayerMissOrFail(e); return (
{e}
); })}
{!done ? (
CHOOSE YOUR ACTION:
{[ { a: "broadside", label: React.createElement(IconCannon, { size: 14, color: T.redBr }), lbl: " Broadside", desc: "Full cannon volley. Reliable damage.", glow: T.redBr }, { a: "precision", label: React.createElement(IconTarget, { size: 14, color: T.yellow }), lbl: " Precision", desc: "Aimed shot. Miss or massive damage.", glow: T.yellow }, { a: "grapple", label: React.createElement(IconGrapple, { size: 14, color: T.blueBr }), lbl: " Grapple", desc: "Board them. Requires crew advantage.", glow: T.blueBr }, { a: "evade", label: React.createElement(IconWind, { size: 14, color: T.greenBr }), lbl: " Evade", desc: "Flee if faster. Reduced incoming fire.", glow: T.greenBr }, ].map(({ a, label, lbl, desc, glow }) => (
{ dispatch({ type: A.BATTLE_ACTION, action: a }); setPulsedAction(a); setTimeout(() => setPulsedAction(null), 150); }} style={{ ...panelStyle({ background: T.panelAlt, cursor: "pointer", transition: "transform 0.12s ease, box-shadow 0.12s ease, border-color 0.15s" }), }} onMouseEnter={e => { e.currentTarget.style.borderColor = T.borderBr; e.currentTarget.style.boxShadow = `0 0 14px ${glow}55`; e.currentTarget.style.transform = "scale(1.03)"; }} onMouseLeave={e => { e.currentTarget.style.borderColor = T.border; e.currentTarget.style.boxShadow = "none"; e.currentTarget.style.transform = "scale(1)"; }} >
{label}{lbl}
{desc}
))}
) : (
{bs.phase === "victory" && (<> VICTORY!)} {bs.phase === "fled" && (<> ESCAPED)} {bs.phase === "defeat" && (<> DEFEATED)}
{bs.phase === "victory" && bs.canPlunder ? (
dispatch({ type: A.NAVIGATE, screen: "plunder" })}> Plunder the Ship dispatch({ type: A.DISMISS_BATTLE })}> Sail Away
) : ( <> {bs.phase === "victory" && bs.goldReward > 0 && (
+{bs.goldReward} gold
)} dispatch({ type: A.DISMISS_BATTLE })}> {bs.returnScreen === "sailing" && state.destination && state.sailingDaysLeft > 0 ? "Continue Voyage" : bs.returnScreen === "arrive" && state.destination ? "Enter Port" : "Return to Port"} )}
)}
); } // ── PLUNDER SCREEN ──────────────────────────────────────────────────── function PlunderScreen({ state, dispatch }) { const bs = state.battleState; if (!bs || !bs.canPlunder) return null; const enemyCargo = bs.enemyCargo || {}; const goldReward = bs.goldReward || 0; const holdCapacity = L.getHoldCapacity(state) || 200; const [playerItems, setPlayerItems] = React.useState({ ...(state.hold?.items || {}) }); const [enemyItems, setEnemyItems] = React.useState({ ...enemyCargo }); const used = Object.values(playerItems).reduce((s, q) => s + q, 0); const free = Math.max(0, holdCapacity - used); // ── Compute total value ─────────────────────────────────────── const goodsValue = Object.entries(enemyItems).reduce((sum, [good, qty]) => { const res = window.D.RESOURCES[good]; const price = res?.basePrice ?? 0; return sum + price * (qty || 0); }, 0); const totalValue = goldReward + goodsValue; const totalFlash = useFlashOnChange(totalValue, { direction: 'up' }); // Illegal goods warning const hasIllegal = Object.keys(enemyItems).some( g => window.D.RESOURCES[g]?.illegal ); const enemyTotal = Object.values(enemyItems).reduce((s, q) => s + q, 0); const moveToPlayer = (good) => { const available = enemyItems[good] || 0; if (available <= 0 || free < 1) return; setEnemyItems(prev => ({ ...prev, [good]: prev[good] - 1 })); setPlayerItems(prev => ({ ...prev, [good]: (prev[good] || 0) + 1 })); }; const moveToEnemy = (good) => { const available = playerItems[good] || 0; if (available <= 0) return; setPlayerItems(prev => ({ ...prev, [good]: prev[good] - 1 })); setEnemyItems(prev => ({ ...prev, [good]: (prev[good] || 0) + 1 })); }; const takeAll = () => { const priority = Object.entries(enemyItems) .map(([good, qty]) => ({ good, qty, price: window.D.RESOURCES[good]?.basePrice ?? 0 })) .filter(g => g.qty > 0) .sort((a, b) => b.price - a.price); let remainingFree = free; const newPlayer = { ...playerItems }; for (const { good, qty } of priority) { const takeQty = Math.min(qty, remainingFree); if (takeQty > 0) { newPlayer[good] = (newPlayer[good] || 0) + takeQty; remainingFree -= takeQty; } } dispatch({ type: window.E.A.TAKE_PLUNDER, holdItems: newPlayer }); }; const handleConfirm = () => { dispatch({ type: window.E.A.TAKE_PLUNDER, holdItems: playerItems }); }; return (
Plunder the {bs.enemy.name}
{/* ── Top summary panel ──────────────────────────────────── */}
Plunder gold
+{goldReward}g
Cargo value
{goodsValue}g
Total haul
{totalValue}g
Take All
{hasIllegal && (
⚠ Illegal goods detected — patrols may inspect
)}
{/* ── Two‑column transfer layout ─────────────────────────── */} holdCapacity * 0.8 ? T.redBr : T.greenBr} h={8} />
{Object.keys(playerItems).length === 0 ? ( ) : ( Object.entries(playerItems).map(([good, qty]) => (
{getGoodIcon(good)} {window.D.RESOURCES[good]?.name || good} ×{qty} moveToEnemy(good)}>Jettison
)) )}
} rightTitle="ENEMY CARGO" rightContent={ Object.keys(enemyItems).length === 0 ? ( ) : ( (() => { let illegalDividerShown = false; return Object.entries(enemyItems).map(([good, qty]) => { const isIllegal = window.D.RESOURCES[good]?.illegal; const showDivider = isIllegal && !illegalDividerShown; if (showDivider) illegalDividerShown = true; return ( {showDivider && (
)}
{getGoodIcon(good)} {window.D.RESOURCES[good]?.name || good} {isIllegal && } ×{qty} moveToPlayer(good)} disabled={free < 1}>+ Take
); }); })() ) } /> {/* ── Confirm ────────────────────────────────────────────── */}
Plunder gold: +{goldReward}g
Confirm Plunder
); } Object.assign(window.S, { EventScreen, InterceptScreen, BattleScreen, PlunderScreen }); })();