DEV Community

Cover image for Recreating LinkedIn’s Crossclimb Game with Angular - Part 3
Michele Stieven
Michele Stieven

Posted on

Recreating LinkedIn’s Crossclimb Game with Angular - Part 3

Now that we've taken care of the game's logic with a service, it's time to end our little project with a couple more components!


The Hint component

But first, let's create a simple component to display the current hint to the user: create the file /components/hint.ts.

import { Component } from '@angular/core';

@Component({
  selector: 'app-hint',
  template: `
    <ng-content />
  `,
  styles: `
    :host {
      background: rgba(0,0,0,.1);
      color: grey;
      display: block;
      position: absolute;
      bottom: 0;
      left: 0;
      right: 0;
      height: 2rem;
      padding: 2em;
      text-align: center;
    }
  `
})
export class Hint {}
Enter fullscreen mode Exit fullscreen mode

This component does nothing more than styling: it'll place our hint on the bottom of the page via Content Projection, like this:

<app-hint>This is a hint</app-hint>
Enter fullscreen mode Exit fullscreen mode

hint


The Container

Time for our final component: the GameContainer! This one will be in charge of displaying all the words, binding the states from our service, and listening to events. Essentially, it is the main orchestrator of our game. Create the file components/game-container.ts.

@Component({
  selector: 'app-game-container',
  template: `
    @if (gameService.game(); as game) {
      <div class="game-container">

      </div>
    }

    <app-hint>{{ gameService.currentHint() }}</app-hint>
  `,
  imports: [Hint],
  styles: `

  `
})
export class GameContainer {

  gameService = inject(GameService);

  ngOnInit() {
    this.gameService.init();
  }
}
Enter fullscreen mode Exit fullscreen mode

For now, all it does is to display the current hint to the user, and it also sets up a container <div>. It is also responsible for initializing the game.

Just put it in your main component, like this:

@Component({
  selector: 'app-root',
  template: `
    <app-game-container />
  `,
  imports: [GameContainer],
})
export class App {}
Enter fullscreen mode Exit fullscreen mode

Now go back to the GameContainer component: let's add some template.

From now on, we'll be working inside the <div class="game-container"> element.


Showing the edge words

First, we'll show the top word.

<app-word
  [letters]="game.currentEdgeWords[0].split('')"
  [isReadonly]="gameService.gameStatus() !== 'sorted' || gameService.gameStatus() === 'solved'"
  [isFinal]="true"
  [isLocked]="gameService.gameStatus() === 'error' || gameService.gameStatus() === 'unsorted'"
  (lettersChange)="onLettersChange(0, $event)"
  (lastKeydown)="focusWord(game.words.length + 1)"
/>
Enter fullscreen mode Exit fullscreen mode

This is what we're doing:

  • We're binding the current top word (transforming it from an array to a single string).
  • We're binding isReadonly, isFinal and isLocked with the appropriate expressions.

Don't forget to import the Word component!

We're also listening to two events: lettersChange and lastKeydown. The first one is responsible for updating the state, the second one is needed to jump to the bottom word when the user has entered the last letter (for convenience). So, we need to create two metods.

The first one is really simple, all it has to do is to call the methods we've already created in our service:

onLettersChange(wordIndex: number, letters: string[]) {
  if (wordIndex === 0) {
    this.gameService.replaceTop(letters.join(''));
  } else if (wordIndex === this.gameService.game()!.words.length + 1) {
    this.gameService.replaceBottom(letters.join(''));
  } else {
    this.gameService.replaceWord(wordIndex - 1, letters.join(''));
  }
}
Enter fullscreen mode Exit fullscreen mode

The wordIndex we get goes from 0 to 6, so we're calling the appropriate method based on that.

Then, we need the ability to focus on some element in our view. To do that, we need to use viewChildren:

  wordComponents = viewChildren(Word);
Enter fullscreen mode Exit fullscreen mode

Import it from @angular/core.

This is a Signal which contains all the Word components on this page. Now we can create the focusWord() handler:

focusWord(i: number) {
  this.wordComponents()[i]?.focus(0);
}
Enter fullscreen mode Exit fullscreen mode

Perfect! Now for the bottom word, things are mostly the same, except that we're binding to the second edge word, and we don't need to listen to the lastKeydown event:

<app-word
  [letters]="game.currentEdgeWords[1].split('')"
  [isReadonly]="gameService.gameStatus() !== 'sorted' || gameService.gameStatus() === 'solved'"
  [isFinal]="true"
  [isLocked]="gameService.gameStatus() === 'error' || gameService.gameStatus() === 'unsorted'"
  (lettersChange)="onLettersChange(game.words.length + 1, $event)"
/>
Enter fullscreen mode Exit fullscreen mode

You should now see both words, in a locked state:

locked


The middle words

It's time to display the middle words! In order to have the drag & drop functionality, we need the Angular CDK, so install it like this:

npm install @angular/cdk 
Enter fullscreen mode Exit fullscreen mode

Now, between the two words, put this template:

<div cdkDropList class="sortable-list" (cdkDropListDropped)="drop($event)">
  @for (word of game.words; track i; let i = $index) {
    <div cdkDrag class="word-container">

    </div>
  }
</div>
Enter fullscreen mode Exit fullscreen mode

Here we're displaying all of our middle words. But also:

  • We're using the cdkDropList directive to tell Angular that this is where we'll put our draggable elements.
  • We're using the cdkDrag directive to the elements that we want to drag.
  • We're listening to the cdkDropListDropped event which tells us when an element has been dropped.

So, first of all, import the directives:

import { CdkDropList, CdkDrag } from '@angular/cdk'; 

// ...
  imports: [Word, CdkDropList, CdkDrag, Hint, Icon],
Enter fullscreen mode Exit fullscreen mode

Then, we need to implement the drop() handler. This is how it looks like:

  drop(event: CdkDragDrop<string[]>) {
    const words = [...this.gameService.game()!.words];
    moveItemInArray(words, event.previousIndex, event.currentIndex);
    this.gameService.game.update(game => ({ ...game!, words }));
  }
Enter fullscreen mode Exit fullscreen mode

Import CdkDragDrop and moveItemInArray from @angular/cdk and place them in the imports array.

Here we're cloning the original array of words (we don't want to mutate it) and sorting it with the helper moveItemInArray from the CDK. Then, we're updating the state with the ordered words.

Believe it or not, the class is done! All we need to do now is to add some more elements and some styles.

Place your cursor inside the <div cdkDrag class="word-container"> element and add the following:

<app-word
  [letters]="word.current.split('')"
  [isReadonly]="gameService.gameStatus() !== 'error'"
  (focusedChange)="gameService.focused.set($event !== null ? i : null)"
  (lettersChange)="onLettersChange(i + 1, $event)"
  (lastKeydown)="$last ? '' : focusWord(i + 2)"
/>
Enter fullscreen mode Exit fullscreen mode

This is more or less the same thing we did for the edge words, but:

  • The bindings and conditions are different.
  • We're using i + 1 on the lettersChange event because we need to account for the top word.
  • We're using i + 2 on lastKeydown for the same reason.

Also, we're listening to focusedChange to know when the user has focused this word. For our state, we don't actually need to know the index of the character (the $event), just the index of the word.

One more touch: we don't want the user to drag the words normally, we want to make sure they drag an icon, so let's add two icons, one per side! Place this immediately after the previous template.

<div cdkDragHandle [style.display]="gameService.gameStatus() !== 'sorted' && gameService.gameStatus() !== 'solved' ? 'block' : 'none'">
  <app-icon icon="bars" />
</div>

<div cdkDragHandle [style.display]="gameService.gameStatus() !== 'sorted' && gameService.gameStatus() !== 'solved' ? 'block' : 'none'">
  <app-icon icon="bars" />
</div>
Enter fullscreen mode Exit fullscreen mode

Import CdkDragHandle from @angular/cdk and place it in the imports array.

All that's left to do is some styling! Put these styles in your component:

These are some simple styles to make it functional, feel free to tweak them!

.game-container {
  max-width: 400px;
  padding: 2em;
  margin: 0 auto;
}
.word-container {
  position: relative;
}
[cdkDragHandle] {
  position: absolute;
  top: 50%;
  display: block;
  cursor: pointer;
}
[cdkDragHandle]:nth-of-type(1) {
  left: 0;
  transform: translate(-50%, -50%);
}
[cdkDragHandle]:nth-of-type(2) {
  right: 0;
  transform: translate(50%, -50%);
}
.sortable-list {
  border: 2px solid rgba(0,0,0,.05);
  border-top: none;
  border-bottom: none;
 }
app-word {
  display: block;
  padding: 10px 30px;
}
.cdk-drag-placeholder {
  opacity: 0;
}
.cdk-drag {
  overflow: visible;
}
.cdk-drag-animating {
  transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}
.sortable-list.cdk-drop-list-dragging > *:not(.cdk-drag-placeholder) {
  transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}
Enter fullscreen mode Exit fullscreen mode

All the classes with the cdk- prefix are automatically applied by the CDK, we just need to style them. The CDK actually clones the element when it moves, so we're just making the previous one invisible while we drag.

And we're done! Our game is now complete! [Link to the final code]

done


Where to go from here

The game is fully working, but the original one has a couple more features. Feel free to implement them as an exercise! For example:

  • Request the GameInfo object from an actual server.
  • Start a timer as soon as the game is loaded, and stop it when the puzzle is solved. You could do it easily with RxJS.
  • Display a button which reveals the focused word.
  • Display a button which reveals only one missing letter of the focused word.
  • Finally, to make it more challenging, allow the user to click on these buttons only every 10 seconds or so. Again, this should be pretty easy with RxJS!

I hope this has been a fun little project to build, as it was for me! Hope you liked it!

Top comments (0)