Search code examples
javascriptreactjsalgorithmuser-inputstate-management

Best way to track values of multiple text inputs to confirm they are all correct in a JavaScript/React word game


I'm making a cryptogram word game (decipher a famous quote) for my portfolio. I'm down to the last major hurdle: confirming that the puzzle is solved.

I don't have the reputation points to post a picture that would really help clarify, but think Wheel of Fortune where each white box is a separate input. I don't have them attached to any state because there can easily be over 100 inputs per puzzle. I've been manipulating them through className to autofill the inputs accordingly (i.e. if a player enters an 'e' in one input all similar inputs will be filled with 'e')

I thought I had completion confirmation figured out but quickly realized how flawed my solution was. I was creating a pendingSolution state that was a string and trying to populate it appropriately through an OnChange function in the inputs. I was checking this every time the value changed to see if it matched with the solution string, but it turns out I'm not setting the state of the pendingSolution string correctly to update the player's progress in the puzzle. I'm not sure if this is even the best approach, so I'm looking for either some help with fixing my updatePendingSolution function, or advice on a better way to approach this problem.

Example:

  • "*h*** *o* *o* *o** h***!" <-- pendingSolution
  • "Thank you for your help!" <-- solution

These are the functions to handle the OnChange of inputs and also to autofill up to three random letters as onClick hints.

const handleChange = (e) => {
     let letter = document.getElementsByClassName(e.target.className)
     for (let i=0; i < letter.length; i++) {
          letter[i].value = e.target.value
          letter[i].style.color = "darkblue"
     }
     setPendingSolution(pendingSolution.split('').map((char, i) => char.replace(/\*/, updatePendingSolution(char, e.target.value, i))).join(''))    
}

const handleClick = () => {
     const rand = Math.floor(Math.random() * 26)
     if(solution === pendingSolution) return
     if (randLetters.includes(rand)) {
          handleClick()
     } else {
          setRandLetters([...randLetters, rand])
          let letter = document.getElementsByClassName(`char_input ${shuffledAlphabet[rand].toLowerCase()}`)
          if (letter.length === 0) {
               letter = document.getElementsByClassName(`char_input ${shuffledAlphabet[rand]}`)
               if(letter.length === 0) handleClick()    
          } else {
               if (letter[0].value.toUpperCase() === alphabet[rand]) {
                    handleClick()
               } else {
                    for (let i=0; i < letter.length; i++) {
                         letter[i].value = alphabet[rand]
                         letter[i].disabled = true
                         letter[i].style.color = 'green';
                         setPendingSolution(pendingSolution.split('').map((char, i) => char.replace('*', updatePendingSolution(char, alphabet[rand], i))).join(''))
                    }
               }
          }
          setHints([...hints, alphabet[rand]])               
     }      
}

This is the part that I need the help with. It is the function I'm working on to update the pendingSolution state whenever a player uses a hint or changes an input.

useEffect(() => {
        if (solution && pendingSolution) {
            if (solution === pendingSolution) {
                alert("You won!!!")
            }
        }
    }, [pendingSolution])

const updatePendingSolution = (char, value, i) => {
     if (solution[i].match(value.toUpperCase())) {
          return value.toUpperCase()
     } else if (solution[i].match(value.toLowerCase())) {
          return value.toLowerCase() 
     } else return char   
}

And this is the code that renders the quote and name components:

function EncryptedText({ divName, onChange, words } ) {
    return (
        <div className={divName}>{words.split(" ").map((word, i) => {
            return (
                <div className="word_div" key={i}> {word.split("").map((char, j) => {
                    return (
                        <div key={j} className="char_container">
                            {char.match(/[A-Za-z]/) ?
                                char.match(/[A-Z]/) ?
                                    <div>
                                        <p className="puzzle_p uppercase"><input className={`char_input ${char.toLowerCase()}`} maxLength="1" onChange={onChange} type="text" /></p>
                                    </div>
                                    :<div>
                                        <p className="puzzle_p lowercase"><input className={`char_input ${char}`} maxLength="1" onChange={onChange} type="text" /></p>
                                    </div>
                                : <p className="puzzle_p">{char}</p>
                            }
                            <p className="puzzle_p">{char}</p>
                        </div>
                    )
                })}</div>
            )
        })}</div>
    )
}

export default EncryptedText


Solution

  • Though this is an entirely different approach, also not implemented with react, the OP might take some inspiration from it.

    The main idea is to provide kind of a 'wheel-of-fortune' component.

    Such a component takes care of its state altogether with all user interaction and it "knows" when it is solved.

    For this it sanitizes any text it got passed into and splits it into words. Each word then is split into characters, and for each character a corresponding input element gets created. Words are characters grouped into lists, and sentences / quotes / citations are made up of such lists.

    The trick which eases any other task is to introduce ...

    1. a weak reference based map where each input element is the key for its corresponding valid letter character.
    2. a map where for each valid lowercased letter character one does store/aggregate a list of input elements, each related to this character.
    3. an array of references of any created input element.

    ... and any component internally holds all of the above three as references.

    Then one just needs to handle any input event at the components root node (event delegation).

    For each event target (an input element) one does look up the correct character by the node reference. Then one compares the lowercased values of both the looked up character and the node value. In case they equal one has a match, and one can continue looking up other matching input elements by the lowercased character. For each other matching element one would autofill the element's related correct character case.

    Of cause one always needs to check whether a component got solved. This is done by concatenating all current element values in/to a string and comparing it with the sanitized version of the originally passed text.

    The beneath provided component implementation features a response promise that can be utilized by third party code. The promise internally gets resolved by the process described with the before paragraph.

    function isComponentSolved(match, placeholders) {
      const currentValue = placeholders
        .reduce((value, node) => value + node.value, '');
    
      return (match === currentValue);
    }
    
    function handleCharacterMatchUI(node, className) {
      const itemNode = node.closest('li');
      const { classList } = itemNode;
    
      classList.add(className);
      setTimeout((() => classList.remove(className)), 300);
    }
    function handleNextPlaceholderFocus(target, placeholders) {
      let idx = placeholders
        .findIndex(node => node === target);
    
      let nextNode = placeholders[++idx];
    
      while (nextNode) {
        if (nextNode.disabled) {
    
          nextNode = placeholders[++idx];
        } else {
          nextNode.focus();
          nextNode = null;
        }
      }
      if (idx >= placeholders.length) {
        idx = 0;
        nextNode = placeholders[idx];
    
        while (nextNode !== target) {
          if (nextNode.disabled) {
    
            nextNode = placeholders[++idx];
          } else {
            nextNode.focus();
            nextNode = target;
          }
        }
      }
    }
    
    function handleInputFromBoundComponentData({ target }) {
      const {
        settle,
        match,
        placeholders,
        charByNodeMap,
        nodesByCharMap,
      } = this;
    
      const value = target.value.toLowerCase();
      const char = charByNodeMap.get(target).toLowerCase();
    
      if (value === char) {
        nodesByCharMap
          .get(char)
          .forEach(node => {
    
            node.disabled = true;
            node.value = charByNodeMap.get(node);
    
            handleCharacterMatchUI(node, 'match');
          });
          handleNextPlaceholderFocus(target, placeholders);
      } else {
        handleCharacterMatchUI(target, 'mismatch');
      }
    
      if (isComponentSolved(match, placeholders)) {
        // resolve the component's
        // `response` promise with
        // the 'solved' payload.
        settle('solved');
      }
    }
    
    function createPlaceholderNode(char, charByNodeMap, nodesByCharMap) {
      const node = document.createElement('input');
    
      // test for Letter only character
      // using unicode property escapes.
      if ((/\p{L}/u).test(char)) {
        let nodeList = nodesByCharMap.get(char);
    
        if (!nodeList) {
          nodeList = [];
          nodesByCharMap.set(char.toLowerCase(), nodeList);
        }
        nodeList.push(node);
    
        charByNodeMap.set(node, char);
      } else {
        // non Letter character.
        node.disabled = true;
        node.value = char;
      }
      node.type = 'text';
    
      return node;
    }
    
    function createWheelOfFortuneComponent(root) {
      let response = null; // a promise for any valid component.
    
      const text = (root.dataset.wheelOfFortune ?? '')
        .trim().replace((/\s+/g), ' '); // text normalization.
    
      if (text !== '') {
        const charByNodeMap = new WeakMap;
        const nodesByCharMap = new Map;
    
        const placeholders = [];
        const wordRootList = text
          .split(/\s/)
    
          .map(word => word
            .split('')
    
            .reduce((listNode, char) => {
              const itemNode = document.createElement('li');
              const phNode =
                createPlaceholderNode(char, charByNodeMap, nodesByCharMap);
    
              placeholders.push(phNode);
    
              itemNode.appendChild(phNode);
              listNode.appendChild(itemNode);
    
              return listNode;
            }, document.createElement('ol'))
          );
    
        let settle;
        response = new Promise(resolve => {
          settle = resolve;
        });
    
        root.addEventListener(
          'input',
          handleInputFromBoundComponentData.bind({
            settle,
            match: text.replace((/\s/g), ''),
            placeholders,
            charByNodeMap,
            nodesByCharMap,
          })
        );
        wordRootList
          .forEach(wordRoot => root.appendChild(wordRoot));
      }
      root.dataset.wheelOfFortune = '';
    
      return {
        root,
        response,
      };
    }
    
    async function handleComponentResponseAsync({ root, response }) {
      if (response !== null) {
        const result = await response;
    
        root.classList.add(result);
      }
    }
    function app() {
      [...document.querySelectorAll('[data-wheel-of-fortune]')]
        .map(createWheelOfFortuneComponent)
        .forEach(handleComponentResponseAsync)
    }
    app();
    [data-wheel-of-fortune] {
      margin: 8px 0;
      padding: 0 0 3px 0;
    }
    [data-wheel-of-fortune].solved {
      outline: 2px solid green;
    }
    [data-wheel-of-fortune].failed {
      outline: 2px solid red;
    }
    [data-wheel-of-fortune] ol,
    [data-wheel-of-fortune] ul {
    list-style-type: none;
      display: inline-block;
      margin: 0 4px;
      padding: 0;
      position: relative;
      top: 3px;
    }
    [data-wheel-of-fortune] ol::after,
    [data-wheel-of-fortune] ul::after {
      clear: both;
      content: '';
    }
    [data-wheel-of-fortune] li {
      position: relative;
      float: left;
      margin: 0;
      padding: 0;
    }
    [data-wheel-of-fortune] li [type="text"] {
      width: 11px;
      margin: 0;
      padding: 0;
      text-align: center;
    }
    [data-wheel-of-fortune] li::after {
      z-index: 1;
      position: absolute;
      display: block;
      content: '';
      left: 0;
      top: 0;
      width: 100%;
      height: 100%;
      transition-property: opacity;
      transition-duration: .3s;
      opacity: 0;
    }
    [data-wheel-of-fortune] li.match::after {
      background-color: #c2ef2f;
      opacity: .5;
    }
    [data-wheel-of-fortune] li.mismatch::after {
      background-color: #fb5100;
      opacity: .5;
    }
    <article data-wheel-of-fortune="The journey of a thousand miles begins with one step."></article>
    
    <article data-wheel-of-fortune="Great minds discuss ideas; average minds discuss events; small minds discuss people."></article>
    
    <article data-wheel-of-fortune="Thank you for your help!"></article>