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;
|
completedAt?: string;
|
||||||
playerCount?: number;
|
playerCount?: number;
|
||||||
expiresAt: string;
|
expiresAt: string;
|
||||||
|
progression?: {
|
||||||
|
currentStep: number;
|
||||||
|
completedSteps: number;
|
||||||
|
totalSteps: number;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
type ResolutionMetric = {
|
type ResolutionMetric = {
|
||||||
@@ -98,22 +103,38 @@ export const load: PageServerLoad = async () => {
|
|||||||
const currentAndIncomingRaw = await db.query.gameSession.findMany({
|
const currentAndIncomingRaw = await db.query.gameSession.findMany({
|
||||||
where: and(eq(gameSession.isActive, 1), gt(gameSession.expiresAt, new Date())),
|
where: and(eq(gameSession.isActive, 1), gt(gameSession.expiresAt, new Date())),
|
||||||
with: {
|
with: {
|
||||||
escapeGame: true,
|
escapeGame: {
|
||||||
players: true
|
with: {
|
||||||
|
steps: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
players: true,
|
||||||
|
progress: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const currentAndIncomingSessions: Session[] = currentAndIncomingRaw
|
const currentAndIncomingSessions: Session[] = currentAndIncomingRaw
|
||||||
.map((session) => ({
|
.map((session) => {
|
||||||
id: session.id,
|
const totalSteps = session.escapeGame.steps.length;
|
||||||
code: session.code,
|
const completedSteps = session.progress.filter((p) => p.completedAt !== null).length;
|
||||||
gameName: session.escapeGame.title,
|
const currentStep = completedSteps + 1;
|
||||||
isActive: session.isActive === 1,
|
|
||||||
startedAt: session.startedAt ? new Date(session.startedAt).toISOString() : undefined,
|
return {
|
||||||
completedAt: session.completedAt ? new Date(session.completedAt).toISOString() : undefined,
|
id: session.id,
|
||||||
playerCount: session.players.length,
|
code: session.code,
|
||||||
expiresAt: session.expiresAt.toISOString()
|
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) => {
|
.sort((a, b) => {
|
||||||
const aIsCurrent = Boolean(a.startedAt) && !a.completedAt;
|
const aIsCurrent = Boolean(a.startedAt) && !a.completedAt;
|
||||||
const bIsCurrent = Boolean(b.startedAt) && !b.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">
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
{$t.admin.players}
|
{$t.admin.players}
|
||||||
</th>
|
</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">
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
{$t.admin.expires}
|
{$t.admin.expires}
|
||||||
</th>
|
</th>
|
||||||
@@ -122,6 +125,28 @@
|
|||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
{session.playerCount || 0}
|
{session.playerCount || 0}
|
||||||
</td>
|
</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">
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
{new Date(session.expiresAt).toLocaleString()}
|
{new Date(session.expiresAt).toLocaleString()}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
let { data, children }: { data: LayoutData; children: import('svelte').Snippet } = $props();
|
let { data, children }: { data: LayoutData; children: import('svelte').Snippet } = $props();
|
||||||
|
|
||||||
let inventoryOpen = $state(false);
|
let inventoryOpen = $state(false);
|
||||||
|
let tutorialOpen = $state(false);
|
||||||
let dragStartY = $state(0);
|
let dragStartY = $state(0);
|
||||||
let dragCurrentY = $state(0);
|
let dragCurrentY = $state(0);
|
||||||
|
|
||||||
@@ -23,6 +24,14 @@
|
|||||||
dragCurrentY = 0;
|
dragCurrentY = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleTutorial = () => {
|
||||||
|
tutorialOpen = !tutorialOpen;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeTutorial = () => {
|
||||||
|
tutorialOpen = false;
|
||||||
|
};
|
||||||
|
|
||||||
const handleTouchStart = (e: TouchEvent) => {
|
const handleTouchStart = (e: TouchEvent) => {
|
||||||
dragStartY = e.touches[0].clientY;
|
dragStartY = e.touches[0].clientY;
|
||||||
dragCurrentY = e.touches[0].clientY;
|
dragCurrentY = e.touches[0].clientY;
|
||||||
@@ -71,15 +80,16 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<a
|
<button
|
||||||
href={resolve('/(game)/game/play/[sessionCode]/tutorial', { sessionCode: data.sessionCode })}
|
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"
|
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}"
|
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">
|
<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" />
|
<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>
|
</svg>
|
||||||
</a>
|
</button>
|
||||||
|
|
||||||
{#if data.previousStepId}
|
{#if data.previousStepId}
|
||||||
<form method="GET" action={resolve('/(game)/game/play/[sessionCode]', { sessionCode: data.sessionCode })}>
|
<form method="GET" action={resolve('/(game)/game/play/[sessionCode]', { sessionCode: data.sessionCode })}>
|
||||||
@@ -186,4 +196,68 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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}
|
{/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