Svelte, XState 및 SpeechRecognition으로 게임 빌드

43769 단어 sveltexstatetypescript
안녕하세요 여러분 이번 주말에 XState , SvelteSpeechRecognition API 🎤 을 가지고 놀았기 때문에 미니 숫자 추측 게임을 만들고 statechart 으로 상태를 모델링하기로 결정했습니다. 그것.

If you want to try it out go to this 🌎 Live demo (only works on Chrome desktop or mobile).



참고: SpeechRecognition은 영어 단어만 인식하므로(또는 적어도 스페인어 😝에서는 작동하지 않습니다), 게임이 스페인어로 되어 있더라도 영어로 숫자를 말해야 합니다.

유형



TypeScript를 사용할 것이기 때문에 먼저 types를 정의합시다.

export type NumberGuessContextType = {
  recognition: SpeechRecognition | null
  randomNumber: number
  hint: string
  error: string
  isChrome: boolean
}

export type NotSupportedErrorType = {
  type: 'NOT_SUPPORTED_ERROR'
  error: string
}

export type CheckReadinessType = {
  type: 'CHECK_READINESS'
}

type NotAllowedErrorType = {
  type: 'NOT_ALLOWED_ERROR'
  error: string
}

type SpeakType = {
  type: 'SPEAK'
  message: string
}

type PlayAgainType = {
  type: 'PLAY_AGAIN'
}

export type UpdateHintType = {
  type: 'UPDATE_HINT'
  data: string
}

export type NumberGuessEventType =
  | NotSupportedErrorType
  | CheckReadinessType
  | NotAllowedErrorType
  | SpeakType
  | PlayAgainType
  | UpdateHintType

export type NumberGuessStateType = {
  context: NumberGuessContextType
  value: 'verifyingBrowser' | 'failure' | 'playing' | 'checkNumber' | 'gameOver'
}


SpeechRecognition에 대한 전역 유형 추가



SpeechRecognition API는 매우 실험적이므로 TS가 이에 대해 알 수 있도록 이 API를 처리하는 방법을 기술해야 합니다. webkitSpeechRecognition 유형에 대한 전역 인터페이스를 선언하겠습니다.

export declare global {
  interface Window {
    webkitSpeechRecognition: SpeechRecognition
  }
}


기계



이제 상태 머신의 차례입니다. 여기에서 우리의 작은 게임 뒤에 모든 논리를 넣을 것입니다.

import { createMachine, assign } from 'xstate'
import type {
  NumberGuessContextType,
  NumberGuessEventType,
  NumberGuessStateType,
  NotSupportedErrorType,
  UpdateHintType,
} from 'src/machine/types'

const numberGuessMachine = createMachine<
  NumberGuessContextType,
  NumberGuessEventType,
  NumberGuessStateType
>(
  {
    id: 'guessNumber',
    initial: 'verifyingBrowser',
    context: {
      hint: '',
      recognition: null,
      randomNumber: -1,
      error: '',
      isChrome: false,
    },
    states: {
      verifyingBrowser: {
        entry: 'checkBrowser',
        on: {
          NOT_SUPPORTED_ERROR: {
            target: 'failure',
            actions: 'displayError',
          },
          CHECK_READINESS: {
            target: 'playing',
            actions: 'initGame',
            cond: 'isSpeechRecognitionReady',
          },
          NOT_ALLOWED_ERROR: {
            target: 'failure',
            actions: 'displayError',
            cond: 'hasError',
          },
        },
      },
      playing: {
        after: {
          2500: {
            actions: 'clearHint',
            cond: 'hasHint',
          },
        },
        on: {
          SPEAK: {
            target: 'checkNumber',
          },
        },
      },
      checkNumber: {
        invoke: {
          id: 'checkingNumber',
          src: 'checkNumber',
          onDone: {
            actions: 'updateHint',
            target: 'gameOver',
          },
          onError: {
            actions: 'updateHint',
            target: 'playing',
          },
        },
      },
      gameOver: {
        exit: 'initGame',
        on: {
          PLAY_AGAIN: {
            target: 'playing',
          },
          SPEAK: {
            target: 'playing',
            cond: 'isPlayAgain',
          },
        },
      },
      failure: {
        type: 'final',
      },
    },
  },
  {
    actions: {
      checkBrowser: assign({
        isChrome: _ => navigator.userAgent.includes('Chrome'),
      }),
      displayError: assign<NumberGuessContextType, NotSupportedErrorType>({
        error: (_, event) => event.error,
      }) as any,
      initGame: assign({
        hint: _ => '',
        recognition: _ => new window.SpeechRecognition(),
        randomNumber: _ => Math.floor(Math.random() * 100) + 1,
      }),
      updateHint: assign<NumberGuessContextType, UpdateHintType>({
        hint: (_, event) => event.data,
      }) as any,
      clearHint: assign({
        hint: _ => '',
      }),
    },
    guards: {
      hasError(_, event: NumberGuessEventType) {
        if (event.type === 'NOT_ALLOWED_ERROR') {
          return event.error !== ''
        }
        return false
      },
      hasHint(context) {
        return context.hint !== ''
      },
      isUnsupportedBrowser(_, event: NumberGuessEventType) {
        return event.type !== 'NOT_SUPPORTED_ERROR'
      },
      isSpeechRecognitionReady() {
        window.SpeechRecognition =
          window.SpeechRecognition || window.webkitSpeechRecognition
        return window.SpeechRecognition !== undefined
      },
      isPlayAgain(_, event: NumberGuessEventType) {
        if (event.type === 'SPEAK') {
          return event.message === 'play'
        }
        return false
      },
    },
    services: {
      checkNumber(
        context: NumberGuessContextType,
        event: NumberGuessEventType
      ) {
        if (event.type !== 'SPEAK') {
          return Promise.reject('Acción no válida.')
        }

        const num = +event.message

        if (Number.isNaN(num)) {
          return Promise.reject('Ese no es un número válido, intenta de nuevo')
        }

        if (num > 100 || num < 1) {
          return Promise.reject('El número debe estar entre 1 y 100')
        }

        if (num === context.randomNumber) {
          return Promise.resolve('¡Felicidades has ganado!')
        }

        if (num > context.randomNumber) {
          return Promise.reject('MENOR')
        }

        return Promise.reject('MAYOR')
      },
    },
  }
)

export { numberGuessMachine }


우리 기계 사용하기


numberGuessMachine 구성 요소에서 App를 사용할 시간입니다.

<script lang="ts">
  import { onMount, onDestroy } from 'svelte'
  import { interpret } from 'xstate'
  import { realisticLook } from 'src/utils'
  import { numberGuessMachine } from 'src/machine/numberGuess'

  const service = interpret(numberGuessMachine).start()

  function onSpeak(event: SpeechRecognitionEvent) {
    const [result] = event.results
    const [transcripts] = result
    const { transcript: message } = transcripts
    service.send({
      message,
      type: 'SPEAK',
    })
  }

  onMount(() => {
    if (!$service.context.isChrome) {
      return service.send({
        type: 'NOT_SUPPORTED_ERROR',
        error: 'Lo siento, tu navegador no soporta la API SpeechRecognition.',
      })
    }

    navigator.mediaDevices
      .getUserMedia({ audio: true })
      .then(() => {
        service.send({
          type: 'CHECK_READINESS',
        })

        const recognition = $service.context.recognition
        if (!recognition) {
          return
        }

        recognition.start()
        recognition.addEventListener('result', onSpeak)
        recognition.addEventListener('end', () => recognition.start())
      })
      .catch(() => {
        service.send({
          type: 'NOT_ALLOWED_ERROR',
          error:
            'Por favor, permita el uso del 🎤 para poder jugar. Y después recargue la página.',
        })
      })
  })

  onDestroy(() => {
    $service.context?.recognition?.stop()
    service.stop()
  })

  service.onTransition(state => {
    if (state.matches('gameOver')) {
      realisticLook()
    }
  })
</script>

<section class="container" data-state={$service.toStrings().join(' ')}>
  {#if $service.matches('failure') && !$service.context.isChrome}
    <div>{$service.context.error}</div>
  {/if}
  {#if $service.matches('playing')}
    <div>
      <svg
        xmlns="http://www.w3.org/2000/svg"
        fill="none"
        viewBox="0 0 24 24"
        stroke="currentColor"
        class="mic"
      >
        <path
          stroke-linecap="round"
          stroke-linejoin="round"
          stroke-width="2"
          d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"
        />
      </svg>

      <h1>Adivina el número entre 1 y 100</h1>

      <h3>Menciona el número que desees (en inglés).</h3>

      <div class="msg">
        {$service.context.hint}
      </div>
    </div>
  {/if}
  {#if $service.matches('gameOver')}
    <div>
      <h2>
        {$service.context.hint}
        <br />
        <br />
        El número era: {$service.context.randomNumber}
      </h2>
      <button
        class="play-again"
        on:click={() =>
          service.send({
            type: 'PLAY_AGAIN',
          })}>Play</button
      >
      <p class="mt-1">O menciona "play"</p>
    </div>
  {/if}
  {#if $service.matches('failure') && $service.context.isChrome}
    <div>
      {$service.context.error}
    </div>
  {/if}
</section>


메모



💻 소스 코드: number-guess

즐거운 코딩 👋🏽

좋은 웹페이지 즐겨찾기