feat: 猜成语游戏 - 完整题库、管理后台、答题弹窗

- 创建 idioms 表(102条谜语式成语题库)和 idiom_game_rounds 表
- 后台成语管理页面:增删改题目 + 游戏参数(金币/经验/间隔)内联设置 + 出题按钮
- IdiomQuizController:出题/答题/当前回合查询,Redis 防并发抢答
- IdiomGameStarted / IdiomGameAnswered 广播事件
- 前端答题弹窗模块:聊天消息带【答题】按钮,点击弹出输入框
- GameConfig 注册 idiom 游戏,由 admin.game-configs 统一管理开关
This commit is contained in:
pllx
2026-04-28 23:42:48 +08:00
parent 461c6a6f56
commit 4ff62e29bd
20 changed files with 1497 additions and 1 deletions
+4
View File
@@ -158,6 +158,9 @@ enum CurrencySource: string
/** 购买头像框消耗(扣除金币) */
case AVATAR_FRAME_BUY = 'avatar_frame_buy';
/** 猜成语游戏奖励 */
case GAME_REWARD = 'game_reward';
/**
* 返回该来源的中文名称,用于后台统计展示。
*/
@@ -210,6 +213,7 @@ enum CurrencySource: string
self::MSG_TEXT_COLOR_BUY => '文字颜色购买',
self::MSG_DECORATION_BUY => '消息装扮购买(旧)',
self::AVATAR_FRAME_BUY => '头像框购买',
self::GAME_REWARD => '猜成语奖励',
};
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
/**
* 文件功能:猜成语答题结果广播事件
*
* 用户答对成语时广播,通知房间内所有用户结果。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class IdiomGameAnswered implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* @param int $roomId 房间 ID
* @param int $roundId 游戏回合 ID
* @param string $answer 正确答案
* @param string $winnerUsername 答对的用户名
* @param int $rewardGold 获得的金币
* @param int $rewardExp 获得的经验
*/
public function __construct(
public readonly int $roomId,
public readonly int $roundId,
public readonly string $answer,
public readonly string $winnerUsername,
public readonly int $rewardGold = 0,
public readonly int $rewardExp = 0,
) {}
public function broadcastOn(): array
{
return [
new PresenceChannel('room.'.$this->roomId),
];
}
public function broadcastWith(): array
{
return [
'round_id' => $this->roundId,
'answer' => $this->answer,
'winner_username' => $this->winnerUsername,
'reward_gold' => $this->rewardGold,
'reward_exp' => $this->rewardExp,
];
}
}
+63
View File
@@ -0,0 +1,63 @@
<?php
/**
* 文件功能:猜成语游戏开始广播事件
*
* 管理员手动出题时触发,广播成语提示到聊天室,前端显示提示+答题按钮。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class IdiomGameStarted implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* @param int $roomId 房间 ID
* @param string $hint 谜语提示
* @param int $roundId 游戏回合 ID(前端提交答案时带上)
* @param int $rewardGold 答对奖励金币
* @param int $rewardExp 答对奖励经验
*/
public function __construct(
public readonly int $roomId,
public readonly string $hint,
public readonly int $roundId,
public readonly int $rewardGold = 0,
public readonly int $rewardExp = 0,
) {}
/**
* 广播频道
*/
public function broadcastOn(): array
{
return [
new PresenceChannel('room.'.$this->roomId),
];
}
/**
* 广播数据
*/
public function broadcastWith(): array
{
return [
'round_id' => $this->roundId,
'hint' => $this->hint,
'reward_gold' => $this->rewardGold,
'reward_exp' => $this->rewardExp,
'message' => "🧩 猜成语时间!{$this->hint}",
];
}
}
@@ -0,0 +1,122 @@
<?php
/**
* 文件功能:猜成语题库后台管理控制器
* 提供成语题目的列表展示、创建、编辑、删除、启用/禁用功能
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Idiom;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class IdiomController extends Controller
{
/**
* 显示所有成语题目列表
*/
public function index(): View
{
$idioms = Idiom::orderBy('sort')->orderBy('id')->get();
return view('admin.idioms.index', compact('idioms'));
}
/**
* 创建新题目
*/
public function store(Request $request): RedirectResponse
{
$data = $request->validate([
'answer' => 'required|string|max:50',
'hint' => 'required|string|max:255',
'sort' => 'required|integer|min:0',
'is_active' => 'boolean',
]);
$data['is_active'] = $request->boolean('is_active', true);
Idiom::create($data);
return redirect()->route('admin.idioms.index')->with('success', '成语题目已添加!');
}
/**
* 更新题目
*/
public function update(Request $request, Idiom $idiom): RedirectResponse
{
$data = $request->validate([
'answer' => 'required|string|max:50',
'hint' => 'required|string|max:255',
'sort' => 'required|integer|min:0',
'is_active' => 'boolean',
]);
$data['is_active'] = $request->boolean('is_active');
$idiom->update($data);
return redirect()->route('admin.idioms.index')->with('success', "题目「{$idiom->answer}」已更新!");
}
/**
* 切换启用/禁用(AJAX
*/
public function toggle(Idiom $idiom): JsonResponse
{
$idiom->update(['is_active' => ! $idiom->is_active]);
return response()->json([
'ok' => true,
'is_active' => $idiom->is_active,
'message' => $idiom->is_active ? "{$idiom->answer}」已启用" : "{$idiom->answer}」已禁用",
]);
}
/**
* 删除题目
*/
public function destroy(Idiom $idiom): RedirectResponse
{
$answer = $idiom->answer;
$idiom->delete();
return redirect()->route('admin.idioms.index')->with('success', "题目「{$answer}」已删除!");
}
/**
* 保存猜成语游戏参数(仅更新 GameConfig params,不影响其他字段)
*/
public function saveSettings(Request $request): RedirectResponse
{
$data = $request->validate([
'reward_gold' => 'required|integer|min:0',
'reward_exp' => 'required|integer|min:0',
'auto_start_interval' => 'required|integer|min:0',
]);
$config = \App\Models\GameConfig::firstOrCreate(
['game_key' => 'idiom'],
['name' => '猜成语', 'icon' => '🧩', 'enabled' => false],
);
// 合并现有 params,只覆盖提交的字段,不影响其他已有参数
$existingParams = $config->params ?? [];
$config->params = array_merge($existingParams, [
'reward_gold' => (int) $data['reward_gold'],
'reward_exp' => (int) $data['reward_exp'],
'auto_start_interval' => (int) $data['auto_start_interval'],
]);
$config->save();
$config->clearCache();
return redirect()->route('admin.idioms.index')->with('success', '游戏参数已保存!');
}
}
@@ -0,0 +1,267 @@
<?php
/**
* 文件功能:猜成语游戏控制器
*
* 负责出题、答题、查询当前回合状态等游戏核心逻辑。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers;
use App\Events\IdiomGameAnswered;
use App\Events\IdiomGameStarted;
use App\Models\GameConfig;
use App\Models\Idiom;
use App\Models\IdiomGameRound;
use App\Services\ChatStateService;
use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class IdiomQuizController extends Controller
{
public function __construct(
private readonly ChatStateService $chatState,
private readonly UserCurrencyService $currencyService,
) {}
/**
* 管理员手动出题(POST
*/
public function start(Request $request): JsonResponse
{
$user = Auth::user();
// 权限校验:仅站长或 superlevel
if (! $user || ($user->id !== 1 && ! $request->user()?->activePosition)) {
return response()->json(['status' => 'error', 'message' => '无权限'], 403);
}
$roomId = (int) $request->input('room_id', 0);
if ($roomId <= 0) {
return response()->json(['status' => 'error', 'message' => '缺少房间 ID'], 422);
}
// 检查是否有进行中的回合
$activeRound = IdiomGameRound::where('room_id', $roomId)
->whereIn('status', ['pending', 'active'])
->first();
if ($activeRound) {
return response()->json([
'status' => 'error',
'message' => '当前房间已有进行中的猜成语题目,请先结束当前回合。',
], 400);
}
// 随机选一道启用的题目
$idiom = Idiom::where('is_active', true)->inRandomOrder()->first();
if (! $idiom) {
return response()->json(['status' => 'error', 'message' => '题库中没有可用的题目,请先在后台添加。'], 400);
}
// 读取游戏配置
$config = GameConfig::forGame('idiom');
$params = $config?->params ?? [];
$rewardGold = (int) ($params['reward_gold'] ?? 50);
$rewardExp = (int) ($params['reward_exp'] ?? 30);
// 创建新回合
$round = IdiomGameRound::create([
'room_id' => $roomId,
'idiom_id' => $idiom->id,
'status' => 'active',
'reward_gold' => $rewardGold,
'reward_exp' => $rewardExp,
'started_at' => now(),
]);
// 广播到聊天室
broadcast(new IdiomGameStarted(
roomId: $roomId,
hint: $idiom->hint,
roundId: $round->id,
rewardGold: $rewardGold,
rewardExp: $rewardExp,
));
// 同时也推一条 MessageSent 消息(显示在聊天窗口)
$msg = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '星海小博士',
'to_user' => '大家',
'content' => "🧩 猜成语时间!{$idiom->hint}",
'is_secret' => false,
'font_color' => '#7c3aed',
'action' => '',
'idiom_game_round_id' => $round->id,
'idiom_reward_gold' => $rewardGold,
'idiom_reward_exp' => $rewardExp,
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($roomId, $msg);
broadcast(new \App\Events\MessageSent($roomId, $msg));
return response()->json([
'status' => 'success',
'data' => [
'round_id' => $round->id,
'hint' => $idiom->hint,
'reward_gold' => $rewardGold,
'reward_exp' => $rewardExp,
],
]);
}
/**
* 提交答案(POST
*
* @param Request $request
* @return JsonResponse
*/
public function answer(Request $request): JsonResponse
{
$user = Auth::user();
if (! $user) {
return response()->json(['status' => 'error', 'message' => '请先登录'], 401);
}
$roundId = (int) $request->input('round_id');
$userAnswer = trim((string) $request->input('answer', ''));
$roomId = (int) $request->input('room_id');
if ($roundId <= 0 || $userAnswer === '' || $roomId <= 0) {
return response()->json(['status' => 'error', 'message' => '参数不完整'], 422);
}
// 查找回合
$round = IdiomGameRound::with('idiom')->find($roundId);
if (! $round || $round->room_id !== $roomId) {
return response()->json(['status' => 'error', 'message' => '回合不存在'], 404);
}
if ($round->status !== 'active') {
if ($round->status === 'answered') {
return response()->json([
'status' => 'error',
'message' => "这道题已被「{$round->winner_username}」抢先答对了!",
], 400);
}
return response()->json(['status' => 'error', 'message' => '该回合已结束'], 400);
}
// 校验答案(忽略空格和全半角)
$normalizedAnswer = str_replace(' ', '', $userAnswer);
$normalizedCorrect = str_replace(' ', '', $round->idiom->answer);
if (mb_strtolower($normalizedAnswer) !== mb_strtolower($normalizedCorrect)) {
return response()->json([
'status' => 'error',
'message' => '答案不正确,再想想!',
], 200);
}
// 答对了!加锁防并发(Redis)
$lockKey = "idiom:answer_lock:{$roundId}";
if (! \Illuminate\Support\Facades\Redis::setnx($lockKey, 1)) {
return response()->json([
'status' => 'error',
'message' => "这道题已被「{$round->winner_username}」抢先答对了!",
], 400);
}
\Illuminate\Support\Facades\Redis::expire($lockKey, 10);
// 更新回合状态
$round->update([
'status' => 'answered',
'winner_id' => $user->id,
'winner_username' => $user->username,
'ended_at' => now(),
]);
// 发放奖励
if ($round->reward_gold > 0) {
$this->currencyService->change(
$user, 'gold', $round->reward_gold,
\App\Enums\CurrencySource::GAME_REWARD,
"猜成语答对「{$round->idiom->answer}」奖励",
$roomId,
);
}
if ($round->reward_exp > 0) {
$user->exp_num = ($user->exp_num ?? 0) + $round->reward_exp;
$user->save();
}
// 广播结果
broadcast(new IdiomGameAnswered(
roomId: $roomId,
roundId: $round->id,
answer: $round->idiom->answer,
winnerUsername: $user->username,
rewardGold: $round->reward_gold,
rewardExp: $round->reward_exp,
));
// 推 MessageSent 系统通知
$resultMsg = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '星海小博士',
'to_user' => '大家',
'content' => "🎉 恭喜 {$user->username} 率先答对成语「{$round->idiom->answer}」,获得 {$round->reward_gold} 金币、{$round->reward_exp} 经验!",
'is_secret' => false,
'font_color' => '#16a34a',
'action' => '',
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($roomId, $resultMsg);
broadcast(new \App\Events\MessageSent($roomId, $resultMsg));
\Illuminate\Support\Facades\Redis::del($lockKey);
return response()->json([
'status' => 'success',
'message' => "🎉 回答正确!获得 {$round->reward_gold} 金币、{$round->reward_exp} 经验!",
'data' => [
'answer' => $round->idiom->answer,
'reward_gold' => $round->reward_gold,
'reward_exp' => $round->reward_exp,
],
]);
}
/**
* 查询当前进行中的回合
*/
public function current(Request $request): JsonResponse
{
$roomId = (int) $request->input('room_id', 0);
if ($roomId <= 0) {
return response()->json(['status' => 'error', 'message' => '缺少房间 ID'], 422);
}
$round = IdiomGameRound::with('idiom')
->where('room_id', $roomId)
->whereIn('status', ['pending', 'active'])
->first();
if (! $round) {
return response()->json(['status' => 'success', 'data' => null]);
}
return response()->json([
'status' => 'success',
'data' => [
'round_id' => $round->id,
'hint' => $round->idiom?->hint ?? '',
'reward_gold' => $round->reward_gold,
'reward_exp' => $round->reward_exp,
],
]);
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
/**
* 文件功能:猜成语题目模型
*
* 对应 idioms 表,每条记录是一道成语题目 + 谜语提示。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Idiom extends Model
{
protected $fillable = [
'answer',
'hint',
'is_active',
'sort',
];
protected function casts(): array
{
return [
'is_active' => 'boolean',
'sort' => 'integer',
];
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
/**
* 文件功能:猜成语游戏回合模型
*
* 每次出题对应一个回合,记录题目、状态、奖励和获胜者。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class IdiomGameRound extends Model
{
protected $fillable = [
'room_id',
'idiom_id',
'status',
'reward_gold',
'reward_exp',
'winner_id',
'winner_username',
'started_at',
'ended_at',
];
protected function casts(): array
{
return [
'reward_gold' => 'integer',
'reward_exp' => 'integer',
'started_at' => 'datetime',
'ended_at' => 'datetime',
];
}
public function idiom(): BelongsTo
{
return $this->belongsTo(Idiom::class);
}
}