SyntaxSquad/DADProject#16 feat: Can now start matches, just missing the actuall gameplay
This commit is contained in:
@@ -10,7 +10,7 @@ use App\Http\Requests\StoreMatchRequest;
|
|||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class MatchController extends Controller
|
class MatchController extends Controller
|
||||||
{
|
{
|
||||||
public function index(Request $request)
|
public function index(Request $request)
|
||||||
{
|
{
|
||||||
$query = MatchGame::query()->with(['winner', 'player1', 'player2']);
|
$query = MatchGame::query()->with(['winner', 'player1', 'player2']);
|
||||||
@@ -29,9 +29,9 @@ class MatchController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function store(StoreMatchRequest $request)
|
public function store(StoreMatchRequest $request)
|
||||||
{
|
{
|
||||||
$validated = $request->validated();
|
$validated = $request->validated();
|
||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
$stake = $validated['stake'];
|
$stake = $validated['stake'];
|
||||||
|
|
||||||
if ($user->coins_balance < $stake) {
|
if ($user->coins_balance < $stake) {
|
||||||
@@ -43,7 +43,7 @@ class MatchController extends Controller
|
|||||||
->orWhere('player2_user_id', $user->id);
|
->orWhere('player2_user_id', $user->id);
|
||||||
})->whereIn('status', ['Pending', 'Playing'])->exists();
|
})->whereIn('status', ['Pending', 'Playing'])->exists();
|
||||||
|
|
||||||
if ($activeMatch) {
|
if ($activeMatch) {
|
||||||
return response()->json(['message' => 'You already have an active match.'], 400);
|
return response()->json(['message' => 'You already have an active match.'], 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ class MatchController extends Controller
|
|||||||
'type' => $validated['type'],
|
'type' => $validated['type'],
|
||||||
'status' => 'Pending',
|
'status' => 'Pending',
|
||||||
'player1_user_id' => $user->id,
|
'player1_user_id' => $user->id,
|
||||||
'player2_user_id' => $GHOST_ID,
|
'player2_user_id' => $GHOST_ID,
|
||||||
'winner_user_id' => $GHOST_ID,
|
'winner_user_id' => $GHOST_ID,
|
||||||
'loser_user_id' => $GHOST_ID,
|
'loser_user_id' => $GHOST_ID,
|
||||||
'stake' => $stake,
|
'stake' => $stake,
|
||||||
@@ -129,7 +129,7 @@ class MatchController extends Controller
|
|||||||
'match_id' => $match->id,
|
'match_id' => $match->id,
|
||||||
'coin_transaction_type_id' => $stakeType->id,
|
'coin_transaction_type_id' => $stakeType->id,
|
||||||
'transaction_datetime' => now(),
|
'transaction_datetime' => now(),
|
||||||
'coins' => -$match->stake,
|
'coins' => -$match->stake,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$match->update([
|
$match->update([
|
||||||
@@ -163,7 +163,7 @@ class MatchController extends Controller
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
return DB::transaction(function () use ($match, $data) {
|
return DB::transaction(function () use ($match, $data) {
|
||||||
|
|
||||||
if (isset($data['status']) && $data['status'] === 'Ended') {
|
if (isset($data['status']) && $data['status'] === 'Ended') {
|
||||||
$data['ended_at'] = now();
|
$data['ended_at'] = now();
|
||||||
}
|
}
|
||||||
@@ -171,7 +171,7 @@ class MatchController extends Controller
|
|||||||
$match->update($data);
|
$match->update($data);
|
||||||
|
|
||||||
if ($match->status === 'Ended' && $match->stake > 0 && $match->winner_user_id) {
|
if ($match->status === 'Ended' && $match->stake > 0 && $match->winner_user_id) {
|
||||||
|
|
||||||
$payoutType = CoinTransactionType::firstOrCreate(
|
$payoutType = CoinTransactionType::firstOrCreate(
|
||||||
['name' => 'Match payout'],
|
['name' => 'Match payout'],
|
||||||
['type' => 'C'] // Credit
|
['type' => 'C'] // Credit
|
||||||
@@ -232,7 +232,7 @@ class MatchController extends Controller
|
|||||||
'type' => $request->type,
|
'type' => $request->type,
|
||||||
'status' => 'Pending',
|
'status' => 'Pending',
|
||||||
'player1_user_id' => $user->id,
|
'player1_user_id' => $user->id,
|
||||||
'player2_user_id' => $GHOST_ID,
|
'player2_user_id' => $GHOST_ID,
|
||||||
'winner_user_id' => null,
|
'winner_user_id' => null,
|
||||||
'loser_user_id' => null,
|
'loser_user_id' => null,
|
||||||
'stake' => $stake,
|
'stake' => $stake,
|
||||||
@@ -255,16 +255,21 @@ class MatchController extends Controller
|
|||||||
|
|
||||||
public function open(Request $request)
|
public function open(Request $request)
|
||||||
{
|
{
|
||||||
$type = $request->query('type', '9');
|
// FIX: Allow matches where P2 is NULL OR 'Ghost' (ID 1)
|
||||||
$GHOST_ID = 1;
|
$query = MatchGame::where('status', 'Pending')
|
||||||
|
->where(function($q) {
|
||||||
|
$q->whereNull('player2_user_id')
|
||||||
|
->orWhere('player2_user_id', 1); // 1 = Ghost ID
|
||||||
|
});
|
||||||
|
|
||||||
$matches = MatchGame::where('status', 'Pending')
|
if ($request->has('type')) {
|
||||||
->where('player2_user_id', $GHOST_ID)
|
$query->where('type', $request->type);
|
||||||
->where('type', $type)
|
}
|
||||||
->with('player1')
|
|
||||||
->orderBy('began_at', 'asc')
|
$matches = $query->with('player1:id,nickname')
|
||||||
|
->orderBy('began_at', 'desc')
|
||||||
->paginate(10);
|
->paginate(10);
|
||||||
|
|
||||||
return response()->json($matches);
|
return response()->json($matches);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-3
@@ -58,10 +58,11 @@ Route::prefix('v1')->group(function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
Route::prefix('matches')->group(function () {
|
Route::prefix('matches')->group(function () {
|
||||||
Route::apiResource('/', MatchController::class)->parameters(['' => 'match']);
|
Route::post('host', [MatchController::class, 'host']);
|
||||||
Route::post('/{match}/join', [MatchController::class, 'join']);
|
|
||||||
Route::post('host', [MatchController::class, 'host']); // <--- NOVA
|
|
||||||
Route::get('open', [MatchController::class, 'open']);
|
Route::get('open', [MatchController::class, 'open']);
|
||||||
|
Route::post('/{match}/join', [MatchController::class, 'join']);
|
||||||
|
Route::post('/{match}/start', [MatchController::class, 'start']); // Changed to /start to be explicit
|
||||||
|
Route::apiResource('/', MatchController::class)->parameters(['' => 'match']);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Admin Routes
|
// Admin Routes
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.17",
|
"@tailwindcss/vite": "^4.1.17",
|
||||||
"@tanstack/vue-table": "^8.21.3",
|
"@tanstack/vue-table": "^8.21.3",
|
||||||
"@vueuse/core": "^14.0.0",
|
"@vueuse/core": "^14.1.0",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"lucide-vue-next": "^0.554.0",
|
"lucide-vue-next": "^0.554.0",
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
"reka-ui": "^2.6.0",
|
"reka-ui": "^2.7.0",
|
||||||
"socket.io-client": "^4.8.1",
|
"socket.io-client": "^4.8.1",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss": "^4.1.17",
|
"tailwindcss": "^4.1.17",
|
||||||
|
|||||||
@@ -92,4 +92,4 @@ watch(() => userLoggedIn, async (isLoggedIn) => {
|
|||||||
userStore.coins = 0
|
userStore.coins = 0
|
||||||
}
|
}
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<script setup>
|
||||||
|
import { reactiveOmit } from "@vueuse/core";
|
||||||
|
import { Label } from "reka-ui";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
for: { type: String, required: false },
|
||||||
|
asChild: { type: Boolean, required: false },
|
||||||
|
as: { type: null, required: false },
|
||||||
|
class: { type: null, required: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class");
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Label
|
||||||
|
data-slot="label"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</Label>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as Label } from "./Label.vue";
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<script setup>
|
||||||
|
import { reactiveOmit } from "@vueuse/core";
|
||||||
|
import { SwitchRoot, SwitchThumb, useForwardPropsEmits } from "reka-ui";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
defaultValue: { type: Boolean, required: false },
|
||||||
|
modelValue: { type: [Boolean, null], required: false },
|
||||||
|
disabled: { type: Boolean, required: false },
|
||||||
|
id: { type: String, required: false },
|
||||||
|
value: { type: String, required: false },
|
||||||
|
asChild: { type: Boolean, required: false },
|
||||||
|
as: { type: null, required: false },
|
||||||
|
name: { type: String, required: false },
|
||||||
|
required: { type: Boolean, required: false },
|
||||||
|
class: { type: null, required: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emits = defineEmits(["update:modelValue"]);
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class");
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SwitchRoot
|
||||||
|
v-slot="slotProps"
|
||||||
|
data-slot="switch"
|
||||||
|
v-bind="forwarded"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<SwitchThumb
|
||||||
|
data-slot="switch-thumb"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0',
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot name="thumb" v-bind="slotProps" />
|
||||||
|
</SwitchThumb>
|
||||||
|
</SwitchRoot>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as Switch } from "./Switch.vue";
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<template>
|
||||||
|
<div class="relative min-h-screen bg-slate-950 text-white">
|
||||||
|
|
||||||
|
<div class="fixed top-0 left-0 right-0 z-40 bg-slate-900/90 backdrop-blur border-b border-slate-700 p-2 shadow-lg">
|
||||||
|
<div class="max-w-4xl mx-auto flex items-center justify-between">
|
||||||
|
|
||||||
|
<div class="flex flex-col items-start">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="font-bold text-lg text-blue-400">{{ myName }}</span>
|
||||||
|
<span v-if="matchState.wager > 0" class="flex items-center text-xs text-yellow-400 bg-yellow-400/10 px-2 rounded">
|
||||||
|
<Coins class="w-3 h-3 mr-1" /> {{ matchState.wager }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-1 mt-1">
|
||||||
|
<div class="w-3 h-3 rounded-full border border-blue-500"
|
||||||
|
:class="{ 'bg-blue-500': matchState.myWins >= 1 }"></div>
|
||||||
|
<div class="w-3 h-3 rounded-full border border-blue-500"
|
||||||
|
:class="{ 'bg-blue-500': matchState.myWins >= 2 }"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-center">
|
||||||
|
<span class="text-xs uppercase tracking-widest text-slate-400">Match {{ matchState.id }}</span>
|
||||||
|
<span class="text-xl font-black font-mono">
|
||||||
|
{{ matchState.myWins }} - {{ matchState.oppWins }}
|
||||||
|
</span>
|
||||||
|
<span class="text-[10px] text-slate-500">Best of 3</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-end">
|
||||||
|
<span class="font-bold text-lg text-red-400">{{ opponentName }}</span>
|
||||||
|
<div class="flex gap-1 mt-1">
|
||||||
|
<div class="w-3 h-3 rounded-full border border-red-500"
|
||||||
|
:class="{ 'bg-red-500': matchState.oppWins >= 1 }"></div>
|
||||||
|
<div class="w-3 h-3 rounded-full border border-red-500"
|
||||||
|
:class="{ 'bg-red-500': matchState.oppWins >= 2 }"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pt-20 h-screen">
|
||||||
|
<GameBoard
|
||||||
|
v-if="matchState.currentGameId && !matchOver"
|
||||||
|
:game-id="matchState.currentGameId"
|
||||||
|
@game-end="handleGameEnd"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-else-if="!matchOver" class="flex h-full items-center justify-center flex-col gap-4">
|
||||||
|
<div class="text-2xl font-bold animate-pulse">Preparing next round...</div>
|
||||||
|
<p class="text-slate-400">Switching sides and shuffling deck</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="matchOver" class="absolute inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-md">
|
||||||
|
<div class="text-center space-y-6 p-8 bg-slate-900 border-2 border-purple-500 rounded-2xl shadow-2xl max-w-md w-full">
|
||||||
|
<Trophy v-if="iWonMatch" class="w-20 h-20 text-yellow-400 mx-auto animate-bounce" />
|
||||||
|
<Frown v-else class="w-20 h-20 text-slate-500 mx-auto" />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1 class="text-4xl font-black uppercase mb-2"
|
||||||
|
:class="iWonMatch ? 'text-yellow-400' : 'text-slate-400'">
|
||||||
|
{{ iWonMatch ? 'Victory!' : 'Defeat' }}
|
||||||
|
</h1>
|
||||||
|
<p class="text-slate-300">Final Score: {{ matchState.myWins }} - {{ matchState.oppWins }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="matchState.wager > 0" class="bg-yellow-500/10 p-4 rounded-xl border border-yellow-500/20">
|
||||||
|
<p class="text-xs uppercase text-yellow-600 font-bold mb-1">Total Reward</p>
|
||||||
|
<div class="flex items-center justify-center gap-2 text-2xl font-bold text-yellow-400">
|
||||||
|
<Coins class="w-6 h-6" />
|
||||||
|
<span>{{ iWonMatch ? '+' + (matchState.wager * 2) : '-' + matchState.wager }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button @click="leaveMatch" class="w-full bg-purple-600 hover:bg-purple-700">
|
||||||
|
Return to Lobby
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
// Imports and setup similar to GamePage, but tracking Match State
|
||||||
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { Coins, Trophy, Frown } from 'lucide-vue-next'
|
||||||
|
import GameBoard from '@/components/game/GameBoard.vue' // Reuse your existing board logic
|
||||||
|
import axios from 'axios'
|
||||||
|
// ... socket logic for match_updates ...
|
||||||
|
|
||||||
|
const matchState = ref({
|
||||||
|
id: null,
|
||||||
|
wager: 0,
|
||||||
|
myWins: 0,
|
||||||
|
oppWins: 0,
|
||||||
|
currentGameId: null,
|
||||||
|
players: {}
|
||||||
|
})
|
||||||
|
|
||||||
|
const matchOver = computed(() => matchState.value.myWins >= 2 || matchState.value.oppWins >= 2)
|
||||||
|
const iWonMatch = computed(() => matchState.value.myWins > matchState.value.oppWins)
|
||||||
|
|
||||||
|
// This function listens for Socket events on the "Match Channel"
|
||||||
|
// e.g., channel: `match.{id}` event: `RoundEnded`
|
||||||
|
const listenToMatch = () => {
|
||||||
|
// When a game ends, the server should send:
|
||||||
|
// { nextGameId: 123, scores: { p1: 1, p2: 0 } }
|
||||||
|
// Update matchState.currentGameId to trigger the GameBoard to reload
|
||||||
|
}
|
||||||
|
</script>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@ import TestAllAnimations from '@/pages/TestAllAnimations.vue'
|
|||||||
import TestGameBoard from '@/pages/TestGameBoard.vue'
|
import TestGameBoard from '@/pages/TestGameBoard.vue'
|
||||||
import SinglePlayerGamePage from '@/pages/game/SinglePlayerGamePage.vue'
|
import SinglePlayerGamePage from '@/pages/game/SinglePlayerGamePage.vue'
|
||||||
import MultiplayerGamePage from '@/pages/game/MultiplayerGamePage.vue'
|
import MultiplayerGamePage from '@/pages/game/MultiplayerGamePage.vue'
|
||||||
|
import MultiplayerMatchPage from '@/pages/game/MultiplayerMatchPage.vue'
|
||||||
import RegisterPage from '@/pages/register/RegisterPage.vue'
|
import RegisterPage from '@/pages/register/RegisterPage.vue'
|
||||||
import CoinsPurchasePage from '@/pages/purchase/CoinPurchasePage.vue'
|
import CoinsPurchasePage from '@/pages/purchase/CoinPurchasePage.vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
@@ -54,6 +55,12 @@ const router = createRouter({
|
|||||||
component: MultiplayerGamePage,
|
component: MultiplayerGamePage,
|
||||||
meta: { requiresAuth: true }
|
meta: { requiresAuth: true }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/match/:id',
|
||||||
|
name: 'multiplayer-match',
|
||||||
|
component: MultiplayerMatchPage,
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/user',
|
path: '/user',
|
||||||
component: UserPage,
|
component: UserPage,
|
||||||
|
|||||||
Generated
+6
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "DADProject",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user