├── .babelrc ├── .eslintrc.yaml ├── .github └── workflows │ └── cd.yml ├── .gitignore ├── LICENSE ├── README.md ├── index.html ├── package.json ├── postcss.config.js ├── src ├── images │ ├── arrow-hero.png │ ├── arrow-hero.svg │ ├── inkys-pandemonium-banner.jpg │ └── key.svg ├── index.js ├── inline │ └── arrow.svg ├── js │ ├── app.js │ ├── currentYear.js │ └── icons.js └── scss │ ├── _animations.scss │ ├── _layout.scss │ ├── _meyer-reset.scss │ ├── _mixins.scss │ ├── _responsive.scss │ ├── _variables.scss │ └── main.scss ├── webpack.config.babel.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["@babel/syntax-dynamic-import"], 3 | "presets": [ 4 | [ 5 | "@babel/preset-env", 6 | { 7 | "modules": false 8 | } 9 | ] 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.eslintrc.yaml: -------------------------------------------------------------------------------- 1 | extends: 2 | - recommended/esnext 3 | - recommended/esnext/style-guide 4 | - recommended/node 5 | - recommended/node/style-guide 6 | env: 7 | browser: true -------------------------------------------------------------------------------- /.github/workflows/cd.yml: -------------------------------------------------------------------------------- 1 | name: cd 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | 7 | jobs: 8 | cd: 9 | runs-on: ${{ matrix.os }} 10 | 11 | strategy: 12 | matrix: 13 | os: [ ubuntu-latest ] 14 | node: [ 16 ] 15 | 16 | environment: cd 17 | 18 | steps: 19 | - name: Checkout 🛎 20 | uses: actions/checkout@master 21 | 22 | - name: Setup node env 🏗 23 | uses: actions/setup-node@v2.4.0 24 | with: 25 | node-version: ${{ matrix.node }} 26 | check-latest: true 27 | 28 | - name: Get yarn cache directory path 🛠 29 | id: yarn-cache-dir-path 30 | run: echo "::set-output name=dir::$(yarn cache dir)" 31 | 32 | - name: Cache node_modules 📦 33 | uses: actions/cache@v2.1.6 34 | id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) 35 | with: 36 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 37 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 38 | restore-keys: | 39 | ${{ runner.os }}-yarn- 40 | 41 | - name: Install dependencies 👨🏻‍💻 42 | run: yarn 43 | 44 | - name: Generate static website 📸 45 | run: yarn build 46 | 47 | - name: Deploy 🚀 48 | uses: JamesIves/github-pages-deploy-action@4.1.5 49 | with: 50 | token: ${{ secrets.GITHUB_TOKEN }} 51 | branch: gh-pages 52 | folder: dist 53 | clean: true -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Node / Yarn ### 2 | yarn-error.log 3 | node_modules 4 | 5 | ### IntelliJ ### 6 | .idea 7 | *.iml 8 | 9 | ### Sass ### 10 | .sass-cache 11 | *.css.map 12 | 13 | ### Project specifics ### 14 | dist 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AcelisWeaven/arrow-hero/1398af87194a746c96b7f7dd074515e1994e3ed3/LICENSE -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Arrow hero 2 | === 3 | 4 | A minimalist game where your goal is to match your inputs with an unstoppable continuous overwelming flow of arrows. 5 | 6 | Setting up Gulp 7 | --- 8 | 9 | First, clone this repository (you can fork it too): 10 | ```shell 11 | git clone git@github.com:AcelisWeaven/arrow-hero.git 12 | ``` 13 | 14 | Then, we need to install our dependencies: 15 | ```shell 16 | yarn install 17 | ``` 18 | 19 | Finally, let's start the dev server: 20 | ```shell 21 | yarn serve 22 | ``` 23 | 24 | You can also build the project, if you want to release it yourself. 25 | ```shell 26 | yarn build 27 | ``` 28 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Arrow hero 6 | 7 | 8 | 10 | 11 | 12 | 14 | 15 | 16 | 18 | 19 | 20 | 21 | 22 | 23 | 30 | 31 | 32 |
33 |

Arrow hero

34 |

0 points

35 |
36 |
37 |
38 | 39 |
40 |
41 |
42 | 43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 | Press a key to begin 53 |
54 |
55 | Touch a key to begin 56 |
57 |
58 |
59 |
60 |
61 |
62 |
Placeholder message
63 |
64 |
65 |
66 | 67 |

You've made...

68 |

0 points

69 |
70 |

Press Space to restart

71 |

Touch Restart to try again

72 |
73 |
74 |
75 |
76 |
77 |
78 | 79 |

Paused

80 |
81 |

Press Space to resume

82 |

Touch Pause again to resume

83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
Pause
97 |
98 | 101 |
102 | Toggle fullscreen 103 |
104 |
105 | 106 | 109 | 110 |
111 |

About this game

112 |

113 | Arrow hero is a minimalist game where your goal is to match a continuous, unstoppable stream of arrows using your keyboard keys. 115 |

116 |

117 | This means that if you have more than one consecutive 118 | you only have to press once to validate both. 119 |

120 |

121 | You can pause the game by pressing Space.
122 | This game is mobile friendly. 123 |

124 |

Sharing is caring

125 |

If you liked this game, please share it!

126 |

Steal this game

127 |

128 | Feel free to fork me on GitHub. 129 |

130 |
131 | 132 | 133 |
134 | 135 | 136 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "arrow-hero", 3 | "version": "1.0.0", 4 | "author": "Jeremy Graziani ", 5 | "license": "MIT", 6 | "private": true, 7 | "repository": { 8 | "type": "git", 9 | "url": "" 10 | }, 11 | "scripts": { 12 | "build": "webpack --mode=production --node-env=production", 13 | "build:dev": "webpack --mode=development", 14 | "build:prod": "webpack --mode=production --node-env=production", 15 | "watch": "webpack --watch", 16 | "serve": "webpack serve" 17 | }, 18 | "devDependencies": { 19 | "@babel/core": "^7.16.0", 20 | "@babel/preset-env": "^7.16.0", 21 | "@babel/register": "^7.16.0", 22 | "@beyonk/google-fonts-webpack-plugin": "^1.7.0", 23 | "@fortawesome/fontawesome-free": "^5.15.4", 24 | "@fortawesome/fontawesome-svg-core": "^1.2.36", 25 | "@fortawesome/free-solid-svg-icons": "^5.15.4", 26 | "autoprefixer": "^10.4.0", 27 | "babel-loader": "^8.2.3", 28 | "babel-register": "^6.26.0", 29 | "copy-webpack-plugin": "^9.0.1", 30 | "css-loader": "^6.5.1", 31 | "css-minimizer-webpack-plugin": "^3.1.3", 32 | "eslint": "^8.2.0", 33 | "eslint-config-recommended": "^4.1.0", 34 | "eslint-plugin-babel": "^5.3.1", 35 | "eslint-plugin-import": "^2.25.2", 36 | "eslint-webpack-plugin": "^3.1.0", 37 | "favicons": "^6.2.2", 38 | "favicons-webpack-plugin": "^5.0.2", 39 | "fontawesome": "4.5", 40 | "html-webpack-plugin": "^5.5.0", 41 | "image-minimizer-webpack-plugin": "^2.2.0", 42 | "imagemin-jpegtran": "^7.0.0", 43 | "imagemin-optipng": "^8.0.0", 44 | "jpegtran": "^2.0.0", 45 | "mini-css-extract-plugin": "^2.4.4", 46 | "mini-svg-data-uri": "^1.4.3", 47 | "postcss-loader": "^6.2.0", 48 | "prettier": "^2.4.1", 49 | "sass": "^1.43.4", 50 | "sass-loader": "^12.3.0", 51 | "style-loader": "^3.3.1", 52 | "terser-webpack-plugin": "^5.2.5", 53 | "uglify-js": "^3.14.3", 54 | "webpack": "^5.62.2", 55 | "webpack-cli": "^4.9.1", 56 | "webpack-dev-server": "^4.4.0" 57 | }, 58 | "description": "Arrow Hero" 59 | } 60 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | require('autoprefixer'), 4 | ], 5 | } -------------------------------------------------------------------------------- /src/images/arrow-hero.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AcelisWeaven/arrow-hero/1398af87194a746c96b7f7dd074515e1994e3ed3/src/images/arrow-hero.png -------------------------------------------------------------------------------- /src/images/arrow-hero.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/images/inkys-pandemonium-banner.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AcelisWeaven/arrow-hero/1398af87194a746c96b7f7dd074515e1994e3ed3/src/images/inkys-pandemonium-banner.jpg -------------------------------------------------------------------------------- /src/images/key.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import './scss/main.scss' 2 | import './js/app' 3 | import './js/icons' 4 | import './js/currentYear' 5 | -------------------------------------------------------------------------------- /src/inline/arrow.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/js/app.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import svg from '../images/key.svg' 4 | 5 | const keySvg = atob(svg.split(',')[1]) 6 | const keyDomItem = new DOMParser().parseFromString(keySvg, 'image/svg+xml') 7 | 8 | function addMultipleEventListener (element, events, handler) { 9 | events.forEach(e => element.addEventListener(e, handler)) 10 | } 11 | 12 | document.addEventListener('DOMContentLoaded', () => { 13 | let points = 0 14 | const pointContainers = document.querySelectorAll('.points') 15 | let keypressed = 'key-' 16 | const container = document.querySelector('.keys-container') 17 | const fullscreenContainer = document.querySelector('.fullscreen-container') 18 | const globalContainer = document.querySelector('.container') 19 | const square = document.querySelector('.key-selector') 20 | // array of objects: {score, speed, message, points} 21 | const speeds = [ 22 | { 23 | score: 0, 24 | speed: 800, 25 | message: '', 26 | points: 1, 27 | keys: 1, 28 | }, 29 | { 30 | score: 3, 31 | speed: 750, 32 | message: "You've got it!", 33 | points: 2, 34 | keys: 2, 35 | }, 36 | { 37 | score: 20, 38 | speed: 670, 39 | message: 'Keep going!', 40 | points: 5, 41 | keys: 3, 42 | }, 43 | { 44 | score: 70, 45 | speed: 620, 46 | message: "You're doing great!", 47 | points: 7, 48 | keys: 3, 49 | }, 50 | { 51 | score: 150, 52 | speed: 560, 53 | message: 'You rock!', 54 | points: 10, 55 | keys: 3, 56 | }, 57 | { 58 | score: 300, 59 | speed: 510, 60 | message: "Don't stop!", 61 | points: 12, 62 | keys: 4, 63 | }, 64 | { 65 | score: 500, 66 | speed: 490, 67 | message: 'Tricky!', 68 | points: 15, 69 | keys: 4, 70 | }, 71 | { 72 | score: 760, 73 | speed: 465, 74 | message: 'Great!', 75 | points: 17, 76 | keys: 4, 77 | }, 78 | { 79 | score: 1100, 80 | speed: 440, 81 | message: 'I like your style!', 82 | points: 20, 83 | keys: 4, 84 | }, 85 | { 86 | score: 1500, 87 | speed: 390, 88 | message: 'Awesome!', 89 | points: 22, 90 | keys: 4, 91 | }, 92 | { 93 | score: 2000, 94 | speed: 360, 95 | message: 'Yeah!!', 96 | points: 25, 97 | keys: 4, 98 | }, 99 | { 100 | score: 2700, 101 | speed: 330, 102 | message: 'How do you do that?', 103 | points: 27, 104 | keys: 4, 105 | }, 106 | { 107 | score: 3500, 108 | speed: 310, 109 | message: '...how?', 110 | points: 30, 111 | keys: 4, 112 | }, 113 | { 114 | score: 4300, 115 | speed: 290, 116 | message: 'Don\'t ever stop!!', 117 | points: 32, 118 | keys: 4, 119 | }, 120 | { 121 | score: 5500, 122 | speed: 280, 123 | message: 'I\'m really impressed.', 124 | points: 35, 125 | keys: 4, 126 | }, 127 | { 128 | score: 7000, 129 | speed: 270, 130 | message: 'Arrow hero!', 131 | points: 40, 132 | keys: 4, 133 | }, 134 | { 135 | score: 10000, 136 | speed: 260, 137 | message: 'You\'re really still here?', 138 | points: 40, 139 | keys: 4, 140 | }, 141 | { 142 | score: 10500, 143 | speed: 250, 144 | message: "That's incredible!", 145 | points: 40, 146 | keys: 4, 147 | }, 148 | ] 149 | let current = speeds[0] 150 | let gameState = false 151 | let maxLife = 5000 152 | let currentLife = maxLife 153 | // array of objects: {delay, started, interval} 154 | let scheduledSpawns = [] 155 | let bestScore = localStorage.getItem('bestScore') 156 | const mobileControls = document.querySelector('.mobile-controls') 157 | 158 | // initialize helpers 159 | const bottom = document.querySelector('.bottom') 160 | const bottomKeys = [ 'left', 'up', 'right', 'down' ] 161 | bottomKeys.forEach(k => 162 | bottom.querySelector('.key-' + k).appendChild(keyDomItem.childNodes[0].cloneNode(true))) 163 | 164 | function updatePoints (pts) { 165 | if (pts < 1) 166 | pts = 1 167 | 168 | pts = Math.floor(pts) 169 | points += pts 170 | square.classList.add('bump') 171 | square.onanimationend = () => { 172 | square.classList.remove('bump') 173 | } 174 | 175 | pointContainers.forEach(pointContainer => { 176 | pointContainer.textContent = points 177 | pointContainer.classList.add('bump') 178 | pointContainer.onanimationend = () => { 179 | pointContainer.classList.remove('bump') 180 | } 181 | }) 182 | 183 | const ding = document.createElement('div') 184 | ding.classList.add('ding') 185 | ding.textContent = `+${ pts }` 186 | ding.onanimationend = () => { 187 | ding.remove() 188 | } 189 | 190 | square.appendChild(ding) 191 | } 192 | 193 | const levelMessage = document.querySelector('.level-message') 194 | 195 | function updateSpeed () { 196 | const oldSpeed = current 197 | for (const i in speeds) { 198 | const _speed = speeds[i] 199 | if (points >= _speed.score) 200 | current = _speed 201 | else if (points < _speed.score) 202 | break 203 | 204 | } 205 | 206 | if (current.speed !== oldSpeed.speed) { 207 | // Speed changed ! 208 | levelMessage.textContent = current.message 209 | levelMessage.classList.add('show') 210 | levelMessage.onanimationend = () => { 211 | levelMessage.classList.remove('show') 212 | } 213 | } 214 | 215 | 216 | } 217 | 218 | function spawnRandomKey (obj) { 219 | if (gameState !== 'paused') // running or ended 220 | removeScheduledSpawn(obj) 221 | 222 | if (gameState === 'end' || gameState === 'paused' || gameState === 'restart') 223 | return 224 | 225 | const arr = [ 'key-right', 'key-left', 'key-down', 'key-up' ] 226 | const direction = arr[Math.floor(Math.random() * current.keys)] 227 | let nextKey = container.querySelector('.idle') 228 | if (nextKey === null) { 229 | nextKey = document.createElement('div') 230 | nextKey.appendChild(keyDomItem.childNodes[0].cloneNode(true)) 231 | nextKey.classList.add('key', direction) 232 | nextKey.onanimationend = () => { 233 | 234 | if (gameState === 'end' || gameState === 'restart' || nextKey.classList.contains('idle')) 235 | return 236 | 237 | if (nextKey.classList.contains(keypressed)) { 238 | currentLife = Math.min(currentLife + 200, maxLife) 239 | 240 | updatePoints(current.points) 241 | } else { 242 | currentLife -= 1000 243 | square.classList.add('bad') 244 | square.onanimationend = () => { 245 | square.classList.remove('bad') 246 | } 247 | } 248 | 249 | updateSpeed() 250 | 251 | const percent = currentLife * 100 / maxLife 252 | const percentElem = document.querySelector('.percent') 253 | percentElem.style.width = percent + '%' 254 | 255 | if (percent < 20) 256 | percentElem.classList.add('low') 257 | else if (percent < 60) { 258 | percentElem.classList.add('medium') 259 | percentElem.classList.remove('low') 260 | } else 261 | percentElem.classList.remove('low', 'medium') 262 | 263 | if (currentLife <= 0 && gameState === 'running') 264 | endGame() 265 | 266 | nextKey.classList.add('idle') 267 | nextKey.classList.remove('key-up', 'key-down', 'key-left', 'key-right') 268 | } 269 | container.appendChild(nextKey) 270 | } else { 271 | nextKey.classList.remove('idle') 272 | nextKey.classList.add(direction) 273 | } 274 | 275 | 276 | // Spawn next key 277 | scheduleSpawn(current.speed) 278 | } 279 | 280 | function scheduleSpawn (delay) { 281 | const now = new Date() 282 | const obj = { 283 | delay, 284 | started: now.getTime(), 285 | } 286 | obj.interval = setTimeout(spawnRandomKey, delay, obj) 287 | scheduledSpawns.push(obj) 288 | } 289 | 290 | function removeScheduledSpawn (obj) { 291 | const index = scheduledSpawns.indexOf(obj) 292 | if (index > -1) 293 | scheduledSpawns.splice(index, 1) 294 | 295 | } 296 | 297 | function pauseScheduledSpawns () { 298 | const now = new Date() 299 | for (const i in scheduledSpawns) { 300 | const obj = scheduledSpawns[i] 301 | obj.delay -= now.getTime() - obj.started 302 | obj.started = null 303 | clearInterval(obj.interval) 304 | } 305 | } 306 | 307 | function resumeScheduledSpawns () { 308 | const now = new Date() 309 | for (const i in scheduledSpawns) { 310 | const obj = scheduledSpawns[i] 311 | obj.started = now.getTime() 312 | obj.interval = setTimeout(spawnRandomKey, obj.delay, obj) 313 | } 314 | } 315 | 316 | function endGame () { 317 | gameState = 'end' 318 | document.querySelectorAll('.key').forEach(k => k.classList.add('hide')) 319 | 320 | const keySelectorContainer = document.querySelector('.key-selector-container') 321 | keySelectorContainer.classList.add('hide') 322 | keySelectorContainer.classList.remove('show') 323 | 324 | const results = document.querySelector('.results') 325 | results.classList.add('show') 326 | results.classList.remove('hide') 327 | 328 | const pointsContainer = document.querySelector('.points-container') 329 | pointsContainer.classList.add('hide') 330 | pointsContainer.classList.remove('show') 331 | 332 | const percent = document.querySelector('.percent') 333 | percent.style.width = '0%' 334 | 335 | document.querySelector('.pause-btn').textContent = 'Restart' 336 | 337 | if (points > bestScore) { 338 | // update best score 339 | bestScore = points 340 | localStorage.setItem('bestScore', bestScore) 341 | document.querySelector('.best-points .value').textContent = bestScore 342 | document.querySelector('.best').style.display = 'block' 343 | } 344 | } 345 | 346 | function restartGame () { 347 | gameState = 'restart' 348 | points = 0 349 | maxLife = 5000 350 | currentLife = maxLife 351 | current = speeds[0] 352 | 353 | if (keypressed !== '') 354 | square.classList.remove('s-' + keypressed) 355 | 356 | keypressed = '' 357 | 358 | container.querySelectorAll('.key').forEach(key => { 359 | key.classList.remove('key-up', 'key-down', 'key-left', 'key-right', 'hide') 360 | key.classList.add('idle') 361 | }) 362 | 363 | const keySelectorContainer = document.querySelector('.key-selector-container') 364 | keySelectorContainer.classList.add('show') 365 | keySelectorContainer.classList.remove('hide') 366 | 367 | const results = document.querySelector('.results') 368 | results.classList.add('hide') 369 | results.classList.remove('show') 370 | 371 | setTimeout(() => { 372 | const pointsContainer = document.querySelector('.points-container') 373 | pointsContainer.classList.add('show') 374 | pointsContainer.classList.remove('hide') 375 | 376 | for (const i in scheduledSpawns) { 377 | const obj = scheduledSpawns[i] 378 | clearInterval(obj.interval) 379 | } 380 | scheduledSpawns = [] 381 | pointContainers.forEach(pointContainer => pointContainer.textContent = points) 382 | 383 | const percent = document.querySelector('.percent') 384 | percent.style.width = '100%' 385 | percent.classList.remove('low', 'medium') 386 | 387 | setTimeout(() => { 388 | gameState = 'running' 389 | document.querySelector('.pause-btn').textContent = 'Pause' 390 | scheduleSpawn(1) 391 | }, 950) 392 | }, 1000) 393 | } 394 | 395 | function toggleFullscreen () { 396 | const isFullscreen = document.fullscreenElement !== null 397 | 398 | if (!isFullscreen) { 399 | if (fullscreenContainer.requestFullscreen) 400 | fullscreenContainer.requestFullscreen() 401 | } else 402 | if (document.exitFullscreen) 403 | document.exitFullscreen() 404 | } 405 | 406 | function updateScaleFactor () { 407 | // Original size is $size in _variables.scss 408 | const size = 390 409 | const height = globalContainer.offsetHeight 410 | 411 | document.documentElement.style.setProperty('--scale-factor', height / size) 412 | } 413 | 414 | document.onfullscreenchange = () => { 415 | const isFullscreen = document.fullscreenElement !== null 416 | 417 | if (!isFullscreen) 418 | fullscreenContainer.classList.remove('is-fullscreen') 419 | else 420 | fullscreenContainer.classList.add('is-fullscreen') 421 | 422 | // timeout is needed, so browser can update the size of the container properly 423 | setTimeout(updateScaleFactor, 100) 424 | } 425 | 426 | window.onresize = updateScaleFactor 427 | 428 | document.body.onblur = () => { 429 | if (gameState === 'running') 430 | // auto pause 431 | document.dispatchEvent(new KeyboardEvent('keydown', { keyCode: 32 /* space */ })) 432 | } 433 | 434 | document.onkeydown = e => { 435 | 436 | if (e.key === 'F11') { 437 | toggleFullscreen() 438 | e.preventDefault() 439 | } 440 | 441 | 442 | if (e.keyCode === 32) { 443 | e.preventDefault() 444 | if (gameState === 'running' || gameState === 'paused') { 445 | // space bar pressed 446 | 447 | gameState = gameState === 'running' ? 'paused' : 'running' 448 | document.querySelectorAll('.key').forEach(k => k.classList.toggle('paused', gameState === 'paused')) 449 | document.querySelector('.pause').classList.toggle('show', gameState === 'paused') 450 | 451 | if (gameState === 'paused') 452 | pauseScheduledSpawns() 453 | else if (gameState === 'running') 454 | resumeScheduledSpawns() 455 | 456 | } else if (gameState === 'end') 457 | restartGame() 458 | 459 | } 460 | 461 | if ((e.keyCode >= 37 && e.keyCode <= 40 || e.keyCode >= 72 && e.keyCode <= 76) 462 | && gameState !== 'paused' && gameState !== 'restart') { 463 | // arrow keys pressed 464 | 465 | e.preventDefault() 466 | if (gameState === false) { 467 | startGame() 468 | return false 469 | } 470 | 471 | if (keypressed !== '') 472 | square.classList.remove('s-' + keypressed) 473 | 474 | switch (e.keyCode) { 475 | case 37: // left 476 | case 72: // h 477 | keypressed = 'key-left' 478 | break 479 | 480 | case 38: // up 481 | case 75: // j 482 | keypressed = 'key-up' 483 | break 484 | 485 | case 39: // right 486 | case 76: // l 487 | keypressed = 'key-right' 488 | break 489 | 490 | case 40: // down 491 | case 74: // j 492 | keypressed = 'key-down' 493 | break 494 | } 495 | square.classList.add('s-' + keypressed) 496 | } 497 | 498 | } 499 | 500 | function startGame () { 501 | gameState = 'running' 502 | 503 | document.querySelector('.points-container').classList.add('show') 504 | document.querySelector('.helper-container').classList.add('hide') 505 | setTimeout(() => { 506 | const keySelector = document.querySelector('.key-selector') 507 | keySelector.classList.add('show', 'fade') 508 | keySelector.onanimationend = () => keySelector.classList.remove('fade') 509 | }, 500) 510 | scheduleSpawn(1000) 511 | 512 | document.querySelector('.percent').style.width = '100%' 513 | } 514 | 515 | if (bestScore) { 516 | document.querySelector('.best-points .value').textContent = bestScore 517 | document.querySelector('.best').style.display = 'block' 518 | } 519 | 520 | 521 | function addMobileListener (selector, /* @deprecated */ keyCode) { 522 | const keyElem = mobileControls.querySelector(selector) 523 | addMultipleEventListener(keyElem, [ 'touchstart', 'click' ], () => { 524 | document.dispatchEvent(new KeyboardEvent('keydown', { keyCode })) 525 | }) 526 | } 527 | 528 | addMobileListener('.key-left', 37) 529 | addMobileListener('.key-up', 38) 530 | addMobileListener('.key-right', 39) 531 | addMobileListener('.key-down', 40) 532 | 533 | addMultipleEventListener(mobileControls.querySelector('.pause-btn'), [ 'click', 'touchstart' ], e => { 534 | e.preventDefault() 535 | document.dispatchEvent(new KeyboardEvent('keydown', { keyCode: 32 /* space */ })) 536 | }) 537 | 538 | // If URL contains ?fullscreen, start in pseudo-fullscreen 539 | if (window.location.search.includes('fullscreen')) 540 | fullscreenContainer.classList.add('is-fullscreen') 541 | 542 | document.getElementById('toggle-fullscreen').addEventListener('click', toggleFullscreen) 543 | 544 | updateScaleFactor() 545 | }) 546 | -------------------------------------------------------------------------------- /src/js/currentYear.js: -------------------------------------------------------------------------------- 1 | document.querySelector('[data-current-year]').textContent = new Date().getFullYear() 2 | -------------------------------------------------------------------------------- /src/js/icons.js: -------------------------------------------------------------------------------- 1 | import { dom, library } from '@fortawesome/fontawesome-svg-core' 2 | import { 3 | faCheckCircle, 4 | faHeart, 5 | faInfoCircle, 6 | } from '@fortawesome/free-solid-svg-icons' 7 | 8 | library.add({ 9 | faCheckCircle, 10 | faInfoCircle, 11 | faHeart, 12 | }) 13 | 14 | dom.watch() -------------------------------------------------------------------------------- /src/scss/_animations.scss: -------------------------------------------------------------------------------- 1 | @keyframes fadeout { 2 | from { 3 | opacity: 1; 4 | } 5 | to { 6 | transform: scale(calc(0.5 * var(--scale-factor))); 7 | opacity: 0; 8 | } 9 | } 10 | 11 | @keyframes fadein { 12 | from { 13 | transform: scale(calc(1.5 * var(--scale-factor))); 14 | opacity: 0; 15 | } 16 | to { 17 | opacity: 1; 18 | } 19 | } 20 | 21 | @keyframes reversedfadein { 22 | 0% { 23 | opacity: 0; 24 | } 25 | 50% { 26 | opacity: 1; 27 | } 28 | 100% { 29 | transform: scale(calc(1.2 * var(--scale-factor))); 30 | opacity: 0; 31 | } 32 | } 33 | 34 | @keyframes lowfadeinwithdelay { 35 | 0% { 36 | opacity: 0; 37 | } 38 | 40% { 39 | transform: scale(calc(0.7 * var(--scale-factor))); 40 | opacity: 0; 41 | } 42 | 100% { 43 | opacity: 1; 44 | } 45 | } 46 | 47 | @keyframes orbit { 48 | 0% { 49 | opacity: 0; 50 | transform: translateY($orbit_radius) rotate(0deg) translateY(-$orbit_radius) rotate(0deg) scale(1); 51 | } 52 | 5% { 53 | opacity: 0; 54 | transform: translateY($orbit_radius) rotate(#{-360*0.05}deg) translateY(-$orbit_radius) rotate(#{360*0.05}deg) scale(1); 55 | } 56 | 20% { 57 | opacity: 1; 58 | transform: translateY($orbit_radius) rotate(#{-360*0.2}deg) translateY(-$orbit_radius) rotate(#{360*0.2}deg) scale(1); 59 | } 60 | 96% { 61 | opacity: 1; 62 | transform: translateY($orbit_radius) rotate(-360deg) translateY(-$orbit_radius) rotate(360deg) scale(1); 63 | } 64 | 100% { 65 | opacity: 0.5; 66 | transform: translateY($orbit_radius) rotate(-360deg) translateY(-$orbit_radius) rotate(360deg) scale(0.3); 67 | } 68 | } 69 | 70 | @keyframes mobile-orbit { 71 | 0% { 72 | transform: translateY($orbit_radius * 0.6) rotate(0deg) translateY(-$orbit_radius * 0.6) rotate(0deg) scale(0.1); 73 | } 74 | 20% { 75 | transform: translateY($orbit_radius * 0.6) rotate(-72deg) translateY(-$orbit_radius * 0.6) rotate(72deg) scale(1); 76 | } 77 | 96% { 78 | transform: translateY($orbit_radius * 0.6) rotate(-359deg) translateY(-$orbit_radius * 0.6) rotate(359deg) scale(1); 79 | } 80 | 100% { 81 | transform: translateY($orbit_radius * 0.6) rotate(-359deg) translateY(-$orbit_radius * 0.6) rotate(359deg) scale(0.3); 82 | } 83 | } 84 | 85 | @keyframes lowbump { 86 | 50% { 87 | transform: scale(1.1); 88 | } 89 | 90 | 100% { 91 | transform: scale(1); 92 | } 93 | } 94 | 95 | 96 | @keyframes bump { 97 | 50% { 98 | transform: scale(2); 99 | } 100 | 101 | 100% { 102 | transform: scale(1); 103 | } 104 | } 105 | 106 | @keyframes selector-bump { 107 | 20% { 108 | transform: scale(1.2); 109 | } 110 | 111 | 100% { 112 | transform: scale(1); 113 | } 114 | } 115 | 116 | @keyframes selector-bad { 117 | 0% { 118 | background-color: rgba(255, 0, 0, 0); 119 | } 120 | 50% { 121 | background-color: rgba(255, 0, 0, 0.3); 122 | transform: scale(0.95); 123 | } 124 | 125 | 100% { 126 | background-color: rgba(255, 0, 0, 0); 127 | transform: scale(1); 128 | } 129 | } 130 | 131 | @keyframes mobile-selector-bad { 132 | 0% { 133 | background-color: rgba(255, 0, 0, 0); 134 | } 135 | 136 | 50% { 137 | background-color: rgba(255, 0, 0, 0.3); 138 | } 139 | 140 | 100% { 141 | background-color: rgba(255, 0, 0, 0); 142 | } 143 | } 144 | 145 | @keyframes ding { 146 | 0% { 147 | opacity: 1; 148 | } 149 | 60% { 150 | opacity: 1; 151 | } 152 | 100% { 153 | opacity: 0; 154 | transform: translateY(-30px); 155 | } 156 | } 157 | 158 | @keyframes color-arrow { 159 | 0%, 10% { 160 | color: $up_color; 161 | transform: rotate(0deg) translateY(-5px); 162 | } 163 | 15%, 35% { 164 | color: $right_color; 165 | transform: rotate(90deg) translateY(-5px); 166 | } 167 | 40%, 60% { 168 | color: $down_color; 169 | transform: rotate(180deg) translateY(-5px); 170 | } 171 | 65%, 85% { 172 | color: $left_color; 173 | transform: rotate(270deg) translateY(-5px); 174 | } 175 | 90%, 100% { 176 | color: $up_color; 177 | transform: rotate(359deg) translateY(-5px); 178 | } 179 | } -------------------------------------------------------------------------------- /src/scss/_layout.scss: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: 'Inconsolata', monospace; 3 | text-align: center; 4 | background-color: $background_color; 5 | padding-top: 20px; 6 | color: $text_color; 7 | margin: 0; 8 | overflow-x: hidden; 9 | 10 | h1, h2, h3 { 11 | font-weight: bold; 12 | } 13 | 14 | h1 { 15 | font-size: 16pt; 16 | margin-top: 10px; 17 | margin-bottom: 10px; 18 | } 19 | 20 | h2 { 21 | font-size: 14pt; 22 | margin-top: 23px; 23 | margin-bottom: 13px; 24 | } 25 | 26 | h3 { 27 | font-size: 12pt; 28 | margin-top: 6px; 29 | margin-bottom: 11px; 30 | } 31 | 32 | em { 33 | font-style: italic; 34 | } 35 | 36 | a { 37 | text-decoration: none; 38 | font-weight: bold; 39 | color: $right_color; 40 | } 41 | 42 | strong { 43 | font-weight: bold; 44 | } 45 | 46 | i { 47 | padding-left: 1px; 48 | } 49 | 50 | hr { 51 | border: none; 52 | border-top: 1px solid #8b8b8b; 53 | margin-top: 40px; 54 | } 55 | } 56 | 57 | .mobile { 58 | max-width: $size - 20px; 59 | margin: 0 auto 20px auto; 60 | text-align: left; 61 | border-radius: 5px; 62 | background-color: rgba(black, 0.5); 63 | padding: 15px 10px; 64 | color: white; 65 | 66 | .centered { 67 | text-align: center; 68 | } 69 | 70 | a { 71 | color: white; 72 | } 73 | 74 | p { 75 | padding-bottom: 10px; 76 | } 77 | 78 | ul { 79 | text-align: center; 80 | 81 | li { 82 | padding: 5px 20px; 83 | margin: 5px 0; 84 | display: inline-block; 85 | background-color: rgba(white, 0.1); 86 | } 87 | } 88 | } 89 | 90 | .fullscreen-container.is-fullscreen { 91 | .container { 92 | height: calc(100vh - 150px); 93 | width: calc(100vh - 150px); 94 | max-width: 100vw; 95 | 96 | @media (max-width: 1024px) { 97 | height: calc(100vh - 350px); 98 | } 99 | } 100 | 101 | // scale sub containers to fit the parent, using some magic calculations 102 | .keys-container, 103 | .key-selector-container, 104 | .level-message, 105 | { 106 | transform: scale(var(--scale-factor)); 107 | } 108 | } 109 | 110 | .container { 111 | max-width: $size; 112 | background-color: $board_color; 113 | border-radius: 5px; 114 | height: $size; 115 | position: relative; 116 | margin: 0 auto 10px auto; 117 | padding: 0; 118 | user-select: text; 119 | 120 | .keys-container { 121 | position: absolute; 122 | top: 0; 123 | left: 0; 124 | right: 0; 125 | 126 | svg { 127 | shape-rendering: optimizeSpeed; 128 | } 129 | } 130 | 131 | .key-selector-container { 132 | padding: $padding; 133 | 134 | &.hide { 135 | animation: fadeout 1s 1; 136 | opacity: 0; 137 | } 138 | 139 | &.show { 140 | animation: fadein 1s 1; 141 | opacity: 1; 142 | } 143 | 144 | .key-selector { 145 | position: absolute; 146 | margin: 0 auto; 147 | left: 0; 148 | right: 0; 149 | width: $selector_size; 150 | border-radius: 999px; 151 | height: $selector_size; 152 | opacity: 0; 153 | 154 | border-width: $selector_border_size; 155 | border-style: solid; 156 | border-left-color: $left_color; 157 | border-top-color: $up_color; 158 | border-right-color: $right_color; 159 | border-bottom-color: $down_color; 160 | 161 | &.show { 162 | opacity: 1; 163 | } 164 | 165 | &.fade { 166 | animation: fadein 1s; 167 | transform-origin: 50% 0; 168 | } 169 | 170 | &.bump { 171 | animation: selector-bump 0.25s 1 ease-in-out; 172 | animation-delay: 50ms; 173 | } 174 | 175 | &.bad { 176 | animation: selector-bad 0.15s 1 ease-out; 177 | } 178 | 179 | &::after { 180 | margin: 5px; 181 | border-radius: 999px !important; 182 | opacity: 0.8; 183 | } 184 | 185 | &.s-key-left::after { 186 | @include key("left"); 187 | border: none; 188 | } 189 | 190 | &.s-key-up::after { 191 | @include key("up"); 192 | border: none; 193 | } 194 | 195 | &.s-key-right::after { 196 | @include key("right"); 197 | border: none; 198 | } 199 | 200 | &.s-key-down::after { 201 | @include key("down"); 202 | border: none; 203 | } 204 | 205 | .ding { 206 | position: absolute; 207 | top: 0; 208 | left: 0; 209 | right: 0; 210 | margin: 0 auto; 211 | font-family: 'Inconsolata', monospace; 212 | font-size: 15pt; 213 | font-weight: bolder; 214 | animation: ding 0.5s 1 ease-out; 215 | opacity: 0; 216 | color: $text_color; 217 | } 218 | } 219 | } 220 | 221 | .img-logo { 222 | width: 200px; 223 | height: 200px; 224 | margin: 0 auto 20px auto; 225 | } 226 | 227 | .key { 228 | $scale_adjustment: 1.15; 229 | position: absolute; 230 | margin: (($selector_size - $key_size) * 0.5) auto; 231 | left: 0; 232 | right: 0; 233 | width: $key_size; 234 | height: $key_size; 235 | animation: orbit 2s 1 linear; 236 | top: $padding + $selector_border_size; 237 | 238 | &.paused { 239 | animation-play-state: paused; 240 | } 241 | 242 | &.idle { 243 | animation: none; 244 | display: none; 245 | } 246 | 247 | svg { 248 | transition: opacity 1s; 249 | } 250 | 251 | &.hide svg { 252 | opacity: 0; 253 | } 254 | 255 | .key-color-stroke { 256 | fill: rgba(black, 0.7); 257 | stroke: currentColor; 258 | } 259 | 260 | .key-color { 261 | fill: currentColor; 262 | } 263 | 264 | &.key-left { 265 | color: $left_color; 266 | 267 | svg { 268 | transform: rotate(-90deg) scale($scale_adjustment); 269 | } 270 | } 271 | 272 | &.key-up { 273 | color: $up_color; 274 | svg { 275 | transform: scale($scale_adjustment); 276 | } 277 | } 278 | 279 | &.key-right { 280 | color: $right_color; 281 | 282 | svg { 283 | transform: rotate(90deg) scale($scale_adjustment); 284 | } 285 | } 286 | 287 | &.key-down { 288 | color: $down_color; 289 | 290 | svg { 291 | transform: rotate(180deg) scale($scale_adjustment); 292 | } 293 | } 294 | } 295 | 296 | .level-message { 297 | display: none; 298 | position: absolute; 299 | left: 0; 300 | top: 0; 301 | line-height: calc($size * var(--scale-factor)); 302 | width: 100%; 303 | height: 100%; 304 | font-size: 17px; 305 | 306 | &.show { 307 | display: block; 308 | animation: reversedfadein 1s; 309 | } 310 | } 311 | 312 | .helper-container { 313 | position: absolute; 314 | width: 100%; 315 | height: 100%; 316 | top: 0; 317 | left: 0; 318 | bottom: 0; 319 | 320 | &.hide { 321 | animation: fadeout 1s; 322 | opacity: 0; 323 | } 324 | 325 | .top { 326 | text-align: left; 327 | } 328 | 329 | .bottom { 330 | position: absolute; 331 | bottom: (2 * $key_size + $helper_spacing*5); 332 | left: 0; 333 | right: 0; 334 | max-width: $size; 335 | margin: 0 auto; 336 | 337 | .text { 338 | position: absolute; 339 | left: 0; 340 | right: 0; 341 | top: $key_size * 2 + $helper_spacing * 2; 342 | max-width: $size*0.75; 343 | margin: 0 auto; 344 | font-size: 14pt; 345 | color: rgba($text_color, 0.7); 346 | } 347 | 348 | .key { 349 | top: 0; 350 | opacity: 1; 351 | animation: lowbump 0.8s infinite ease-out; 352 | 353 | &.key-left { 354 | top: $key_size + $helper_spacing; 355 | right: $key_size + $helper_spacing * 6; 356 | } 357 | 358 | &.key-up { 359 | top: $helper_spacing; 360 | } 361 | 362 | &.key-down { 363 | top: $key_size + $helper_spacing; 364 | } 365 | 366 | &.key-right { 367 | top: $key_size + $helper_spacing; 368 | right: 0; 369 | left: $key_size + $helper_spacing * 6; 370 | } 371 | } 372 | } 373 | } 374 | 375 | .results { 376 | position: absolute; 377 | display: none; 378 | top: 0; 379 | left: 0; 380 | margin: 0; 381 | padding: 0; 382 | width: 100%; 383 | height: 100%; 384 | background-color: $board_color; 385 | border-radius: 5px; 386 | opacity: 0; 387 | 388 | &.show { 389 | display: table; 390 | animation: lowfadeinwithdelay 1.8s ease-in-out; 391 | opacity: 1; 392 | } 393 | 394 | &.hide { 395 | display: table; 396 | animation: fadeout 0.8s ease-in-out; 397 | opacity: 0; 398 | } 399 | 400 | .results-container { 401 | display: table-cell; 402 | vertical-align: middle; 403 | 404 | .results-content { 405 | margin-left: auto; 406 | margin-right: auto; 407 | 408 | p { 409 | animation: lowbump 0.8s infinite ease-out; 410 | } 411 | } 412 | } 413 | } 414 | 415 | .pause { 416 | position: absolute; 417 | display: none; 418 | top: 0; 419 | left: 0; 420 | width: 100%; 421 | height: 100%; 422 | background-color: $board_color; 423 | border-radius: 5px; 424 | overflow: hidden; 425 | 426 | &.show { 427 | display: table; 428 | } 429 | 430 | .pause-container { 431 | display: table-cell; 432 | vertical-align: middle; 433 | 434 | .pause-content { 435 | margin-left: auto; 436 | margin-right: auto; 437 | 438 | p { 439 | animation: lowbump 0.8s infinite ease-out; 440 | } 441 | } 442 | } 443 | } 444 | 445 | .total { 446 | position: absolute; 447 | max-width: $size; 448 | border-radius: 0 0 5px 5px; 449 | margin: 0 auto; 450 | left: 0; 451 | right: 0; 452 | bottom: 0; 453 | 454 | .percent { 455 | height: 6px; 456 | margin: 0 auto; 457 | transition: width 0.2s ease-in-out, background-color 0.2s linear; 458 | background-color: rgb(0, 219, 0); 459 | border-radius: 5px; 460 | 461 | &.medium { 462 | background-color: rgb(255, 208, 0); 463 | } 464 | 465 | &.low { 466 | background-color: rgb(255, 57, 45); 467 | } 468 | } 469 | } 470 | } 471 | 472 | .points-container { 473 | max-width: $size + 2*$padding; 474 | text-align: center; 475 | font-family: 'Inconsolata', monospace; 476 | margin: 0 auto 20px auto; 477 | opacity: 0; 478 | 479 | .points { 480 | display: inline-block; 481 | 482 | &.bump { 483 | animation: selector-bump 0.3s 1 ease-in-out; 484 | } 485 | } 486 | 487 | &.hide { 488 | animation: fadeout 0.5s ease-out; 489 | opacity: 0; 490 | } 491 | 492 | &.show { 493 | animation: fadein 0.5s ease-out; 494 | opacity: 1; 495 | } 496 | } 497 | 498 | .best { 499 | max-width: $size; 500 | margin: 0 auto; 501 | text-align: right; 502 | padding-right: 5px; 503 | font-size: 16px; 504 | 505 | .best-points { 506 | font-weight: bold; 507 | } 508 | } 509 | 510 | .fullscreen-btn { 511 | max-width: $size; 512 | margin: 5px auto 0; 513 | text-align: right; 514 | padding-right: 5px; 515 | font-size: 16px; 516 | } 517 | 518 | .banner { 519 | display: inline-block; 520 | margin-top: 30px; 521 | 522 | img { 523 | max-width: 100%; 524 | } 525 | } 526 | 527 | .about { 528 | max-width: $size - 20px; 529 | margin: 0 auto; 530 | text-align: justify; 531 | font-size: 16px; 532 | padding-top: 20px; 533 | 534 | p { 535 | padding-top: 10px; 536 | padding-bottom: 10px; 537 | } 538 | 539 | .prim { 540 | color: $text_color; 541 | font-weight: bold; 542 | } 543 | 544 | .sec { 545 | color: $text_color; 546 | font-weight: bold; 547 | } 548 | 549 | .third { 550 | color: $up_color; 551 | font-weight: bold; 552 | } 553 | 554 | .key-up { 555 | display: inline-block; 556 | 557 | &::after { 558 | @include key("up", 21px, 2px, 0); 559 | position: relative; 560 | } 561 | } 562 | 563 | .tiny { 564 | font-size: 12px; 565 | } 566 | 567 | .share { 568 | padding-bottom: 20px; 569 | padding-top: 10px; 570 | text-align: center; 571 | min-height: 30px; 572 | 573 | &.big { 574 | min-height: 100px; 575 | } 576 | 577 | .reddit-share-button { 578 | top: -3px; 579 | vertical-align: middle; 580 | padding-bottom: 6px; 581 | } 582 | 583 | .fb-share-button { 584 | top: -3px; 585 | } 586 | 587 | .reddit-share-button { 588 | vertical-align: top; 589 | } 590 | } 591 | 592 | .footer { 593 | text-align: right; 594 | } 595 | } 596 | 597 | .mobile-controls { 598 | position: relative; 599 | display: none; 600 | max-width: $size; 601 | margin: 0 auto; 602 | user-select: none; 603 | 604 | .top { 605 | text-align: center; 606 | padding-bottom: 10px; 607 | 608 | .key-up { 609 | display: inline-block; 610 | 611 | &::after { 612 | @include key("up", $key_size+15px); 613 | position: relative; 614 | } 615 | } 616 | } 617 | 618 | .bottom { 619 | text-align: center; 620 | padding-bottom: 10px; 621 | 622 | .key-left { 623 | display: inline-block; 624 | 625 | &::after { 626 | @include key("left", $key_size+15px); 627 | position: relative; 628 | } 629 | } 630 | 631 | .key-down { 632 | display: inline-block; 633 | 634 | &::after { 635 | @include key("down", $key_size+15px); 636 | position: relative; 637 | } 638 | } 639 | 640 | .key-right { 641 | display: inline-block; 642 | 643 | &::after { 644 | @include key("right", $key_size+15px); 645 | position: relative; 646 | } 647 | } 648 | } 649 | 650 | .pause-btn { 651 | position: absolute; 652 | top: 0; 653 | right: 0; 654 | background-color: rgba($text_color, 0.1); 655 | padding: 13px 17px; 656 | margin-right: 7px; 657 | border-radius: 5px; 658 | } 659 | } 660 | -------------------------------------------------------------------------------- /src/scss/_meyer-reset.scss: -------------------------------------------------------------------------------- 1 | // http://meyerweb.com/eric/tools/css/reset/ 2 | // v2.0 | 20110126 3 | // License: none (public domain) 4 | 5 | @mixin meyer-reset { 6 | html, body, div, span, applet, object, iframe, h1, h2, h3, h4, h5, h6, p, blockquote, pre, a, abbr, acronym, address, big, cite, code, del, dfn, em, img, ins, kbd, q, s, samp, small, strike, strong, sub, sup, tt, var, b, u, i, center, dl, dt, dd, ol, ul, li, fieldset, form, label, legend, table, caption, tbody, tfoot, thead, tr, th, td, article, aside, canvas, details, embed, figure, figcaption, footer, header, hgroup, menu, nav, output, ruby, section, summary, time, mark, audio, video { 7 | margin: 0; 8 | padding: 0; 9 | border: 0; 10 | font-size: 100%; 11 | font: inherit; 12 | vertical-align: baseline; 13 | } 14 | 15 | // HTML5 display-role reset for older browsers 16 | article, aside, details, figcaption, figure, footer, header, hgroup, menu, nav, section { 17 | display: block; 18 | } 19 | body { 20 | line-height: 1; 21 | } 22 | ol, ul { 23 | list-style: none; 24 | } 25 | blockquote, q { 26 | quotes: none; 27 | } 28 | blockquote { 29 | &:before, &:after { 30 | content: ""; 31 | content: none; 32 | } 33 | } 34 | q { 35 | &:before, &:after { 36 | content: ""; 37 | content: none; 38 | } 39 | } 40 | table { 41 | border-collapse: collapse; 42 | border-spacing: 0; 43 | } 44 | } 45 | 46 | @include meyer-reset; -------------------------------------------------------------------------------- /src/scss/_mixins.scss: -------------------------------------------------------------------------------- 1 | @use "sass:math"; 2 | @import "variables"; 3 | 4 | @mixin key($direction, $_size:$key_size, $_border_size:3px, $_offset:5px) { 5 | position: absolute; 6 | text-align: center; 7 | display: block; 8 | transform-origin: 50% 50%; 9 | line-height: $_size - 2 * $_border_size; 10 | width: $_size - 2 * $_border_size; 11 | height: $_size - 2 * $_border_size; 12 | content: ''; 13 | border-radius: 999px; 14 | border-style: solid; 15 | $color: transparent; 16 | 17 | @if ($direction == "left") { 18 | transform: rotate(-90deg); 19 | $color: $left_color; 20 | padding-right: $_offset; 21 | width: $_size - 2 * $_border_size - $_offset; 22 | } @else if ($direction == "up") { 23 | $color: $up_color; 24 | padding-bottom: $_offset; 25 | line-height: $_size - 2 * $_border_size - $_offset; 26 | height: $_size - 2 * $_border_size - $_offset; 27 | } @else if ($direction == "right") { 28 | transform: rotate(90deg); 29 | $color: $right_color; 30 | padding-left: $_offset; 31 | width: $_size - 2 * $_border_size - $_offset; 32 | } @else if ($direction == "down") { 33 | transform: rotate(180deg); 34 | $color: $down_color; 35 | padding-top: $_offset; 36 | line-height: $_size - 2 * $_border_size - $_offset; 37 | height: $_size - 2 * $_border_size - $_offset; 38 | } 39 | 40 | border-color: $color; 41 | color: $color; 42 | background-image: url("../inline/arrow.svg?color=#{str-slice(unquote("#{$color}"), 2)}"); 43 | background-size: 50%; 44 | background-position: center center; 45 | background-repeat: no-repeat; 46 | } 47 | -------------------------------------------------------------------------------- /src/scss/_responsive.scss: -------------------------------------------------------------------------------- 1 | // Desktop 2 | @media (min-width: 1024px) { 3 | .desktop-only { 4 | display: block; 5 | } 6 | .not-desktop { 7 | display: none; 8 | } 9 | } 10 | 11 | // Tablet 12 | @media (max-width: 1024px) { 13 | .mobile-controls { 14 | display: block; 15 | } 16 | .helper-container { 17 | .key { 18 | display: none; 19 | } 20 | } 21 | .desktop-only { 22 | display: none; 23 | } 24 | .not-desktop { 25 | display: block; 26 | } 27 | 28 | .container { 29 | .img-logo { 30 | height: 80px; 31 | } 32 | } 33 | 34 | } 35 | 36 | // Mobile 37 | @media (max-width: 480px) { 38 | body { 39 | padding-top: 0; 40 | 41 | h1 { 42 | margin-top: 5px; 43 | } 44 | } 45 | 46 | .container { 47 | height: $size * 0.7; 48 | margin-top: 0; 49 | 50 | .key-selector-container { 51 | .key-selector { 52 | &.bump { 53 | animation: none; 54 | } 55 | 56 | &.bad { 57 | animation: mobile-selector-bad 0.15s 1 ease-out; 58 | } 59 | } 60 | } 61 | 62 | .key { 63 | backface-visibility: hidden; 64 | animation: mobile-orbit 2s 1 linear; 65 | opacity: 1; 66 | } 67 | } 68 | 69 | .about { 70 | padding-left: 15px; 71 | padding-right: 15px; 72 | } 73 | } -------------------------------------------------------------------------------- /src/scss/_variables.scss: -------------------------------------------------------------------------------- 1 | $padding: 10px; 2 | $key_size: 60px; 3 | $key_color: white; 4 | $selector_size: $key_size + 4; 5 | $selector_border_size: 5px; 6 | $real_selector_size: $selector_size + 2*$selector_border_size; 7 | $size: $key_size * 6 + $padding * 2 + $selector_border_size * 2; 8 | $orbit_radius: 250%; 9 | $helper_spacing: 10px; 10 | $background_color: #181818; 11 | $text_color: #e1e1e1; 12 | $board_color: black; 13 | $left_color: #48ff3b; 14 | $up_color: #ff5252; 15 | $right_color: #00b2ff; 16 | $down_color: #ffff00; 17 | $logo_border_size: 20px; -------------------------------------------------------------------------------- /src/scss/main.scss: -------------------------------------------------------------------------------- 1 | @import "meyer-reset"; 2 | @import "variables"; 3 | @import "mixins"; 4 | @import "animations"; 5 | @import "layout"; 6 | @import "responsive"; -------------------------------------------------------------------------------- /webpack.config.babel.js: -------------------------------------------------------------------------------- 1 | const CopyWebpackPlugin = require('copy-webpack-plugin') 2 | const CssMinimizerPlugin = require("css-minimizer-webpack-plugin"); 3 | const ESLintPlugin = require('eslint-webpack-plugin') 4 | const FaviconsWebpackPlugin = require('favicons-webpack-plugin') 5 | const GoogleFontsPlugin = require('@beyonk/google-fonts-webpack-plugin') 6 | const HtmlWebpackPlugin = require('html-webpack-plugin') 7 | const ImageMinimizerPlugin = require("image-minimizer-webpack-plugin"); 8 | const MiniCssExtractPlugin = require('mini-css-extract-plugin') 9 | const TerserPlugin = require("terser-webpack-plugin"); 10 | const path = require('path'); 11 | const svgToMiniDataURI = require('mini-svg-data-uri'); 12 | 13 | const isProduction = process.env.NODE_ENV === 'production' 14 | 15 | const config = { 16 | entry: './src/index.js', 17 | mode: isProduction ? 'production' : 'development', 18 | cache: { 19 | type: "filesystem" 20 | }, 21 | output: { 22 | filename: '[name].[contenthash].js', 23 | path: path.resolve(__dirname, 'dist'), 24 | clean: true, 25 | }, 26 | devServer: { 27 | open: true, 28 | host: 'localhost', 29 | }, 30 | plugins: [ 31 | new CopyWebpackPlugin({ 32 | 'patterns': [ 33 | { 34 | from: './src/images', 35 | to: 'images', 36 | } 37 | ] 38 | }), 39 | new ESLintPlugin({ 40 | fix: true, 41 | }), 42 | new HtmlWebpackPlugin({ 43 | hash: true, 44 | inject: true, 45 | template: 'index.html', 46 | }), 47 | new GoogleFontsPlugin({ 48 | apiUrl: 'https://gwfh.mranftl.com/api/fonts', // alternate Google Fonts API, since the default is down 49 | fonts: [ 50 | { 51 | family: 'Inconsolata', 52 | variants: ['400', '700'], 53 | }, 54 | ], 55 | }), 56 | new MiniCssExtractPlugin(), 57 | new ImageMinimizerPlugin({ 58 | minimizerOptions: { 59 | // Lossless optimization 60 | plugins: [ 61 | ['jpegtran', {progressive: true}], 62 | ['optipng', {optimizationLevel: 9}], 63 | ], 64 | }, 65 | // Ignore favicons implicitly (else, Github build time shoots up to 20 minutes) 66 | include: /images/ 67 | }), 68 | new FaviconsWebpackPlugin({ 69 | logo: './src/images/arrow-hero.png', 70 | favicons: { 71 | theme_color: "#ff3232", 72 | logging: true, 73 | pixel_art: true, 74 | } 75 | }) 76 | ], 77 | module: { 78 | rules: [ 79 | { 80 | test: /\.(js|jsx)$/i, 81 | loader: 'babel-loader', 82 | }, 83 | { 84 | test: /\.s[ac]ss$/i, 85 | use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader', 'postcss-loader'], 86 | }, 87 | { 88 | test: /\.svg$/i, 89 | type: 'asset/inline', 90 | include: [ 91 | path.resolve(__dirname, "src/inline") 92 | ], 93 | generator: { 94 | dataUrl: (content, settings) => { 95 | const urlParams = new URLSearchParams(settings.module.resourceResolveData.query); 96 | const color = urlParams.get('color'); 97 | content = content.toString(); 98 | if (color) 99 | content = content.replace("currentColor", '#'+color) 100 | content = svgToMiniDataURI(content); 101 | return content; 102 | } 103 | } 104 | }, 105 | { 106 | test: /\.(eot|svg|ttf|woff|woff2|png|jpe?g|gif)$/i, 107 | type: 'asset', 108 | exclude: [ 109 | path.resolve(__dirname, "src/inline") 110 | ], 111 | }, 112 | 113 | // Add your rules for custom modules here 114 | // Learn more about loaders from https://webpack.js.org/loaders/ 115 | ], 116 | }, 117 | } 118 | 119 | module.exports = () => { 120 | if (isProduction) { 121 | config.mode = 'production' 122 | 123 | config.plugins.push(new MiniCssExtractPlugin({ 124 | filename: "[name].[contenthash].css", 125 | chunkFilename: "[id].[contenthash].css", 126 | })) 127 | config.optimization = { 128 | minimize: true, 129 | minimizer: [ 130 | new TerserPlugin({ 131 | minify: TerserPlugin.uglifyJsMinify, 132 | // `terserOptions` options will be passed to `uglify-js` 133 | // Link to options - https://github.com/mishoo/UglifyJS#minify-options 134 | terserOptions: {}, 135 | }), 136 | new CssMinimizerPlugin(), 137 | ], 138 | splitChunks: { 139 | cacheGroups: { 140 | vendor: { 141 | test: /[\\/]node_modules[\\/]/, 142 | name: 'vendors', 143 | chunks: 'all', 144 | }, 145 | styles: { 146 | name: "styles", 147 | type: "css/mini-extract", 148 | chunks: "all", 149 | enforce: true, 150 | }, 151 | }, 152 | }, 153 | } 154 | } else 155 | config.mode = 'development' 156 | 157 | return config 158 | } 159 | --------------------------------------------------------------------------------