В процессе создания своего сайта-портфолио я подумал, что было бы здорово добавить небольшую игру на запоминание, в которую пользователь мог бы играть, и решил, что это может стать отличным забавным проектом, чтобы познакомить некоторых любопытных программистов с этим замечательным компилятором/фреймворком под названием Svelte.
- Предварительный просмотр того, что мы собираемся создать:
- Все готово!
- КРАЙНИЕ СЛУЧАИ
- Разметка
- Предварительные требования
- Давайте теперь сосредоточимся на блоке «each».
- Логика игры
- Теперь самое интересное, давайте создадим нашу функцию handleUncovering
- Все раскрытия теперь отсортированы, теперь мы можем работать с ошибками и условиями выигрыша.
- ВОТ И ВСЕ! 🥳
- Спасибо, что проверили это руководство!
Предварительный просмотр того, что мы собираемся создать:
Предварительный просмотр
В этом уроке мы будем использовать SvelteKit и Tailwind CSS — это очень просто настроить, две команды и готово.
Откройте терминал и напишите эту команду:
npm init svelte your-app-name
Svelte задаст вам пару вопросов, для целей этого руководства вы захотите использовать TypeScript, ESLINT и Prettier для форматирования кода, а PlayWright мы можем пропустить, так как не собираемся создавать тесты.
Чтобы установить tailwind для вашего проекта, вы можете просто использовать эту команду:
npx svelte-add@latest tailwindcss
Все готово!
Прежде чем приступить к кодированию, будет полезно записать все, что мы ожидаем от нашей программы, давайте подытожим:
- позволить игроку раскрывать карты, нажимая на них
- в любой момент времени разрешается раскрывать не более двух карт
- если две карты раскрыты, то выполняется проверка
- если две карты имеют одинаковый символ, то это пара
- если нет, то накройте их снова
- если все карты раскрыты, игрок выигрывает.
КРАЙНИЕ СЛУЧАИ
- если карта уже раскрыта, не закрывайте ее
- если две карты уже раскрыты и пользователь нажимает еще раз, ничего не делайте.
Запись может показаться лишним шагом, но это действительно помогает представить все компоненты, которые могут понадобиться, и найти большинство проблем, с которыми вы можете столкнуться.
Я собираюсь поместить весь игровой код в компонент «Game», поэтому внутри папки «src» я создам папку «lib», которая будет содержать все мои файлы. Папка «lib» полезна в SvelteKit, потому что она предоставляет то, что мы называем «псевдонимом», то есть, если у вас есть файл, который хранится в папке «SvelteKit».
"src/lib/components/game/Game.svelte"
вы можете импортировать его, сказав:
import Game from '$lib/components/game/Game.svelte'
Итак, теперь ваш файл «/src/routes/index.svelte» должен выглядеть примерно так:
<script lang="ts">
import Game from '$lib/components/Game.svelte';
</script>
<Game />
<style>
:global(body) {
background-color: rgb(15, 23, 42);
}
</style>
Тег :global(body) просто говорит svelte, что текущее правило, примененное к «body», будет применено к каждому файлу в проекте — поэтому для каждой страницы фоном будет
«rgb(15, 23, 42)»
Теперь давайте создадим файл «Game.svelte» в «/src/lib/components».
Посмотрев на список вещей, которые должна делать наша программа, мы можем увидеть все переменные, которые нам понадобятся для реализации базовой версии игры, поэтому давайте создадим их:
- shouldShow -> поможет нам с начальной анимацией при первой загрузке
- currentlyUncovered -> помогает отслеживать, какие записи открыты
- isWon -> позволяет нам узнать, закончена игра или нет
- pairedCards -> отслеживает, какие записи являются парными
- cards -> все карты с их состоянием
переведены в код:
<script lang="ts">
let shouldShow: boolean = false;
let currentlyUncovered: Array<Card> = [];
let isWon: boolean = false;
$: pairedCards = cards.filter((f) => f.paired);
interface Card {
symbol: string;
paired: boolean;
covered: boolean;
id: number;
}
let cards: Array<Card> = [
{ symbol: '👻', paired: false, covered: true, id: Math.random() },
{ symbol: '💩', paired: false, covered: true, id: Math.random() },
{ symbol: '🐊', paired: false, covered: true, id: Math.random() },
{ symbol: '🐳', paired: false, covered: true, id: Math.random() },
{ symbol: '🦥', paired: false, covered: true, id: Math.random() },
{ symbol: '🍄', paired: false, covered: true, id: Math.random() },
{ symbol: '👻', paired: false, covered: true, id: Math.random() },
{ symbol: '💩', paired: false, covered: true, id: Math.random() },
{ symbol: '🐊', paired: false, covered: true, id: Math.random() },
{ symbol: '🐳', paired: false, covered: true, id: Math.random() },
{ symbol: '🦥', paired: false, covered: true, id: Math.random() },
{ symbol: '🍄', paired: false, covered: true, id: Math.random() }
];
</script>
Давайте разберем это на части — если вы, как и я, не знакомы с TypeScript, это очень просто.
let currentlyUncovered: Array<Card> = [];
«Array» в основном говорит, что currentlyUncovered будет массивом, и он будет содержать элементы типа «Card», тип «Card» не существует в javascript, это не типичный тип — но это тип, который мы создали сами парой строк ниже:
interface Card {
symbol: string;
paired: boolean;
covered: boolean;
id: number;
}
Это говорит о том, что наша карточка является объектом и содержит следующие ключи: «символ» (это строка), «paired» (это булево значение), «covered» и так далее…
Это практически только объявление типов для каждой записи в нашем объекте Card.
$: pairedCards = cards.filter((f) => f.paired);
Эта строка может быть довольно сложной для понимания, если вы не знакомы со Svelte, «$:» — это то, что мы называем «реактивный оператор».
Это объявление переменной (если она еще не объявлена), которое, по сути, говорит: Каждый раз, когда что-то меняется в этом выражении, я буду реагировать на это.
так что в данном случае каждый раз, когда результат
cards.filter((f) => f.paired)
будет меняться, значение pairedCards будет отражать результат.
Это невероятно мощный инструмент, который может быть обоюдоострым мечом, поскольку вы можете себе представить, как трудно отлаживать реактивные операторы в большом приложении, если ими злоупотреблять.
Небольшое замечание по поводу «Math.random()» для нашего идентификатора — для целей данного руководства это сработает, у нас только 16 карт в нашем массиве и вероятность того, что идентификатор будет дублироваться, очень мала… но если вы хотите быть абсолютно уверены, что этого не произойдет, вы можете использовать библиотеку вроде uuid для генерации уникального идентификатора.
Разметка
Все готово для нашей базовой установки, и мы можем перейти к созданию базовой HTML структуры.
Предварительные требования
Я использую библиотеку под названием Confetti Explosion для создания красивого визуального эффекта, когда игра выиграна, это делает весь процесс немного более веселым. Вот как ее установить:
npm install svelte-confetti-explosion
и вот как его использовать:
<ConfettiExplosion
particleCount={200}
force={0.7}
/>
Теперь, когда Confetti Explosion установлен и готов, мы можем перейти к созданию разметки для игры.
Вот как это выглядит: (не волнуйтесь, я разберу это ниже)
{#if shouldShow}
<div
transition:scale={{ duration: 400 }}
class="game absolute w-max z-50 p-8 bg-gray-900 shadow-3xl rounded-3xl text-white top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2"
>
<h2 class="text-center font-extrabold mb-8 text-3xl text-gray-200 tracking-tighter">Memory</h2>
<div class="game-grid relative grid grid-cols-3 gap-1">
{#if isWon}
<div class="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2">
<ConfettiExplosion particleCount={200} force={0.7} />
</div>
<div
class="absolute w-full top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 confetti-explosion mx-auto text-center py-16"
>
<div class="win-message flex flex-wrap justify-center">
<p class="text-2xl w-full font-light tracking-tighter italic mb-0">congratulations</p>
<h2 class="text-6xl w-full font-black tracking-tighter mb-4">YOU WON!</h2>
<div class="flex gap-3">
<button
class="text-white bg-transparent border border-white hover:bg-white hover:text-black transition-all py-1 px-4 rounded-md cursor-pointer w-max"
on:click={playAgain}>Play Again</button
>
</div>
</div>
</div>
{/if}
{#each cards as card}
<div
class:uncovered={!card.covered}
class:covered={card.covered}
class="card text-gray-600 cursor-pointer w-32 h-32 {isWon
? 'opacity-0 pointer-events-none'
: ''} {card.covered
? 'bg-gray-800 hover:bg-blue-800 hover:text-white'
: 'bg-white'} rounded-3xl p-4 relative"
on:click={() => handleUncovering(card)}
>
<div
class="absolute symbol top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 text-6xl"
>
{#if card.covered}
<span
in:fade={{ duration: 500, delay: 200 }}
out:fade={{ duration: 0, delay: 0 }}
class="text-6xl font-black"
>
?
</span>
{:else}
<div
in:fade={{ duration: 500, delay: 200 }}
out:fade={{ duration: 0, delay: 0 }}
class="symbol"
>
{card.symbol}
</div>
{/if}
</div>
</div>
{/each}
</div>
</div>
{/if}
<style>
.uncovered {
transform: rotateY(180deg);
}
.card {
transition: 0.8s all ease;
}
.covered {
transform: rotateY(0deg);
}
</style>
Как вы можете видеть, весь блок обернут в оператор if.
{#if shouldShow}{/if}
Это сделано для того, чтобы у нас был переход при установке компонента.
Прямо под ним первый div, содержащий всю игру, имеет установленную шкалу перехода. Он будет выполняться, когда мы изменим «shouldShow» с false на true.
Давайте теперь сосредоточимся на блоке «each».
Мы перебираем наши карты, и нам нужно убедиться, что они имеют два разных состояния: покрытое и непокрытое.
Мы можем сделать это с помощью CSS-трансформации и повернуть их по оси Y.
Как вы можете видеть, мы применили эти условные классы к div
class:uncovered={!card.covered}
class:covered={card.covered}
Это «условные классы», то есть класс «uncovered» будет добавлен к div, если текущая карта не закрыта, а «covered» будет добавлен, если карта закрыта.
Если вы не знакомы с циклами, то вот откуда берется «card»: когда мы открыли блок each {#each cards as card} -> «cards» — это наш объект с 16 карточками, а «card» — это экземпляр, который мы сейчас перебираем в цикле «each».
стоит обратить внимание и на это:
class="card text-gray-600 cursor-pointer w-32 h-32 {isWon
? 'opacity-0 pointer-events-none'
: ''} {card.covered
? 'bg-gray-800 hover:bg-blue-800 hover:text-white'
: 'bg-white'} rounded-3xl p-4 relative"
если игра выиграна, мы меняем непрозрачность карт на 0 и удаляем все события указателя, чтобы пользователь не мог нажать на них — это потому, что мы хотим сохранить игру на той же высоте и сообщение «ВЫ ВЫИГРАЛИ» в центре (чтобы избежать уменьшения контейнера и прыгучести); Это также можно было бы сделать, установив определенную высоту для div игры, но это просто вопрос предпочтения, я нашел это более простым, особенно если игра должна быть отзывчивой.
on:click={() => handleUncovering(card)}
это просто функция, которую мы скоро напишем, и она передает текущую карту в качестве аргумента.
Обратите внимание, что в событиях мы используем функцию стрелки для передачи аргумента, если бы мы сделали так
on:click={handleUncovering(card)}
функция была бы вызвана сразу, а это не то, что нам нужно в данном случае.
Другие примечания к разметке таковы:
in:fade={{ duration: 500, delay: 200 }}
out:fade={{ duration: 0, delay: 0 }}
для обоих состояний — закрытия и открытия — переход out установлен на 0 с задержкой 0. Это потому, что мы хотим, чтобы следующая анимация начиналась сразу же.
В остальном все, что мы делаем, это показываем символ карты, если она раскрыта, и знак вопроса, если она раскрыта.
Логика игры
Наконец-то пришло время построить логику игры — наша функция handleUncovering будет выполнять большую часть работы, но прежде чем мы начнем, нам нужно найти способ перетасовать наши карты, как если бы это была колода карт.
Я нашел хороший алгоритм, который делает именно это.
// helper function to shuffle array
function shuffleArray(array: Array<any>) {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j], array[i]];
}
// reassign entries to trigger an update
cards = cards;
}
Подробнее об этом можно прочитать здесь
Итак, теперь все, что нам нужно сделать, когда компонент монтируется, это перетасовать массив и установить shouldShow в true, чтобы наш компонент мог отображаться, как показано ниже
onMount(() => {
shouldShow = true;
shuffleArray(cards);
});
Не забудьте импортировать onMount:
import { onMount } from 'svelte'
onMount — это хук Svelte, по сути, функция, которая запускается, когда компонент монтируется, если вы пришли из react, это эквивалент старого componentDidMount или нового способа сделать это с помощью useEffect.
Прежде чем приступить к большой функции раскрытия карты, мы можем создать две вспомогательные функции:
- раскрыть карту
- сбросить массив currentlyUncovered.
Они не обязательны, но помогут сократить количество повторений в нашем коде.
// resets the currently uncovered array
// and reassign cards to itself so svelte knows
// it needs to re-render
function reset() {
currentlyUncovered = [];
cards = cards;
}
// uncovers an card and reassigns cards to let svelte
// know to re-render
function uncover(card: Card) {
if (card?.covered) {
card.covered = false;
}
cards = cards;
}
Не беспокойтесь об этом сейчас, мы только что объявили их, но еще не использовали (не вызывали), мы сделаем это через минуту.
Теперь самое интересное, давайте создадим нашу функцию handleUncovering
сначала объявим ее
function handleUncovering(card: Card) {
// logic here
}
Наша функция будет принимать карту в качестве параметра с ранее объявленным типом Card.
Теперь давайте посмотрим на наш список, который мы создали ранее. Помните, что эта функция запускается каждый раз, когда пользователь нажимает на любую карточку, потому что мы установили обработчик on:click на запись — поэтому давайте начнем выполнять некоторые проверки.
// if the card is already uncovered, do nothing
if (pairedCards.find((f) => f === card)) {
return;
}
проверяет наш массив pairedCards, реактивный массив, чтобы проверить, находится ли карта, которую мы пытаемся раскрыть, уже там, если да, то мы можем прекратить выполнение (return)
возврат означает прекращение выполнения функции, поэтому ни один из кодов внутри скобок нашей функции не будет выполнен
Давайте добавим еще одну проверку для устранения крайнего случая, мы хотим предотвратить быстрое нажатие кнопки мыши для открытия третьей карты, если две из них уже открыты, вот как мы это сделаем:
// if you've already uncovered two entries do nothing
if (currentlyUncovered.length === 2) {
return;
}
Теперь, если ни одна карта в данный момент не раскрыта ИЛИ мы раскрыли только одну карту, мы можем выполнить несколько других проверок:
// if there is already an uncovered card and the user clicks on the same one again
// do nothing
if (!currentlyUncovered.length || currentlyUncovered.length === 1) {
if (card === currentlyUncovered[0]) {
return;
}
// find the card the user has clicked on
const cardToPush: any = cards.find((f) => f.id === card.id);
// uncover the card and push it to the currently uncovered array
uncover(cardToPush);
currentlyUncovered.push(card);
}
Здесь мы выполняем раскрытие карт, если в данный момент есть 0 или 1 раскрытая карта, то мы продолжаем раскрытие.
Как вы видите, мы находим нужную нам карточку, используя cards.find(), который является методом массива, позволяющим находить элементы, соответствующие переданному выражению
так что
const cardToPush: any = cards.find((f) => f.id === card.id)
будет найден элемент внутри массива cards, который имеет тот же id, что и элемент, на который мы нажали, после того как он будет найден, мы используем нашу вспомогательную функцию, созданную ранее, чтобы раскрыть его.
uncover(cardToPush);
и мы также помещаем открытую карту в массив ‘currentlyUncovered’, чтобы мы могли отслеживать открытые карты:
currentlyUncovered.push(card);
Все раскрытия теперь отсортированы, теперь мы можем работать с ошибками и условиями выигрыша.
На данный момент возможны два варианта развития событий:
- карты совпадают, в этом случае мы можем пометить их как «парные»
- карты не совпадают, в этом случае мы снова их закрываем.
вот логика:
// if the user uncovers two entries then start doing checks
if (currentlyUncovered.length === 2) {
// if the two entries have the same symbol then they're a match
if (currentlyUncovered[0].symbol === currentlyUncovered[1].symbol) {
// loop through them and change their state to 'paired'
currentlyUncovered.forEach((f) => {
f.paired = true;
});
reset();
} else {
// if it's not a match then loop through the uncovered
// entries and cover them back again
// timeout to not make it instant, let the user
// realise they made a mistake
setTimeout(() => {
currentlyUncovered.forEach((f) => {
f.covered = true;
});
reset();
}, 800);
}
}
Комментарии к этому практически объясняют каждый шаг, единственное, что я хотел бы прокомментировать, это тайм-аут в 800 мс, это сделано только для того, чтобы искусственно задержать переворачивание карт, только для того, чтобы было понятно, что произошла ошибка и карты переворачиваются снова.
Осталось только задать условие выигрыша:
// determine if the game is won by checking
// if the user has paired all the entries
$: if (pairedCards.length === cards.length) {
setTimeout(() => {
isWon = true;
}, 800);
}
Это реактивный оператор, который проверяет длину нашего массива pairedCards, если количество парных карт равно количеству общих карт, то игра завершена, и мы можем перейти к показу экрана выигрыша, установив isWon в true.
ВОТ И ВСЕ! 🥳
Теперь ваша игра завершена, вот как должен выглядеть код:
Тег скрипта
<script lang="ts">
// import { v4 as uuidv4 } from 'uuid';
import { onMount } from 'svelte';
import { fade, scale } from 'svelte/transition';
import { ConfettiExplosion } from 'svelte-confetti-explosion';
let currentlyUncovered: Array<Card> = [];
let isWon: boolean = false;
let shouldShow: boolean = false;
// reactive statement to determine how many
// entries the user has paid
$: pairedCards = cards.filter((f) => f.paired);
// determine if the game is won by checking
// if the user has paired all the entries
$: if (pairedCards.length === cards.length) {
setTimeout(() => {
isWon = true;
}, 800);
}
interface Card {
symbol: string;
paired: boolean;
covered: boolean;
id: number;
}
let cards: Array<Card> = [
{ symbol: '👻', paired: false, covered: true, id: Math.random() },
{ symbol: '💩', paired: false, covered: true, id: Math.random() },
{ symbol: '🐊', paired: false, covered: true, id: Math.random() },
{ symbol: '🐳', paired: false, covered: true, id: Math.random() },
{ symbol: '🦥', paired: false, covered: true, id: Math.random() },
{ symbol: '🍄', paired: false, covered: true, id: Math.random() },
{ symbol: '👻', paired: false, covered: true, id: Math.random() },
{ symbol: '💩', paired: false, covered: true, id: Math.random() },
{ symbol: '🐊', paired: false, covered: true, id: Math.random() },
{ symbol: '🐳', paired: false, covered: true, id: Math.random() },
{ symbol: '🦥', paired: false, covered: true, id: Math.random() },
{ symbol: '🍄', paired: false, covered: true, id: Math.random() }
];
// helper function to shuffle array
function shuffleArray(array: Array<any>) {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j], array[i]];
}
// reassign entries to trigger an update
cards = cards;
}
function handleUncovering(card: Card) {
// if the card is already uncovered, do nothing
if (pairedCards.find((f) => f === card)) {
return;
}
// if you've already uncovered two entries do nothing
if (currentlyUncovered.length === 2) {
return;
}
// if there is already an uncovered card and the user clicks on the same one again
// do nothing
if (!currentlyUncovered.length || currentlyUncovered.length === 1) {
if (card === currentlyUncovered[0]) {
return;
}
// find the card the user has clicked on
const cardToPush: any = cards.find((f) => f.id === card.id);
// uncover the card and push it to the currently uncovered array
uncover(cardToPush);
currentlyUncovered.push(card);
}
// if the user uncovers two entries then start doing checks
if (currentlyUncovered.length === 2) {
// if the two entries have the same symbol then they're a match
if (currentlyUncovered[0].symbol === currentlyUncovered[1].symbol) {
// loop through them and change their state to 'paired'
currentlyUncovered.forEach((f) => {
f.paired = true;
});
reset();
} else {
// if it's not a match then loop through the uncovered
// entries and cover them back again
// timeout to not make it instant, let the user
// realise they made a mistake
setTimeout(() => {
currentlyUncovered.forEach((f) => {
f.covered = true;
});
reset();
}, 800);
}
}
}
// resets the currently uncovered array
// and reassign entries to itself so svelte knows
// it needs to re-render
function reset() {
currentlyUncovered = [];
cards = cards;
}
// uncovers an card and reassigns entries to let svelte
// know to re-render
function uncover(card: Card) {
if (card?.covered) {
card.covered = false;
}
cards = cards;
}
onMount(() => {
shouldShow = true;
shuffleArray(cards);
});
function playAgain() {
cards.forEach((f) => {
f.paired = false;
f.covered = true;
});
pairedCards = [];
isWon = false;
cards = cards;
}
</script>
Разметка + стиль
{#if shouldShow}
<div
transition:scale={{ duration: 400 }}
class="game absolute w-max z-50 p-8 bg-gray-900 shadow-3xl rounded-3xl text-white top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2"
>
<h2 class="text-center font-extrabold mb-8 text-3xl text-gray-200 tracking-tighter">Memory</h2>
<div class="game-grid relative grid grid-cols-3 gap-1">
{#if isWon}
<div class="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2">
<ConfettiExplosion particleCount={200} force={0.7} />
</div>
<div
class="absolute w-full top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 confetti-explosion mx-auto text-center py-16"
>
<div class="win-message flex flex-wrap justify-center">
<p class="text-2xl w-full font-light tracking-tighter italic mb-0">congratulations</p>
<h2 class="text-6xl w-full font-black tracking-tighter mb-4">YOU WON!</h2>
<div class="flex gap-3">
<button
class="text-white bg-transparent border border-white hover:bg-white hover:text-black transition-all py-1 px-4 rounded-md cursor-pointer w-max"
on:click={playAgain}>Play Again</button
>
</div>
</div>
</div>
{/if}
{#each cards as card}
<div
class:uncovered={!card.covered}
class:covered={card.covered}
class="card text-gray-600 cursor-pointer w-32 h-32 {isWon
? 'opacity-0 pointer-events-none'
: ''} {card.covered
? 'bg-gray-800 hover:bg-blue-800 hover:text-white'
: 'bg-white'} rounded-3xl p-4 relative"
on:click={() => handleUncovering(card)}
>
<div
class="absolute symbol top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 text-6xl"
>
{#if card.covered}
<span
in:fade={{ duration: 500, delay: 200 }}
out:fade={{ duration: 0, delay: 0 }}
class="text-6xl font-black"
>
?
</span>
{:else}
<div
in:fade={{ duration: 500, delay: 200 }}
out:fade={{ duration: 0, delay: 0 }}
class="symbol"
>
{card.symbol}
</div>
{/if}
</div>
</div>
{/each}
</div>
</div>
{/if}
<style>
.uncovered {
transform: rotateY(180deg);
}
.card {
transition: 0.8s all ease;
}
.covered {
transform: rotateY(0deg);
}
</style>
Спасибо, что проверили это руководство!
Я надеюсь, что это помогло вам освоить Svelte и Tailwind, я буду делать больше уроков по SvelteKit в виде статей и видео в ближайшие месяцы, так что следите за обновлениями!