2048 is a classic puzzle game where players slide the screen to merge blocks with the same numbers, with the ultimate goal of synthesizing the number 2048. This article is based on the HarmonyOS ArkUI framework and provides a detailed analysis of its implementation process, explaining how to use declarative UI and state management to build such games.
I. Core Data Structures and State Management
1. Game Grid and Scores
The core of the game is a 4x4 two-dimensional array used to store the numbers in each grid. The grid state is managed through the @State decorator to ensure that the UI is automatically refreshed when the data changes:
@State grid: number[][] = Array(4).fill(0).map(() => Array(4).fill(0));
@State score: number = 0; // Current score
@State bestScore: number = 0; // Historical highest score
2. Game Initialization
The initGame method is responsible for resetting the grid, adding initial blocks, and resetting the score. addNewTile is used to generate new blocks in random empty positions (90% probability of generating 2, 10% probability of generating 4):
initGame() {
this.grid = this.grid.map(() => Array(4).fill(0));
this.addNewTile();
this.addNewTile();
this.score = 0;
}
II. Sliding Logic and Merging Algorithm
1. Direction Handling and Matrix Rotation
The game supports sliding in four directions: up, down, left, and right. To simplify the code logic, matrix rotation is used to uniformly convert movements in different directions into leftward movements:
- Leftward: Directly process each row.
- Rightward: Reverse the row, move left, and then reverse it back.
- Upward/Downward: Rotate the matrix into rows, process it, and then restore it to columns.
// Matrix rotation helper method
const rotate = (matrix: number[][]): number[][] => {
return matrix[0].map((_, i) => matrix.map(row => row[i]).reverse());
};
2. Single-Row Merging Logic
The processing of each row is divided into three steps:
- Remove Spaces: Filter out non-zero numbers.
- Merge Identical Numbers: Merge adjacent identical numbers and accumulate the score.
- Complete Length: Fill with zeros to a length of 4.
const moveRow = (row: number[]): number[] => {
let newRow = row.filter(cell => cell !== 0);
for (let i = 0; i < newRow.length - 1; i++) {
if (newRow[i] === newRow[i + 1]) {
newRow[i] *= 2;
this.score += newRow[i]; // Score accumulation
newRow.splice(i + 1, 1);
}
}
return [...newRow, ...Array(4 - newRow.length).fill(0)];
};
III. Game End Judgment
The game ends when the grid is full and there are no adjacent blocks that can be merged. The detection is carried out through the following steps:
- Check for Spaces: If there are spaces, the game has not ended.
- Horizontal Detection: Traverse each row to check for adjacent identical numbers.
- Vertical Detection: Traverse each column to check for adjacent identical numbers.
isGameOver(): boolean {
if (this.grid.some(row => row.includes(0))) return false;
// Horizontal and vertical detection logic
// ...
return true;
}
IV. UI Implementation and Interaction Design
1. Grid Rendering
The Grid component is used to dynamically generate a 4x4 grid, and each GridItem displays different background colors and text colors according to the numerical value:
Grid() {
ForEach(this.grid, (row: number[], i) => {
ForEach(row, (value: number, j) => {
GridItem() {
Text(value ? `${value}` : '')
.backgroundColor(this.getTileColor(value))
.fontColor(this.getTextColor(value));
}
})
})
}
2. Touch Event Handling
The onTouch event listens for sliding events, calculates the difference between the start and end coordinates, and determines the sliding direction:
onTouch((event) => {
if (event.type === TouchType.Down) {
this.startX = event.touches[0].x;
this.startY = event.touches[0].y;
} else if (event.type === TouchType.Up) {
const deltaX = event.touches[0].x - this.startX;
const deltaY = event.touches[0].y - this.startY;
// Judge the direction and call the move method
}
});
V. Local Storage and Animation Effects
1. High Score Persistence
PreferencesUtil is used to store and read the highest score to ensure that the data is retained after the application is restarted:
aboutToAppear() {
this.bestScore = PreferencesUtil.getNumberSync("bestScore");
}
// Update the highest score
if (this.score > this.bestScore) {
PreferencesUtil.putSync('bestScore', this.score);
}
2. Animation and Visual Effects
Each block's text change is added with a 150ms gradient animation to enhance the user experience:
Text(value ? `${value}` : '')
.animation({ duration: 150, curve: Curve.EaseOut });
VI. Summary and Complete Code
Through ArkUI's declarative UI and state management, the core logic of 2048 can be efficiently implemented. The key points include:
- Matrix rotation simplifies direction processing.
- State-driven UI automatic update.
- Smooth combination of touch events and animations.
import { HashMap } from '@kit.ArkTS'
import { AppUtil, PreferencesUtil, ToastUtil } from '@pura/harmony-utils'
// index.ets
@Entry
@Component
struct Game2048 {
@State grid: number[][] = Array(4).fill(0).map(() => Array(4).fill(0)) // 4x4 game grid
@State score: number = 0 // Current score
@State bestScore: number = 0 // Historical highest score
private startX: number = 0 // Touch start X coordinate
private startY: number = 0 // Touch start Y coordinate
// Lifecycle method: triggered when the page is about to be displayed
aboutToAppear() {
this.initGame()
this.bestScore = PreferencesUtil.getNumberSync("bestScore") // Read the highest score stored locally
}
// Initialize the game
initGame() {
this.grid = this.grid.map(() => Array(4).fill(0)) // Reset the grid
this.addNewTile() // Add two new blocks
this.addNewTile() // Reset the current score
this.score = 0
}
addNewTile() {
const emptyCells: [number, number][] = [] // Collect the coordinates of empty cells
this.grid.forEach((row, i) => {
row.forEach((cell, j) => {
if (cell === 0) {
emptyCells.push([i, j])
}
})
})
if (emptyCells.length > 0) {
let n = Math.floor(Math.random() * emptyCells.length) // Randomly select an empty cell
const i = emptyCells[n][0]
const j = emptyCells[n][1]
this.grid[i][j] = Math.random() < 0.9 ? 2 : 4 // 90% probability of generating 2, 10% probability of generating 4
}
}
// Process movement logic
move(direction: 'left' | 'right' | 'up' | 'down') {
let newGrid = this.grid.map(row => [...row]) // Create a grid copy
let moved = false // Movement flag
// Matrix rotation helper methods
const rotate = (matrix: number[][]): number[][] => {
return matrix[0].map((_, i) => matrix.map(row => row[i]).reverse())
}
const rotateReverse = (matrix: number[][]): number[][] => {
return matrix[0].map((_, i) => matrix.map(row => row[row.length - 1 - i]))
}
// Process single-row movement and merging
const moveRow = (row: number[]): number[] => {
let newRow = row.filter(cell => cell !== 0) // Remove spaces
for (let i = 0; i < newRow.length - 1; i++) {
if (newRow[i] === newRow[i + 1]) { // Merge identical numbers
newRow[i] *= 2
this.score += newRow[i] // Update the score
newRow.splice(i + 1, 1) // Remove the merged element
}
}
// Complete the length
while (newRow.length < 4) {
newRow.push(0)
}
return newRow
}
// Process movement according to direction
switch (direction) {
case 'left':
newGrid.forEach((row, i) => newGrid[i] = moveRow(row))
break
case 'right':
newGrid.forEach((row, i) => newGrid[i] = moveRow(row.reverse()).reverse())
break
case 'up':
let rotatedDown = rotate(newGrid)
rotatedDown.forEach((row, i) => rotatedDown[i] = moveRow(row.reverse()).reverse())
newGrid = rotateReverse(rotatedDown)
break
case 'down':
let rotatedUp = rotate(newGrid)
rotatedUp.forEach((row, i) => rotatedUp[i] = moveRow(row))
newGrid = rotateReverse(rotatedUp)
break
}
moved = JSON.stringify(newGrid) !== JSON.stringify(this.grid) // Judge whether movement has occurred
this.grid = newGrid
if (moved) {
this.addNewTile() // Add a new block after movement
if (this.score > this.bestScore) { // Update the highest score
this.bestScore = this.score
PreferencesUtil.putSync('bestScore', this.bestScore) // Save the highest score
}
}
if (this.isGameOver()) { // Game end detection
ToastUtil.showToast('Game Over!')
}
}
// Game end judgment
isGameOver(): boolean {
// Check for empty cells
if (this.grid.some(row => row.includes(0))) {
return false
}
// Check for horizontal merges
for (let i = 0; i < 4; i++) {
for (let j = 0; j < 3; j++) {
if (this.grid[i][j] === this.grid[i][j + 1]) {
return false
}
}
}
// Check for vertical merges
for (let j = 0; j < 4; j++) {
for (let i = 0; i < 3; i++) {
if (this.grid[i][j] === this.grid[i + 1][j]) {
return false
}
}
}
return true
}
build() {
Column() {
// Score display row
Row() {
Text(`Score: ${this.score}`)
.fontSize(20)
.margin(10)
Text(`Highest Score: ${this.bestScore}`)
.fontSize(20)
.margin(10)
Button('New Game')
.onClick(() => this.initGame())
.margin(10)
}.margin({ top: px2vp(AppUtil.getStatusBarHeight()) })
// Game grid
Grid() {
ForEach(this.grid, (row: number[], i) => {
ForEach(row, (value: number, j) => {
GridItem() {
Text(value ? `${value}` : '')
.textAlign(TextAlign.Center)
.fontSize(24)
.fontColor(this.getTextColor(value))
.width('100%')
.height('100%')
.backgroundColor(this.getTileColor(value))
.animation({
duration: 150,
curve: Curve.EaseOut
})
}.key(`${i}-${j}`)
})
})
}
.columnsTemplate('1fr 1fr 1fr 1fr') // 4 equal columns
.rowsTemplate('1fr 1fr 1fr 1fr') // 4 equal rows
.width('90%')
.aspectRatio(1) // Maintain square shape
.margin(10)
.onTouch((event) => { // Touch event handling
if (event.type === TouchType.Down) {
this.startX = event.touches[0].x
this.startY = event.touches[0].y
} else if (event.type === TouchType.Up) {
const deltaX = event.touches[0].x - this.startX
const deltaY = event.touches[0].y - this.startY
// Judge the movement according to the sliding direction
if (Math.abs(deltaX) > Math.abs(deltaY)) {
deltaX > 0 ? this.move('right') : this.move('left')
} else {
deltaY > 0 ? this.move('down') : this.move('up')
}
}
})
}
.width('100%')
}
// Get the block background color
getTileColor(value: number): string {
const colors = new HashMap<number, string>()
colors.set(0, '#CDC1B4')
colors.set(2, '#EEE4DA')
colors.set(4, '#EDE0C8')
colors.set(8, '#F2B179')
colors.set(16, '#F59563')
colors.set(32, '#F67C5F')
colors.set(64, '#F65E3B')
colors.set(128, '#EDCF72')
colors.set(256, '#EDCF72')
colors.set(512, '#EDCC61')
colors.set(1024, '#EDC850')
colors.set(2048, '#EDC22E')
return colors.get(value) || '#CDC1B4'
}
// Get the text color
getTextColor(value: number): Color {
return value > 4 ? Color.White : Color.Black
}
}