Sunday, 21 March 2021

Web Tutorial: ReactJS Hangman (Part 3/4)

What we implemented in the last part was a shortcut to winning the game - by guessing the entire word. But for the most part, the general usage revolves around guesing the word one letter at a time until the user reveals the entire word, or gains enough confidence to guess the word at one go.

So let's add more to the interface of the Player component. Here we are going to add the keyboard to the left of the screen. The new CSS classes are SelectLetter, Keyboard and Middle.

src/components/Player/Player.js
<div className="SelectLetter">
    <h3>Select A Letter</h3>
    <div className="Keyboard">
        
    </div>
</div>
<div className="Middle">
    <br /><br />OR
</div>

<div className="GuessWord">
    <h3>Guess The Word</h3>
    <input
        type="text"
        maxLength="13"
        value={ guessedWord }
        onChange={ (e)=>{ setGuessedWord(RemoveIllegalCharacters(e.target.value)); }}
        data-testid="txtGuessWord"
    />
    <br /><br />
    <button onClick={ ()=>{BtnConfirm_click();}} disabled={guessedWord.length === 0}>
        Confirm
    </button>
</div>


In the CSS, we add SelectLetter to the specification for the GuessWord CSS class, to piggyback on it. So now SelectLetter will be styled the same as GuessWord in certain parts.

src/components/Player/Player.css
.SelectLetter, .GuessWord {
    background-color: rgba(0, 0, 0, 1);
    width: 45%;
    height: 150px;
}

.SelectLetter {
    float: left;
}


.GuessWord {
    float: right;
}

.GuessWord input{
    padding: 0.5em;
    width: 13em;
    border-radius: 5px;
    border: 0px solid red;
}

.GuessWord button{
    padding: 0.5em;
    width: 8em;
    border-radius: 5px;
    border: 0px solid red;
    background-color: rgba(100, 100, 100, 1);
    color: rgba(0, 0, 0, 1);
    font-weight: bold;
    cursor: pointer;
}

.GuessWord button:hover{
    background-color: rgba(200, 200, 200, 1);
    color: rgba(100, 100, 100, 1);
}

.SelectLetter, .GuessWord {
    text-align: center;
    width: 45%;
    min-width: 200px;
}

.SelectLetter h3,
.GuessWord h3 {
    color: rgba(255, 255, 255, 1);
}


Then we add the Middle CSS class. It's just styling the "OR" text. Do what makes sense.

src/components/Player/Player.css
.Player {
    display: flex;
    background-color: rgba(0, 0, 0, 1);
    box-shadow: 10px 10px 10px rgba(0, 0, 0, 0.5);
    padding: 10px 0;
    flex-flow: row wrap;
    justify-content: center;
    align-items: center;
}

.Middle {
    width: 10%;
    height: 150px;
    background-color: rgba(0, 0, 0, 1);
    float: left;
    font-size: 2em;
    color: rgba(255, 255, 255, 1);
    text-align: center;
}


.SelectLetter, .GuessWord {
    background-color: rgba(0, 0, 0, 1);
    width: 45%;
    height: 150px;
}


Looks good. Time to add a keyboard.


Add this line in Player.js. Basically, declare playerLetters and make it an array of all the letters in the alphabet, in lowercase, using the split() method.

src/components/Player/Player.js
function Player(props) {
    const [guessedWord, setGuessedWord] = useState('');
    const [usedLetters, setUsedLetters] = useState([]);
    
    let playerLetters = 'abcdefghijklmnopqrstuvwxyz'.split('');

    let stage = props.stage;


Declare the constant keyboard. It's a bit of JSX generated by running playerLetters through the map() method, a series of divs each with a letter inside. Before we forget, also add keyboard to the dashboard.

src/components/Player/Player.js
const BtnBegin_click = ()=> {
    if (error) {
        alert("Error has occured. Please reload.");
        return;
    };

    if (isPending) {
        alert("Fetching words in progress. Please wait.");
        return;
    };

    setStage(0);
    setGuessedLetters([]);
    setUsedLetters([]);
    setMessageAndContext("", "");
};

const keyboard = playerLetters.map((item, index) => (
    <div>
        {item}                  
    </div>
    )
);


const dashboard = (
    <>
        <div className="SelectLetter">
            <h3>Select A Letter</h3>
            <div className="Keyboard">
                {keyboard}
            </div>
        </div>
        <div className="Middle">
            <br /><br />OR
        </div>


Add a key attribute and use index to create a unique value for each mapped item. For now, style those divs using Key.

src/components/Player/Player.js
const keyboard = playerLetters.map((item, index) => (
    <div
        key={'letter_' + index}
        className='Key'

    >
        {item}                  
    </div>
    )
);


For the CSS, let's add the Keyboard and Key CSS class. Keyboard is basically a container and aligned in the middle of its parent. Key here is a round-cornered square with background colors set and font formatted.

src/components/Player/Player.css
.SelectLetter h3, .GuessWord h3 {
    color: rgba(255, 255, 255, 1);
}

.Keyboard {
    width: 90%;
    background-color: rgba(0, 0, 0, 1);
    margin: 0 auto 0 auto;
}

.Keyboard .Key {
    margin: 5px 0 0 5px;
    width: 20px;
    height: 20px;
    border-radius: 5px;
    border: 1px solid rgba(200, 200, 200, 1);
    background-color: rgba(255, 255, 200, 1);
    color: rgba(100, 0, 0, 1);
    float: left;
    text-align: center;
    font-size: 0.8em;
    font-weight: bold;
    text-transform: uppercase;
    cursor: pointer;
}


.BtnBegin {
    display: block;
    margin: 0 auto 0 auto;
    padding: 0.5em;
    width: 8em;
    border-radius: 5px;
    border: 0px solid red;
    background-color: rgba(100, 100, 100, 1);
    color: rgba(0, 0, 0, 1);
    font-weight: bold;
    font-size: 2em;
    cursor: pointer;
}


Oh this looks good, doesn't it?


We expand on the styling here. If the letter has been used, we add hidden to the styling.

src/components/Player/Player.js
const keyboard = playerLetters.map((item, index) => (
    <div
        key={'letter_' + index}
        className='{usedLetters.indexOf(item) === -1 ? 'Key' : 'Key hidden'}'
    >
        {item}                  
    </div>
    )
);


And while we're at it, add an event handler. We'll call it LetterClick() and pass in the value of item as an argument.

src/components/Player/Player.js
const keyboard = playerLetters.map((item, index) => (
    <div
        key={'letter_' + index}
        className='{usedLetters.indexOf(item) === -1 ? 'Key' : 'Key hidden'}'
        onClick={()=>{LetterClick(item);}}
    >
        {item}                  
    </div>
    )
);


hidden is not invisible in this case. It's just disabled and unclickable. Possibly this is a bad naming convention, but let's just go with it.

src/components/Player/Player.css
.Keyboard .Key {
    margin: 5px 0 0 5px;
    width: 20px;
    height: 20px;
    border-radius: 5px;
    border: 1px solid rgba(200, 200, 200, 1);
    background-color: rgba(255, 255, 200, 1);
    color: rgba(100, 0, 0, 1);
    float: left;
    text-align: center;
    font-size: 0.8em;
    font-weight: bold;
    text-transform: uppercase;
    cursor: pointer;
}

.Keyboard .hidden {
    background-color: rgba(0, 0, 0, 1);
    color: rgba(100, 100, 100, 1);
    cursor: default;
}


.BtnBegin {
    display: block;
    margin: 0 auto 0 auto;
    padding: 0.5em;
    width: 8em;
    border-radius: 5px;
    border: 0px solid red;
    background-color: rgba(100, 100, 100, 1);
    color: rgba(0, 0, 0, 1);
    font-weight: bold;
    font-size: 2em;
    cursor: pointer;
}


Now for LetterClick()! We exit the function prematurely if the game is not in progress (stage is 6 or negative) and the letter is already in UsedLetters. The function will in turn call UseLetter(), passing in letter as an argument. We'll go ahead and create that function as well.

src/components/Player/Player.js
const BtnBegin_click = ()=> {
    if (error) {
        alert("Error has occured. Please reload.");
        return;
    };

    if (isPending) {
        alert("Fetching words in progress. Please wait.");
        return;
    };

    setStage(0);
    setGuessedLetters([]);
    setUsedLetters([]);
    setMessageAndContext("", "");
};

const LetterClick = (letter) => {
    if (stage === 6 || stage === -1) return;
    if (usedLetters.indexOf(letter) !== -1) return;

    UseLetter(letter);
};

const UseLetter = (letter) => {
      
}


const keyboard = playerLetters.map((item, index) => (
    <div
        key={'letter_' + index}
        className={usedLetters.indexOf(item) === -1 ? 'Key' : 'Key hidden'}
        onClick={()=>{LetterClick(item);}}
        data-testid={'btnLetter_' + item}
    >
        {item}                  
    </div>
    )
);


Here, we add letter into the array usedLetters. Then use setUsedLetters() to the new array.

src/components/Player/Player.js
const LetterClick = (letter) => {
    if (stage === 6 || stage === -1) return;
    if (usedLetters.indexOf(letter) !== -1) return;

    usedLetters.push(letter);
    setUsedLetters(usedLetters);


    UseLetter(letter);
};


For UseLetter(), set an If block. Check if letter is in mysteryLetters; that is, the letter is part of the mystery word.

src/components/Player/Player.js
const UseLetter = (letter) => {
    if (mysteryLetters.indexOf(letter) === -1) {

    } else {

    } 
      
}


If it isn't, increment stage and use setMessageAndContext() to tell the user that the guess was incorrect.

src/components/Player/Player.js
const UseLetter = (letter) => {
    if (mysteryLetters.indexOf(letter) === -1) {
        setStage(stage + 1);
        setMessageAndContext("You guessed '" + letter + "'. Wrong!", "failure");

    } else {

    }       
}


If stage is now 5, the game is over. We use setGuessedLetters() to reveal the entire word.

src/components/Player/Player.js
const UseLetter = (letter) => {
    if (mysteryLetters.indexOf(letter) === -1) {
        setStage(stage + 1);
        setMessageAndContext("You guessed '" + letter + "'. Wrong!", "failure");

        if (stage === 5) {
            setMessageAndContext("You have run out of tries!", "failure");
            setGuessedLetters(mysteryLetters);
        }

    } else {

    }       
}


If the guess is correct, however, we add letter to guessedLetters and use setMessageAndContext() to give the user a congratulatory message.

src/components/Player/Player.js
const UseLetter = (letter) => {
    if (mysteryLetters.indexOf(letter) === -1) {
        setStage(stage + 1);
        setMessageAndContext("You guessed '" + letter + "'. Wrong!", "failure");

        if (stage === 5) {
            setMessageAndContext("You have run out of tries!", "failure");
            setGuessedLetters(mysteryLetters);
        }
    } else {
        guessedLetters.push(letter);
        setGuessedLetters(guessedLetters);
        setMessageAndContext("You guessed '" + letter + "'. Correct!", "success");

    }       
}


Declare unguessedLetters. It should be the letters in mysteryLetters that aren't in guessedLetters. Basically, letters in the mystery word that have not been guessed.

src/components/Player/Player.js
const UseLetter = (letter) => {
    if (mysteryLetters.indexOf(letter) === -1) {
        setStage(stage + 1);
        setMessageAndContext("You guessed '" + letter + "'. Wrong!", "failure");

        if (stage === 5) {
            setMessageAndContext("You have run out of tries!", "failure");
            setGuessedLetters(mysteryLetters);
        }
    } else {
        guessedLetters.push(letter);
        setGuessedLetters(guessedLetters);
        setMessageAndContext("You guessed '" + letter + "'. Correct!", "success");

        let unguessedLetters = mysteryLetters.filter((item)=>{
            return guessedLetters.indexOf(item) === -1;
        });

    }       
}


Now, if the number of unguessed letters is 0, set state to -1 because the game is over. The game has been won.

src/components/Player/Player.js
const UseLetter = (letter) => {
    if (mysteryLetters.indexOf(letter) === -1) {
        setStage(stage + 1);
        setMessageAndContext("You guessed '" + letter + "'. Wrong!", "failure");

        if (stage === 5) {
            setMessageAndContext("You have run out of tries!", "failure");
            setGuessedLetters(mysteryLetters);
        }
    } else {
        guessedLetters.push(letter);
        setGuessedLetters(guessedLetters);
        setMessageAndContext("You guessed '" + letter + "'. Correct!", "success");

        let unguessedLetters = mysteryLetters.filter((item)=>{
            return guessedLetters.indexOf(item) === -1;
        });

        if (unguessedLetters.length === 0) {
            setStage(-1);
            setMessageAndContext("You Win!", "success");
        }

    }       
}


Time to test!

The word here is "UNCLENCHING". Let's try guessing the letter "C". Here you can see the letter displays change!


And now try an obviously wrong letter. Maybe "A". You'll see the letter on the keyboard grey out and the failure message come on.


Test this to completion!


Now, we've been dealing with a stacked deck; obviously this game is no challenge if you can see the mysery word. So do this for the Computer component. Show item only if guessedLetters contains the current letter. If not, show an empty string.

src/components/Computer/Computer.js
let mystery = mysteryLetters.map((item, index) => (
    <div
        key={ 'letter_' + index }
        className={guessedLetters.indexOf(item) !== -1 ? 'Letter' : 'Letter hidden'}
    >
        {guessedLetters.indexOf(item) !== -1 ? item : ''}                 
    </div>
))


This is how it should look!


Next

Automated testing for your Hangman app.

No comments:

Post a Comment