DEV Community

Cover image for HarmonyOS NEXT Development Case: Reversi (Othello)
zhongcx
zhongcx

Posted on

HarmonyOS NEXT Development Case: Reversi (Othello)

Image description

[Introduction]

Reversi, also known as Othello or Anti reversi, is a popular strategy game in Western countries and Japan. Players take turns placing disks to flip their opponent's pieces, with the winner determined by who has more disks on the board at game end. While its rules are simple enough to learn in minutes, the game offers deep strategic complexity that can take a lifetime to master.

[Environment Setup]

OS: Windows 10
IDE: DevEco Studio NEXT Beta1 Build Version: 5.0.3.806
SDK Version: API 12
Test Device: Mate 60 Pro
Languages: ArkTS, ArkUI
[Implemented Features]

Board Initialization: Creates an 8×8 grid with four initial disks placed according to standard Reversi rules.
Disk Display & Animation: ChessCell class manages disk states (black/white) with flip animations.
Valid Move Detection: findReversible method checks valid moves across 8 directions.
Player Switching: currentPlayerIsBlackChanged handles turn transitions and game-over checks.
AI Strategy: Single-player mode features random valid move selection.
Game Termination: Ends when neither player can move, displaying the winner.
[Core Algorithms]

  1. Valid Move Detection Algorithm

The findReversible method checks all 8 directions for flippable disks:

findReversible(row: number, col: number, color: number): ChessCell[] {
  let reversibleTiles: ChessCell[] = [];
  const directions = [ /* 8 directions */ ];

  for (const direction of directions) {
    let foundOpposite = false;
    let x = row, y = col;
    do {
      x += direction[0];
      y += direction[1];
      // Boundary checks
      const cell = this.chessBoard[x][y];
      if (cell.frontVisibility === color && foundOpposite) {
        // Collect reversible tiles
      } else {
        foundOpposite = true;
      }
    } while (valid);
  }
  return reversibleTiles;
}

Enter fullscreen mode Exit fullscreen mode
  1. AI Random Move Strategy
aiPlaceRandom() {
  let validMoves: [number, number][] = [];
  // Collect valid moves
  if (validMoves.length > 0) {
    const randomMove = validMoves[Math.floor(Math.random() * validMoves.length)];
    this.placeChessPiece(randomMove[0], randomMove[1], chessCell);
  }
}

Enter fullscreen mode Exit fullscreen mode
  1. Disk Flip Animation The ChessCell class handles animations through rotation and opacity changes:
flip(time: number) {
  if (this.frontVisibility === 1) {
    this.showWhite(time);
  } else if (this.frontVisibility === 2) {
    this.showBlack(time);
  }
}

Enter fullscreen mode Exit fullscreen mode

[Full Code]

import { promptAction } from '@kit.ArkUI'; // Import dialog box utility

@ObservedV2
class ChessCell { // Chessboard cell class
  @Trace frontVisibility: number = 0; // Stone state: 0=empty, 1=black, 2=white
  @Trace rotationAngle: number = 0; // Card rotation angle
  @Trace opacity: number = 1; // Opacity
  isAnimationRunning: boolean = false; // Animation running flag

  flip(time: number) { // Flip stone method
    if (this.frontVisibility === 1) { // Current is black, flip to white
      this.showWhite(time);
    } else if (this.frontVisibility === 2) { // Current is white, flip to black
      this.showBlack(time);
    }
  }

  showBlack(animationTime: number, callback?: () => void) { // Show black stone
    if (this.isAnimationRunning) { // Animation already running
      return;
    }
    this.isAnimationRunning = true;
    if (animationTime == 0) { // No animation needed
      this.rotationAngle = 0;
      this.frontVisibility = 1;
      this.isAnimationRunning = false;
      if (callback) {
        callback();
      }
    }

    animateToImmediately({
      duration: animationTime, // Animation duration
      iterations: 1, // Iteration count
      curve: Curve.Linear, // Animation curve type
      onFinish: () => {
        animateToImmediately({
          duration: animationTime,
          iterations: 1,
          curve: Curve.Linear,
          onFinish: () => {
            this.isAnimationRunning = false;
            if (callback) {
              callback();
            }
          }
        }, () => {
          this.frontVisibility = 1; // Show black
          this.rotationAngle = 0;
        });
      }
    }, () => {
      this.rotationAngle = 90; // Rotate to 90 degrees
    });
  }

  showWhite(animationTime: number, callback?: () => void) { // Show white stone
    if (this.isAnimationRunning) {
      return;
    }
    this.isAnimationRunning = true;
    if (animationTime == 0) {
      this.rotationAngle = 180;
      this.frontVisibility = 2;
      this.isAnimationRunning = false;
      if (callback) {
        callback();
      }
    }

    animateToImmediately({
      duration: animationTime,
      iterations: 1,
      curve: Curve.Linear,
      onFinish: () => {
        animateToImmediately({
          duration: animationTime,
          iterations: 1,
          curve: Curve.Linear,
          onFinish: () => {
            this.isAnimationRunning = false;
            if (callback) {
              callback();
            }
          }
        }, () => {
          this.frontVisibility = 2; // Show white
          this.rotationAngle = 180;
        });
      }
    }, () => {
      this.rotationAngle = 90;
    });
  }

  showWhiteAi(animationTime: number, callback?: () => void) { // Show white stone with AI animation
    if (this.isAnimationRunning) {
      return;
    }
    this.isAnimationRunning = true;
    if (animationTime == 0) {
      this.rotationAngle = 180;
      this.frontVisibility = 2;
      this.isAnimationRunning = false;
      if (callback) {
        callback();
      }
    }
    this.rotationAngle = 180;
    this.frontVisibility = 2;

    animateToImmediately({
      duration: animationTime * 3, // Longer duration
      curve: Curve.EaseOut, // Different curve
      iterations: 3, // Multiple iterations
      onFinish: () => {
        animateToImmediately({
          duration: animationTime,
          iterations: 1,
          curve: Curve.Linear,
          onFinish: () => {
            this.isAnimationRunning = false;
            if (callback) {
              callback();
            }
          }
        }, () => {
          this.opacity = 1; // Full opacity
        });
      }
    }, () => {
      this.opacity = 0.2; // Semi-transparent
    });
  }
}

@ObservedV2
class TileHighlight { // Valid move highlight
  @Trace isValidMove: boolean = false; // Valid move flag
}

@Entry
@Component
struct OthelloGame { // Othello game component
  @State chessBoard: ChessCell[][] = []; // Board array
  @State cellSize: number = 70; // Cell size
  @State cellSpacing: number = 5; // Cell spacing
  @State transitionDuration: number = 200; // Animation duration
  @State @Watch('currentPlayerIsBlackChanged') currentPlayerIsBlack: boolean = true; // Current player
  @State chessBoardSize: number = 8; // 8x8 board
  @State validMoveIndicators: TileHighlight [][] = []; // Valid move indicators
  @State isTwoPlayerMode: boolean = false; // Two-player mode
  @State isAIPlaying:boolean = false; // AI playing flag

  currentPlayerIsBlackChanged() { // Player change handler
    setTimeout(() => {
      const color = this.currentPlayerIsBlack ? 1 : 2;
      let hasMoves = this.hasValidMoves(color);

      if (!hasMoves) {
        let opponentHasMoves = this.hasValidMoves(!this.currentPlayerIsBlack ? 1 : 2);
        if (!opponentHasMoves) {
          const winner = this.determineWinner();
          console.log(winner);
          promptAction.showDialog({
            title: 'Game Over',
            message: `${winner}`,
            buttons: [{ text: 'Restart', color: '#ffa500' }]
          }).then(() => {
            this.initGame();
          });
        } else {
          this.currentPlayerIsBlack = !this.currentPlayerIsBlack;
        }
      } else {
        if (!this.currentPlayerIsBlack) { // AI turn
          if (!this.isTwoPlayerMode) {
            setTimeout(() => {
              this.aiPlaceRandom();
            }, this.transitionDuration + 20);
          }
        }
      }
    }, this.transitionDuration + 20);
  }

  aiPlaceRandom() { // AI random placement
    let validMoves: [number, number][] = [];
    for (let i = 0; i < this.validMoveIndicators.length; i++) {
      for (let j = 0; j < this.validMoveIndicators[i].length; j++) {
        if (this.validMoveIndicators[i][j].isValidMove) {
          validMoves.push([i, j]);
        }
      }
    }

    if (validMoves.length > 0) {
      const randomMove = validMoves[Math.floor(Math.random() * validMoves.length)];
      let chessCell = this.chessBoard[randomMove[0]][randomMove[1]];
      this.placeChessPiece(randomMove[0], randomMove[1], chessCell);
    }
  }

  placeChessPiece(i: number, j: number, chessCell: ChessCell) { // Place stone
    let reversibleTiles = this.findReversible(i, j, this.currentPlayerIsBlack ? 1 : 2);
    console.info(`reversibleTiles:${JSON.stringify(reversibleTiles)}`);

    if (reversibleTiles.length > 0) {
      if (this.currentPlayerIsBlack) {
        this.currentPlayerIsBlack = false;
        chessCell.showBlack(0);
        for (let i = 0; i < reversibleTiles.length; i++) {
          reversibleTiles[i].flip(this.transitionDuration);
        }
      } else {
        this.currentPlayerIsBlack = true;
        if (this.isTwoPlayerMode) {
          chessCell.showWhite(0);
          for (let i = 0; i < reversibleTiles.length; i++) {
            reversibleTiles[i].flip(this.transitionDuration);
          }
        } else {
          this.isAIPlaying = true;
          chessCell.showWhiteAi(this.transitionDuration, () => {
            for (let i = 0; i < reversibleTiles.length; i++) {
              reversibleTiles[i].flip(this.transitionDuration);
            }
            this.currentPlayerIsBlackChanged();
            this.isAIPlaying = false;
          });
        }
      }
    }
  }

  hasValidMoves(color: number) { // Check valid moves
    let hasMoves = false;
    for (let row = 0; row < this.chessBoardSize; row++) {
      for (let col = 0; col < this.chessBoardSize; col++) {
        if (this.chessBoard[row][col].frontVisibility === 0 && this.findReversible(row, col, color).length > 0) {
          this.validMoveIndicators[row][col].isValidMove = true;
          hasMoves = true;
        } else {
          this.validMoveIndicators[row][col].isValidMove = false;
        }
      }
    }
    return hasMoves;
  }

  aboutToAppear(): void { // Component lifecycle
    for (let i = 0; i < this.chessBoardSize; i++) {
      this.chessBoard.push([]);
      this.validMoveIndicators.push([]);
      for (let j = 0; j < this.chessBoardSize; j++) {
        this.chessBoard[i].push(new ChessCell());
        this.validMoveIndicators[i].push(new TileHighlight());
      }
    }
    this.initGame();
  }

  initGame() { // Initialize game
    this.currentPlayerIsBlack = true;
    for (let i = 0; i < this.chessBoardSize; i++) {
      for (let j = 0; j < this.chessBoardSize; j++) {
        this.chessBoard[i][j].frontVisibility = 0;
      }
    }
    // Initial board setup
    this.chessBoard[3][3].frontVisibility = 2; // White
    this.chessBoard[3][4].frontVisibility = 1; // Black
    this.chessBoard[4][3].frontVisibility = 1; // Black
    this.chessBoard[4][4].frontVisibility = 2; // White
    this.currentPlayerIsBlackChanged();
  }

  findReversible(row: number, col: number, color: number): ChessCell[] { // Find reversible stones
    let reversibleTiles: ChessCell[] = [];
    const directions = [ // 8 directions
      [-1, -1], // NW
      [-1, 0], // N
      [-1, 1], // NE
      [0, -1], // W
      [0, 1], // E
      [1, -1], // SW
      [1, 0], // S
      [1, 1] // SE
    ];
    for (const direction of directions) {
      let foundOpposite = false;
      let x = row;
      let y = col;
      do {
        x += direction[0];
        y += direction[1];
        if (x < 0 || y < 0 || x >= this.chessBoardSize || y >= this.chessBoardSize) break;
        const cell = this.chessBoard[x][y];
        if (cell.frontVisibility === 0) break;
        if (cell.frontVisibility === color) {
          if (foundOpposite) {
            let tempX: number = x - direction[0];
            let tempY: number = y - direction[1];
            while (tempX !== row || tempY !== col) {
              reversibleTiles.push(this.chessBoard[tempX][tempY]);
              tempX -= direction[0];
              tempY -= direction[1];
            }
          }
          break;
        } else {
          foundOpposite = true;
        }
      } while (true);
    }
    return reversibleTiles;
  }

  determineWinner(): string { // Determine winner
    let blackCount = 0;
    let whiteCount = 0;
    for (let row of this.chessBoard) {
      for (let cell of row) {
        if (cell.frontVisibility === 1) blackCount++;
        if (cell.frontVisibility === 2) whiteCount++;
      }
    }
    if (blackCount > whiteCount) return "Black Wins!";
    if (whiteCount > blackCount) return "White Wins!";
    return "Draw!";
  }

  hasValidMove(color: number): boolean { // Check valid moves
    for (let row = 0; row < this.chessBoardSize; row++) {
      for (let col = 0; col < this.chessBoardSize; col++) {
        if (this.chessBoard[row][col].frontVisibility === 0 && 
            this.findReversible(row, col, color).length > 0) {
          return true;
        }
      }
    }
    return false;
  }

  build() { // UI construction
    Column({ space: 20 }) {
      Row() {
        Row() { // Black player indicator
          Text(``)
            .width(`${this.cellSize}lpx`)
            .height(`${this.cellSize}lpx`)
            .textAlign(TextAlign.Center)
            .backgroundColor(Color.Black)
            .borderRadius('50%')
            .padding(10)
          Text(`Black's Turn`)
            .fontColor(Color.White)
            .padding(10)
        }
        .visibility(this.currentPlayerIsBlack ? Visibility.Visible : Visibility.Hidden)

        Row() { // White player indicator
          Text(`White's Turn`)
            .fontColor(Color.White)
            .padding(10)
          Text(``)
            .width(`${this.cellSize}lpx`)
            .height(`${this.cellSize}lpx`)
            .textAlign(TextAlign.Center)
            .backgroundColor(Color.White)
            .fontColor(Color.White)
            .borderRadius('50%')
            .padding(10)
        }
        .visibility(!this.currentPlayerIsBlack ? Visibility.Visible : Visibility.Hidden)
      }
      .width(`${(this.cellSize + this.cellSpacing * 2) * 8}lpx`)
      .justifyContent(FlexAlign.SpaceBetween)
      .margin({ top: 20 })

      Stack() { // Game board
        Flex({ wrap: FlexWrap.Wrap }) { // Valid moves layer
          ForEach(this.validMoveIndicators, (row: boolean[], _rowIndex: number) => {
            ForEach(row, (item: TileHighlight, _colIndex: number) => {
              Text(`${item.isValidMove ? '+' : ''}`)
                .width(`${this.cellSize}lpx`)
                .height(`${this.cellSize}lpx`)
                .margin(`${this.cellSpacing}lpx`)
                .fontSize(`${this.cellSize / 2}lpx`)
                .fontColor(Color.White)
                .textAlign(TextAlign.Center)
                .backgroundColor(Color.Gray)
                .borderRadius(2);
            });
          });
        }
        .width(`${(this.cellSize + this.cellSpacing * 2) * 8}lpx`)

        Flex({ wrap: FlexWrap.Wrap }) { // Stones layer
          ForEach(this.chessBoard, (row: ChessCell[], rowIndex: number) => {
            ForEach(row, (chessCell: ChessCell, colIndex: number) => {
              Text(``)
                .width(`${this.cellSize}lpx`)
                .height(`${this.cellSize}lpx`)
                .margin(`${this.cellSpacing}lpx`)
                .fontSize(`${this.cellSize / 2}lpx`)
                .textAlign(TextAlign.Center)
                .opacity(chessCell.opacity)
                .backgroundColor(chessCell.frontVisibility != 0 ? 
                  (chessCell.frontVisibility === 1 ? Color.Black : Color.White) : 
                Color.Transparent)
                .borderRadius('50%')
                .rotate({
                  x: 0,
                  y: 1,
                  z: 0,
                  angle: chessCell.rotationAngle,
                  centerX: `${this.cellSize / 2}lpx`,
                  centerY: `${this.cellSize / 2}lpx`,
                })
                .onClick(() => {
                  if (this.isAIPlaying) {
                    console.info(`AI is making move, please wait`);
                    return;
                  }
                  if (chessCell.frontVisibility === 0) {
                    this.placeChessPiece(rowIndex, colIndex, chessCell);
                  }
                });
            });
          });
        }
        .width(`${(this.cellSize + this.cellSpacing * 2) * 8}lpx`)
      }
      .padding(`${this.cellSpacing}lpx`)
      .backgroundColor(Color.Black)

      Row() { // Game mode toggle
        Text(`${this.isTwoPlayerMode ? 'Two Player' : 'Single Player'}`)
          .height(50)
          .padding({ left: 10 })
          .fontSize(16)
          .textAlign(TextAlign.Start)
          .backgroundColor(0xFFFFFF);
        Toggle({ type: ToggleType.Switch, isOn: this.isTwoPlayerMode })
          .margin({ left: 200, right: 10 })
          .onChange((isOn: boolean) => {
            this.isTwoPlayerMode = isOn;
          });
      }
      .backgroundColor(0xFFFFFF)
      .borderRadius(5);

      Button('Restart').onClick(() => {
        this.initGame();
      });
    }
    .height('100%').width('100%')
    .backgroundColor(Color.Orange);
  }
}
Enter fullscreen mode Exit fullscreen mode

Top comments (0)