Compare commits

...
Author SHA1 Message Date
AfonsoCMSousa a3da9af90d hotfix: Singler player now plays fine 2026-01-03 14:02:58 +00:00
AfonsoCMSousa 83832aaaa9 hotfix: Finnal fix 2026-01-03 14:01:38 +00:00
FernandoJVideira 4cfab79611 Merge pull request 'feature/bestof-series-interface' (#99) from feature/bestof-series-interface into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/99
Reviewed-by: KZix <[email protected]>
Reviewed-by: Edd <[email protected]>
Reviewed-by: FernandoJVideira <[email protected]>
2026-01-03 13:26:18 +00:00
AfonsoCMSousa 00cb5ba966 SyntaxSquad/DADProject#16 fix: merge conflics fixed 2026-01-03 13:25:21 +00:00
AfonsoCMSousa ceba5178f3 SyntaxSquad/DADProject#16 fix: i forgot this 2026-01-03 13:14:59 +00:00
AfonsoCMSousa 0fb64ff30c SyntaxSquad/DADProject#16 feat: MATCHES AND STUFF 2026-01-03 13:14:37 +00:00
AfonsoCMSousa e0ca8e51a6 SyntaxSquad/DADProject#16 feat: Can now start matches, just missing the actuall gameplay 2026-01-03 04:52:02 +00:00
FernandoJVideira cd25915d92 Merge pull request 'fix:admin & user match table sort' (#98) from fix/frontend-admin-match-sort into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/98
Reviewed-by: Edd <[email protected]>
Reviewed-by: Afonso Clerigo Mendes de Sousa <[email protected]>
2026-01-03 02:35:07 +00:00
FernandoJVideira bcfa5c06f7 fix:user profile match table sort 2026-01-03 02:33:22 +00:00
AfonsoCMSousa 3dcd8df4c7 SyntaxSquad/DADProject#16 fix: Cancle and Return is working and some other fixes involving game duration 2026-01-03 02:19:00 +00:00
FernandoJVideira 46e3f0eef6 fix:admin match table sort 2026-01-03 02:11:41 +00:00
AfonsoCMSousa 7e6766c726 SyntaxSquad/DADProject#16 merge: fix merge conflics 2026-01-03 00:53:51 +00:00
AfonsoCMSousa 1088749386 SyntaxSquad/DADProject#16 fix: Surrender if leave 2026-01-03 00:51:42 +00:00
KZix 52cb893bd0 Merge pull request 'Added matches scrollable to home page' (#96) from na/matches-home-page into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/96
Reviewed-by: Afonso Clerigo Mendes de Sousa <[email protected]>
Reviewed-by: KZix <[email protected]>
2026-01-03 00:43:38 +00:00
Edd 59fea64df5 Merge branch 'develop' into na/matches-home-page 2026-01-03 00:43:07 +00:00
Edd a09639abcf Merge pull request 'feature/frontend-add-match-profile-tabs' (#97) from feature/frontend-add-match-profile-tabs into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/97
Reviewed-by: Afonso Clerigo Mendes de Sousa <[email protected]>
Reviewed-by: Edd <[email protected]>
2026-01-03 00:43:03 +00:00
Edd 2d2d0149d8 little date adjustements 2026-01-03 00:42:04 +00:00
KZix 894a38bb27 Added corrections to user profile and admin match and game history 2026-01-03 00:41:24 +00:00
Edd 432d3c0b88 Added matches scrollable to home page 2026-01-03 00:39:08 +00:00
KZix 1a7916a836 Added list for games and matches in the user profile 2026-01-03 00:06:26 +00:00
Edd 030b00059e Merge pull request 'feature/websockets-matches' (#95) from feature/websockets-matches into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/95
Reviewed-by: Afonso Clerigo Mendes de Sousa <[email protected]>
Reviewed-by: Edd <[email protected]>
2026-01-02 22:23:20 +00:00
KZix a804f41956 Final touches 2026-01-02 22:11:47 +00:00
KZix 25d21f7cf8 Merge branch 'develop' into feature/websockets-matches 2026-01-02 21:47:30 +00:00
KZix c3f8c3fff0 Matches almost done still refining 2026-01-02 21:45:37 +00:00
AfonsoCMSousa 23d0ea1343 SyntaxSquad/DADProject#16 fix: Timer issues and Forfeight button 2026-01-02 20:12:18 +00:00
KZix 1dc2d25a41 Merge pull request 'feature/multiplayer-game-interface' (#94) from feature/multiplayer-game-interface into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/94
Reviewed-by: FernandoJVideira <[email protected]>
Reviewed-by: Edd <[email protected]>
Reviewed-by: KZix <[email protected]>
2026-01-02 19:18:04 +00:00
AfonsoCMSousa 7c8fdd6ea2 SyntaxSquad/DADProject#15 fix: Merge Conflicts Fixed. 2026-01-02 19:12:35 +00:00
AfonsoCMSousa 5e90de812b SyntaxSquad/DADProject#15 feat: Added Multiplayer View and Stuff 2026-01-02 19:07:27 +00:00
AfonsoCMSousa ee28333da8 Merge pull request 'Admin platform, with users, transactions, games and admin creation' (#93) from feature/admin-platform into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/93
Reviewed-by: KZix <[email protected]>
Reviewed-by: Afonso Clerigo Mendes de Sousa <[email protected]>
2026-01-02 16:20:26 +00:00
AfonsoCMSousa 5880960c42 Merge remote-tracking branch 'origin/develop' into feature/multiplayer-game-interface 2026-01-02 13:34:53 +00:00
AfonsoCMSousa e84dd993af SyntaxSquad/DADProject#15 feat: Initial Idea 2026-01-02 00:39:39 +00:00
41 changed files with 4690 additions and 3342 deletions
+116 -29
View File
@@ -17,6 +17,7 @@ class GameController extends Controller
$user = Auth::user(); $user = Auth::user();
$query = Game::query()->with(["winner", "player1", "player2"]); $query = Game::query()->with(["winner", "player1", "player2"]);
// Se não for Admin, mostra apenas os jogos do utilizador
if ($user->type !== 'A') { if ($user->type !== 'A') {
$query->where(function ($q) use ($user) { $query->where(function ($q) use ($user) {
$q->where('player1_user_id', $user->id) $q->where('player1_user_id', $user->id)
@@ -40,81 +41,152 @@ class GameController extends Controller
$query->where("status", $request->status); $query->where("status", $request->status);
} }
if ($request->has("sort_direction") && in_array($request->sort_direction, ["asc", "desc"])) { if ($request->has("sort_direction") && in_array($request->sort_direction, ["asc", "desc"])) {
$query->orderBy("began_at", $request->sort_direction); $query->orderBy("began_at", $request->sort_direction);
}else{ } else {
$query->orderBy("began_at", "desc"); $query->orderBy("began_at", "desc");
} }
return response()->json($query->paginate(15)); return response()->json($query->paginate(15));
} }
// --- MÉTODOS ADICIONADOS ---
// Host a new multiplayer Game
public function host(Request $request)
{
$request->validate([
'type' => 'required|in:3,9'
]);
$user = $request->user();
// Check for active game
$activeGame = Game::where(function ($q) use ($user) {
$q->where("player1_user_id", $user->id)
->orWhere("player2_user_id", $user->id);
})
->whereIn("status", ["Pending", "Playing"])
->exists();
if ($activeGame) {
return response()->json(["message" => "You already have an active game."], 400);
}
$game = Game::create([
'type' => $request->type,
'status' => 'Pending',
'player1_user_id' => $user->id,
'player2_user_id' => self::GHOST_ID, // Ghost ID = 1 (Waiting for player)
'began_at' => now(),
'player1_points' => 0,
'player2_points' => 0,
]);
return response()->json($game, 201);
}
// List open games (Available to join)
public function open(Request $request)
{
$type = $request->query('type', '9');
$GHOST_ID = 1;
$games = Game::where('status', 'Pending')
->where('player2_user_id', $GHOST_ID) // Games waiting for P2
->where('type', $type)
->with('player1')
->orderBy('began_at', 'asc')
->paginate(10);
return response()->json($games);
}
// ---------------------------
public function store(Request $request) public function store(Request $request)
{ {
$this->authorize('create', Game::class); $this->authorize('create', Game::class);
$validated = $request->validate([ $validated = $request->validate([
"type" => "required|in:3,9", "type" => "required|in:3,9",
"player2_user_id" => "nullable|integer|exists:users,id",
"match_id" => "nullable|integer|exists:matches,id",
"status" => "nullable|in:Pending,Playing",
"began_at" => "nullable|date"
]); ]);
$user = Auth::user(); $user = Auth::user();
$activeGame = Game::where(function ($q) use ($user) { $activeGame = Game::where(function ($q) use ($user) {
$q->where("player1_user_id", $user->id) $q->where("player1_user_id", $user->id)
->orWhere("player2_user_id", $user->id); ->orWhere("player2_user_id", $user->id);
}) })
->whereIn("status", ["Pending", "Playing"]) ->whereIn("status", ["Pending", "Playing"])
->exists(); ->exists();
if ($activeGame) { if ($activeGame) {
return response()->json(["message" => "You already have an active game."], 400); return response()->json(["message" => "You already have an active game."], 400);
}
$player2Id = $request->input('player2_user_id', self::GHOST_ID);
$initialStatus = $request->input('status', 'Pending');
// If specific player 2 is provided, auto-start game
if ($player2Id !== self::GHOST_ID && !$request->has('status')) {
$initialStatus = 'Playing';
} }
$game = Game::create([ $game = Game::create([
"type" => $validated["type"], "type" => $validated["type"],
"status" => "Pending", "status" => $initialStatus,
"player1_user_id" => $user->id, "player1_user_id" => $user->id,
"player2_user_id" => self::GHOST_ID, "player2_user_id" => $player2Id,
"winner_user_id" => self::GHOST_ID, "winner_user_id" => null,
"loser_user_id" => self::GHOST_ID, "loser_user_id" => null,
"began_at" => now(), "match_id" => $request->input('match_id', null),
"began_at" => $request->input('began_at', now()),
"player1_points" => 0, "player1_points" => 0,
"player2_points" => 0, "player2_points" => 0,
"custom" => null, "custom" => null,
]); ]);
return response()->json([ return response()->json([
"message" => "Multiplayer game room created", "message" => "Game created",
"game" => $game, "game" => $game,
], 201); ], 201);
} }
public function join(Game $game) public function join(Request $request, Game $game)
{ {
$this->authorize('join', $game); // Prevent joining if already full or started
if ($game->status !== 'Pending' || $game->player2_user_id !== self::GHOST_ID) {
$user = Auth::user(); return response()->json(['message' => 'Game is not available'], 400);
if ($game->player1_user_id == $user->id) {
return response()->json([
"message" => "You are the host. Waiting for opponent...",
"game" => $game
], 200);
} }
if ($game->player2_user_id !== self::GHOST_ID) { // Prevent joining your own game
return response()->json(["message" => "Game is full"], 400); if ($game->player1_user_id === $request->user()->id) {
return response()->json(['message' => 'Cannot join your own game'], 400);
}
// Check if joining user has another active game
$activeGame = Game::where(function ($q) use ($request) {
$q->where("player1_user_id", $request->user()->id)
->orWhere("player2_user_id", $request->user()->id);
})
->whereIn("status", ["Pending", "Playing"])
->exists();
if ($activeGame) {
return response()->json(["message" => "You already have an active game."], 400);
} }
$game->update([ $game->update([
"status" => "Playing", 'player2_user_id' => $request->user()->id,
"player2_user_id" => $user->id, 'status' => 'Playing',
]); ]);
return response()->json([ return response()->json($game);
"message" => "Joined successfully!",
"game" => $game,
], 200);
} }
public function show(Game $game) public function show(Game $game)
@@ -172,4 +244,19 @@ class GameController extends Controller
'game' => $game->fresh(), 'game' => $game->fresh(),
]); ]);
} }
public function destroy(Game $game)
{
// Ensure only the host can delete, and ONLY if it's still Pending
if ($game->player1_user_id !== auth()->id()) {
return response()->json(['message' => 'Unauthorized'], 403);
}
if ($game->status !== 'Pending') {
return response()->json(['message' => 'Cannot delete a game in progress'], 400);
}
$game->delete();
return response()->noContent();
}
} }
+248 -74
View File
@@ -3,29 +3,36 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\MatchGame; use App\Models\MatchGame;
use App\Models\Game;
use App\Models\CoinTransaction; use App\Models\CoinTransaction;
use App\Models\CoinTransactionType; use App\Models\CoinTransactionType;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Http\Requests\StoreMatchRequest; use App\Http\Requests\StoreMatchRequest;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
class MatchController extends Controller class MatchController extends Controller
{ {
public function index(Request $request) public function index(Request $request)
{ {
$query = MatchGame::query()->with(['winner', 'player1', 'player2']); $query = MatchGame::query()->with(["winner", "player1", "player2"]);
if ($request->has('type')) { if ($request->has("type")) {
$query->where('type', $request->type); $query->where("type", $request->type);
} }
if ($request->has('status')) { if ($request->has("status")) {
$query->where('status', $request->status); $query->where("status", $request->status);
} }
$query->orderBy('began_at', 'desc'); if ($request->has("pot") && in_array($request->pot, ["asc", "desc"])) {
$query->orderBy("stake", $request->pot);
} elseif (
$request->has("sort_direction") &&
in_array($request->sort_direction, ["asc", "desc"])
) {
$query->orderBy("began_at", $request->sort_direction);
} else {
$query->orderBy("began_at", "desc");
}
return response()->json($query->paginate(15)); return response()->json($query->paginate(15));
} }
@@ -37,13 +44,20 @@ class MatchController extends Controller
$stake = $validated['stake']; $stake = $validated['stake'];
if ($user->coins_balance < $stake) { if ($user->coins_balance < $stake) {
return response()->json(['message' => 'Insufficient coin balance'], 400); return response()->json(
["message" => "Insufficient coin balance"],
400,
);
} }
$activeMatch = MatchGame::where(function ($q) use ($user) { $activeMatch = MatchGame::where(function ($q) use ($user) {
$q->where('player1_user_id', $user->id) $q->where("player1_user_id", $user->id)->orWhere(
->orWhere('player2_user_id', $user->id); "player2_user_id",
})->whereIn('status', ['Pending', 'Playing'])->exists(); $user->id,
);
})
->whereIn("status", ["Pending", "Playing"])
->exists();
if ($activeMatch) { if ($activeMatch) {
return response()->json(['message' => 'You already have an active match.'], 400); return response()->json(['message' => 'You already have an active match.'], 400);
@@ -52,45 +66,50 @@ class MatchController extends Controller
return DB::transaction(function () use ($validated, $user, $stake) { return DB::transaction(function () use ($validated, $user, $stake) {
$GHOST_ID = 1; $GHOST_ID = 1;
$user->decrement('coins_balance', $stake); $user->decrement("coins_balance", $stake);
$stakeType = CoinTransactionType::firstOrCreate( $stakeType = CoinTransactionType::firstOrCreate(
['name' => 'Match stake'], ["name" => "Match stake"],
['type' => 'D'] // Debit ["type" => "D"], // Debit
); );
$match = MatchGame::create([ $match = MatchGame::create([
'type' => $validated['type'], "type" => $validated["type"],
'status' => 'Pending', "status" => "Pending",
'player1_user_id' => $user->id, "player1_user_id" => $user->id,
'player2_user_id' => $GHOST_ID, "player2_user_id" => $GHOST_ID,
'winner_user_id' => $GHOST_ID, "winner_user_id" => $GHOST_ID,
'loser_user_id' => $GHOST_ID, "loser_user_id" => $GHOST_ID,
'stake' => $stake, "stake" => $stake,
'began_at' => now(), "began_at" => now(),
'player1_marks' => 0, 'player2_marks' => 0, "player1_marks" => 0,
'player1_points' => 0, 'player2_points' => 0, "player2_marks" => 0,
"player1_points" => 0,
"player2_points" => 0,
]); ]);
CoinTransaction::create([ CoinTransaction::create([
'user_id' => $user->id, "user_id" => $user->id,
'match_id' => $match->id, "match_id" => $match->id,
'coin_transaction_type_id' => $stakeType->id, "coin_transaction_type_id" => $stakeType->id,
'transaction_datetime' => now(), "transaction_datetime" => now(),
'coins' => -$stake, "coins" => -$stake,
]); ]);
return response()->json([ return response()->json(
'message' => 'Match created successfully', [
'match' => $match, "message" => "Match created successfully",
'balance' => $user->coins_balance "match" => $match,
], 201); "balance" => $user->coins_balance,
],
201,
);
}); });
} }
public function show(MatchGame $match) public function show(MatchGame $match)
{ {
$match->load(['player1', 'player2', 'winner']); $match->load(["player1", "player2", "winner"]);
return response()->json($match); return response()->json($match);
} }
@@ -99,31 +118,47 @@ class MatchController extends Controller
$user = request()->user(); $user = request()->user();
if ($match->player1_user_id == $user->id) { if ($match->player1_user_id == $user->id) {
return response()->json(['message' => 'You cannot join your own match'], 400); return response()->json(
["message" => "You cannot join your own match"],
400,
);
} }
if ($match->status !== 'Pending') { if ($match->status !== "Pending") {
return response()->json(['message' => 'Match is full or started'], 400); return response()->json(
["message" => "Match is full or started"],
400,
);
} }
if ($user->coins_balance < $match->stake) { if ($user->coins_balance < $match->stake) {
return response()->json(['message' => 'Insufficient coin balance to join this match'], 400); return response()->json(
["message" => "Insufficient coin balance to join this match"],
400,
);
} }
$activeMatch = MatchGame::where(function ($q) use ($user) { $activeMatch = MatchGame::where(function ($q) use ($user) {
$q->where('player1_user_id', $user->id) $q->where("player1_user_id", $user->id)->orWhere(
->orWhere('player2_user_id', $user->id); "player2_user_id",
})->whereIn('status', ['Pending', 'Playing'])->exists(); $user->id,
);
})
->whereIn("status", ["Pending", "Playing"])
->exists();
if ($activeMatch) { if ($activeMatch) {
return response()->json(['message' => 'You are already in another active match.'], 400); return response()->json(
["message" => "You are already in another active match."],
400,
);
} }
return DB::transaction(function () use ($match, $user) { return DB::transaction(function () use ($match, $user) {
$user->decrement('coins_balance', $match->stake); $user->decrement("coins_balance", $match->stake);
$stakeType = CoinTransactionType::firstOrCreate( $stakeType = CoinTransactionType::firstOrCreate(
['name' => 'Match stake'], ["name" => "Match stake"],
['type' => 'D'] ["type" => "D"],
); );
CoinTransaction::create([ CoinTransaction::create([
@@ -135,65 +170,204 @@ class MatchController extends Controller
]); ]);
$match->update([ $match->update([
'status' => 'Playing', "status" => "Playing",
'player2_user_id' => $user->id "player2_user_id" => $user->id,
]); ]);
return response()->json([ return response()->json([
'message' => 'Joined match successfully!', "message" => "Joined match successfully!",
'match' => $match, "match" => $match,
'balance' => $user->coins_balance "balance" => $user->coins_balance,
]); ]);
}); });
} }
public function update(Request $request, MatchGame $match) public function update(Request $request, MatchGame $match)
{ {
if ($match->status == 'Ended') { if ($match->status == "Ended") {
return response()->json(['message' => 'Match already ended'], 400); return response()->json(["message" => "Match already ended"], 400);
} }
$data = $request->validate([ $data = $request->validate([
'status' => 'in:Playing,Ended,Interrupted', "status" => "in:Playing,Ended,Interrupted",
'winner_user_id' => 'exists:users,id', "winner_user_id" => "exists:users,id",
'loser_user_id' => 'exists:users,id', "loser_user_id" => "exists:users,id",
'player1_marks' => 'integer', "player1_marks" => "integer",
'player2_marks' => 'integer', "player2_marks" => "integer",
'player1_points' => 'integer', "player1_points" => "integer",
'player2_points' => 'integer', "player2_points" => "integer",
'total_time' => 'numeric' "total_time" => "numeric",
]); ]);
return DB::transaction(function () use ($match, $data) { return DB::transaction(function () use ($match, $data) {
if (isset($data['status']) && $data['status'] === 'Ended') { if (isset($data['status']) && $data['status'] === 'Ended') {
$data['ended_at'] = now(); $data['ended_at'] = now();
} }
$match->update($data); $match->update($data);
if ($match->status === 'Ended' && $match->stake > 0 && $match->winner_user_id) { if (
$match->status === "Ended" &&
$match->stake > 0 &&
$match->winner_user_id
) {
$payoutType = CoinTransactionType::firstOrCreate( $payoutType = CoinTransactionType::firstOrCreate(
['name' => 'Match payout'], ["name" => "Match payout"],
['type' => 'C'] // Credit ["type" => "C"], // Credit
); );
$totalPrize = $match->stake * 2; $totalPrize = $match->stake * 2;
CoinTransaction::create([ CoinTransaction::create([
'user_id' => $match->winner_user_id, "user_id" => $match->winner_user_id,
'match_id' => $match->id, "match_id" => $match->id,
'coin_transaction_type_id' => $payoutType->id, "coin_transaction_type_id" => $payoutType->id,
'transaction_datetime' => now(), "transaction_datetime" => now(),
'coins' => $totalPrize, "coins" => $totalPrize,
]); ]);
$match->winner->increment("coins_balance", $totalPrize);
$match->winner->increment('coins_balance', $totalPrize);
} }
return response()->json($match); return response()->json($match);
}); });
} }
public function host(Request $request)
{
$request->validate([
"type" => "required|in:3,9",
"stake" => "required|integer|min:0",
]);
$user = $request->user();
$stake = $request->stake;
if ($user->coins_balance < $stake) {
return response()->json(
["message" => "Insufficient coin balance"],
400,
);
}
$activeMatch = MatchGame::where(function ($q) use ($user) {
$q->where("player1_user_id", $user->id)->orWhere(
"player2_user_id",
$user->id,
);
})
->whereIn("status", ["Pending", "Playing"])
->exists();
if ($activeMatch) {
return response()->json(
["message" => "You already have an active match."],
400,
);
}
return DB::transaction(function () use ($request, $user, $stake) {
$GHOST_ID = 1;
$user->decrement("coins_balance", $stake);
$stakeType = CoinTransactionType::firstOrCreate(
["name" => "Match stake"],
["type" => "D"], // Debit
);
$match = MatchGame::create([
'type' => $request->type,
'status' => 'Pending',
'player1_user_id' => $user->id,
'player2_user_id' => $GHOST_ID,
'winner_user_id' => null,
'loser_user_id' => null,
'stake' => $stake,
'began_at' => now(),
'player1_marks' => 0, 'player2_marks' => 0,
'player1_points' => 0, 'player2_points' => 0,
]);
CoinTransaction::create([
"user_id" => $user->id,
"match_id" => $match->id,
"coin_transaction_type_id" => $stakeType->id,
"transaction_datetime" => now(),
"coins" => -$stake,
]);
return response()->json($match, 201);
});
}
public function open(Request $request)
{
$query = MatchGame::where('status', 'Pending')
->where(function($q) {
$q->whereNull('player2_user_id')
->orWhere('player2_user_id', 1); // 1 = Ghost ID
});
if ($request->has('type')) {
$query->where('type', $request->type);
}
$matches = $query->with('player1:id,nickname')
->orderBy('began_at', 'desc')
->paginate(10);
return response()->json($matches);
}
// --- FIX: Safely Delete Match by removing dependencies first ---
public function destroy(MatchGame $match)
{
$user = request()->user();
if ($match->player1_user_id !== $user->id) {
return response()->json(['message' => 'Unauthorized'], 403);
}
if ($match->status !== 'Pending') {
return response()->json(['message' => 'Cannot cancel a match that has already started'], 400);
}
return DB::transaction(function () use ($match, $user) {
// 1. Delete associated Games first (The FK constraint cause)
DB::table('games')->where('match_id', $match->id)->delete();
// 2. Unlink existing Coin Transactions (Set match_id to NULL)
CoinTransaction::where('match_id', $match->id)->update(['match_id' => null]);
// 3. Process Refund
if ($match->stake > 0) {
$user->increment('coins_balance', $match->stake);
$refundType = CoinTransactionType::firstOrCreate(
['name' => 'Refund'],
['type' => 'C']
);
// Create Refund Transaction with NO LINK to the deleted match
CoinTransaction::create([
'user_id' => $user->id,
'match_id' => null, // IMPORTANT: Must be null
'coin_transaction_type_id' => $refundType->id,
'transaction_datetime' => now(),
'coins' => $match->stake,
'custom' => "Refund for Match #{$match->id}"
]);
}
// 4. Finally delete the match
$match->delete();
return response()->json([
'message' => 'Match canceled and refunded',
'balance' => $user->coins_balance
]);
});
}
} }
@@ -82,7 +82,7 @@ class ProfileController extends Controller
]); ]);
} }
public function updateAvatar(Request $request) public function uploadAvatar(Request $request)
{ {
$request->validate([ $request->validate([
"avatar" => "required|image|mimes:jpeg,png,jpg,gif|max:2048", "avatar" => "required|image|mimes:jpeg,png,jpg,gif|max:2048",
+187 -111
View File
@@ -4,6 +4,7 @@ namespace App\Http\Controllers;
use App\Models\Game; use App\Models\Game;
use App\Models\User; use App\Models\User;
use App\Models\MatchGame;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
@@ -17,23 +18,23 @@ class UserController extends Controller
*/ */
public function index(Request $request) public function index(Request $request)
{ {
$this->authorize('viewAny', User::class); $this->authorize("viewAny", User::class);
$query = User::query(); $query = User::query();
if ($request->has('type')) { if ($request->has("type")) {
$query->where('type', $request->type); $query->where("type", $request->type);
} }
if ($request->has('blocked')) { if ($request->has("blocked")) {
$query->where('blocked', $request->type); $query->where("blocked", $request->type);
} }
if ($request->has('search')) { if ($request->has("search")) {
$query->where(function ($q) use ($request) { $query->where(function ($q) use ($request) {
$q->where('name', 'like', '%'.$request->search.'%') $q->where("name", "like", "%" . $request->search . "%")
->orWhere('email', 'like', '%'.$request->search.'%') ->orWhere("email", "like", "%" . $request->search . "%")
->orWhere('nickname', 'like', '%'.$request->search.'%'); ->orWhere("nickname", "like", "%" . $request->search . "%");
}); });
} }
@@ -46,16 +47,16 @@ class UserController extends Controller
*/ */
public function store(Request $request) public function store(Request $request)
{ {
$this->authorize('create', User::class); $this->authorize("create", User::class);
$validated = $request->validate([ $validated = $request->validate([
'name' => 'required|string|max:255', "name" => "required|string|max:255",
'email' => 'required|email|unique:users,email', "email" => "required|email|unique:users,email",
'password' => 'required|string|min:3', "password" => "required|string|min:3",
'type' => 'required|in:A,U', "type" => "required|in:A,U",
]); ]);
$validated['password'] = Hash::make($validated['password']); $validated["password"] = Hash::make($validated["password"]);
$user = User::create($validated); $user = User::create($validated);
@@ -81,15 +82,15 @@ class UserController extends Controller
$currentUser = Auth::user(); $currentUser = Auth::user();
// Admins and owners get full profile, others get limited view // Admins and owners get full profile, others get limited view
if ($currentUser->type === 'A' || $currentUser->id === $user->id) { if ($currentUser->type === "A" || $currentUser->id === $user->id) {
return response()->json($user); return response()->json($user);
} else { } else {
return response()->json([ return response()->json([
'id' => $user->id, "id" => $user->id,
'name' => $user->name, "name" => $user->name,
'nickname' => $user->nickname, "nickname" => $user->nickname,
'photo_avatar_filename' => $user->photo_avatar_filename, "photo_avatar_filename" => $user->photo_avatar_filename,
'type' => $user->type, "type" => $user->type,
]); ]);
} }
} }
@@ -100,22 +101,22 @@ class UserController extends Controller
*/ */
public function update(Request $request, User $user) public function update(Request $request, User $user)
{ {
$this->authorize('update', $user); $this->authorize("update", $user);
$validated = $request->validate([ $validated = $request->validate([
'name' => 'sometimes|string|max:255', "name" => "sometimes|string|max:255",
'nickname' => [ "nickname" => [
'sometimes', "sometimes",
'string', "string",
'max:20', "max:20",
Rule::unique('users')->ignore($user->id), Rule::unique("users")->ignore($user->id),
], ],
'email' => [ "email" => [
'sometimes', "sometimes",
'email', "email",
Rule::unique('users')->ignore($user->id), Rule::unique("users")->ignore($user->id),
], ],
'password' => 'sometimes|string|min:3', "password" => "sometimes|string|min:3",
]); ]);
// Lógica de Upload de Foto (Exemplo Básico) // Lógica de Upload de Foto (Exemplo Básico)
@@ -129,26 +130,28 @@ class UserController extends Controller
return response()->json($user); return response()->json($user);
} }
public function updatePassword(Request $request, User $user) public function updatePassword(Request $request, User $user)
{ {
$this->authorize('update', $user); $this->authorize("update", $user);
$validated = $request->validate([ $validated = $request->validate([
'current_password' => 'required|string', "current_password" => "required|string",
'new_password' => 'required|string|min:3|confirmed', "new_password" => "required|string|min:3|confirmed",
]); ]);
// Verificar se a password atual está correta // Verificar se a password atual está correta
if (!Hash::check($validated['current_password'], $user->password)) { if (!Hash::check($validated["current_password"], $user->password)) {
return response()->json(['message' => 'Current password is incorrect'], 403); return response()->json(
["message" => "Current password is incorrect"],
403,
);
} }
// Atualizar para a nova password // Atualizar para a nova password
$user->password = Hash::make($validated['new_password']); $user->password = Hash::make($validated["new_password"]);
$user->save(); $user->save();
return response()->json(['message' => 'Password updated successfully']); return response()->json(["message" => "Password updated successfully"]);
} }
/** /**
@@ -157,100 +160,165 @@ class UserController extends Controller
*/ */
public function destroy(User $user) public function destroy(User $user)
{ {
$this->authorize('delete', $user); $this->authorize("delete", $user);
// Check if user has transactions or games - if so, soft delete // Check if user has transactions or games - if so, soft delete
$hasTransactions = $user->transactions()->exists(); $hasTransactions = $user->transactions()->exists();
$hasGames = Game::where(function ($q) use ($user) { $hasGames = Game::where(function ($q) use ($user) {
$q->where('player1_user_id', $user->id) $q->where("player1_user_id", $user->id)->orWhere(
->orWhere('player2_user_id', $user->id); "player2_user_id",
$user->id,
);
})->exists(); })->exists();
if ($hasTransactions || $hasGames) { if ($hasTransactions || $hasGames) {
$user->delete(); // Soft delete $user->delete(); // Soft delete
$message = 'User account deactivated (soft-deleted due to transaction/game history)'; $message =
"User account deactivated (soft-deleted due to transaction/game history)";
} else { } else {
$user->forceDelete(); // Hard delete $user->forceDelete(); // Hard delete
$message = 'User permanently deleted'; $message = "User permanently deleted";
} }
return response()->json(['message' => $message]); return response()->json(["message" => $message]);
}
/**
* GET /api/users/{user}/games
* Histórico de Partidas Individuais (Cartas)
*/
public function getGames(Request $request, User $user)
{
$this->authorize("view", $user);
// Define a query base para GAMES
$query = \App\Models\Game::query()
->where(function ($q) use ($user) {
$q->where("player1_user_id", $user->id)->orWhere(
"player2_user_id",
$user->id,
);
})
->with(["winner", "player1", "player2"]);
if ($request->has("type")) {
$query->where("type", $request->type);
}
if ($request->has("status")) {
$query->where("status", $request->status);
}
// Date filtering
if ($request->has("date_from")) {
$query->whereDate("began_at", ">=", $request->date_from);
}
if ($request->has("date_to")) {
$query->whereDate("began_at", "<=", $request->date_to);
}
// Flexible sorting
$sortColumn = $request->get("sort_by", "began_at");
$sortDirection = $request->get("sort_direction", "desc");
// Whitelist sortable columns for security
$allowedColumns = [
"began_at",
"ended_at",
"total_time",
"total_points",
];
if (in_array($sortColumn, $allowedColumns)) {
$query->orderBy($sortColumn, $sortDirection);
} else {
$query->orderBy("began_at", $sortDirection);
}
$games = $query->paginate(10);
$games->getCollection()->transform(function ($game) use ($user) {
if ($game->winner_user_id == $user->id) {
$game->outcome = "win";
} elseif ($game->is_draw) {
$game->outcome = "draw";
} else {
$game->outcome = "loss";
}
return $game;
});
return response()->json($games);
} }
/** /**
* GET /api/users/{user}/matches * GET /api/users/{user}/matches
* Histórico de Jogos Multiplayer do Utilizador * Histórico de Matches (Apostas / Marcas)
*/ */
public function getMatches(Request $request, User $user) public function getMatches(Request $request, User $user)
{ {
$this->authorize('view', $user); $this->authorize("view", $user);
$outcomeSql = "CASE $query = MatchGame::query()
WHEN is_draw = 1 THEN 'DRAW' ->where(function ($q) use ($user) {
WHEN winner_user_id = {$user->id} THEN 'WIN' $q->where("player1_user_id", $user->id)->orWhere(
ELSE 'LOSS' "player2_user_id",
END"; $user->id,
);
})
->with(["winner", "player1", "player2"]);
$query = Game::query() if ($request->has("type")) {
->select('*') $query->where("type", $request->type);
->selectRaw("($outcomeSql) as outcome_text")
->where(function ($q) use ($user) {
$q->where('player1_user_id', $user->id)
->orWhere('player2_user_id', $user->id);
})
->with(['winner', 'player1', 'player2']);
if ($request->has('type')) {
$query->where('type', $request->type);
} }
if ($request->has('status')) { if ($request->has("status")) {
$query->where('status', $request->status); $query->where("status", $request->status);
} }
if ($request->filled('outcome')) { // Date filtering
switch ($request->outcome) { if ($request->has("date_from")) {
case 'win': $query->whereDate("began_at", ">=", $request->date_from);
$query->where('winner_user_id', $user->id)
->where('is_draw', 0);
break;
case 'loss':
case 'forfeit': // Forfeit treated as loss for now
$query->where('loser_user_id', $user->id)
->where('is_draw', 0);
break;
case 'draw':
$query->where('is_draw', 1);
break;
}
}
if ($request->has('date_from')) {
$query->whereDate('began_at', '>=', $request->date_from);
} }
if ($request->has('date_to')) { if ($request->has("date_to")) {
$query->whereDate('ended_at', '<=', $request->date_to); $query->whereDate("began_at", "<=", $request->date_to);
} }
$order = $request->get('sort_by', 'began_at'); // Flexible sorting
$direction = $request->get('sort_direction', 'desc'); $sortColumn = $request->get("sort_by", "began_at");
$sortDirection = $request->get("sort_direction", "desc");
if ($order === 'outcome') { // Whitelist sortable columns for security
$query->orderBy('outcome_text', $direction); $allowedColumns = ["began_at", "ended_at", "stake", "total_time"];
if (in_array($sortColumn, $allowedColumns)) {
$query->orderBy($sortColumn, $sortDirection);
} else { } else {
$query->orderBy($order, $direction); $query->orderBy("began_at", $sortDirection);
} }
$matches = $query->orderBy($order, $direction) $matches = $query->paginate(10);
->paginate(10);
$matches->getCollection()->transform(function ($match) use ($user) {
if ($match->winner_user_id == $user->id) {
$match->outcome = "win";
} elseif (
$match->winner_user_id &&
$match->winner_user_id != $user->id
) {
$match->outcome = "loss";
} else {
$match->outcome = "interrupted"; // Matches geralmente não empatam (alguém chega a 4 marcas)
}
return $match;
});
return response()->json($matches); return response()->json($matches);
} }
public function block(User $user) public function block(User $user)
{ {
$this->authorize('block', $user); $this->authorize("block", $user);
$user->blocked = true; $user->blocked = true;
$user->save(); $user->save();
@@ -258,43 +326,51 @@ class UserController extends Controller
// Revoke all active tokens for immediate effect // Revoke all active tokens for immediate effect
$user->tokens()->delete(); $user->tokens()->delete();
return response()->json(['message' => 'User blocked successfully']); return response()->json(["message" => "User blocked successfully"]);
} }
public function unblock(User $user) public function unblock(User $user)
{ {
$this->authorize('unblock', $user); $this->authorize("unblock", $user);
$user->blocked = false; $user->blocked = false;
$user->save(); $user->save();
return response()->json(['message' => 'User unblocked successfully']); return response()->json(["message" => "User unblocked successfully"]);
} }
public function getTransactions(Request $request, User $user) public function getTransactions(Request $request, User $user)
{ {
if ($request->user()->id !== $user->id && $request->user()->type !== 'A') { if (
return response()->json(['message' => 'Forbidden'], 403); $request->user()->id !== $user->id &&
$request->user()->type !== "A"
) {
return response()->json(["message" => "Forbidden"], 403);
} }
$query = $user->transactions()->with('type'); $query = $user->transactions()->with("type");
if ($request->has('type')) { if ($request->has("type")) {
$query->whereHas('type', function ($q) use ($request) { $query->whereHas("type", function ($q) use ($request) {
$q->where('type', $request->type); $q->where("type", $request->type);
}); });
} }
if ($request->has('date_from')) { if ($request->has("date_from")) {
$query->whereDate('transaction_datetime', '>=', $request->date_from); $query->whereDate(
"transaction_datetime",
">=",
$request->date_from,
);
} }
if ($request->has('date_to')) { if ($request->has("date_to")) {
$query->whereDate('transaction_datetime', '<=', $request->date_to); $query->whereDate("transaction_datetime", "<=", $request->date_to);
} }
$transactions = $query->orderBy('transaction_datetime', 'desc') $transactions = $query
->paginate(10); ->orderBy("transaction_datetime", "desc")
->paginate(10);
return response()->json($transactions); return response()->json($transactions);
} }
+7 -7
View File
@@ -4,17 +4,17 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class CoinTransactionType extends Model class CoinTransactionType extends Model
{ {
use HasFactory, SoftDeletes; use HasFactory;
protected $table = 'coin_transaction_types'; // FIX: Disable auto-timestamps because the table doesn't have updated_at/created_at columns
public $timestamps = false;
protected $fillable = ['name', 'type', 'custom']; protected $fillable = [
'name',
protected $casts = [ 'type',
'custom' => 'array', 'description' // Include description if your table has it
]; ];
} }
+11
View File
@@ -29,11 +29,22 @@ class Game extends Model
'match_id' 'match_id'
]; ];
protected $casts = [
'began_at' => 'datetime',
'ended_at' => 'datetime',
'is_draw' => 'boolean',
];
public function winner() public function winner()
{ {
return $this->belongsTo(User::class, "winner_user_id"); return $this->belongsTo(User::class, "winner_user_id");
} }
public function loser()
{
return $this->belongsTo(User::class, "loser_user_id");
}
public function player1() public function player1()
{ {
return $this->belongsTo(User::class, 'player1_user_id'); return $this->belongsTo(User::class, 'player1_user_id');
+5 -7
View File
@@ -86,7 +86,6 @@ class GamePolicy
return true; return true;
} }
/** /**
* Determine whether the user can update the model. * Determine whether the user can update the model.
*/ */
@@ -96,17 +95,16 @@ class GamePolicy
return false; return false;
} }
if ( if (!in_array($game->status, ['Playing', 'Pending'])) {
$game->status !== "Playing" || return false;
($game->player1_user_id !== $user->id && }
$game->player2_user_id !== $user->id)
) { if ($game->player1_user_id !== $user->id && $game->player2_user_id !== $user->id) {
return false; return false;
} }
return true; return true;
} }
/** /**
* Determine whether the user can delete the model. * Determine whether the user can delete the model.
*/ */
+18 -17
View File
@@ -40,31 +40,30 @@ Route::prefix('v1')->group(function () {
// User Resources // User Resources
Route::prefix('/users')->group(function () { Route::prefix('/users')->group(function () {
Route::get('/{user}', [UserController::class, 'show']); Route::get('/{user}', [UserController::class, 'show']);
Route::get('/{user}/matches', [
UserController::class,
'getMatches',
]);
Route::get('/{user}/transactions', [UserController::class, 'getTransactions']); Route::get('/{user}/transactions', [UserController::class, 'getTransactions']);
Route::get('/{user}/games', [UserController::class, 'getGames']);
Route::get('/{user}/matches', [UserController::class, 'getMatches']);
}); });
// Game Resources
Route::prefix('games')->group(function () { Route::prefix('games')->group(function () {
Route::apiResource('/', GameController::class)->parameters([ Route::post('host', [GameController::class, 'host']);
'' => 'game', Route::get('open', [GameController::class, 'open']);
]); Route::get('/', [GameController::class, 'index']);
Route::post('/{game}/join', [GameController::class, 'join']); Route::get('/{game}', [GameController::class, 'show']);
Route::post('/', [GameController::class, 'store']);
Route::put('/{game}', [GameController::class, 'update']);
Route::delete('/{game}', [GameController::class, 'destroy']);
Route::post('/{game}/resign', [GameController::class, 'resign']); Route::post('/{game}/resign', [GameController::class, 'resign']);
Route::post('/{game}/join', [GameController::class, 'join']);
}); });
// Match Resources
Route::prefix('matches')->group(function () { Route::prefix('matches')->group(function () {
Route::apiResource('/', MatchController::class)->parameters([ Route::post('host', [MatchController::class, 'host']);
'' => 'match', Route::get('open', [MatchController::class, 'open']);
]); Route::post('/{match}/join', [MatchController::class, 'join']);
Route::post('/{match}/join', [ Route::post('/{match}/start', [MatchController::class, 'start']); // Changed to /start to be explicit
MatchController::class, Route::delete('/{match}', [MatchController::class, 'destroy']);
'join', Route::apiResource('/', MatchController::class)->parameters(['' => 'match']);
]);
}); });
// Admin Routes // Admin Routes
@@ -73,6 +72,8 @@ Route::prefix('v1')->group(function () {
->group(function () { ->group(function () {
Route::get('/statistics', [StatisticsController::class, 'getAdminStats']); Route::get('/statistics', [StatisticsController::class, 'getAdminStats']);
Route::get('/transactions', [TransactionController::class, 'index']); Route::get('/transactions', [TransactionController::class, 'index']);
Route::get('/games', [GameController::class, 'index']);
Route::get('/matches', [MatchController::class, 'index']);
Route::prefix('users')->group(function () { Route::prefix('users')->group(function () {
Route::get('/', [UserController::class, 'index']); Route::get('/', [UserController::class, 'index']);
Route::post('/', [UserController::class, 'store']); Route::post('/', [UserController::class, 'store']);
+2 -2
View File
@@ -16,7 +16,7 @@
"dependencies": { "dependencies": {
"@tailwindcss/vite": "^4.1.17", "@tailwindcss/vite": "^4.1.17",
"@tanstack/vue-table": "^8.21.3", "@tanstack/vue-table": "^8.21.3",
"@vueuse/core": "^14.0.0", "@vueuse/core": "^14.1.0",
"axios": "^1.13.2", "axios": "^1.13.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@@ -24,7 +24,7 @@
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"lucide-vue-next": "^0.554.0", "lucide-vue-next": "^0.554.0",
"pinia": "^3.0.3", "pinia": "^3.0.3",
"reka-ui": "^2.6.0", "reka-ui": "^2.7.0",
"socket.io-client": "^4.8.1", "socket.io-client": "^4.8.1",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.17", "tailwindcss": "^4.1.17",
+2 -1
View File
@@ -42,7 +42,8 @@ const logout = () => {
onMounted(async () => { onMounted(async () => {
await authStore.restoreSession() await authStore.restoreSession()
socketStore.handleConnection() // REMOVED - The WebSocket for multiplayer is handled separately in the WebSocketStore
// socketStore.handleConnection()
}) })
</script> </script>
@@ -7,6 +7,8 @@
:player-score="playerScore" :player-score="playerScore"
:opponent-score="opponentScore" :opponent-score="opponentScore"
:current-turn="currentTurn" :current-turn="currentTurn"
:player-name="playerName"
:opponent-name="opponentName"
:round-number="1" :round-number="1"
/> />
</div> </div>
@@ -62,16 +64,20 @@ const props = defineProps({
// REMOVIDO: playableCardIndices (já não precisamos dele) // REMOVIDO: playableCardIndices (já não precisamos dele)
playerScore: { type: Number, default: 0 }, playerScore: { type: Number, default: 0 },
opponentScore: { type: Number, default: 0 }, opponentScore: { type: Number, default: 0 },
playerName: { type: String, default: 'You' },
opponentName: { type: String, default: 'Bot' },
currentTurn: { currentTurn: {
type: String, type: String,
default: 'player', default: 'player',
validator: (value) => ['player', 'opponent'].includes(value), validator: (value) => ['player', 'opponent'].includes(value),
}, },
isMyTurn: { type: Boolean, default: false },
}) })
const emit = defineEmits(['play-card']) const emit = defineEmits(['play-card'])
const handleCardClick = (payload) => { const handleCardClick = (payload) => {
if (!props.isMyTurn) return
emit('play-card', payload.card) emit('play-card', payload.card)
} }
</script> </script>
@@ -51,6 +51,10 @@ const props = defineProps({
default: 3, default: 3,
validator: (value) => [3, 9].includes(value), validator: (value) => [3, 9].includes(value),
}, },
isDisabled: {
type: Boolean,
default: false,
},
}) })
const emit = defineEmits(['card-clicked']) const emit = defineEmits(['card-clicked'])
@@ -33,7 +33,8 @@
turnChanged && 'animate-pulse', turnChanged && 'animate-pulse',
]" ]"
> >
{{ currentTurn === 'player' ? 'Your Turn' : "Bot's Turn" }} <!-- You can replace the above line with the following if you want to show names instead -->
{{ currentTurn === 'player' ? playerName + "'s Turn" : opponentName + "'s Turn" }}
</div> </div>
<span v-if="roundNumber" class="text-gray-500 text-[10px] font-medium"> <span v-if="roundNumber" class="text-gray-500 text-[10px] font-medium">
Round {{ roundNumber }} Round {{ roundNumber }}
+54 -67
View File
@@ -1,64 +1,49 @@
<template> <template>
<div> <div>
<NavigationMenu> <NavigationMenu>
<NavigationMenuList class="justify-around gap-20"> <NavigationMenuList class="justify-around gap-20">
<NavigationMenuItem> <NavigationMenuItem v-if="!userLoggedIn">
<NavigationMenuTrigger>Testing</NavigationMenuTrigger> <NavigationMenuLink>
<NavigationMenuContent> <RouterLink to="/login">Login</RouterLink>
<li> </NavigationMenuLink>
<NavigationMenuLink as-child> </NavigationMenuItem>
<RouterLink to="/testing/laravel">Laravel</RouterLink>
</NavigationMenuLink>
<NavigationMenuLink as-child>
<RouterLink to="/testing/websockets">Web Sockets</RouterLink>
</NavigationMenuLink>
</li>
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem v-if="!userLoggedIn"> <NavigationMenuItem v-else class="flex flex-row gap-4">
<NavigationMenuLink> <NavigationMenuLink v-if="userStore.coins > 0" href="/coins-purchase"
<RouterLink to="/login">Login</RouterLink> class="flex flex-row items-center gap-1">
</NavigationMenuLink> <div class="flex items-center justify-center w-5 h-5 bg-purple-400 rounded-full shadow-sm">
</NavigationMenuItem> <span class="text-[10px] font-bold text-purple-900">$</span>
</div>
<NavigationMenuItem v-else class="flex flex-row gap-4"> <span class="text-sm font-medium tabular-nums">
<NavigationMenuLink v-if="userStore.coins > 0" href="/coins-purchase" {{ userStore.coins }} <span class="text-muted-foreground text-xs">Coins</span>
class="flex flex-row items-center gap-1"> </span>
<div class="flex items-center justify-center w-5 h-5 bg-purple-400 rounded-full shadow-sm"> </NavigationMenuLink>
<span class="text-[10px] font-bold text-purple-900">$</span> <NavigationMenuLink>
</div> <RouterLink to="/user">Profile</RouterLink>
</NavigationMenuLink>
<span class="text-sm font-medium tabular-nums"> <NavigationMenuLink>
{{ userStore.coins }} <span class="text-muted-foreground text-xs">Coins</span> <a href="/home" @click.prevent="logoutClickHandler" class="cursor-pointer">Logout</a>
</span> </NavigationMenuLink>
</NavigationMenuLink> </NavigationMenuItem>
<NavigationMenuLink> </NavigationMenuList>
<RouterLink to="/user">Profile</RouterLink> </NavigationMenu>
</NavigationMenuLink> </div>
<NavigationMenuLink>
<a href="/home" @click.prevent="logoutClickHandler" class="cursor-pointer">Logout</a>
</NavigationMenuLink>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
</div>
</template> </template>
<script setup> <script setup>
import { useBiscaStore } from '@/stores/bisca' import { useBiscaStore } from '@/stores/bisca'
import { import {
NavigationMenu, NavigationMenu,
NavigationMenuContent, NavigationMenuContent,
NavigationMenuItem, NavigationMenuItem,
NavigationMenuLink, NavigationMenuLink,
NavigationMenuList, NavigationMenuList,
NavigationMenuTrigger, NavigationMenuTrigger,
} from '@/components/ui/navigation-menu' } from '@/components/ui/navigation-menu'
import router from '@/router'; import router from '@/router'
import { watch } from 'vue'; import { watch } from 'vue'
import { useUserStore } from '@/stores/user'; import { useUserStore } from '@/stores/user'
const emits = defineEmits(['logout']) const emits = defineEmits(['logout'])
const { userLoggedIn } = defineProps(['userLoggedIn']) const { userLoggedIn } = defineProps(['userLoggedIn'])
@@ -66,30 +51,32 @@ const { userLoggedIn } = defineProps(['userLoggedIn'])
const biscaStore = useBiscaStore() const biscaStore = useBiscaStore()
const logoutClickHandler = () => { const logoutClickHandler = () => {
if (biscaStore.isGameRunning) { if (biscaStore.isGameRunning) {
const confirmLogout = window.confirm( const confirmLogout = window.confirm(
'If you leave now this game will count as "FORFEIT". Are you sure you want to logout?' 'If you leave now this game will count as "FORFEIT". Are you sure you want to logout?',
) )
if (!confirmLogout) return if (!confirmLogout) return
biscaStore.isLoggingOut = true biscaStore.isLoggingOut = true
biscaStore.quitGame() biscaStore.quitGame()
return return
} }
emits('logout') emits('logout')
router.push('/login') router.push('/login')
} }
const userStore = useUserStore() const userStore = useUserStore()
watch(() => userLoggedIn, async (isLoggedIn) => { watch(
() => userLoggedIn,
async (isLoggedIn) => {
if (isLoggedIn) { if (isLoggedIn) {
await userStore.fetchCoins() await userStore.fetchCoins()
} else { } else {
userStore.coins = 0 userStore.coins = 0
} }
}, { immediate: true }) }, { immediate: true })
</script> </script>
@@ -0,0 +1,29 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { Label } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
for: { type: String, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
</script>
<template>
<Label
data-slot="label"
v-bind="delegatedProps"
:class="
cn(
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
props.class,
)
"
>
<slot />
</Label>
</template>
@@ -0,0 +1 @@
export { default as Label } from "./Label.vue";
@@ -0,0 +1,49 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { SwitchRoot, SwitchThumb, useForwardPropsEmits } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
defaultValue: { type: Boolean, required: false },
modelValue: { type: [Boolean, null], required: false },
disabled: { type: Boolean, required: false },
id: { type: String, required: false },
value: { type: String, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
name: { type: String, required: false },
required: { type: Boolean, required: false },
class: { type: null, required: false },
});
const emits = defineEmits(["update:modelValue"]);
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<SwitchRoot
v-slot="slotProps"
data-slot="switch"
v-bind="forwarded"
:class="
cn(
'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
props.class,
)
"
>
<SwitchThumb
data-slot="switch-thumb"
:class="
cn(
'bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0',
)
"
>
<slot name="thumb" v-bind="slotProps" />
</SwitchThumb>
</SwitchRoot>
</template>
@@ -0,0 +1 @@
export { default as Switch } from "./Switch.vue";
-342
View File
@@ -1,342 +0,0 @@
<template>
<div class="min-h-screen bg-gradient-to-br from-green-700 via-green-800 to-green-900 p-8">
<div class="max-w-6xl mx-auto">
<!-- Title -->
<h1 class="text-white text-3xl font-bold mb-8 text-center">
All Animations Test Page
</h1>
<!-- Control Panel -->
<div class="bg-gray-900/80 rounded-lg p-6 mb-8">
<h2 class="text-white text-xl font-semibold mb-4">Controls</h2>
<!-- Score Animation Tests -->
<div class="mb-6">
<h3 class="text-emerald-400 font-semibold mb-3">Score Count-Up Animation</h3>
<div class="flex flex-wrap gap-3">
<button
@click="increasePlayerScore"
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg font-medium transition-colors"
>
+10 Player Score
</button>
<button
@click="increaseOpponentScore"
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg font-medium transition-colors"
>
+10 Opponent Score
</button>
<button
@click="resetScores"
class="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg font-medium transition-colors"
>
Reset Scores
</button>
</div>
</div>
<!-- Turn Transition Tests -->
<div class="mb-6">
<h3 class="text-blue-400 font-semibold mb-3">Turn Transition Animation</h3>
<div class="flex flex-wrap gap-3">
<button
@click="toggleTurn"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors"
>
Switch Turn
</button>
<button
@click="currentTurn = 'player'"
class="px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg font-medium transition-colors"
>
Your Turn
</button>
<button
@click="currentTurn = 'opponent'"
class="px-4 py-2 bg-rose-600 hover:bg-rose-700 text-white rounded-lg font-medium transition-colors"
>
Bot's Turn
</button>
</div>
</div>
<!-- Trump Reveal Tests -->
<div class="mb-6">
<h3 class="text-amber-400 font-semibold mb-3">Trump Reveal Animation</h3>
<div class="flex flex-wrap gap-3">
<button
@click="triggerTrumpReveal"
class="px-4 py-2 bg-amber-600 hover:bg-amber-700 text-white rounded-lg font-medium transition-colors"
>
Reveal Trump
</button>
</div>
</div>
<!-- Game Over Tests -->
<div class="mb-6">
<h3 class="text-purple-400 font-semibold mb-3">Game Over Screen</h3>
<div class="flex flex-wrap gap-3">
<button
@click="showGameOverPlayerWin"
class="px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg font-medium transition-colors"
>
Player Wins
</button>
<button
@click="showGameOverOpponentWin"
class="px-4 py-2 bg-rose-600 hover:bg-rose-700 text-white rounded-lg font-medium transition-colors"
>
Opponent Wins
</button>
<button
@click="showGameOverDraw"
class="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg font-medium transition-colors"
>
Draw
</button>
</div>
</div>
<!-- Full Sequence -->
<div>
<h3 class="text-pink-400 font-semibold mb-3">Complete Sequence</h3>
<div class="flex flex-wrap gap-3">
<button
@click="runFullSequence"
:disabled="isRunningSequence"
class="px-4 py-2 bg-pink-600 hover:bg-pink-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white rounded-lg font-medium transition-colors"
>
Run Full Animation Sequence
</button>
</div>
</div>
</div>
<!-- Game Board Preview -->
<div class="grid grid-rows-[auto_1fr] gap-8">
<!-- Score Display -->
<div class="flex justify-center">
<ScoreDisplay
:player-score="playerScore"
:opponent-score="opponentScore"
:current-turn="currentTurn"
:round-number="1"
/>
</div>
<!-- Deck Area (with trump) -->
<div class="flex justify-center">
<div class="bg-gray-800/30 rounded-lg p-6">
<h3 class="text-white text-sm font-semibold mb-4 text-center">Trump Card</h3>
<DeckArea
:trump-card="trumpCard"
:cards-remaining="cardsRemaining"
:is-empty="false"
:reveal-trump="revealTrump"
/>
</div>
</div>
</div>
<!-- Instructions -->
<div class="bg-gray-900/80 rounded-lg p-6 mt-8 text-gray-300">
<h3 class="text-white font-semibold mb-2">Instructions:</h3>
<ul class="list-disc list-inside space-y-1 text-sm">
<li>
<strong>Score Count-Up:</strong> Click "+10" buttons to see scores animate smoothly
from old to new value
</li>
<li>
<strong>Turn Transition:</strong> Switch turns to see the indicator pulse and glow with
color change
</li>
<li>
<strong>Trump Reveal:</strong> Triggers a 3-second pulse/glow animation on the trump
card with label
</li>
<li>
<strong>Game Over:</strong> Shows victory/defeat modal with score count-up and confetti
(on win)
</li>
<li>
<strong>Full Sequence:</strong> Runs all animations in order: trump reveal → turn
changes → score updates → game over
</li>
</ul>
</div>
</div>
<!-- Game Over Modal -->
<GameOver
:is-visible="gameOverVisible"
:winner="gameOverWinner"
:player-score="playerScore"
:opponent-score="opponentScore"
:stats="gameOverStats"
@close="gameOverVisible = false"
@play-again="handlePlayAgain"
/>
</div>
</template>
<script setup>
import { ref } from 'vue'
import ScoreDisplay from '@/components/game/ScoreDisplay.vue'
import DeckArea from '@/components/game/DeckArea.vue'
import GameOver from '@/components/game/GameOver.vue'
// Game state
const playerScore = ref(23)
const opponentScore = ref(15)
const currentTurn = ref('player')
const cardsRemaining = ref(30)
const revealTrump = ref(false)
const isRunningSequence = ref(false)
// Trump card
const trumpCard = ref({ suit: 'o', rank: 7 })
// Game Over state
const gameOverVisible = ref(false)
const gameOverWinner = ref('player')
const gameOverStats = ref({
playerTricks: 8,
opponentTricks: 6,
})
// Score manipulation
const increasePlayerScore = () => {
playerScore.value += 10
}
const increaseOpponentScore = () => {
opponentScore.value += 10
}
const resetScores = () => {
playerScore.value = 0
opponentScore.value = 0
}
// Turn control
const toggleTurn = () => {
currentTurn.value = currentTurn.value === 'player' ? 'opponent' : 'player'
}
// Trump reveal
const triggerTrumpReveal = () => {
revealTrump.value = true
setTimeout(() => {
revealTrump.value = false
}, 100)
}
// Game Over screens
const showGameOverPlayerWin = () => {
playerScore.value = 67
opponentScore.value = 53
gameOverWinner.value = 'player'
gameOverStats.value = {
playerTricks: 11,
opponentTricks: 9,
}
gameOverVisible.value = true
}
const showGameOverOpponentWin = () => {
playerScore.value = 45
opponentScore.value = 75
gameOverWinner.value = 'opponent'
gameOverStats.value = {
playerTricks: 7,
opponentTricks: 13,
}
gameOverVisible.value = true
}
const showGameOverDraw = () => {
playerScore.value = 60
opponentScore.value = 60
gameOverWinner.value = 'draw'
gameOverStats.value = {
playerTricks: 10,
opponentTricks: 10,
}
gameOverVisible.value = true
}
const handlePlayAgain = () => {
gameOverVisible.value = false
resetScores()
currentTurn.value = 'player'
cardsRemaining.value = 40
}
// Full animation sequence
const runFullSequence = async () => {
if (isRunningSequence.value) return
isRunningSequence.value = true
// Reset everything
resetScores()
currentTurn.value = 'player'
gameOverVisible.value = false
await new Promise((resolve) => setTimeout(resolve, 500))
// 1. Trump Reveal
console.log('1. Trump Reveal')
triggerTrumpReveal()
await new Promise((resolve) => setTimeout(resolve, 3500))
// 2. First turn
console.log('2. Player turn')
currentTurn.value = 'player'
await new Promise((resolve) => setTimeout(resolve, 1500))
// 3. Player scores
console.log('3. Player scores')
playerScore.value = 11
await new Promise((resolve) => setTimeout(resolve, 2000))
// 4. Switch turn
console.log('4. Switch to opponent turn')
currentTurn.value = 'opponent'
await new Promise((resolve) => setTimeout(resolve, 1500))
// 5. Opponent scores
console.log('5. Opponent scores')
opponentScore.value = 10
await new Promise((resolve) => setTimeout(resolve, 2000))
// 6. Back to player
console.log('6. Back to player')
currentTurn.value = 'player'
await new Promise((resolve) => setTimeout(resolve, 1000))
// 7. More scoring
console.log('7. More scoring')
playerScore.value = 23
await new Promise((resolve) => setTimeout(resolve, 1500))
opponentScore.value = 19
await new Promise((resolve) => setTimeout(resolve, 1500))
playerScore.value = 34
await new Promise((resolve) => setTimeout(resolve, 1500))
// 8. Final scores and game over
console.log('8. Game over')
playerScore.value = 67
opponentScore.value = 53
await new Promise((resolve) => setTimeout(resolve, 2000))
// Show game over
gameOverWinner.value = 'player'
gameOverStats.value = {
playerTricks: 11,
opponentTricks: 9,
}
gameOverVisible.value = true
isRunningSequence.value = false
}
</script>
-252
View File
@@ -1,252 +0,0 @@
<template>
<div class="min-h-screen bg-gradient-to-br from-green-700 via-green-800 to-green-900 p-8">
<div class="max-w-4xl mx-auto">
<!-- Title -->
<h1 class="text-white text-3xl font-bold mb-8 text-center">Animation Test Page</h1>
<!-- Control Panel -->
<div class="bg-gray-900/80 rounded-lg p-6 mb-8">
<h2 class="text-white text-xl font-semibold mb-4">Controls</h2>
<div class="flex flex-wrap gap-3">
<button
@click="playPlayerCard"
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg font-medium transition-colors"
>
Play Player Card
</button>
<button
@click="playOpponentCard"
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg font-medium transition-colors"
>
Play Opponent Card
</button>
<button
@click="playBothCards"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors"
>
Play Both Cards
</button>
<button
@click="clearCards"
class="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg font-medium transition-colors"
>
Clear Cards
</button>
<button
@click="setPlayerWinner"
class="px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg font-medium transition-colors"
>
Player Wins
</button>
<button
@click="setOpponentWinner"
class="px-4 py-2 bg-rose-600 hover:bg-rose-700 text-white rounded-lg font-medium transition-colors"
>
Opponent Wins
</button>
<button
@click="fullSequence"
class="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg font-medium transition-colors"
>
Full Trick Sequence
</button>
<button
@click="collectTrickPlayerWins"
:disabled="!playerCard || !opponentCard"
class="px-4 py-2 bg-cyan-600 hover:bg-cyan-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white rounded-lg font-medium transition-colors"
>
Collect Trick (Player Wins)
</button>
<button
@click="collectTrickOpponentWins"
:disabled="!playerCard || !opponentCard"
class="px-4 py-2 bg-orange-600 hover:bg-orange-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white rounded-lg font-medium transition-colors"
>
Collect Trick (Opponent Wins)
</button>
</div>
<!-- Current State Display -->
<div class="mt-4 text-gray-300 text-sm">
<p>
<strong>Player Card:</strong>
{{ playerCard ? `${playerCard.suit}${playerCard.rank}` : 'None' }}
</p>
<p>
<strong>Opponent Card:</strong>
{{ opponentCard ? `${opponentCard.suit}${opponentCard.rank}` : 'None' }}
</p>
<p><strong>Winner:</strong> {{ winner || 'None' }}</p>
<p><strong>First Player:</strong> {{ firstPlayer || 'None' }}</p>
</div>
</div>
<!-- PlayArea Component -->
<div class="bg-gray-800/50 rounded-lg p-8">
<h2 class="text-white text-xl font-semibold mb-4 text-center">Play Area</h2>
<PlayArea
:player-card="playerCard"
:opponent-card="opponentCard"
:winner="winner"
:first-player="firstPlayer"
/>
</div>
<!-- Instructions -->
<div class="bg-gray-900/80 rounded-lg p-6 mt-8 text-gray-300">
<h3 class="text-white font-semibold mb-2">Instructions:</h3>
<ul class="list-disc list-inside space-y-1 text-sm">
<li><strong>Play Player Card:</strong> Animates a card from bottom (player's hand)</li>
<li><strong>Play Opponent Card:</strong> Animates a card from top (opponent's hand)</li>
<li><strong>Play Both Cards:</strong> Both cards animate in simultaneously</li>
<li><strong>Clear Cards:</strong> Triggers exit animations (cards slide out)</li>
<li>
<strong>Winner Buttons:</strong> Add glowing ring effect to winning card (play cards
first)
</li>
<li>
<strong>Full Trick Sequence:</strong> Plays both cards shows winner clears (2 second
delay)
</li>
<li>
<strong>Collect Trick (Player Wins):</strong> Sets player as winner, waits 1.5s, then
cards slide DOWN toward player area
</li>
<li>
<strong>Collect Trick (Opponent Wins):</strong> Sets opponent as winner, waits 1.5s,
then cards slide UP toward opponent area
</li>
</ul>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import PlayArea from '@/components/game/PlayArea.vue'
// Reactive state
const playerCard = ref(null)
const opponentCard = ref(null)
const winner = ref(null)
const firstPlayer = ref(null)
// Sample cards to test with
const sampleCards = [
{ suit: 'c', rank: 1 },
{ suit: 'c', rank: 7 },
{ suit: 'c', rank: 13 },
{ suit: 'e', rank: 11 },
{ suit: 'e', rank: 12 },
{ suit: 'o', rank: 2 },
{ suit: 'o', rank: 3 },
{ suit: 'p', rank: 4 },
{ suit: 'p', rank: 7 },
]
// Get random card
const getRandomCard = () => {
return sampleCards[Math.floor(Math.random() * sampleCards.length)]
}
// Control functions
const playPlayerCard = () => {
if (!playerCard.value && !opponentCard.value) {
firstPlayer.value = 'player'
}
playerCard.value = getRandomCard()
winner.value = null
}
const playOpponentCard = () => {
if (!playerCard.value && !opponentCard.value) {
firstPlayer.value = 'opponent'
}
opponentCard.value = getRandomCard()
winner.value = null
}
const playBothCards = () => {
firstPlayer.value = 'player' // Player plays first in this scenario
playerCard.value = getRandomCard()
opponentCard.value = getRandomCard()
winner.value = null
}
const clearCards = () => {
playerCard.value = null
opponentCard.value = null
winner.value = null
firstPlayer.value = null
}
const setPlayerWinner = () => {
if (playerCard.value) {
winner.value = 'player'
}
}
const setOpponentWinner = () => {
if (opponentCard.value) {
winner.value = 'opponent'
}
}
const fullSequence = async () => {
// Clear first
clearCards()
await new Promise((resolve) => setTimeout(resolve, 500))
// Play both cards
playBothCards()
await new Promise((resolve) => setTimeout(resolve, 1000))
// Show winner (randomly)
winner.value = Math.random() > 0.5 ? 'player' : 'opponent'
await new Promise((resolve) => setTimeout(resolve, 2000))
// Clear cards
clearCards()
}
// Collect trick with player winning
const collectTrickPlayerWins = async () => {
if (!playerCard.value || !opponentCard.value) return
// Set player as winner
winner.value = 'player'
// Wait 1.5 seconds to show winner glow
await new Promise((resolve) => setTimeout(resolve, 1500))
// Clear cards (triggers exit animation toward player)
playerCard.value = null
opponentCard.value = null
// Reset after animation completes
await new Promise((resolve) => setTimeout(resolve, 600))
winner.value = null
firstPlayer.value = null
}
// Collect trick with opponent winning
const collectTrickOpponentWins = async () => {
if (!playerCard.value || !opponentCard.value) return
// Set opponent as winner
winner.value = 'opponent'
// Wait 1.5 seconds to show winner glow
await new Promise((resolve) => setTimeout(resolve, 1500))
// Clear cards (triggers exit animation toward opponent)
playerCard.value = null
opponentCard.value = null
// Reset after animation completes
await new Promise((resolve) => setTimeout(resolve, 600))
winner.value = null
firstPlayer.value = null
}
</script>
-275
View File
@@ -1,275 +0,0 @@
<template>
<div class="min-h-screen bg-gradient-to-br from-green-700 via-green-800 to-green-900 p-8">
<div class="max-w-6xl mx-auto">
<!-- Title -->
<h1 class="text-white text-3xl font-bold mb-8 text-center">Card Dealing Test Page</h1>
<!-- Control Panel -->
<div class="bg-gray-900/80 rounded-lg p-6 mb-8">
<h2 class="text-white text-xl font-semibold mb-4">Controls</h2>
<div class="flex flex-wrap gap-3 mb-4">
<button
@click="startDealing"
:disabled="isDealing"
class="px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white rounded-lg font-medium transition-colors"
>
Start Dealing
</button>
<button
@click="dealSingleCard"
:disabled="isDealing || cardsRemaining === 0"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white rounded-lg font-medium transition-colors"
>
Deal One Card
</button>
<button
@click="reset"
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg font-medium transition-colors"
>
Reset
</button>
</div>
<!-- Current State Display -->
<div class="grid grid-cols-3 gap-4 text-gray-300 text-sm">
<div>
<p><strong>Player Hand:</strong> {{ playerHand.length }} cards</p>
<p><strong>Opponent Hand:</strong> {{ opponentHand.length }} cards</p>
</div>
<div>
<p><strong>Cards Remaining:</strong> {{ cardsRemaining }}</p>
<p><strong>Dealing:</strong> {{ isDealing ? 'Yes' : 'No' }}</p>
</div>
<div>
<p><strong>Next Recipient:</strong> {{ nextRecipient }}</p>
</div>
</div>
</div>
<!-- Game Board Layout -->
<div class="grid grid-rows-[auto_1fr_auto] gap-8">
<!-- Opponent Hand (Top) -->
<div class="flex justify-center">
<div class="bg-gray-800/30 rounded-lg p-4">
<h3 class="text-white text-sm font-semibold mb-2 text-center">Opponent Hand</h3>
<PlayerHand
ref="opponentHandRef"
:cards="opponentHand"
:face-down="true"
:max-cards="3"
:playable-cards="[]"
/>
</div>
</div>
<!-- Center Area (Deck) -->
<div class="flex items-center justify-center">
<div class="bg-gray-800/30 rounded-lg p-6">
<h3 class="text-white text-sm font-semibold mb-4 text-center">Deck</h3>
<DeckArea
ref="deckRef"
:trump-card="trumpCard"
:cards-remaining="cardsRemaining"
:is-empty="cardsRemaining === 0"
/>
</div>
</div>
<!-- Player Hand (Bottom) -->
<div class="flex justify-center">
<div class="bg-gray-800/30 rounded-lg p-4">
<h3 class="text-white text-sm font-semibold mb-2 text-center">Your Hand</h3>
<PlayerHand
ref="playerHandRef"
:cards="playerHand"
:face-down="false"
:max-cards="3"
:playable-cards="[]"
/>
</div>
</div>
</div>
<!-- Instructions -->
<div class="bg-gray-900/80 rounded-lg p-6 mt-8 text-gray-300">
<h3 class="text-white font-semibold mb-2">Instructions:</h3>
<ul class="list-disc list-inside space-y-1 text-sm">
<li><strong>Start Dealing:</strong> Automatically deals 6 cards (3 to each player) with 200ms delay</li>
<li><strong>Deal One Card:</strong> Manually deal a single card to the next recipient</li>
<li><strong>Reset:</strong> Clear all hands and reset the deck to 40 cards</li>
<li>Watch the FlyingCard animation as cards move from deck to hands</li>
<li>Notice the deck counter decreasing and the pulse effect</li>
<li>Cards appear in hands with a fade-in animation</li>
</ul>
</div>
</div>
<!-- Flying Cards Container -->
<FlyingCard
v-for="flyingCard in flyingCards"
:key="flyingCard.id"
:card="flyingCard.card"
:start-position="flyingCard.startPosition"
:end-position="flyingCard.endPosition"
:duration="500"
:face-down="flyingCard.faceDown"
:delay="flyingCard.delay"
@complete="onFlyingCardComplete(flyingCard)"
/>
</div>
</template>
<script setup>
import { ref, computed, nextTick } from 'vue'
import PlayerHand from '@/components/game/PlayerHand.vue'
import DeckArea from '@/components/game/DeckArea.vue'
import FlyingCard from '@/components/game/FlyingCard.vue'
// Refs for components
const playerHandRef = ref(null)
const opponentHandRef = ref(null)
const deckRef = ref(null)
// Game state
const playerHand = ref([])
const opponentHand = ref([])
const cardsRemaining = ref(40)
const isDealing = ref(false)
const flyingCards = ref([])
let flyingCardIdCounter = 0
// Trump card (fixed for testing)
const trumpCard = ref({ suit: 'o', rank: 7 })
// Sample deck
const sampleCards = [
{ suit: 'c', rank: 1 },
{ suit: 'c', rank: 2 },
{ suit: 'c', rank: 3 },
{ suit: 'c', rank: 4 },
{ suit: 'c', rank: 5 },
{ suit: 'c', rank: 6 },
{ suit: 'c', rank: 7 },
{ suit: 'c', rank: 11 },
{ suit: 'c', rank: 12 },
{ suit: 'c', rank: 13 },
{ suit: 'e', rank: 1 },
{ suit: 'e', rank: 2 },
{ suit: 'e', rank: 3 },
{ suit: 'e', rank: 4 },
{ suit: 'e', rank: 5 },
{ suit: 'e', rank: 6 },
{ suit: 'e', rank: 7 },
{ suit: 'e', rank: 11 },
{ suit: 'e', rank: 12 },
{ suit: 'e', rank: 13 },
{ suit: 'o', rank: 1 },
{ suit: 'o', rank: 2 },
{ suit: 'o', rank: 3 },
{ suit: 'o', rank: 4 },
{ suit: 'o', rank: 5 },
{ suit: 'o', rank: 6 },
{ suit: 'p', rank: 1 },
{ suit: 'p', rank: 2 },
{ suit: 'p', rank: 3 },
{ suit: 'p', rank: 4 },
{ suit: 'p', rank: 5 },
{ suit: 'p', rank: 6 },
{ suit: 'p', rank: 7 },
{ suit: 'p', rank: 11 },
{ suit: 'p', rank: 12 },
{ suit: 'p', rank: 13 },
]
// Computed
const nextRecipient = computed(() => {
const totalDealt = playerHand.value.length + opponentHand.value.length
return totalDealt % 2 === 0 ? 'Player' : 'Opponent'
})
// Get random card
const getRandomCard = () => {
return sampleCards[Math.floor(Math.random() * sampleCards.length)]
}
// Get position of an element
const getElementPosition = (el) => {
if (!el) return { x: 0, y: 0 }
const rect = el.$el?.getBoundingClientRect() || el.getBoundingClientRect()
return {
x: rect.left + rect.width / 2 - 64, // Center, minus half card width
y: rect.top + rect.height / 2 - 89, // Center, minus half card height
}
}
// Deal a single card
const dealSingleCard = async () => {
if (cardsRemaining.value === 0) return
await nextTick()
// Determine recipient
const totalDealt = playerHand.value.length + opponentHand.value.length
const isPlayer = totalDealt % 2 === 0
// Get positions
const startPos = getElementPosition(deckRef.value)
const endPos = getElementPosition(isPlayer ? playerHandRef.value : opponentHandRef.value)
// Create flying card
const card = getRandomCard()
const flyingCard = {
id: flyingCardIdCounter++,
card,
startPosition: startPos,
endPosition: endPos,
faceDown: !isPlayer, // Face down for opponent
delay: 0,
recipient: isPlayer ? 'player' : 'opponent',
}
flyingCards.value.push(flyingCard)
cardsRemaining.value--
}
// Flying card animation complete
const onFlyingCardComplete = (flyingCard) => {
// Add card to appropriate hand
if (flyingCard.recipient === 'player') {
playerHand.value.push(flyingCard.card)
} else {
opponentHand.value.push(flyingCard.card)
}
// Remove flying card
flyingCards.value = flyingCards.value.filter((fc) => fc.id !== flyingCard.id)
}
// Start automatic dealing sequence
const startDealing = async () => {
if (isDealing.value) return
isDealing.value = true
// Deal 6 cards (3 to each player)
for (let i = 0; i < 6; i++) {
if (cardsRemaining.value === 0) break
await dealSingleCard()
// Wait 200ms before next card
await new Promise((resolve) => setTimeout(resolve, 700))
}
isDealing.value = false
}
// Reset everything
const reset = () => {
playerHand.value = []
opponentHand.value = []
cardsRemaining.value = 40
flyingCards.value = []
isDealing.value = false
}
</script>
-444
View File
@@ -1,444 +0,0 @@
<template>
<div class="min-h-screen bg-gradient-to-br from-green-700 via-green-800 to-green-900">
<!-- Control Panel (Floating) -->
<div class="fixed top-4 left-4 bg-gray-900/95 rounded-lg p-4 max-w-xs z-50 shadow-2xl">
<h3 class="text-white font-bold mb-3 text-sm">Test Controls</h3>
<div class="space-y-2">
<button
@click="dealInitialCards"
:disabled="isDealing"
class="w-full px-3 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 text-white rounded text-xs font-medium transition-colors"
>
Deal Cards
</button>
<button
@click="playRandomCards"
:disabled="playerHand.length === 0"
class="w-full px-3 py-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-600 text-white rounded text-xs font-medium transition-colors"
>
Play Both Cards
</button>
<button
@click="collectTrick"
:disabled="!currentTrick.playerCard || !currentTrick.opponentCard"
class="w-full px-3 py-2 bg-purple-600 hover:bg-purple-700 disabled:bg-gray-600 text-white rounded text-xs font-medium transition-colors"
>
Collect Trick
</button>
<button
@click="revealTrump = !revealTrump"
class="w-full px-3 py-2 bg-amber-600 hover:bg-amber-700 text-white rounded text-xs font-medium transition-colors"
>
{{ revealTrump ? 'Hide' : 'Reveal' }} Trump
</button>
<button
@click="showGameOver"
class="w-full px-3 py-2 bg-rose-600 hover:bg-rose-700 text-white rounded text-xs font-medium transition-colors"
>
Show Game Over
</button>
<button
@click="resetGame"
class="w-full px-3 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded text-xs font-medium transition-colors"
>
Reset Game
</button>
<div class="pt-2 border-t border-gray-700 mt-2 text-gray-400 text-xs">
<p><strong>Turn:</strong> {{ currentTurn === 'player' ? 'You' : 'Bot' }}</p>
<p><strong>Deck:</strong> {{ cardsRemaining }} cards</p>
</div>
</div>
</div>
<!-- Main Game Board -->
<div
class="grid grid-rows-[auto_auto_1fr_auto] grid-cols-[1fr_3fr_1fr] gap-6 p-8 min-h-screen"
>
<!-- Score Display (Top Left) -->
<div class="col-start-1 col-end-4 row-start-1 flex justify-start items-start">
<ScoreDisplay
:player-score="playerScore"
:opponent-score="opponentScore"
:current-turn="currentTurn"
:round-number="1"
/>
</div>
<!-- Opponent Hand -->
<div class="col-start-2 col-end-3 row-start-2 flex justify-center">
<PlayerHand
ref="opponentHandRef"
:cards="opponentHand"
:face-down="true"
:max-cards="3"
:playable-cards="[]"
/>
</div>
<!-- Play Area -->
<div class="col-start-2 col-end-3 row-start-3 flex items-center justify-center">
<PlayArea
:player-card="currentTrick.playerCard"
:opponent-card="currentTrick.opponentCard"
:winner="currentTrick.winner"
:first-player="currentTrick.firstPlayer"
/>
</div>
<!-- Deck Area -->
<div class="col-start-3 col-end-4 row-start-3 flex items-center justify-center">
<DeckArea
ref="deckRef"
:trump-card="trumpCard"
:cards-remaining="cardsRemaining"
:is-empty="cardsRemaining === 0"
:reveal-trump="revealTrump"
/>
</div>
<!-- Player Hand -->
<div class="col-start-2 col-end-3 row-start-4 flex justify-center">
<PlayerHand
ref="playerHandRef"
:cards="playerHand"
:face-down="false"
:max-cards="3"
:playable-cards="playableCards"
@card-clicked="handleCardClick"
/>
</div>
</div>
<!-- Flying Cards Container -->
<FlyingCard
v-for="flyingCard in flyingCards"
:key="flyingCard.id"
:card="flyingCard.card"
:start-position="flyingCard.startPosition"
:end-position="flyingCard.endPosition"
:duration="500"
:face-down="flyingCard.faceDown"
:delay="flyingCard.delay"
@complete="onFlyingCardComplete(flyingCard)"
/>
<!-- Game Over Modal -->
<GameOver
:is-visible="gameOverVisible"
:winner="gameOverWinner"
:player-score="playerScore"
:opponent-score="opponentScore"
:stats="gameOverStats"
@close="gameOverVisible = false"
@play-again="resetGame"
/>
</div>
</template>
<script setup>
import { ref, computed, nextTick } from 'vue'
import GameBoard from '@/components/game/GameBoard.vue'
import ScoreDisplay from '@/components/game/ScoreDisplay.vue'
import PlayerHand from '@/components/game/PlayerHand.vue'
import PlayArea from '@/components/game/PlayArea.vue'
import DeckArea from '@/components/game/DeckArea.vue'
import FlyingCard from '@/components/game/FlyingCard.vue'
import GameOver from '@/components/game/GameOver.vue'
// Component refs
const playerHandRef = ref(null)
const opponentHandRef = ref(null)
const deckRef = ref(null)
// Game state
const playerHand = ref([])
const opponentHand = ref([])
const playerScore = ref(0)
const opponentScore = ref(0)
const currentTurn = ref('player')
const cardsRemaining = ref(40)
const isDealing = ref(false)
const revealTrump = ref(false)
// Trump card
const trumpCard = ref({ suit: 'o', rank: 7 })
// Current trick
const currentTrick = ref({
playerCard: null,
opponentCard: null,
winner: null,
firstPlayer: null,
})
// Flying cards for dealing animation
const flyingCards = ref([])
let flyingCardIdCounter = 0
// Game Over
const gameOverVisible = ref(false)
const gameOverWinner = ref('player')
const gameOverStats = ref({
playerTricks: 0,
opponentTricks: 0,
})
// Sample deck
const sampleCards = [
{ suit: 'c', rank: 1 },
{ suit: 'c', rank: 2 },
{ suit: 'c', rank: 3 },
{ suit: 'c', rank: 4 },
{ suit: 'c', rank: 5 },
{ suit: 'c', rank: 6 },
{ suit: 'c', rank: 7 },
{ suit: 'c', rank: 11 },
{ suit: 'c', rank: 12 },
{ suit: 'c', rank: 13 },
{ suit: 'e', rank: 1 },
{ suit: 'e', rank: 2 },
{ suit: 'e', rank: 3 },
{ suit: 'e', rank: 4 },
{ suit: 'e', rank: 5 },
{ suit: 'e', rank: 6 },
{ suit: 'e', rank: 7 },
{ suit: 'e', rank: 11 },
{ suit: 'e', rank: 12 },
{ suit: 'e', rank: 13 },
{ suit: 'o', rank: 1 },
{ suit: 'o', rank: 2 },
{ suit: 'o', rank: 3 },
{ suit: 'o', rank: 4 },
{ suit: 'o', rank: 5 },
{ suit: 'o', rank: 6 },
{ suit: 'p', rank: 1 },
{ suit: 'p', rank: 2 },
{ suit: 'p', rank: 3 },
{ suit: 'p', rank: 4 },
{ suit: 'p', rank: 5 },
{ suit: 'p', rank: 6 },
{ suit: 'p', rank: 7 },
{ suit: 'p', rank: 11 },
{ suit: 'p', rank: 12 },
{ suit: 'p', rank: 13 },
]
// Playable cards (all cards in hand for testing)
const playableCards = computed(() => {
return playerHand.value.map((_, index) => index)
})
// Get random card
const getRandomCard = () => {
return sampleCards[Math.floor(Math.random() * sampleCards.length)]
}
// Get element position
const getElementPosition = (el) => {
if (!el) return { x: 0, y: 0 }
const rect = el.$el?.getBoundingClientRect() || el.getBoundingClientRect()
return {
x: rect.left + rect.width / 2 - 64,
y: rect.top + rect.height / 2 - 89,
}
}
// Deal a single card
const dealSingleCard = async (isPlayer) => {
if (cardsRemaining.value === 0) return
await nextTick()
const startPos = getElementPosition(deckRef.value)
const endPos = getElementPosition(isPlayer ? playerHandRef.value : opponentHandRef.value)
const card = getRandomCard()
const flyingCard = {
id: flyingCardIdCounter++,
card,
startPosition: startPos,
endPosition: endPos,
faceDown: !isPlayer,
delay: 0,
recipient: isPlayer ? 'player' : 'opponent',
}
flyingCards.value.push(flyingCard)
cardsRemaining.value--
}
// Flying card complete
const onFlyingCardComplete = (flyingCard) => {
if (flyingCard.recipient === 'player') {
playerHand.value.push(flyingCard.card)
} else {
opponentHand.value.push(flyingCard.card)
}
flyingCards.value = flyingCards.value.filter((fc) => fc.id !== flyingCard.id)
}
// Deal initial cards
const dealInitialCards = async () => {
if (isDealing.value) return
isDealing.value = true
// Reveal trump at start
revealTrump.value = true
await new Promise((resolve) => setTimeout(resolve, 1000))
revealTrump.value = false
// Deal 6 cards (3 to each player)
for (let i = 0; i < 6; i++) {
if (cardsRemaining.value === 0) break
const isPlayer = i % 2 === 0
await dealSingleCard(isPlayer)
await new Promise((resolve) => setTimeout(resolve, 700))
}
isDealing.value = false
}
// Handle card click from player hand
const handleCardClick = ({ card, index }) => {
if (currentTurn.value !== 'player') return
if (currentTrick.value.playerCard) return
// Set first player if no cards played yet
if (!currentTrick.value.opponentCard) {
currentTrick.value.firstPlayer = 'player'
}
// Play the card
currentTrick.value.playerCard = card
playerHand.value.splice(index, 1)
// Switch turn
currentTurn.value = 'opponent'
// Auto-play opponent card after delay
setTimeout(() => {
if (opponentHand.value.length > 0 && !currentTrick.value.opponentCard) {
playOpponentCard()
}
}, 1000)
}
// Play opponent card
const playOpponentCard = () => {
if (opponentHand.value.length === 0) return
// Set first player if no cards played yet
if (!currentTrick.value.playerCard) {
currentTrick.value.firstPlayer = 'opponent'
}
// Play random card
const randomIndex = Math.floor(Math.random() * opponentHand.value.length)
const card = opponentHand.value[randomIndex]
currentTrick.value.opponentCard = card
opponentHand.value.splice(randomIndex, 1)
// Switch turn
currentTurn.value = 'player'
}
// Play random cards from both players
const playRandomCards = () => {
if (playerHand.value.length === 0) return
// Player plays first
currentTrick.value.firstPlayer = 'player'
const playerCard = playerHand.value[0]
currentTrick.value.playerCard = playerCard
playerHand.value.shift()
// Opponent plays after delay
setTimeout(() => {
if (opponentHand.value.length > 0) {
const opponentCard = opponentHand.value[0]
currentTrick.value.opponentCard = opponentCard
opponentHand.value.shift()
}
}, 600)
}
// Collect trick
const collectTrick = async () => {
if (!currentTrick.value.playerCard || !currentTrick.value.opponentCard) return
// Randomly determine winner
const winner = Math.random() > 0.5 ? 'player' : 'opponent'
currentTrick.value.winner = winner
// Wait to show winner glow
await new Promise((resolve) => setTimeout(resolve, 1500))
// Update score (random points)
const points = Math.floor(Math.random() * 15) + 5
if (winner === 'player') {
playerScore.value += points
gameOverStats.value.playerTricks++
} else {
opponentScore.value += points
gameOverStats.value.opponentTricks++
}
// Clear cards (triggers collection animation)
currentTrick.value.playerCard = null
currentTrick.value.opponentCard = null
// Reset after animation
await new Promise((resolve) => setTimeout(resolve, 600))
currentTrick.value.winner = null
currentTrick.value.firstPlayer = null
// Deal new cards if deck has cards
if (cardsRemaining.value >= 2 && playerHand.value.length < 3) {
await new Promise((resolve) => setTimeout(resolve, 500))
await dealSingleCard(winner === 'player')
await new Promise((resolve) => setTimeout(resolve, 700))
await dealSingleCard(winner === 'opponent')
}
// Switch turn to winner
currentTurn.value = winner
}
// Show game over
const showGameOver = () => {
// Make player win for demo
if (playerScore.value <= opponentScore.value) {
playerScore.value = opponentScore.value + 10
}
gameOverWinner.value =
playerScore.value > opponentScore.value
? 'player'
: opponentScore.value > playerScore.value
? 'opponent'
: 'draw'
gameOverVisible.value = true
}
// Reset game
const resetGame = () => {
playerHand.value = []
opponentHand.value = []
playerScore.value = 0
opponentScore.value = 0
currentTurn.value = 'player'
cardsRemaining.value = 40
currentTrick.value = {
playerCard: null,
opponentCard: null,
winner: null,
firstPlayer: null,
}
flyingCards.value = []
gameOverVisible.value = false
gameOverStats.value = {
playerTricks: 0,
opponentTricks: 0,
}
revealTrump.value = false
}
</script>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,387 @@
<template>
<div class="relative min-h-screen overflow-hidden bg-green-900">
<div v-if="shouldShowBoard && !gameOver" class="absolute top-4 right-4 z-50">
<button @click="handleSurrender"
class="bg-red-600 hover:bg-red-700 text-white font-bold py-2 px-4 rounded-lg shadow-lg border-2 border-red-800 transition-all transform hover:scale-105 flex items-center gap-2">
<span>🏳</span> Surrender
</button>
</div>
<div v-if="!wsStore.connected && hasConnectedOnce"
class="absolute top-4 left-4 z-50 flex items-center gap-2 bg-gray-800 px-4 py-2 rounded-lg border border-gray-700 shadow-xl">
<div class="w-3 h-3 rounded-full bg-red-500 animate-pulse"></div>
<span class="text-white text-sm">Reconnecting...</span>
</div>
<div v-if="shouldShowBoard && !gameOver && isMyTurn" class="absolute top-4 left-1/2 -translate-x-1/2 z-50">
<div class="bg-gray-800 px-6 py-3 rounded-lg border-2 shadow-xl transition-colors duration-300"
:class="timeLeft <= 5 ? 'border-red-500 bg-red-900/20' : 'border-gray-700'">
<div class="flex items-center gap-3">
<svg class="w-6 h-6" :class="timeLeft <= 5 ? 'text-red-500 animate-bounce' : 'text-white'" viewBox="0 0 24 24"
fill="none" stroke="currentColor">
<circle cx="12" cy="12" r="10" stroke-width="2" />
<polyline points="12 6 12 12 16 14" stroke-width="2" />
</svg>
<span :class="['text-3xl font-mono font-bold', timeLeft <= 5 ? 'text-red-500' : 'text-white']">
{{ timeLeft }}
</span>
</div>
</div>
</div>
<div v-if="shouldShowBoard">
<GameBoard :trump-card="trumpCard" :cards-remaining="deckCount + (trumpCard ? 1 : 0)" :player-hand="visibleHand"
:opponent-hand="opponentHand" :player-score="playerScore" :opponent-score="opponentScore"
:player-name="myDisplayName" :opponent-name="opponentDisplayName" :current-turn="currentTurnLabel"
:current-trick="displayedTrick" :is-my-turn="isMyTurn" @play-card="handlePlayCard" />
</div>
<div v-else-if="!gameOver" class="flex h-screen items-center justify-center bg-green-900 text-white">
<div class="text-center p-8 bg-green-800/50 rounded-2xl border border-green-700 shadow-2xl backdrop-blur-sm">
<div class="relative mx-auto mb-6 h-16 w-16">
<div class="absolute inset-0 rounded-full border-4 border-green-600"></div>
<div
class="absolute inset-0 rounded-full border-4 border-t-white border-r-transparent border-b-transparent border-l-transparent animate-spin">
</div>
</div>
<h2 class="text-2xl font-bold mb-2">
Waiting for Opponent...
</h2>
<p class="text-green-100 mb-8 max-w-sm">
Share the game code or wait for a player to join your lobby.
</p>
<div class="bg-black/20 p-3 rounded mb-6 font-mono text-sm border border-white/10 text-green-100">
Match ID: <span class="font-bold text-white">{{ matchState.id }}</span>
</div>
<button @click="handleCloseModal"
class="px-6 py-3 bg-red-600 hover:bg-red-700 text-white rounded-lg font-semibold transition-all shadow-lg hover:shadow-red-500/20">
Cancel & Return Home
</button>
</div>
</div>
<GameOver :is-visible="gameOver" :winner="winnerResult" :player-score="playerScore" :opponent-score="opponentScore"
:player-name="myDisplayName" :opponent-name="opponentDisplayName" :stats="{ playerTricks: 0, opponentTricks: 0 }"
:is-logging-out="false" @play-again="handleCloseModal" @close="handleCloseModal" />
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch, inject } from 'vue'
import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router'
import { useSocketStore } from '@/stores/socket'
import { useAuthStore } from '@/stores/auth'
import { toast } from 'vue-sonner'
import axios from 'axios'
import GameBoard from '@/components/game/GameBoard.vue'
import GameOver from '@/components/game/GameOver.vue'
const route = useRoute()
const router = useRouter()
const wsStore = useSocketStore()
const authStore = useAuthStore()
const API_BASE_URL = inject('apiBaseURL')
const gameId = ref(route.params.id)
const visibleHand = ref([])
const gameOver = ref(false)
const isDealing = ref(false)
const hasInitialDealHappened = ref(false)
const winnerResult = ref('draw')
const timeLeft = ref(20)
const timerInterval = ref(null)
const hasConnectedOnce = ref(false)
// --- STATE ---
const matchState = computed(() => wsStore.gameState || { id: gameId.value })
const gameMetadata = ref({
p1_id: null,
p1_name: 'Player 1',
p2_id: null,
p2_name: 'Opponent'
})
const displayedTrick = ref({ playerCard: null, opponentCard: null })
const trickClearTimer = ref(null)
// --- COMPUTED ---
const myUserId = computed(() => authStore.currentUser?.id)
const shouldShowBoard = computed(() => {
const s = wsStore.gameState;
if (s && s.player2 && s.player2.id > 1) {
return true;
}
return false;
})
const amIHost = computed(() => {
if (gameMetadata.value.p1_id) {
return String(gameMetadata.value.p1_id) === String(myUserId.value)
}
const p1Id = wsStore.gameState?.player1_user_id || wsStore.gameState?.player1?.id
return String(p1Id) === String(myUserId.value)
})
const myDisplayName = computed(() => amIHost.value ? gameMetadata.value.p1_name : gameMetadata.value.p2_name)
const opponentDisplayName = computed(() => amIHost.value ? gameMetadata.value.p2_name : gameMetadata.value.p1_name)
const mappedTrick = computed(() => {
const table = wsStore.gameState?.table
if (!table) return { playerCard: null, opponentCard: null }
if (amIHost.value) {
return { playerCard: table.playerCard, opponentCard: table.opponentCard }
} else {
return { playerCard: table.opponentCard, opponentCard: table.playerCard }
}
})
const currentTurnLabel = computed(() => {
const turnId = wsStore.gameState?.currentTurn
if (!turnId) return 'player'
return String(turnId) === String(myUserId.value) ? 'player' : 'opponent'
})
const trumpCard = computed(() => wsStore.gameState?.trumpCard || null)
const deckCount = computed(() => wsStore.gameState?.deckCount || 0)
const playerScore = computed(() => wsStore.gameState?.points || 0)
const opponentScore = computed(() => wsStore.gameState?.opponent?.points || 0)
const serverHand = computed(() => wsStore.gameState?.hand || [])
const isMyTurn = computed(() => String(wsStore.gameState?.currentTurn) === String(myUserId.value))
const opponentHand = computed(() => {
const count = wsStore.gameState?.opponent?.cardCount || 0
return Array.from({ length: count }, (_, i) => ({ id: `opp-${i}`, suit: 'back', rank: 0 }))
})
// --- WATCHERS ---
watch(() => wsStore.connected, (isConnected) => {
if (isConnected) {
hasConnectedOnce.value = true
wsStore.joinGame(gameId.value)
}
}, { immediate: true })
watch(() => wsStore.gameState, async (newState, oldState) => {
const oldP2 = oldState?.player2?.id;
const newP2 = newState?.player2?.id;
if (newP2 && newP2 > 1 && newP2 !== oldP2) {
await fetchGameMetadata();
}
}, { deep: true })
watch(mappedTrick, (newVal) => {
if (newVal.playerCard || newVal.opponentCard) {
if (trickClearTimer.value) clearTimeout(trickClearTimer.value)
displayedTrick.value = { ...newVal }
} else {
const currentlyShowing = displayedTrick.value.playerCard || displayedTrick.value.opponentCard
if (currentlyShowing) {
trickClearTimer.value = setTimeout(() => {
displayedTrick.value = { playerCard: null, opponentCard: null }
}, 1500)
} else {
displayedTrick.value = { playerCard: null, opponentCard: null }
}
}
}, { deep: true })
// --- FIX: TIMER GHOSTING ---
const startTimer = () => {
if (timerInterval.value) clearInterval(timerInterval.value)
if (!shouldShowBoard.value && wsStore.gameState?.status !== 'playing') {
timeLeft.value = 20;
return;
}
// FORCE RESET to avoid showing old time for 1 frame
timeLeft.value = 20;
const startAt = wsStore.gameState?.turnStartedAt ? new Date(wsStore.gameState.turnStartedAt).getTime() : Date.now()
// Define update function
const update = () => {
const now = Date.now()
const elapsed = Math.floor((now - startAt) / 1000)
timeLeft.value = Math.max(0, 20 - elapsed)
if (timeLeft.value <= 0) clearInterval(timerInterval.value)
}
// EXECUTE IMMEDIATELY
update();
// Then set interval
timerInterval.value = setInterval(update, 1000)
}
watch(() => wsStore.gameState?.currentTurn, (newTurn) => {
if (newTurn) startTimer()
}, { immediate: true })
const animateDeal = async (cards) => {
if (isDealing.value) return
isDealing.value = true
visibleHand.value = []
for (const card of cards) {
visibleHand.value.push(card)
await new Promise(r => setTimeout(r, 150))
}
isDealing.value = false
}
watch(serverHand, (newHand) => {
if (visibleHand.value.length === 0 && newHand.length > 0 && !hasInitialDealHappened.value) {
animateDeal(newHand)
hasInitialDealHappened.value = true
} else if (!isDealing.value) {
visibleHand.value = [...newHand]
}
}, { deep: true })
// --- METHODS ---
const fetchGameMetadata = async () => {
try {
const response = await axios.get(`${API_BASE_URL}/games/${gameId.value}`)
const game = response.data.data || response.data
gameMetadata.value = {
p1_id: game.player1_user_id,
p1_name: game.player1?.nickname || 'Host',
p2_id: game.player2_user_id,
p2_name: game.player2?.nickname || 'Opponent'
}
} catch (e) {
console.error("Failed to load metadata", e)
}
}
const handlePlayCard = (card) => {
if (!isMyTurn.value || gameOver.value) return
visibleHand.value = visibleHand.value.filter(c => c.id !== card.id)
wsStore.playCard(gameId.value, card.id)
}
const handleBeforeUnload = (event) => {
if (shouldShowBoard.value && !gameOver.value) {
event.preventDefault();
event.returnValue = '';
}
};
// --- FIX: SURRENDER LOGIC ---
const handleSurrender = () => {
if (confirm('Are you sure you want to surrender? You will lose.')) {
wsStore.surrender(gameId.value)
}
}
const handleCloseModal = async () => {
// 1. If game is still in Lobby (Pending) and I am the Host, DELETE it.
if (!shouldShowBoard.value && amIHost.value && !gameOver.value) {
try {
// This calls Route::delete('/{game}', ...) in your api.php
await axios.delete(`${API_BASE_URL}/games/${gameId.value}`);
console.log("Lobby cancelled and deleted.");
} catch (e) {
console.error("Failed to delete pending game:", e);
}
}
// 2. Notify server of voluntary exit (to skip the 10s reconnect timer)
if (wsStore.socket) {
wsStore.socket.emit('notify_disconnect');
}
// 3. Disconnect and go home
wsStore.disconnect();
router.push({ name: 'home' });
}
// --- LIFECYCLE ---
onBeforeRouteLeave(async (to, from, next) => {
// CASE A: Game is Pending (Waiting Room)
if (!shouldShowBoard.value && !gameOver.value) {
if (amIHost.value) {
// Ask for confirmation to close the lobby
const confirmClose = window.confirm("This will cancel the lobby. Are you sure?");
if (confirmClose) {
try {
// Delete the game from DB so it doesn't get stuck as "Pending"
await axios.delete(`${API_BASE_URL}/games/${gameId.value}`);
} catch (e) { console.error(e) }
if (wsStore.socket) wsStore.socket.emit('notify_disconnect');
wsStore.disconnect();
next();
} else {
next(false); // Stay on page
}
return;
} else {
// If I am NOT host (just a guest waiting), just leave
if (wsStore.socket) wsStore.socket.emit('notify_disconnect');
wsStore.disconnect();
next();
return;
}
}
// CASE B: Game is Over (Standard exit)
if (gameOver.value) {
next();
return;
}
// CASE C: Game is Active/Playing (The Surrender Logic we built before)
const answer = window.confirm(
"If you leave, you will be register has 'losing' or 'surrendered', Are you sure you want to leave?"
);
if (answer) {
// ... (Your existing surrender logic) ...
try { await wsStore.surrender(gameId.value); } catch (e) { }
if (wsStore.socket) {
wsStore.socket.emit('notify_disconnect', () => {
wsStore.disconnect();
next();
});
setTimeout(() => { if (wsStore.connected) { wsStore.disconnect(); next(); } }, 500);
return;
}
wsStore.disconnect();
next();
} else {
next(false);
}
});
onMounted(async () => {
await fetchGameMetadata()
wsStore.connect()
wsStore.onTrickEnd((result) => {
const winnerName = result.winnerId == authStore.currentUser?.id ? "You" : "Opponent"
toast.success(`${winnerName} won the trick!`, { position: 'top-center', duration: 1500 })
})
wsStore.onGameOver((result) => {
gameOver.value = true
if (result.isDraw) winnerResult.value = 'draw'
else winnerResult.value = result.winnerId == authStore.currentUser?.id ? 'player' : 'opponent'
})
})
onUnmounted(() => {
window.removeEventListener('beforeunload', handleBeforeUnload)
if (timerInterval.value) clearInterval(timerInterval.value)
wsStore.disconnect()
})
</script>
@@ -0,0 +1,362 @@
<script setup>
import { ref, onMounted, computed, inject, onUnmounted, watch } from 'vue'
import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useSocketStore } from '@/stores/socket'
import { Coins, Trophy, Frown } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import GameBoard from '@/components/game/GameBoard.vue'
import axios from 'axios'
import { toast } from 'vue-sonner'
const API_BASE_URL = inject('apiBaseURL')
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const wsStore = useSocketStore()
// --- FIX: Flag to prevent double cancellation ---
const isCancelling = ref(false)
const matchState = ref({
id: route.params.id,
wager: 0,
myMarks: 0,
oppMarks: 0,
currentGameId: null,
player1: null,
player2: null,
status: 'Pending'
})
const timeLeft = ref(20)
const timerInterval = ref(null)
const showSurrenderModal = ref(false)
const showTrickResult = ref(false)
const lastTrickWinner = ref(null)
const showRoundModal = ref(false)
const roundData = ref({ winnerId: null, marksAdded: 0, myPoints: 0, oppPoints: 0, reason: '' })
const myUserId = computed(() => authStore.currentUser?.id)
const amIHost = computed(() => matchState.value.player1?.id === myUserId.value)
const isPending = computed(() => !matchState.value.player2 || matchState.value.player2.id <= 1)
const matchOver = computed(() => matchState.value.myMarks >= 4 || matchState.value.oppMarks >= 4)
const iWonMatch = computed(() => matchState.value.myMarks > matchState.value.oppMarks)
const isMyTurn = computed(() => String(wsStore.gameState?.currentTurn) === String(myUserId.value))
const currentTurnLabel = computed(() => isMyTurn.value ? 'player' : 'opponent')
const myDisplayName = computed(() => amIHost.value ? matchState.value.player1?.nickname : matchState.value.player2?.nickname)
const opponentDisplayName = computed(() => amIHost.value ? matchState.value.player2?.nickname : matchState.value.player1?.nickname)
const opponentHand = computed(() => {
const count = wsStore.gameState?.opponent?.cardCount || 0
return Array.from({ length: count }, (_, i) => ({ id: `opp-${i}`, suit: 'back', rank: 0 }))
})
// Timer
watch(() => wsStore.gameState?.currentTurn, () => { startTimer() }, { deep: true })
const startTimer = () => {
if (timerInterval.value) clearInterval(timerInterval.value);
timeLeft.value = 20;
if(!wsStore.gameState) return;
timerInterval.value = setInterval(() => {
if(timeLeft.value > 0) timeLeft.value--;
else clearInterval(timerInterval.value);
}, 1000);
}
const handlePlayCard = (card) => {
if (wsStore.socket) wsStore.socket.emit('game:play-card', { matchId: matchState.value.id, cardId: card.id })
}
const openSurrenderModal = () => { showSurrenderModal.value = true }
const confirmSurrenderRound = () => {
showSurrenderModal.value = false;
if (wsStore.socket) wsStore.socket.emit('game:surrender', { matchId: matchState.value.id });
}
const confirmSurrenderMatch = () => {
showSurrenderModal.value = false;
if (wsStore.socket) wsStore.socket.emit('match:surrender', { matchId: matchState.value.id });
}
const closeRoundModal = () => { showRoundModal.value = false; }
const updateLocalState = (data) => {
if (!data) return
if (data.player1) matchState.value.player1 = data.player1
if (data.player2) matchState.value.player2 = data.player2
if (data.stake) matchState.value.wager = data.stake
matchState.value.status = data.status
if (data.marks) {
const myId = authStore.currentUser?.id;
const oppId = amIHost.value ? matchState.value.player2?.id : matchState.value.player1?.id;
matchState.value.myMarks = data.marks[myId] || 0;
matchState.value.oppMarks = data.marks[oppId] || 0;
}
if (data.game) {
matchState.value.currentGameId = data.game.id;
wsStore.gameState = data.game;
} else if (data.status === 'Playing') {
matchState.value.currentGameId = null;
wsStore.gameState = null;
}
}
// --- FIX: Cancel Match with Flag ---
const cancelMatch = async () => {
if (isCancelling.value) return;
isCancelling.value = true;
try {
await axios.delete(`${API_BASE_URL}/matches/${matchState.value.id}`)
router.push({ name: 'home' })
} catch (e) {
// Ignore 404s if we just deleted it
if (e.response && e.response.status === 404) {
router.push({ name: 'home' })
} else {
isCancelling.value = false; // Reset if legitimate error
alert(e.message)
}
}
}
const leaveMatch = () => router.push({ name: 'home' })
const handleBeforeUnload = (e) => {
if (matchState.value.status === 'Playing' && !matchOver.value) {
const msg = "If you leave now, you will lose the match and your wager.";
e.returnValue = msg;
return msg;
}
}
// --- FIX: Router Guard checks Flag ---
onBeforeRouteLeave((to, from, next) => {
// If we already clicked cancel, just let it go
if (isCancelling.value) {
return next();
}
if (matchState.value.status === 'Playing' && !matchOver.value) {
if (confirm("⚠️ WARNING: Leaving now triggers an automatic match surrender (Loss).\n\nAre you sure?")) {
if(wsStore.socket) wsStore.socket.emit('match:surrender', { matchId: matchState.value.id });
next();
} else {
next(false);
}
} else if (isPending.value && amIHost.value) {
if(confirm("Cancel this lobby?")) {
// Mark as cancelling so we don't trigger double requests if cancelMatch is weird
isCancelling.value = true;
cancelMatch().then(() => next());
} else {
next(false);
}
} else {
next();
}
})
onMounted(async () => {
window.addEventListener('beforeunload', handleBeforeUnload)
try {
const res = await axios.get(`${API_BASE_URL}/matches/${matchState.value.id}`)
const d = res.data.data || res.data
matchState.value.player1 = d.player1
matchState.value.player2 = d.player2
matchState.value.wager = d.stake
} catch (e) { }
wsStore.connect()
if (wsStore.socket) {
wsStore.socket.emit('match:enter', { matchId: matchState.value.id })
wsStore.socket.on('match:update', updateLocalState)
wsStore.socket.on('match:new-round', (data) => {
updateLocalState(data);
toast.info("New round started!");
})
wsStore.socket.on('match:ended', updateLocalState)
wsStore.socket.on('game:update', (state) => { wsStore.gameState = state })
wsStore.socket.on('game:trick-end', (result) => {
lastTrickWinner.value = result.winnerId;
showTrickResult.value = true;
setTimeout(() => { showTrickResult.value = false }, 2000);
});
wsStore.socket.on('match:round-ended', (data) => {
const myPoints = data.winnerId == myUserId.value ? (data.winnerId == matchState.value.player1.id ? data.p1Points : data.p2Points) : (data.winnerId == matchState.value.player1.id ? data.p2Points : data.p1Points);
const oppPoints = data.winnerId != myUserId.value ? (data.winnerId == matchState.value.player1.id ? data.p1Points : data.p2Points) : (data.winnerId == matchState.value.player1.id ? data.p2Points : data.p1Points);
roundData.value = {
winnerId: data.winnerId,
marksAdded: data.marksAdded,
myPoints: myPoints,
oppPoints: oppPoints,
reason: data.reason
};
showRoundModal.value = true;
});
}
})
onUnmounted(() => {
window.removeEventListener('beforeunload', handleBeforeUnload)
if (wsStore.socket) {
wsStore.socket.off('match:update')
wsStore.socket.off('match:new-round')
wsStore.socket.off('game:trick-end')
wsStore.socket.off('match:round-ended')
}
wsStore.disconnect()
})
</script>
<template>
<div class="relative min-h-screen bg-green-900 text-white overflow-hidden">
<div class="fixed top-0 left-0 right-0 z-40 bg-green-950/90 backdrop-blur border-b border-green-800 p-2 shadow-xl">
<div class="max-w-4xl mx-auto flex items-center justify-between">
<div class="flex flex-col items-start">
<div class="flex items-center gap-2">
<span class="font-bold text-lg text-white drop-shadow-md">{{ myDisplayName }}</span>
<span v-if="matchState.wager > 0" class="flex items-center text-xs text-yellow-300 bg-yellow-900/50 px-2 rounded border border-yellow-700/50">
<Coins class="w-3 h-3 mr-1" /> {{ matchState.wager }}
</span>
</div>
<div class="flex gap-1 mt-1">
<div v-for="i in 4" :key="i" class="w-3 h-3 rounded-full border border-green-400 bg-green-900"
:class="{ '!bg-green-400 shadow-[0_0_10px_rgba(74,222,128,0.5)]': matchState.myMarks >= i }"></div>
</div>
</div>
<div class="flex flex-col items-center">
<span class="text-[10px] uppercase tracking-widest text-green-400 font-bold">Match #{{ matchState.id }}</span>
<span class="text-2xl font-black font-mono text-white drop-shadow-md">{{ matchState.myMarks }} - {{ matchState.oppMarks }}</span>
</div>
<div class="flex flex-col items-end">
<span class="font-bold text-lg text-white drop-shadow-md">{{ opponentDisplayName }}</span>
<div class="flex gap-1 mt-1">
<div v-for="i in 4" :key="i" class="w-3 h-3 rounded-full border border-red-400 bg-green-900"
:class="{ '!bg-red-500 shadow-[0_0_10px_rgba(239,68,68,0.5)]': matchState.oppMarks >= i }"></div>
</div>
</div>
</div>
</div>
<div v-if="wsStore.gameState && !matchOver" class="absolute top-24 right-4 z-50">
<button @click="openSurrenderModal" class="bg-red-600 hover:bg-red-700 text-white font-bold py-2 px-4 rounded-lg shadow-lg border-2 border-red-800 transition-all hover:scale-105 flex items-center gap-2 text-sm cursor-pointer">
<span>🏳</span> Surrender
</button>
</div>
<div v-if="wsStore.gameState && !matchOver && isMyTurn" class="absolute top-24 left-1/2 -translate-x-1/2 z-50">
<div class="bg-gray-800 px-6 py-2 rounded-lg border-2 shadow-xl transition-colors duration-300" :class="timeLeft <= 5 ? 'border-red-500 bg-red-900/20' : 'border-gray-700'">
<div class="flex items-center gap-3">
<svg class="w-5 h-5" :class="timeLeft <= 5 ? 'text-red-500 animate-bounce' : 'text-white'" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<circle cx="12" cy="12" r="10" stroke-width="2" />
<polyline points="12 6 12 12 16 14" stroke-width="2" />
</svg>
<span :class="['text-2xl font-mono font-bold', timeLeft <= 5 ? 'text-red-500' : 'text-white']">{{ timeLeft }}</span>
</div>
</div>
</div>
<div class="pt-20 h-screen">
<GameBoard
v-if="wsStore.gameState && !matchOver"
:game-id="matchState.currentGameId"
@play-card="handlePlayCard"
:trump-card="wsStore.gameState?.trumpCard"
:cards-remaining="wsStore.gameState?.deckCount"
:player-hand="wsStore.gameState?.hand"
:opponent-hand="opponentHand"
:player-score="wsStore.gameState?.points"
:opponent-score="wsStore.gameState?.opponent?.points"
:current-turn="currentTurnLabel"
:current-trick="wsStore.gameState?.table"
:is-my-turn="isMyTurn"
:player-name="myDisplayName"
:opponent-name="opponentDisplayName"
/>
<div v-else-if="isPending" class="flex h-full items-center justify-center flex-col gap-6">
<div class="text-center p-8 bg-green-800/60 rounded-2xl border border-green-600 shadow-2xl backdrop-blur-md max-w-sm w-full mx-4">
<div class="relative mx-auto mb-6 h-16 w-16">
<div class="absolute inset-0 rounded-full border-4 border-green-700/50"></div>
<div class="absolute inset-0 rounded-full border-4 border-t-white border-r-transparent border-b-transparent border-l-transparent animate-spin"></div>
</div>
<h2 class="text-2xl font-bold mb-2 text-white">Waiting for Opponent</h2>
<button v-if="amIHost" @click="cancelMatch" class="bg-red-600 hover:bg-red-700 text-white py-2 px-4 rounded shadow mt-4">Cancel Match</button>
</div>
</div>
<div v-else-if="!matchOver && !wsStore.gameState" class="flex h-full items-center justify-center flex-col gap-4">
<div class="text-3xl font-bold animate-pulse text-white drop-shadow">Preparing next round...</div>
</div>
<div v-if="matchOver" class="absolute inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-md">
<div class="text-center space-y-6 p-8 bg-green-900 border-2 border-green-500 rounded-2xl shadow-2xl max-w-md w-full">
<Trophy v-if="iWonMatch" class="w-24 h-24 text-yellow-400 mx-auto animate-bounce" />
<Frown v-else class="w-24 h-24 text-green-300 mx-auto opacity-75" />
<h1 class="text-5xl font-black uppercase mb-2" :class="iWonMatch ? 'text-yellow-400' : 'text-gray-300'">{{ iWonMatch ? 'Victory!' : 'Defeat' }}</h1>
<p class="text-green-100 text-lg">Marks: <span class="font-bold">{{ matchState.myMarks }} - {{ matchState.oppMarks }}</span></p>
<Button @click="leaveMatch" class="w-full py-6 text-lg bg-purple-600 hover:bg-purple-700">Return to Lobby</Button>
</div>
</div>
</div>
<div v-if="showRoundModal" class="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-md">
<div class="bg-gray-800 p-8 rounded-2xl border-2 border-yellow-500/50 shadow-2xl max-w-md w-full text-center relative overflow-hidden">
<div class="absolute top-0 left-0 w-full h-2 bg-gradient-to-r from-transparent via-yellow-500 to-transparent"></div>
<h2 class="text-3xl font-black text-white mb-2 uppercase">Round Over</h2>
<p class="text-gray-400 mb-6 uppercase text-xs tracking-widest">{{ roundData.reason === 'surrender' ? 'Opponent Surrendered' : 'Points Limit Reached' }}</p>
<div class="flex justify-between items-center bg-black/30 p-4 rounded-lg mb-6">
<div class="text-left">
<p class="text-xs text-gray-500 uppercase">You</p>
<p class="text-2xl font-bold text-white">{{ roundData.myPoints }} pts</p>
</div>
<div class="text-xl font-bold text-yellow-500">VS</div>
<div class="text-right">
<p class="text-xs text-gray-500 uppercase">Opponent</p>
<p class="text-2xl font-bold text-white">{{ roundData.oppPoints }} pts</p>
</div>
</div>
<div class="mb-8">
<p class="text-lg text-white" v-if="roundData.winnerId == myUserId">
You won <span class="text-yellow-400 font-bold">+{{ roundData.marksAdded }} marks</span>!
</p>
<p class="text-lg text-white" v-else>
Opponent won <span class="text-red-400 font-bold">+{{ roundData.marksAdded }} marks</span>.
</p>
</div>
<button @click="closeRoundModal" class="w-full py-4 bg-emerald-600 hover:bg-emerald-500 text-white font-bold rounded-xl shadow-lg transition-transform hover:scale-105">
Ready for Next Round
</button>
</div>
</div>
<div v-if="showSurrenderModal" class="fixed inset-0 z-[100] flex items-center justify-center bg-black/70 backdrop-blur-sm">
<div class="bg-gray-800 p-6 rounded-lg border border-gray-600 shadow-2xl max-w-sm w-full text-center">
<h3 class="text-xl font-bold text-white mb-6">Surrender Options</h3>
<div class="space-y-3">
<button @click="confirmSurrenderRound" class="w-full px-4 py-3 bg-gray-700 hover:bg-gray-600 rounded text-white font-bold border border-gray-500 flex justify-between items-center group">
<span>Surrender Round</span>
<span class="text-xs bg-black/40 px-2 py-1 rounded text-gray-300 group-hover:bg-red-500 group-hover:text-white">-2 Marks</span>
</button>
<button @click="confirmSurrenderMatch" class="w-full px-4 py-3 bg-red-900/50 hover:bg-red-600 rounded text-red-200 hover:text-white font-bold border border-red-800 flex justify-between items-center group">
<span>Surrender Match</span>
<span class="text-xs bg-black/40 px-2 py-1 rounded text-red-300 group-hover:bg-red-800 group-hover:text-white">Defeat</span>
</button>
</div>
<button @click="showSurrenderModal = false" class="mt-6 text-gray-400 hover:text-white text-sm underline">Cancel</button>
</div>
</div>
<Transition enter-active-class="transition-all duration-300 ease-out" enter-from-class="opacity-0 scale-50" enter-to-class="opacity-100 scale-100" leave-active-class="transition-all duration-200 ease-in" leave-from-class="opacity-100 scale-100" leave-to-class="opacity-0 scale-75">
<div v-if="showTrickResult" class="fixed top-1/3 left-1/2 -translate-x-1/2 z-[70] pointer-events-none">
<div class="bg-black/70 backdrop-blur-md px-8 py-4 rounded-2xl border border-white/10 shadow-2xl text-center">
<p class="text-sm uppercase tracking-widest text-gray-300 font-bold mb-1">Trick Winner</p>
<h2 class="text-3xl font-black" :class="lastTrickWinner === myUserId ? 'text-green-400' : 'text-red-400'">{{ lastTrickWinner === myUserId ? 'YOU' : 'OPPONENT' }}</h2>
</div>
</div>
</Transition>
</div>
</template>
@@ -1,47 +1,33 @@
<template> <template>
<div class="relative min-h-screen overflow-hidden"> <div class="relative min-h-screen overflow-hidden">
<div v-if="store.isGameRunning" class="absolute top-4 right-4 z-50"> <div v-if="store.isGameRunning" class="absolute top-4 right-4 z-50">
<button <button @click="handleSurrender"
@click="handleSurrender" class="bg-red-600 hover:bg-red-700 text-white font-bold py-2 px-4 rounded-lg shadow-lg border-2 border-red-800 transition-all transform hover:scale-105 flex items-center gap-2">
class="bg-red-600 hover:bg-red-700 text-white font-bold py-2 px-4 rounded-lg shadow-lg border-2 border-red-800 transition-all transform hover:scale-105 flex items-center gap-2"
>
<span>🏳</span> Surrender <span>🏳</span> Surrender
</button> </button>
</div> </div>
<div v-if="store.isGameRunning"> <div v-if="store.isGameRunning">
<GameBoard <GameBoard :trump-card="store.trumpCard" :cards-remaining="store.deck.length + (store.trumpCard ? 1 : 0)"
:trump-card="store.trumpCard" :player-hand="store.playerHand" :opponent-hand="store.opponentHand" :player-score="store.playerScore"
:cards-remaining="store.deck.length + (store.trumpCard ? 1 : 0)" :opponent-score="store.opponentScore" :current-turn="'player'" :is-my-turn="true" :current-trick="store.table"
:player-hand="store.playerHand" :player-name="'You'" :opponent-name="'CPU'" @play-card="handlePlayCard" />
:opponent-hand="store.opponentHand"
:player-score="store.playerScore"
:opponent-score="store.opponentScore"
:current-turn="store.currentTurn"
:current-trick="store.table"
@play-card="handlePlayCard"
/>
</div> </div>
<div v-else class="flex h-screen items-center justify-center bg-green-900 text-white"> <div v-else class="flex h-screen items-center justify-center bg-green-900 text-white">
<div class="animate-pulse text-xl">Preparing game...</div> <div class="animate-pulse text-xl">Preparing game...</div>
</div> </div>
<GameOver
:is-visible="store.isGameOver" <GameOver :is-visible="store.isGameOver" :winner="store.winner || 'draw'" :player-score="store.playerScore"
:winner="store.winner || 'draw'"
:player-score="store.playerScore"
:opponent-score="store.opponentScore" :opponent-score="store.opponentScore"
:stats="{ playerTricks: store.playerTricks, opponentTricks: store.opponentTricks }" :stats="{ playerTricks: store.playerTricks, opponentTricks: store.opponentTricks }"
:is-logging-out="store.isLoggingOut" :is-logging-out="store.isLoggingOut" @play-again="handlePlayAgain" @close="handleCloseModal" />
@play-again="handlePlayAgain"
@close="handleCloseModal"
/>
</div> </div>
</template> </template>
<script setup> <script setup>
import { onMounted, onUnmounted, onBeforeMount } from 'vue' import { onMounted, onUnmounted, onBeforeMount, watch } from 'vue'
import { useRouter, onBeforeRouteLeave } from 'vue-router' import { useRouter, onBeforeRouteLeave } from 'vue-router'
import { toast } from 'vue-sonner' // Import Toast for feedback import { toast } from 'vue-sonner' // Import Toast for feedback
import { useBiscaStore } from '@/stores/bisca' import { useBiscaStore } from '@/stores/bisca'
@@ -60,6 +46,11 @@ const store = useBiscaStore()
const router = useRouter() const router = useRouter()
const authStore = useAuthStore() const authStore = useAuthStore()
// DEBUG: Watch the store state
watch(() => store.playerHand, (newHand) => {
console.log('Player hand updated:', newHand)
}, { deep: true })
onBeforeMount(() => { onBeforeMount(() => {
store.isGameOver = false store.isGameOver = false
}) })
@@ -67,6 +58,15 @@ onBeforeMount(() => {
onMounted(() => { onMounted(() => {
store.startGame(props.gameType) store.startGame(props.gameType)
window.addEventListener('beforeunload', handleBrowserUnload) window.addEventListener('beforeunload', handleBrowserUnload)
// DEBUG: Log initial state
setTimeout(() => {
console.log('Game state after init:', {
playerHand: store.playerHand,
currentTurn: store.currentTurn,
isGameRunning: store.isGameRunning
})
}, 500)
}) })
onUnmounted(() => { onUnmounted(() => {
+380 -361
View File
@@ -1,17 +1,24 @@
<script setup> <script setup>
import { User, Trophy } from 'lucide-vue-next' import { User, Trophy, Coins } from 'lucide-vue-next'
import { ref, onMounted, nextTick, inject } from 'vue' import { ref, onMounted, nextTick, inject, computed } from 'vue'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { watch } from 'vue';
import { import {
Card, Card,
CardContent, CardContent,
CardDescription, CardDescription,
CardHeader, CardHeader,
CardTitle CardTitle
} from '@/components/ui/card' } from '@/components/ui/card'
import { useAPIStore } from '@/stores/api' import { useAPIStore } from '@/stores/api'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useUserStore } from '@/stores/user'
import axios from 'axios'
import { toast } from 'vue-sonner'
const API_BASE_URL = inject('apiBaseURL') const API_BASE_URL = inject('apiBaseURL')
const apiStore = useAPIStore() const apiStore = useAPIStore()
@@ -22,6 +29,11 @@ const gObserverTarget = ref(null);
const gObserver = ref(null); const gObserver = ref(null);
const gLoading = ref(false); const gLoading = ref(false);
const gPage = ref(1); const gPage = ref(1);
const openMatches = ref([])
const mObserverTarget = ref(null);
const mObserver = ref(null);
const mLoading = ref(false);
const mPage = ref(1);
const showHostModal = ref(false) const showHostModal = ref(false)
const showJoinModal = ref(false) const showJoinModal = ref(false)
const selectedGame = ref(null) const selectedGame = ref(null)
@@ -37,421 +49,428 @@ const lbObserverTarget = ref(null)
const lbObserver = ref(null) const lbObserver = ref(null)
const lbMorePages = ref(true) const lbMorePages = ref(true)
const publicStats = ref(null) const publicStats = ref(null)
const isLoggedIn = useAuthStore().isLoggedIn
const isBestOfThree = ref(false)
const wagerAmount = ref(0)
const authStore = useAuthStore()
const userStore = useUserStore()
const userLoggedIn = computed(() => authStore.isLoggedIn)
const userCoins = ref(0)
const hostLoading = ref(false)
const gHasMore = ref(true);
const mHasMore = ref(true);
const setupMatchesObserver = () => {
if (mObserver.value) mObserver.value.disconnect();
mObserver.value = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && !mLoading.value && openMatches.value.length > 0) {
getPendingMatches();
}
}, { threshold: 0.1 });
if (mObserverTarget.value) {
mObserver.value.observe(mObserverTarget.value);
}
}
const setupGamesObserver = () => { const setupGamesObserver = () => {
if (gObserver.value) gObserver.value.disconnect(); if (gObserver.value) gObserver.value.disconnect();
gObserver.value = new IntersectionObserver((entries) => { gObserver.value = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && !gLoading.value && openGames.value.length > 0) { if (entries[0].isIntersecting && !gLoading.value && openGames.value.length > 0) {
getPendingGames(); getPendingGames();
}
}, { threshold: 0.1 });
if (gObserverTarget.value) {
gObserver.value.observe(gObserverTarget.value);
} }
}, { threshold: 0.1 });
if (gObserverTarget.value) {
gObserver.value.observe(gObserverTarget.value);
}
} }
const getPendingGames = async (mode = selectedMode.value) => { const getPendingGames = async (mode = selectedMode.value) => {
if (gLoading.value) return; if (gLoading.value || !gHasMore.value) return;
gLoading.value = true; gLoading.value = true;
apiStore.gameQueryParameters.page = gPage.value++; try {
apiStore.gameQueryParameters.filters.sort_direction = "asc" const response = await axios.get(`${API_BASE_URL}/games/open`, {
apiStore.gameQueryParameters.filters.type = mode params: { type: mode, page: gPage.value }
apiStore.gameQueryParameters.filters.status = 'Pending' });
try { const newGames = response.data.data;
const response = await apiStore.getGames(); if (newGames.length === 0) {
const newGames = response.data.data; gHasMore.value = false;
openGames.value = [...openGames.value, ...newGames]; } else {
} finally { openGames.value = [...openGames.value, ...newGames];
gLoading.value = false; gPage.value++;
} }
} finally {
gLoading.value = false;
}
}
const getPendingMatches = async (mode = selectedMode.value) => {
if (mLoading.value || !mHasMore.value) return;
mLoading.value = true;
try {
const response = await axios.get(`${API_BASE_URL}/matches/open`, {
params: { type: mode, page: mPage.value }
});
const newMatches = response.data.data;
if (newMatches.length === 0) {
mHasMore.value = false;
} else {
openMatches.value = [...openMatches.value, ...newMatches];
mPage.value++;
}
} finally {
mLoading.value = false;
}
} }
const fetchPublicStats = async () => { const fetchPublicStats = async () => {
try { try {
const response = await apiStore.getPublicStats() const response = await apiStore.getPublicStats()
publicStats.value = response.data publicStats.value = response.data
} catch (error) { } catch (error) {
console.error("Failed to fetch public stats", error) console.error("Failed to fetch public stats", error)
} }
} }
const openStats = async () => { const openStats = async () => {
showStatsModal.value = true showStatsModal.value = true
statsLoading.value = true statsLoading.value = true
try { try {
const response = await apiStore.getCurrentUserStats() const response = await apiStore.getCurrentUserStats()
userStats.value = response.data userStats.value = response.data
} catch (error) { } catch (error) {
console.error("Failed to fetch stats", error) console.error("Failed to fetch stats", error)
} finally { } finally {
statsLoading.value = false statsLoading.value = false
} }
} }
const getAvatarUrl = (filename) => { const getAvatarUrl = (filename) => {
return filename return filename
? `${API_BASE_URL.replace('/api/v1', '').replace('v1/')}/storage/photos_avatars/${filename}` ? `${API_BASE_URL.replace('/api/v1', '').replace('v1/', '')}/storage/photos_avatars/${filename}`
: `${API_BASE_URL.replace('/api/v1', '').replace('v1/')}/storage/photos_avatars/anonymous.png`; : `${API_BASE_URL.replace('/api/v1', '').replace('v1/', '')}/storage/photos_avatars/anonymous.png`;
} }
const fetchLeaderboard = async () => { const fetchLeaderboard = async () => {
if (lbLoading.value || !lbMorePages.value) return; if (lbLoading.value || !lbMorePages.value) return;
lbLoading.value = true; lbLoading.value = true;
try { try {
const response = await apiStore.getLeaderboard({ page: lbPage.value }); const response = await apiStore.getLeaderboard({ page: lbPage.value });
const newEntries = response.data.data; const newEntries = response.data.data;
if (newEntries.length < 10) {
if (newEntries.length < 10) { lbMorePages.value = false;
lbMorePages.value = false;
}
leaderboardEntries.value = [...leaderboardEntries.value, ...newEntries];
lbPage.value++;
} finally {
lbLoading.value = false;
} }
leaderboardEntries.value = [...leaderboardEntries.value, ...newEntries];
lbPage.value++;
} finally {
lbLoading.value = false;
}
} }
const openLeaderboard = async () => { const openLeaderboard = async () => {
leaderboardEntries.value = []; leaderboardEntries.value = [];
lbPage.value = 1; lbPage.value = 1;
showLeaderboardModal.value = true; showLeaderboardModal.value = true;
await nextTick(); await nextTick();
setupLeaderboardObserver(); setupLeaderboardObserver();
await fetchLeaderboard(); await fetchLeaderboard();
} }
const setupLeaderboardObserver = () => { const setupLeaderboardObserver = () => {
if (lbObserver.value) lbObserver.value.disconnect(); if (lbObserver.value) lbObserver.value.disconnect();
lbObserver.value = new IntersectionObserver((entries) => { lbObserver.value = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && !lbLoading.value && leaderboardEntries.value.length > 0) { if (entries[0].isIntersecting && !lbLoading.value && leaderboardEntries.value.length > 0) {
fetchLeaderboard(); fetchLeaderboard();
}
}, { threshold: 0.1 });
if (lbObserverTarget.value) {
lbObserver.value.observe(lbObserverTarget.value);
} }
}, { threshold: 0.1 });
if (lbObserverTarget.value) {
lbObserver.value.observe(lbObserverTarget.value);
}
} }
const clickMode = async (mode) => { const clickMode = async (mode) => {
if (selectedMode.value === mode) return; if (selectedMode.value === mode) return;
openGames.value = []; selectedMode.value = mode;
selectedMode.value = mode; openGames.value = [];
gPage.value = 1; openMatches.value = [];
await getPendingGames(); gPage.value = 1;
setupGamesObserver(); mPage.value = 1;
gHasMore.value = true;
mHasMore.value = true;
await Promise.all([getPendingGames(), getPendingMatches()]);
setupGamesObserver();
setupMatchesObserver();
} }
const startSingle = () => { const startSingle = () => {
router.push({ name: 'bisca' + selectedMode.value }); router.push({ name: 'bisca' + selectedMode.value });
} }
const JoinGameModal = (game) => { // --- FIX: Correct Wager Logic ---
selectedGame.value = game const joinMatch = async (match) => {
isReady.value = false // Use 'stake' because that's what the API returns
showJoinModal.value = true const cost = match.stake || match.wager || 0;
if (cost > userCoins.value) {
toast.error(`Insufficient funds! You need ${cost} coins.`);
return;
}
if (!confirm(`Join match for ${cost} coins?`)) {
return;
}
try {
await axios.post(`${API_BASE_URL}/matches/${match.id}/join`)
router.push({
name: 'multiplayer-match',
params: { id: match.id },
query: { type: selectedMode.value }
})
} catch (error) {
toast.error(error.response?.data?.message || 'Failed to join match');
}
} }
const hostGameModal = () => { const hostGame = async () => {
showHostModal.value = true if (isBestOfThree.value) {
if (wagerAmount.value <= 0) {
toast.error("Stake must be at least 1 coin.");
return;
}
if (wagerAmount.value > userCoins.value) {
toast.error("Insufficient coins!");
return;
}
}
hostLoading.value = true;
const endpoint = isBestOfThree.value ? '/matches/host' : '/games/host'
const payload = isBestOfThree.value
? { type: parseInt(selectedMode.value), stake: parseInt(wagerAmount.value) }
: { type: parseInt(selectedMode.value) }
try {
const response = await axios.post(`${API_BASE_URL}${endpoint}`, payload)
const resultId = response.data.match?.id || response.data.id || response.data.data?.id;
if (!resultId) throw new Error("Missing ID");
showHostModal.value = false;
router.push({
name: isBestOfThree.value ? 'multiplayer-match' : 'multiplayer-game',
params: { id: resultId },
query: { type: selectedMode.value }
})
} catch (error) {
if (error.response?.data?.message) {
toast.error(error.response.data.message);
} else {
toast.error('Failed to host game');
}
} finally {
hostLoading.value = false;
}
} }
const toggleReady = () => { const joinGame = async (game) => {
isReady.value = !isReady.value try {
await axios.post(`${API_BASE_URL}/games/${game.id}/join`)
router.push({
name: 'multiplayer-game',
params: { id: game.id },
query: { type: selectedMode.value }
})
} catch (error) {
toast.error(error.response?.data?.message || 'Failed to join game');
}
} }
const openHostModal = (forMatch) => {
isBestOfThree.value = forMatch;
showHostModal.value = true;
wagerAmount.value = 0;
}
watch(() => userLoggedIn, async (isLoggedIn) => {
if (isLoggedIn) {
await userStore.fetchCoins()
userCoins.value = userStore.coins
} else {
userStore.coins = 0
}
}, { immediate: true })
onMounted(async () => { onMounted(async () => {
await fetchPublicStats(); await fetchPublicStats();
await getPendingGames(); if (isLoggedIn) {
await Promise.all([getPendingGames(), getPendingMatches()]);
await nextTick(); await nextTick();
setupGamesObserver(); setupGamesObserver();
setupMatchesObserver();
}
}) })
</script> </script>
<template> <template>
<div class="min-h-screen bg-cover bg-center transition-all duration-700"> <div class="min-h-screen bg-cover bg-center transition-all duration-700">
<div class="flex flex-col justify-center items-center gap-5 pt-10 pb-10 px-4">
<div class="flex flex-col justify-center items-center gap-5 pt-10 px-4"> <Card v-if="publicStats" class="w-full max-w-2xl border-[#a855f7] bg-[#f8f0ff] shadow-sm">
<CardContent class="p-4">
<div class="flex justify-around items-center">
<div class="text-center">
<p class="text-xs uppercase font-bold opacity-70">Players</p>
<p class="text-xl font-black">{{ publicStats.total_players }}</p>
</div>
<div class="h-8 w-[1px] bg-[#a855f7]/20"></div>
<div class="text-center">
<p class="text-xs uppercase font-bold opacity-70">Total Games</p>
<p class="text-xl font-black">{{ publicStats.total_games }}</p>
</div>
<div class="h-8 w-[1px] bg-[#a855f7]/20"></div>
<div class="text-center">
<p class="text-xs uppercase font-bold opacity-70">Active</p>
<p class="text-xl font-black">{{ publicStats.active_games }}</p>
</div>
</div>
</CardContent>
</Card>
<Card v-if="publicStats" class="w-full max-w-2xl border-[#a855f7] bg-[#f8f0ff] shadow-sm"> <div class="relative w-full max-w-md bg-card rounded-lg border shadow-sm overflow-hidden">
<CardContent class="p-4"> <div class="relative h-14 flex items-center">
<div class="flex justify-around items-center"> <button @click="clickMode('9')"
<div class="text-center"> class="h-full flex items-center justify-center font-semibold transition-all duration-500 ease-in-out border-r last:border-r-0"
<p class="text-xs uppercase font-bold opacity-70">Players</p> :class="{ 'w-2/3 z-10 text-purple-600 bg-purple-500/10': selectedMode === '9', 'w-1/3 z-0 text-muted-foreground/40': selectedMode !== '9' }">
<p class="text-xl font-black">{{ publicStats.total_players }}</p> <span>Bisca de 9</span>
</div> </button>
<div class="h-8 w-[1px] bg-[#a855f7]/20"></div> <button @click="clickMode('3')"
<div class="text-center"> class="h-full flex items-center justify-center font-semibold transition-all duration-500 ease-in-out"
<p class="text-xs uppercase font-bold opacity-70">Total Games</p> :class="{ 'w-2/3 z-10 text-purple-600 bg-purple-500/10': selectedMode === '3', 'w-1/3 z-0 text-muted-foreground/40': selectedMode !== '3' }">
<p class="text-xl font-black">{{ publicStats.total_games }}</p> <span>Bisca de 3</span>
</div> </button>
<div class="h-8 w-[1px] bg-[#a855f7]/20"></div> </div>
<div class="text-center"> </div>
<p class="text-xs uppercase font-bold opacity-70">Active</p>
<p class="text-xl font-black">{{ publicStats.active_games }}</p>
</div>
</div>
</CardContent>
</Card>
<div class="relative w-full max-w-md bg-card rounded-lg border shadow-sm overflow-hidden"> <div class="flex flex-col items-center gap-5 w-[100vw]">
<div class="relative h-14 flex items-center"> <Card class="w-full max-w-2xl">
<CardHeader>
<CardTitle class="text-3xl font-bold text-center">Single Player</CardTitle>
<CardDescription class="text-center">Go against one of our bots!</CardDescription>
</CardHeader>
<CardContent class="space-y-6">
<div class="flex justify-center">
<Button @click="startSingle()" size="lg" variant="secondary" class="hover:bg-purple-500 hover:text-slate-200">
Start Game
</Button>
</div>
</CardContent>
</Card>
<button @click="clickMode('9')" <Card class="w-full max-w-2xl flex flex-col">
class="h-full flex items-center justify-center font-semibold transition-all duration-500 ease-in-out border-r last:border-r-0" <CardHeader>
:class="{ <CardTitle class="text-3xl font-bold text-center">Multi Player</CardTitle>
'w-2/3 z-10 text-purple-600 dark:text-purple-400 bg-purple-500/10': selectedMode === '9', <CardDescription class="text-center">Go one on one with another player!</CardDescription>
'w-1/3 z-0 text-muted-foreground/40 bg-transparent': selectedMode !== '9' </CardHeader>
}"> <CardContent class="flex-1 flex flex-col space-y-6">
<span>Bisca de 9</span>
</button>
<button @click="clickMode('3')"
class="h-full flex items-center justify-center font-semibold transition-all duration-500 ease-in-out"
:class="{
'w-2/3 z-10 text-purple-600 dark:text-purple-400 bg-purple-500/10': selectedMode === '3',
'w-1/3 z-0 text-muted-foreground/40 bg-transparent': selectedMode !== '3'
}">
<span>Bisca de 3</span>
</button>
<div class="flex flex-col flex-1">
<div class="flex justify-between items-end mb-2">
<label class="text-sm font-bold uppercase tracking-wider text-muted-foreground">Open Matches (Best of 3)</label>
<Button v-if="isLoggedIn" @click="openHostModal(true)" size="sm" variant="outline" class="border-purple-500 text-purple-500 hover:bg-purple-500 hover:text-white font-bold">
Host
</Button>
</div>
<div class="rounded-lg border bg-card text-card-foreground shadow-sm">
<div v-if="openMatches.length === 0" class="p-6 text-center text-sm text-muted-foreground">
No open matches.
</div> </div>
<div v-else class="max-h-[350px] overflow-y-auto custom-scrollbar p-1">
<div v-for="(match, index) in openMatches" :key="match.id" @click="joinMatch(match)"
class="flex items-center justify-between p-3 mb-2 border border-transparent rounded-lg cursor-pointer transition-all hover:bg-muted/50 hover:border-purple-500">
<div class="flex items-center gap-3">
<div class="flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold bg-red-100 text-red-700">
{{ index + 1 }}
</div>
<div>
<div class="font-medium text-sm flex items-center gap-2">
Match #{{ match.id }}
<span class="flex items-center text-xs bg-yellow-100 text-yellow-700 px-2 py-0.5 rounded-full">
<Coins class="w-3 h-3 mr-1" /> {{ match.stake || match.wager }}
</span>
</div>
<div class="text-[10px] text-muted-foreground">Host: {{ match.player1?.nickname || 'Unknown' }}</div>
</div>
</div>
</div>
<div ref="mObserverTarget" class="h-10 flex items-center justify-center">
<span v-if="mLoading" class="text-xs text-purple-500 animate-pulse">Loading...</span>
</div>
</div>
</div>
</div> </div>
<div class="flex flex-col items-center gap-5 w-[100vw]"> <div class="flex flex-col flex-1">
<Card class="w-full max-w-2xl"> <div class="flex justify-between items-end mb-2">
<CardHeader> <label class="text-sm font-medium uppercase tracking-wider text-muted-foreground">Open Games (Single)</label>
<CardTitle class="text-3xl font-bold text-center"> <Button v-if="isLoggedIn" @click="openHostModal(false)" size="sm" variant="outline" class="border-purple-500 text-purple-500 hover:bg-purple-500 hover:text-white font-bold">
Single Player Host
</CardTitle> </Button>
<CardDescription class="text-center"> </div>
Go against one of our bots! <div class="rounded-lg border bg-card text-card-foreground shadow-sm">
</CardDescription> <div v-if="openGames.length === 0" class="p-6 text-center text-sm text-muted-foreground">
</CardHeader> No open games.
<CardContent class="space-y-6"> </div>
<div class="flex justify-center"> <div v-else class="max-h-[350px] overflow-y-auto custom-scrollbar p-1">
<Button @click="startSingle()" size="lg" variant="secondary" <div v-for="(game, index) in openGames" :key="game.id" @click="joinGame(game)"
class="hover:bg-purple-500 hover:text-slate-200"> class="flex items-center justify-between p-3 mb-2 border border-transparent rounded-lg cursor-pointer transition-all hover:bg-muted/50 hover:border-purple-500">
Start Game <div class="flex items-center gap-3">
</Button> <div class="flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold bg-green-100 text-green-700">
</div> {{ index + 1 }}
</CardContent> </div>
</Card> <div>
<Card class="w-full max-w-2xl"> <div class="font-medium text-sm">Host: {{ game.player1?.nickname || 'Anonymous' }}</div>
<CardHeader> </div>
<CardTitle class="text-3xl font-bold text-center"> </div>
MultiPlayer </div>
</CardTitle> <div ref="gObserverTarget" class="h-10 flex items-center justify-center">
<CardDescription class="text-center"> <span v-if="gLoading" class="text-xs text-purple-500 animate-pulse">Loading...</span>
Go one on one with another player! </div>
</CardDescription> </div>
</CardHeader> </div>
<CardContent class="space-y-6">
<label class="text-sm font-medium">Open games (oldest first)</label>
<div class="rounded-lg border bg-card text-card-foreground shadow-sm">
<div v-if="openGames.length === 0" class="p-6 text-center text-sm text-muted-foreground">
No open games yet. Try hosting one!
</div>
<div v-else class="max-h-[350px] overflow-y-auto custom-scrollbar p-1">
<div v-for="(game, index) in openGames" :key="game.id" @click="JoinGameModal(game)"
class="flex items-center justify-between p-3 mb-2 border border-transparent rounded-lg transition-all hover:bg-muted/50 hover:border-purple-500">
<div class="flex items-center gap-3">
<div
class="flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold bg-green-100 text-green-700">
{{ index + 1 }}
</div>
<div>
<div class="font-medium text-sm">Started: {{ game.began_at }}</div>
<div class="text-xs text-muted-foreground">host: {{
game.player1?.nickname || 'Anonymous' }}</div>
</div>
</div>
</div>
<div ref="gObserverTarget" class="h-10 flex items-center justify-center">
<span v-if="gLoading" class="text-xs text-purple-500 animate-pulse">Loading
more...</span>
</div>
</div>
</div>
<div class="flex justify-center">
<Button @click="hostGameModal()" size="lg" variant="secondary"
class="hover:bg-purple-500 hover:text-slate-200">
Host Game
</Button>
</div>
</CardContent>
</Card>
</div> </div>
</div>
<div v-if="showHostModal"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<Card class="w-full max-w-sm border-purple-500 shadow-2xl">
<CardHeader>
<CardTitle class="text-center">Hosting Game</CardTitle>
</CardHeader>
<CardContent class="flex flex-col items-center gap-6 py-10">
<div class="relative">
<div
class="h-16 w-16 animate-spin rounded-full border-4 border-purple-500 border-t-transparent">
</div>
</div>
<p class="animate-pulse text-lg font-medium text-muted-foreground">Waiting for opponents...</p>
<Button variant="outline" @click="showHostModal = false">Cancel Hosting</Button>
</CardContent>
</Card>
</div>
<div v-if="showJoinModal"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<Card class="w-full max-w-md border-purple-500 shadow-2xl">
<CardHeader>
<CardTitle>Join Game</CardTitle>
<CardDescription v-if="selectedGame">
Hosted by {{ selectedGame.player1?.nickname || 'Anonymous' }}
</CardDescription>
</CardHeader>
<CardContent class="space-y-4"> </CardContent>
<div class="rounded-lg bg-muted p-4 text-sm"> </Card>
<p><strong>Game Type:</strong> Bisca de {{ selectedMode }}</p> </div>
<p><strong>Started at:</strong> {{ selectedGame?.began_at }}</p> </div>
</div>
<div v-if="showHostModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div v-if="isReady" <Card class="w-full max-w-sm border-purple-500 shadow-2xl">
class="flex items-center justify-center rounded-md bg-green-500/10 p-3 text-green-600 dark:text-green-400"> <CardHeader><CardTitle class="text-center">{{ isBestOfThree ? 'Host Match (Best of 3)' : 'Host Game' }}</CardTitle></CardHeader>
<span class="animate-pulse font-semibold">Waiting for the host to start...</span> <CardContent class="flex flex-col gap-6 py-4">
</div> <div v-if="isBestOfThree" class="space-y-2">
<Label>Wager (Coins)</Label>
<div class="flex gap-3 pt-4"> <div class="relative">
<Button variant="destructive" class="flex-1" @click="showJoinModal = false"> <Coins class="absolute left-3 top-2.5 h-4 w-4 text-yellow-500" />
Leave <Input v-model="wagerAmount" type="number" min="0" class="pl-9" />
</Button> </div>
<p class="text-xs text-muted-foreground text-right">Balance: {{ userCoins }}</p>
<Button class="flex-1 transition-all duration-300" :variant="isReady ? 'outline' : 'default'" </div>
:class="!isReady ? 'bg-purple-600 hover:bg-purple-700' : ''" @click="toggleReady"> <div class="flex gap-2">
{{ isReady ? 'Cancel Ready' : 'Ready up' }} <Button variant="outline" class="flex-1" @click="showHostModal = false" :disabled="hostLoading">Cancel</Button>
</Button> <Button class="flex-1 bg-purple-600 hover:bg-purple-700" @click="hostGame" :disabled="hostLoading">
</div> {{ hostLoading ? 'Creating...' : 'Create' }}
</CardContent> </Button>
</Card> </div>
</div> </CardContent>
</Card>
<div v-if="showStatsModal"
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<Card class="w-full max-w-sm max-h-2/3 border-purple-500 shadow-2xl">
<CardHeader class="border-b bg-muted/20">
<div class="flex justify-between items-center">
<CardTitle class="flex items-center gap-2">
<User class="text-yellow-500" /> My Statistics
</CardTitle>
<Button variant="ghost" size="sm" @click="showStatsModal = false">X</Button>
</div>
</CardHeader>
<CardContent class="py-6">
<div v-if="statsLoading" class="flex flex-col items-center py-10 gap-4">
<div class="h-8 w-8 animate-spin rounded-full border-4 border-purple-500 border-t-transparent">
</div>
<p class="text-sm text-muted-foreground">Calculating your glory...</p>
</div>
<div v-else-if="userStats" class="space-y-6">
<div class="text-center">
<h3 class="text-2xl font-bold text-purple-600">{{ userStats.nickname }}</h3>
<p class="text-sm text-muted-foreground">Performance Overview</p>
</div>
<div class="grid grid-cols-2 gap-4">
<div
class="rounded-lg bg-muted/50 p-4 text-center border border-transparent hover:border-purple-500/30 transition-all">
<p class="text-xs uppercase text-muted-foreground font-semibold">Total Games</p>
<p class="text-2xl font-bold">{{ userStats.total_games }}</p>
</div>
<div class="rounded-lg bg-purple-500/10 p-4 text-center border border-purple-500/20">
<p class="text-xs uppercase text-purple-600 font-semibold">Win Rate</p>
<p class="text-2xl font-bold text-purple-600">{{ userStats.win_rate }}</p>
</div>
<div class="rounded-lg bg-green-500/10 p-4 text-center border border-green-500/20">
<p class="text-xs uppercase text-green-600 font-semibold">Wins</p>
<p class="text-2xl font-bold text-green-600">{{ userStats.total_wins }}</p>
</div>
<div class="rounded-lg bg-red-500/10 p-4 text-center border border-red-500/20">
<p class="text-xs uppercase text-red-600 font-semibold">Losses</p>
<p class="text-2xl font-bold text-red-600">{{ userStats.total_losses }}</p>
</div>
</div>
<div class="space-y-2">
<div class="flex justify-between text-xs font-medium">
<span>Win/Loss Ratio</span>
<span>{{ userStats.total_wins }}W - {{ userStats.total_losses }}L</span>
</div>
<div class="h-2 w-full bg-red-500/20 rounded-full overflow-hidden flex">
<div class="bg-green-500 h-full" :style="{ width: userStats.win_rate }"></div>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
<div v-if="showLeaderboardModal"
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<Card class="w-full max-w-lg max-h-1/2 h-[80vh] flex flex-col border-purple-500 shadow-2xl">
<CardHeader class="border-b">
<div class="flex justify-between items-center">
<CardTitle class="flex items-center gap-2">
<Trophy class="text-purple-500" /> Leaderboard
</CardTitle>
<Button variant="ghost" size="sm" @click="showLeaderboardModal = false">X</Button>
</div>
</CardHeader>
<CardContent class="flex-1 overflow-y-auto custom-scrollbar p-0">
<div v-for="(user, index) in leaderboardEntries" :key="user.id"
class="flex items-center justify-between p-4 border-b last:border-0 hover:bg-muted/30 transition-colors">
<div class="flex items-center gap-4">
<span class="font-bold text-muted-foreground w-6 text-center"
:class="{ 'text-yellow-500': index === 0, 'text-slate-400': index === 1, 'text-orange-400': index === 2 }">
#{{ index + 1 }}
</span>
<img :src="getAvatarUrl(user.photo_avatar_filename)"
class="h-10 w-10 rounded-full border border-purple-200 object-cover" alt="Avatar" />
<div>
<p class="font-semibold text-sm">{{ user.nickname }}</p>
<p class="text-[10px] text-muted-foreground uppercase tracking-wider">Player</p>
</div>
</div>
<div class="text-right">
<p class="text-lg font-bold text-purple-600">{{ user.won_games_count }}</p>
<p class="text-[10px] text-muted-foreground uppercase">Wins</p>
</div>
</div>
<div ref="lbObserverTarget" class="h-20 flex items-center justify-center">
<span v-if="lbLoading" class="text-purple-500 animate-bounce">Loading rankings...</span>
</div>
</CardContent>
</Card>
</div>
</div> </div>
<div class="fixed right-4 top-1/2 -translate-y-1/2 flex flex-col gap-4 z-40">
<Button @click="openLeaderboard" variant="outline" size="icon"
class="h-12 w-12 rounded-full shadow-lg border-purple-500 bg-card hover:bg-purple-500 hover:text-white transition-all">
<Trophy class="h-6 w-6" />
</Button>
<Button v-if="useAuthStore().isLoggedIn" @click="openStats" variant="outline" size="icon"
class="h-12 w-12 rounded-full shadow-lg border-purple-500 bg-card hover:bg-purple-500 hover:text-white transition-all">
<User class="h-6 w-6" />
</Button>
</div> </div>
</template> </template>
File diff suppressed because it is too large Load Diff
+15 -36
View File
@@ -1,14 +1,10 @@
import HomePage from '@/pages/home/HomePage.vue' import HomePage from '@/pages/home/HomePage.vue'
import LoginPage from '@/pages/login/LoginPage.vue' import LoginPage from '@/pages/login/LoginPage.vue'
import LaravelPage from '@/pages/testing/LaravelPage.vue'
import WebsocketsPage from '@/pages/testing/WebsocketsPage.vue'
import UserPage from '@/pages/user/UserPage.vue' import UserPage from '@/pages/user/UserPage.vue'
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import TestAnimations from '@/pages/TestAnimations.vue'
import TestDealing from '@/pages/TestDealing.vue'
import TestAllAnimations from '@/pages/TestAllAnimations.vue'
import TestGameBoard from '@/pages/TestGameBoard.vue'
import SinglePlayerGamePage from '@/pages/game/SinglePlayerGamePage.vue' import SinglePlayerGamePage from '@/pages/game/SinglePlayerGamePage.vue'
import MultiplayerGamePage from '@/pages/game/MultiplayerGamePage.vue'
import MultiplayerMatchPage from '@/pages/game/MultiplayerMatchPage.vue'
import RegisterPage from '@/pages/register/RegisterPage.vue' import RegisterPage from '@/pages/register/RegisterPage.vue'
import CoinsPurchasePage from '@/pages/purchase/CoinPurchasePage.vue' import CoinsPurchasePage from '@/pages/purchase/CoinPurchasePage.vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
@@ -21,7 +17,7 @@ const router = createRouter({
path: '/', path: '/',
name: 'home', name: 'home',
component: HomePage, component: HomePage,
meta: { requiresAdmin: false } meta: { requiresAdmin: false },
}, },
{ {
path: '/login', path: '/login',
@@ -47,6 +43,18 @@ const router = createRouter({
props: { gameType: 9 }, props: { gameType: 9 },
meta: { requiresAuth: true }, meta: { requiresAuth: true },
}, },
{
path: '/game/multiplayer/:id',
name: 'multiplayer-game',
component: MultiplayerGamePage,
meta: { requiresAuth: true },
},
{
path: '/match/:id',
name: 'multiplayer-match',
component: MultiplayerMatchPage,
meta: { requiresAuth: true }
},
{ {
path: '/user', path: '/user',
component: UserPage, component: UserPage,
@@ -63,35 +71,6 @@ const router = createRouter({
component: AdminPage, component: AdminPage,
meta: { requiresAuth: true, requiresAdmin: true }, meta: { requiresAuth: true, requiresAdmin: true },
}, },
{
path: '/testing',
children: [
{
path: 'laravel',
component: LaravelPage,
},
{
path: 'websockets',
component: WebsocketsPage,
},
{
path: 'animations',
component: TestAnimations,
},
{
path: 'dealing',
component: TestDealing,
},
{
path: 'all-animations',
component: TestAllAnimations,
},
{
path: 'gameboard',
component: TestGameBoard,
},
],
},
], ],
}) })
+31 -1
View File
@@ -76,6 +76,23 @@ export const useAPIStore = defineStore('api', () => {
return axios.get(`${API_BASE_URL}/games?${queryParams}`) return axios.get(`${API_BASE_URL}/games?${queryParams}`)
} }
// --- ADICIONADO: Função que faltava ---
const getCurrentUserGames = (params = {}) => {
const queryParams = new URLSearchParams({
page: params.page || 1,
...(params.type && { type: params.type }),
...(params.outcome && { outcome: params.outcome }),
...(params.status && { status: params.status }),
...(params.date_from && { date_from: params.date_from }),
...(params.date_to && { date_to: params.date_to }),
sort_by: params.sort_by || 'began_at',
sort_direction: params.sort_direction || 'desc',
}).toString()
return axios.get(`${API_BASE_URL}/users/${authStore.currentUserID}/games?${queryParams}`)
}
// --------------------------------------
const getCurrentUserMatches = (params = {}) => { const getCurrentUserMatches = (params = {}) => {
const queryParams = new URLSearchParams({ const queryParams = new URLSearchParams({
page: params.page || 1, page: params.page || 1,
@@ -157,19 +174,31 @@ export const useAPIStore = defineStore('api', () => {
...(params.sort_direction == 'asc' && { sort_direction: 'asc' }), ...(params.sort_direction == 'asc' && { sort_direction: 'asc' }),
...(params.type && { type: params.type }), ...(params.type && { type: params.type }),
}).toString() }).toString()
return axios.get(`${API_BASE_URL}/games?${queryParams}`) return axios.get(`${API_BASE_URL}/games?${queryParams}`)
} }
const getAdminMatches = (params = {}) => {
const queryParams = new URLSearchParams({
page: params.page || 1,
...(params.sort_direction == 'asc' && { sort_direction: 'asc' }),
...(params.type && { type: params.type }),
...(params.pot && { pot: params.pot }),
}).toString()
return axios.get(`${API_BASE_URL}/matches?${queryParams}`)
}
const postAdmin = async (credentials) => { const postAdmin = async (credentials) => {
return axios.post(`${API_BASE_URL}/admin/users`, credentials) return axios.post(`${API_BASE_URL}/admin/users`, credentials)
} }
return { return {
postLogin, postLogin,
postLogout, postLogout,
getAuthUser, getAuthUser,
getGames, getGames,
getCurrentUserGames,
getCurrentUserMatches, getCurrentUserMatches,
getCurrentUserTransactions, getCurrentUserTransactions,
getCurrentUserStats, getCurrentUserStats,
@@ -182,6 +211,7 @@ export const useAPIStore = defineStore('api', () => {
deleteUser, deleteUser,
getAdminTransactions, getAdminTransactions,
getAdminGames, getAdminGames,
getAdminMatches,
postAdmin, postAdmin,
gameQueryParameters, gameQueryParameters,
} }
+120 -15
View File
@@ -1,24 +1,129 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { inject } from 'vue' import { ref } from 'vue'
import { io } from 'socket.io-client'
import { useAuthStore } from './auth'
export const useSocketStore = defineStore('socket', () => { export const useSocketStore = defineStore('websocket', () => {
const socket = inject('socket') const authStore = useAuthStore()
const handleConnection = () => { const socket = ref(null)
try { const connected = ref(false)
socket.on('connect', () => { const gameState = ref(null)
console.log(`[Socket] Connected -- ${socket.id}`) const lastError = ref(null)
}) const currentGameId = ref(null)
socket.on('disconnect', () => {
console.log(`[Socket] Disconnected -- ${socket.id}`) const WS_URL = 'http://localhost:3000'
})
} catch (error) { const connect = () => {
console.error('[Socket] Connection error:', error) if (socket.value?.connected) return
const token = authStore.getToken()
if (!token) {
console.error('No token available for WebSocket connection')
return
}
socket.value = io(WS_URL, {
auth: { token: `Bearer ${token}` },
reconnection: true,
reconnectionAttempts: 5,
reconnectionDelay: 1000
})
socket.value.on('connect', () => {
if (currentGameId.value) {
console.log("Reconnected to socket. Rejoining game...");
socket.emit("join-game", { gameId: currentGameId.value });
}
connected.value = true
console.log('[WebSocket] Connected:', socket.value.id)
})
socket.value.on('disconnect', (reason) => {
connected.value = false
console.log('[WebSocket] Disconnected:', reason)
})
socket.value.on('connect_error', (error) => {
console.error('[WebSocket] Connection error:', error.message)
lastError.value = error.message
})
socket.value.on('heartbeat', () => {
socket.value.emit('heartbeat_ack')
})
socket.value.on('game-state', (state) => {
gameState.value = state
})
socket.value.on('game-error', (data) => {
lastError.value = data.message
console.error('[Game] Error:', data.message)
})
socket.value.on('error', (data) => {
lastError.value = data.message
console.error('[Socket] Error:', data.message)
})
}
const disconnect = () => {
if (socket.value) {
socket.value.disconnect()
socket.value = null
connected.value = false
gameState.value = null
lastError.value = null
currentGameId.value = null
}
}
const joinGame = (gameId) => {
currentGameId.value = gameId
if (!socket.value?.connected) return
console.log('[Game] Joining game:', gameId)
socket.value.emit('join-game', { gameId })
}
const playCard = (gameId, cardId) => {
if (!socket.value?.connected) return
socket.value.emit('play-card', { gameId, cardId })
}
const surrender = (gameId) => {
if (!socket.value?.connected) return
console.log('[Game] Surrendering:', gameId)
socket.value.emit('surrender', { gameId })
}
const onTrickEnd = (callback) => {
if (socket.value) {
socket.value.off('trick-end')
socket.value.on('trick-end', callback)
}
}
const onGameOver = (callback) => {
if (socket.value) {
socket.value.off('game-over')
socket.value.on('game-over', callback)
} }
} }
return { return {
handleConnection, socket,
connected,
gameState,
lastError,
currentGameId,
connect,
disconnect,
joinGame,
playCard,
surrender, // Exported
onTrickEnd,
onGameOver
} }
}) })
+6
View File
@@ -0,0 +1,6 @@
{
"name": "DADProject",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}
+29 -17
View File
@@ -1,7 +1,9 @@
class BiscaGame { class BiscaGame {
constructor(id, type, player1, player2) { constructor(id, type, player1, player2, dbId = null, onGameEnd = null) {
this.id = id; this.id = id;
this.dbId = dbId;
this.type = type; this.type = type;
this.onGameEnd = onGameEnd;
this.deck = []; this.deck = [];
this.trumpCard = null; this.trumpCard = null;
@@ -17,7 +19,7 @@ class BiscaGame {
}; };
this.currentTurn = null; this.currentTurn = null;
this.status = 'pending'; this.status = "pending";
this.startTime = null; this.startTime = null;
this.players = { this.players = {
@@ -46,11 +48,14 @@ class BiscaGame {
this.dealInitialCards(); this.dealInitialCards();
const playerIds = Object.keys(this.players); // --- FIX: Ensure currentTurn uses the original ID type (Number) ---
this.currentTurn = playerIds.find((id) => id != 1) || playerIds[0]; // Object.keys returns strings ("1"), which breaks strict checks against user.id (1)
const playerKeys = Object.keys(this.players);
const startKey = playerKeys.find((key) => key != 1) || playerKeys[0];
this.currentTurn = this.players[startKey].id;
this.startTime = Date.now(); this.startTime = Date.now();
this.status = 'playing'; this.status = "playing";
} }
startTurnTimer(callback) { startTurnTimer(callback) {
@@ -75,7 +80,7 @@ class BiscaGame {
handleTimeout() { handleTimeout() {
console.log( console.log(
`[Game ${this.id}] Timeout! Auto-playing for ${this.currentTurn}`, `[Game ${this.id}] Timeout! Auto-playing for ${this.currentTurn}`
); );
const lowestCard = this.getLowestCard(this.currentTurn); const lowestCard = this.getLowestCard(this.currentTurn);
@@ -94,7 +99,7 @@ class BiscaGame {
} }
createDeck() { createDeck() {
const suits = ['c', 'e', 'o', 'p']; const suits = ["c", "e", "o", "p"];
const ranks = [1, 2, 3, 4, 5, 6, 7, 11, 12, 13]; const ranks = [1, 2, 3, 4, 5, 6, 7, 11, 12, 13];
let deck = []; let deck = [];
@@ -130,7 +135,7 @@ class BiscaGame {
} }
playCard(userId, cardId) { playCard(userId, cardId) {
if (this.currentTurn != userId) return { error: 'Not your turn!' }; if (this.currentTurn != userId) return { error: "Not your turn!" };
const player = this.players[userId]; const player = this.players[userId];
const cardIndex = player.hand.findIndex((c) => c.id === cardId); const cardIndex = player.hand.findIndex((c) => c.id === cardId);
@@ -144,7 +149,7 @@ class BiscaGame {
const suitToFollow = this.table.playerCard.suit; const suitToFollow = this.table.playerCard.suit;
const hasSuit = player.hand.some((c) => c.suit === suitToFollow); const hasSuit = player.hand.some((c) => c.suit === suitToFollow);
if (hasSuit && card.suit !== suitToFollow) { if (hasSuit && card.suit !== suitToFollow) {
return { error: 'You must follow the suit!' }; return { error: "You must follow the suit!" };
} }
} }
@@ -156,7 +161,7 @@ class BiscaGame {
this.table.playerCard = card; this.table.playerCard = card;
this.table.firstPlayerId = userId; this.table.firstPlayerId = userId;
this.currentTurn = this.getOpponentId(userId); this.currentTurn = this.getOpponentId(userId);
return { action: 'next_turn' }; return { action: "next_turn" };
} else { } else {
this.table.opponentCard = card; this.table.opponentCard = card;
return this.resolveTrick(); return this.resolveTrick();
@@ -187,7 +192,8 @@ class BiscaGame {
this.players[winnerId].tricks += 1; this.players[winnerId].tricks += 1;
this.currentTurn = winnerId; this.currentTurn = winnerId;
if (this.type == 3) { // --- FIX: Always draw if deck has cards (Removed type check) ---
if (this.deck.length > 0 || this.trumpCard) {
this.drawCard(winnerId); this.drawCard(winnerId);
this.drawCard(this.getOpponentId(winnerId)); this.drawCard(this.getOpponentId(winnerId));
} }
@@ -196,7 +202,7 @@ class BiscaGame {
this.table = { playerCard: null, opponentCard: null, firstPlayerId: null }; this.table = { playerCard: null, opponentCard: null, firstPlayerId: null };
if (this.players[winnerId].hand.length === 0) { if (this.players[winnerId].hand.length === 0) {
this.status = 'finished'; this.status = "finished";
this.stopTimer(); this.stopTimer();
const pIds = Object.keys(this.players); const pIds = Object.keys(this.players);
@@ -216,11 +222,11 @@ class BiscaGame {
const finalLoser = isDraw const finalLoser = isDraw
? null ? null
: gameWinnerId == p1Obj.id : gameWinnerId == p1Obj.id
? p2Obj.id ? p2Obj.id
: p1Obj.id; : p1Obj.id;
return { const result = {
action: 'game_ended', action: "game_ended",
trickResult, trickResult,
winnerId: finalWinner, winnerId: finalWinner,
loserId: finalLoser, loserId: finalLoser,
@@ -231,15 +237,21 @@ class BiscaGame {
player2_points: p2Obj.points, player2_points: p2Obj.points,
totalTime: totalTime, totalTime: totalTime,
}; };
if (this.onGameEnd) {
this.onGameEnd(result);
}
return result;
} }
return { action: 'trick_resolved', trickResult }; return { action: "trick_resolved", trickResult };
} }
getStateForPlayer(userId) { getStateForPlayer(userId) {
const oppId = this.getOpponentId(userId); const oppId = this.getOpponentId(userId);
return { return {
id: this.id, id: this.id,
dbId: this.dbId,
trumpCard: this.trumpCard, trumpCard: this.trumpCard,
trumpSuit: this.trumpSuit, trumpSuit: this.trumpSuit,
deckCount: this.deck.length + (this.trumpCard ? 1 : 0), deckCount: this.deck.length + (this.trumpCard ? 1 : 0),
+239
View File
@@ -0,0 +1,239 @@
import BiscaGame from "../classes/BiscaGame.js";
import axios from "axios";
const API_URL = "http://localhost:8000/api/v1";
class BiscaMatch {
// FIX: Added autoPlayCallback parameter
constructor(apiData, emitStateCallback, token, autoPlayCallback) {
this.id = apiData.id;
this.emitStateCallback = emitStateCallback;
this.autoPlayCallback = autoPlayCallback;
this.token = token;
this.stake = apiData.stake;
this.type = apiData.type;
this.status = apiData.status;
this.player1Id = apiData.player1_user_id;
this.player2Id = apiData.player2_user_id;
this.player1 = apiData.player1;
this.player2 = apiData.player2;
this.marks = {
[this.player1Id]: apiData.player1_marks || 0,
[this.player2Id]: apiData.player2_marks || 0,
};
this.points = {
[this.player1Id]: apiData.player1_points || 0,
[this.player2Id]: apiData.player2_points || 0,
};
this.currentGame = null;
this.GHOST_ID = 1;
if (this.status === "Playing" && this.player2Id !== this.GHOST_ID) {
this.startNewGame();
}
}
async joinPlayer(userId, user) {
if (this.player2Id !== this.GHOST_ID) return false;
if (userId === this.player1Id) return false;
this.player2Id = userId;
this.player2 = user;
this.marks[userId] = 0;
this.points[userId] = 0;
this.status = "Playing";
await this.startNewGame();
return true;
}
getAuthHeaders() {
return {
headers: {
Authorization: this.token,
"Content-Type": "application/json",
"Accept": "application/json"
},
};
}
async startNewGame() {
console.log(`[Match ${this.id}] Starting new round...`);
let newGameDbId = null;
try {
const payload = {
type: this.type,
player1_user_id: this.player1Id,
player2_user_id: this.player2Id,
match_id: this.id,
status: "Playing",
began_at: new Date().toISOString(),
};
const res = await axios.post(`${API_URL}/games`, payload, this.getAuthHeaders());
const gameData = res.data.game || res.data.data || res.data;
newGameDbId = gameData.id;
const p1Name = this.player1?.nickname || `User ${this.player1Id}`;
const p2Name = this.player2?.nickname || `User ${this.player2Id}`;
this.currentGame = new BiscaGame(
this.id,
this.type,
{ id: this.player1Id, name: p1Name },
{ id: this.player2Id, name: p2Name },
newGameDbId,
(result) => { this.handleGameEnd(result); }
);
this.currentGame.init();
// FIX: Start Timer & Connect Callback
this.currentGame.startTurnTimer((uid, cid) => {
if (this.autoPlayCallback) this.autoPlayCallback(uid, cid);
});
if (this.emitStateCallback) {
this.emitStateCallback("match:new-round-start", null);
}
} catch (e) {
console.error(`❌ Start Game Error: ${e.message}`);
}
}
// --- FIX: Close DB Game row on surrender ---
async abortCurrentGame(loserId) {
if (this.currentGame && this.currentGame.dbId) {
console.log(`[Match ${this.id}] Aborting Game #${this.currentGame.dbId}`);
try {
// Determine winner for the DB record based on who DIDN'T lose
const winnerId = loserId == this.player1Id ? this.player2Id : this.player1Id;
const payload = {
status: "Ended",
winner_user_id: winnerId,
loser_user_id: loserId,
is_draw: false,
ended_at: new Date().toISOString(),
};
await axios.put(`${API_URL}/games/${this.currentGame.dbId}`, payload, this.getAuthHeaders());
} catch(e) { console.error("Error aborting game:", e.message); }
}
}
async handleGameEnd(result) {
console.log(`[Match ${this.id}] Round ended. Winner: ${result.winnerId}`);
if (this.points[result.player1_id] !== undefined) this.points[result.player1_id] += result.player1_points;
if (this.points[result.player2_id] !== undefined) this.points[result.player2_id] += result.player2_points;
if (this.currentGame && this.currentGame.dbId) {
try {
const payload = {
status: "Ended",
winner_user_id: result.winnerId,
loser_user_id: result.loserId,
is_draw: result.isDraw,
player1_points: result.player1_points,
player2_points: result.player2_points,
ended_at: new Date().toISOString(),
};
await axios.put(`${API_URL}/games/${this.currentGame.dbId}`, payload, this.getAuthHeaders());
} catch (e) { console.error("DB Game Update Error:", e.message); }
}
let marksToAdd = 0;
let roundWinnerId = result.winnerId;
if (roundWinnerId) {
const winningScore = roundWinnerId == result.player1_id ? result.player1_points : result.player2_points;
if (winningScore === 120) marksToAdd = 4;
else if (winningScore > 90) marksToAdd = 2;
else if (winningScore >= 60) marksToAdd = 1;
if (this.marks[roundWinnerId] !== undefined) this.marks[roundWinnerId] += marksToAdd;
}
if (this.emitStateCallback) {
this.emitStateCallback("match:round-ended", {
winnerId: roundWinnerId,
marksAdded: marksToAdd,
p1Points: result.player1_points,
p2Points: result.player2_points,
p1Marks: this.marks[this.player1Id],
p2Marks: this.marks[this.player2Id],
reason: result.reason || 'points'
});
}
if (this.marks[this.player1Id] >= 4 || this.marks[this.player2Id] >= 4) {
await this.finishMatch();
if (this.emitStateCallback) this.emitStateCallback("match:ended-signal", null);
} else {
setTimeout(() => {
this.startNewGame();
}, 1000);
}
}
async finishMatch() {
this.status = "Ended";
const winnerId = this.marks[this.player1Id] >= 4 ? this.player1Id : this.player2Id;
const loserId = winnerId == this.player1Id ? this.player2Id : this.player1Id;
const payload = {
status: "Ended",
winner_user_id: winnerId,
loser_user_id: loserId,
player1_marks: this.marks[this.player1Id],
player2_marks: this.marks[this.player2Id],
player1_points: this.points[this.player1Id],
player2_points: this.points[this.player2Id],
};
try {
await axios.put(`${API_URL}/matches/${this.id}`, payload, this.getAuthHeaders());
console.log(`[Match ${this.id}] Match Closed in DB.`);
} catch (e) {
console.error(`Match Close Error: ${e.message}`);
}
}
playCard(userId, cardId) {
if (!this.currentGame || this.status !== "Playing") return;
const result = this.currentGame.playCard(userId, cardId);
// FIX: Restart timer if game continues
if (result && (result.action === 'next_turn' || result.action === 'trick_resolved')) {
this.currentGame.startTurnTimer((uid, cid) => {
if (this.autoPlayCallback) this.autoPlayCallback(uid, cid);
});
}
return result;
}
getGameState(userId) {
if (!this.currentGame) return null;
if (this.currentGame.players[userId]) return this.currentGame.getStateForPlayer(userId);
return null;
}
getPublicState() {
return {
matchId: this.id,
status: this.status,
marks: this.marks,
points: this.points,
player1: this.player1,
player2: this.player2
};
}
}
export default BiscaMatch;
+8 -8
View File
@@ -1,4 +1,4 @@
import { addUser, removeUser } from '../state/connection.js'; import { addUser, removeUser } from "../state/connection.js";
const HEARTBEAT_INTERVAL = 30000; // 30 seconds const HEARTBEAT_INTERVAL = 30000; // 30 seconds
const HEARTBEAT_TIMEOUT = 10000; // 10 seconds const HEARTBEAT_TIMEOUT = 10000; // 10 seconds
@@ -8,36 +8,36 @@ export const handleConnectionEvents = (io, socket) => {
let heartbeatTimeout; let heartbeatTimeout;
const startHeartbeat = () => { const startHeartbeat = () => {
heartbeatInterval = setInterval(() => { heartbeatInterval = setInterval(() => {
socket.emit('heartbeat'); socket.emit("heartbeat");
heartbeatTimeout = setTimeout(() => { heartbeatTimeout = setTimeout(() => {
console.log( console.log(
`[Heartbeat] No response from ${socket.id}, disconnecting...`, `[Heartbeat] No response from ${socket.id}, disconnecting...`
); );
socket.disconnect(true); socket.disconnect(true);
}, HEARTBEAT_TIMEOUT); }, HEARTBEAT_TIMEOUT);
}, HEARTBEAT_INTERVAL); }, HEARTBEAT_INTERVAL);
}; };
socket.on('heartbeat_ack', () => { socket.on("heartbeat_ack", () => {
clearTimeout(heartbeatTimeout); clearTimeout(heartbeatTimeout);
console.log(`[Heartbeat] Received pong from ${socket.id}`); console.log(`[Heartbeat] Received pong from ${socket.id}`);
}); });
socket.on('join', (user) => { socket.on("join", (user) => {
addUser(socket, user); addUser(socket, user);
console.log(`[Connection] User ${user.name} has joined the server`); console.log(`[Connection] User ${user.name} has joined the server`);
}); });
socket.on('leave', () => { socket.on("leave", () => {
const user = removeUser(socket.id); const user = removeUser(socket.id);
if (user) { if (user) {
console.log(`[Connection] User ${user.name} has left the server`); console.log(`[Connection] User ${user.name} has left the server`);
} }
}); });
socket.on('disconnect', () => { socket.on("disconnect", () => {
console.log('Connection Lost:', socket.id); console.log("Connection Lost:", socket.id);
removeUser(socket.id); removeUser(socket.id);
}); });
}; };
+192 -199
View File
@@ -4,6 +4,71 @@ import { getUser } from "../state/connection.js";
const API_URL = "http://localhost:8000/api/v1"; const API_URL = "http://localhost:8000/api/v1";
// --- HELPER: Card Strength for Auto-Play ---
const getStrength = (rank) => {
const strengthMap = { 1: 10, 7: 9, 13: 8, 11: 7, 12: 6, 6: 5, 5: 4, 4: 3, 3: 2, 2: 1 };
return strengthMap[rank] || 0;
};
// --- HELPER: Draw Card Logic ---
const drawCard = (game, playerId) => {
const player = game.players[playerId];
if (!player) return;
if (game.deck && game.deck.length > 0) {
const card = game.deck.pop();
player.hand.push(card);
} else if (game.trumpCard) {
player.hand.push(game.trumpCard);
game.trumpCard = null;
}
};
// --- HELPER: Sanitize Data ---
const sanitizeState = (state, game) => {
if (!state) return null;
const p1 = game.player1 || state.player1 || {};
const p2 = game.player2 || state.player2 || {};
return {
id: state.id,
status: state.status || game.status,
player1: { id: p1.id, name: p1.name },
player2: { id: p2.id, name: p2.name },
players: state.players,
hand: state.hand,
points: state.points,
opponent: {
id: state.opponent?.id,
name: state.opponent?.name,
points: state.opponent?.points,
cardCount: state.opponent?.cardCount
},
table: state.table,
trumpCard: state.trumpCard,
deckCount: game.deck ? game.deck.length : (state.deckCount || 0),
currentTurn: state.currentTurn,
turnStartedAt: state.turnStartedAt
};
};
const broadcastState = (io, gameId, game) => {
const roomName = `game_${gameId}`;
const socketsInRoom = io.sockets.adapter.rooms.get(roomName);
if (socketsInRoom) {
for (const socketId of socketsInRoom) {
const s = io.sockets.sockets.get(socketId);
const u = getUser(socketId);
if (u && game.players[u.id]) {
const rawState = game.getStateForPlayer(u.id);
const cleanState = sanitizeState(rawState, game);
s.emit("game-state", cleanState);
}
}
}
}
async function saveGameResult(gameId, result, token) { async function saveGameResult(gameId, result, token) {
try { try {
const payload = { const payload = {
@@ -13,90 +78,106 @@ async function saveGameResult(gameId, result, token) {
player1_points: result.player1_points, player1_points: result.player1_points,
player2_points: result.player2_points, player2_points: result.player2_points,
}; };
if (!result.isDraw && result.winnerId) { if (!result.isDraw && result.winnerId) {
payload.winner_user_id = result.winnerId; payload.winner_user_id = result.winnerId;
payload.loser_user_id = result.loserId; payload.loser_user_id = result.loserId;
} }
await axios.put(`${API_URL}/games/${gameId}`, payload, { await axios.put(`${API_URL}/games/${gameId}`, payload, {
headers: { Authorization: token }, headers: { Authorization: token },
}); });
console.log(`[DB] Game ${gameId} saved.`);
} catch (error) { } catch (error) {
console.error(`[DB Error] Game ${gameId}:`, error.message); console.error(`[DB Error] Game ${gameId}:`, error.message);
} }
} }
// --- SHARED END GAME LOGIC ---
const performGameEnd = (io, game, gameId, winnerId, loserId, reason) => {
const result = {
action: "game_ended",
winnerId: parseInt(winnerId),
loserId: parseInt(loserId),
totalTime: game.startTime ? Math.floor((Date.now() - game.startTime) / 1000) : 0,
isDraw: false,
player1_points: game.players[Object.keys(game.players)[0]]?.points || 0,
player2_points: game.players[Object.keys(game.players)[1]]?.points || 0,
reason: reason
};
game.status = "ended";
if (game.timer) clearInterval(game.timer);
io.to(`game_${gameId}`).emit("game-over", {
winnerId: result.winnerId,
isDraw: result.isDraw,
p1Points: result.player1_points,
p2Points: result.player2_points,
message: reason === "surrender" ? "Opponent Surrendered!" : "Game Over"
});
const anyId = Object.keys(game.players)[0];
const token = game.players[anyId]?.token;
if (token) saveGameResult(gameId, result, token);
}
const handleTimeout = (io, gameId, userId) => {
const game = getGame(gameId);
if (!game || game.status === 'ended') return;
console.log(`[Timeout] User ${userId} auto-playing lowest card.`);
const player = game.players[userId];
if (!player || !player.hand || player.hand.length === 0) return;
const trumpSuit = game.trumpCard?.suit || '';
const sortedHand = [...player.hand].sort((a, b) => {
const strengthA = getStrength(a.rank) + (a.suit === trumpSuit ? 100 : 0);
const strengthB = getStrength(b.rank) + (b.suit === trumpSuit ? 100 : 0);
return strengthA - strengthB;
});
const cardToPlay = sortedHand[0];
if (cardToPlay) {
handleGameMove(io, gameId, userId, cardToPlay.id);
}
};
const handleGameMove = (io, gameId, userId, cardId) => { const handleGameMove = (io, gameId, userId, cardId) => {
const game = getGame(gameId); const game = getGame(gameId);
if (!game) return; if (!game) return;
const result = game.playCard(userId, cardId); const result = game.playCard(userId, cardId);
if (result.error) {
console.log(`[Game Error] ${result.error}`);
return;
}
const roomName = `game_${gameId}`; const roomName = `game_${gameId}`;
const socketsInRoom = io.sockets.adapter.rooms.get(roomName);
if (socketsInRoom) { broadcastState(io, gameId, game);
for (const socketId of socketsInRoom) {
const s = io.sockets.sockets.get(socketId);
const u = getUser(socketId);
if (u && game.players[u.id]) {
s.emit("game-state", game.getStateForPlayer(u.id));
}
}
}
if (result.action === "trick_resolved") { if (result.action === "trick_resolved") {
io.to(roomName).emit("trick-end", result.trickResult); io.to(roomName).emit("trick-end", result.trickResult);
setTimeout(() => {
if (game.status !== "ended") {
const canDraw = (game.deck && game.deck.length > 0) || game.trumpCard !== null;
if (canDraw) {
const winnerId = result.trickResult.winnerId;
const loserId = Object.keys(game.players).find(id => String(id) !== String(winnerId));
if (winnerId) drawCard(game, winnerId);
if (loserId) drawCard(game, loserId);
}
broadcastState(io, gameId, game);
}
}, 1500);
} }
if (result.action === "game_ended") { if (result.action === "game_ended") {
io.to(roomName).emit("game-over", { performGameEnd(io, game, gameId, result.winnerId, result.loserId, "points");
winnerId: result.winnerId,
isDraw: result.isDraw,
p1Points: result.player1_points,
p2Points: result.player2_points,
});
let tokenToUse = null;
const playerIds = Object.keys(game.players);
for (const pid of playerIds) {
if (game.players[pid] && game.players[pid].token) {
tokenToUse = game.players[pid].token;
break;
}
}
if (tokenToUse) {
saveGameResult(gameId, result, tokenToUse);
console.log(`[DB] Jogo salvo usando o token de um dos jogadores.`);
} else {
console.error(
`[DB Error] CRÍTICO: Nenhum jogador tem token. O jogo ${gameId} não foi salvo.`
);
}
} }
if (result.action === "next_turn" || result.action === "trick_resolved") { if (result.action === "next_turn" || result.action === "trick_resolved") {
game.startTurnTimer((timeoutUserId, timeoutCardId) => { game.startTurnTimer((timeoutUserId) => {
handleGameMove(io, gameId, timeoutUserId, timeoutCardId); handleTimeout(io, gameId, timeoutUserId);
}); });
if (socketsInRoom) {
for (const socketId of socketsInRoom) {
const s = io.sockets.sockets.get(socketId);
const u = getUser(socketId);
if (u && game.players[u.id])
s.emit("game-state", game.getStateForPlayer(u.id));
}
}
} }
}; };
@@ -109,181 +190,93 @@ export default (io, socket) => {
} }
let game = getGame(gameId); let game = getGame(gameId);
let gameData = null;
if (!game) { try {
try {
const token = socket.handshake.auth.token; const token = socket.handshake.auth.token;
const response = await axios.get(`${API_URL}/games/${gameId}`, { const response = await axios.get(`${API_URL}/games/${gameId}`, {
headers: { Authorization: token }, headers: { Authorization: token },
}); });
gameData = response.data.data || response.data;
} catch (e) { console.error("DB Sync Failed"); }
const gameData = response.data.data || response.data; if (!game && gameData) {
const p1Name = gameData.player1?.nickname || "Player 1";
console.log( const p2Name = gameData.player2?.nickname || "Player 2";
`[Join Debug] API Data recebida para Jogo ${gameId}:`,
gameData ? "OK" : "NULL"
);
if (
gameData.player2_user_id == 1 &&
user.id != gameData.player1_user_id
) {
try {
console.log(
`[API Sync] A oficializar User ${user.id} como Player 2 na BD...`
);
await axios.post(
`${API_URL}/games/${gameId}/join`,
{},
{
headers: { Authorization: token },
}
);
console.log(`[API Sync] Sucesso! O jogo passou a 'Playing' na BD.`);
gameData.player2_user_id = user.id;
gameData.player2 = { name: user.name };
gameData.status = "Playing";
} catch (joinError) {
console.error(
`[API Sync Error] Falha ao fazer join na BD:`,
joinError.message
);
}
}
if (gameData.status === "Ended") {
socket.emit("error", { message: "Game already finished." });
return;
}
if (
gameData.player1_user_id != user.id &&
gameData.player2_user_id != user.id &&
gameData.player2_user_id !== 1
) {
socket.emit("error", { message: "You are not in this game." });
return;
}
const p1Name = gameData.player1?.name || "Player 1";
const p2Name = gameData.player2?.name || "Player 2";
const type = gameData.type == "9" ? 9 : 3; const type = gameData.type == "9" ? 9 : 3;
game = createGame( game = createGame(
gameId, gameId,
type, type,
{ id: gameData.player1_user_id, name: p1Name }, { id: gameData.player1_user_id, name: p1Name },
{ id: gameData.player2_user_id, name: p2Name } { id: gameData.player2_user_id, name: p2Name }
); );
console.log( game.status = gameData.status === 'Playing' ? 'playing' : 'pending';
`[Join Debug] Jogo criado?`,
game ? "SIM" : "NÃO (Undefined)"
);
} catch (error) {
if (error.response) {
console.error(`[Join Error] Status: ${error.response.status}`);
console.error(`[Join Error] Data:`, error.response.data);
} else {
console.error(`[Join Error] Message:`, error.message);
}
socket.emit("error", { message: "Failed to join game." });
return;
}
} }
const GHOST_ID = 1; if (!game) {
socket.emit("error", { message: "Game not found." });
return;
}
if (game && game.players) { const myId = user.id;
if (game.players[user.id]) { const ghostKey = Object.keys(game.players).find(key => key == 1);
game.players[user.id].token = socket.handshake.auth.token; const amIDbPlayer2 = gameData && gameData.player2_user_id == myId;
}
if (!game.players[user.id] && game.players[GHOST_ID]) { if (!game.players[myId] && (ghostKey || amIDbPlayer2)) {
console.log(`[Ghost Swap] User ${user.name} substituiu o Fantasma.`); let ghostHand = [];
let ghostPoints = 0;
let ghostTricks = 0;
if (ghostKey && game.players[ghostKey]) {
ghostHand = game.players[ghostKey].hand || [];
ghostPoints = game.players[ghostKey].points || 0;
ghostTricks = game.players[ghostKey].tricks || 0;
delete game.players[ghostKey];
}
delete game.players[GHOST_ID]; game.players[myId] = {
game.players[user.id] = { id: myId,
id: user.id, name: user.nickname,
name: user.name, hand: ghostHand,
hand: [], points: ghostPoints,
points: 0, tricks: ghostTricks,
tricks: 0, token: socket.handshake.auth.token,
token: socket.handshake.auth.token,
}; };
game.player2 = { id: myId, name: user.nickname };
const token = socket.handshake.auth.token; game.status = 'playing';
if(!game.timer) {
axios game.startTurnTimer((timeoutUserId) => handleTimeout(io, gameId, timeoutUserId));
.post( }
`${API_URL}/games/${gameId}/join`,
{},
{
headers: { Authorization: token },
}
)
.then(() =>
console.log(`[API Sync] Sucesso! BD atualizada para 'Playing'.`)
)
.catch((err) => console.error(`[API Sync Error]`, err.message));
}
} }
socket.join(`game_${gameId}`); socket.join(`game_${gameId}`);
socket.activeGameId = gameId;
if (game && game.players[user.id]) { if (game.players[myId]) {
socket.emit("game-state", game.getStateForPlayer(user.id)); game.players[myId].token = socket.handshake.auth.token;
console.log(`--> Estado enviado para User ${user.id}`);
game.players[user.id].token = socket.handshake.auth.token; const rawState = game.getStateForPlayer(myId);
if (!game.timer && game.status === "playing") { const cleanState = sanitizeState(rawState, game);
game.startTurnTimer((uId, cId) => handleGameMove(io, gameId, uId, cId));
} socket.emit("game-state", cleanState);
socket.emit("game-state", game.getStateForPlayer(user.id)); broadcastState(io, gameId, game);
} else {
socket.emit("error", { message: "Error joining game state." });
} }
}); });
socket.on("play-card", ({ gameId, cardId }) => { socket.on("play-card", ({ gameId, cardId }) => {
const user = getUser(socket.id); const user = getUser(socket.id);
const game = getGame(gameId); if (user) handleGameMove(io, gameId, user.id, cardId);
});
if (!game || !user) return; // --- NEW: SURRENDER HANDLER ---
socket.on("surrender", ({ gameId }) => {
const user = getUser(socket.id);
const game = getGame(gameId);
const result = game.playCard(user.id, cardId); if (!game || !user || !game.players[user.id]) return;
if (result.error) { console.log(`[Game] User ${user.nickname} surrendered.`);
socket.emit("game-error", { message: result.error });
return;
}
handleGameMove(io, gameId, user.id, cardId); const winnerId = Object.keys(game.players).find(id => String(id) !== String(user.id));
performGameEnd(io, game, gameId, winnerId, user.id, "surrender");
const roomName = `game_${gameId}`;
const socketsInRoom = io.sockets.adapter.rooms.get(roomName);
if (socketsInRoom) {
for (const socketId of socketsInRoom) {
const s = io.sockets.sockets.get(socketId);
const u = getUser(socketId);
if (u && game.players[u.id]) {
s.emit("game-state", game.getStateForPlayer(u.id));
}
}
}
if (result.action === "trick_resolved") {
io.to(roomName).emit("trick-end", result.trickResult);
}
if (result.action === "game_ended") {
io.to(roomName).emit("game-over", {
winnerId: result.winnerId,
isDraw: result.isDraw,
p1Points: result.player1_points,
p2Points: result.player2_points,
});
saveGameResult(gameId, result, socket.handshake.auth.token);
}
}); });
}; };
+158
View File
@@ -0,0 +1,158 @@
import { getMatch, addMatch } from "../state/match.js";
import BiscaMatch from "../classes/BiscaMatch.js";
import axios from "axios";
const API_URL = "http://localhost:8000/api/v1";
export default (io, socket) => {
const broadcastMatchUpdate = (matchId, match) => {
const roomName = `match_${matchId}`;
const room = io.sockets.adapter.rooms.get(roomName);
if (room) {
for (const socketId of room) {
const clientSocket = io.sockets.sockets.get(socketId);
if (!clientSocket || !clientSocket.user) continue;
const userId = clientSocket.user.id;
const publicState = match.getPublicState();
const gameState = match.getGameState(userId);
clientSocket.emit("match:update", { ...publicState, game: gameState });
}
}
};
const createMatchEmitter = (matchId) => (eventName, payload) => {
const match = getMatch(matchId);
if (!match) return;
if (eventName === 'match:new-round-start' || eventName === 'match:ended-signal') {
broadcastMatchUpdate(matchId, match);
} else if (eventName === 'match:round-ended') {
io.to(`match_${matchId}`).emit('match:round-ended', payload);
} else {
io.to(`match_${matchId}`).emit(eventName, payload);
}
};
// --- FIX: Timer Callback ---
const handleAutoPlay = (matchId) => (userId, cardId) => {
const match = getMatch(matchId);
if (!match || match.status !== "Playing") return;
console.log(`[Match ${matchId}] Auto-playing for User ${userId}`);
const result = match.playCard(userId, cardId);
if (result && result.action === "trick_resolved") {
io.to(`match_${matchId}`).emit("game:trick-end", result.trickResult);
setTimeout(() => { broadcastMatchUpdate(matchId, match); }, 1500);
} else {
broadcastMatchUpdate(matchId, match);
}
};
socket.on("match:enter", async (data) => {
const { matchId } = data;
if (!socket.user || !socket.user.id) return socket.emit("error", { message: "Auth failed" });
const userId = socket.user.id;
socket.activeMatchId = matchId;
const room = `match_${matchId}`;
let match = getMatch(matchId);
if (!match) {
try {
const res = await axios.get(`${API_URL}/matches/${matchId}`, {
headers: { Authorization: socket.token || socket.handshake.auth.token },
});
const matchData = res.data.data || res.data;
// Pass handleAutoPlay to constructor
match = new BiscaMatch(
matchData,
createMatchEmitter(matchId),
socket.token,
handleAutoPlay(matchId)
);
addMatch(match);
} catch (e) {
return socket.emit("error", "Match not found");
}
}
match.emitStateCallback = createMatchEmitter(matchId);
if (userId !== match.player1Id && match.player2Id === 1) await match.joinPlayer(userId, socket.user);
socket.join(room);
const publicState = match.getPublicState();
const gameState = match.getGameState(userId);
socket.emit("match:update", { ...publicState, game: gameState });
if (match.status === 'Playing') broadcastMatchUpdate(matchId, match);
});
socket.on("game:play-card", (data) => {
const { matchId, cardId } = data;
const match = getMatch(matchId);
if (!match || match.status !== "Playing") return;
const result = match.playCard(socket.user.id, cardId);
if (result && result.error) return socket.emit("error", { message: result.error });
if (result && result.action === "trick_resolved") {
io.to(`match_${matchId}`).emit("game:trick-end", result.trickResult);
setTimeout(() => { broadcastMatchUpdate(matchId, match); }, 1500);
} else {
broadcastMatchUpdate(matchId, match);
}
});
socket.on("game:surrender", async ({ matchId }) => {
const match = getMatch(matchId);
if(!match || match.status !== "Playing") return;
const userId = socket.user.id;
console.log(`[Match ${matchId}] User ${userId} surrendered ROUND.`);
const opponentId = match.player1Id == userId ? match.player2Id : match.player1Id;
const result = {
winnerId: opponentId,
loserId: userId,
isDraw: false,
player1_id: match.player1Id,
player2_id: match.player2Id,
player1_points: match.player1Id == opponentId ? 91 : 0,
player2_points: match.player2Id == opponentId ? 91 : 0,
reason: 'surrender'
};
await match.handleGameEnd(result);
});
socket.on("match:surrender", async ({ matchId }) => {
const match = getMatch(matchId);
if(!match || match.status !== "Playing") return;
const userId = socket.user.id;
// FIX: Abort current game to remove ghost
await match.abortCurrentGame(userId);
console.log(`[Match ${matchId}] User ${userId} surrendered MATCH.`);
const opponentId = match.player1Id == userId ? match.player2Id : match.player1Id;
match.marks[opponentId] = 4;
await match.finishMatch();
broadcastMatchUpdate(matchId, match);
});
socket.on("disconnect", async () => {
if (socket.activeMatchId) {
const match = getMatch(socket.activeMatchId);
if (match && match.status === 'Playing') {
const userId = socket.user?.id;
if (userId === match.player1Id || userId === match.player2Id) {
console.log(`[Match ${match.id}] Player ${userId} disconnected.`);
// FIX: Abort current game
await match.abortCurrentGame(userId);
const opponentId = match.player1Id == userId ? match.player2Id : match.player1Id;
match.marks[opponentId] = 4;
await match.finishMatch();
broadcastMatchUpdate(match.id, match);
}
}
}
});
};
+127 -59
View File
@@ -3,72 +3,140 @@ import axios from "axios";
import { handleConnectionEvents } from "./events/connection.js"; import { handleConnectionEvents } from "./events/connection.js";
import { addUser, removeUser } from "./state/connection.js"; import { addUser, removeUser } from "./state/connection.js";
import gameEvents from "./events/game.js"; import gameEvents from "./events/game.js";
import matchEvents from "./events/match.js";
export const server = { export const server = {
io: null, io: null,
}; };
const API_URL = "http://localhost:8000/api/v1"; const API_URL = "http://localhost:8000/api/v1";
const disconnectTimers = new Map();
const RECONNECT_GRACE_PERIOD = 1 * 60 * 1000; // 1 minutes
const userId = (socket) => socket.user && socket.user.id;
export const serverStart = (port) => { export const serverStart = (port) => {
server.io = new Server(port, { server.io = new Server(port, {
cors: { cors: {
origin: "*", origin: "*",
},
});
server.io.use(async (socket, next) => {
let token = socket.handshake.auth.token;
if (!token) {
return next(new Error("Authentication error: No token provided"));
}
if (!token.startsWith("Bearer ")) {
token = "Bearer " + token;
}
try {
const response = await axios.get(`${API_URL}/users/me`, {
headers: {
Authorization: token,
Accept: "application/json"
}, },
});
const user = response.data.data || response.data;
if (!user || !user.id) {
return next(new Error("Authentication error: User data not found"));
}
socket.user = user;
socket.handshake.auth.token = token;
addUser(socket.id, user);
next();
} catch (error) {
if (error.response) {
console.error(
"❌ Erro Laravel:",
error.response.status,
error.response.data
);
} else {
console.error("❌ Erro Rede:", error.message);
}
next(new Error("Authentication error: Invalid token"));
}
});
server.io.on("connection", (socket) => {
gameEvents(server.io, socket);
handleConnectionEvents(server.io, socket);
socket.on("disconnect", () => {
removeUser(socket.id);
}); });
});
server.io.use(async (socket, next) => {
let token = socket.handshake.auth.token;
if (!token) {
return next(new Error("Authentication error: No token provided"));
}
if (!token.startsWith("Bearer ")) {
token = "Bearer " + token;
}
try {
const response = await axios.get(`${API_URL}/users/me`, {
headers: {
Authorization: token,
Accept: "application/json"
},
});
const user = response.data.data || response.data;
if (!user || !user.id) {
return next(new Error("Authentication error: User data not found"));
}
socket.user = user;
socket.token = token;
socket.handshake.auth.token = token;
if (disconnectTimers.has(user.id)) {
console.log(`[Connection] User ${user.id} reconnected! Cancelling surrender timer.`);
clearTimeout(disconnectTimers.get(user.id));
disconnectTimers.delete(user.id);
}
addUser(socket.id, user);
next();
} catch (error) {
if (error.response) {
console.error(
"❌ Erro Laravel:",
error.response.status,
error.response.data
);
} else {
console.error("❌ Erro Rede:", error.message);
}
next(new Error("Authentication error: Invalid token"));
}
});
server.io.on("connection", (socket) => {
gameEvents(server.io, socket);
matchEvents(server.io, socket);
handleConnectionEvents(server.io, socket);
socket.on("notify_disconnect", () => {
socket.isVoluntaryDisconnect = true;
});
socket.on("disconnect", () => {
// Remove from active socket list
removeUser(socket.id);
if (socket.isVoluntaryDisconnect) {
console.log(`[Disconnect] User ${userId} disconnected voluntarily. No surrender needed.`);
return;
}
if (userId) {
console.log(`[Disconnect] User ${userId} disconnected. Waiting ${RECONNECT_GRACE_PERIOD / 1000}s...`);
// -----------------------------------------------------------
// 2. START SURRENDER TIMER
// -----------------------------------------------------------
const timer = setTimeout(async () => {
console.log(`[Timeout] User ${userId} did not return. Forcing surrender.`);
// A. Find the Game ID this user is playing (You need a way to look this up)
// Ideally, your 'socket' object or a global map knows which gameID the user is in.
// For this example, let's assume you stored `socket.activeGameId` when they joined.
const gameId = socket.activeGameId;
if (gameId) {
try {
// B. Call Laravel API to resign on their behalf
// We use the token we saved during the handshake
await axios.post(`${API_URL}/games/${gameId}/resign`, {}, {
headers: {
Authorization: socket.handshake.auth.token,
Accept: "application/json"
}
});
// C. Notify the room (The API likely triggers a Pusher/Socket event,
// but we can also emit locally if needed)
server.io.to(gameId).emit("game_over", {
winner: "opponent",
reason: "disconnect"
});
console.log(`[Timeout] Successfully resigned game ${gameId} for user ${userId}`);
} catch (err) {
console.error(`[Timeout] Failed to resign game for user ${userId}:`, err.message);
}
}
disconnectTimers.delete(userId);
}, RECONNECT_GRACE_PERIOD);
disconnectTimers.set(userId, timer);
}
});
});
}; };
+11 -5
View File
@@ -1,8 +1,15 @@
import BiscaGame from '../classes/BiscaGame.js'; import BiscaGame from "../classes/BiscaGame.js";
const activeGames = new Map(); const activeGames = new Map();
const createGame = (id, type, player1, player2) => { export const createGame = (id, type, player1, player2) => {
const existingGame = activeGames.get(id);
if (existingGame) {
console.log(
`[Game State] Game ${id} already exists, returning existing instance`
);
return existingGame;
}
const game = new BiscaGame(id, type, player1, player2); const game = new BiscaGame(id, type, player1, player2);
game.init(); game.init();
@@ -13,11 +20,11 @@ const createGame = (id, type, player1, player2) => {
return game; return game;
}; };
const getGame = (id) => { export const getGame = (id) => {
return activeGames.get(id); return activeGames.get(id);
}; };
const removeGame = (id) => { export const removeGame = (id) => {
const game = activeGames.get(id); const game = activeGames.get(id);
if (game) { if (game) {
game.stopTimer(); game.stopTimer();
@@ -26,4 +33,3 @@ const removeGame = (id) => {
} }
}; };
export { createGame, getGame, removeGame };
+16
View File
@@ -0,0 +1,16 @@
const matches = new Map();
export const addMatch = (match) => {
matches.set(parseInt(match.id), match);
return match;
};
export const getMatch = (id) => {
return matches.get(parseInt(id));
};
export const removeMatch = (id) => {
matches.delete(parseInt(id));
};
export { matches };