├── 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 |
14 |
15 |
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 | ![demo gif](https://cl.ly/7da1772ebb18/Screen%252520Recording%2525202019-05-08%252520at%25252012.56%252520AM.gif) 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 |
67 | 72 |
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 | } --------------------------------------------------------------------------------