ChestService.php 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488
  1. <?php
  2. namespace App\Module\GameItems\Services;
  3. use App\Module\GameItems\Enums\ITEM_TYPE;
  4. use App\Module\GameItems\Logics\ChestContent as ChestContentLogic;
  5. use App\Module\GameItems\Logics\Item as ItemLogic;
  6. use App\Module\GameItems\Logics\PityTime as PityTimeLogic;
  7. use App\Module\GameItems\Models\ItemChestContent;
  8. use App\Module\GameItems\Models\ItemChestOpenLog;
  9. use App\Module\GameItems\Models\Item;
  10. use App\Module\GameItems\Models\ItemPityTime;
  11. use Exception;
  12. use Illuminate\Support\Collection;
  13. use Illuminate\Support\Facades\DB;
  14. /**
  15. * 宝箱服务类
  16. *
  17. * 提供宝箱相关的服务,包括开启宝箱、获取宝箱内容预览等功能。
  18. * 该类处理宝箱开启的复杂逻辑,包括随机抽取物品、保底机制、物品组处理等,
  19. * 通过调用ChestContentLogic、ItemLogic和PityTimeLogic类实现具体的业务逻辑处理。
  20. */
  21. class ChestService
  22. {
  23. /**
  24. * @var ItemService
  25. */
  26. protected $itemService;
  27. /**
  28. * @var ChestContentLogic
  29. */
  30. protected $chestContentLogic;
  31. /**
  32. * @var ItemLogic
  33. */
  34. protected $itemLogic;
  35. /**
  36. * @var PityTimeLogic
  37. */
  38. protected $pityTimeLogic;
  39. /**
  40. * 构造函数
  41. *
  42. * @param ItemService $itemService
  43. */
  44. public function __construct(ItemService $itemService)
  45. {
  46. $this->itemService = $itemService;
  47. $this->chestContentLogic = new ChestContentLogic();
  48. $this->itemLogic = new ItemLogic();
  49. $this->pityTimeLogic = new PityTimeLogic();
  50. }
  51. /**
  52. * 开启宝箱
  53. *
  54. * @param int $userId 用户ID
  55. * @param int $chestId 宝箱ID
  56. * @param int $quantity 开启数量
  57. * @param array $options 选项
  58. * @return array 开启结果
  59. * @throws Exception
  60. */
  61. public function openChest(int $userId, int $chestId, int $quantity = 1, array $options = []): array
  62. {
  63. // 获取宝箱信息
  64. $chest = Item::findOrFail($chestId);
  65. // 检查是否为宝箱类型
  66. if ($chest->type != ITEM_TYPE::OPENABLE) {
  67. throw new Exception("物品 {$chestId} 不是宝箱类型");
  68. }
  69. // 获取宝箱内容配置
  70. $chestContents = ItemChestContent::where('chest_id', $chestId)->get();
  71. if ($chestContents->isEmpty()) {
  72. throw new Exception("宝箱 {$chestId} 没有配置内容");
  73. }
  74. // 开始事务
  75. DB::beginTransaction();
  76. try {
  77. // 消耗宝箱
  78. $consumeResult = $this->itemService->consumeItem(
  79. $userId,
  80. $chestId,
  81. null,
  82. $quantity,
  83. [
  84. 'source_type' => 'chest_open',
  85. 'source_id' => $chestId,
  86. 'details' => ['quantity' => $quantity],
  87. 'ip_address' => $options['ip_address'] ?? null,
  88. 'device_info' => $options['device_info'] ?? null,
  89. ]
  90. );
  91. // 获取宝箱掉落物品数量范围
  92. $minDropCount = $chest->numeric_attributes['min_drop_count'] ?? 1;
  93. $maxDropCount = $chest->numeric_attributes['max_drop_count'] ?? 1;
  94. $allResults = [];
  95. $allPityTriggered = false;
  96. $pityContentId = null;
  97. // 循环开启指定数量的宝箱
  98. for ($i = 0; $i < $quantity; $i++) {
  99. // 随机决定本次开启掉落的物品数量
  100. $dropCount = mt_rand($minDropCount, $maxDropCount);
  101. // 获取开启结果
  102. $openResult = $this->getChestOpenResult($userId, $chestId, $chestContents, $dropCount);
  103. // 添加物品到用户背包
  104. foreach ($openResult['items'] as $item) {
  105. $this->itemService->addItem(
  106. $userId,
  107. $item['item_id'],
  108. $item['quantity'],
  109. [
  110. 'source_type' => 'chest_open',
  111. 'source_id' => $chestId,
  112. 'details' => [
  113. 'chest_id' => $chestId,
  114. 'is_pity' => $item['is_pity'] ?? false,
  115. ],
  116. 'ip_address' => $options['ip_address'] ?? null,
  117. 'device_info' => $options['device_info'] ?? null,
  118. ]
  119. );
  120. }
  121. // 记录本次开启结果
  122. $allResults[] = $openResult['items'];
  123. // 更新保底触发状态
  124. if ($openResult['pity_triggered']) {
  125. $allPityTriggered = true;
  126. $pityContentId = $openResult['pity_content_id'];
  127. }
  128. }
  129. // 记录宝箱开启日志
  130. $openLog = ItemChestOpenLog::create([
  131. 'user_id' => $userId,
  132. 'chest_id' => $chestId,
  133. 'quantity' => $quantity,
  134. 'results' => $allResults,
  135. 'pity_triggered' => $allPityTriggered,
  136. 'pity_content_id' => $pityContentId,
  137. 'open_time' => now(),
  138. 'ip_address' => $options['ip_address'] ?? null,
  139. 'device_info' => $options['device_info'] ?? null,
  140. ]);
  141. DB::commit();
  142. return [
  143. 'success' => true,
  144. 'chest_id' => $chestId,
  145. 'quantity' => $quantity,
  146. 'results' => $allResults,
  147. 'pity_triggered' => $allPityTriggered,
  148. 'log_id' => $openLog->id,
  149. ];
  150. } catch (Exception $e) {
  151. DB::rollBack();
  152. throw $e;
  153. }
  154. }
  155. /**
  156. * 获取宝箱开启结果
  157. *
  158. * @param int $userId 用户ID
  159. * @param int $chestId 宝箱ID
  160. * @param Collection $chestContents 宝箱内容配置
  161. * @param int $dropCount 掉落物品数量
  162. * @return array 开启结果
  163. */
  164. protected function getChestOpenResult(int $userId, int $chestId, Collection $chestContents, int $dropCount): array
  165. {
  166. $items = [];
  167. $selectedContentIds = [];
  168. $pityTriggered = false;
  169. $pityContentId = null;
  170. // 获取用户保底计数
  171. $pityTimes = ItemPityTime::where('user_id', $userId)
  172. ->where('chest_id', $chestId)
  173. ->get()
  174. ->keyBy('chest_content_id');
  175. // 计算调整后的权重
  176. $contentsWithAdjustedWeight = $chestContents->map(function ($content) use ($pityTimes) {
  177. $pityCount = 0;
  178. if (isset($pityTimes[$content->id])) {
  179. $pityCount = $pityTimes[$content->id]->current_count;
  180. }
  181. $adjustedWeight = $this->chestContentLogic->getAdjustedWeight($content, $pityCount);
  182. return [
  183. 'content' => $content,
  184. 'adjusted_weight' => $adjustedWeight,
  185. 'pity_count' => $pityCount,
  186. ];
  187. });
  188. // 检查是否有达到保底次数的内容
  189. $guaranteedContents = $contentsWithAdjustedWeight->filter(function ($item) {
  190. return $item['content']->pity_count > 0 && $item['pity_count'] >= $item['content']->pity_count;
  191. });
  192. // 如果有达到保底次数的内容,优先选择
  193. if ($guaranteedContents->isNotEmpty()) {
  194. $guaranteedContent = $guaranteedContents->first();
  195. $content = $guaranteedContent['content'];
  196. // 添加到结果
  197. $items[] = $this->processChestContent($content, true);
  198. $selectedContentIds[] = $content->id;
  199. // 重置保底计数
  200. $this->resetPityCount($userId, $chestId, $content->id);
  201. // 更新保底触发状态
  202. $pityTriggered = true;
  203. $pityContentId = $content->id;
  204. // 减少掉落数量
  205. $dropCount--;
  206. }
  207. // 如果还需要掉落物品,继续随机选择
  208. if ($dropCount > 0) {
  209. // 计算总权重
  210. $totalWeight = $contentsWithAdjustedWeight->sum('adjusted_weight');
  211. // 随机选择指定数量的物品
  212. for ($i = 0; $i < $dropCount; $i++) {
  213. // 过滤掉已选择的内容(如果不允许重复)
  214. $availableContents = $contentsWithAdjustedWeight->filter(function ($item) use ($selectedContentIds) {
  215. return !in_array($item['content']->id, $selectedContentIds) || $item['content']->allow_duplicate;
  216. });
  217. if ($availableContents->isEmpty()) {
  218. break;
  219. }
  220. // 重新计算总权重
  221. $availableTotalWeight = $availableContents->sum('adjusted_weight');
  222. // 随机选择
  223. $randomValue = mt_rand(1, (int)($availableTotalWeight * 1000)) / 1000;
  224. $currentWeight = 0;
  225. $selectedContent = null;
  226. foreach ($availableContents as $item) {
  227. $currentWeight += $item['adjusted_weight'];
  228. if ($randomValue <= $currentWeight) {
  229. $selectedContent = $item['content'];
  230. break;
  231. }
  232. }
  233. // 如果没有选中(可能由于浮点数精度问题),选择第一个
  234. if (!$selectedContent && $availableContents->isNotEmpty()) {
  235. $selectedContent = $availableContents->first()['content'];
  236. }
  237. if ($selectedContent) {
  238. // 添加到结果
  239. $items[] = $this->processChestContent($selectedContent, false);
  240. $selectedContentIds[] = $selectedContent->id;
  241. // 增加保底计数
  242. $this->incrementPityCount($userId, $chestId, $selectedContent->id);
  243. }
  244. }
  245. }
  246. // 增加其他内容的保底计数
  247. foreach ($chestContents as $content) {
  248. if (!in_array($content->id, $selectedContentIds) && $content->pity_count > 0) {
  249. $this->incrementPityCount($userId, $chestId, $content->id);
  250. }
  251. }
  252. return [
  253. 'items' => $items,
  254. 'pity_triggered' => $pityTriggered,
  255. 'pity_content_id' => $pityContentId,
  256. ];
  257. }
  258. /**
  259. * 处理宝箱内容
  260. *
  261. * @param ItemChestContent $content 宝箱内容
  262. * @param bool $isPity 是否为保底触发
  263. * @return array 处理结果
  264. */
  265. protected function processChestContent(ItemChestContent $content, bool $isPity): array
  266. {
  267. // 获取随机数量
  268. $quantity = $this->chestContentLogic->getRandomQuantity($content);
  269. // 如果是物品组,随机选择一个物品
  270. if ($this->chestContentLogic->isGroupContent($content)) {
  271. $group = $content->group;
  272. $groupLogic = new \App\Module\GameItems\Logics\Group();
  273. $item = $groupLogic->getRandomItem($group);
  274. if (!$item) {
  275. throw new Exception("物品组 {$group->id} 没有配置物品");
  276. }
  277. return [
  278. 'item_id' => $item->id,
  279. 'quantity' => $quantity,
  280. 'is_pity' => $isPity,
  281. 'content_id' => $content->id,
  282. ];
  283. }
  284. // 直接返回物品
  285. return [
  286. 'item_id' => $content->item_id,
  287. 'quantity' => $quantity,
  288. 'is_pity' => $isPity,
  289. 'content_id' => $content->id,
  290. ];
  291. }
  292. /**
  293. * 增加保底计数
  294. *
  295. * @param int $userId 用户ID
  296. * @param int $chestId 宝箱ID
  297. * @param int $contentId 内容ID
  298. * @return void
  299. */
  300. protected function incrementPityCount(int $userId, int $chestId, int $contentId): void
  301. {
  302. $pityTime = ItemPityTime::firstOrCreate(
  303. [
  304. 'user_id' => $userId,
  305. 'chest_id' => $chestId,
  306. 'chest_content_id' => $contentId,
  307. ],
  308. [
  309. 'current_count' => 0,
  310. ]
  311. );
  312. $this->pityTimeLogic->incrementCount($pityTime);
  313. }
  314. /**
  315. * 重置保底计数
  316. *
  317. * @param int $userId 用户ID
  318. * @param int $chestId 宝箱ID
  319. * @param int $contentId 内容ID
  320. * @return void
  321. */
  322. protected function resetPityCount(int $userId, int $chestId, int $contentId): void
  323. {
  324. $pityTime = ItemPityTime::firstOrCreate(
  325. [
  326. 'user_id' => $userId,
  327. 'chest_id' => $chestId,
  328. 'chest_content_id' => $contentId,
  329. ],
  330. [
  331. 'current_count' => 0,
  332. ]
  333. );
  334. $this->pityTimeLogic->resetCount($pityTime);
  335. }
  336. /**
  337. * 获取宝箱内容预览
  338. *
  339. * @param int $chestId 宝箱ID
  340. * @return array 宝箱内容预览
  341. */
  342. public function getChestContentPreview(int $chestId): array
  343. {
  344. // 获取宝箱信息
  345. $chest = Item::findOrFail($chestId);
  346. // 检查是否为宝箱类型
  347. if ($chest->type != ITEM_TYPE::OPENABLE) {
  348. throw new Exception("物品 {$chestId} 不是宝箱类型");
  349. }
  350. // 获取宝箱内容配置
  351. $chestContents = ItemChestContent::where('chest_id', $chestId)
  352. ->with(['item', 'group.groupItems.item'])
  353. ->get();
  354. if ($chestContents->isEmpty()) {
  355. return [
  356. 'chest_id' => $chestId,
  357. 'chest_name' => $chest->name,
  358. 'contents' => [],
  359. ];
  360. }
  361. // 计算总权重
  362. $totalWeight = $chestContents->sum('weight');
  363. // 处理宝箱内容
  364. $contents = [];
  365. foreach ($chestContents as $content) {
  366. // 计算概率
  367. $probability = ($totalWeight > 0) ? ($content->weight / $totalWeight * 100) : 0;
  368. if ($content->isGroupContent()) {
  369. // 处理物品组
  370. $group = $content->group;
  371. $groupItems = [];
  372. // 计算物品组内总权重
  373. $groupTotalWeight = $group->groupItems->sum('weight');
  374. foreach ($group->groupItems as $groupItem) {
  375. // 计算物品在组内的概率
  376. $groupItemProbability = ($groupTotalWeight > 0) ? ($groupItem->weight / $groupTotalWeight * 100) : 0;
  377. // 计算物品的综合概率
  378. $combinedProbability = $probability * $groupItemProbability / 100;
  379. $groupItems[] = [
  380. 'item_id' => $groupItem->item_id,
  381. 'item_name' => $groupItem->item->name,
  382. 'item_icon' => $groupItem->item->icon,
  383. 'group_probability' => $groupItemProbability,
  384. 'combined_probability' => $combinedProbability,
  385. ];
  386. }
  387. $contents[] = [
  388. 'content_id' => $content->id,
  389. 'type' => 'group',
  390. 'group_id' => $group->id,
  391. 'group_name' => $group->name,
  392. 'min_quantity' => $content->min_quantity,
  393. 'max_quantity' => $content->max_quantity,
  394. 'probability' => $probability,
  395. 'pity_count' => $content->pity_count,
  396. 'items' => $groupItems,
  397. ];
  398. } else {
  399. // 处理单个物品
  400. $item = $content->item;
  401. $contents[] = [
  402. 'content_id' => $content->id,
  403. 'type' => 'item',
  404. 'item_id' => $item->id,
  405. 'item_name' => $item->name,
  406. 'item_icon' => $item->icon,
  407. 'min_quantity' => $content->min_quantity,
  408. 'max_quantity' => $content->max_quantity,
  409. 'probability' => $probability,
  410. 'pity_count' => $content->pity_count,
  411. ];
  412. }
  413. }
  414. return [
  415. 'chest_id' => $chestId,
  416. 'chest_name' => $chest->name,
  417. 'min_drop_count' => $chest->numeric_attributes['min_drop_count'] ?? 1,
  418. 'max_drop_count' => $chest->numeric_attributes['max_drop_count'] ?? 1,
  419. 'contents' => $contents,
  420. ];
  421. }
  422. }