新增百家乐游戏:①数据库表+模型 ②OpenBaccaratRoundJob开局(广播+公屏) ③CloseBaccaratRoundJob结算(摇骰+赔付+CAS防并发) ④BaccaratController下注接口 ⑤前端弹窗(倒计时/骰子动画/历史趋势) ⑥调度器每分钟检查开局 ⑦GameConfig管控开关
This commit is contained in:
@@ -72,6 +72,12 @@ enum CurrencySource: string
|
||||
/** 节日福利红包(管理员设置的定时金币福利) */
|
||||
case HOLIDAY_BONUS = 'holiday_bonus';
|
||||
|
||||
/** 百家乐下注消耗(扣除金币) */
|
||||
case BACCARAT_BET = 'baccarat_bet';
|
||||
|
||||
/** 百家乐中奖赔付(收入金币,含本金返还) */
|
||||
case BACCARAT_WIN = 'baccarat_win';
|
||||
|
||||
/**
|
||||
* 返回该来源的中文名称,用于后台统计展示。
|
||||
*/
|
||||
@@ -95,6 +101,8 @@ enum CurrencySource: string
|
||||
self::WEDDING_ENV_RECV => '领取婚礼红包',
|
||||
self::FORCED_DIVORCE_TRANSFER => '强制离婚财产转移',
|
||||
self::HOLIDAY_BONUS => '节日福利',
|
||||
self::BACCARAT_BET => '百家乐下注',
|
||||
self::BACCARAT_WIN => '百家乐赢钱',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
66
app/Events/BaccaratRoundOpened.php
Normal file
66
app/Events/BaccaratRoundOpened.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:百家乐开局广播事件
|
||||
*
|
||||
* 新局开始时广播给房间所有用户,携带局次 ID 和下注截止时间,
|
||||
* 前端收到后展示倒计时下注面板。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\BaccaratRound;
|
||||
use Illuminate\Broadcasting\Channel;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class BaccaratRoundOpened implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* @param BaccaratRound $round 本局信息
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly BaccaratRound $round,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 广播至房间公共频道。
|
||||
*
|
||||
* @return array<Channel>
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [new Channel('room.1')];
|
||||
}
|
||||
|
||||
/**
|
||||
* 广播事件名(前端监听 .baccarat.opened)。
|
||||
*/
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'baccarat.opened';
|
||||
}
|
||||
|
||||
/**
|
||||
* 广播数据。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'round_id' => $this->round->id,
|
||||
'bet_opens_at' => $this->round->bet_opens_at->toIso8601String(),
|
||||
'bet_closes_at' => $this->round->bet_closes_at->toIso8601String(),
|
||||
'bet_seconds' => (int) now()->diffInSeconds($this->round->bet_closes_at),
|
||||
];
|
||||
}
|
||||
}
|
||||
69
app/Events/BaccaratRoundSettled.php
Normal file
69
app/Events/BaccaratRoundSettled.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:百家乐结算广播事件
|
||||
*
|
||||
* 开奖后广播骰子结果和获奖类型,前端播放骰子动画,
|
||||
* 并显示用户是否中奖及赔付金额。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\BaccaratRound;
|
||||
use Illuminate\Broadcasting\Channel;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class BaccaratRoundSettled implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* @param BaccaratRound $round 已结算的局次
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly BaccaratRound $round,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 广播至房间公共频道。
|
||||
*
|
||||
* @return array<Channel>
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [new Channel('room.1')];
|
||||
}
|
||||
|
||||
/**
|
||||
* 广播事件名(前端监听 .baccarat.settled)。
|
||||
*/
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'baccarat.settled';
|
||||
}
|
||||
|
||||
/**
|
||||
* 广播数据:骰子点数 + 开奖结果 + 统计。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'round_id' => $this->round->id,
|
||||
'dice' => [$this->round->dice1, $this->round->dice2, $this->round->dice3],
|
||||
'total_points' => $this->round->total_points,
|
||||
'result' => $this->round->result,
|
||||
'result_label' => $this->round->resultLabel(),
|
||||
'total_payout' => $this->round->total_payout,
|
||||
'bet_count' => $this->round->bet_count,
|
||||
];
|
||||
}
|
||||
}
|
||||
171
app/Http/Controllers/BaccaratController.php
Normal file
171
app/Http/Controllers/BaccaratController.php
Normal file
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:百家乐前台下注控制器
|
||||
*
|
||||
* 提供用户在聊天室内下注的 API 接口:
|
||||
* - 查询当前局次信息
|
||||
* - 提交下注(扣除金币 + 写入下注记录)
|
||||
* - 查询本人在当前局的下注状态
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\CurrencySource;
|
||||
use App\Models\BaccaratBet;
|
||||
use App\Models\BaccaratRound;
|
||||
use App\Models\GameConfig;
|
||||
use App\Services\UserCurrencyService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class BaccaratController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserCurrencyService $currency,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 获取当前进行中的局次信息(前端轮询或开局事件后调用)。
|
||||
*/
|
||||
public function currentRound(Request $request): JsonResponse
|
||||
{
|
||||
$round = BaccaratRound::currentRound();
|
||||
|
||||
if (! $round) {
|
||||
return response()->json(['round' => null]);
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
$myBet = BaccaratBet::query()
|
||||
->where('round_id', $round->id)
|
||||
->where('user_id', $user->id)
|
||||
->first();
|
||||
|
||||
return response()->json([
|
||||
'round' => [
|
||||
'id' => $round->id,
|
||||
'status' => $round->status,
|
||||
'bet_closes_at' => $round->bet_closes_at->toIso8601String(),
|
||||
'seconds_left' => max(0, (int) now()->diffInSeconds($round->bet_closes_at, false)),
|
||||
'total_bet_big' => $round->total_bet_big,
|
||||
'total_bet_small' => $round->total_bet_small,
|
||||
'total_bet_triple' => $round->total_bet_triple,
|
||||
'my_bet' => $myBet ? [
|
||||
'bet_type' => $myBet->bet_type,
|
||||
'amount' => $myBet->amount,
|
||||
] : null,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户提交下注。
|
||||
*
|
||||
* 同一局每人限下一注(后台强制幂等)。
|
||||
* 下注成功后立即扣除金币,结算时中奖者才返还本金+赔付。
|
||||
*/
|
||||
public function bet(Request $request): JsonResponse
|
||||
{
|
||||
if (! GameConfig::isEnabled('baccarat')) {
|
||||
return response()->json(['ok' => false, 'message' => '百家乐游戏当前未开启。']);
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'round_id' => 'required|integer|exists:baccarat_rounds,id',
|
||||
'bet_type' => 'required|in:big,small,triple',
|
||||
'amount' => 'required|integer|min:1',
|
||||
]);
|
||||
|
||||
$config = GameConfig::forGame('baccarat')?->params ?? [];
|
||||
$minBet = (int) ($config['min_bet'] ?? 100);
|
||||
$maxBet = (int) ($config['max_bet'] ?? 50000);
|
||||
|
||||
if ($data['amount'] < $minBet || $data['amount'] > $maxBet) {
|
||||
return response()->json(['ok' => false, 'message' => "押注金额须在 {$minBet}~{$maxBet} 金币之间。"]);
|
||||
}
|
||||
|
||||
$round = BaccaratRound::find($data['round_id']);
|
||||
|
||||
if (! $round || ! $round->isBettingOpen()) {
|
||||
return response()->json(['ok' => false, 'message' => '当前不在下注时间内。']);
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
// 检查用户金币余额
|
||||
if ($user->gold < $data['amount']) {
|
||||
return response()->json(['ok' => false, 'message' => '金币不足,无法下注。']);
|
||||
}
|
||||
|
||||
$currency = $this->currency;
|
||||
|
||||
return DB::transaction(function () use ($user, $round, $data, $currency): JsonResponse {
|
||||
// 幂等:同一局只能下一注
|
||||
$existing = BaccaratBet::query()
|
||||
->where('round_id', $round->id)
|
||||
->where('user_id', $user->id)
|
||||
->lockForUpdate()
|
||||
->exists();
|
||||
|
||||
if ($existing) {
|
||||
return response()->json(['ok' => false, 'message' => '本局您已下注,请等待开奖。']);
|
||||
}
|
||||
|
||||
// 扣除金币
|
||||
$currency->change(
|
||||
$user,
|
||||
'gold',
|
||||
-$data['amount'],
|
||||
CurrencySource::BACCARAT_BET,
|
||||
"百家乐 #{$round->id} 押 ".match ($data['bet_type']) {
|
||||
'big' => '大', 'small' => '小', default => '豹子'
|
||||
},
|
||||
);
|
||||
|
||||
// 写入下注记录
|
||||
BaccaratBet::create([
|
||||
'round_id' => $round->id,
|
||||
'user_id' => $user->id,
|
||||
'bet_type' => $data['bet_type'],
|
||||
'amount' => $data['amount'],
|
||||
'status' => 'pending',
|
||||
]);
|
||||
|
||||
// 更新局次汇总统计
|
||||
$field = 'total_bet_'.$data['bet_type'];
|
||||
$round->increment($field, $data['amount']);
|
||||
$round->increment('bet_count');
|
||||
|
||||
$betLabel = match ($data['bet_type']) {
|
||||
'big' => '大', 'small' => '小', default => '豹子'
|
||||
};
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'message' => "✅ 已押注「{$betLabel}」{$data['amount']} 金币,等待开奖!",
|
||||
'amount' => $data['amount'],
|
||||
'bet_type' => $data['bet_type'],
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询最近5局的历史记录(前端展示趋势)。
|
||||
*/
|
||||
public function history(): JsonResponse
|
||||
{
|
||||
$rounds = BaccaratRound::query()
|
||||
->where('status', 'settled')
|
||||
->orderByDesc('id')
|
||||
->limit(10)
|
||||
->get(['id', 'dice1', 'dice2', 'dice3', 'total_points', 'result', 'settled_at']);
|
||||
|
||||
return response()->json(['history' => $rounds]);
|
||||
}
|
||||
}
|
||||
179
app/Jobs/CloseBaccaratRoundJob.php
Normal file
179
app/Jobs/CloseBaccaratRoundJob.php
Normal file
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:百家乐结算队列任务
|
||||
*
|
||||
* 在押注截止时间到达后自动触发:
|
||||
* ① 摇三颗骰子 ② 判断大/小/豹子/庄家收割
|
||||
* ③ 遍历下注记录逐一发放赔付 ④ 广播结果 ⑤ 公屏公告
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Enums\CurrencySource;
|
||||
use App\Events\BaccaratRoundSettled;
|
||||
use App\Events\MessageSent;
|
||||
use App\Models\BaccaratBet;
|
||||
use App\Models\BaccaratRound;
|
||||
use App\Models\GameConfig;
|
||||
use App\Services\ChatStateService;
|
||||
use App\Services\UserCurrencyService;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class CloseBaccaratRoundJob implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
/**
|
||||
* 最大重试次数。
|
||||
*/
|
||||
public int $tries = 3;
|
||||
|
||||
/**
|
||||
* @param BaccaratRound $round 要结算的局次
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly BaccaratRound $round,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 执行结算逻辑。
|
||||
*/
|
||||
public function handle(
|
||||
UserCurrencyService $currency,
|
||||
ChatStateService $chatState,
|
||||
): void {
|
||||
$round = $this->round->fresh();
|
||||
|
||||
// 防止重复结算
|
||||
if (! $round || $round->status !== 'betting') {
|
||||
return;
|
||||
}
|
||||
|
||||
$config = GameConfig::forGame('baccarat')?->params ?? [];
|
||||
|
||||
// 乐观锁:CAS 先把状态改为 settling
|
||||
$updated = BaccaratRound::query()
|
||||
->where('id', $round->id)
|
||||
->where('status', 'betting')
|
||||
->update(['status' => 'settling']);
|
||||
|
||||
if (! $updated) {
|
||||
return; // 已被其他进程处理
|
||||
}
|
||||
|
||||
// ── 摇骰子 ──────────────────────────────────────────────────
|
||||
$dice = [random_int(1, 6), random_int(1, 6), random_int(1, 6)];
|
||||
$total = array_sum($dice);
|
||||
|
||||
// ── 判断结果 ────────────────────────────────────────────────
|
||||
$killPoints = $config['kill_points'] ?? [3, 18];
|
||||
if (! is_array($killPoints)) {
|
||||
$killPoints = explode(',', (string) $killPoints);
|
||||
}
|
||||
$killPoints = array_map('intval', $killPoints);
|
||||
|
||||
$result = match (true) {
|
||||
$dice[0] === $dice[1] && $dice[1] === $dice[2] => 'triple', // 豹子(优先判断)
|
||||
in_array($total, $killPoints, true) => 'kill', // 庄家收割
|
||||
$total >= 11 && $total <= 17 => 'big', // 大
|
||||
default => 'small', // 小
|
||||
};
|
||||
|
||||
// ── 结算下注记录 ─────────────────────────────────────────────
|
||||
$bets = BaccaratBet::query()->where('round_id', $round->id)->where('status', 'pending')->with('user')->get();
|
||||
$totalPayout = 0;
|
||||
|
||||
DB::transaction(function () use ($bets, $result, $config, $currency, &$totalPayout) {
|
||||
foreach ($bets as $bet) {
|
||||
if ($result === 'kill') {
|
||||
// 庄家收割:全灭无退款
|
||||
$bet->update(['status' => 'lost', 'payout' => 0]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($bet->bet_type === $result) {
|
||||
// 中奖:计算赔付(含本金返还)
|
||||
$payout = BaccaratRound::calcPayout($bet->bet_type, $bet->amount, $config);
|
||||
$bet->update(['status' => 'won', 'payout' => $payout]);
|
||||
|
||||
// 金币入账
|
||||
$currency->change(
|
||||
$bet->user,
|
||||
'gold',
|
||||
$payout,
|
||||
CurrencySource::BACCARAT_WIN,
|
||||
"百家乐 #{$this->round->id} 押 {$bet->betTypeLabel()} 中奖",
|
||||
);
|
||||
$totalPayout += $payout;
|
||||
} else {
|
||||
$bet->update(['status' => 'lost', 'payout' => 0]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── 更新局次记录 ─────────────────────────────────────────────
|
||||
$round->update([
|
||||
'dice1' => $dice[0],
|
||||
'dice2' => $dice[1],
|
||||
'dice3' => $dice[2],
|
||||
'total_points' => $total,
|
||||
'result' => $result,
|
||||
'status' => 'settled',
|
||||
'settled_at' => now(),
|
||||
'total_payout' => $totalPayout,
|
||||
]);
|
||||
|
||||
$round->refresh();
|
||||
|
||||
// ── 广播结算事件 ─────────────────────────────────────────────
|
||||
broadcast(new BaccaratRoundSettled($round));
|
||||
|
||||
// ── 公屏公告 ─────────────────────────────────────────────────
|
||||
$this->pushResultMessage($round, $chatState);
|
||||
}
|
||||
|
||||
/**
|
||||
* 向公屏发送开奖结果系统消息。
|
||||
*/
|
||||
private function pushResultMessage(BaccaratRound $round, ChatStateService $chatState): void
|
||||
{
|
||||
$diceStr = "【{$round->dice1}】【{$round->dice2}】【{$round->dice3}】";
|
||||
|
||||
$resultText = match ($round->result) {
|
||||
'big' => "🔵 大({$round->total_points} 点)",
|
||||
'small' => "🟡 小({$round->total_points} 点)",
|
||||
'triple' => "💥 豹子!({$round->dice1}{$round->dice1}{$round->dice1})",
|
||||
'kill' => "☠️ 庄家收割!({$round->total_points} 点)全灭",
|
||||
default => '',
|
||||
};
|
||||
|
||||
$payoutText = $round->total_payout > 0
|
||||
? '共派发 🪙'.number_format($round->total_payout).' 金币'
|
||||
: '本局无人获奖';
|
||||
|
||||
$content = "🎲 【百家乐】第 #{$round->id} 局开奖!{$diceStr} 总点 {$round->total_points} → {$resultText}!{$payoutText}。";
|
||||
|
||||
$msg = [
|
||||
'id' => $chatState->nextMessageId(1),
|
||||
'room_id' => 1,
|
||||
'from_user' => '系统传音',
|
||||
'to_user' => '大家',
|
||||
'content' => $content,
|
||||
'is_secret' => false,
|
||||
'font_color' => '#8b5cf6',
|
||||
'action' => '大声宣告',
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
];
|
||||
$chatState->pushMessage(1, $msg);
|
||||
broadcast(new MessageSent(1, $msg));
|
||||
SaveMessageJob::dispatch($msg);
|
||||
}
|
||||
}
|
||||
87
app/Jobs/OpenBaccaratRoundJob.php
Normal file
87
app/Jobs/OpenBaccaratRoundJob.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:百家乐开局队列任务
|
||||
*
|
||||
* 由调度器每 N 分钟触发一次(N 来自 game_configs.params.interval_minutes),
|
||||
* 游戏开启时创建新局次,广播开局事件,并在押注截止时间到期后自动调度结算任务。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Events\BaccaratRoundOpened;
|
||||
use App\Events\MessageSent;
|
||||
use App\Models\BaccaratRound;
|
||||
use App\Models\GameConfig;
|
||||
use App\Services\ChatStateService;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
|
||||
class OpenBaccaratRoundJob implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
/**
|
||||
* 最大重试次数。
|
||||
*/
|
||||
public int $tries = 1;
|
||||
|
||||
/**
|
||||
* 执行开局逻辑。
|
||||
*/
|
||||
public function handle(ChatStateService $chatState): void
|
||||
{
|
||||
// 检查游戏是否开启
|
||||
if (! GameConfig::isEnabled('baccarat')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$config = GameConfig::forGame('baccarat')?->params ?? [];
|
||||
$betSeconds = (int) ($config['bet_window_seconds'] ?? 60);
|
||||
|
||||
// 防止重复开局(如果上一局还在押注中则跳过)
|
||||
if (BaccaratRound::currentRound()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$now = now();
|
||||
$closesAt = $now->copy()->addSeconds($betSeconds);
|
||||
|
||||
// 创建新局次
|
||||
$round = BaccaratRound::create([
|
||||
'status' => 'betting',
|
||||
'bet_opens_at' => $now,
|
||||
'bet_closes_at' => $closesAt,
|
||||
]);
|
||||
|
||||
// 广播开局事件
|
||||
broadcast(new BaccaratRoundOpened($round));
|
||||
|
||||
// 公屏系统公告
|
||||
$minBet = number_format($config['min_bet'] ?? 100);
|
||||
$maxBet = number_format($config['max_bet'] ?? 50000);
|
||||
|
||||
$content = "🎲 【百家乐】第 #{$round->id} 局开始!下注时间 {$betSeconds} 秒,可押「大/小/豹子」,押注范围 {$minBet}~{$maxBet} 金币。";
|
||||
$msg = [
|
||||
'id' => $chatState->nextMessageId(1),
|
||||
'room_id' => 1,
|
||||
'from_user' => '系统传音',
|
||||
'to_user' => '大家',
|
||||
'content' => $content,
|
||||
'is_secret' => false,
|
||||
'font_color' => '#8b5cf6',
|
||||
'action' => '大声宣告',
|
||||
'sent_at' => $now->toDateTimeString(),
|
||||
];
|
||||
$chatState->pushMessage(1, $msg);
|
||||
broadcast(new MessageSent(1, $msg));
|
||||
SaveMessageJob::dispatch($msg);
|
||||
|
||||
// 在下注截止时安排结算任务
|
||||
CloseBaccaratRoundJob::dispatch($round)->delay($closesAt);
|
||||
}
|
||||
}
|
||||
68
app/Models/BaccaratBet.php
Normal file
68
app/Models/BaccaratBet.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:百家乐下注记录模型
|
||||
*
|
||||
* 记录用户在某局中的押注信息和结算状态。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class BaccaratBet extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'round_id',
|
||||
'user_id',
|
||||
'bet_type',
|
||||
'amount',
|
||||
'payout',
|
||||
'status',
|
||||
];
|
||||
|
||||
/**
|
||||
* 属性类型转换。
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'amount' => 'integer',
|
||||
'payout' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 关联局次。
|
||||
*/
|
||||
public function round(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(BaccaratRound::class, 'round_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 关联用户。
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取押注类型中文名。
|
||||
*/
|
||||
public function betTypeLabel(): string
|
||||
{
|
||||
return match ($this->bet_type) {
|
||||
'big' => '大',
|
||||
'small' => '小',
|
||||
'triple' => '豹子',
|
||||
default => '未知',
|
||||
};
|
||||
}
|
||||
}
|
||||
111
app/Models/BaccaratRound.php
Normal file
111
app/Models/BaccaratRound.php
Normal file
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:百家乐局次模型
|
||||
*
|
||||
* 代表一局百家乐游戏,包含骰子结果、局次状态、下注汇总等信息。
|
||||
* 提供局次判断和赔率计算的辅助方法。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class BaccaratRound extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'dice1', 'dice2', 'dice3',
|
||||
'total_points', 'result', 'status',
|
||||
'bet_opens_at', 'bet_closes_at', 'settled_at',
|
||||
'total_bet_big', 'total_bet_small', 'total_bet_triple',
|
||||
'total_payout', 'bet_count',
|
||||
];
|
||||
|
||||
/**
|
||||
* 属性类型转换。
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'bet_opens_at' => 'datetime',
|
||||
'bet_closes_at' => 'datetime',
|
||||
'settled_at' => 'datetime',
|
||||
'dice1' => 'integer',
|
||||
'dice2' => 'integer',
|
||||
'dice3' => 'integer',
|
||||
'total_points' => 'integer',
|
||||
'total_bet_big' => 'integer',
|
||||
'total_bet_small' => 'integer',
|
||||
'total_bet_triple' => 'integer',
|
||||
'total_payout' => 'integer',
|
||||
'bet_count' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 该局的所有下注记录。
|
||||
*/
|
||||
public function bets(): HasMany
|
||||
{
|
||||
return $this->hasMany(BaccaratBet::class, 'round_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断当前是否在押注时间窗口内。
|
||||
*/
|
||||
public function isBettingOpen(): bool
|
||||
{
|
||||
return $this->status === 'betting'
|
||||
&& now()->between($this->bet_opens_at, $this->bet_closes_at);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算指定押注类型和金额的预计回报(含本金)。
|
||||
*
|
||||
* @param string $betType 'big' | 'small' | 'triple'
|
||||
* @param int $amount 押注金额
|
||||
* @param array $config 游戏配置参数
|
||||
*/
|
||||
public static function calcPayout(string $betType, int $amount, array $config): int
|
||||
{
|
||||
$payout = match ($betType) {
|
||||
'triple' => $amount * ($config['payout_triple'] + 1),
|
||||
'big' => $amount * ($config['payout_big'] + 1),
|
||||
'small' => $amount * ($config['payout_small'] + 1),
|
||||
default => 0,
|
||||
};
|
||||
|
||||
return (int) $payout;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取结果中文名称。
|
||||
*/
|
||||
public function resultLabel(): string
|
||||
{
|
||||
return match ($this->result) {
|
||||
'big' => '大',
|
||||
'small' => '小',
|
||||
'triple' => "豹子({$this->dice1}{$this->dice1}{$this->dice1})",
|
||||
'kill' => '庄家收割',
|
||||
default => '未知',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询当前正在进行的局次(状态为 betting 且未截止)。
|
||||
*/
|
||||
public static function currentRound(): ?static
|
||||
{
|
||||
return static::query()
|
||||
->where('status', 'betting')
|
||||
->where('bet_closes_at', '>', now())
|
||||
->latest()
|
||||
->first();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:百家乐局次表迁移
|
||||
*
|
||||
* 每一局百家乐对应一条记录,存储骰子结果、局次状态、投注汇总等信息。
|
||||
*/
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 创建 baccarat_rounds 表。
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('baccarat_rounds', function (Blueprint $table) {
|
||||
$table->id();
|
||||
|
||||
// 骰子结果(开奖后写入)
|
||||
$table->unsignedTinyInteger('dice1')->nullable()->comment('第一颗骰子点数');
|
||||
$table->unsignedTinyInteger('dice2')->nullable()->comment('第二颗骰子点数');
|
||||
$table->unsignedTinyInteger('dice3')->nullable()->comment('第三颗骰子点数');
|
||||
$table->unsignedTinyInteger('total_points')->nullable()->comment('骰子总点数');
|
||||
$table->enum('result', ['big', 'small', 'triple', 'kill'])->nullable()->comment('开奖结果');
|
||||
|
||||
// 局次状态
|
||||
$table->enum('status', ['betting', 'settling', 'settled', 'cancelled'])->default('betting');
|
||||
|
||||
// 时间
|
||||
$table->dateTime('bet_opens_at')->comment('下注开始时间');
|
||||
$table->dateTime('bet_closes_at')->comment('下注截止时间');
|
||||
$table->dateTime('settled_at')->nullable()->comment('结算完成时间');
|
||||
|
||||
// 投注汇总统计
|
||||
$table->unsignedBigInteger('total_bet_big')->default(0)->comment('押大总额');
|
||||
$table->unsignedBigInteger('total_bet_small')->default(0)->comment('押小总额');
|
||||
$table->unsignedBigInteger('total_bet_triple')->default(0)->comment('押豹子总额');
|
||||
$table->unsignedBigInteger('total_payout')->default(0)->comment('总赔付额');
|
||||
$table->unsignedInteger('bet_count')->default(0)->comment('下注人次');
|
||||
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('status');
|
||||
$table->index('bet_closes_at');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 回滚迁移。
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('baccarat_rounds');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:百家乐下注记录表迁移
|
||||
*
|
||||
* 记录每个用户在每局中的押注类型、金额和结算结果。
|
||||
*/
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 创建 baccarat_bets 表。
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('baccarat_bets', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('round_id')->comment('关联局次');
|
||||
$table->unsignedBigInteger('user_id')->comment('下注用户');
|
||||
$table->enum('bet_type', ['big', 'small', 'triple'])->comment('押注类型');
|
||||
$table->unsignedInteger('amount')->comment('押注金额');
|
||||
$table->unsignedInteger('payout')->default(0)->comment('赔付金额(含本金)');
|
||||
$table->enum('status', ['pending', 'won', 'lost', 'refunded'])->default('pending');
|
||||
$table->timestamps();
|
||||
|
||||
$table->foreign('round_id')->references('id')->on('baccarat_rounds')->cascadeOnDelete();
|
||||
$table->index(['round_id', 'user_id']);
|
||||
$table->index(['user_id', 'status']);
|
||||
// 每局每人每种类型只能下一注(可在 Service 层控制)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 回滚迁移。
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('baccarat_bets');
|
||||
}
|
||||
};
|
||||
@@ -101,6 +101,19 @@ export function initChat(roomId) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("chat:holiday.started", { detail: e }),
|
||||
);
|
||||
})
|
||||
// ─── 百家乐:开局 & 结算 ──────────────────────────────────
|
||||
.listen(".baccarat.opened", (e) => {
|
||||
console.log("百家乐开局:", e);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("chat:baccarat.opened", { detail: e }),
|
||||
);
|
||||
})
|
||||
.listen(".baccarat.settled", (e) => {
|
||||
console.log("百家乐结算:", e);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("chat:baccarat.settled", { detail: e }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -137,6 +137,8 @@
|
||||
@include('chat.partials.marriage-modals')
|
||||
{{-- ═══════════ 节日福利弹窗组件 ═══════════ --}}
|
||||
@include('chat.partials.holiday-modal')
|
||||
{{-- ═══════════ 百家乐游戏面板 ═══════════ --}}
|
||||
@include('chat.partials.baccarat-panel')
|
||||
|
||||
{{-- 全屏特效系统:管理员烟花/下雨/雷电/下雪 --}}
|
||||
<script src="/js/effects/effect-sounds.js"></script>
|
||||
|
||||
514
resources/views/chat/partials/baccarat-panel.blade.php
Normal file
514
resources/views/chat/partials/baccarat-panel.blade.php
Normal file
@@ -0,0 +1,514 @@
|
||||
{{--
|
||||
文件功能:百家乐前台弹窗组件
|
||||
|
||||
聊天室内百家乐游戏面板:
|
||||
- 监听 WebSocket baccarat.opened 事件触发弹窗
|
||||
- 倒计时下注(大/小/豹子)
|
||||
- 监听 baccarat.settled 展示骰子动画 + 结果 + 个人赔付
|
||||
- 展示近10局历史趋势
|
||||
--}}
|
||||
|
||||
{{-- ─── 百家乐主面板 ─── --}}
|
||||
<div id="baccarat-panel" x-data="baccaratPanel()" x-show="show" x-cloak>
|
||||
<div x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100" x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100 scale-100" x-transition:leave-end="opacity-0 scale-95"
|
||||
style="position:fixed; inset:0; background:rgba(0,0,0,.7); z-index:9940;
|
||||
display:flex; align-items:center; justify-content:center;">
|
||||
|
||||
<div
|
||||
style="width:480px; max-width:96vw; border-radius:24px; overflow:hidden;
|
||||
box-shadow:0 24px 80px rgba(139,92,246,.5); font-family:system-ui,sans-serif;">
|
||||
|
||||
{{-- ─── 顶部标题 ─── --}}
|
||||
<div
|
||||
style="background:linear-gradient(135deg,#4c1d95,#6d28d9,#7c3aed); padding:18px 22px 14px; position:relative;">
|
||||
<div style="display:flex; align-items:center; justify-content:space-between;">
|
||||
<div>
|
||||
<div style="color:#fff; font-weight:900; font-size:18px; letter-spacing:1px;">🎲 百家乐</div>
|
||||
<div style="color:rgba(255,255,255,.6); font-size:12px; margin-top:2px;">
|
||||
第 <span x-text="'#' + roundId"></span> 局
|
||||
</div>
|
||||
</div>
|
||||
{{-- 倒计时 --}}
|
||||
<div x-show="phase === 'betting'" style="text-align:center;">
|
||||
<div style="color:#fbbf24; font-size:32px; font-weight:900; line-height:1;" x-text="countdown">
|
||||
</div>
|
||||
<div style="color:rgba(255,255,255,.5); font-size:11px;">秒后截止</div>
|
||||
</div>
|
||||
{{-- 骰子结果 --}}
|
||||
<div x-show="phase === 'settled'" style="display:none; text-align:center;">
|
||||
<div style="font-size:28px;" x-text="diceEmoji"></div>
|
||||
<div style="color:#fbbf24; font-size:12px; font-weight:bold; margin-top:2px;"
|
||||
x-text="resultLabel"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 进度条 --}}
|
||||
<div x-show="phase === 'betting'"
|
||||
style="margin-top:10px; height:4px; background:rgba(255,255,255,.15); border-radius:2px; overflow:hidden;">
|
||||
<div style="height:100%; background:#fbbf24; border-radius:2px; transition:width 1s linear;"
|
||||
:style="'width:' + Math.max(0, (countdown / totalSeconds * 100)) + '%'"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ─── 历史趋势 ─── --}}
|
||||
<div
|
||||
style="background:#1e1b4b; padding:8px 16px; display:flex; gap:6px; align-items:center; flex-wrap:wrap;">
|
||||
<span style="color:rgba(255,255,255,.4); font-size:11px; margin-right:2px;">近期</span>
|
||||
<template x-for="h in history" :key="h.id">
|
||||
<span
|
||||
style="width:22px; height:22px; border-radius:50%; font-size:11px; font-weight:bold;
|
||||
display:flex; align-items:center; justify-content:center;"
|
||||
:style="h.result === 'big' ? 'background:#1d4ed8; color:#fff' :
|
||||
h.result === 'small' ? 'background:#b45309; color:#fff' :
|
||||
h.result === 'triple' ? 'background:#7c3aed; color:#fff' :
|
||||
'background:#374151; color:#9ca3af'"
|
||||
:title="'#' + h.id + ' ' + (h.result === 'big' ? '大' : h.result === 'small' ? '小' : h
|
||||
.result === 'triple' ? '豹' : '☠')"
|
||||
x-text="h.result === 'big' ? '大' : h.result === 'small' ? '小' : h.result === 'triple' ? '豹' : '☠'">
|
||||
</span>
|
||||
</template>
|
||||
<span x-show="history.length === 0" style="color:rgba(255,255,255,.3); font-size:11px;">暂无记录</span>
|
||||
</div>
|
||||
|
||||
{{-- ─── 主体内容 ─── --}}
|
||||
<div style="background:linear-gradient(180deg,#1e1b4b,#1a1035); padding:18px 20px;">
|
||||
|
||||
{{-- 押注阶段 --}}
|
||||
<div x-show="phase === 'betting'">
|
||||
{{-- 当前下注池统计 --}}
|
||||
<div style="display:grid; grid-template-columns:1fr 1fr 1fr; gap:8px; margin-bottom:14px;">
|
||||
<div style="background:rgba(29,78,216,.3); border-radius:10px; padding:8px; text-align:center;">
|
||||
<div style="color:#60a5fa; font-size:11px;">押大</div>
|
||||
<div style="color:#fff; font-weight:bold; font-size:13px;"
|
||||
x-text="Number(totalBetBig).toLocaleString()"></div>
|
||||
</div>
|
||||
<div style="background:rgba(180,83,9,.3); border-radius:10px; padding:8px; text-align:center;">
|
||||
<div style="color:#fbbf24; font-size:11px;">押小</div>
|
||||
<div style="color:#fff; font-weight:bold; font-size:13px;"
|
||||
x-text="Number(totalBetSmall).toLocaleString()"></div>
|
||||
</div>
|
||||
<div
|
||||
style="background:rgba(124,58,237,.3); border-radius:10px; padding:8px; text-align:center;">
|
||||
<div style="color:#c4b5fd; font-size:11px;">押豹子</div>
|
||||
<div style="color:#fff; font-weight:bold; font-size:13px;"
|
||||
x-text="Number(totalBetTriple).toLocaleString()"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 已下注状态 / 下注表单 --}}
|
||||
<div x-show="myBet">
|
||||
<div
|
||||
style="background:rgba(34,197,94,.15); border:1px solid rgba(34,197,94,.3); border-radius:12px;
|
||||
padding:12px 16px; text-align:center; margin-bottom:12px;">
|
||||
<div style="color:#4ade80; font-weight:bold; font-size:14px;">
|
||||
✅ 已押注「<span x-text="betTypeLabel(myBetType)"></span>」
|
||||
<span x-text="Number(myBetAmount).toLocaleString()"></span> 金币
|
||||
</div>
|
||||
<div style="color:rgba(255,255,255,.4); font-size:11px; margin-top:4px;">等待开奖中…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="!myBet">
|
||||
{{-- 押注选项 --}}
|
||||
<div style="display:grid; grid-template-columns:1fr 1fr 1fr; gap:8px; margin-bottom:12px;">
|
||||
{{-- 大 --}}
|
||||
<button x-on:click="selectedType='big'"
|
||||
style="border:none; border-radius:12px; padding:12px 0; cursor:pointer; transition:all .15s; font-weight:bold;"
|
||||
:style="selectedType === 'big' ?
|
||||
'background:#1d4ed8; color:#fff; transform:scale(1.05); box-shadow:0 4px 20px rgba(29,78,216,.5)' :
|
||||
'background:rgba(29,78,216,.2); color:#93c5fd;'">
|
||||
<div style="font-size:20px;">🔵</div>
|
||||
<div style="font-size:13px; margin-top:2px;">大</div>
|
||||
<div style="font-size:10px; opacity:.7;">11~17点 • 1:1</div>
|
||||
</button>
|
||||
{{-- 小 --}}
|
||||
<button x-on:click="selectedType='small'"
|
||||
style="border:none; border-radius:12px; padding:12px 0; cursor:pointer; transition:all .15s; font-weight:bold;"
|
||||
:style="selectedType === 'small' ?
|
||||
'background:#b45309; color:#fff; transform:scale(1.05); box-shadow:0 4px 20px rgba(180,83,9,.5)' :
|
||||
'background:rgba(180,83,9,.2); color:#fcd34d;'">
|
||||
<div style="font-size:20px;">🟡</div>
|
||||
<div style="font-size:13px; margin-top:2px;">小</div>
|
||||
<div style="font-size:10px; opacity:.7;">4~10点 • 1:1</div>
|
||||
</button>
|
||||
{{-- 豹子 --}}
|
||||
<button x-on:click="selectedType='triple'"
|
||||
style="border:none; border-radius:12px; padding:12px 0; cursor:pointer; transition:all .15s; font-weight:bold;"
|
||||
:style="selectedType === 'triple' ?
|
||||
'background:#7c3aed; color:#fff; transform:scale(1.05); box-shadow:0 4px 20px rgba(124,58,237,.5)' :
|
||||
'background:rgba(124,58,237,.2); color:#c4b5fd;'">
|
||||
<div style="font-size:20px;">💥</div>
|
||||
<div style="font-size:13px; margin-top:2px;">豹子</div>
|
||||
<div style="font-size:10px; opacity:.7;">三同 • 1:24</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- 快捷金额 + 自定义 --}}
|
||||
<div style="margin-bottom:10px;">
|
||||
<div style="display:flex; gap:6px; margin-bottom:8px; flex-wrap:wrap;">
|
||||
<template x-for="preset in [100, 500, 1000, 5000, 10000]" :key="preset">
|
||||
<button x-on:click="betAmount = preset"
|
||||
style="flex:1; min-width:50px; border:none; border-radius:8px; padding:6px 4px;
|
||||
font-size:12px; font-weight:bold; cursor:pointer; transition:all .1s;"
|
||||
:style="betAmount === preset ?
|
||||
'background:#fbbf24; color:#1a1035;' :
|
||||
'background:rgba(255,255,255,.1); color:rgba(255,255,255,.7);'"
|
||||
x-text="preset >= 1000 ? (preset/1000)+'k' : preset">
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<input type="number" x-model.number="betAmount" min="100" placeholder="自定义金额"
|
||||
style="width:100%; background:rgba(255,255,255,.1); border:1px solid rgba(255,255,255,.15);
|
||||
border-radius:8px; padding:8px 12px; color:#fff; font-size:13px; box-sizing:border-box;"
|
||||
x-on:focus="$event.target.select()">
|
||||
</div>
|
||||
|
||||
{{-- 下注按钮 --}}
|
||||
<button x-on:click="submitBet()" :disabled="!selectedType || betAmount < 100 || submitting"
|
||||
style="width:100%; border:none; border-radius:12px; padding:13px; font-size:14px;
|
||||
font-weight:bold; cursor:pointer; transition:all .15s; letter-spacing:1px;"
|
||||
:style="(!selectedType || betAmount < 100 || submitting) ?
|
||||
'background:rgba(255,255,255,.1); color:rgba(255,255,255,.3); cursor:not-allowed;' :
|
||||
'background:linear-gradient(135deg,#7c3aed,#4f46e5); color:#fff; box-shadow:0 4px 20px rgba(124,58,237,.4);'"
|
||||
x-text="submitting ? '提交中…' : ('🎲 押注「' + betTypeLabel(selectedType) + '」' + (betAmount > 0 ? ' ' + Number(betAmount).toLocaleString() + ' 金币' : ''))">
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- 规则提示 --}}
|
||||
<div style="margin-top:10px; color:rgba(255,255,255,.3); font-size:10px; text-align:center;">
|
||||
☠️ 3点或18点为庄家收割,全灭无退款。豹子优先于大小判断。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 等待开奖阶段 --}}
|
||||
<div x-show="phase === 'waiting'" style="display:none; text-align:center; padding:16px 0;">
|
||||
<div style="font-size:40px; animation:spin 1s linear infinite; display:inline-block;">🎲</div>
|
||||
<div style="color:rgba(255,255,255,.6); margin-top:8px;">正在摇骰子…</div>
|
||||
</div>
|
||||
|
||||
{{-- 结算阶段 --}}
|
||||
<div x-show="phase === 'settled'" style="display:none;">
|
||||
{{-- 骰子点数展示 --}}
|
||||
<div style="display:flex; justify-content:center; gap:12px; margin-bottom:12px;">
|
||||
<template x-for="(d, i) in settledDice" :key="i">
|
||||
<div style="width:52px; height:52px; background:rgba(255,255,255,.95); border-radius:10px;
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
font-size:28px; box-shadow:0 4px 12px rgba(0,0,0,.4);
|
||||
animation:dice-pop .4s ease-out both;"
|
||||
:style="'animation-delay:' + (i * 0.15) + 's'"
|
||||
x-text="['⚀','⚁','⚂','⚃','⚄','⚅'][d-1]">
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div style="text-align:center; margin-bottom:10px;">
|
||||
<div style="font-size:20px; font-weight:bold; color:#fbbf24;" x-text="resultLabel"></div>
|
||||
<div style="color:rgba(255,255,255,.4); font-size:12px; margin-top:2px;"
|
||||
x-text="'总点数:' + settledTotal"></div>
|
||||
</div>
|
||||
|
||||
{{-- 个人结果 --}}
|
||||
<div x-show="myBet"
|
||||
style="border-radius:12px; padding:12px 16px; text-align:center; margin-bottom:8px;"
|
||||
:style="myWon ? 'background:rgba(34,197,94,.15); border:1px solid rgba(34,197,94,.3);' :
|
||||
'background:rgba(239,68,68,.15); border:1px solid rgba(239,68,68,.3);'">
|
||||
<div style="font-size:15px; font-weight:bold;"
|
||||
:style="myWon ? 'color:#4ade80;' : 'color:#f87171;'"
|
||||
x-text="myWon ? '🎉 恭喜!赢得 ' + Number(myPayout).toLocaleString() + ' 金币!' : '💸 本局未中奖'">
|
||||
</div>
|
||||
<div style="color:rgba(255,255,255,.4); font-size:11px; margin-top:2px;"
|
||||
x-text="'押注:' + betTypeLabel(myBetType) + ' ' + Number(myBetAmount).toLocaleString() + ' 金币'">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ─── 底部关闭 ─── --}}
|
||||
<div style="background:rgba(15,10,40,.95); padding:10px 20px; display:flex; justify-content:center;">
|
||||
<button x-on:click="close()"
|
||||
style="padding:7px 28px; background:rgba(255,255,255,.08); border:none; border-radius:20px;
|
||||
font-size:12px; color:rgba(255,255,255,.5); cursor:pointer; transition:all .15s;"
|
||||
onmouseover="this.style.background='rgba(255,255,255,.15)'"
|
||||
onmouseout="this.style.background='rgba(255,255,255,.08)'">
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ─── 骰子悬浮入口(游戏开启时常驻) ─── --}}
|
||||
<div id="baccarat-fab" x-data="{ visible: false }" x-show="visible" x-cloak
|
||||
style="position:fixed; bottom:90px; right:18px; z-index:9900;">
|
||||
<button x-on:click="document.getElementById('baccarat-panel')._x_dataStack[0].show = true"
|
||||
style="width:52px; height:52px; border-radius:50%; border:none; cursor:pointer;
|
||||
background:linear-gradient(135deg,#7c3aed,#4f46e5);
|
||||
box-shadow:0 4px 20px rgba(124,58,237,.5);
|
||||
font-size:22px; display:flex; align-items:center; justify-content:center;
|
||||
animation:pulse-fab 2s infinite;"
|
||||
title="百家乐">🎲</button>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dice-pop {
|
||||
0% {
|
||||
transform: scale(0) rotate(-20deg);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
70% {
|
||||
transform: scale(1.15) rotate(5deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1) rotate(0deg);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-fab {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 4px 20px rgba(124, 58, 237, .5);
|
||||
}
|
||||
|
||||
50% {
|
||||
box-shadow: 0 4px 30px rgba(124, 58, 237, .9);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* 百家乐游戏面板 Alpine 组件
|
||||
*/
|
||||
function baccaratPanel() {
|
||||
return {
|
||||
show: false,
|
||||
phase: 'betting', // betting | waiting | settled
|
||||
|
||||
roundId: null,
|
||||
totalSeconds: 60,
|
||||
countdown: 60,
|
||||
countdownTimer: null,
|
||||
|
||||
// 下注池统计
|
||||
totalBetBig: 0,
|
||||
totalBetSmall: 0,
|
||||
totalBetTriple: 0,
|
||||
|
||||
// 本人下注
|
||||
myBet: false,
|
||||
myBetType: '',
|
||||
myBetAmount: 0,
|
||||
|
||||
// 下注表单
|
||||
selectedType: '',
|
||||
betAmount: 100,
|
||||
submitting: false,
|
||||
|
||||
// 结算结果
|
||||
settledDice: [],
|
||||
settledTotal: 0,
|
||||
resultLabel: '',
|
||||
diceEmoji: '',
|
||||
myWon: false,
|
||||
myPayout: 0,
|
||||
|
||||
// 历史记录
|
||||
history: [],
|
||||
|
||||
/**
|
||||
* 开局:填充局次数据并开始倒计时
|
||||
*/
|
||||
openRound(data) {
|
||||
this.phase = 'betting';
|
||||
this.roundId = data.round_id;
|
||||
this.countdown = data.bet_seconds || 60;
|
||||
this.totalSeconds = this.countdown;
|
||||
this.myBet = false;
|
||||
this.myBetType = '';
|
||||
this.myBetAmount = 0;
|
||||
this.settledDice = [];
|
||||
this.selectedType = '';
|
||||
this.betAmount = 100;
|
||||
this.show = true;
|
||||
|
||||
this.loadCurrentRound();
|
||||
this.startCountdown();
|
||||
this.updateFab(true);
|
||||
},
|
||||
|
||||
/**
|
||||
* 从接口获取当前局的状态(我的下注、投注池)
|
||||
*/
|
||||
async loadCurrentRound() {
|
||||
try {
|
||||
const res = await fetch('/baccarat/current');
|
||||
const data = await res.json();
|
||||
if (data.round) {
|
||||
this.totalBetBig = data.round.total_bet_big;
|
||||
this.totalBetSmall = data.round.total_bet_small;
|
||||
this.totalBetTriple = data.round.total_bet_triple;
|
||||
if (data.round.my_bet) {
|
||||
this.myBet = true;
|
||||
this.myBetType = data.round.my_bet.bet_type;
|
||||
this.myBetAmount = data.round.my_bet.amount;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
},
|
||||
|
||||
/**
|
||||
* 启动倒计时
|
||||
*/
|
||||
startCountdown() {
|
||||
clearInterval(this.countdownTimer);
|
||||
this.countdownTimer = setInterval(() => {
|
||||
this.countdown--;
|
||||
if (this.countdown <= 0) {
|
||||
clearInterval(this.countdownTimer);
|
||||
this.phase = 'waiting';
|
||||
}
|
||||
}, 1000);
|
||||
},
|
||||
|
||||
/**
|
||||
* 提交下注
|
||||
*/
|
||||
async submitBet() {
|
||||
if (!this.selectedType || this.betAmount < 100 || this.submitting) return;
|
||||
this.submitting = true;
|
||||
|
||||
try {
|
||||
const res = await fetch('/baccarat/bet', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]')?.content,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
round_id: this.roundId,
|
||||
bet_type: this.selectedType,
|
||||
amount: this.betAmount,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.ok) {
|
||||
this.myBet = true;
|
||||
this.myBetType = data.bet_type;
|
||||
this.myBetAmount = data.amount;
|
||||
window.chatDialog?.alert(data.message, '下注成功', '#7c3aed');
|
||||
} else {
|
||||
window.chatDialog?.alert(data.message || '下注失败', '提示', '#ef4444');
|
||||
}
|
||||
} catch {
|
||||
window.chatDialog?.alert('网络异常,请稍后重试。', '错误', '#ef4444');
|
||||
}
|
||||
|
||||
this.submitting = false;
|
||||
},
|
||||
|
||||
/**
|
||||
* 显示开奖结果动画
|
||||
*/
|
||||
showResult(data) {
|
||||
clearInterval(this.countdownTimer);
|
||||
this.settledDice = data.dice;
|
||||
this.settledTotal = data.total_points;
|
||||
this.resultLabel = data.result_label;
|
||||
this.diceEmoji = data.dice.map(d => ['⚀', '⚁', '⚂', '⚃', '⚄', '⚅'][d - 1]).join('');
|
||||
this.phase = 'settled';
|
||||
this.show = true;
|
||||
|
||||
// 判断本人是否中奖(从后端拿到的 result 与我的下注 type 比较)
|
||||
if (this.myBet && this.myBetType === data.result && data.result !== 'kill') {
|
||||
this.myWon = true;
|
||||
// 简单计算前端显示赔付(实际赔付以后端为准)
|
||||
const payoutRate = data.result === 'triple' ? 24 : 1;
|
||||
this.myPayout = this.myBetAmount * (payoutRate + 1);
|
||||
} else {
|
||||
this.myWon = false;
|
||||
this.myPayout = 0;
|
||||
}
|
||||
|
||||
this.updateFab(false);
|
||||
this.loadHistory();
|
||||
},
|
||||
|
||||
/**
|
||||
* 加载历史趋势
|
||||
*/
|
||||
async loadHistory() {
|
||||
try {
|
||||
const res = await fetch('/baccarat/history');
|
||||
const data = await res.json();
|
||||
this.history = (data.history || []).reverse();
|
||||
} catch {}
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新悬浮按钮显示状态
|
||||
*/
|
||||
updateFab(visible) {
|
||||
const fab = document.getElementById('baccarat-fab');
|
||||
if (fab) fab._x_dataStack[0].visible = visible;
|
||||
},
|
||||
|
||||
/**
|
||||
* 关闭面板
|
||||
*/
|
||||
close() {
|
||||
this.show = false;
|
||||
if (this.phase === 'betting') {
|
||||
this.updateFab(true); // 还在下注阶段时保留悬浮按钮
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 押注类型中文标签
|
||||
*/
|
||||
betTypeLabel(type) {
|
||||
return {
|
||||
big: '大',
|
||||
small: '小',
|
||||
triple: '豹子'
|
||||
} [type] || '';
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── WebSocket 监听 ──────────────────────────────────────────────
|
||||
|
||||
/** 收到开局事件:弹出押注面板 */
|
||||
window.addEventListener('chat:baccarat.opened', (e) => {
|
||||
const panel = document.getElementById('baccarat-panel');
|
||||
if (panel) Alpine.$data(panel).openRound(e.detail);
|
||||
});
|
||||
|
||||
/** 收到结算事件:展示骰子动画和结果 */
|
||||
window.addEventListener('chat:baccarat.settled', (e) => {
|
||||
const panel = document.getElementById('baccarat-panel');
|
||||
if (panel) Alpine.$data(panel).showResult(e.detail);
|
||||
});
|
||||
|
||||
/** 页面加载时初始化历史记录 */
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
try {
|
||||
const res = await fetch('/baccarat/history');
|
||||
const data = await res.json();
|
||||
const panel = document.getElementById('baccarat-panel');
|
||||
if (panel) Alpine.$data(panel).history = (data.history || []).reverse();
|
||||
} catch {}
|
||||
});
|
||||
</script>
|
||||
@@ -38,3 +38,26 @@ Schedule::call(function () {
|
||||
\App\Models\HolidayEvent::pendingToTrigger()
|
||||
->each(fn ($e) => \App\Jobs\TriggerHolidayEventJob::dispatch($e));
|
||||
})->everyMinute()->name('holiday-events:trigger')->withoutOverlapping();
|
||||
|
||||
// ──────────── 百家乐定时任务 ─────────────────────────────────────
|
||||
|
||||
// 每分钟:检查是否应开新一局(游戏开启 + 无正在进行的局)
|
||||
Schedule::call(function () {
|
||||
if (! \App\Models\GameConfig::isEnabled('baccarat')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$config = \App\Models\GameConfig::forGame('baccarat')?->params ?? [];
|
||||
$interval = (int) ($config['interval_minutes'] ?? 2);
|
||||
|
||||
// 检查距上一局触发时间是否已达到间隔
|
||||
$lastRound = \App\Models\BaccaratRound::latest()->first();
|
||||
if ($lastRound && $lastRound->created_at->diffInMinutes(now()) < $interval) {
|
||||
return; // 还没到开局时间
|
||||
}
|
||||
|
||||
// 无当前进行中的局才开新局
|
||||
if (! \App\Models\BaccaratRound::currentRound()) {
|
||||
\App\Jobs\OpenBaccaratRoundJob::dispatch();
|
||||
}
|
||||
})->everyMinute()->name('baccarat:open-round')->withoutOverlapping();
|
||||
|
||||
@@ -115,6 +115,16 @@ Route::middleware(['chat.auth'])->group(function () {
|
||||
Route::get('/{event}/status', [\App\Http\Controllers\HolidayController::class, 'status'])->name('status');
|
||||
});
|
||||
|
||||
// ── 百家乐(前台)────────────────────────────────────────────────
|
||||
Route::prefix('baccarat')->name('baccarat.')->group(function () {
|
||||
// 获取当前局次信息
|
||||
Route::get('/current', [\App\Http\Controllers\BaccaratController::class, 'currentRound'])->name('current');
|
||||
// 提交下注
|
||||
Route::post('/bet', [\App\Http\Controllers\BaccaratController::class, 'bet'])->name('bet');
|
||||
// 查询历史记录
|
||||
Route::get('/history', [\App\Http\Controllers\BaccaratController::class, 'history'])->name('history');
|
||||
});
|
||||
|
||||
// ---- 第五阶段:具体房间内部聊天核心 ----
|
||||
// 进入具体房间界面的初始化
|
||||
Route::get('/room/{id}', [ChatController::class, 'init'])->name('chat.room');
|
||||
|
||||
Reference in New Issue
Block a user