feat: add progression tracking to game sessions and implement tutorial modal
All checks were successful
Migrate supabase / migrate (push) Successful in 15s
All checks were successful
Migrate supabase / migrate (push) Successful in 15s
This commit is contained in:
@@ -23,6 +23,11 @@ type Session = {
|
||||
completedAt?: string;
|
||||
playerCount?: number;
|
||||
expiresAt: string;
|
||||
progression?: {
|
||||
currentStep: number;
|
||||
completedSteps: number;
|
||||
totalSteps: number;
|
||||
};
|
||||
};
|
||||
|
||||
type ResolutionMetric = {
|
||||
@@ -98,22 +103,38 @@ export const load: PageServerLoad = async () => {
|
||||
const currentAndIncomingRaw = await db.query.gameSession.findMany({
|
||||
where: and(eq(gameSession.isActive, 1), gt(gameSession.expiresAt, new Date())),
|
||||
with: {
|
||||
escapeGame: true,
|
||||
players: true
|
||||
escapeGame: {
|
||||
with: {
|
||||
steps: true
|
||||
}
|
||||
},
|
||||
players: true,
|
||||
progress: true
|
||||
}
|
||||
});
|
||||
|
||||
const currentAndIncomingSessions: Session[] = currentAndIncomingRaw
|
||||
.map((session) => ({
|
||||
id: session.id,
|
||||
code: session.code,
|
||||
gameName: session.escapeGame.title,
|
||||
isActive: session.isActive === 1,
|
||||
startedAt: session.startedAt ? new Date(session.startedAt).toISOString() : undefined,
|
||||
completedAt: session.completedAt ? new Date(session.completedAt).toISOString() : undefined,
|
||||
playerCount: session.players.length,
|
||||
expiresAt: session.expiresAt.toISOString()
|
||||
}))
|
||||
.map((session) => {
|
||||
const totalSteps = session.escapeGame.steps.length;
|
||||
const completedSteps = session.progress.filter((p) => p.completedAt !== null).length;
|
||||
const currentStep = completedSteps + 1;
|
||||
|
||||
return {
|
||||
id: session.id,
|
||||
code: session.code,
|
||||
gameName: session.escapeGame.title,
|
||||
isActive: session.isActive === 1,
|
||||
startedAt: session.startedAt ? new Date(session.startedAt).toISOString() : undefined,
|
||||
completedAt: session.completedAt ? new Date(session.completedAt).toISOString() : undefined,
|
||||
playerCount: session.players.length,
|
||||
expiresAt: session.expiresAt.toISOString(),
|
||||
progression: totalSteps > 0 ? {
|
||||
currentStep,
|
||||
completedSteps,
|
||||
totalSteps
|
||||
} : undefined
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const aIsCurrent = Boolean(a.startedAt) && !a.completedAt;
|
||||
const bIsCurrent = Boolean(b.startedAt) && !b.completedAt;
|
||||
|
||||
@@ -96,6 +96,9 @@
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{$t.admin.players}
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Progression
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{$t.admin.expires}
|
||||
</th>
|
||||
@@ -122,6 +125,28 @@
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{session.playerCount || 0}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
{#if session.progression}
|
||||
<div class="max-w-xs">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-xs font-medium text-gray-700">
|
||||
Step {session.progression.currentStep} of {session.progression.totalSteps}
|
||||
</span>
|
||||
<span class="text-xs text-gray-500">
|
||||
{Math.round((session.progression.completedSteps / session.progression.totalSteps) * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="w-full h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-indigo-500 transition-all duration-300"
|
||||
style="width: {(session.progression.completedSteps / session.progression.totalSteps) * 100}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<span class="text-xs text-gray-400">No progress</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{new Date(session.expiresAt).toLocaleString()}
|
||||
</td>
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
let { data, children }: { data: LayoutData; children: import('svelte').Snippet } = $props();
|
||||
|
||||
let inventoryOpen = $state(false);
|
||||
let tutorialOpen = $state(false);
|
||||
let dragStartY = $state(0);
|
||||
let dragCurrentY = $state(0);
|
||||
|
||||
@@ -23,6 +24,14 @@
|
||||
dragCurrentY = 0;
|
||||
};
|
||||
|
||||
const toggleTutorial = () => {
|
||||
tutorialOpen = !tutorialOpen;
|
||||
};
|
||||
|
||||
const closeTutorial = () => {
|
||||
tutorialOpen = false;
|
||||
};
|
||||
|
||||
const handleTouchStart = (e: TouchEvent) => {
|
||||
dragStartY = e.touches[0].clientY;
|
||||
dragCurrentY = e.touches[0].clientY;
|
||||
@@ -71,15 +80,16 @@
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<a
|
||||
href={resolve('/(game)/game/play/[sessionCode]/tutorial', { sessionCode: data.sessionCode })}
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggleTutorial}
|
||||
class="rounded-lg border border-indigo-200 bg-indigo-50 px-3 py-2 text-center transition-colors hover:bg-indigo-100 flex items-center justify-center"
|
||||
aria-label="{$t.gameplay.tutorial}"
|
||||
>
|
||||
<svg class="h-5 w-5 text-indigo-700" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</a>
|
||||
</button>
|
||||
|
||||
{#if data.previousStepId}
|
||||
<form method="GET" action={resolve('/(game)/game/play/[sessionCode]', { sessionCode: data.sessionCode })}>
|
||||
@@ -186,4 +196,68 @@
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if tutorialOpen}
|
||||
<div
|
||||
class="fixed inset-0 z-40 bg-black/40 transition-opacity duration-300"
|
||||
onclick={closeTutorial}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Escape') closeTutorial();
|
||||
}}
|
||||
role="button"
|
||||
tabindex={0}
|
||||
></div>
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center px-4 py-8"
|
||||
role="dialog"
|
||||
aria-label="Tutorial"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div class="w-full max-w-2xl rounded-2xl border border-gray-200 bg-white p-5 shadow-lg sm:p-7">
|
||||
<div class="flex items-start justify-between mb-5">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">{$t.gameplay.tutorialTitle}</h1>
|
||||
<p class="mt-1 text-sm text-gray-700 sm:text-base">{$t.gameplay.tutorialIntro}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={closeTutorial}
|
||||
class="text-gray-400 hover:text-gray-600"
|
||||
aria-label="Close tutorial"
|
||||
>
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-start gap-3 rounded-xl border border-gray-200 bg-gray-50 p-4">
|
||||
<span class="mt-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-white text-xs font-semibold text-gray-700">1</span>
|
||||
<p class="text-sm text-gray-800 sm:text-base">{$t.gameplay.tutorialPrevious}</p>
|
||||
</div>
|
||||
<div class="flex items-start gap-3 rounded-xl border border-gray-200 bg-gray-50 p-4">
|
||||
<span class="mt-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-white text-xs font-semibold text-gray-700">2</span>
|
||||
<p class="text-sm text-gray-800 sm:text-base">{$t.gameplay.tutorialInventory}</p>
|
||||
</div>
|
||||
<div class="flex items-start gap-3 rounded-xl border border-gray-200 bg-gray-50 p-4">
|
||||
<span class="mt-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-white text-xs font-semibold text-gray-700">3</span>
|
||||
<p class="text-sm text-gray-800 sm:text-base">{$t.gameplay.tutorialNext}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="mt-5 rounded-lg border border-indigo-100 bg-indigo-50 px-3 py-2 text-xs text-indigo-800 sm:text-sm">
|
||||
{$t.gameplay.tutorial}: {$t.gameplay.inventory}, {$t.gameplay.previous}, {$t.gameplay.next}
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={closeTutorial}
|
||||
class="mt-6 w-full rounded-lg bg-indigo-600 px-4 py-3 text-base font-semibold text-white transition-colors hover:bg-indigo-700"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n';
|
||||
</script>
|
||||
|
||||
<div class="bg-gray-50 px-3 py-5 sm:px-4 sm:py-8">
|
||||
<div class="mx-auto max-w-2xl rounded-2xl border border-gray-200 bg-white p-5 shadow-sm sm:p-7">
|
||||
<h1 class="mb-2 text-2xl font-bold text-gray-900">{$t.gameplay.tutorialTitle}</h1>
|
||||
<p class="mb-5 text-sm text-gray-700 sm:text-base">{$t.gameplay.tutorialIntro}</p>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-start gap-3 rounded-xl border border-gray-200 bg-gray-50 p-4">
|
||||
<span class="mt-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-white text-xs font-semibold text-gray-700">1</span>
|
||||
<p class="text-sm text-gray-800 sm:text-base">{$t.gameplay.tutorialPrevious}</p>
|
||||
</div>
|
||||
<div class="flex items-start gap-3 rounded-xl border border-gray-200 bg-gray-50 p-4">
|
||||
<span class="mt-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-white text-xs font-semibold text-gray-700">2</span>
|
||||
<p class="text-sm text-gray-800 sm:text-base">{$t.gameplay.tutorialInventory}</p>
|
||||
</div>
|
||||
<div class="flex items-start gap-3 rounded-xl border border-gray-200 bg-gray-50 p-4">
|
||||
<span class="mt-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-white text-xs font-semibold text-gray-700">3</span>
|
||||
<p class="text-sm text-gray-800 sm:text-base">{$t.gameplay.tutorialNext}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="mt-5 rounded-lg border border-indigo-100 bg-indigo-50 px-3 py-2 text-xs text-indigo-800 sm:text-sm">
|
||||
{$t.gameplay.tutorial}: {$t.gameplay.inventory}, {$t.gameplay.previous}, {$t.gameplay.next}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user