// 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 (
{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
{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]) => (
))}
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:
- Broadside — reliable cannon volley
- Precision — risky but devastating if it hits
- Grapple — board the enemy. High risk, instant victory if successful. Depends on your crew size advantage and morale.
- Evade — attempt to flee the battle, depend on your ship speed.
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
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 });
})();