├── public
├── favicon
│ ├── favicon.ico
│ ├── favicon-16x16.png
│ ├── favicon-32x32.png
│ ├── mstile-150x150.png
│ ├── apple-touch-icon.png
│ ├── android-chrome-192x192.png
│ ├── android-chrome-384x384.png
│ ├── browserconfig.xml
│ └── site.webmanifest
├── manifest.json
└── index.html
├── src
├── assets
│ └── icons
│ │ └── sprite.png
├── components
│ ├── SnakeCanvas
│ │ ├── SnakeCanvas.css
│ │ └── index.jsx
│ ├── InfoBoard
│ │ ├── index.jsx
│ │ └── InfoBoard.scss
│ ├── GameBoard
│ │ ├── GameBoard.scss
│ │ └── index.jsx
│ └── EntryBoard
│ │ ├── index.jsx
│ │ └── EntryBoard.scss
├── context
│ ├── staticStore.js
│ ├── StoreContext.js
│ ├── actions.js
│ └── reducers.js
├── App.test.js
├── styles
│ └── _variables.css
├── utilities
│ ├── deviceDetect.js
│ ├── camera.js
│ └── snake.js
├── index.js
├── App.scss
├── App.js
├── index.css
└── serviceWorker.js
├── package.json
├── README.md
└── .gitignore
/public/favicon/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vince19972/TeachableSnake/HEAD/public/favicon/favicon.ico
--------------------------------------------------------------------------------
/src/assets/icons/sprite.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vince19972/TeachableSnake/HEAD/src/assets/icons/sprite.png
--------------------------------------------------------------------------------
/public/favicon/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vince19972/TeachableSnake/HEAD/public/favicon/favicon-16x16.png
--------------------------------------------------------------------------------
/public/favicon/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vince19972/TeachableSnake/HEAD/public/favicon/favicon-32x32.png
--------------------------------------------------------------------------------
/public/favicon/mstile-150x150.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vince19972/TeachableSnake/HEAD/public/favicon/mstile-150x150.png
--------------------------------------------------------------------------------
/public/favicon/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vince19972/TeachableSnake/HEAD/public/favicon/apple-touch-icon.png
--------------------------------------------------------------------------------
/src/components/SnakeCanvas/SnakeCanvas.css:
--------------------------------------------------------------------------------
1 | #SnakeCanvas {
2 | max-width: 100%;
3 | max-height: 100%;
4 | overflow: hidden;
5 | }
--------------------------------------------------------------------------------
/public/favicon/android-chrome-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vince19972/TeachableSnake/HEAD/public/favicon/android-chrome-192x192.png
--------------------------------------------------------------------------------
/public/favicon/android-chrome-384x384.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vince19972/TeachableSnake/HEAD/public/favicon/android-chrome-384x384.png
--------------------------------------------------------------------------------
/src/context/staticStore.js:
--------------------------------------------------------------------------------
1 | const staticStore = {}
2 |
3 | staticStore.model = {
4 | checkPoint: 'https://storage.googleapis.com/tm-pro-a6966.appspot.com/vince-arrows/model.json'
5 | }
6 |
7 | export default staticStore
--------------------------------------------------------------------------------
/src/App.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import App from './App';
4 |
5 | it('renders without crashing', () => {
6 | const div = document.createElement('div');
7 | ReactDOM.render(, div);
8 | ReactDOM.unmountComponentAtNode(div);
9 | });
10 |
--------------------------------------------------------------------------------
/src/styles/_variables.css:
--------------------------------------------------------------------------------
1 | :root {
2 |
3 | --c_light: #fafafa;
4 | --c_dark: black;
5 | --c_theme-primary: #000051;
6 | --c_theme-light: #304ffe;
7 | --c_yellow-light: #fffde7;
8 | --c_pink-light: #fce4ec;
9 | --c_blue-light: #e0f7fa;
10 | --c_green-light: #f9fbe7;
11 | --c_orange: #FF5000;
12 |
13 | }
--------------------------------------------------------------------------------
/src/utilities/deviceDetect.js:
--------------------------------------------------------------------------------
1 | export function isAndroid() {
2 | return /Android/i.test(navigator.userAgent)
3 | }
4 |
5 | export function isiOS() {
6 | return /iPhone|iPad|iPod/i.test(navigator.userAgent)
7 | }
8 |
9 | export default function isMobile() {
10 | return isAndroid() || isiOS()
11 | }
--------------------------------------------------------------------------------
/public/favicon/browserconfig.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | #da532c
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | }
10 | ],
11 | "start_url": ".",
12 | "display": "standalone",
13 | "theme_color": "#000000",
14 | "background_color": "#ffffff"
15 | }
16 |
--------------------------------------------------------------------------------
/public/favicon/site.webmanifest:
--------------------------------------------------------------------------------
1 | {
2 | "name": "",
3 | "short_name": "",
4 | "icons": [
5 | {
6 | "src": "/android-chrome-192x192.png",
7 | "sizes": "192x192",
8 | "type": "image/png"
9 | },
10 | {
11 | "src": "/android-chrome-384x384.png",
12 | "sizes": "384x384",
13 | "type": "image/png"
14 | }
15 | ],
16 | "theme_color": "#ffffff",
17 | "background_color": "#ffffff",
18 | "display": "standalone"
19 | }
20 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import ReactDOM from 'react-dom'
3 | import './index.css'
4 | import App from './App'
5 | import * as serviceWorker from './serviceWorker'
6 |
7 | import { StoreProvider } from './context/StoreContext'
8 |
9 | ReactDOM.render(
10 |
11 |
12 | ,
13 | document.getElementById('root')
14 | )
15 |
16 | // If you want your app to work offline and load faster, you can change
17 | // unregister() to register() below. Note this comes with some pitfalls.
18 | // Learn more about service workers: https://bit.ly/CRA-PWA
19 | serviceWorker.unregister()
20 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "teachable-snake",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "node-sass": "^4.11.0",
7 | "react": "^16.8.6",
8 | "react-dom": "^16.8.6",
9 | "react-scripts": "2.1.8",
10 | "react-transition-group": "1.x"
11 | },
12 | "scripts": {
13 | "start": "react-scripts start",
14 | "build": "react-scripts build",
15 | "test": "react-scripts test",
16 | "eject": "react-scripts eject"
17 | },
18 | "eslintConfig": {
19 | "extends": "react-app"
20 | },
21 | "browserslist": [
22 | ">0.2%",
23 | "not dead",
24 | "not ie <= 11",
25 | "not op_mini all"
26 | ]
27 | }
28 |
--------------------------------------------------------------------------------
/src/App.scss:
--------------------------------------------------------------------------------
1 | .App {
2 | position: relative;
3 | background-color: var(--c_theme-primary);
4 |
5 | @media screen and (max-width: 1240px) {
6 | .media-warn {
7 | display: flex;
8 | }
9 | }
10 | }
11 |
12 | .media-warn {
13 | display: none;
14 |
15 | position: absolute;
16 | top: 0;
17 | left: 0;
18 | width: 100vw;
19 | height: 100vh;
20 | background-color: var(--c_theme-primary);
21 | padding: 8vw;
22 | box-sizing: border-box;
23 | align-items: center;
24 | justify-content: center;
25 | text-align: center;
26 |
27 | h2 {
28 | color: var(--c_light);
29 | font-size: 8vw;
30 | font-weight: 900;
31 | }
32 | }
33 |
34 | .-flex-column {
35 | display: flex;
36 | flex-direction: column;
37 | }
--------------------------------------------------------------------------------
/src/App.js:
--------------------------------------------------------------------------------
1 | import React, { useContext } from 'react'
2 | import { StoreContext } from './context/StoreContext'
3 | import './App.scss'
4 | import './styles/_variables.css'
5 |
6 | import GameBoard from './components/GameBoard'
7 | import EntryBoard from './components/EntryBoard'
8 |
9 | function App () {
10 |
11 | const { state } = useContext(StoreContext)
12 |
13 | return (
14 |
15 | {state.globalValues.isGameStarted ? (
16 |
17 | ) : (
18 |
19 | )}
20 |
21 |
Sorry! Only support desktop experience at this moment.
22 |
23 |
24 | )
25 |
26 | }
27 |
28 | export default App
29 |
--------------------------------------------------------------------------------
/src/context/StoreContext.js:
--------------------------------------------------------------------------------
1 | import React, { createContext, useReducer } from 'react'
2 | import { reducer, initialState } from './reducers'
3 | import { useActions } from './actions'
4 |
5 | const StoreContext = createContext(initialState)
6 |
7 | const StoreProvider = ({ children }) => {
8 | const [state, dispatch] = useReducer(reducer, initialState)
9 | const actions = useActions(state, dispatch)
10 |
11 | // useEffect(
12 | // () => {
13 | // console.log({ newState: state })
14 | // },
15 | // [state]
16 | // )
17 |
18 | return (
19 |
20 | {children}
21 |
22 | )
23 | }
24 |
25 | export { StoreContext, StoreProvider }
26 |
--------------------------------------------------------------------------------
/src/components/InfoBoard/index.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import './InfoBoard.scss'
3 |
4 | function InfoBoard () {
5 |
6 | return (
7 |
8 |
9 |
10 |
11 | how to play the game
12 |
13 |
16 |
17 |
18 |
19 | white paper with black arrow
20 | hold the paper in front of computer camera
21 | rotate the paper to control the snake
22 |
23 |
24 |
25 |
26 | )
27 | }
28 |
29 | export default InfoBoard
--------------------------------------------------------------------------------
/src/components/GameBoard/GameBoard.scss:
--------------------------------------------------------------------------------
1 | #GameBoard {
2 | max-width: 100vw;
3 | min-height: 100vh;
4 | max-height: 100vh;
5 | display: flex;
6 | align-items: center;
7 | justify-content: space-between;
8 | flex-direction: column;
9 | padding: 8vh 10vw 12vh 10vw;
10 | box-sizing: border-box;
11 | margin: 0 auto;
12 | }
13 |
14 | .info-bar {
15 | width: 100%;
16 | display: flex;
17 | justify-content: space-between;
18 | align-items: baseline;
19 |
20 | & .game-title {
21 | color: var(--c_light);
22 | font-size: 7vw;
23 | font-weight: 900;
24 | margin: 0;
25 | transform: translate3d(0, -8px, 0);
26 | }
27 | }
28 |
29 | .btm-bar {
30 | width: 100%;
31 | font-size: .7vw;
32 | color: #c9c9c9;
33 |
34 | .infos {
35 | display: flex;
36 | margin: 0;
37 | padding: 0;
38 |
39 | .info {
40 | list-style: none;
41 |
42 | a {
43 | color: white;
44 | text-decoration: none;
45 |
46 | &:hover {
47 | color: var(--c_orange);
48 | }
49 | }
50 |
51 | &:not(:last-child) {
52 | margin-right: 1vw;
53 | }
54 | }
55 | }
56 | }
--------------------------------------------------------------------------------
/src/components/InfoBoard/InfoBoard.scss:
--------------------------------------------------------------------------------
1 | #InfoBoard {
2 | height: 100%;
3 | padding: 0 8vw;
4 | }
5 |
6 | .tutorial {
7 | display: flex;
8 | flex-direction: column;
9 | align-items: center;
10 | justify-content: center;
11 | height: 100%;
12 | margin: 0 auto;
13 |
14 | .top {
15 | .indication {
16 | font-size: 1.5vw;
17 | text-align: center;
18 | width: 100%;
19 | padding: 40px 0;
20 | }
21 | }
22 |
23 | .image {
24 | text-align: center;
25 | max-width: 25vh;
26 | height: 25vh;
27 | max-height: 25vh;
28 | margin: 0 auto;
29 | overflow: hidden;
30 |
31 | .sprite {
32 | height: 100%;
33 | background: url(../../assets/icons/sprite.png) left center;
34 | background-size: cover;
35 | animation: sprite 3s steps(4) infinite;
36 | }
37 | }
38 | .steps {
39 | text-align: center;
40 | font-size: 2vw;
41 | font-weight: 700;
42 |
43 | .step {
44 | &:not(:last-child) {
45 | &:after {
46 | content: '>';
47 | margin: 0 12px 0 16px;
48 | }
49 | }
50 | }
51 | }
52 | }
53 |
54 | @keyframes sprite {
55 | 100% {
56 | background-position: -100vh;
57 | }
58 | }
--------------------------------------------------------------------------------
/src/components/SnakeCanvas/index.jsx:
--------------------------------------------------------------------------------
1 | import React, { useContext, useEffect } from 'react'
2 | import { StoreContext } from '../../context/StoreContext'
3 | import { types } from '../../context/reducers'
4 | import './SnakeCanvas.css'
5 |
6 | import { updateGameFrame } from '../../utilities/snake'
7 |
8 | function SnakeCanvas () {
9 | const { state, dispatch, actions } = useContext(StoreContext)
10 |
11 | useEffect(() => {
12 | const snakeCanvas = document.getElementById('SnakeCanvas')
13 | const ctx = snakeCanvas.getContext('2d')
14 |
15 | document.addEventListener('keydown', actions.updateSnakePosition)
16 | requestAnimationFrame(updateGameFrame(
17 | state,
18 | snakeCanvas,
19 | {
20 | updateFood: actions.updateFoodPosition,
21 | updateSnakePosition: actions.updateSnakePosition,
22 | updateGameStatus: () => dispatch({ type: types.UPDATE_GAME_STATUS }),
23 | updateUnit: () => dispatch({ type: types.UPDATE_UNIT, payload: { ctx }}),
24 | updateSnakeLength: (playerId) => dispatch({ type: types.UPDATE_LENGTH, payload: { playerId }}),
25 | }
26 | ))
27 | }, [])
28 |
29 | return (
30 |
31 | )
32 | }
33 |
34 | export default SnakeCanvas
--------------------------------------------------------------------------------
/src/utilities/camera.js:
--------------------------------------------------------------------------------
1 | /**
2 | *
3 | * The code is revised from https://github.com/tensorflow/tfjs-models/tree/master/posenet/demos
4 | *
5 | */
6 |
7 | import isMobile from './deviceDetect'
8 |
9 | const canvasWidth = window.innerWidth * 0.15
10 | const canvasHeight = window.innerHeight * 0.125
11 |
12 | export async function setupCamera(videoElement) {
13 | if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
14 | throw new Error(
15 | 'Browser API navigator.mediaDevices.getUserMedia not available')
16 | }
17 |
18 | videoElement.width = canvasWidth
19 | videoElement.height = canvasHeight
20 |
21 | const mobile = isMobile()
22 | const stream = await navigator.mediaDevices.getUserMedia({
23 | 'audio': false,
24 | 'video': {
25 | facingMode: 'user',
26 | width: mobile ? undefined : canvasWidth,
27 | height: mobile ? undefined : canvasHeight,
28 | },
29 | })
30 | videoElement.srcObject = stream
31 |
32 | return new Promise((resolve) => {
33 | videoElement.onloadedmetadata = () => {
34 | resolve(videoElement)
35 | }
36 | })
37 | }
38 |
39 | export default async function loadVideo(videoElement) {
40 | const video = await setupCamera(videoElement)
41 | video.play()
42 |
43 | return video
44 | }
--------------------------------------------------------------------------------
/src/components/EntryBoard/index.jsx:
--------------------------------------------------------------------------------
1 | import React, { useContext } from 'react'
2 | import { StoreContext } from '../../context/StoreContext'
3 | import { types } from '../../context/reducers'
4 | import './EntryBoard.scss'
5 |
6 | import InfoBoard from '../InfoBoard'
7 |
8 | function EntryBoard () {
9 |
10 | const { dispatch } = useContext(StoreContext)
11 |
12 | function startGame() {
13 | dispatch({ type: types.UPDATE_GAME_START })
14 | }
15 |
16 | return (
17 |
18 |
19 |
20 |
21 |
Teachable Snake
22 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 | )
38 | }
39 |
40 | export default EntryBoard
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Teachable Snake
2 |
3 | 
4 |
5 | 🕹 Play the game [here](https://teachable-snake.netlify.com)
6 |
7 | 👉 [Case study](https://www.vinceshao.com/works/teachable-snake) of the project
8 |
9 | This project is the final project of class [Machine Learning for the Web](https://github.com/yining1023/machine-learning-for-the-web) at ITP, NYU. It's powered by [Tensorflow.js](https://www.tensorflow.org/js/guide/nodejs) and [Teachable Machine](https://teachablemachine.withgoogle.com/) by Google.
10 |
11 |
12 | ---
13 |
14 | Bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
15 |
16 | ## Available Scripts
17 |
18 | In the project directory, you can run:
19 |
20 | ### `npm start`
21 |
22 | Runs the app in the development mode.
23 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
24 |
25 | The page will reload if you make edits.
26 | You will also see any lint errors in the console.
27 |
28 | ### `npm run build`
29 |
30 | Builds the app for production to the `build` folder.
31 | It correctly bundles React in production mode and optimizes the build for the best performance.
32 |
33 | The build is minified and the filenames include the hashes.
34 | Your app is ready to be deployed!
35 |
36 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
37 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | padding: 0;
4 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
5 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
6 | -webkit-font-smoothing: antialiased;
7 | -moz-osx-font-smoothing: grayscale;
8 | }
9 |
10 | code {
11 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
12 | monospace;
13 | }
14 |
15 | /**
16 | * 1. Change the font styles in all browsers.
17 | * 2. Remove the margin in Firefox and Safari.
18 | */
19 |
20 | button,
21 | input,
22 | optgroup,
23 | select,
24 | textarea {
25 | font-family: inherit; /* 1 */
26 | font-size: 100%; /* 1 */
27 | line-height: 1.15; /* 1 */
28 | margin: 0; /* 2 */
29 | }
30 |
31 | /**
32 | * Show the overflow in IE.
33 | * 1. Show the overflow in Edge.
34 | */
35 |
36 | button,
37 | input { /* 1 */
38 | overflow: visible;
39 | }
40 |
41 | /**
42 | * Remove the inheritance of text transform in Edge, Firefox, and IE.
43 | * 1. Remove the inheritance of text transform in Firefox.
44 | */
45 |
46 | button,
47 | select { /* 1 */
48 | text-transform: none;
49 | background-color: transparent;
50 | border: none;
51 | }
52 |
53 | button:focus {
54 | outline: none;
55 | outline-offset: -4px;
56 | }
57 |
58 | /**
59 | * Correct the inability to style clickable types in iOS and Safari.
60 | */
61 |
62 | button,
63 | [type="button"],
64 | [type="reset"],
65 | [type="submit"] {
66 | -webkit-appearance: button;
67 | }
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | lerna-debug.log*
8 |
9 | # Diagnostic reports (https://nodejs.org/api/report.html)
10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
11 |
12 | # Runtime data
13 | pids
14 | *.pid
15 | *.seed
16 | *.pid.lock
17 |
18 | # Directory for instrumented libs generated by jscoverage/JSCover
19 | lib-cov
20 |
21 | # Coverage directory used by tools like istanbul
22 | coverage
23 |
24 | # nyc test coverage
25 | .nyc_output
26 |
27 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
28 | .grunt
29 |
30 | # Bower dependency directory (https://bower.io/)
31 | bower_components
32 |
33 | # node-waf configuration
34 | .lock-wscript
35 |
36 | # Compiled binary addons (https://nodejs.org/api/addons.html)
37 | build/Release
38 |
39 | # Dependency directories
40 | node_modules/
41 | jspm_packages/
42 |
43 | # TypeScript v1 declaration files
44 | typings/
45 |
46 | # Optional npm cache directory
47 | .npm
48 |
49 | # Optional eslint cache
50 | .eslintcache
51 |
52 | # Optional REPL history
53 | .node_repl_history
54 |
55 | # Output of 'npm pack'
56 | *.tgz
57 |
58 | # Yarn Integrity file
59 | .yarn-integrity
60 |
61 | # dotenv environment variables file
62 | .env
63 | .env.test
64 |
65 | # parcel-bundler cache (https://parceljs.org/)
66 | .cache
67 |
68 | # next.js build output
69 | .next
70 |
71 | # nuxt.js build output
72 | .nuxt
73 |
74 | # vuepress build output
75 | .vuepress/dist
76 |
77 | # Serverless directories
78 | .serverless/
79 |
80 | # FuseBox cache
81 | .fusebox/
82 |
83 | # DynamoDB Local files
84 | .dynamodb/# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
85 |
86 | # dependencies
87 | /node_modules
88 | /.pnp
89 | .pnp.js
90 |
91 | # testing
92 | /coverage
93 |
94 | # production
95 | /build
96 |
97 | # misc
98 | .DS_Store
99 | .env.local
100 | .env.development.local
101 | .env.test.local
102 | .env.production.local
103 |
104 | npm-debug.log*
105 | yarn-debug.log*
106 | yarn-error.log*
107 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
16 |
17 |
21 |
22 |
31 | Teachable Snake
32 |
33 |
34 |
35 |
36 |
46 |
47 |
48 |
49 |
--------------------------------------------------------------------------------
/src/components/EntryBoard/EntryBoard.scss:
--------------------------------------------------------------------------------
1 | #EntryBoard {
2 | max-width: 100vw;
3 | min-height: 100vh;
4 | max-height: 100vh;
5 | display: flex;
6 | background-color: var(--c_light);
7 | }
8 |
9 | .left-side {
10 | max-width: 35vw;
11 | min-width: 35vw;
12 | background-color: var(--c_theme-primary);
13 |
14 | .left-side__top {
15 | flex-grow: 2;
16 | padding: 24px 4vw;
17 | display: flex;
18 | flex-direction: column;
19 | align-items: center;
20 | justify-content: center;
21 | position: relative;
22 |
23 | &:after {
24 | content: '';
25 | position: absolute;
26 | top: 0;
27 | left: 0;
28 | width: 1.1vw;
29 | height: 1.1vw;
30 | background-color: var(--c_orange);
31 |
32 | animation-name: dot;
33 | animation-duration: 4s;
34 | animation-timing-function: linear;
35 | animation-iteration-count: infinite;
36 | }
37 | }
38 |
39 | .left-side__btm {
40 | height: 10vh;
41 | display: flex;
42 | align-items: center;
43 | justify-content: center;
44 | }
45 |
46 | }
47 |
48 | .right-side {
49 | flex-grow: 2;
50 | text-align: center;
51 | }
52 |
53 | .start-btn {
54 | font-size: 2vw;
55 | font-weight: 900;
56 | color: var(--c_light);
57 | text-align: center;
58 | width: 100%;
59 | height: 100%;
60 | background-color: var(--c_orange);
61 |
62 | &:hover {
63 | cursor: pointer;
64 | background-color: #F5A623;
65 | }
66 | }
67 |
68 | .main {
69 | text-align: center;
70 | color: var(--c_light);
71 |
72 | .title {
73 | font-size: 4vw;
74 | font-weight: 900;
75 | line-height: 4.5vw;
76 | }
77 | .infos {
78 | font-size: 0.7vw;
79 | line-height: 1vw;
80 | width: 80%;
81 | margin: 0 auto;
82 | list-style: none;
83 | padding: 0;
84 | opacity: .7;
85 |
86 | a {
87 | color: white;
88 | text-decoration: none;
89 | font-weight: 700;
90 |
91 | &:hover {
92 | color: var(--c_orange);
93 | }
94 | }
95 | }
96 | }
97 |
98 | @keyframes dot {
99 | 0% {
100 | top: 0;
101 | left: 0;
102 | }
103 | 25% {
104 | top: calc(100% - 1.1vw);
105 | left: 0;
106 | }
107 | 50% {
108 | top: calc(100% - 1.1vw);
109 | left: calc(100% - 1.1vw);
110 | }
111 | 75% {
112 | top: 0%;
113 | left: calc(100% - 1.1vw);
114 | }
115 | 100% {
116 | top: 0;
117 | left: 0;
118 | }
119 | }
--------------------------------------------------------------------------------
/src/components/GameBoard/index.jsx:
--------------------------------------------------------------------------------
1 | import React, { useContext, useState, useEffect } from 'react'
2 | import { StoreContext } from '../../context/StoreContext'
3 | import './GameBoard.scss'
4 |
5 | import SnakeCanvas from '../SnakeCanvas'
6 | import loadVideo from '../../utilities/camera'
7 | import staticStore from '../../context/staticStore'
8 | import { isInFrame } from '../../utilities/snake'
9 |
10 | function GameBoard () {
11 | const { actions } = useContext(StoreContext)
12 |
13 | const tm = window.tm
14 | const [userWebCam, setUserWebCam] = useState(null)
15 | const [model, setModel] = useState(null)
16 |
17 | async function loadModel() {
18 | const loadedModel = await tm.mobilenet.load(staticStore.model.checkPoint)
19 | setModel(loadedModel)
20 | }
21 |
22 | async function predictVideo(image) {
23 | if (model) {
24 | const prediction = await model.predict(image, 4)
25 | const predictType = prediction[0].className
26 |
27 | if (isInFrame) actions.updateSnakePosition({ predictType })
28 |
29 | predictVideo(userWebCam)
30 | }
31 | }
32 |
33 | // load the model (only once as component is mounted)
34 | useEffect(() => {
35 | loadModel()
36 | }, [])
37 |
38 | // load the video (only once as component is mounted)
39 | useEffect(() => {
40 | try {
41 | const video = loadVideo(document.getElementById('userWebCam'))
42 | video.then((resolvedVideo) => {
43 | setUserWebCam(resolvedVideo)
44 | })
45 | } catch (err) {
46 | throw err
47 | }
48 | }, [])
49 |
50 | // make prediction (as userWebCam and model is set)
51 | useEffect(() => {
52 | if (userWebCam) {
53 | predictVideo(userWebCam)
54 | }
55 | }, [userWebCam, model])
56 |
57 | return (
58 |
59 |
60 |
Teachable Snake
61 |
62 |
63 |
64 |
65 |
66 |
73 |
74 | )
75 | }
76 |
77 | export default GameBoard
--------------------------------------------------------------------------------
/src/context/actions.js:
--------------------------------------------------------------------------------
1 | import { types, directions } from './reducers'
2 | import { generateFoodPosition, checkDirection } from '../utilities/snake'
3 |
4 | const keys = {
5 | LEFT: 37,
6 | UP: 38,
7 | RIGHT: 39,
8 | DOWN: 40,
9 | arrowUp: 0,
10 | arrowRight: 1,
11 | arrowDown: 2,
12 | arrowLeft: 3,
13 | }
14 |
15 | export const useActions = (state, dispatch) => {
16 |
17 | function updateSnakePosition(event) {
18 | const { globalValues, players } = state
19 | const { unit } = globalValues
20 | const {
21 | LEFT,
22 | UP,
23 | RIGHT,
24 | DOWN,
25 | arrowUp,
26 | arrowRight,
27 | arrowDown,
28 | arrowLeft
29 | } = keys
30 | const keyCode = event
31 | ? event.keyCode
32 | ? event.keyCode
33 | : event.predictType
34 | : null
35 |
36 | switch(keyCode) {
37 | case LEFT:
38 | case arrowLeft:
39 | dispatch({
40 | type: types.MOVE_SNAKE,
41 | payload: {
42 | isBackWrapping: checkDirection(directions.LEFT, players[0].trails),
43 | playerId: 0,
44 | xVelocity: -1 * unit,
45 | yVelocity: 0
46 | }
47 | })
48 | return
49 | case UP:
50 | case arrowUp:
51 | dispatch({
52 | type: types.MOVE_SNAKE,
53 | payload: {
54 | isBackWrapping: checkDirection(directions.UP, players[0].trails),
55 | playerId: 0,
56 | xVelocity: 0,
57 | yVelocity: -1 * unit,
58 | }
59 | })
60 | return
61 | case RIGHT:
62 | case arrowRight:
63 | dispatch({
64 | type: types.MOVE_SNAKE,
65 | payload: {
66 | isBackWrapping: checkDirection(directions.RIGHT, players[0].trails),
67 | playerId: 0,
68 | xVelocity: 1 * unit,
69 | yVelocity: 0
70 | }
71 | })
72 | return
73 | case DOWN:
74 | case arrowDown:
75 | dispatch({
76 | type: types.MOVE_SNAKE,
77 | payload: {
78 | isBackWrapping: checkDirection(directions.DOWN, players[0].trails),
79 | playerId: 0,
80 | xVelocity: 0,
81 | yVelocity: 1 * unit,
82 | }
83 | })
84 | return
85 | default:
86 | dispatch({
87 | type: types.MOVE_SNAKE,
88 | payload: {
89 | playerId: 0,
90 | keepMoving: true
91 | }
92 | })
93 | return
94 | }
95 | }
96 |
97 | function updateFoodPosition(foodId = null) {
98 | const { foods } = state
99 | const newFood = generateFoodPosition(state)
100 | newFood.id = generateFoodId()
101 |
102 | dispatch({ type: types.UPDATE_FOOD, payload: { newFood } })
103 |
104 | function generateFoodId() {
105 | return foodId !== null ? foodId : foods.length
106 | }
107 | }
108 |
109 | return {
110 | updateSnakePosition,
111 | updateFoodPosition
112 | }
113 | }
--------------------------------------------------------------------------------
/src/context/reducers.js:
--------------------------------------------------------------------------------
1 | import { generateSnakePosition } from '../utilities/snake'
2 |
3 | const initialState = {
4 | globalValues: {
5 | ctx: '',
6 | unit: 20,
7 | snakeCanvas: {
8 | isResized: false,
9 | widthPortion: 0.85,
10 | heightPortion: 0.6
11 | },
12 | isGameOver: false,
13 | isGameStarted: false
14 | },
15 | players: [
16 | {
17 | name: 'player 1',
18 | color: '#00701a',
19 | xVelocity: 0,
20 | yVelocity: 0,
21 | length: 1,
22 | trails: [[0,0]]
23 | }
24 | ],
25 | foods: []
26 | }
27 |
28 | const types = {
29 | MOVE_SNAKE: 'MOVE_SNAKE',
30 | UPDATE_LENGTH: 'UPDATE_LENGTH',
31 | UPDATE_UNIT: 'UPDATE_UNIT',
32 | UPDATE_FOOD: 'UPDATE_FOOD',
33 | UPDATE_GAME_START: 'UPDATE_GAME_START',
34 | UPDATE_GAME_STATUS: 'UPDATE_GAME_STATUS',
35 | }
36 |
37 | const directions = {
38 | LEFT: 'left',
39 | UP: 'up',
40 | RIGHT: 'right',
41 | DOWN: 'down',
42 | }
43 |
44 | const reducer = (state = initialState, action) => {
45 |
46 | switch (action.type) {
47 | case types.MOVE_SNAKE: {
48 | const { playerId, xVelocity: newXVelocity, yVelocity: newYVelocity, keepMoving, isBackWrapping } = action.payload
49 | const updatePlayer = state.players[playerId]
50 |
51 | if (!keepMoving && !isBackWrapping) {
52 | updatePlayer.xVelocity = newXVelocity
53 | updatePlayer.yVelocity = newYVelocity
54 | }
55 |
56 | const newTrails = generateSnakePosition({
57 | currentLength: updatePlayer.length,
58 | currentTrails: updatePlayer.trails,
59 | currentXYVelocity: {
60 | xVelocity: updatePlayer.xVelocity,
61 | yVelocity: updatePlayer.yVelocity
62 | }
63 | })
64 |
65 | state.players[playerId] = {
66 | ...updatePlayer,
67 | trails: newTrails
68 | }
69 |
70 | return { ...state }
71 | }
72 | case types.UPDATE_UNIT: {
73 | const { ctx } = action.payload
74 | state.globalValues.ctx = ctx
75 |
76 | return { ...state }
77 | }
78 | case types.UPDATE_LENGTH: {
79 | const { playerId } = action.payload
80 | state.players[playerId] = {
81 | ...state.players[playerId],
82 | length: state.players[playerId].length += 1
83 | }
84 |
85 | return { ...state }
86 | }
87 | case types.UPDATE_FOOD: {
88 | const { newFood } = action.payload
89 | state.foods[newFood.id] = newFood
90 |
91 | return { ...state }
92 | }
93 | case types.UPDATE_GAME_START: {
94 | state.globalValues.isGameStarted = !state.globalValues.isGameStarted
95 |
96 | return { ...state }
97 | }
98 | case types.UPDATE_GAME_STATUS: {
99 | state.globalValues.isGameOver = !state.globalValues.isGameOver
100 |
101 | alert('GAME OVER')
102 |
103 | return { ...state }
104 | }
105 | default:
106 | return
107 | }
108 |
109 | }
110 |
111 | export { initialState, types, reducer, directions }
--------------------------------------------------------------------------------
/src/serviceWorker.js:
--------------------------------------------------------------------------------
1 | // This optional code is used to register a service worker.
2 | // register() is not called by default.
3 |
4 | // This lets the app load faster on subsequent visits in production, and gives
5 | // it offline capabilities. However, it also means that developers (and users)
6 | // will only see deployed updates on subsequent visits to a page, after all the
7 | // existing tabs open on the page have been closed, since previously cached
8 | // resources are updated in the background.
9 |
10 | // To learn more about the benefits of this model and instructions on how to
11 | // opt-in, read https://bit.ly/CRA-PWA
12 |
13 | const isLocalhost = Boolean(
14 | window.location.hostname === 'localhost' ||
15 | // [::1] is the IPv6 localhost address.
16 | window.location.hostname === '[::1]' ||
17 | // 127.0.0.1/8 is considered localhost for IPv4.
18 | window.location.hostname.match(
19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
20 | )
21 | );
22 |
23 | export function register(config) {
24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
25 | // The URL constructor is available in all browsers that support SW.
26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
27 | if (publicUrl.origin !== window.location.origin) {
28 | // Our service worker won't work if PUBLIC_URL is on a different origin
29 | // from what our page is served on. This might happen if a CDN is used to
30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374
31 | return;
32 | }
33 |
34 | window.addEventListener('load', () => {
35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
36 |
37 | if (isLocalhost) {
38 | // This is running on localhost. Let's check if a service worker still exists or not.
39 | checkValidServiceWorker(swUrl, config);
40 |
41 | // Add some additional logging to localhost, pointing developers to the
42 | // service worker/PWA documentation.
43 | navigator.serviceWorker.ready.then(() => {
44 | console.log(
45 | 'This web app is being served cache-first by a service ' +
46 | 'worker. To learn more, visit https://bit.ly/CRA-PWA'
47 | );
48 | });
49 | } else {
50 | // Is not localhost. Just register service worker
51 | registerValidSW(swUrl, config);
52 | }
53 | });
54 | }
55 | }
56 |
57 | function registerValidSW(swUrl, config) {
58 | navigator.serviceWorker
59 | .register(swUrl)
60 | .then(registration => {
61 | registration.onupdatefound = () => {
62 | const installingWorker = registration.installing;
63 | if (installingWorker == null) {
64 | return;
65 | }
66 | installingWorker.onstatechange = () => {
67 | if (installingWorker.state === 'installed') {
68 | if (navigator.serviceWorker.controller) {
69 | // At this point, the updated precached content has been fetched,
70 | // but the previous service worker will still serve the older
71 | // content until all client tabs are closed.
72 | console.log(
73 | 'New content is available and will be used when all ' +
74 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.'
75 | );
76 |
77 | // Execute callback
78 | if (config && config.onUpdate) {
79 | config.onUpdate(registration);
80 | }
81 | } else {
82 | // At this point, everything has been precached.
83 | // It's the perfect time to display a
84 | // "Content is cached for offline use." message.
85 | console.log('Content is cached for offline use.');
86 |
87 | // Execute callback
88 | if (config && config.onSuccess) {
89 | config.onSuccess(registration);
90 | }
91 | }
92 | }
93 | };
94 | };
95 | })
96 | .catch(error => {
97 | console.error('Error during service worker registration:', error);
98 | });
99 | }
100 |
101 | function checkValidServiceWorker(swUrl, config) {
102 | // Check if the service worker can be found. If it can't reload the page.
103 | fetch(swUrl)
104 | .then(response => {
105 | // Ensure service worker exists, and that we really are getting a JS file.
106 | const contentType = response.headers.get('content-type');
107 | if (
108 | response.status === 404 ||
109 | (contentType != null && contentType.indexOf('javascript') === -1)
110 | ) {
111 | // No service worker found. Probably a different app. Reload the page.
112 | navigator.serviceWorker.ready.then(registration => {
113 | registration.unregister().then(() => {
114 | window.location.reload();
115 | });
116 | });
117 | } else {
118 | // Service worker found. Proceed as normal.
119 | registerValidSW(swUrl, config);
120 | }
121 | })
122 | .catch(() => {
123 | console.log(
124 | 'No internet connection found. App is running in offline mode.'
125 | );
126 | });
127 | }
128 |
129 | export function unregister() {
130 | if ('serviceWorker' in navigator) {
131 | navigator.serviceWorker.ready.then(registration => {
132 | registration.unregister();
133 | });
134 | }
135 | }
136 |
--------------------------------------------------------------------------------
/src/utilities/snake.js:
--------------------------------------------------------------------------------
1 | import { initialState, directions } from '../context/reducers'
2 |
3 | export let isInFrame = false
4 |
5 | const snakeStore = {
6 | canvasWidth: 0,
7 | canvasHeight: 0,
8 | notFirstInitFrame: true,
9 | startedAnimationFrame: false,
10 | frameDebounce: 125,
11 | foodColor: [
12 | '#fbc02d',
13 | '#f48fb1'
14 | ],
15 | unit: 0
16 | }
17 |
18 | export function initCanvas(canvas) {
19 | const { widthPortion, heightPortion } = initialState.globalValues.snakeCanvas
20 | const ctx = canvas.getContext('2d')
21 |
22 | ctx.canvas.width = window.innerWidth * widthPortion
23 | ctx.canvas.height = window.innerHeight * heightPortion
24 | ctx.fillStyle = '#eeeeee'
25 | ctx.fillRect(0, 0, canvas.width, canvas.height)
26 |
27 | snakeStore.canvasWidth = ctx.canvas.width
28 | snakeStore.canvasHeight = ctx.canvas.height
29 | }
30 |
31 | export function updateGameFrame(state, canvas, contextCallbacks) {
32 | const ctx = canvas.getContext('2d')
33 | const { updateUnit, updateFood, updateSnakePosition, updateSnakeLength, updateGameStatus } = contextCallbacks
34 |
35 | return (timestamp) => {
36 | if (!snakeStore.startedAnimationFrame) snakeStore.startedAnimationFrame = timestamp
37 | let progress = timestamp - snakeStore.startedAnimationFrame
38 | isInFrame = progress > snakeStore.frameDebounce
39 |
40 | if (isInFrame && !state.globalValues.isGameOver) {
41 | // execute main frame function
42 | initCanvas(canvas)
43 |
44 | // game over status
45 | if (isGameOver(state.players)) updateGameStatus()
46 |
47 | // from action context, dispatch reducer function
48 | // update the positions in global state
49 | snakeEating(state, updateFood, updateSnakeLength)
50 | updateSnakePosition()
51 |
52 | // draw the shapes according to global state
53 | redrawSnake(state, ctx)
54 | redrawFood(state, ctx)
55 |
56 | // reset flag
57 | snakeStore.startedAnimationFrame = false
58 |
59 | if (snakeStore.notFirstInitFrame) {
60 | updateUnit()
61 | updateFood()
62 | snakeStore.unit = state.globalValues.unit
63 | snakeStore.notFirstInitFrame = false
64 | }
65 | }
66 |
67 | requestAnimationFrame(updateGameFrame(state, canvas, contextCallbacks))
68 | }
69 | }
70 |
71 | export function generateSnakePosition(requiredInfo) {
72 | const { currentLength, currentTrails, currentXYVelocity } = requiredInfo
73 | const { canvasWidth, canvasHeight } = snakeStore
74 | const { xVelocity, yVelocity } = currentXYVelocity
75 | const snakeHead = currentTrails[0]
76 |
77 | const [ xPosition, yPosition ] = snakeHead
78 | const newXY = [ xPosition + xVelocity, yPosition + yVelocity ]
79 | const newTrails = [newXY, ...currentTrails].slice(0, currentLength)
80 |
81 | // exceeding boundaries situation handling
82 | const fmtTrails = newTrails.map((trail) => {
83 | const [xPosition, yPosition] = trail
84 | const fmtTrail = [xPosition, yPosition]
85 |
86 | if (xPosition > canvasWidth) fmtTrail[0] = 0
87 | if (xPosition < 0) fmtTrail[0] = fmtPosition(canvasWidth, snakeStore.unit)
88 | if (yPosition > canvasHeight) fmtTrail[1] = 0
89 | if (yPosition < 0) fmtTrail[1] = fmtPosition(canvasHeight, snakeStore.unit)
90 |
91 | return fmtTrail
92 | })
93 |
94 | return fmtTrails
95 | }
96 |
97 | export function redrawSnake(state, ctx) {
98 | state.players.forEach((player) => {
99 | const { trails, color } = player
100 | trails.forEach(trail => {
101 | const [ xPosition, yPosition ] = trail
102 | ctx.fillStyle = color
103 | ctx.fillRect(xPosition, yPosition, state.globalValues.unit, state.globalValues.unit)
104 | })
105 | })
106 | }
107 |
108 | export function generateFoodPosition(state) {
109 | const { globalValues, foods } = state
110 | const { foodColor } = snakeStore
111 | const { width: canvasWidth, height: canvasHeight } = globalValues.ctx.canvas
112 | const newFood = {
113 | id: '',
114 | color: '',
115 | xPosition: 0,
116 | yPosition: 0
117 | }
118 |
119 | // helper functions
120 | const checkPositionCollision = () => {
121 | const { xPosition, yPosition } = newFood
122 | const isCollided = foods
123 | .filter(food => food.xPosition === xPosition, yPosition)
124 | .length > 0
125 |
126 | if (isCollided) {
127 | generateFood()
128 | checkPositionCollision()
129 | }
130 |
131 | return
132 | }
133 | const generateFood = () => {
134 | const randomX = getRandomInt(0, canvasWidth)
135 | const randomY = getRandomInt(0, canvasHeight)
136 |
137 | newFood.color = foodColor[0]
138 | newFood.xPosition = fmtPosition(randomX, globalValues.unit)
139 | newFood.yPosition = fmtPosition(randomY, globalValues.unit)
140 | }
141 |
142 | // generate new color, x and y position
143 | generateFood()
144 |
145 | // check if new position is collided with old position
146 | if (foods.length > 0) checkPositionCollision()
147 |
148 | return newFood
149 | }
150 |
151 | export function redrawFood(state, ctx) {
152 | state.foods.forEach((food) => {
153 | const { xPosition, yPosition, color } = food
154 | ctx.fillStyle = color
155 | ctx.fillRect(xPosition, yPosition, state.globalValues.unit, state.globalValues.unit)
156 | })
157 | }
158 |
159 | export function snakeEating(state, updateFoodPosition, updateSnakeLength) {
160 | const { players, foods } = state
161 |
162 | players.forEach((player, index) => {
163 | const { trails } = player
164 | const [ xPosition, yPosition ] = trails[0]
165 | const eatenFood = foods.filter((food) => food.xPosition === xPosition && food.yPosition === yPosition)
166 | const isFoodEaten = eatenFood.length > 0
167 |
168 | if (isFoodEaten) {
169 | updateFoodPosition(eatenFood[0].id)
170 | updateSnakeLength(index)
171 | }
172 | })
173 | }
174 |
175 | export function checkDirection(movingDirection, snakeTrails, checkIsSameDirection = false) {
176 | let resultCondition = false
177 |
178 | if (snakeTrails.length > 1) {
179 | const [ xHead, yHead ] = snakeTrails[0]
180 | const [ xSecond, ySecond ] = snakeTrails[1]
181 |
182 | switch (movingDirection) {
183 | case directions.UP: {
184 | const condition = checkIsSameDirection ? yHead < ySecond : yHead > ySecond
185 | resultCondition = xHead === xSecond && condition
186 | break
187 | }
188 | case directions.RIGHT: {
189 | const condition = checkIsSameDirection ? xHead > xSecond : xHead < xSecond
190 | resultCondition = yHead === ySecond && condition
191 | break
192 | }
193 | case directions.DOWN: {
194 | const condition = checkIsSameDirection ? yHead > ySecond : yHead < ySecond
195 | resultCondition = xHead === xSecond && condition
196 | break
197 | }
198 | case directions.LEFT: {
199 | const condition = checkIsSameDirection ? xHead < xSecond : xHead > xSecond
200 | resultCondition = yHead === ySecond && condition
201 | break
202 | }
203 | default:
204 | resultCondition = false
205 | break
206 | }
207 | }
208 |
209 | return resultCondition
210 | }
211 |
212 | function isGameOver(players) {
213 | const checkResult = players.map((player, index) => {
214 | const allNodes = players.map((mapPlayer, mapIndex) => {
215 | if (mapIndex !== index) {
216 | return mapPlayer.trails
217 | }
218 | return mapPlayer.trails.slice(1, mapPlayer.trails.length)
219 | })
220 | const [ xHead, yHead ] = player.trails[0]
221 | const isCrashed = [...allNodes][0].filter((node) => node[0] === xHead && node[1] === yHead).length > 0
222 |
223 | return isCrashed ? index : false
224 | })
225 |
226 | return checkResult.filter(result => result !== false).length > 0
227 | }
228 |
229 | function getRandomInt(min, max) {
230 | min = Math.ceil(min)
231 | max = Math.floor(max)
232 | return Math.floor(Math.random() * (max - min)) + min
233 | }
234 |
235 | function fmtPosition(position, unit) {
236 | return position - (position % unit)
237 | }
--------------------------------------------------------------------------------