ChestService.php 15 KB

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