preface
What can you learn from this article
- Learn the core data structures and algorithms of flip games and easily port them to other platforms, frameworks or engines
- Understand the complexity of game development and learn how to deal with it and take it apart
- Develop HTML5 games efficiently while still getting highly maintainable code
- Learn the old transition animation library @tweenjs/tween.js combat experience
- Learn the excellent WebGL rendering engine PixiJS combat experience.
- Online demonstrations and complete GitHub code help improve learning efficiency
The rules of the game
Let’s take a look at the game with a GIF:
The rules are simple:
- When you start the game, you have a few seconds to memorize the patterns on the card
- After that, all the cards will be flipped to the back state
- The user clicks on the back of the card for reverse matching, and the game wins after complete matching
Interface rendering library
For this game I used WebGL-based PixiJS as the base rendering code library. PixiJS has the advantage of being lightweight and fast, making it a good choice for creating expressive games and applications. Of course, you can also use other rendering methods or code libraries, and you can even use DOM for rendering.
Online demo and code
This is the online demo program, you can immediately get started play: wildfirecode.com/flipgame/in…
This is the Github repository for the complete code, which you can refer to for implementation details: github.com/wildfirecod…
Complexity disassembly
To reduce the complexity of the program, we will unpack the code at the following four levels:
- Data structures and algorithms
- View structure
- The user interaction
- Transition animations
Data structures and algorithms
What code falls under the category of data structures and algorithms? Data structures and algorithm code should be easily portable.
If we want to use the 3D Renderer library Three.js to render the game in 3D, or if you want to use the ReactPixi (React Renderer based on PixiJS), the data and algorithm code should be available without modification.
Good data and algorithm design can make code more readable and maintainable.
Flip game has the following data and algorithms:
- Gets the random initial card texture for all cards
- Get the corresponding card according to the coordinates that the user clicks
- Find the matching card in the unpaired card with the card face up
Let’s describe them in detail.
Gets the random initial card pattern for all cards
Here we use a one-dimensional array to store this data. If, say, the game is 3 rows and 4 columns and the card texture pattern type is 5, the final data might look like this:
[
3.0.3.2.1.2.0.4.4.3.1.3
]
Copy the code
Here is a simple version of the algorithm that generates this data. You can also optimize it to include all five card types.
Note that the shuffle shuffle method uses the excellent Fischer-Yates shuffle algorithm.
/** * Fisher-Yates shuffle method */
export const shuffle = (array: number[]) = > {
const length = array == null ? 0 : array.length
if(! length) {return[]}let index = -1
const lastIndex = length - 1
const result = array.concat();
while (++index < length) {
const rand = index + Math.floor(Math.random() * (lastIndex - index + 1))
const value = result[rand]
result[rand] = result[index]
result[index] = value
}
return result
}
const getNextPairType = (textureTypeNumbers: number) = > {
return Math.floor(Math.random() * textureTypeNumbers)
}
/** * Get the initial card texture for all cards *@param TextureTypeNumber Number of card texture types *@param CardNumber Indicates the total number of cards */
export const getRandomTypeList = (textureTypeNumbers: number, cardNumbers: number) = > {
let cardTypeList = [];
for (let index = 0; index < cardNumbers / 2; index++) {
const nextType = getNextPairType(textureTypeNumbers);
cardTypeList.push(nextType, nextType);
}
cardTypeList = shuffle(cardTypeList);
return cardTypeList
}
Copy the code
The corresponding card index is obtained according to the coordinates clicked by the user
At the data level, we want to store all the cards in a one-dimensional array, which makes it easy to index the cards.
Since all the cards are added to a PixiJS Conainer, and the Children property of the Container just meets our needs, we don’t need to create additional arrays.
Then it is relatively simple to obtain the corresponding card index method according to the coordinates that the user clicks. We first get the row and column of the clicked card based on the coordinates, and then calculate the index.
Note that there is a prerequisite for adding cards using Conainer that is left to right and horizontal preference.
const getPositionByGlobalPoint = (point: number[], cellWidth: number, cellHeight: number) = > {
const [x, y] = point;
const col = Math.floor(x / cellWidth);
const row = Math.floor(y / cellHeight);
return [col, row]
}
const getIndex = (position: number[], maxCol: number) = > {
const [col, row] = position;
return col + row * maxCol
}
/** * Retrieves the index of the grid based on the global position *@param GlobalPoint Global location */
export const getGridCellIndex = (globalPoint: number[], cellWidth: number, cellHeight: number, maxCol: number) = > {
return getIndex(
getPositionByGlobalPoint(globalPoint, cellWidth, cellHeight),
maxCol
);
}
Copy the code
Find the matching card in the unpaired card with the card face up
At the data level, an unpaired card facing up is a one-dimensional array, which we call userCards: Container[].
Each time we take a card from the userCards and compare it with the remaining cards, if the match is successful, we remove the paired cards and store them in the new array until there are no cards in the userCards.
/** * Find the pairable card in the unpaired card face up *@param UserCards Unpaired cards with cards face up@param Match Match detection method */
export const getMatchedCards = (userCards: any[], match: (card0, card1) => boolean) = > {
const result: any[] = [];
userCards = userCards.concat();/ / copy
while (userCards.length > 0) {
const card0 = userCards.pop();// If this round is not found, then there is no matching element
for (let i = 0; i < userCards.length; i++) {
const card1 = userCards[i];
if (match(card0, card1)) { // Determine whether a match is made
userCards.splice(i, 1); // Once matched, it needs to be removed and exit the for loop
result.push(card0, card1)
break; }}}return result;
}
Copy the code
At this point, all data structures and algorithms have been designed and implemented.
It is worth mentioning that we do not rely on any other external libraries for this part of the code, so they are easy to test, maintain and migrate.
Let’s consider the design of the view structure.
View structure
For the concept of view structure, I took inspiration from the W3C Web standard’s description of structure.
I recommend creating a static user interface first, focusing on writing code that doesn’t involve interaction or animation, to improve our development efficiency.
To create a structure
We know that the structure of a web page is described in HTML, so we can also describe the structure of a PixiJs-based game in XML:
export const gameStructure =
'
< Front pivotX = "72.5" / > < / Card > < / Grid > `
;
Copy the code
To elaborate on this XML:
- CardA card has two parts: the face and the back. We use
<Card>
Label description cards using and<Back>
and<Front>
To describe the back and face of the card, in addition<Card>
Also the parcel<Back>
and<Front>
. - Back Because of animation requirements, we will set animation anchors through the pivotX property. Also since the texture on the back of the card is fixed, we use the texture property to set the texture.
- The Front pivotX property is set similarly to the Back TAB. Unlike Back, the card is random, so the texture property is not set here.
- Grid Because cards are laid out on a Grid, we use a Grid to describe them. The grid has four attributes: COLs indicates how many columns the card has, Rows indicates how many rows the card has, cellWidth indicates the width of the card or grid, and cellHeight indicates the height of the card or grid.
I wrote a simple XML Parser code base to convert the XML of the game structure into a tree of display objects, which I then added to the stages. Here’s the code to use the XML Parser:
import { parseXML,initParser } from "xml-pixi";
const MAX_COL = 4;
const MAX_ROW = 3;
const CARD_SIZE = [145.193];
const ASSETS = {
'back': 'http://wildfirecode.com/objects/flipgame/back.png'.'frontList': [
'http://wildfirecode.com/objects/flipgame/lajiao.png'.'http://wildfirecode.com/objects/flipgame/caomei.png'.'http://wildfirecode.com/objects/flipgame/xigua.png'.'http://wildfirecode.com/objects/flipgame/hamigua.png'.'http://wildfirecode.com/objects/flipgame/boluo.png',
]
}
initParser(ASSETS);
const gridView = parseXML(gameStructure,
{ cols: MAX_COL, rows: MAX_ROW, cellWidth: CARD_SIZE[0].cellHeight: CARD_SIZE[1]});//parseXML returns the display object tree
app.stage.addChild(gridView);// Add the display object tree to the stage
Copy the code
At this point, we’ve created the structure of the game in just a few lines of JavaScript code.
In general, XML is a great way to describe a user interface that makes your code readable and maintainable.
Operating structure
Once you have a view structure, it’s time to think about the operation of the structure. So, what are the structural actions in the game?
- Initialize card patterns for all brands
- Flip individual brand to front or back
- Flip all signs to front or back
Next, we describe each of these operations. Some familiarity with the PixiJS API is required.
Initialize card patterns for all brands
const initCardType = (girdView: Container) = > {
const typeList = getRandomTypeList(ASSETS.frontList.length, MAX_COL * MAX_ROW);
girdView.children.forEach(
async (cardView: Container, index) => {
const [back, front] = cardView.children as Sprite[];
const type = typeList[index];
front.texture = awaitTexture.fromURL(ASSETS.frontList[type]); }); }Copy the code
Flip individual brand to front or back
enum FLIP_TYPE {
FRONT = 'FRONT',
BACK = 'BACK',}const flipCardTo = (type: FLIP_TYPE, cardView: Container) = > {
const toFront = type == FLIP_TYPE.FRONT;
const [back, front] = cardView.children;
back.scale.x = 1;
front.scale.x = 1; back.visible = ! toFront; front.visible = toFront; }Copy the code
Flip all signs to front or back
const flipAllCardTo = (type: FLIP_TYPE, gridView: Container) = > {
gridView.children.forEach( (cardView: Container) = > flipCardTo(type, cardView) )
}
Copy the code
At this point, all view structure-related code has been written.
To summarize, you can write some test code to test the structural manipulation methods. Because the process of writing code is focused, overall efficiency is guaranteed.
Here’s some test code.
flipCardTo(FLIP_TYPE.FRONT, gridView.children[0]);// Flip the first plate to the front
flipCardTo(FLIP_TYPE.BACK, gridView.children[0]);// Flip the first plate to the back
flipAllCardTo(FLIP_TYPE.BACK,gridView)// Flip everything to the back
flipAllCardTo(FLIP_TYPE.FRONT,gridView)// Flip everything to faces
Copy the code
Next, we’ll start focusing on the third part of the program: user interaction.
The user interaction
Good interaction design leads to a smooth user experience. In general, a single point is a comparative recommendation, which is how the game interacts.
Without interaction, the game becomes “animated” and the user doesn’t feel engaged. But interactions can also add complexity to your application because you need to account for all interaction scenarios and add additional application state to store user interaction state.
Let’s take it step by step.
Add click interaction
Let’s start by adding the click-and-flip interaction and the rollover after a short delay.
const wait = (ms) = > {
return new Promise(resolve= > setTimeout(resolve, ms));
};
const onUserClick = async (e: InteractionEvent) => {
const gridView = e.target as Container;
const { global } = e.data;
const index = getGridCellIndex([global.x, global.y], CARD_SIZE[0], CARD_SIZE[1], MAX_COL);
const clickedCard = gridView.children[index] as Sprite;
flipCardTo(FLIP_TYPE.FRONT, clickedCard);
await wait(500)
flipCardTo(FLIP_TYPE.BACK, clickedCard);
const addUserInteraction = (gridView: Container) = > {
gridView.interactive = true;
gridView.on('pointerdown', onUserClick);
}
addUserInteraction();
Copy the code
It’s worth noting that at this point, we have created no additional application state except for the gridView variable.
That’s a good thing. Maintaining too much state can complicate our code and consume extra attention.
Now, however, we have to add additional states to the program.
Add user interaction state
There are two user interaction related states that need to be added:
- After the user clicks the action, the current card faces up and the list of elements to be paired is userCards. The key here is that if there’s no pairing, you need to remove it from userCards when you roll back. To put it another way, if there are no cards to roll back, then userCards should be an empty array. In addition, userCards have another function: Before the rollback is completed, cards in userCards cannot participate in interactive processing.
- List of paired elements matchedCards. We represent it as a one-dimensional array of type Sprite. It serves two purposes: to determine whether the game has been won or not, and to indicate that elements already paired can no longer participate in interactive processing.
Each time a user clicks an interaction, the two states are computed and updated. The paired elements are removed from the userCards and then matchedCards are added.
Let’s update the onUserClick method.
function includeCard(card, cardList) { returncardList.indexOf(card) ! = -1 }
function removeCard(card, cardList) {
const index = cardList.indexOf(card);
cardList.splice(index, 1)}let matchedCards: Container[] = [];
const userCards: Container[] = [];
const onUserClick = async (e: InteractionEvent) => {
const gridView = e.target as Container;
const { global } = e.data;
const index = getGridCellIndex([global.x, global.y], CARD_SIZE[0], CARD_SIZE[1], MAX_COL);
const clickedCard = gridView.children[index] as Sprite;// Get the card corresponding to the clicked position
if (includeCard(clickedCard, matchedCards)// Already paired elements can no longer participate in interaction processing
|| includeCard(clickedCard, userCards)) // Before the rollback is complete, the cards in userCards cannot participate in interactive processing.
return;
userCards.push(clickedCard);// Store it immediately and wait for the match within the next 1 second
const currentMatchedCards = getMatchedCards(userCards, match);// Find a matchable card in an unpaired card that faces up, and process it with each click
matchedCards = matchedCards.concat(currentMatchedCards);// Store matching cards immediately, currentMatchedCards may be empty
currentMatchedCards.forEach(matchedCard= > removeCard(matchedCard, userCards));// The matched element is immediately removed from userCards
flipCardTo(FLIP_TYPE.FRONT, clickedCard);// Immediately flip, no animation
if (isSuccess(matchedCards, gridView.children)) {
wait(1000).then(() = > alert('Victory'));
} else {
await wait(1000);// Wait does not interrupt, but does make the code more readable
if(! includeCard(clickedCard, matchedCards)) {// If there is no pairing, roll back and resume the interactionflipCardTo(FLIP_TYPE.BACK, clickedCard); removeCard(clickedCard, userCards); }}}Copy the code
Be careful with asynchronous code
It is worth noting that the 1000 ms waiting for a rollback is asynchronous code. This makes it look like synchronous code execution with await syntax, which increases the readability of the code and makes it easier to understand.
To conclude, the game is now fully playable.
Next, we added some transition animations to the game to make the user experience more fluid.
Transition animations
There are two transition animations in the game:
- Flip individual cards to front or back
- Flip all cards to front or back
Use the library @tweenjs/tween.js
The transition library here uses @tweenjs/tween.js, one of the older libraries.
Here is the implementation of the animation:
import * as TWEEN from "@tweenjs/tween.js";
// Setup the animation loop.
function animate(time) {
requestAnimationFrame(animate)
TWEEN.update(time)
}
requestAnimationFrame(animate)
/** flip all cards to front or back */
export const playFipAllAnimation = (type: FLIP_TYPE, gridView: Container) = > {
return Promise.all(
gridView.children.map(
(child: Container) = > playFlipAnimation(type, child))
)
}
/** Flip the single card to the front or back */
export const playFlipAnimation = (type: FLIP_TYPE, cardView: Container) = > {
return new Promise((resolve) = > {
const toFront = type == FLIP_TYPE.FRONT;
const [back, front] = cardView.children;
const DURATION = 300;
back.visible = front.visible = true;
back.scale.x = front.scale.x = 0;
const callback = () = >{ back.visible = ! toFront; front.visible = toFront; resolve(cardView); }const tweenBack = new TWEEN.Tween(back.scale);
const tweenFront = new TWEEN.Tween(front.scale);
if (toFront) {
back.scale.x = 1;
tweenBack.to({ x: 0 }, DURATION).start().onComplete(() = > {
tweenFront.to({ x: 1 }, DURATION).start().onComplete(callback);
});
} else {
front.scale.x = 1;
tweenFront.to({ x: 0 }, DURATION).start().onComplete(() = > {
tweenBack.to({ x: 1}, DURATION).start().onComplete(callback); }); }})}Copy the code
Add the transition animation and handle it carefully
It should look easy to add animations. It looks like we could just replace the flipCardTo method in onUserClick with the playFlipAnimation method.
But things may not be that simple: animation brings additional complexity. Consider whether an exception may occur if you interact with the card during animation playback. There are two cases of animation here:
- Click on the card and flip to the front of the animation. This ensures that the target card cannot be clicked during the animation. The current code supports this interaction prohibition scenario. Consider delaying the rollback rollover longer than the animation time.
- Based on the first case, if there is no pairing, the card plays an animation that reverses the flip. At this point, the interaction of the target card needs to be disabled while the animation is playing, and it cannot be matched with other cards. The current code does not support this, so we need to add an additional state variable called lockedCards.
const lockedCards: Container[] = [];
const onUserClick = async (e: InteractionEvent) => {
...
if (includeCard(clickedCard, matchedCards)// Already paired elements can no longer participate in interaction processing
|| includeCard(clickedCard, userCards) // Before the rollback is complete, the cards in userCards cannot participate in interactive processing.
|| includeCard(clickedCard, lockedCards)) // The interaction of the target card needs to be disabled and cannot be matched with other cards during the playback of the back animation.
return; . playFlipAnimation(FLIP_TYPE.FRONT, clickedCard);// Animation flip. Consider delaying the rollback rollover longer than the animation time.
if (isSuccess(matchedCards, gridView.children)) {
wait(1000).then(() = > alert('Victory'));
} else {
await wait(1000);// Wait does not interrupt, but does make the code more readable
if(! includeCard(clickedCard, matchedCards)) {// If there is no pairing, roll back and resume the interaction
playFlipAnimation(FLIP_TYPE.BACK, clickedCard).then(() = >{ removeCard(clickedCard,lockedCards) }); removeCard(clickedCard, userCards); lockedCards.push(clickedCard); }}}Copy the code
review
That concludes the main content of this article. Let’s review and reinforce the concept of the four levels above by adding two game requirements to the game. New requirements are as follows:
- Added a game item that automatically highlights a set of unpaired cards when used.
- Add another game item that automatically flips a set of unpaired cards when used.
Data structures and algorithms
Let’s start at the data structure and algorithmic level. Here we need to add an algorithm that finds all the unpaired cards. This shouldn’t be hard: just strip out matchedCards, userCards, and lockedCards from all the cards.
function includeCard(card, cardList) { returncardList.indexOf(card) ! = -1 }
export const getUnmatchedCard = (allCard: any[], matchedCards: any[], userCards: any[], lockedCards: any[]) = > {
return allCard.filter(
card= >! includeCard(card, matchedCards) && ! includeCard(card, userCards) && ! includeCard(card, lockedCards) ) }Copy the code
This is then combined with the written getMatchedCards method to find cards that need to be highlighted or flipped automatically.
View structure
Let’s think about the view structure. You need to add highlighting and clear highlighting methods here. PixiJS has a collection of community-developed filters, including the Glow Filter.
import { GlowFilter } from "@pixi/filter-glow"
export const highlight = (card) = > {
card.filters = [new GlowFilter({ distance: 30.outerStrength: 2.color: 0x00ff00}})]export const clearHighlight = (card) = > {
card.filters = []
}
Copy the code
Usage:
export const highlightCardPairs = () = > {
const cardList = getUnmatchedCard(gridView.children, matchedCards, userCards, lockedCards);
const matched = getMatchedCards(cardList, match);
if (matched.length > 0) {
const[card0, card1] = matched; highlight(card0); highlight(card1); }}Copy the code
The user interaction
The new requirement says that we have to hack into the user’s interaction data, but it’s not complicated, just add the matching cards to the matchedCards array.
export const autoFlip = () = > {
const cardList = getUnmatchedCard(gridView.children, matchedCards, userCards, lockedCards);
const matched = getMatchedCards(cardList, match);
if (matched.length > 0) {
const [card0, card1] = matched;
matchedCards.push(card0, card1);
clearHighlight(card0);
clearHighlight(card1);
playFlipAnimation(FLIP_TYPE.FRONT, card0);// Flip immediately
playFlipAnimation(FLIP_TYPE.FRONT, card1);// Flip immediately
}
if (isSuccess(matchedCards, gridView.children)) {
wait(1000).then(() = > alert('Victory')); }}Copy the code
Animation, interactive
There is no new animation interaction in this requirement, so it will not be considered.
Ok Finally, let’s see how it works:
The last
That’s the end of the article. If it is helpful to you, I hope I can give 👍 comment collection three even!
Welcome to pay attention to exchange, have a question can comment and leave a message, I will timely reply.