DEV Community

HarmonyOS
HarmonyOS

Posted on

Building Hangman with ArkTS

Read the original article:Building Hangman with ArkTS

Introduction

Let's travel back in time to my childhood and merge it with today's tech.

What you will learn:

  • Getting text input with input filters and a character limit.
  • Using a Button to fire an action.
  • Using Canvas to draw graphics.
  • Creating custom popups.

Let's not wait anymore.

We will use TextInput with an input filter to prevent numbers and special characters, and a character limit to only accept one letter at a time.

// define controller and text parameter
tcn: TextInputController = new TextInputController()
txt: string = ''

// add TextInput
TextInput({ controller: this.tcn })
  .maxLength(1)
  .inputFilter('^[a-zA-Z]*$')
  .onChange((txt: string) => {
    this.txt = txt
  })
Enter fullscreen mode Exit fullscreen mode

To submit the entered character, we will use a Button.

Button('Submit')
  .onClick(() => {
    if (this.txt.length === 1) {
      // guess
    }
  })
Enter fullscreen mode Exit fullscreen mode

Let's add some style; this part only depends on your imagination.

@Entry
@Component
struct Index {
  tcn: TextInputController = new TextInputController()
  txt: string = ''

  build() {
    Column() {
      Blank().layoutWeight(1) // canvas will be here

      Row() {
        TextInput({ controller: this.tcn })
          .maxLength(1)
          .inputFilter('^[a-zA-Z]*$')
          .width(40)
          .height(40)
          .textAlign(TextAlign.Center)
          .borderRadius({
            topRight: 0,
            bottomRight: 0,
            topLeft: 10,
            bottomLeft: 10
          })
          .onChange((txt: string) => {
            this.txt = txt
          })

        Button('Submit')
          .height(40)
          .layoutWeight(1)
          .borderRadius({
            topRight: 10,
            bottomRight: 10,
            topLeft: 0,
            bottomLeft: 0
          })
          .onClick(() => {
            if (this.txt.length === 1) {
              // guess
            }
          })
      }
      .width('70%')
      .padding({ bottom: '15%' })
    }
    .height('100%')
    .width('100%')
  }
}
Enter fullscreen mode Exit fullscreen mode

The result should look like this:

Let's create our view model and add the Canvas. Create Hangman.ets under the viewmodel directory.

Here we will implement data and canvas operations.

@Observed
export default class Hangman {
  // Initialize Canvas context
  context: CanvasRenderingContext2D = new CanvasRenderingContext2D()
}
Enter fullscreen mode Exit fullscreen mode

Modify Index.ets; Initialize Hangman and add the Canvas.

// initialize Hangman view model
@State hangman: Hangman = new Hangman()


// replace Blank with the Canvas
Canvas(this.hangman.context)
  .backgroundColor(Color.Grey) // we will remove this later
  .layoutWeight(1)
Enter fullscreen mode Exit fullscreen mode

The Result:

Finally, we need the secret word and the characters we found. We can easily do this by storing empty strings in an array and changing them with real values as the user guesses.

Modify Hangman.ets;

// define current value array
current: string[] = ['','','',''] // we will remove this initial value later
Enter fullscreen mode Exit fullscreen mode

Modify Index.ets;

Row({ space: 8 }) {
  Blank().layoutWeight(1)
  ForEach(this.hangman.current, (letter: string) => {
    Stack() {
      Text(letter)
        .height(18)
    }
    .width(8)
    .border({
      color: Color.White,
      width: {
        left: 0,
        right: 0,
        top: 0,
        bottom: 1
      },
    })
  })
  Blank().layoutWeight(1)
}
.width('70%')
.padding({ top: 8, bottom: 8 })
Enter fullscreen mode Exit fullscreen mode

The result:

Let's implement the logic. First, we will define the necessary models and parameters.

Create HangmanStatus.ets and CanvasPoint.ets under the model directory.

  • HangmanStatus.ets
export enum  HangmanStatus {
  playing,
  win,
  lose
}
Enter fullscreen mode Exit fullscreen mode
  • CanvasPoint.ets
export interface CanvasPoint {
  x: number
  y: number
}
Enter fullscreen mode Exit fullscreen mode

Now we can work on our view model, Hangman.ets.

Let's start by defining some secret words. These can be stored anywhere you want. For simplicity, we will put it in the same file.

const questions = [
  'huawei',
  'harmonyos',
  'turkey',
  'istanbul',
  'black',
  'lion',
  'computer',
  'engineer'
]
Enter fullscreen mode Exit fullscreen mode

Define necessary parameters.

preguesses: string[] = [] // this will store previous wrong guesses
private goal: string = '' // this will store the secret value
status: HangmanStatus = HangmanStatus.playing // current game status
Enter fullscreen mode Exit fullscreen mode

And the initializer to start the game.

constructor() {
  this.start()
}

start() {
  this.context.reset() // clear the canvas
  this.goal = questions[Math.round(Math.random() * (questions.length - 1))]
  this.status = HangmanStatus.playing
  this.preguesses = []
  this.current = new Array(this.goal.length).fill('')
}
Enter fullscreen mode Exit fullscreen mode

Time to guess. We will get one word from the user and show all occurrences if the secret word contains it, or continue drawing the stick figure.

guess(letter: string) {
  if (this.status !== HangmanStatus.playing) {
    return
  }

  let newCurrent = [...this.current]
  if (this.goal.includes(letter)) {
    for (let i = 0; i < this.goal.length; i++) {
      if (letter === this.goal.charAt(i)) {
        newCurrent[i] = letter
      }
    }
    this.current = newCurrent
  } else if (!this.preguesses.includes(letter)) {
    this.preguesses.push(letter)
    this.draw(this.preguesses.length) // this will show an error
  }

  this.checkGame() // this will show an error
}
Enter fullscreen mode Exit fullscreen mode

Let's draw. (This part is just some math, so we will not dive into details)

private draw(step: number) {
  this.context.lineWidth = 2
  this.context.strokeStyle = "#FFFFFF"

  const l = 20

  const canvasWidth = this.context.width
  const canvasHeight = this.context.height
  // where vertical line starts
  const bottomx = canvasWidth / 2 - l / 2
  const bottomy = canvasHeight - 8

  // base
  if (step === 1) {
    const start: CanvasPoint = { x: bottomx - l, y: bottomy }
    const end: CanvasPoint = { x: bottomx + 2 * l, y: bottomy }
    this.drawLine(start, end)
  }
  // vertical line
  if (step === 2) {
    const start: CanvasPoint = { x: bottomx, y: bottomy }
    const end: CanvasPoint = { x: bottomx, y: bottomy - 3 * l }
    this.drawLine(start, end)
  }
  // top horizontal line
  if (step === 3) {
    const start: CanvasPoint = { x: bottomx, y: bottomy - 3 * l }
    const end: CanvasPoint = { x: bottomx + 2 * l, y: bottomy - 3 * l }
    this.drawLine(start, end)
  }
  // rope
  if (step === 4) {
    const start: CanvasPoint = { x: bottomx + 1.5 * l, y: bottomy - 3 * l }
    const end: CanvasPoint = { x: bottomx + 1.5 * l, y: bottomy - 2.5 * l }
    this.drawLine(start, end)
  }
  // head
  if (step === 5) {
    this.context.beginPath()
    this.context.arc(bottomx + 1.5 * l, bottomy - 2 * l, l / 2, 0, 6.28)
    this.context.stroke()
  }
  // neck
  if (step === 6) {
    const start: CanvasPoint = { x: bottomx + 1.5 * l, y: bottomy - 1.5 * l }
    const end: CanvasPoint = { x: bottomx + 1.5 * l, y: bottomy - 1.25 * l }
    this.drawLine(start, end)
  }
  // arms
  if (step === 7) {
    const start: CanvasPoint = { x: bottomx + 1.5 * l, y: bottomy - 1.25 * l }
    const end1: CanvasPoint = { x: bottomx + l, y: bottomy - .75 * l }
    const end2: CanvasPoint = { x: bottomx + 2 * l, y: bottomy - .75 * l }
    this.drawLine(start, end1)
    this.drawLine(start, end2)
  }
  // body
  if (step === 8) {
    const start: CanvasPoint = { x: bottomx + 1.5 * l, y: bottomy - 1.25 * l }
    const end: CanvasPoint = { x: bottomx + 1.5 * l, y: bottomy - .75 * l }
    this.drawLine(start, end)
  }
  // legs
  if (step === 9) {
    const start: CanvasPoint = { x: bottomx + 1.5 * l, y: bottomy - .75 * l }
    const end1: CanvasPoint = { x: bottomx + l, y: bottomy - .25 * l }
    const end2: CanvasPoint = { x: bottomx + 2 * l, y: bottomy - .25 * l }
    this.drawLine(start, end1)
    this.drawLine(start, end2)
  }
}

// helper function to draw lines
private drawLine(from: CanvasPoint, to: CanvasPoint) {
  this.context.beginPath()
  this.context.moveTo(from.x, from.y)
  this.context.lineTo(to.x, to.y)
  this.context.stroke()
}
Enter fullscreen mode Exit fullscreen mode

And the game checker.

private checkGame() {
  if (this.preguesses.length >= 9) {
    this.status = HangmanStatus.lose
  } else if (this.current.join('') === this.goal) {
    this.status = HangmanStatus.win
  }
}
Enter fullscreen mode Exit fullscreen mode

Now we can call the guess function. Modify the submit button.

.onClick(() => {
  if (this.txt.length === 1) {
    this.hangman.guess(this.txt)
    this.tcn.deleteText()
  }
})
Enter fullscreen mode Exit fullscreen mode

You should be able to make guesses and see the stick figure.

Do not give up, we are one step away from perfection.

We need to inform the user when the game ends. Let's create a custom pop-up to show the game result.

Create WinLosePopup.ets under the components directory.

@Component
export default struct WinLosePopup {
  @Link showPopup: boolean;
  @Require win: boolean;

  build() {
    Column() {
      Text(`YOU ${this.win ? 'WON' : 'LOST'}`)
        .fontColor(this.win ? Color.Green : Color.Red)

      Blank().size({ height: 8 })

      Button() {
        SymbolGlyph($r('sys.symbol.arrow_clockwise'))
          .fontColor([Color.White])
      }
      .size({
        width: '100%',
        height: 32
      })
      .onClick(() => {
        this.showPopup = false
      })
    }
    .width('70%')
    .backgroundColor(Color.White)
    .padding(16)
  }
}
Enter fullscreen mode Exit fullscreen mode

We will call it from Index.ets.

First, define a state parameter:

@State pop: boolean = false;
Enter fullscreen mode Exit fullscreen mode

And the builder:

@Builder
winLosePopupBuilder() {
  WinLosePopup({
    showPopup: this.pop,
    win: this.hangman.status == HangmanStatus.win
  })
}
Enter fullscreen mode Exit fullscreen mode

Now bind the popup:

build() {
  Column() {
    ...
  }
  .bindPopup(this.pop, {
    builder: this.winLosePopupBuilder(),
    autoCancel: false,
    onStateChange: (e) => {
      if (!e.isVisible) {
        this.hangman.start()
      }
    },
  })
}
Enter fullscreen mode Exit fullscreen mode

Of course, this will not open with magic, but something like it. We will watch our view model and open the pop-up when the game state changes.

@State @Watch('winLose') hangman: Hangman = new Hangman()

winLose() {
  this.pop = this.hangman.status != HangmanStatus.playing
}
Enter fullscreen mode Exit fullscreen mode

Do not forget to remove the grey background color from the Canvas.

The Final Result:

Conclusion

We did it! Hope you had fun. See you all in new adventures. :)

~ Fortuna Favet Fortibus

Written by Mehmet Karaaslan

Top comments (0)