Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
754a227608 | ||
|
|
83f92e670d | ||
|
|
59df3e2d37 | ||
|
|
39f55a12c0 | ||
|
|
edecef3af8 | ||
|
|
51f745a3dd
|
||
|
|
4b311c74eb
|
||
|
|
0ecdc30bd4
|
||
|
|
25c819c6e4
|
||
|
|
f60b25f29b
|
||
|
|
52887fc2eb
|
||
|
|
c4078d9b82
|
||
|
|
d29e44c61c
|
||
|
|
8f7102b335
|
||
|
|
a34027889f
|
||
|
|
87e0dc2518
|
||
|
|
79be3544cb
|
||
|
|
eafb4a4d1c
|
||
|
|
08c20acdf4
|
||
|
|
ab231ee94f
|
||
|
|
ceb3d735ad
|
||
|
|
5924d08f73
|
||
|
|
3ec6f8971c
|
||
|
|
e68f1357cc
|
||
|
|
cfda949ea6
|
||
|
|
c68cda350e
|
||
|
|
b7daa4610b | ||
|
|
329bbcc1d3 | ||
|
|
b35cac3b2c | ||
|
|
b5c3c552da | ||
|
|
14dd4b3a4e | ||
|
|
0d0f7569a6 | ||
|
|
09c4732e19 | ||
|
|
d4361121d8
|
||
|
|
ef0bdab0d3 | ||
|
|
5295d0620f | ||
|
|
d8cdbbf2ad | ||
|
|
160489b080 | ||
|
|
a0bbd8a69d | ||
|
|
943a5dd898 | ||
|
|
8912029686 | ||
|
|
0bf96fc948 | ||
|
|
79310fa63e | ||
|
|
83adbcf2f1 | ||
|
|
00666a16b2 | ||
|
|
7a6fbb4ba3 | ||
|
|
f3f6c6e564 | ||
|
|
d8e20dfc67 | ||
|
|
1939822e9b | ||
|
|
c0b77aebb1 | ||
|
|
ea15f5e28a | ||
|
|
8505d207e6 | ||
|
|
e54ec58f0c | ||
|
|
e833e403f7 | ||
|
|
5c1fc11975 | ||
|
|
b59640c858 | ||
|
|
e8ccea4e42 | ||
|
|
2b161e2099
|
@@ -80,6 +80,7 @@ frontend/.env.*.local
|
|||||||
# Websockets: NodeJs (in websockets)
|
# Websockets: NodeJs (in websockets)
|
||||||
# -------------------------
|
# -------------------------
|
||||||
/websockets/node_modules/
|
/websockets/node_modules/
|
||||||
|
/websockets/.env
|
||||||
# -------------------------
|
# -------------------------
|
||||||
# Other useful ignores
|
# Other useful ignores
|
||||||
# -------------------------
|
# -------------------------
|
||||||
@@ -100,5 +101,8 @@ Desktop.ini
|
|||||||
# If you have any local secrets or files not to track, add them here:
|
# If you have any local secrets or files not to track, add them here:
|
||||||
# /path/to/some/file
|
# /path/to/some/file
|
||||||
|
|
||||||
package-lock.json
|
/frontend/package-lock.json
|
||||||
|
/websockets/package-lock.json
|
||||||
|
/api/package-lock.json
|
||||||
|
|
||||||
.vscode
|
.vscode
|
||||||
@@ -22,3 +22,5 @@
|
|||||||
Homestead.json
|
Homestead.json
|
||||||
Homestead.yaml
|
Homestead.yaml
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
composer.lock
|
||||||
@@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Requests\RegisterRequest;
|
||||||
|
use App\Models\User;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
use App\Models\User;
|
|
||||||
use App\Http\Requests\RegisterRequest;
|
|
||||||
|
|
||||||
class AuthController extends Controller
|
class AuthController extends Controller
|
||||||
{
|
{
|
||||||
@@ -24,6 +24,13 @@ class AuthController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
|
|
||||||
|
if ($user->blocked) {
|
||||||
|
Auth::logout();
|
||||||
|
|
||||||
|
return response()->json(['message' => 'Your account is blocked. Please contact support.'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
$token = $user->createToken('auth-token')->plainTextToken;
|
$token = $user->createToken('auth-token')->plainTextToken;
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
@@ -47,6 +54,10 @@ class AuthController extends Controller
|
|||||||
return response()->json(['message' => 'Email already registered'], 409);
|
return response()->json(['message' => 'Email already registered'], 409);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (User::where('nickname', $request->nickname)->exists()) {
|
||||||
|
return response()->json(['message' => 'Nickname already registered'], 409);
|
||||||
|
}
|
||||||
|
|
||||||
$user = User::create([
|
$user = User::create([
|
||||||
'email' => $request->email,
|
'email' => $request->email,
|
||||||
'nickname' => $request->nickname,
|
'nickname' => $request->nickname,
|
||||||
|
|||||||
@@ -10,10 +10,25 @@ use App\Models\MatchGame;
|
|||||||
|
|
||||||
class GameController extends Controller
|
class GameController extends Controller
|
||||||
{
|
{
|
||||||
|
const GHOST_ID = 1;
|
||||||
|
|
||||||
public function index(Request $request)
|
public function index(Request $request)
|
||||||
{
|
{
|
||||||
|
$this->authorize('viewAny', Game::class);
|
||||||
|
|
||||||
|
$user = Auth::user();
|
||||||
$query = Game::query()->with(["winner", "player1", "player2"]);
|
$query = Game::query()->with(["winner", "player1", "player2"]);
|
||||||
|
|
||||||
|
// Filter games based on user type
|
||||||
|
if ($user->type !== 'A') {
|
||||||
|
// Players can only see games they participate in
|
||||||
|
$query->where(function ($q) use ($user) {
|
||||||
|
$q->where('player1_user_id', $user->id)
|
||||||
|
->orWhere('player2_user_id', $user->id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Admins see all games (no filter needed)
|
||||||
|
|
||||||
if ($request->has("type") && in_array($request->type, ["3", "9"])) {
|
if ($request->has("type") && in_array($request->type, ["3", "9"])) {
|
||||||
$query->where("type", $request->type);
|
$query->where("type", $request->type);
|
||||||
}
|
}
|
||||||
@@ -30,100 +45,70 @@ class GameController extends Controller
|
|||||||
$query->where("status", $request->status);
|
$query->where("status", $request->status);
|
||||||
}
|
}
|
||||||
|
|
||||||
$query->orderBy("began_at", "desc");
|
if ($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));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function store(Request $request)
|
public function store(Request $request)
|
||||||
{
|
{
|
||||||
|
$this->authorize('create', Game::class);
|
||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
"type" => "required|in:3,9",
|
"type" => "required|in:3,9",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
|
|
||||||
$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();
|
|
||||||
|
|
||||||
$activeGame = Game::where(function ($q) use ($user) {
|
$activeGame = Game::where(function ($q) use ($user) {
|
||||||
$q->where("player1_user_id", $user->id)->orWhere(
|
$q->where("player1_user_id", $user->id)
|
||||||
"player2_user_id",
|
->orWhere("player2_user_id", $user->id);
|
||||||
$user->id,
|
|
||||||
);
|
|
||||||
})
|
})
|
||||||
->whereIn("status", ["Pending", "Playing"])
|
->whereIn("status", ["Pending", "Playing"])
|
||||||
->exists();
|
->exists();
|
||||||
|
|
||||||
if ($activeMatch || $activeGame) {
|
if ($activeGame) {
|
||||||
return response()->json(
|
return response()->json(["message" => "You already have an active game."], 400);
|
||||||
["message" => "You already have an active game or match."],
|
|
||||||
400,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$game = Game::create([
|
$game = Game::create([
|
||||||
"type" => $validated["type"],
|
"type" => $validated["type"],
|
||||||
"status" => "Pending",
|
"status" => "Pending",
|
||||||
"player1_user_id" => $user->id,
|
"player1_user_id" => $user->id,
|
||||||
"player2_user_id" => null,
|
"player2_user_id" => self::GHOST_ID,
|
||||||
"winner_user_id" => null,
|
"winner_user_id" => self::GHOST_ID,
|
||||||
"loser_user_id" => null,
|
"loser_user_id" => self::GHOST_ID,
|
||||||
"began_at" => now(),
|
"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" => "Multiplayer game room created",
|
"game" => $game,
|
||||||
"game" => $game,
|
], 201);
|
||||||
],
|
|
||||||
201,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function join(Game $game)
|
public function join(Game $game)
|
||||||
{
|
{
|
||||||
|
$this->authorize('join', $game);
|
||||||
|
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
|
|
||||||
if ($game->player1_user_id == $user->id) {
|
if ($game->player1_user_id == $user->id) {
|
||||||
return response()->json(
|
return response()->json([
|
||||||
[
|
"message" => "You are the host. Waiting for opponent...",
|
||||||
"message" => "Waiting for opponent...",
|
"game" => $game
|
||||||
"game" => $game,
|
], 200);
|
||||||
],
|
|
||||||
200,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($game->status !== "Pending") {
|
if ($game->player2_user_id !== self::GHOST_ID) {
|
||||||
return response()->json(
|
return response()->json(["message" => "Game is full"], 400);
|
||||||
["message" => "Game is full or already started"],
|
|
||||||
400,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$activeGame = Game::where(function ($query) use ($user) {
|
|
||||||
$query
|
|
||||||
->where("player1_user_id", $user->id)
|
|
||||||
->orWhere("player2_user_id", $user->id);
|
|
||||||
})
|
|
||||||
->whereIn("status", ["Pending", "Playing"])
|
|
||||||
->exists();
|
|
||||||
|
|
||||||
if ($activeGame) {
|
|
||||||
return response()->json(
|
|
||||||
["message" => "You are already in another active game."],
|
|
||||||
400,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$game->update([
|
$game->update([
|
||||||
@@ -131,23 +116,24 @@ class GameController extends Controller
|
|||||||
"player2_user_id" => $user->id,
|
"player2_user_id" => $user->id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return response()->json(
|
return response()->json([
|
||||||
[
|
"message" => "Joined successfully!",
|
||||||
"message" => "Joined successfully! Game starts now.",
|
"game" => $game,
|
||||||
"game" => $game,
|
], 200);
|
||||||
],
|
|
||||||
200,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function show(Game $game)
|
public function show(Game $game)
|
||||||
{
|
{
|
||||||
|
$this->authorize('view', $game);
|
||||||
|
|
||||||
$game->load(["player1", "player2", "winner"]);
|
$game->load(["player1", "player2", "winner"]);
|
||||||
return response()->json($game);
|
return response()->json($game);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update(Request $request, Game $game)
|
public function update(Request $request, Game $game)
|
||||||
{
|
{
|
||||||
|
$this->authorize('update', $game);
|
||||||
|
|
||||||
if ($game->status == "Ended") {
|
if ($game->status == "Ended") {
|
||||||
return response()->json(["message" => "Game already ended"], 400);
|
return response()->json(["message" => "Game already ended"], 400);
|
||||||
}
|
}
|
||||||
@@ -170,4 +156,25 @@ class GameController extends Controller
|
|||||||
|
|
||||||
return response()->json($game);
|
return response()->json($game);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function resign(Game $game)
|
||||||
|
{
|
||||||
|
$this->authorize('resign', $game);
|
||||||
|
|
||||||
|
$user = Auth::user();
|
||||||
|
|
||||||
|
$game->update([
|
||||||
|
'status' => 'Ended',
|
||||||
|
'winner_user_id' => $game->player1_user_id === $user->id
|
||||||
|
? $game->player2_user_id
|
||||||
|
: $game->player1_user_id,
|
||||||
|
'loser_user_id' => $user->id,
|
||||||
|
'ended_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'You have resigned from the game',
|
||||||
|
'game' => $game->fresh(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,12 @@
|
|||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
|
||||||
class ProfileController extends Controller
|
class ProfileController extends Controller
|
||||||
{
|
{
|
||||||
@@ -52,7 +58,7 @@ class ProfileController extends Controller
|
|||||||
|
|
||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
|
|
||||||
if (!\Hash::check($request->current_password, $user->password)) {
|
if (!Hash::check($request->current_password, $user->password)) {
|
||||||
return response()->json(
|
return response()->json(
|
||||||
[
|
[
|
||||||
"message" => "Current password is incorrect",
|
"message" => "Current password is incorrect",
|
||||||
@@ -61,7 +67,7 @@ class ProfileController extends Controller
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$user->password = \Hash::make($request->new_password);
|
$user->password = Hash::make($request->new_password);
|
||||||
$user->save();
|
$user->save();
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
@@ -69,6 +75,13 @@ class ProfileController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getCoins(Request $request)
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'coins' => $request->user()->coins_balance
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function updateAvatar(Request $request)
|
public function updateAvatar(Request $request)
|
||||||
{
|
{
|
||||||
$request->validate([
|
$request->validate([
|
||||||
@@ -78,7 +91,7 @@ class ProfileController extends Controller
|
|||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
|
|
||||||
if ($user->photo_avatar_filename) {
|
if ($user->photo_avatar_filename) {
|
||||||
\Storage::disk("public")->delete(
|
Storage::disk("public")->delete(
|
||||||
"photos_avatars/" . $user->photo_avatar_filename,
|
"photos_avatars/" . $user->photo_avatar_filename,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -87,7 +100,7 @@ class ProfileController extends Controller
|
|||||||
$filename =
|
$filename =
|
||||||
str_pad($user->id, 5, "0", STR_PAD_LEFT) .
|
str_pad($user->id, 5, "0", STR_PAD_LEFT) .
|
||||||
"_" .
|
"_" .
|
||||||
\Str::random(10) .
|
Str::random(10) .
|
||||||
"." .
|
"." .
|
||||||
$file->extension();
|
$file->extension();
|
||||||
$file->storeAs("photos_avatars", $filename, "public");
|
$file->storeAs("photos_avatars", $filename, "public");
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Game;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class StatisticsController extends Controller
|
||||||
|
{
|
||||||
|
public function getPublicStats()
|
||||||
|
{
|
||||||
|
$totalPlayers = User::where('type', 'P')->count();
|
||||||
|
|
||||||
|
$totalGames = Game::where('status', 'Ended')->count();
|
||||||
|
$activeGames = Game::where('status', 'Playing')->count();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'total_players' => $totalPlayers,
|
||||||
|
'total_games' => $totalGames,
|
||||||
|
'active_games' => $activeGames,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLeaderboard(Request $request)
|
||||||
|
{
|
||||||
|
$type = $request->query('type');
|
||||||
|
|
||||||
|
$query = User::where('type', 'P')
|
||||||
|
->withCount(['wonGames' => function ($query) use ($type) {
|
||||||
|
if ($type) {
|
||||||
|
$query->where('type', $type);
|
||||||
|
}
|
||||||
|
}])
|
||||||
|
->orderBy('won_games_count', 'desc')
|
||||||
|
->take(10);
|
||||||
|
|
||||||
|
$leaderboard = $query->get(['id', 'nickname', 'photo_avatar_filename']);
|
||||||
|
|
||||||
|
return response()->json($leaderboard);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPersonalStats(Request $request)
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
$totalGames = Game::where('status', 'Ended')
|
||||||
|
->where(function ($q) use ($user) {
|
||||||
|
$q->where('player1_user_id', $user->id)
|
||||||
|
->orWhere('player2_user_id', $user->id);
|
||||||
|
})
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$totalWins = Game::where('status', 'Ended')
|
||||||
|
->where('winner_user_id', $user->id)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$totalLosses = $totalGames - $totalWins; // Simplest math
|
||||||
|
|
||||||
|
$winRate = 0;
|
||||||
|
if ($totalGames > 0) {
|
||||||
|
$winRate = round(($totalWins / $totalGames) * 100, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'nickname' => $user->nickname,
|
||||||
|
'total_games' => $totalGames,
|
||||||
|
'total_wins' => $totalWins,
|
||||||
|
'total_losses' => $totalLosses,
|
||||||
|
'win_rate' => $winRate . '%',
|
||||||
|
'current_balance' => $user->coins_balance, // Extra info
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,8 @@ class UserController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function index(Request $request)
|
public function index(Request $request)
|
||||||
{
|
{
|
||||||
|
$this->authorize('viewAny', User::class);
|
||||||
|
|
||||||
$query = User::query();
|
$query = User::query();
|
||||||
|
|
||||||
// Filtros úteis para o Backoffice
|
// Filtros úteis para o Backoffice
|
||||||
@@ -41,6 +43,8 @@ class UserController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function store(Request $request)
|
public function store(Request $request)
|
||||||
{
|
{
|
||||||
|
$this->authorize('create', User::class);
|
||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'name' => 'required|string|max:255',
|
'name' => 'required|string|max:255',
|
||||||
'nickname' => 'required|string|max:20|unique:users,nickname',
|
'nickname' => 'required|string|max:20|unique:users,nickname',
|
||||||
@@ -73,7 +77,11 @@ class UserController extends Controller
|
|||||||
public function show(User $user)
|
public function show(User $user)
|
||||||
{
|
{
|
||||||
$currentUser = Auth::user();
|
$currentUser = Auth::user();
|
||||||
if ($currentUser->id !== $user->id && $currentUser->type !== 'A') {
|
|
||||||
|
// Admins and owners get full profile, others get limited view
|
||||||
|
if ($currentUser->type === 'A' || $currentUser->id === $user->id) {
|
||||||
|
return response()->json($user);
|
||||||
|
} else {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'id' => $user->id,
|
'id' => $user->id,
|
||||||
'name' => $user->name,
|
'name' => $user->name,
|
||||||
@@ -82,8 +90,6 @@ class UserController extends Controller
|
|||||||
'type' => $user->type,
|
'type' => $user->type,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response()->json($user);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -92,11 +98,7 @@ class UserController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function update(Request $request, User $user)
|
public function update(Request $request, User $user)
|
||||||
{
|
{
|
||||||
$currentUser = Auth::user();
|
$this->authorize('update', $user);
|
||||||
|
|
||||||
if ($currentUser->id !== $user->id && $currentUser->type !== 'A') {
|
|
||||||
return response()->json(['message' => 'Unauthorized'], 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'name' => 'sometimes|string|max:255',
|
'name' => 'sometimes|string|max:255',
|
||||||
@@ -112,16 +114,8 @@ class UserController extends Controller
|
|||||||
Rule::unique('users')->ignore($user->id),
|
Rule::unique('users')->ignore($user->id),
|
||||||
],
|
],
|
||||||
'password' => 'sometimes|string|min:3',
|
'password' => 'sometimes|string|min:3',
|
||||||
'blocked' => 'sometimes|boolean',
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (isset($validated['blocked']) && $currentUser->type !== 'A') {
|
|
||||||
return response()->json(
|
|
||||||
['message' => 'Only admins can block users'],
|
|
||||||
403,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lógica de Upload de Foto (Exemplo Básico)
|
// Lógica de Upload de Foto (Exemplo Básico)
|
||||||
// if ($request->hasFile('photo_avatar')) {
|
// if ($request->hasFile('photo_avatar')) {
|
||||||
// $path = $request->file('photo_avatar')->store('avatars', 'public');
|
// $path = $request->file('photo_avatar')->store('avatars', 'public');
|
||||||
@@ -139,24 +133,24 @@ class UserController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function destroy(User $user)
|
public function destroy(User $user)
|
||||||
{
|
{
|
||||||
$currentUser = Auth::user();
|
$this->authorize('delete', $user);
|
||||||
|
|
||||||
if ($currentUser->id !== $user->id && $currentUser->type !== 'A') {
|
// Check if user has transactions or games - if so, soft delete
|
||||||
return response()->json(['message' => 'Unauthorized'], 403);
|
$hasTransactions = $user->transactions()->exists();
|
||||||
|
$hasGames = Game::where(function ($q) use ($user) {
|
||||||
|
$q->where('player1_user_id', $user->id)
|
||||||
|
->orWhere('player2_user_id', $user->id);
|
||||||
|
})->exists();
|
||||||
|
|
||||||
|
if ($hasTransactions || $hasGames) {
|
||||||
|
$user->delete(); // Soft delete
|
||||||
|
$message = 'User account deactivated (soft-deleted due to transaction/game history)';
|
||||||
|
} else {
|
||||||
|
$user->forceDelete(); // Hard delete
|
||||||
|
$message = 'User permanently deleted';
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($currentUser->id === $user->id && $currentUser->type === 'A') {
|
return response()->json(['message' => $message]);
|
||||||
if ($user->type === 'A') {
|
|
||||||
return response()->json(
|
|
||||||
['message' => 'Admins cannot delete their own account'],
|
|
||||||
403,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$user->delete();
|
|
||||||
|
|
||||||
return response()->json(['message' => 'User deleted successfully']);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -165,11 +159,7 @@ class UserController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function getMatches(Request $request, User $user)
|
public function getMatches(Request $request, User $user)
|
||||||
{
|
{
|
||||||
$currentUser = Auth::user();
|
$this->authorize('view', $user);
|
||||||
|
|
||||||
if ($currentUser->id !== $user->id && $currentUser->type !== 'A') {
|
|
||||||
return response()->json(['message' => 'Unauthorized'], 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$matches = Game::query()
|
$matches = Game::query()
|
||||||
->where(function ($q) use ($user) {
|
->where(function ($q) use ($user) {
|
||||||
@@ -184,4 +174,27 @@ class UserController extends Controller
|
|||||||
|
|
||||||
return response()->json($matches);
|
return response()->json($matches);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function block(User $user)
|
||||||
|
{
|
||||||
|
$this->authorize('block', $user);
|
||||||
|
|
||||||
|
$user->blocked = true;
|
||||||
|
$user->save();
|
||||||
|
|
||||||
|
// Revoke all active tokens for immediate effect
|
||||||
|
$user->tokens()->delete();
|
||||||
|
|
||||||
|
return response()->json(['message' => 'User blocked successfully']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function unblock(User $user)
|
||||||
|
{
|
||||||
|
$this->authorize('unblock', $user);
|
||||||
|
|
||||||
|
$user->blocked = false;
|
||||||
|
$user->save();
|
||||||
|
|
||||||
|
return response()->json(['message' => 'User unblocked successfully']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,4 +48,14 @@ class User extends Authenticatable
|
|||||||
'blocked' => 'boolean',
|
'blocked' => 'boolean',
|
||||||
'deleted_at' => 'datetime',
|
'deleted_at' => 'datetime',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
public function wonGames()
|
||||||
|
{
|
||||||
|
return $this->hasMany(Game::class, 'winner_user_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function games()
|
||||||
|
{
|
||||||
|
return $this->hasMany(Game::class, 'player1_user_id')->orWhere('player2_user_id', $this->id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\Models\Game;
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
class GamePolicy
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine whether the user can view any models.
|
||||||
|
*/
|
||||||
|
public function viewAny(User $user): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**2
|
||||||
|
* Determine whether the user can view the model.
|
||||||
|
*/
|
||||||
|
public function view(User $user, Game $game): bool
|
||||||
|
{
|
||||||
|
if ($user->type === 'A') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
$game->player1_user_id === $user->id ||
|
||||||
|
$game->player2_user_id === $user->id
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can create models.
|
||||||
|
*/
|
||||||
|
public function create(User $user): bool
|
||||||
|
{
|
||||||
|
if ($user->type === 'A') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function join(User $user, Game $game): bool
|
||||||
|
{
|
||||||
|
if ($user->type === 'A') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
$game->status !== 'waiting' ||
|
||||||
|
$game->player1_user_id === $user->id
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resign(User $user, Game $game): bool
|
||||||
|
{
|
||||||
|
if ($user->type === 'A') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
$game->status !== 'ongoing' ||
|
||||||
|
($game->player1_user_id !== $user->id &&
|
||||||
|
$game->player2_user_id !== $user->id)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can update the model.
|
||||||
|
*/
|
||||||
|
public function update(User $user, Game $game): bool
|
||||||
|
{
|
||||||
|
if ($user->type === 'A') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
$game->status !== 'ongoing' ||
|
||||||
|
($game->player1_user_id !== $user->id &&
|
||||||
|
$game->player2_user_id !== $user->id)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can delete the model.
|
||||||
|
*/
|
||||||
|
public function delete(User $user, Game $game): bool
|
||||||
|
{
|
||||||
|
// Only admins can delete games
|
||||||
|
return $user->type === 'A';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can restore the model.
|
||||||
|
*/
|
||||||
|
public function restore(User $user, Game $game): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can permanently delete the model.
|
||||||
|
*/
|
||||||
|
public function forceDelete(User $user, Game $game): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
class UserPolicy
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine whether the user can view any models.
|
||||||
|
*/
|
||||||
|
public function viewAny(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->type === 'A';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can view the model.
|
||||||
|
*/
|
||||||
|
public function view(User $user, User $model): bool
|
||||||
|
{
|
||||||
|
return $user->id === $model->id || $user->type === 'A';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can create models.
|
||||||
|
*/
|
||||||
|
public function create(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->type === 'A';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can update the model.
|
||||||
|
*/
|
||||||
|
public function update(User $user, User $model): bool
|
||||||
|
{
|
||||||
|
return $user->id === $model->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can delete the model.
|
||||||
|
*/
|
||||||
|
public function delete(User $user, User $model): bool
|
||||||
|
{
|
||||||
|
return ($user->type === 'A' && $user->id !== $model->id) || ($user->type === 'P' && $user->id === $model->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function block(User $user, User $model): bool
|
||||||
|
{
|
||||||
|
return $user->type === 'A' && $user->id !== $model->id && $model->type === 'P';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function unblock(User $user, User $model): bool
|
||||||
|
{
|
||||||
|
return $user->type === 'A' && $user->id !== $model->id && $model->type === 'P';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can restore the model.
|
||||||
|
*/
|
||||||
|
public function restore(User $user, User $model): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the user can permanently delete the model.
|
||||||
|
*/
|
||||||
|
public function forceDelete(User $user, User $model): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -102,12 +102,15 @@
|
|||||||
'Illuminate\\Queue\\Console\\ForgetFailedCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
'Illuminate\\Queue\\Console\\ForgetFailedCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
'Illuminate\\Queue\\Console\\ListenCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
'Illuminate\\Queue\\Console\\ListenCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
'Illuminate\\Queue\\Console\\MonitorCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
'Illuminate\\Queue\\Console\\MonitorCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Queue\\Console\\PauseCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
'Illuminate\\Queue\\Console\\PruneBatchesCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
'Illuminate\\Queue\\Console\\PruneBatchesCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
'Illuminate\\Queue\\Console\\PruneFailedJobsCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
'Illuminate\\Queue\\Console\\PruneFailedJobsCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
'Illuminate\\Queue\\Console\\RestartCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
'Illuminate\\Queue\\Console\\RestartCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Queue\\Console\\ResumeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
'Illuminate\\Queue\\Console\\RetryCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
'Illuminate\\Queue\\Console\\RetryCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
'Illuminate\\Queue\\Console\\RetryBatchCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
'Illuminate\\Queue\\Console\\RetryBatchCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
'Illuminate\\Queue\\Console\\WorkCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
'Illuminate\\Queue\\Console\\WorkCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Foundation\\Console\\ReloadCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
'Illuminate\\Foundation\\Console\\RouteCacheCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
'Illuminate\\Foundation\\Console\\RouteCacheCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
'Illuminate\\Foundation\\Console\\RouteClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
'Illuminate\\Foundation\\Console\\RouteClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
'Illuminate\\Foundation\\Console\\RouteListCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
'Illuminate\\Foundation\\Console\\RouteListCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use App\Http\Controllers\GameController;
|
|||||||
use App\Http\Controllers\MatchController;
|
use App\Http\Controllers\MatchController;
|
||||||
use App\Http\Controllers\ProfileController;
|
use App\Http\Controllers\ProfileController;
|
||||||
use App\Http\Controllers\UserController;
|
use App\Http\Controllers\UserController;
|
||||||
|
use App\Http\Controllers\StatisticsController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::prefix('v1')->group(function () {
|
Route::prefix('v1')->group(function () {
|
||||||
@@ -12,14 +13,18 @@ Route::prefix('v1')->group(function () {
|
|||||||
Route::post('/login', [AuthController::class, 'login']);
|
Route::post('/login', [AuthController::class, 'login']);
|
||||||
Route::post('/register', [AuthController::class, 'register']);
|
Route::post('/register', [AuthController::class, 'register']);
|
||||||
|
|
||||||
|
Route::get('/statistics/public', [StatisticsController::class, 'getPublicStats']);
|
||||||
|
Route::get('/leaderboard', [StatisticsController::class, 'getLeaderboard']);
|
||||||
|
|
||||||
// Authenticated Routes
|
// Authenticated Routes
|
||||||
Route::middleware(['auth:sanctum', 'throttle:60,1'])->group(function () {
|
Route::middleware(['auth:sanctum', 'throttle:60,1'])->group(function () {
|
||||||
Route::post('/logout', [AuthController::class, 'logout']);
|
Route::post('/logout', [AuthController::class, 'logout']);
|
||||||
|
Route::get('/statistics/me', [StatisticsController::class, 'getPersonalStats']);
|
||||||
Route::prefix('users/me')->group(function () {
|
Route::prefix('users/me')->group(function () {
|
||||||
Route::get('/', [ProfileController::class, 'show']);
|
Route::get('/', [ProfileController::class, 'show']);
|
||||||
Route::put('/', [ProfileController::class, 'update']);
|
Route::put('/', [ProfileController::class, 'update']);
|
||||||
Route::delete('/', [ProfileController::class, 'destroy']);
|
Route::delete('/', [ProfileController::class, 'destroy']);
|
||||||
|
Route::get('/coins', [ProfileController::class, 'getCoins']);
|
||||||
Route::put('/password', [
|
Route::put('/password', [
|
||||||
ProfileController::class,
|
ProfileController::class,
|
||||||
'updatePassword',
|
'updatePassword',
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
### Get All Students
|
### Get All Students (Login)
|
||||||
|
# @name login
|
||||||
POST http://localhost:8000/api/login
|
POST http://localhost:8000/api/login
|
||||||
content-Type: application/json
|
Content-Type: application/json
|
||||||
Accept: application/json
|
Accept: application/json
|
||||||
|
|
||||||
{
|
{
|
||||||
"email": "[email protected]",
|
"email": "[email protected]",
|
||||||
"password": "123"
|
"password": "123"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
### Capture the token
|
||||||
|
@token = {{login.response.body.token}}
|
||||||
|
|
||||||
|
### Get All matches
|
||||||
|
GET http://localhost:8000/api/matches
|
||||||
|
Content-Type: application/json
|
||||||
|
Accept: application/json
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
VITE_API_DOMAIN=localhost:8000
|
VITE_API_DOMAIN=localhost:8000
|
||||||
VITE_WS_CONNECTION=ws://localhost:3000
|
VITE_WS_CONNECTION=ws://localhost:3001
|
||||||
|
|||||||
@@ -9,13 +9,12 @@
|
|||||||
"cssVariables": true,
|
"cssVariables": true,
|
||||||
"prefix": ""
|
"prefix": ""
|
||||||
},
|
},
|
||||||
"iconLibrary": "lucide",
|
|
||||||
"aliases": {
|
"aliases": {
|
||||||
"components": "@/components",
|
"components": "@/components",
|
||||||
|
"composables": "@/composables",
|
||||||
"utils": "@/lib/utils",
|
"utils": "@/lib/utils",
|
||||||
"ui": "@/components/ui",
|
"ui": "@/components/ui",
|
||||||
"lib": "@/lib",
|
"lib": "@/lib"
|
||||||
"composables": "@/composables"
|
|
||||||
},
|
},
|
||||||
"registries": {}
|
"iconLibrary": "lucide"
|
||||||
}
|
}
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.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.6.0",
|
||||||
|
|||||||
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 255 KiB |
|
After Width: | Height: | Size: 238 KiB |
|
After Width: | Height: | Size: 273 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 242 KiB |
|
After Width: | Height: | Size: 201 KiB |
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 210 KiB |
|
After Width: | Height: | Size: 191 KiB |
|
After Width: | Height: | Size: 260 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 203 KiB |
|
After Width: | Height: | Size: 279 KiB |
|
After Width: | Height: | Size: 257 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 312 KiB |
@@ -3,8 +3,8 @@
|
|||||||
<nav class="max-w-full p-5 flex flex-row justify-between align-middle">
|
<nav class="max-w-full p-5 flex flex-row justify-between align-middle">
|
||||||
<div class="align-middle text-xl">
|
<div class="align-middle text-xl">
|
||||||
<RouterLink to="/"> {{ pageTitle }} </RouterLink>
|
<RouterLink to="/"> {{ pageTitle }} </RouterLink>
|
||||||
<span class="text-xs" v-if="authStore.currentUser">
|
<span class="text-xs" v-if="authStore.currentUser"
|
||||||
({{ authStore.currentUser?.name }})
|
> ({{ authStore.currentUser?.name }})
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<NavBar @logout="logout" :userLoggedIn="authStore.isLoggedIn" />
|
<NavBar @logout="logout" :userLoggedIn="authStore.isLoggedIn" />
|
||||||
@@ -17,26 +17,22 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { RouterLink, RouterView } from 'vue-router';
|
import { RouterLink, RouterView } from 'vue-router'
|
||||||
import { toast } from 'vue-sonner';
|
import { toast } from 'vue-sonner'
|
||||||
import 'vue-sonner/style.css'
|
import 'vue-sonner/style.css'
|
||||||
import { ref, onMounted } from 'vue';
|
import { ref, onMounted } from 'vue'
|
||||||
import { Toaster } from '@/components/ui/sonner'
|
import { Toaster } from '@/components/ui/sonner'
|
||||||
import NavBar from './components/layout/NavBar.vue';
|
import NavBar from './components/layout/NavBar.vue'
|
||||||
import { useAuthStore } from './stores/auth';
|
import { useAuthStore } from './stores/auth'
|
||||||
import { useSocketStore } from './stores/socket';
|
import { useSocketStore } from './stores/socket'
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const socketStore = useSocketStore()
|
const socketStore = useSocketStore()
|
||||||
|
|
||||||
|
|
||||||
const year = new Date().getFullYear()
|
const year = new Date().getFullYear()
|
||||||
const pageTitle = ref(`DAD ${year}/${String(year + 1).slice(-2)}`)
|
const pageTitle = ref(`DAD ${year}/${String(year + 1).slice(-2)}`)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
|
|
||||||
toast.promise(authStore.logout(), {
|
toast.promise(authStore.logout(), {
|
||||||
loading: 'Calling API',
|
loading: 'Calling API',
|
||||||
success: () => {
|
success: () => {
|
||||||
@@ -44,15 +40,12 @@ const logout = () => {
|
|||||||
},
|
},
|
||||||
error: (data) => `[API] Error - ${data?.response?.data?.message}`,
|
error: (data) => `[API] Error - ${data?.response?.data?.message}`,
|
||||||
})
|
})
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await authStore.restoreSession()
|
await authStore.restoreSession()
|
||||||
socketStore.handleConnection()
|
socketStore.handleConnection()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style></style>`
|
<style></style>
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<template>
|
||||||
|
<div class="relative flex items-center justify-center w-64 h-48">
|
||||||
|
<div
|
||||||
|
v-if="trumpCard"
|
||||||
|
class="absolute rotate-90 origin-center transition-all duration-700"
|
||||||
|
:class="[trumpReveal && 'scale-110']"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="ring-2 ring-amber-400/60 ring-offset-2 rounded-lg transition-all duration-700"
|
||||||
|
:class="[
|
||||||
|
trumpReveal &&
|
||||||
|
'ring-4 ring-amber-400 shadow-[0_0_30px_rgba(251,191,36,0.8)] animate-pulse',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<GameCard :suit="trumpCard.suit" :rank="trumpCard.rank" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="trumpReveal"
|
||||||
|
class="absolute -bottom-8 left-1/2 -translate-x-1/2 bg-amber-500/90 text-white text-[10px] font-bold px-3 py-1 rounded-full whitespace-nowrap animate-pulse"
|
||||||
|
>
|
||||||
|
TRUMP
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="!isEmpty && cardsRemaining > 0"
|
||||||
|
class="absolute -translate-x-8 -translate-y-4 transition-all duration-300"
|
||||||
|
:class="[cardsRemaining === 0 && 'opacity-0 scale-95', deckPulse && 'scale-105']"
|
||||||
|
>
|
||||||
|
<div class="relative">
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 bg-gray-800 rounded-lg opacity-20 translate-x-1 translate-y-1"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 bg-gray-800 rounded-lg opacity-15 translate-x-2 translate-y-2"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 bg-gray-800 rounded-lg opacity-10 translate-x-3 translate-y-3"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<div class="relative">
|
||||||
|
<GameCard suit="c" :rank="1" :face-down="true" />
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="absolute -top-2 -right-2 bg-blue-600 text-white text-xs font-bold rounded-full w-8 h-8 flex items-center justify-center shadow-lg border-2 border-white transition-all duration-300"
|
||||||
|
>
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition-all duration-200"
|
||||||
|
enter-from-class="scale-150 opacity-0"
|
||||||
|
enter-to-class="scale-100 opacity-100"
|
||||||
|
leave-active-class="transition-all duration-200"
|
||||||
|
leave-from-class="scale-100 opacity-100"
|
||||||
|
leave-to-class="scale-50 opacity-0"
|
||||||
|
mode="out-in"
|
||||||
|
>
|
||||||
|
<span :key="cardsRemaining">{{ cardsRemaining }}</span>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import GameCard from './GameCard.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
trumpCard: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
cardsRemaining: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
isEmpty: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
revealTrump: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const deckPulse = ref(false)
|
||||||
|
const trumpReveal = ref(false)
|
||||||
|
|
||||||
|
// Pulse animation when cards remaining changes
|
||||||
|
watch(
|
||||||
|
() => props.cardsRemaining,
|
||||||
|
() => {
|
||||||
|
deckPulse.value = true
|
||||||
|
setTimeout(() => {
|
||||||
|
deckPulse.value = false
|
||||||
|
}, 300)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// Trump reveal animation
|
||||||
|
watch(
|
||||||
|
() => props.revealTrump,
|
||||||
|
(newValue) => {
|
||||||
|
if (newValue) {
|
||||||
|
trumpReveal.value = true
|
||||||
|
setTimeout(() => {
|
||||||
|
trumpReveal.value = false
|
||||||
|
}, 3000) // Show for 3 seconds
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="isVisible"
|
||||||
|
class="fixed pointer-events-none z-[9999]"
|
||||||
|
:style="{
|
||||||
|
left: currentPosition.x + 'px',
|
||||||
|
top: currentPosition.y + 'px',
|
||||||
|
transition: isAnimating ? `all ${duration}ms ease-out` : 'none',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<GameCard :suit="card.suit" :rank="card.rank" :face-down="faceDown" />
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, watch } from 'vue'
|
||||||
|
import GameCard from './GameCard.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
card: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
startPosition: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
validator: (value) => 'x' in value && 'y' in value,
|
||||||
|
},
|
||||||
|
endPosition: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
validator: (value) => 'x' in value && 'y' in value,
|
||||||
|
},
|
||||||
|
duration: {
|
||||||
|
type: Number,
|
||||||
|
default: 500,
|
||||||
|
},
|
||||||
|
faceDown: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
delay: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['complete'])
|
||||||
|
|
||||||
|
const isVisible = ref(false)
|
||||||
|
const isAnimating = ref(false)
|
||||||
|
const currentPosition = ref({ x: 0, y: 0 })
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// Start at the deck position
|
||||||
|
currentPosition.value = { ...props.startPosition }
|
||||||
|
isVisible.value = true
|
||||||
|
|
||||||
|
// Wait for delay, then start animation
|
||||||
|
setTimeout(() => {
|
||||||
|
// Force a reflow to ensure starting position is set
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
isAnimating.value = true
|
||||||
|
currentPosition.value = { ...props.endPosition }
|
||||||
|
|
||||||
|
// After animation completes, emit event and hide
|
||||||
|
setTimeout(() => {
|
||||||
|
emit('complete')
|
||||||
|
isVisible.value = false
|
||||||
|
}, props.duration)
|
||||||
|
})
|
||||||
|
}, props.delay)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="min-h-screen bg-linear-to-br from-green-700 via-green-800 to-green-900 grid grid-rows-[auto_auto_1fr_auto] grid-cols-[1fr_3fr_1fr] gap-6 p-8"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div class="col-start-2 col-end-3 row-start-2 flex justify-center">
|
||||||
|
<PlayerHand
|
||||||
|
:cards="opponentHand"
|
||||||
|
:face-down="true"
|
||||||
|
:max-cards="maxCards"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-start-3 col-end-4 row-start-3 flex items-center justify-center">
|
||||||
|
<DeckArea :trump-card="trumpCard" :cards-remaining="cardsRemaining" :is-empty="deckIsEmpty" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-start-2 col-end-3 row-start-4 flex justify-center">
|
||||||
|
<PlayerHand
|
||||||
|
:cards="playerHand"
|
||||||
|
:face-down="false"
|
||||||
|
:max-cards="maxCards"
|
||||||
|
@card-clicked="handleCardClick"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import PlayerHand from './PlayerHand.vue'
|
||||||
|
import PlayArea from './PlayArea.vue'
|
||||||
|
import DeckArea from './DeckArea.vue'
|
||||||
|
import ScoreDisplay from './ScoreDisplay.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
playerHand: { type: Array, default: () => [] },
|
||||||
|
opponentHand: { type: Array, default: () => [] },
|
||||||
|
currentTrick: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({ playerCard: null, opponentCard: null, winner: null }),
|
||||||
|
},
|
||||||
|
trumpCard: { type: Object, required: true },
|
||||||
|
cardsRemaining: { type: Number, default: 0 },
|
||||||
|
deckIsEmpty: { type: Boolean, default: false },
|
||||||
|
maxCards: { type: Number, default: 3 },
|
||||||
|
// REMOVIDO: playableCardIndices (já não precisamos dele)
|
||||||
|
playerScore: { type: Number, default: 0 },
|
||||||
|
opponentScore: { type: Number, default: 0 },
|
||||||
|
currentTurn: {
|
||||||
|
type: String,
|
||||||
|
default: 'player',
|
||||||
|
validator: (value) => ['player', 'opponent'].includes(value),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['play-card'])
|
||||||
|
|
||||||
|
const handleCardClick = (payload) => {
|
||||||
|
emit('play-card', payload.card)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="relative w-32 aspect-[2.5/3.5] cursor-pointer transition-all duration-300 ease-in-out hover:scale-105 hover:-translate-y-2 hover:shadow-2xl"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="cardImage"
|
||||||
|
:alt="faceDown ? 'Card back' : `${suit} ${rank}`"
|
||||||
|
class="w-full h-full object-cover rounded-lg shadow-lg select-none"
|
||||||
|
draggable="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
suit: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
rank: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
faceDown: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const cardImage = computed(() => {
|
||||||
|
if (props.faceDown) {
|
||||||
|
return '/cards/semFace.png'
|
||||||
|
} else {
|
||||||
|
return `/cards/${props.suit}${props.rank}.png`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition-all duration-500 ease-out"
|
||||||
|
enter-from-class="opacity-0"
|
||||||
|
enter-to-class="opacity-100"
|
||||||
|
leave-active-class="transition-all duration-300 ease-in"
|
||||||
|
leave-from-class="opacity-100"
|
||||||
|
leave-to-class="opacity-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="isVisible"
|
||||||
|
class="fixed inset-0 bg-black/70 backdrop-blur-sm z-[9998] flex items-center justify-center p-4"
|
||||||
|
@click.self="handleClose"
|
||||||
|
>
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition-all duration-500 ease-out delay-100"
|
||||||
|
enter-from-class="opacity-0 scale-75 -translate-y-10"
|
||||||
|
enter-to-class="opacity-100 scale-100 translate-y-0"
|
||||||
|
leave-active-class="transition-all duration-300 ease-in"
|
||||||
|
leave-from-class="opacity-100 scale-100"
|
||||||
|
leave-to-class="opacity-0 scale-90"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="isVisible"
|
||||||
|
class="bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900 rounded-2xl shadow-2xl border-2 max-w-md w-full p-8 relative overflow-hidden"
|
||||||
|
:class="[
|
||||||
|
winner === 'player'
|
||||||
|
? 'border-emerald-500/50'
|
||||||
|
: winner === 'opponent'
|
||||||
|
? 'border-rose-500/50'
|
||||||
|
: 'border-gray-600/50',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<!-- Confetti Effect (if player wins) -->
|
||||||
|
<div
|
||||||
|
v-if="winner === 'player' && showConfetti"
|
||||||
|
class="absolute inset-0 pointer-events-none"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="i in 30"
|
||||||
|
:key="i"
|
||||||
|
class="absolute w-2 h-2 animate-confetti"
|
||||||
|
:style="{
|
||||||
|
left: Math.random() * 100 + '%',
|
||||||
|
top: '-10px',
|
||||||
|
backgroundColor: ['#fbbf24', '#34d399', '#60a5fa', '#f472b6', '#a78bfa'][
|
||||||
|
Math.floor(Math.random() * 5)
|
||||||
|
],
|
||||||
|
animationDelay: Math.random() * 2 + 's',
|
||||||
|
animationDuration: 3 + Math.random() * 2 + 's',
|
||||||
|
}"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Winner Icon/Badge -->
|
||||||
|
<div class="flex justify-center mb-6">
|
||||||
|
<div
|
||||||
|
class="relative"
|
||||||
|
:class="[winner === 'player' ? 'animate-bounce' : 'animate-pulse']"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="winner === 'player'"
|
||||||
|
class="w-24 h-24 rounded-full bg-gradient-to-br from-emerald-400 to-emerald-600 flex items-center justify-center shadow-lg shadow-emerald-500/50"
|
||||||
|
>
|
||||||
|
<span class="text-5xl">🏆</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="winner === 'opponent'"
|
||||||
|
class="w-24 h-24 rounded-full bg-gradient-to-br from-rose-400 to-rose-600 flex items-center justify-center shadow-lg shadow-rose-500/50"
|
||||||
|
>
|
||||||
|
<span class="text-5xl">😔</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="w-24 h-24 rounded-full bg-gradient-to-br from-gray-400 to-gray-600 flex items-center justify-center shadow-lg"
|
||||||
|
>
|
||||||
|
<span class="text-5xl">🤝</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Title -->
|
||||||
|
<h2
|
||||||
|
class="text-4xl font-black text-center mb-2 bg-clip-text text-transparent bg-gradient-to-r"
|
||||||
|
:class="[
|
||||||
|
winner === 'player'
|
||||||
|
? 'from-emerald-300 to-emerald-500'
|
||||||
|
: winner === 'opponent'
|
||||||
|
? 'from-rose-300 to-rose-500'
|
||||||
|
: 'from-gray-300 to-gray-500',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ title }}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<!-- Subtitle -->
|
||||||
|
<p class="text-center text-gray-400 mb-8">{{ subtitle }}</p>
|
||||||
|
|
||||||
|
<!-- Scores -->
|
||||||
|
<div class="bg-black/30 rounded-xl p-6 mb-8 space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-gray-300 font-medium">{{ playerName }}</span>
|
||||||
|
<span
|
||||||
|
class="text-3xl font-black tabular-nums transition-all duration-500"
|
||||||
|
:class="[
|
||||||
|
playerScore > opponentScore
|
||||||
|
? 'text-emerald-400 drop-shadow-[0_0_12px_rgba(52,211,153,0.6)]'
|
||||||
|
: 'text-white',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ displayPlayerScore }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="h-px bg-gray-700"></div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-gray-300 font-medium">{{ opponentName }}</span>
|
||||||
|
<span
|
||||||
|
class="text-3xl font-black tabular-nums transition-all duration-500"
|
||||||
|
:class="[
|
||||||
|
opponentScore > playerScore
|
||||||
|
? 'text-rose-400 drop-shadow-[0_0_12px_rgba(251,113,133,0.6)]'
|
||||||
|
: 'text-white',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ displayOpponentScore }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats (optional) -->
|
||||||
|
<div v-if="stats" class="grid grid-cols-2 gap-4 mb-8">
|
||||||
|
<div class="bg-black/20 rounded-lg p-3 text-center">
|
||||||
|
<p class="text-gray-400 text-xs mb-1">Your Tricks</p>
|
||||||
|
<p class="text-2xl font-bold text-white">{{ stats.playerTricks }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-black/20 rounded-lg p-3 text-center">
|
||||||
|
<p class="text-gray-400 text-xs mb-1">Opponent Tricks</p>
|
||||||
|
<p class="text-2xl font-bold text-white">{{ stats.opponentTricks }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button
|
||||||
|
v-if="!isLoggingOut"
|
||||||
|
@click="$emit('play-again')"
|
||||||
|
class="flex-1 bg-gradient-to-r from-emerald-500 to-emerald-600 hover:from-emerald-600 hover:to-emerald-700 text-white font-bold py-3 px-6 rounded-lg transition-all shadow-lg"
|
||||||
|
>
|
||||||
|
Play Again
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="!hideClose"
|
||||||
|
@click="handleClose"
|
||||||
|
class="flex-1 font-bold py-3 px-6 rounded-lg transition-all"
|
||||||
|
:class="[
|
||||||
|
isLoggingOut
|
||||||
|
? 'bg-red-600 hover:bg-red-700 text-white w-full'
|
||||||
|
: 'bg-gray-700 hover:bg-gray-600 text-white',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ isLoggingOut ? 'Confirm Logout' : 'Close' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch, onMounted } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
isVisible: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
winner: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
validator: (value) => ['player', 'opponent', 'draw'].includes(value),
|
||||||
|
},
|
||||||
|
playerScore: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
opponentScore: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
playerName: {
|
||||||
|
type: String,
|
||||||
|
default: 'You',
|
||||||
|
},
|
||||||
|
opponentName: {
|
||||||
|
type: String,
|
||||||
|
default: 'Bot',
|
||||||
|
},
|
||||||
|
stats: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
hideClose: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
isLoggingOut: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['close', 'play-again'])
|
||||||
|
|
||||||
|
const showConfetti = ref(false)
|
||||||
|
const displayPlayerScore = ref(0)
|
||||||
|
const displayOpponentScore = ref(0)
|
||||||
|
|
||||||
|
// Computed title based on winner
|
||||||
|
const title = computed(() => {
|
||||||
|
if (props.winner === 'player') return 'Victory!'
|
||||||
|
if (props.winner === 'opponent') return 'Defeat'
|
||||||
|
return 'Draw!'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Computed subtitle
|
||||||
|
const subtitle = computed(() => {
|
||||||
|
if (props.winner === 'player') return 'Congratulations! You won the game!'
|
||||||
|
if (props.winner === 'opponent') return 'Better luck next time!'
|
||||||
|
return 'The game ended in a draw'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Animate score count-up
|
||||||
|
const animateScore = (fromValue, toValue, callback) => {
|
||||||
|
const duration = 1000
|
||||||
|
const steps = 30
|
||||||
|
const stepDuration = duration / steps
|
||||||
|
const increment = (toValue - fromValue) / steps
|
||||||
|
|
||||||
|
let current = fromValue
|
||||||
|
let step = 0
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
step++
|
||||||
|
current += increment
|
||||||
|
|
||||||
|
if (step >= steps) {
|
||||||
|
current = toValue
|
||||||
|
clearInterval(interval)
|
||||||
|
}
|
||||||
|
|
||||||
|
callback(Math.round(current))
|
||||||
|
}, stepDuration)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch for visibility and trigger animations
|
||||||
|
watch(
|
||||||
|
() => props.isVisible,
|
||||||
|
(newValue) => {
|
||||||
|
if (newValue) {
|
||||||
|
// Start confetti if player wins
|
||||||
|
if (props.winner === 'player') {
|
||||||
|
setTimeout(() => {
|
||||||
|
showConfetti.value = true
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animate scores counting up
|
||||||
|
setTimeout(() => {
|
||||||
|
animateScore(0, props.playerScore, (value) => {
|
||||||
|
displayPlayerScore.value = value
|
||||||
|
})
|
||||||
|
animateScore(0, props.opponentScore, (value) => {
|
||||||
|
displayOpponentScore.value = value
|
||||||
|
})
|
||||||
|
}, 400)
|
||||||
|
} else {
|
||||||
|
showConfetti.value = false
|
||||||
|
displayPlayerScore.value = 0
|
||||||
|
displayOpponentScore.value = 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@keyframes confetti {
|
||||||
|
0% {
|
||||||
|
transform: translateY(0) rotate(0deg);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateY(100vh) rotate(720deg);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-confetti {
|
||||||
|
animation: confetti linear forwards;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex items-center justify-center h-64 relative">
|
||||||
|
<div class="relative w-96 h-full flex items-center justify-center">
|
||||||
|
<!-- Opponent Card Slot -->
|
||||||
|
<div
|
||||||
|
class="absolute top-4"
|
||||||
|
:class="[
|
||||||
|
winner === 'opponent' && 'ring-4 ring-green-400 ring-offset-4 rounded-lg animate-pulse',
|
||||||
|
]"
|
||||||
|
:style="{ zIndex: opponentCard ? (firstPlayer === 'opponent' ? 1 : 2) : 0 }"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="!opponentCard"
|
||||||
|
class="w-32 aspect-[2.5/3.5] border-4 border-dashed border-gray-400/40 rounded-lg bg-gray-100/20 flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<span class="text-gray-400 text-xs font-medium">Opponent</span>
|
||||||
|
</div>
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition-all duration-500 ease-out"
|
||||||
|
enter-from-class="opacity-0 -translate-y-[200px] scale-75"
|
||||||
|
enter-to-class="opacity-100 translate-y-0 scale-100 rotate-6"
|
||||||
|
leave-active-class="transition-all duration-500 ease-in"
|
||||||
|
:leave-from-class="`opacity-100 translate-y-0 scale-100 rotate-6`"
|
||||||
|
:leave-to-class="
|
||||||
|
winner === 'opponent'
|
||||||
|
? 'opacity-0 -translate-y-[200px] translate-x-[300px] scale-50 rotate-0'
|
||||||
|
: 'opacity-0 translate-y-[200px] translate-x-[300px] scale-50 rotate-0'
|
||||||
|
"
|
||||||
|
mode="out-in"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="opponentCard"
|
||||||
|
:key="`${opponentCard.suit}-${opponentCard.rank}`"
|
||||||
|
class="rotate-6"
|
||||||
|
>
|
||||||
|
<GameCard :suit="opponentCard.suit" :rank="opponentCard.rank" />
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Player Card Slot -->
|
||||||
|
<div
|
||||||
|
class="absolute bottom-4"
|
||||||
|
:class="[
|
||||||
|
winner === 'player' && 'ring-4 ring-green-400 ring-offset-4 rounded-lg animate-pulse',
|
||||||
|
]"
|
||||||
|
:style="{ zIndex: playerCard ? (firstPlayer === 'player' ? 1 : 2) : 0 }"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="!playerCard"
|
||||||
|
class="w-32 aspect-[2.5/3.5] border-4 border-dashed border-gray-400/40 rounded-lg bg-gray-100/20 flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<span class="text-gray-400 text-xs font-medium">You</span>
|
||||||
|
</div>
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition-all duration-500 ease-out"
|
||||||
|
enter-from-class="opacity-0 translate-y-[200px] scale-75"
|
||||||
|
enter-to-class="opacity-100 translate-y-0 scale-100 -rotate-6"
|
||||||
|
leave-active-class="transition-all duration-500 ease-in"
|
||||||
|
:leave-from-class="`opacity-100 translate-y-0 scale-100 -rotate-6`"
|
||||||
|
:leave-to-class="
|
||||||
|
winner === 'opponent'
|
||||||
|
? 'opacity-0 -translate-y-[200px] translate-x-[300px] scale-50 rotate-0'
|
||||||
|
: 'opacity-0 translate-y-[200px] translate-x-[300px] scale-50 rotate-0'
|
||||||
|
"
|
||||||
|
mode="out-in"
|
||||||
|
>
|
||||||
|
<div v-if="playerCard" :key="`${playerCard.suit}-${playerCard.rank}`" class="-rotate-6">
|
||||||
|
<GameCard :suit="playerCard.suit" :rank="playerCard.rank" />
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import GameCard from './GameCard.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
playerCard: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
opponentCard: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
winner: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
validator: (value) => [null, 'player', 'opponent'].includes(value),
|
||||||
|
},
|
||||||
|
firstPlayer: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
validator: (value) => [null, 'player', 'opponent'].includes(value),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex justify-center items-end relative h-40">
|
||||||
|
<TransitionGroup
|
||||||
|
enter-active-class="transition-all duration-500 ease-out"
|
||||||
|
enter-from-class="opacity-0 -translate-y-8 scale-75"
|
||||||
|
enter-to-class="opacity-100 translate-y-0 scale-100"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="(card, index) in cards"
|
||||||
|
:key="`${card.suit}-${card.rank}-${index}`"
|
||||||
|
class="absolute transition-all duration-300 ease-out"
|
||||||
|
:style="getCardStyle(index)"
|
||||||
|
@mouseenter="hoveredIndex = index"
|
||||||
|
@mouseleave="hoveredIndex = null"
|
||||||
|
@click="handleCardClick(card, index)"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
'transition-all duration-300 rounded-lg',
|
||||||
|
isValid(card)
|
||||||
|
? 'cursor-pointer hover:shadow-xl ring-2 ring-transparent hover:ring-yellow-400/50'
|
||||||
|
: 'cursor-not-allowed opacity-60 grayscale brightness-75',
|
||||||
|
hoveredIndex === index && isValid(card) && 'scale-110 -translate-y-4 z-50',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<GameCard :suit="card.suit" :rank="card.rank" :face-down="faceDown" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TransitionGroup>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import GameCard from './GameCard.vue'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
import { useBiscaStore } from '@/stores/bisca'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
cards: {
|
||||||
|
type: Array,
|
||||||
|
required: true,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
faceDown: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
maxCards: {
|
||||||
|
type: Number,
|
||||||
|
default: 3,
|
||||||
|
validator: (value) => [3, 9].includes(value),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['card-clicked'])
|
||||||
|
const store = useBiscaStore()
|
||||||
|
|
||||||
|
const hoveredIndex = ref(null)
|
||||||
|
|
||||||
|
const isValid = (card) => {
|
||||||
|
if (props.faceDown) return false
|
||||||
|
return store.canPlayCard(card)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCardClick = (card, index) => {
|
||||||
|
if (props.faceDown) return
|
||||||
|
|
||||||
|
if (store.canPlayCard(card)) {
|
||||||
|
emit('card-clicked', { card, index })
|
||||||
|
} else {
|
||||||
|
toast.warning('Jogada Inválida', {
|
||||||
|
description: 'Tens de assistir ao naipe jogado!',
|
||||||
|
duration: 2000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const overlapPercentage = computed(() => {
|
||||||
|
return props.maxCards === 3 ? 0.7 : 0.85
|
||||||
|
})
|
||||||
|
|
||||||
|
const cardWidth = 128
|
||||||
|
const getCardStyle = (index) => {
|
||||||
|
const totalCards = props.cards.length
|
||||||
|
const overlapOffset = cardWidth * (1 - overlapPercentage.value)
|
||||||
|
const totalWidth = cardWidth + (totalCards - 1) * overlapOffset
|
||||||
|
const startOffset = -totalWidth / 2 + cardWidth / 2
|
||||||
|
|
||||||
|
let left = startOffset + index * overlapOffset
|
||||||
|
|
||||||
|
if (hoveredIndex.value !== null && hoveredIndex.value !== index) {
|
||||||
|
if (isValid(props.cards[hoveredIndex.value])) {
|
||||||
|
if (index < hoveredIndex.value) {
|
||||||
|
left -= 10
|
||||||
|
} else if (index > hoveredIndex.value) {
|
||||||
|
left += 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
left: `calc(50% + ${left}px)`,
|
||||||
|
zIndex: hoveredIndex.value === index ? 50 : index + 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="inline-flex bg-gray-900/80 backdrop-blur-sm border border-gray-700/50 shadow-xl rounded-md px-4 py-1.5 items-center gap-4"
|
||||||
|
>
|
||||||
|
<!-- Opponent Score (Left) -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="flex flex-col items-start">
|
||||||
|
<span class="text-gray-400 text-[10px] uppercase tracking-wide mb-0.5">{{
|
||||||
|
opponentName
|
||||||
|
}}</span>
|
||||||
|
<span
|
||||||
|
class="text-2xl font-black tabular-nums transition-all duration-300"
|
||||||
|
:class="[
|
||||||
|
opponentScore > playerScore
|
||||||
|
? 'text-amber-400 drop-shadow-[0_0_12px_rgba(251,191,36,0.6)]'
|
||||||
|
: 'text-white',
|
||||||
|
scoreChanged === 'opponent' && 'scale-110',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ displayOpponentScore }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Center Turn Indicator -->
|
||||||
|
<div class="flex flex-col items-center gap-1">
|
||||||
|
<div
|
||||||
|
class="px-3 py-0.5 rounded-full text-[9px] font-bold uppercase tracking-wider transition-all duration-300"
|
||||||
|
:class="[
|
||||||
|
currentTurn === 'player'
|
||||||
|
? 'bg-emerald-500/20 text-emerald-400 border border-emerald-500/40 shadow-[0_0_10px_rgba(52,211,153,0.4)]'
|
||||||
|
: 'bg-rose-500/20 text-rose-400 border border-rose-500/40 shadow-[0_0_10px_rgba(251,113,133,0.4)]',
|
||||||
|
turnChanged && 'animate-pulse',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ currentTurn === 'player' ? 'Your Turn' : "Bot's Turn" }}
|
||||||
|
</div>
|
||||||
|
<span v-if="roundNumber" class="text-gray-500 text-[10px] font-medium">
|
||||||
|
Round {{ roundNumber }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Player Score (Right) -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="flex flex-col items-end">
|
||||||
|
<span class="text-gray-400 text-[10px] uppercase tracking-wide mb-0.5">{{
|
||||||
|
playerName
|
||||||
|
}}</span>
|
||||||
|
<span
|
||||||
|
class="text-2xl font-black tabular-nums transition-all duration-300"
|
||||||
|
:class="[
|
||||||
|
playerScore > opponentScore
|
||||||
|
? 'text-amber-400 drop-shadow-[0_0_12px_rgba(251,191,36,0.6)]'
|
||||||
|
: 'text-white',
|
||||||
|
scoreChanged === 'player' && 'scale-110',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ displayPlayerScore }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch, computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
playerScore: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
opponentScore: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
playerName: {
|
||||||
|
type: String,
|
||||||
|
default: 'You',
|
||||||
|
},
|
||||||
|
opponentName: {
|
||||||
|
type: String,
|
||||||
|
default: 'Bot',
|
||||||
|
},
|
||||||
|
currentTurn: {
|
||||||
|
type: String,
|
||||||
|
default: 'player',
|
||||||
|
validator: (value) => ['player', 'opponent'].includes(value),
|
||||||
|
},
|
||||||
|
roundNumber: {
|
||||||
|
type: Number,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const scoreChanged = ref(null)
|
||||||
|
const turnChanged = ref(false)
|
||||||
|
const displayPlayerScore = ref(props.playerScore)
|
||||||
|
const displayOpponentScore = ref(props.opponentScore)
|
||||||
|
|
||||||
|
// Animate score count-up
|
||||||
|
const animateScore = (fromValue, toValue, callback) => {
|
||||||
|
const duration = 500 // Total animation duration in ms
|
||||||
|
const steps = 20 // Number of increments
|
||||||
|
const stepDuration = duration / steps
|
||||||
|
const increment = (toValue - fromValue) / steps
|
||||||
|
|
||||||
|
let current = fromValue
|
||||||
|
let step = 0
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
step++
|
||||||
|
current += increment
|
||||||
|
|
||||||
|
if (step >= steps) {
|
||||||
|
current = toValue
|
||||||
|
clearInterval(interval)
|
||||||
|
}
|
||||||
|
|
||||||
|
callback(Math.round(current))
|
||||||
|
}, stepDuration)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch for player score changes and trigger count-up animation
|
||||||
|
watch(
|
||||||
|
() => props.playerScore,
|
||||||
|
(newScore, oldScore) => {
|
||||||
|
scoreChanged.value = 'player'
|
||||||
|
|
||||||
|
if (oldScore !== undefined && newScore !== oldScore) {
|
||||||
|
animateScore(oldScore, newScore, (value) => {
|
||||||
|
displayPlayerScore.value = value
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
displayPlayerScore.value = newScore
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
scoreChanged.value = null
|
||||||
|
}, 300)
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
// Watch for opponent score changes and trigger count-up animation
|
||||||
|
watch(
|
||||||
|
() => props.opponentScore,
|
||||||
|
(newScore, oldScore) => {
|
||||||
|
scoreChanged.value = 'opponent'
|
||||||
|
|
||||||
|
if (oldScore !== undefined && newScore !== oldScore) {
|
||||||
|
animateScore(oldScore, newScore, (value) => {
|
||||||
|
displayOpponentScore.value = value
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
displayOpponentScore.value = newScore
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
scoreChanged.value = null
|
||||||
|
}, 300)
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
// Watch for turn changes and trigger pulse animation
|
||||||
|
watch(
|
||||||
|
() => props.currentTurn,
|
||||||
|
() => {
|
||||||
|
turnChanged.value = true
|
||||||
|
setTimeout(() => {
|
||||||
|
turnChanged.value = false
|
||||||
|
}, 1000)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
</script>
|
||||||
@@ -15,14 +15,16 @@
|
|||||||
</li>
|
</li>
|
||||||
</NavigationMenuContent>
|
</NavigationMenuContent>
|
||||||
</NavigationMenuItem>
|
</NavigationMenuItem>
|
||||||
|
|
||||||
<NavigationMenuItem v-if="!userLoggedIn">
|
<NavigationMenuItem v-if="!userLoggedIn">
|
||||||
<NavigationMenuLink>
|
<NavigationMenuLink>
|
||||||
<RouterLink to="/login">Login</RouterLink>
|
<RouterLink to="/login">Login</RouterLink>
|
||||||
</NavigationMenuLink>
|
</NavigationMenuLink>
|
||||||
</NavigationMenuItem>
|
</NavigationMenuItem>
|
||||||
|
|
||||||
<NavigationMenuItem v-else>
|
<NavigationMenuItem v-else>
|
||||||
<NavigationMenuLink>
|
<NavigationMenuLink>
|
||||||
<a @click.prevent="logoutClickHandler">Logout</a>
|
<a href="/home" @click.prevent="logoutClickHandler" class="cursor-pointer">Logout</a>
|
||||||
</NavigationMenuLink>
|
</NavigationMenuLink>
|
||||||
<NavigationMenuLink>
|
<NavigationMenuLink>
|
||||||
<RouterLink to="/user">Profile</RouterLink>
|
<RouterLink to="/user">Profile</RouterLink>
|
||||||
@@ -34,6 +36,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { useBiscaStore } from '@/stores/bisca'
|
||||||
import {
|
import {
|
||||||
NavigationMenu,
|
NavigationMenu,
|
||||||
NavigationMenuContent,
|
NavigationMenuContent,
|
||||||
@@ -44,12 +47,27 @@ import {
|
|||||||
} from '@/components/ui/navigation-menu'
|
} from '@/components/ui/navigation-menu'
|
||||||
import router from '@/router';
|
import router from '@/router';
|
||||||
|
|
||||||
|
|
||||||
const emits = defineEmits(['logout'])
|
const emits = defineEmits(['logout'])
|
||||||
const { userLoggedIn } = defineProps(['userLoggedIn'])
|
const { userLoggedIn } = defineProps(['userLoggedIn'])
|
||||||
|
|
||||||
|
const biscaStore = useBiscaStore()
|
||||||
|
|
||||||
const logoutClickHandler = () => {
|
const logoutClickHandler = () => {
|
||||||
|
if (biscaStore.isGameRunning) {
|
||||||
|
const confirmLogout = window.confirm(
|
||||||
|
'⚠️ Jogo em Progresso!\n\nSe fizeres Logout agora, perderás o jogo atual e serás considerado PERDEDOR.\n\nQueres mesmo sair?'
|
||||||
|
)
|
||||||
|
if (!confirmLogout) return
|
||||||
|
|
||||||
|
biscaStore.isLoggingOut = true
|
||||||
|
|
||||||
|
biscaStore.quitGame()
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
emits('logout')
|
emits('logout')
|
||||||
router.push('/login')
|
|
||||||
|
router.push({ name: 'home' })
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup>
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
class: { type: null, required: false },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup>
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
class: { type: null, required: false },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'col-start-2 row-span-2 row-start-1 self-start justify-self-end',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<script setup>
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
class: { type: null, required: false },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div data-slot="card-content" :class="cn('px-6', props.class)">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<script setup>
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
class: { type: null, required: false },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<p
|
||||||
|
data-slot="card-description"
|
||||||
|
:class="cn('text-muted-foreground text-sm', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<script setup>
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
class: { type: null, required: false },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
:class="cn('flex items-center px-6 [.border-t]:pt-6', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup>
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
class: { type: null, required: false },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<script setup>
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
class: { type: null, required: false },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<h3
|
||||||
|
data-slot="card-title"
|
||||||
|
:class="cn('leading-none font-semibold', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</h3>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export { default as Card } from "./Card.vue";
|
||||||
|
export { default as CardAction } from "./CardAction.vue";
|
||||||
|
export { default as CardContent } from "./CardContent.vue";
|
||||||
|
export { default as CardDescription } from "./CardDescription.vue";
|
||||||
|
export { default as CardFooter } from "./CardFooter.vue";
|
||||||
|
export { default as CardHeader } from "./CardHeader.vue";
|
||||||
|
export { default as CardTitle } from "./CardTitle.vue";
|
||||||
@@ -39,6 +39,9 @@
|
|||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--radius-2xl: calc(var(--radius) + 8px);
|
||||||
|
--radius-3xl: calc(var(--radius) + 12px);
|
||||||
|
--radius-4xl: calc(var(--radius) + 16px);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { clsx } from "clsx";
|
import { clsx } from "clsx";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
export function cn(...inputs) {
|
export function cn(...inputs) {
|
||||||
return twMerge(clsx(inputs));
|
return twMerge(clsx(inputs));
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const app = createApp(App)
|
|||||||
|
|
||||||
//app.provide('socket', io(wsConnection))
|
//app.provide('socket', io(wsConnection))
|
||||||
app.provide('serverBaseURL', `http://${apiDomain}`)
|
app.provide('serverBaseURL', `http://${apiDomain}`)
|
||||||
app.provide('apiBaseURL', `http://${apiDomain}/api`)
|
app.provide('apiBaseURL', `http://${apiDomain}/api/v1`)
|
||||||
|
|
||||||
app.use(createPinia())
|
app.use(createPinia())
|
||||||
app.use(router)
|
app.use(router)
|
||||||
|
|||||||
@@ -0,0 +1,342 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,444 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
<template>
|
||||||
|
<div class="relative min-h-screen overflow-hidden">
|
||||||
|
<div v-if="store.isGameRunning" 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="store.isGameRunning">
|
||||||
|
<GameBoard
|
||||||
|
:trump-card="store.trumpCard"
|
||||||
|
:cards-remaining="store.deck.length + (store.trumpCard ? 1 : 0)"
|
||||||
|
:player-hand="store.playerHand"
|
||||||
|
: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 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>
|
||||||
|
|
||||||
|
<GameOver
|
||||||
|
:is-visible="store.isGameOver"
|
||||||
|
:winner="store.winner || 'draw'"
|
||||||
|
:player-score="store.playerScore"
|
||||||
|
:opponent-score="store.opponentScore"
|
||||||
|
:stats="{ playerTricks: store.playerTricks, opponentTricks: store.opponentTricks }"
|
||||||
|
:is-logging-out="store.isLoggingOut"
|
||||||
|
@play-again="handlePlayAgain"
|
||||||
|
@close="handleCloseModal"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, onUnmounted, onBeforeMount } from 'vue'
|
||||||
|
import { useRouter, onBeforeRouteLeave } from 'vue-router'
|
||||||
|
import { toast } from 'vue-sonner' // Import Toast for feedback
|
||||||
|
import { useBiscaStore } from '@/stores/bisca'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import GameBoard from '@/components/game/GameBoard.vue'
|
||||||
|
import GameOver from '@/components/game/GameOver.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
gameType: {
|
||||||
|
type: Number,
|
||||||
|
default: 3,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const store = useBiscaStore()
|
||||||
|
const router = useRouter()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
onBeforeMount(() => {
|
||||||
|
store.isGameOver = false
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
store.startGame(props.gameType)
|
||||||
|
window.addEventListener('beforeunload', handleBrowserUnload)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('beforeunload', handleBrowserUnload)
|
||||||
|
|
||||||
|
store.isGameOver = false
|
||||||
|
store.isGameRunning = false
|
||||||
|
store.isLoggingOut = false
|
||||||
|
})
|
||||||
|
|
||||||
|
const handlePlayCard = (card) => {
|
||||||
|
store.playerPlayCard(card)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePlayAgain = () => {
|
||||||
|
store.startGame(props.gameType)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSurrender = () => {
|
||||||
|
const confirmSurrender = window.confirm(
|
||||||
|
'Are you sure you want to surrender? Your opponent will be awarded the win.',
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!confirmSurrender) return
|
||||||
|
|
||||||
|
store.quitGame()
|
||||||
|
|
||||||
|
toast.error('Game Surrendered', {
|
||||||
|
description: 'You forfeited the match.',
|
||||||
|
})
|
||||||
|
|
||||||
|
router.push({ name: 'home' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCloseModal = () => {
|
||||||
|
if (store.isLoggingOut) {
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
authStore.logout()
|
||||||
|
store.$reset()
|
||||||
|
router.push({ name: 'login' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
store.isGameRunning = false
|
||||||
|
store.isGameOver = false
|
||||||
|
router.push({ name: 'home' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBrowserUnload = (event) => {
|
||||||
|
if (store.isGameRunning) {
|
||||||
|
event.preventDefault()
|
||||||
|
event.returnValue = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeRouteLeave((to, from, next) => {
|
||||||
|
if (!store.isGameRunning) {
|
||||||
|
next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmExit = window.confirm(
|
||||||
|
'⚠️ Game in Progress!\n\nIf you leave now, the game will be cancelled and recorded as a LOSS.\n\nAre you sure you want to leave?',
|
||||||
|
)
|
||||||
|
|
||||||
|
if (confirmExit) {
|
||||||
|
store.quitGame()
|
||||||
|
next()
|
||||||
|
} else {
|
||||||
|
next(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -1,11 +1,242 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { ref, onMounted, nextTick } from 'vue'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import { useAPIStore } from '@/stores/api'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const apiStore = useAPIStore()
|
||||||
|
const router = useRouter()
|
||||||
|
const openGames = ref([])
|
||||||
|
const selectedMode = ref('9')
|
||||||
|
const observerTarget = ref(null);
|
||||||
|
const observer = ref(null);
|
||||||
|
const isLoading = ref(false);
|
||||||
|
const pageCounter = ref(1);
|
||||||
|
const showHostModal = ref(false)
|
||||||
|
const showJoinModal = ref(false)
|
||||||
|
const selectedGame = ref(null)
|
||||||
|
const isReady = ref(false)
|
||||||
|
|
||||||
|
const setupObserver = () => {
|
||||||
|
if (observer.value) observer.value.disconnect();
|
||||||
|
|
||||||
|
observer.value = new IntersectionObserver((entries) => {
|
||||||
|
if (entries[0].isIntersecting && !isLoading.value && openGames.value.length > 0) {
|
||||||
|
getPendingGames();
|
||||||
|
}
|
||||||
|
}, { threshold: 0.1 });
|
||||||
|
|
||||||
|
if (observerTarget.value) {
|
||||||
|
observer.value.observe(observerTarget.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPendingGames = async (mode = selectedMode.value) => {
|
||||||
|
if (isLoading.value) return;
|
||||||
|
isLoading.value = true;
|
||||||
|
|
||||||
|
|
||||||
|
apiStore.gameQueryParameters.page = pageCounter.value++;
|
||||||
|
apiStore.gameQueryParameters.filters.sort_direction = "asc"
|
||||||
|
apiStore.gameQueryParameters.filters.type = mode
|
||||||
|
apiStore.gameQueryParameters.filters.status = 'Pending'
|
||||||
|
try {
|
||||||
|
const response = await apiStore.getGames();
|
||||||
|
const newGames = response.data.data;
|
||||||
|
openGames.value = [...openGames.value, ...newGames];
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clickMode = async (mode) => {
|
||||||
|
if (selectedMode.value === mode) return;
|
||||||
|
openGames.value = [];
|
||||||
|
selectedMode.value = mode;
|
||||||
|
pageCounter.value = 1;
|
||||||
|
await getPendingGames();
|
||||||
|
setupObserver();
|
||||||
|
}
|
||||||
|
|
||||||
|
const startSingle = () => {
|
||||||
|
router.push({ name: 'bisca' + selectedMode.value });
|
||||||
|
}
|
||||||
|
|
||||||
|
const JoinGameModal = (game) => {
|
||||||
|
selectedGame.value = game
|
||||||
|
isReady.value = false
|
||||||
|
showJoinModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostGameModal = () => {
|
||||||
|
showHostModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleReady = () => {
|
||||||
|
isReady.value = !isReady.value
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await getPendingGames();
|
||||||
|
await nextTick();
|
||||||
|
setupObserver();
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped></style>`
|
<template>
|
||||||
|
<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 px-4">
|
||||||
|
|
||||||
|
<div class="relative w-full max-w-md bg-card rounded-lg border shadow-sm overflow-hidden">
|
||||||
|
<div class="relative h-14 flex items-center">
|
||||||
|
|
||||||
|
<button @click="clickMode('9')"
|
||||||
|
class="h-full flex items-center justify-center font-semibold transition-all duration-500 ease-in-out border-r last:border-r-0"
|
||||||
|
:class="{
|
||||||
|
'w-2/3 z-10 text-purple-600 dark:text-purple-400 bg-purple-500/10': selectedMode === '9',
|
||||||
|
'w-1/3 z-0 text-muted-foreground/40 bg-transparent': selectedMode !== '9'
|
||||||
|
}">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-center gap-5 w-[100vw]">
|
||||||
|
<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>
|
||||||
|
<Card class="w-full max-w-2xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="text-3xl font-bold text-center">
|
||||||
|
MultiPlayer
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription class="text-center">
|
||||||
|
Go one on one with another player!
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<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="observerTarget" class="h-10 flex items-center justify-center">
|
||||||
|
<span v-if="isLoading" 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 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">
|
||||||
|
<div class="rounded-lg bg-muted p-4 text-sm">
|
||||||
|
<p><strong>Game Type:</strong> Bisca de {{ selectedMode }}</p>
|
||||||
|
<p><strong>Started at:</strong> {{ selectedGame?.began_at }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isReady"
|
||||||
|
class="flex items-center justify-center rounded-md bg-green-500/10 p-3 text-green-600 dark:text-green-400">
|
||||||
|
<span class="animate-pulse font-semibold">Waiting for the host to start...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3 pt-4">
|
||||||
|
<Button variant="destructive" class="flex-1" @click="showJoinModal = false">
|
||||||
|
Leave
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button class="flex-1 transition-all duration-300" :variant="isReady ? 'outline' : 'default'"
|
||||||
|
:class="!isReady ? 'bg-purple-600 hover:bg-purple-700' : ''" @click="toggleReady">
|
||||||
|
{{ isReady ? 'Cancel Ready' : 'Ready up' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|||||||
@@ -2,20 +2,56 @@ 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 LaravelPage from '@/pages/testing/LaravelPage.vue'
|
||||||
import WebsocketsPage from '@/pages/testing/WebsocketsPage.vue'
|
import WebsocketsPage from '@/pages/testing/WebsocketsPage.vue'
|
||||||
|
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 UserPage from '@/pages/user/UserPage.vue'
|
import UserPage from '@/pages/user/UserPage.vue'
|
||||||
|
import SinglePlayerGamePage from '@/pages/game/SinglePlayerGamePage.vue'
|
||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
routes: [
|
routes: [
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
|
name: 'home',
|
||||||
component: HomePage,
|
component: HomePage,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/login',
|
path: '/login',
|
||||||
|
name: 'login',
|
||||||
component: LoginPage,
|
component: LoginPage,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/game/3',
|
||||||
|
name: 'bisca3',
|
||||||
|
component: SinglePlayerGamePage,
|
||||||
|
props: { gameType: 3 },
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/game/9',
|
||||||
|
name: 'bisca9',
|
||||||
|
component: SinglePlayerGamePage,
|
||||||
|
props: { gameType: 9 },
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/game/3',
|
||||||
|
name: 'bisca3',
|
||||||
|
component: SinglePlayerGamePage,
|
||||||
|
props: { gameType: 3 },
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/game/9',
|
||||||
|
name: 'bisca9',
|
||||||
|
component: SinglePlayerGamePage,
|
||||||
|
props: { gameType: 9 },
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/user',
|
path: '/user',
|
||||||
component: UserPage,
|
component: UserPage,
|
||||||
@@ -31,9 +67,38 @@ const router = createRouter({
|
|||||||
path: 'websockets',
|
path: 'websockets',
|
||||||
component: WebsocketsPage,
|
component: WebsocketsPage,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'animations',
|
||||||
|
component: TestAnimations,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'dealing',
|
||||||
|
component: TestDealing,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'all-animations',
|
||||||
|
component: TestAllAnimations,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'gameboard',
|
||||||
|
component: TestGameBoard,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
router.beforeEach((to, from, next) => {
|
||||||
|
const requiresAuth = to.matched.some((record) => record.meta.requiresAuth)
|
||||||
|
|
||||||
|
if (requiresAuth) {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return next({ name: 'login' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
@@ -0,0 +1,323 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
export const useBiscaStore = defineStore('bisca', () => {
|
||||||
|
const isDealing = ref(false)
|
||||||
|
const isGameRunning = ref(false)
|
||||||
|
const isGameOver = ref(false)
|
||||||
|
const gameType = ref(3)
|
||||||
|
const isLoggingOut = ref(false)
|
||||||
|
|
||||||
|
const deck = ref([])
|
||||||
|
const playerHand = ref([])
|
||||||
|
const opponentHand = ref([])
|
||||||
|
|
||||||
|
const trumpCard = ref(null)
|
||||||
|
const trumpSuit = ref('')
|
||||||
|
|
||||||
|
const playerScore = ref(0)
|
||||||
|
const opponentScore = ref(0)
|
||||||
|
const currentTurn = ref('player')
|
||||||
|
const playerTricks = ref(0)
|
||||||
|
const opponentTricks = ref(0)
|
||||||
|
const winner = ref(null)
|
||||||
|
|
||||||
|
const table = ref({
|
||||||
|
playerCard: null,
|
||||||
|
opponentCard: null,
|
||||||
|
firstToPlay: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const startGame = async (type = 3) => {
|
||||||
|
if (isDealing.value) return
|
||||||
|
|
||||||
|
isDealing.value = true
|
||||||
|
isGameRunning.value = true
|
||||||
|
gameType.value = type
|
||||||
|
isGameOver.value = false
|
||||||
|
isLoggingOut.value = false
|
||||||
|
|
||||||
|
playerScore.value = 0
|
||||||
|
opponentScore.value = 0
|
||||||
|
playerTricks.value = 0
|
||||||
|
opponentTricks.value = 0
|
||||||
|
deck.value = []
|
||||||
|
playerHand.value = []
|
||||||
|
opponentHand.value = []
|
||||||
|
trumpCard.value = null
|
||||||
|
trumpSuit.value = ''
|
||||||
|
currentTurn.value = 'player'
|
||||||
|
table.value = { playerCard: null, opponentCard: null, firstToPlay: null }
|
||||||
|
|
||||||
|
deck.value = createDeck()
|
||||||
|
|
||||||
|
const lastCard = deck.value.pop()
|
||||||
|
trumpCard.value = lastCard
|
||||||
|
trumpSuit.value = lastCard.suit
|
||||||
|
|
||||||
|
await wait(500)
|
||||||
|
await dealInitialCards()
|
||||||
|
isDealing.value = false
|
||||||
|
|
||||||
|
if (currentTurn.value === 'opponent') {
|
||||||
|
setTimeout(botPlayCard, 1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const playerPlayCard = (card) => {
|
||||||
|
if (!isGameRunning.value || currentTurn.value !== 'player') return
|
||||||
|
if (table.value.playerCard) return
|
||||||
|
|
||||||
|
if (!canPlayCard(card)) {
|
||||||
|
console.warn('Invalid play! Must follow suit if possible')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
playerHand.value = playerHand.value.filter((c) => c.id !== card.id)
|
||||||
|
table.value.playerCard = card
|
||||||
|
|
||||||
|
if (!table.value.firstToPlay) table.value.firstToPlay = 'player'
|
||||||
|
|
||||||
|
checkEndOfTrick()
|
||||||
|
}
|
||||||
|
|
||||||
|
const botPlayCard = () => {
|
||||||
|
if (!isGameRunning.value) return
|
||||||
|
if (opponentHand.value.length === 0) return
|
||||||
|
|
||||||
|
let cardToPlay = null
|
||||||
|
|
||||||
|
if (table.value.playerCard) {
|
||||||
|
const pCard = table.value.playerCard
|
||||||
|
|
||||||
|
const winningCards = opponentHand.value.filter((botCard) => doesCardWin(botCard, pCard))
|
||||||
|
|
||||||
|
if (winningCards.length > 0) {
|
||||||
|
winningCards.sort((a, b) => a.strength - b.strength)
|
||||||
|
cardToPlay = winningCards[0]
|
||||||
|
} else {
|
||||||
|
const sortedHand = [...opponentHand.value].sort((a, b) => {
|
||||||
|
if (a.points === b.points) return a.strength - b.strength
|
||||||
|
return a.points - b.points
|
||||||
|
})
|
||||||
|
cardToPlay = sortedHand[0]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const sortedHand = [...opponentHand.value].sort((a, b) => {
|
||||||
|
if (a.suit === trumpSuit.value && b.suit !== trumpSuit.value) return 1
|
||||||
|
if (a.suit !== trumpSuit.value && b.suit === trumpSuit.value) return -1
|
||||||
|
return a.strength - b.strength
|
||||||
|
})
|
||||||
|
cardToPlay = sortedHand[0]
|
||||||
|
table.value.firstToPlay = 'opponent'
|
||||||
|
}
|
||||||
|
|
||||||
|
opponentHand.value = opponentHand.value.filter((c) => c.id !== cardToPlay.id)
|
||||||
|
table.value.opponentCard = cardToPlay
|
||||||
|
|
||||||
|
checkEndOfTrick()
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkEndOfTrick = () => {
|
||||||
|
if (table.value.playerCard && table.value.opponentCard) {
|
||||||
|
currentTurn.value = 'resolving'
|
||||||
|
setTimeout(resolveTrick, 1500)
|
||||||
|
} else {
|
||||||
|
if (table.value.playerCard) {
|
||||||
|
currentTurn.value = 'opponent'
|
||||||
|
setTimeout(botPlayCard, 1000)
|
||||||
|
} else {
|
||||||
|
currentTurn.value = 'player'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveTrick = async () => {
|
||||||
|
const pCard = table.value.playerCard
|
||||||
|
const oCard = table.value.opponentCard
|
||||||
|
|
||||||
|
let weWon = false
|
||||||
|
|
||||||
|
if (table.value.firstToPlay === 'player') {
|
||||||
|
if (doesCardWin(oCard, pCard)) weWon = false
|
||||||
|
else weWon = true
|
||||||
|
} else {
|
||||||
|
if (doesCardWin(pCard, oCard)) weWon = true
|
||||||
|
else weWon = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const points = pCard.points + oCard.points
|
||||||
|
if (weWon) {
|
||||||
|
playerScore.value += points
|
||||||
|
playerTricks.value++
|
||||||
|
currentTurn.value = 'player'
|
||||||
|
} else {
|
||||||
|
opponentScore.value += points
|
||||||
|
opponentTricks.value++
|
||||||
|
currentTurn.value = 'opponent'
|
||||||
|
}
|
||||||
|
|
||||||
|
table.value = { playerCard: null, opponentCard: null, firstToPlay: null }
|
||||||
|
|
||||||
|
if (deck.value.length > 0 || trumpCard.value !== null) {
|
||||||
|
if (weWon) {
|
||||||
|
await drawOneCard('player')
|
||||||
|
await drawOneCard('opponent')
|
||||||
|
} else {
|
||||||
|
await drawOneCard('opponent')
|
||||||
|
await drawOneCard('player')
|
||||||
|
}
|
||||||
|
} else if (playerHand.value.length === 0 && opponentHand.value.length === 0) {
|
||||||
|
endGame()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentTurn.value === 'opponent') {
|
||||||
|
setTimeout(botPlayCard, 1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const drawOneCard = async (who) => {
|
||||||
|
let cardToDraw = null
|
||||||
|
|
||||||
|
if (deck.value.length > 0) {
|
||||||
|
cardToDraw = deck.value.pop()
|
||||||
|
} else if (trumpCard.value !== null) {
|
||||||
|
cardToDraw = trumpCard.value
|
||||||
|
trumpCard.value = null
|
||||||
|
} else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (who === 'player') {
|
||||||
|
playerHand.value.push(cardToDraw)
|
||||||
|
} else {
|
||||||
|
opponentHand.value.push(cardToDraw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const doesCardWin = (attacker, defender) => {
|
||||||
|
const suit = trumpSuit.value
|
||||||
|
|
||||||
|
if (attacker.suit === suit && defender.suit !== suit) return true
|
||||||
|
if (defender.suit === suit && attacker.suit !== suit) return false
|
||||||
|
|
||||||
|
if (attacker.suit === defender.suit) {
|
||||||
|
return attacker.strength > defender.strength
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const endGame = () => {
|
||||||
|
isGameRunning.value = false
|
||||||
|
isGameOver.value = true
|
||||||
|
|
||||||
|
if (playerScore.value > opponentScore.value) winner.value = 'player'
|
||||||
|
else if (opponentScore.value > playerScore.value) winner.value = 'opponent'
|
||||||
|
else winner.value = 'draw'
|
||||||
|
}
|
||||||
|
|
||||||
|
const dealInitialCards = async () => {
|
||||||
|
const cardsToDeal = gameType.value === 9 ? 9 : 3
|
||||||
|
const dealSpeed = gameType.value === 9 ? 200 : 500
|
||||||
|
|
||||||
|
for (let i = 0; i < cardsToDeal; i++) {
|
||||||
|
if (deck.value.length > 0) {
|
||||||
|
playerHand.value.push(deck.value.pop())
|
||||||
|
await wait(dealSpeed)
|
||||||
|
}
|
||||||
|
if (deck.value.length > 0) {
|
||||||
|
opponentHand.value.push(deck.value.pop())
|
||||||
|
await wait(dealSpeed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createDeck = () => {
|
||||||
|
const suits = ['c', 'e', 'o', 'p']
|
||||||
|
const ranks = [1, 2, 3, 4, 5, 6, 7, 11, 12, 13]
|
||||||
|
let newDeck = []
|
||||||
|
|
||||||
|
for (const suit of suits) {
|
||||||
|
for (const rank of ranks) {
|
||||||
|
newDeck.push({
|
||||||
|
id: `${suit}-${rank}`,
|
||||||
|
suit,
|
||||||
|
rank,
|
||||||
|
points: getPoints(rank),
|
||||||
|
strength: getStrength(rank),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return newDeck.sort(() => Math.random() - 0.5)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPoints = (rank) => {
|
||||||
|
if (rank === 1) return 11
|
||||||
|
if (rank === 7) return 10
|
||||||
|
if (rank === 13) return 4
|
||||||
|
if (rank === 11) return 3
|
||||||
|
if (rank === 12) return 2
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
const quitGame = () => {
|
||||||
|
if (!isGameRunning.value) return
|
||||||
|
|
||||||
|
opponentScore.value = 120
|
||||||
|
playerScore.value = 0
|
||||||
|
winner.value = 'opponent'
|
||||||
|
|
||||||
|
// Parar o jogo imediatamente
|
||||||
|
isGameRunning.value = false
|
||||||
|
isGameOver.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const canPlayCard = (card) => {
|
||||||
|
if (currentTurn.value !== 'player') return false
|
||||||
|
if (!table.value.opponentCard) return true
|
||||||
|
|
||||||
|
const hasCardsToDraw = deck.value.length > 0 || trumpCard.value !== null
|
||||||
|
if (hasCardsToDraw) return true
|
||||||
|
|
||||||
|
const suitToFollow = table.value.opponentCard.suit
|
||||||
|
const hasSuit = playerHand.value.some((c) => c.suit === suitToFollow)
|
||||||
|
|
||||||
|
if (hasSuit && card.suit !== suitToFollow) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
|
|
||||||
|
return {
|
||||||
|
isDealing,
|
||||||
|
isGameRunning,
|
||||||
|
gameType,
|
||||||
|
deck,
|
||||||
|
playerHand,
|
||||||
|
opponentHand,
|
||||||
|
isLoggingOut,
|
||||||
|
trumpCard,
|
||||||
|
trumpSuit,
|
||||||
|
playerScore,
|
||||||
|
opponentScore,
|
||||||
|
currentTurn,
|
||||||
|
table,
|
||||||
|
playerTricks,
|
||||||
|
opponentTricks,
|
||||||
|
isGameOver,
|
||||||
|
winner,
|
||||||
|
startGame,
|
||||||
|
quitGame,
|
||||||
|
resolveTrick,
|
||||||
|
playerPlayCard,
|
||||||
|
canPlayCard,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import { defineStore } from 'pinia'
|
|
||||||
import { ref, computed } from 'vue'
|
|
||||||
import { toast } from 'vue-sonner'
|
|
||||||
import { useApiStore } from './api'
|
|
||||||
|
|
||||||
export const useGameStore = defineStore('game', () => {
|
|
||||||
|
|
||||||
})
|
|
||||||
@@ -1 +1,2 @@
|
|||||||
PORT=
|
PORT=
|
||||||
|
API_URL=
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
class BiscaGame {
|
||||||
|
constructor(id, type, player1, player2) {
|
||||||
|
this.id = id;
|
||||||
|
this.type = type;
|
||||||
|
|
||||||
|
this.deck = [];
|
||||||
|
this.trumpCard = null;
|
||||||
|
this.trumpSuit = null;
|
||||||
|
|
||||||
|
this.table = {
|
||||||
|
playerCard: null,
|
||||||
|
opponentCard: null,
|
||||||
|
firstPlayerId: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.currentTurn = null;
|
||||||
|
this.status = "pending";
|
||||||
|
this.startTime = null;
|
||||||
|
|
||||||
|
this.players = {
|
||||||
|
[player1.id]: {
|
||||||
|
id: player1.id,
|
||||||
|
name: player1.name,
|
||||||
|
hand: [],
|
||||||
|
points: 0,
|
||||||
|
tricks: 0,
|
||||||
|
},
|
||||||
|
[player2.id]: {
|
||||||
|
id: player2.id,
|
||||||
|
name: player2.name,
|
||||||
|
hand: [],
|
||||||
|
points: 0,
|
||||||
|
tricks: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.deck = this.createDeck();
|
||||||
|
const lastCard = this.deck.pop();
|
||||||
|
this.trumpCard = lastCard;
|
||||||
|
this.trumpSuit = lastCard.suit;
|
||||||
|
|
||||||
|
this.dealInitialCards();
|
||||||
|
|
||||||
|
const playerIds = Object.keys(this.players);
|
||||||
|
this.currentTurn = playerIds.find((id) => id != 1) || playerIds[0];
|
||||||
|
|
||||||
|
this.startTime = Date.now();
|
||||||
|
this.status = "playing";
|
||||||
|
}
|
||||||
|
|
||||||
|
createDeck() {
|
||||||
|
const suits = ["c", "e", "o", "p"];
|
||||||
|
const ranks = [1, 2, 3, 4, 5, 6, 7, 11, 12, 13];
|
||||||
|
let deck = [];
|
||||||
|
|
||||||
|
for (const suit of suits) {
|
||||||
|
for (const rank of ranks) {
|
||||||
|
deck.push({
|
||||||
|
id: `${suit}-${rank}`,
|
||||||
|
suit,
|
||||||
|
rank,
|
||||||
|
strength: this.getStrength(rank),
|
||||||
|
points: this.getPoints(rank),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return deck.sort(() => Math.random() - 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
dealInitialCards() {
|
||||||
|
const cardsToDeal = this.type == 9 ? 9 : 3;
|
||||||
|
const pIds = Object.keys(this.players);
|
||||||
|
for (let i = 0; i < cardsToDeal; i++) {
|
||||||
|
pIds.forEach((id) => this.drawCard(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
drawCard(userId) {
|
||||||
|
if (this.deck.length > 0) {
|
||||||
|
this.players[userId].hand.push(this.deck.pop());
|
||||||
|
} else if (this.trumpCard) {
|
||||||
|
this.players[userId].hand.push(this.trumpCard);
|
||||||
|
this.trumpCard = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
playCard(userId, cardId) {
|
||||||
|
if (this.currentTurn != userId) return { error: "Not your turn!" };
|
||||||
|
|
||||||
|
const player = this.players[userId];
|
||||||
|
const cardIndex = player.hand.findIndex((c) => c.id === cardId);
|
||||||
|
|
||||||
|
if (cardIndex === -1) return { error: "You don't own that card!" };
|
||||||
|
|
||||||
|
const card = player.hand[cardIndex];
|
||||||
|
|
||||||
|
const deckEmpty = this.deck.length === 0 && this.trumpCard === null;
|
||||||
|
if (this.table.playerCard && deckEmpty) {
|
||||||
|
const suitToFollow = this.table.playerCard.suit;
|
||||||
|
const hasSuit = player.hand.some((c) => c.suit === suitToFollow);
|
||||||
|
if (hasSuit && card.suit !== suitToFollow) {
|
||||||
|
return { error: "You must follow the suit!" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
player.hand.splice(cardIndex, 1);
|
||||||
|
|
||||||
|
if (!this.table.playerCard) {
|
||||||
|
this.table.playerCard = card;
|
||||||
|
this.table.firstPlayerId = userId;
|
||||||
|
this.currentTurn = this.getOpponentId(userId);
|
||||||
|
return { action: "next_turn" };
|
||||||
|
} else {
|
||||||
|
this.table.opponentCard = card;
|
||||||
|
return this.resolveTrick();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveTrick() {
|
||||||
|
const p1Id = this.table.firstPlayerId;
|
||||||
|
const p2Id = this.getOpponentId(p1Id);
|
||||||
|
const card1 = this.table.playerCard;
|
||||||
|
const card2 = this.table.opponentCard;
|
||||||
|
|
||||||
|
let winnerId = p2Id;
|
||||||
|
let p1Wins = false;
|
||||||
|
|
||||||
|
if (card2.suit === this.trumpSuit && card1.suit !== this.trumpSuit)
|
||||||
|
p1Wins = false;
|
||||||
|
else if (card1.suit === this.trumpSuit && card2.suit !== this.trumpSuit)
|
||||||
|
p1Wins = true;
|
||||||
|
else if (card1.suit === card2.suit)
|
||||||
|
p1Wins = card1.strength > card2.strength;
|
||||||
|
else p1Wins = true;
|
||||||
|
|
||||||
|
winnerId = p1Wins ? p1Id : p2Id;
|
||||||
|
|
||||||
|
const points = card1.points + card2.points;
|
||||||
|
this.players[winnerId].points += points;
|
||||||
|
this.players[winnerId].tricks += 1;
|
||||||
|
this.currentTurn = winnerId;
|
||||||
|
|
||||||
|
if (this.type == 3) {
|
||||||
|
this.drawCard(winnerId);
|
||||||
|
this.drawCard(this.getOpponentId(winnerId));
|
||||||
|
}
|
||||||
|
|
||||||
|
const trickResult = { winnerId, points, cards: [card1, card2] };
|
||||||
|
this.table = { playerCard: null, opponentCard: null, firstPlayerId: null };
|
||||||
|
|
||||||
|
if (this.players[winnerId].hand.length === 0) {
|
||||||
|
this.status = "finished";
|
||||||
|
|
||||||
|
const pIds = Object.keys(this.players);
|
||||||
|
const p1Obj = this.players[pIds[0]];
|
||||||
|
const p2Obj = this.players[pIds[1]];
|
||||||
|
|
||||||
|
const totalTime = (Date.now() - this.startTime) / 1000;
|
||||||
|
|
||||||
|
let gameWinnerId = null;
|
||||||
|
let isDraw = false;
|
||||||
|
|
||||||
|
if (p1Obj.points > p2Obj.points) gameWinnerId = p1Obj.id;
|
||||||
|
else if (p2Obj.points > p1Obj.points) gameWinnerId = p2Obj.id;
|
||||||
|
else isDraw = true;
|
||||||
|
|
||||||
|
const finalWinner = isDraw ? null : gameWinnerId;
|
||||||
|
const finalLoser = isDraw
|
||||||
|
? null
|
||||||
|
: gameWinnerId == p1Obj.id
|
||||||
|
? p2Obj.id
|
||||||
|
: p1Obj.id;
|
||||||
|
|
||||||
|
return {
|
||||||
|
action: "game_ended",
|
||||||
|
trickResult,
|
||||||
|
winnerId: finalWinner,
|
||||||
|
loserId: finalLoser,
|
||||||
|
isDraw: isDraw,
|
||||||
|
player1_id: pIds[0],
|
||||||
|
player1_points: p1Obj.points,
|
||||||
|
player2_id: pIds[1],
|
||||||
|
player2_points: p2Obj.points,
|
||||||
|
totalTime: totalTime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { action: "trick_resolved", trickResult };
|
||||||
|
}
|
||||||
|
|
||||||
|
getStateForPlayer(userId) {
|
||||||
|
const oppId = this.getOpponentId(userId);
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
trumpCard: this.trumpCard,
|
||||||
|
trumpSuit: this.trumpSuit,
|
||||||
|
deckCount: this.deck.length + (this.trumpCard ? 1 : 0),
|
||||||
|
currentTurn: this.currentTurn,
|
||||||
|
table: this.table,
|
||||||
|
hand: this.players[userId].hand,
|
||||||
|
points: this.players[userId].points,
|
||||||
|
opponent: {
|
||||||
|
points: this.players[oppId].points,
|
||||||
|
cardCount: this.players[oppId].hand.length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
getOpponentId(id) {
|
||||||
|
return Object.keys(this.players).find((pid) => pid != id);
|
||||||
|
}
|
||||||
|
getPoints(r) {
|
||||||
|
const p = { 1: 11, 7: 10, 13: 4, 11: 3, 12: 2 };
|
||||||
|
return p[r] || 0;
|
||||||
|
}
|
||||||
|
getStrength(r) {
|
||||||
|
const s = {
|
||||||
|
1: 10,
|
||||||
|
7: 9,
|
||||||
|
13: 8,
|
||||||
|
11: 7,
|
||||||
|
12: 6,
|
||||||
|
6: 5,
|
||||||
|
5: 4,
|
||||||
|
4: 3,
|
||||||
|
3: 2,
|
||||||
|
2: 1,
|
||||||
|
};
|
||||||
|
return s[r] || 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = BiscaGame;
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
import { getGame, createGame } from "../state/game.js";
|
||||||
|
import { getUser } from "../state/connection.js";
|
||||||
|
|
||||||
|
const API_URL = "http://localhost:8000/api/v1";
|
||||||
|
|
||||||
|
async function saveGameResult(gameId, result, token) {
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
status: "Ended",
|
||||||
|
is_draw: result.isDraw,
|
||||||
|
total_time: result.totalTime,
|
||||||
|
player1_points: result.player1_points,
|
||||||
|
player2_points: result.player2_points,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!result.isDraw && result.winnerId) {
|
||||||
|
payload.winner_user_id = result.winnerId;
|
||||||
|
payload.loser_user_id = result.loserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
await axios.put(`${API_URL}/games/${gameId}`, payload, {
|
||||||
|
headers: { Authorization: token },
|
||||||
|
});
|
||||||
|
console.log(`[DB] Game ${gameId} saved.`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[DB Error] Game ${gameId}:`, error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default (io, socket) => {
|
||||||
|
socket.on("join-game", async ({ gameId }) => {
|
||||||
|
const user = getUser(socket.id);
|
||||||
|
if (!user) {
|
||||||
|
socket.emit("error", { message: "Not authenticated." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let game = getGame(gameId);
|
||||||
|
|
||||||
|
if (!game) {
|
||||||
|
try {
|
||||||
|
const token = socket.handshake.auth.token;
|
||||||
|
const response = await axios.get(`${API_URL}/games/${gameId}`, {
|
||||||
|
headers: { Authorization: token },
|
||||||
|
});
|
||||||
|
|
||||||
|
const gameData = response.data.data || response.data;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
game = createGame(
|
||||||
|
gameId,
|
||||||
|
type,
|
||||||
|
{ id: gameData.player1_user_id, name: p1Name },
|
||||||
|
{ id: gameData.player2_user_id, name: p2Name }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
socket.emit("error", { message: "Failed to join game." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const GHOST_ID = 1;
|
||||||
|
|
||||||
|
if (game && game.players) {
|
||||||
|
if (!game.players[user.id]) {
|
||||||
|
if (game.players[GHOST_ID]) {
|
||||||
|
delete game.players[GHOST_ID];
|
||||||
|
game.players[user.id] = {
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
hand: [],
|
||||||
|
points: 0,
|
||||||
|
tricks: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.join(`game_${gameId}`);
|
||||||
|
|
||||||
|
if (game && game.players[user.id]) {
|
||||||
|
socket.emit("game-state", game.getStateForPlayer(user.id));
|
||||||
|
console.log(`--> Estado enviado para User ${user.id}`);
|
||||||
|
} else {
|
||||||
|
socket.emit("error", { message: "Error joining game state." });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("play-card", ({ gameId, cardId }) => {
|
||||||
|
const user = getUser(socket.id);
|
||||||
|
const game = getGame(gameId);
|
||||||
|
|
||||||
|
if (!game || !user) return;
|
||||||
|
|
||||||
|
const result = game.playCard(user.id, cardId);
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
socket.emit("game-error", { message: result.error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,573 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "websockets",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"lockfileVersion": 3,
|
|
||||||
"requires": true,
|
|
||||||
"packages": {
|
|
||||||
"": {
|
|
||||||
"name": "websockets",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"nodemon": "^3.1.11",
|
|
||||||
"socket.io": "^4.8.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@socket.io/component-emitter": {
|
|
||||||
"version": "3.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
|
||||||
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@types/cors": {
|
|
||||||
"version": "2.8.19",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
|
|
||||||
"integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/node": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/node": {
|
|
||||||
"version": "24.10.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz",
|
|
||||||
"integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"undici-types": "~7.16.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/accepts": {
|
|
||||||
"version": "1.3.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
|
||||||
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"mime-types": "~2.1.34",
|
|
||||||
"negotiator": "0.6.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/anymatch": {
|
|
||||||
"version": "3.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
|
||||||
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"normalize-path": "^3.0.0",
|
|
||||||
"picomatch": "^2.0.4"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/balanced-match": {
|
|
||||||
"version": "1.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
|
||||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/base64id": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": "^4.5.0 || >= 5.9"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/binary-extensions": {
|
|
||||||
"version": "2.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
|
||||||
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/brace-expansion": {
|
|
||||||
"version": "1.1.12",
|
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
|
||||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"balanced-match": "^1.0.0",
|
|
||||||
"concat-map": "0.0.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/braces": {
|
|
||||||
"version": "3.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
|
||||||
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"fill-range": "^7.1.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/chokidar": {
|
|
||||||
"version": "3.6.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
|
||||||
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"anymatch": "~3.1.2",
|
|
||||||
"braces": "~3.0.2",
|
|
||||||
"glob-parent": "~5.1.2",
|
|
||||||
"is-binary-path": "~2.1.0",
|
|
||||||
"is-glob": "~4.0.1",
|
|
||||||
"normalize-path": "~3.0.0",
|
|
||||||
"readdirp": "~3.6.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 8.10.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://paulmillr.com/funding/"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"fsevents": "~2.3.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/concat-map": {
|
|
||||||
"version": "0.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
|
||||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/cookie": {
|
|
||||||
"version": "0.7.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
|
||||||
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/cors": {
|
|
||||||
"version": "2.8.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
|
|
||||||
"integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"object-assign": "^4",
|
|
||||||
"vary": "^1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/debug": {
|
|
||||||
"version": "4.3.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
|
|
||||||
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"ms": "^2.1.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"supports-color": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/engine.io": {
|
|
||||||
"version": "6.6.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.4.tgz",
|
|
||||||
"integrity": "sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/cors": "^2.8.12",
|
|
||||||
"@types/node": ">=10.0.0",
|
|
||||||
"accepts": "~1.3.4",
|
|
||||||
"base64id": "2.0.0",
|
|
||||||
"cookie": "~0.7.2",
|
|
||||||
"cors": "~2.8.5",
|
|
||||||
"debug": "~4.3.1",
|
|
||||||
"engine.io-parser": "~5.2.1",
|
|
||||||
"ws": "~8.17.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10.2.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/engine.io-parser": {
|
|
||||||
"version": "5.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
|
|
||||||
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/fill-range": {
|
|
||||||
"version": "7.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
|
||||||
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"to-regex-range": "^5.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/fsevents": {
|
|
||||||
"version": "2.3.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
|
||||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/glob-parent": {
|
|
||||||
"version": "5.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
|
||||||
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"is-glob": "^4.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/has-flag": {
|
|
||||||
"version": "3.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
|
|
||||||
"integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ignore-by-default": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==",
|
|
||||||
"license": "ISC"
|
|
||||||
},
|
|
||||||
"node_modules/is-binary-path": {
|
|
||||||
"version": "2.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
|
||||||
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"binary-extensions": "^2.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/is-extglob": {
|
|
||||||
"version": "2.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
|
||||||
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/is-glob": {
|
|
||||||
"version": "4.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
|
||||||
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"is-extglob": "^2.1.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/is-number": {
|
|
||||||
"version": "7.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
|
||||||
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.12.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/mime-db": {
|
|
||||||
"version": "1.52.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
|
||||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/mime-types": {
|
|
||||||
"version": "2.1.35",
|
|
||||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
|
||||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"mime-db": "1.52.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/minimatch": {
|
|
||||||
"version": "3.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
|
||||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"brace-expansion": "^1.1.7"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ms": {
|
|
||||||
"version": "2.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/negotiator": {
|
|
||||||
"version": "0.6.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
|
||||||
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/nodemon": {
|
|
||||||
"version": "3.1.11",
|
|
||||||
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz",
|
|
||||||
"integrity": "sha512-is96t8F/1//UHAjNPHpbsNY46ELPpftGUoSVNXwUfMk/qdjSylYrWSu1XavVTBOn526kFiOR733ATgNBCQyH0g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"chokidar": "^3.5.2",
|
|
||||||
"debug": "^4",
|
|
||||||
"ignore-by-default": "^1.0.1",
|
|
||||||
"minimatch": "^3.1.2",
|
|
||||||
"pstree.remy": "^1.1.8",
|
|
||||||
"semver": "^7.5.3",
|
|
||||||
"simple-update-notifier": "^2.0.0",
|
|
||||||
"supports-color": "^5.5.0",
|
|
||||||
"touch": "^3.1.0",
|
|
||||||
"undefsafe": "^2.0.5"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"nodemon": "bin/nodemon.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/nodemon"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/normalize-path": {
|
|
||||||
"version": "3.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
|
||||||
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/object-assign": {
|
|
||||||
"version": "4.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
|
||||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/picomatch": {
|
|
||||||
"version": "2.3.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
|
||||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8.6"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/pstree.remy": {
|
|
||||||
"version": "1.1.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",
|
|
||||||
"integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/readdirp": {
|
|
||||||
"version": "3.6.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
|
||||||
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"picomatch": "^2.2.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/semver": {
|
|
||||||
"version": "7.7.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
|
||||||
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
|
|
||||||
"license": "ISC",
|
|
||||||
"bin": {
|
|
||||||
"semver": "bin/semver.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/simple-update-notifier": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"semver": "^7.5.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/socket.io": {
|
|
||||||
"version": "4.8.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz",
|
|
||||||
"integrity": "sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"accepts": "~1.3.4",
|
|
||||||
"base64id": "~2.0.0",
|
|
||||||
"cors": "~2.8.5",
|
|
||||||
"debug": "~4.3.2",
|
|
||||||
"engine.io": "~6.6.0",
|
|
||||||
"socket.io-adapter": "~2.5.2",
|
|
||||||
"socket.io-parser": "~4.2.4"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10.2.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/socket.io-adapter": {
|
|
||||||
"version": "2.5.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.5.tgz",
|
|
||||||
"integrity": "sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"debug": "~4.3.4",
|
|
||||||
"ws": "~8.17.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/socket.io-parser": {
|
|
||||||
"version": "4.2.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz",
|
|
||||||
"integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@socket.io/component-emitter": "~3.1.0",
|
|
||||||
"debug": "~4.3.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/supports-color": {
|
|
||||||
"version": "5.5.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
|
|
||||||
"integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"has-flag": "^3.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/to-regex-range": {
|
|
||||||
"version": "5.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
|
||||||
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"is-number": "^7.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/touch": {
|
|
||||||
"version": "3.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz",
|
|
||||||
"integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==",
|
|
||||||
"license": "ISC",
|
|
||||||
"bin": {
|
|
||||||
"nodetouch": "bin/nodetouch.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/undefsafe": {
|
|
||||||
"version": "2.0.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
|
|
||||||
"integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/undici-types": {
|
|
||||||
"version": "7.16.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
|
||||||
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/vary": {
|
|
||||||
"version": "1.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
|
||||||
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ws": {
|
|
||||||
"version": "8.17.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
|
|
||||||
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"bufferutil": "^4.0.1",
|
|
||||||
"utf-8-validate": ">=5.0.2"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"bufferutil": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"utf-8-validate": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"description": "",
|
"description": "",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"axios": "^1.13.2",
|
||||||
"nodemon": "^3.1.11",
|
"nodemon": "^3.1.11",
|
||||||
"socket.io": "^4.8.1"
|
"socket.io": "^4.8.1"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,67 @@
|
|||||||
import { Server } from "socket.io";
|
import { Server } from "socket.io";
|
||||||
|
import axios from "axios";
|
||||||
import { handleConnectionEvents } from "./events/connection.js";
|
import { handleConnectionEvents } from "./events/connection.js";
|
||||||
|
import { addUser, removeUser } from "./state/connection.js";
|
||||||
|
import gameEvents from "./events/game.js";
|
||||||
|
|
||||||
export const server = {
|
export const server = {
|
||||||
io: null,
|
io: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const API_URL = "http://localhost:8000/api/v1";
|
||||||
|
|
||||||
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) => {
|
||||||
|
const token = socket.handshake.auth.token;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return next(new Error("Authentication error: No token provided"));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${API_URL}/users/me`, {
|
||||||
|
headers: { Authorization: token },
|
||||||
|
});
|
||||||
|
|
||||||
|
const user = response.data.data || response.data;
|
||||||
|
|
||||||
|
if (!user || !user.id) {
|
||||||
|
return next(new Error("Authentication error: User data not found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
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) => {
|
server.io.on("connection", (socket) => {
|
||||||
console.log("New connection:", socket.id);
|
console.log("New connection:", socket.id);
|
||||||
|
|
||||||
|
gameEvents(server.io, socket);
|
||||||
|
|
||||||
handleConnectionEvents(server.io, socket);
|
handleConnectionEvents(server.io, socket);
|
||||||
|
|
||||||
|
socket.on("disconnect", () => {
|
||||||
|
removeUser(socket.id);
|
||||||
|
console.log("Disconnected:", socket.id);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
const users = new Map();
|
const users = new Map();
|
||||||
|
|
||||||
export const addUser = (socket, user) => {
|
export const addUser = (socketId, userData) => {
|
||||||
users.set(socket.id, user);
|
users.set(socketId, userData);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const removeUser = (socketID) => {
|
export const getUser = (socketId) => {
|
||||||
const userToDelete = { ...users.get(socketID) };
|
return users.get(socketId);
|
||||||
users.delete(socketID);
|
|
||||||
return userToDelete;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getUser = (socketID) => {
|
export const removeUser = (socketId) => {
|
||||||
return users.get(socketID);
|
users.delete(socketId);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getUserCount = () => {
|
export const getUserCount = () => {
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
const BiscaGame = require("../classes/BiscaGame");
|
||||||
|
|
||||||
|
const activeGames = new Map();
|
||||||
|
|
||||||
|
const createGame = (id, type, player1, player2) => {
|
||||||
|
const game = new BiscaGame(id, type, player1, player2);
|
||||||
|
|
||||||
|
game.init();
|
||||||
|
|
||||||
|
activeGames.set(id, game);
|
||||||
|
|
||||||
|
console.log(`[Game State] Game created: ${id} (Type: ${type})`);
|
||||||
|
return game;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getGame = (id) => {
|
||||||
|
return activeGames.get(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeGame = (id) => {
|
||||||
|
activeGames.delete(id);
|
||||||
|
console.log(`[Game State] Game removed: ${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createGame,
|
||||||
|
getGame,
|
||||||
|
removeGame,
|
||||||
|
};
|
||||||