generated from nhcarrigan/template
e780dc5f6c
Zones now unlock in strict linear order. Each zone requires both the final boss AND the final quest of the previous zone to be completed: Verdant Vale → Shattered Ruins (forest_giant + ancient_ruins) Shattered Ruins → Frozen Peaks (elder_dragon + dragon_lair) Frozen Peaks → Shadow Marshes (void_titan + storm_citadel) Shadow Marshes → Volcanic Depths (mud_kraken + plague_ruins) Volcanic Depths → Astral Void (phoenix_lord + the_forge) - Zone type gains `unlockQuestId` field alongside `unlockBossId` - boss.ts checks both conditions before unlocking zone on boss defeat - tick.ts checks both conditions and unlocks zones + first boss on quest completion if the boss condition is already met - GameContext optimistic update also respects dual-condition logic - BossPanel zone-gate hints now show both "⚔️ Defeat: X & 📜 Complete: Y" - game.ts backfill syncs unlockQuestId and re-verifies zone status using both conditions, reverting incorrectly-unlocked saves
230 lines
7.3 KiB
TypeScript
230 lines
7.3 KiB
TypeScript
import type { Boss } from "@elysium/types";
|
||
import { useState } from "react";
|
||
import { useGame } from "../../context/GameContext.js";
|
||
import { formatNumber } from "../../utils/format.js";
|
||
import { LockToggle } from "../ui/LockToggle.js";
|
||
import { ZoneSelector } from "./ZoneSelector.js";
|
||
|
||
interface BossCardProps {
|
||
boss: Boss;
|
||
prestigeCount: number;
|
||
onChallenge: (bossId: string) => void;
|
||
isChallenging: boolean;
|
||
unlockHint?: string | undefined;
|
||
}
|
||
|
||
const BossCard = ({
|
||
boss,
|
||
prestigeCount,
|
||
onChallenge,
|
||
isChallenging,
|
||
unlockHint,
|
||
}: BossCardProps): React.JSX.Element => {
|
||
const hpPercent = (boss.currentHp / boss.maxHp) * 100;
|
||
const isPrestigeLocked = boss.prestigeRequirement > prestigeCount;
|
||
const canChallenge =
|
||
(boss.status === "available" || boss.status === "in_progress") && !isChallenging;
|
||
|
||
return (
|
||
<div className={`boss-card boss-${boss.status}`}>
|
||
<div className="boss-info">
|
||
<h3>{boss.name}</h3>
|
||
<p>{boss.description}</p>
|
||
{isPrestigeLocked && boss.status === "locked" && (
|
||
<p className="prestige-lock">
|
||
🔒 Requires Prestige {boss.prestigeRequirement}
|
||
</p>
|
||
)}
|
||
{!isPrestigeLocked && boss.status === "locked" && unlockHint && (
|
||
<p className="unlock-hint">{unlockHint}</p>
|
||
)}
|
||
</div>
|
||
|
||
{boss.status !== "locked" && boss.status !== "defeated" && (
|
||
<div className="boss-hp">
|
||
<div className="hp-bar">
|
||
<div
|
||
className="hp-fill"
|
||
style={{ width: `${hpPercent.toFixed(1)}%` }}
|
||
/>
|
||
</div>
|
||
<span className="hp-text">
|
||
{formatNumber(boss.currentHp)} / {formatNumber(boss.maxHp)} HP
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
<div className="boss-meta">
|
||
<span className="boss-dps">💢 Boss DPS: {formatNumber(boss.damagePerSecond)}</span>
|
||
</div>
|
||
|
||
<div className="boss-rewards">
|
||
<span>🪙 {formatNumber(boss.goldReward)}</span>
|
||
{boss.essenceReward > 0 && (
|
||
<span>✨ {formatNumber(boss.essenceReward)}</span>
|
||
)}
|
||
{boss.crystalReward > 0 && (
|
||
<span>💎 {formatNumber(boss.crystalReward)}</span>
|
||
)}
|
||
{(boss.equipmentRewards ?? []).length > 0 && (
|
||
<span>🗡️ {boss.equipmentRewards.length} Equipment</span>
|
||
)}
|
||
</div>
|
||
|
||
{(boss.status === "available" || boss.status === "in_progress") && (
|
||
<button
|
||
className="attack-button"
|
||
disabled={!canChallenge}
|
||
onClick={() => {
|
||
onChallenge(boss.id);
|
||
}}
|
||
type="button"
|
||
>
|
||
{isChallenging ? "⚔️ Battling…" : "⚔️ Challenge"}
|
||
</button>
|
||
)}
|
||
|
||
{boss.status === "defeated" && (
|
||
<span className="boss-badge defeated">☠️ Defeated</span>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export const BossPanel = (): React.JSX.Element => {
|
||
const { state, challengeBoss } = useGame();
|
||
const [challengingBossId, setChallengingBossId] = useState<string | null>(null);
|
||
const [activeZoneId, setActiveZoneId] = useState("verdant_vale");
|
||
const [showLocked, setShowLocked] = useState(true);
|
||
|
||
if (!state) return <section className="panel"><p>Loading...</p></section>;
|
||
|
||
// Calculate party combat stats including equipment multiplier
|
||
let globalMultiplier = 1;
|
||
for (const upgrade of state.upgrades) {
|
||
if (upgrade.purchased && upgrade.target === "global") {
|
||
globalMultiplier *= upgrade.multiplier;
|
||
}
|
||
}
|
||
const prestigeMultiplier = 1 + state.prestige.count * 0.1;
|
||
const equipmentCombatMultiplier = (state.equipment ?? [])
|
||
.filter((e) => e.equipped && e.bonus.combatMultiplier != null)
|
||
.reduce((mult, e) => mult * (e.bonus.combatMultiplier ?? 1), 1);
|
||
|
||
let partyDPS = 0;
|
||
let partyHP = 0;
|
||
for (const adventurer of state.adventurers) {
|
||
if (adventurer.count === 0) continue;
|
||
let adventurerMultiplier = 1;
|
||
for (const upgrade of state.upgrades) {
|
||
if (
|
||
upgrade.purchased &&
|
||
upgrade.target === "adventurer" &&
|
||
upgrade.adventurerId === adventurer.id
|
||
) {
|
||
adventurerMultiplier *= upgrade.multiplier;
|
||
}
|
||
}
|
||
partyDPS +=
|
||
adventurer.combatPower *
|
||
adventurer.count *
|
||
adventurerMultiplier *
|
||
globalMultiplier *
|
||
prestigeMultiplier;
|
||
partyHP += adventurer.level * 50 * adventurer.count;
|
||
}
|
||
partyDPS *= equipmentCombatMultiplier;
|
||
|
||
const handleChallenge = async (bossId: string): Promise<void> => {
|
||
setChallengingBossId(bossId);
|
||
try {
|
||
await challengeBoss(bossId);
|
||
} finally {
|
||
setChallengingBossId(null);
|
||
}
|
||
};
|
||
|
||
const zones = state.zones ?? [];
|
||
const zoneBosses = state.bosses.filter((b) => b.zoneId === activeZoneId);
|
||
const lockedCount = zoneBosses.filter((b) => b.status === "locked").length;
|
||
const visibleBosses = showLocked
|
||
? zoneBosses
|
||
: zoneBosses.filter((b) => b.status !== "locked");
|
||
|
||
const bossUnlockHints = new Map<string, string>();
|
||
for (const zone of zones) {
|
||
const allZoneBosses = state.bosses.filter((b) => b.zoneId === zone.id);
|
||
for (let i = 0; i < allZoneBosses.length; i++) {
|
||
const boss = allZoneBosses[i];
|
||
if (!boss || boss.status !== "locked") continue;
|
||
if (i === 0) {
|
||
const parts: string[] = [];
|
||
if (zone.unlockBossId) {
|
||
const gateBoss = state.bosses.find((b) => b.id === zone.unlockBossId);
|
||
if (gateBoss) parts.push(`⚔️ Defeat: ${gateBoss.name}`);
|
||
}
|
||
if (zone.unlockQuestId) {
|
||
const gateQuest = state.quests.find((q) => q.id === zone.unlockQuestId);
|
||
if (gateQuest) parts.push(`📜 Complete: ${gateQuest.name}`);
|
||
}
|
||
if (parts.length > 0) {
|
||
bossUnlockHints.set(boss.id, parts.join(" & "));
|
||
}
|
||
} else {
|
||
const prevBoss = allZoneBosses[i - 1];
|
||
if (prevBoss) {
|
||
bossUnlockHints.set(boss.id, `⚔️ Defeat: ${prevBoss.name} first`);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return (
|
||
<section className="panel boss-panel">
|
||
<div className="panel-header">
|
||
<h2>Boss Encounters</h2>
|
||
<LockToggle
|
||
lockedCount={lockedCount}
|
||
showLocked={showLocked}
|
||
onToggle={() => { setShowLocked((v) => !v); }}
|
||
/>
|
||
</div>
|
||
|
||
<ZoneSelector
|
||
activeZoneId={activeZoneId}
|
||
zones={zones}
|
||
onSelectZone={setActiveZoneId}
|
||
/>
|
||
|
||
<div className="party-combat-stats">
|
||
<div className="combat-stat">
|
||
<span className="stat-label">⚔️ Party DPS</span>
|
||
<span className="stat-value">{formatNumber(partyDPS)}</span>
|
||
</div>
|
||
<div className="combat-stat">
|
||
<span className="stat-label">❤️ Party HP</span>
|
||
<span className="stat-value">{formatNumber(partyHP)}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="boss-list">
|
||
{visibleBosses.map((boss) => (
|
||
<BossCard
|
||
key={boss.id}
|
||
boss={boss}
|
||
isChallenging={challengingBossId === boss.id}
|
||
prestigeCount={state.prestige.count}
|
||
unlockHint={bossUnlockHints.get(boss.id)}
|
||
onChallenge={(id) => {
|
||
void handleChallenge(id);
|
||
}}
|
||
/>
|
||
))}
|
||
{visibleBosses.length === 0 && (
|
||
<p className="empty-zone">No bosses to show in this zone.</p>
|
||
)}
|
||
</div>
|
||
</section>
|
||
);
|
||
};
|