├── .gitignore ├── README.md ├── package-lock.json ├── package.json ├── public ├── favicon.ico ├── index.html ├── logo192.png ├── logo512.png ├── manifest.json └── robots.txt ├── src ├── App.css ├── App.test.tsx ├── App.tsx ├── assets │ └── assets.sketch ├── bg.png ├── collision │ └── collision.ts ├── components │ ├── Button │ │ ├── Button.module.css │ │ └── Button.tsx │ ├── Camera │ │ ├── Camera.module.css │ │ └── Camera.tsx │ ├── Intersectable │ │ ├── Intersectable.module.css │ │ └── Intersectable.tsx │ ├── Maze │ │ └── Maze.tsx │ ├── Player │ │ ├── Player.module.css │ │ └── Player.tsx │ ├── Transform │ │ ├── Rotate.tsx │ │ ├── Transform.module.css │ │ ├── Transform.tsx │ │ └── Translate.tsx │ ├── Wall │ │ ├── Wall.module.css │ │ ├── Wall.tsx │ │ └── texture.png │ └── World │ │ ├── Floor.png │ │ ├── Palm │ │ ├── Palm.tsx │ │ ├── leaf.png │ │ └── trunk.png │ │ ├── SlidingDoor │ │ ├── SlidingDoor.tsx │ │ ├── sliding door.png │ │ └── sliding-door.png │ │ ├── Television │ │ ├── Television.module.css │ │ ├── Television.tsx │ │ ├── tv-furniture.png │ │ └── tv-side.png │ │ ├── World.tsx │ │ ├── statue.png │ │ └── world.module.css ├── context │ ├── LookAtContext.ts │ └── TransformContext.ts ├── global.css ├── hooks │ └── useTick.tsx ├── index.css ├── index.tsx ├── logo.svg ├── react-app-env.d.ts ├── serviceWorker.ts ├── setupTests.ts └── util │ ├── Vec3.ts │ ├── radDeg.ts │ └── uid.ts ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Virtual doom 2 | 3 | ### [Demo](https://maanraket.nl/experiments/virtual-doom/) 4 | 5 | Virtual DOOM is a first-person 3D demo that uses the DOM as its render engine. This is an exercise in finding out what is possible with the 3d capabilities of modern browsers using just HTML/CSS for the actual rendering, i.e. no WebGL, canvas or SVG. It uses React to manage state and perform actual updates to the DOM. 6 | 7 | ### Rendering 8 | 9 | Roughly speaking, the app is structured as follows: 10 | 11 | ``` 12 | Player -> Camera -> Scene 13 | ``` 14 | 15 | The `Player` component manages most game state: position, looking direction, speed, which keys are pressed, etc. This is passed on to the `Camera` component, which wraps everything in a div with the correct `transform` css properties set, so that the entire scene is transformed in the expected way. 16 | 17 | ### Transformations 18 | 19 | The app comes with some handy components that let you position things (i.e. any HTML element) in the 3D world: ``, `` and ``. These position their children, and also provide a `TransformContext` for any child component that needs to know where it is / how it is rotated in 3d space. 20 | 21 | ### Collision detection 22 | 23 | In first person engines, you need collision detection to prevent the player from walking through walls. And since we use the pointer lock API to prevent the cursor from moving out of the browser window, we also need a way of knowing what the player is looking at / clicking on. 24 | 25 | The app provides a handy primitive for this: ``. This component measures its content (by looking at its own `offsetWidth`/`offsetHeight`), and uses the `TransformContext` to know where it is in 3D space. Each `Intersectable` therefore represents a rectangle in 3D space, which we can use to do collision detection. 26 | 27 | To know what the player is looking at, we can cast a ray from the camera position in the direction the player is looking at. Any `Intersectable` that intersects with this ray is in the player's line of sight. We can then compare the distances along the ray of each intersection to find the nearest element that is being looked at. 28 | 29 | 30 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "virtual-doom", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^4.2.4", 7 | "@testing-library/react": "^9.3.2", 8 | "@testing-library/user-event": "^7.1.2", 9 | "@types/gl-matrix": "2.4.5", 10 | "@types/jest": "^24.0.0", 11 | "@types/node": "^12.0.0", 12 | "@types/react": "^16.9.0", 13 | "@types/react-dom": "^16.9.0", 14 | "@types/react-youtube": "^7.6.2", 15 | "gl-matrix": "3.1.0", 16 | "react": "^16.12.0", 17 | "react-dom": "^16.12.0", 18 | "react-scripts": "3.3.0", 19 | "react-youtube": "^7.9.0", 20 | "typescript": "~3.7.2" 21 | }, 22 | "scripts": { 23 | "start": "react-scripts start", 24 | "build": "react-scripts build", 25 | "test": "react-scripts test", 26 | "eject": "react-scripts eject" 27 | }, 28 | "eslintConfig": { 29 | "extends": "react-app" 30 | }, 31 | "browserslist": { 32 | "production": [ 33 | ">0.2%", 34 | "not dead", 35 | "not op_mini all" 36 | ], 37 | "development": [ 38 | "last 1 chrome version", 39 | "last 1 firefox version", 40 | "last 1 safari version" 41 | ] 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ath92/virtual-doom/6dfa1535179a3ecb0ac930dc02efc521161cb8ea/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ath92/virtual-doom/6dfa1535179a3ecb0ac930dc02efc521161cb8ea/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ath92/virtual-doom/6dfa1535179a3ecb0ac930dc02efc521161cb8ea/public/logo512.png -------------------------------------------------------------------------------- /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 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | background-image: url('./bg.png'); 4 | background-size: 100vh; 5 | } 6 | 7 | .App-logo { 8 | height: 40vmin; 9 | pointer-events: none; 10 | } 11 | 12 | @media (prefers-reduced-motion: no-preference) { 13 | .App-logo { 14 | animation: App-logo-spin infinite 20s linear; 15 | } 16 | } 17 | 18 | .App-header { 19 | background-color: #282c34; 20 | min-height: 100vh; 21 | display: flex; 22 | flex-direction: column; 23 | align-items: center; 24 | justify-content: center; 25 | font-size: calc(10px + 2vmin); 26 | color: white; 27 | } 28 | 29 | .App-link { 30 | color: #61dafb; 31 | } 32 | 33 | @keyframes App-logo-spin { 34 | from { 35 | transform: rotate(0deg); 36 | } 37 | to { 38 | transform: rotate(360deg); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import App from './App'; 4 | 5 | test('renders learn react link', () => { 6 | const { getByText } = render(); 7 | const linkElement = getByText(/learn react/i); 8 | expect(linkElement).toBeInTheDocument(); 9 | }); 10 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Player from './components/Player/Player'; 3 | import World from './components/World/World'; 4 | import './App.css'; 5 | import './global.css'; 6 | 7 | const App: React.FC = () => { 8 | return ( 9 |
10 | 11 | 12 | 13 |
14 | ); 15 | } 16 | 17 | export default App; 18 | -------------------------------------------------------------------------------- /src/assets/assets.sketch: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ath92/virtual-doom/6dfa1535179a3ecb0ac930dc02efc521161cb8ea/src/assets/assets.sketch -------------------------------------------------------------------------------- /src/bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ath92/virtual-doom/6dfa1535179a3ecb0ac930dc02efc521161cb8ea/src/bg.png -------------------------------------------------------------------------------- /src/collision/collision.ts: -------------------------------------------------------------------------------- 1 | import { vec3 } from 'gl-matrix'; 2 | 3 | type Intersectable = { // intersectable rectangle 4 | position: vec3; // top left corner 5 | topSide: vec3; // top right corner 6 | leftSide: vec3; // bottom left corner 7 | callback?: (type?: string) => void 8 | }; 9 | 10 | type Ray = { 11 | position: vec3; 12 | direction: vec3; 13 | }; 14 | 15 | type Intersection = { 16 | key: string, 17 | distance: number, 18 | } 19 | 20 | // for now we're dealing with a very small number of intersectables, 21 | // so it doesn't make sense to optimize this to something better (e.g. quadtree) 22 | const intersectables = new Map(); 23 | 24 | export const register = (key: string, intersectable: Intersectable) => { 25 | intersectables.set(key, intersectable); 26 | }; 27 | 28 | export const unRegister = (key: string) => { 29 | intersectables.delete(key); 30 | }; 31 | 32 | const projectOnto = (onto: vec3, vector: vec3): vec3 | null => { 33 | const ontolen = vec3.len(onto); 34 | const scale = vec3.dot(onto, vector) / ontolen; 35 | if (scale < 0) return null; // we don't want to use the projection if the angle between the two is obtuse 36 | return vec3.scale(vec3.create(), onto, scale / ontolen); 37 | } 38 | 39 | // console.log(projectOnto( 40 | // vec3.fromValues(1, 0, 0), 41 | // vec3.fromValues(-1, 1, 0) 42 | // )) 43 | 44 | const getRectangleRayIntersection = ( 45 | () => { 46 | // create a bunch of empty vec3s to reuse 47 | const P0MinusR0 = vec3.create(); 48 | const N = vec3.create(); 49 | const P = vec3.create(); 50 | const P0P = vec3.create(); 51 | const PP = vec3.create(); 52 | return (ray: Ray, intersectable: Intersectable): number => { 53 | vec3.subtract(P0MinusR0, intersectable.position, ray.position); 54 | vec3.cross(N, intersectable.topSide, intersectable.leftSide); 55 | const DDotN = vec3.dot(ray.direction, N); 56 | 57 | const P0MinusR0DotN = vec3.dot(P0MinusR0, N); 58 | 59 | const t = P0MinusR0DotN / DDotN; 60 | 61 | vec3.add(P, ray.position, vec3.scale(PP, ray.direction, t)); 62 | vec3.sub(P0P, P, intersectable.position); 63 | 64 | const Q1 = projectOnto(intersectable.leftSide, P0P); 65 | const Q2 = projectOnto(intersectable.topSide, P0P); 66 | 67 | if (!Q1 || !Q2) return -1; 68 | 69 | const Q1Length = vec3.length(Q1); 70 | const Q2Length = vec3.length(Q2); 71 | const leftSideLength = vec3.length(intersectable.leftSide); 72 | const topSideLength = vec3.length(intersectable.topSide); 73 | 74 | if (Q1Length >= 0 && Q1Length <= leftSideLength 75 | && Q2Length >= 0 && Q2Length <= topSideLength) { 76 | return t; 77 | } 78 | 79 | return -1; 80 | }; 81 | } 82 | )(); 83 | 84 | // console.log(getRectangleRayIntersection( 85 | // { 86 | // position: vec3.fromValues(1, -1, 0), 87 | // direction: vec3.fromValues(0, 0, 1) 88 | // }, 89 | // { 90 | // position: vec3.fromValues(0, 0, 1), 91 | // topSide: vec3.fromValues(2, 0, 0), 92 | // leftSide: vec3.fromValues(0, 2, 0) 93 | // } 94 | // )); 95 | 96 | // expect no intersection 97 | 98 | export const getRectangleRayIntersections = (ray: Ray, type?: string) => { 99 | const intersections = Array(); 100 | intersectables.forEach((intersectable, key) => { 101 | const intersection = getRectangleRayIntersection(ray, intersectable); 102 | if (intersection > 0) { 103 | // TODO: instead of sorting later, push items in the right spot 104 | intersections.push({ 105 | distance: intersection, 106 | key, 107 | }); 108 | } 109 | }); 110 | intersections.sort((a, b) => a.distance < b.distance ? -1 : 1); 111 | if (intersections.length) { 112 | const first = intersectables.get(intersections[0].key); 113 | if (first && first.callback) { 114 | first.callback(type); 115 | } 116 | } 117 | return intersections; 118 | }; 119 | -------------------------------------------------------------------------------- /src/components/Button/Button.module.css: -------------------------------------------------------------------------------- 1 | .button { 2 | transform: scale(5); 3 | } 4 | 5 | .button--hover { 6 | background-color: red; 7 | } -------------------------------------------------------------------------------- /src/components/Button/Button.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useContext, useState } from 'react'; 2 | import { vec3, mat4 } from 'gl-matrix'; 3 | import Transform from '../Transform/Transform'; 4 | import Intersectable from '../Intersectable/Intersectable'; 5 | import styles from './Button.module.css'; 6 | import uid from '../../util/uid'; 7 | import LookAtContext from '../../context/LookAtContext'; 8 | 9 | 10 | const Wall: React.FC<{ 11 | position: vec3; 12 | yRotation?: number; 13 | length?: number; 14 | }> = ({ yRotation = 0, position, length = 1000, ...props }) => { 15 | const lookAt = useContext(LookAtContext); 16 | const [intersectableId] = useState(uid('button')); 17 | const transform = mat4.fromTranslation(mat4.create(), position); 18 | mat4.rotateY(transform, transform, yRotation); // -yRotation because y is flipped 19 | 20 | const intersectionCallback = useCallback((type?: string) => { 21 | if (type === 'click') { 22 | console.log('Im being clicked at!'); 23 | } 24 | }, []); 25 | 26 | const className = lookAt === intersectableId ? styles['button--hover'] : styles.button; 27 | 28 | return ( 29 | 30 | 31 | 34 | 35 | 36 | ); 37 | }; 38 | 39 | export default Wall; 40 | -------------------------------------------------------------------------------- /src/components/Camera/Camera.module.css: -------------------------------------------------------------------------------- 1 | .camera { 2 | width: 100vw; 3 | height: 100vh; 4 | overflow: hidden; 5 | position: relative; 6 | } 7 | 8 | .scene { 9 | left: 50%; 10 | position: absolute; 11 | top: 50%; 12 | transform-style: preserve-3d; 13 | } 14 | 15 | .crosshair { 16 | position: fixed; 17 | left: 50vw; 18 | top: 50vh; 19 | border: 2px solid blue; 20 | border-radius: 50%; 21 | z-index: 1; 22 | } -------------------------------------------------------------------------------- /src/components/Camera/Camera.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import styles from './Camera.module.css'; 3 | import { vec3, mat4 } from 'gl-matrix'; 4 | 5 | const up = vec3.fromValues(0, 1, 0); 6 | 7 | const Camera: React.FC<{ 8 | position: vec3, 9 | direction: vec3, 10 | perspective: number 11 | }> = ({ position, direction, perspective, ...props }) => { 12 | //for reuse 13 | const [cameraPosition] = useState(vec3.create()); 14 | const [sceneTransform] = useState(mat4.create()); 15 | const [lookAt] = useState(vec3.create()); 16 | 17 | const cameraZOffset = vec3.fromValues(0, 0, -perspective); 18 | const perspectiveStyle = { perspective: `${perspective}px` }; 19 | 20 | vec3.add(cameraPosition, position, cameraZOffset); 21 | 22 | mat4.lookAt( 23 | sceneTransform, 24 | cameraPosition, 25 | vec3.add( 26 | lookAt, 27 | cameraPosition, 28 | direction 29 | ), 30 | up 31 | ); 32 | 33 | const sceneTransformStyle = { 34 | transform: `matrix3d(${sceneTransform.join(',')})`, 35 | transformOrigin: `0 0 ${perspective}px` 36 | }; 37 | 38 | return ( 39 | <> 40 |
41 |
42 |
43 | {props.children} 44 |
45 |
46 | 47 | ); 48 | } 49 | 50 | export default Camera; -------------------------------------------------------------------------------- /src/components/Intersectable/Intersectable.module.css: -------------------------------------------------------------------------------- 1 | .intersectable > * { 2 | position: static!important; /* to allow measuring of children using offsetWidth / Height */ 3 | } -------------------------------------------------------------------------------- /src/components/Intersectable/Intersectable.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useCallback, useState, useEffect } from 'react'; 2 | import TransformContext from '../../context/TransformContext'; 3 | import styles from './Intersectable.module.css'; 4 | import { vec3 } from 'gl-matrix'; 5 | import { register, unRegister } from '../../collision/collision'; 6 | import uid from '../../util/uid'; 7 | 8 | const origin = vec3.fromValues(0, 0, 0); 9 | const right = vec3.fromValues(1, 0, 0); 10 | const down = vec3.fromValues(0, 1, 0); 11 | 12 | const Intersectable: React.FC<{ 13 | callback?: (type?: string) => void, 14 | id?: string 15 | }> = ({ callback, id, ...props }) => { 16 | const [dimensions, setDimensions] = useState([0, 0]); 17 | const worldTransform = useContext(TransformContext); 18 | const [intersectableKey] = useState(id || uid('inter')); 19 | // initialize vectors as state so they can be reused 20 | const [[position, topSide, leftSide]] = useState([vec3.create(), vec3.create(), vec3.create()]); 21 | 22 | const measuredRef = useCallback(node => { 23 | if (node !== null) { 24 | setDimensions([node.offsetWidth, node.offsetHeight]); 25 | } 26 | }, [setDimensions]); 27 | 28 | useEffect(() => { 29 | // register intersectable 30 | vec3.transformMat4(position, origin, worldTransform); 31 | vec3.scale(topSide, right, dimensions[0]); 32 | vec3.transformMat4(topSide, topSide, worldTransform); 33 | vec3.sub(topSide, topSide, position); 34 | vec3.scale(leftSide, down, dimensions[1]); 35 | vec3.transformMat4(leftSide, leftSide, worldTransform); 36 | vec3.sub(leftSide, leftSide, position); 37 | 38 | register(intersectableKey, { position, leftSide, topSide, callback }); 39 | }, [dimensions, worldTransform, intersectableKey, callback, position, topSide, leftSide]); 40 | 41 | // only unregister when component unmounts 42 | useEffect(() => () => unRegister(intersectableKey), [intersectableKey]); 43 | 44 | // maybe add a mutationObserver so dimensions changing because of a DOM change is picked up on? 45 | return ( 46 |
47 | {props.children} 48 |
49 | ); 50 | } 51 | 52 | export default Intersectable; 53 | -------------------------------------------------------------------------------- /src/components/Maze/Maze.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import Wall from '../Wall/Wall'; 3 | import { vec3 } from 'gl-matrix'; 4 | 5 | type MazeCell = { 6 | walls: { 7 | top: boolean, 8 | left: boolean 9 | }, 10 | visited: boolean, 11 | x: number, 12 | y: number 13 | } 14 | 15 | function pickRandom(a: Array): T { 16 | return a[Math.floor(Math.random() * a.length)]; 17 | } 18 | 19 | const generateMaze = (size: number, start: [number, number] = [0, 0]) => { 20 | const maze = new Array>(); 21 | for (let y = 0; y < size; y++) { 22 | maze[y] = new Array(); 23 | for (let x = 0; x < size; x++) { 24 | maze[y][x] = { 25 | walls: { 26 | top: true, 27 | left: true, 28 | }, 29 | visited: false, 30 | x, 31 | y, 32 | } 33 | } 34 | } 35 | 36 | const frontier = new Array(); 37 | const firstCell = maze[start[0]][start[1]]; 38 | 39 | const printMaze = () => { 40 | let string = ''; 41 | maze.forEach(row => { 42 | row.forEach(cell => { 43 | if (cell.walls.top) { 44 | string += ' -'; 45 | } else { 46 | string += ' '; 47 | } 48 | }) 49 | 50 | string += '\n'; 51 | 52 | row.forEach(cell => { 53 | let s = frontier.includes(cell) ? 'x' : ' '; 54 | s = cell.visited ? 'o' : s; 55 | if (cell.walls.left) { 56 | string += '|' + s; 57 | } else { 58 | string += ' ' + s; 59 | } 60 | }); 61 | 62 | string += '\n'; 63 | }); 64 | console.log(string); 65 | } 66 | 67 | const getNeighbors = (cell: MazeCell) => { 68 | const { x, y } = cell; 69 | const neighbors = []; 70 | if (x > 0) neighbors.push(maze[y][x - 1]); 71 | if (x < size - 1) neighbors.push(maze[y][x + 1]); 72 | if (y > 0) neighbors.push(maze[y - 1][x]); 73 | if (y < size - 1) neighbors.push(maze[y + 1][x]); 74 | return neighbors; 75 | } 76 | 77 | const visit = (cell: MazeCell) => { 78 | cell.visited = true; 79 | const neighbors = getNeighbors(cell); 80 | neighbors 81 | .filter(({ visited }) => !visited) 82 | .forEach(c => { 83 | frontier.push(c); 84 | }); 85 | 86 | const visitedNeighbor = pickRandom(neighbors.filter(({ visited }) => visited)); 87 | if (!visitedNeighbor) return; 88 | if (visitedNeighbor.x < cell.x) cell.walls.left = false; 89 | if (visitedNeighbor.y < cell.y) cell.walls.top = false; 90 | if (visitedNeighbor.x > cell.x) visitedNeighbor.walls.left = false; 91 | if (visitedNeighbor.y > cell.y) visitedNeighbor.walls.top = false; 92 | } 93 | 94 | visit(firstCell); 95 | let currentCell = firstCell; 96 | while (frontier.length) { 97 | currentCell = frontier.splice(frontier.findIndex(c => { 98 | return getNeighbors(currentCell).includes(c); 99 | }), 1)[0]; 100 | visit(currentCell); 101 | // printMaze(); 102 | } 103 | return maze; 104 | } 105 | 106 | const Maze: React.FC<{ 107 | wallLength?: number 108 | }> = ({wallLength = 1000, ...props}) => { 109 | const [maze] = useState(generateMaze(5)); 110 | 111 | const getPosition = (x: number, y: number) => { 112 | // added offset temporarily for testing 113 | return vec3.fromValues(x * wallLength + 1000, 0, y * wallLength + 1000); 114 | } 115 | 116 | return ( 117 | <> 118 | { 119 | maze.map((row, y) => row.map((cell, x) => { 120 | const leftWall = cell.walls.left ? 121 | () : 122 | null; 123 | const topWall = cell.walls.top ? 124 | () : 125 | null; 126 | return [leftWall, topWall]; 127 | })) 128 | } 129 | 130 | ); 131 | }; 132 | 133 | export default Maze; -------------------------------------------------------------------------------- /src/components/Player/Player.module.css: -------------------------------------------------------------------------------- 1 | .intro { 2 | color: #fff; 3 | text-shadow: 2px 2px 0 black; 4 | font-size: 20px; 5 | font-family: sans-serif; 6 | text-align: center; 7 | position: absolute; 8 | left: 50%; 9 | width: 600px; 10 | margin-left: -300px; 11 | margin-top: 20px; 12 | letter-spacing: 4px; 13 | line-height: 1.5em; 14 | z-index: 2; 15 | } 16 | 17 | .intro a { 18 | color: white; 19 | } -------------------------------------------------------------------------------- /src/components/Player/Player.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useCallback, useRef } from 'react'; 2 | import useTick from '../../hooks/useTick'; 3 | import { vec3, mat4 } from 'gl-matrix'; 4 | import { getRectangleRayIntersections } from '../../collision/collision'; 5 | import Camera from '../Camera/Camera'; 6 | import LookAtContext from '../../context/LookAtContext'; 7 | import styles from './Player.module.css'; 8 | 9 | // TODO: clean up maths in here 10 | const speed = 30; 11 | const mouseSensitivity = 0.0015; 12 | 13 | const origin = vec3.fromValues(0, 0, 0); 14 | const forward = vec3.fromValues(0, 0, -1); 15 | const rotateLeft = mat4.fromYRotation(mat4.create(), 0.5 * Math.PI); 16 | const rotateRight = mat4.fromYRotation(mat4.create(), -0.5 * Math.PI); 17 | 18 | const Player: React.FC = props => { 19 | const [position, setPosition] = useState(vec3.fromValues(0, 0, 0)); 20 | const [direction, setDirection] = useState(vec3.fromValues(0, 0, -1)); 21 | // refs for mousePosition and directionKeys so that updating doesn't cause re-render (re-render in rAF loop instead) 22 | const directionKeys = useRef({ 23 | forward: false, 24 | backward: false, 25 | left: false, 26 | right: false 27 | }); 28 | const mousePosition = useRef([0, 0]); 29 | const [hasPointerLock, setHasPointerLock] = useState(false); 30 | const [lookAt, setLookAt] = useState(null); 31 | 32 | // keep direction keys in state so we can update position on every tick 33 | useEffect(() => { 34 | const setDirectionKey = (keyboardEvent: KeyboardEvent) => { 35 | const { code, type } = keyboardEvent; 36 | const value = type === 'keydown'; 37 | if (code === 'KeyW' || code === 'ArrowUp') directionKeys.current.forward = value; 38 | if (code === 'KeyS' || code === 'ArrowDown') directionKeys.current.backward = value; 39 | if (code === 'KeyA' || code === 'ArrowLeft') directionKeys.current.left = value; 40 | if (code === 'KeyD' || code === 'ArrowRight') directionKeys.current.right = value; 41 | }; 42 | window.addEventListener('keydown', setDirectionKey); 43 | window.addEventListener('keyup', setDirectionKey); 44 | return () => { 45 | window.removeEventListener('keydown', setDirectionKey); 46 | window.removeEventListener('keyup', setDirectionKey); 47 | }; 48 | }, [directionKeys]); 49 | 50 | // change direction based on mouseMove 51 | useEffect(() => { 52 | const onMouseMove = (e: MouseEvent) => { 53 | if (!hasPointerLock) return; 54 | mousePosition.current = [ 55 | mousePosition.current[0] + e.movementX, 56 | mousePosition.current[1] + e.movementY 57 | ]; 58 | }; 59 | window.addEventListener('mousemove', onMouseMove); 60 | return () => window.removeEventListener('mousemove', onMouseMove); 61 | }, [mousePosition, hasPointerLock]); 62 | 63 | // only track mouse if there is pointer lock 64 | useEffect(() => { 65 | const onPointerLockChange = () => { 66 | setHasPointerLock(!!document.pointerLockElement); 67 | }; 68 | const body = document.querySelector('body') as HTMLBodyElement; 69 | const requestPointerLock = (e: MouseEvent) => { 70 | if ((e.target as Element).nodeName === 'A') return; 71 | body.requestPointerLock(); 72 | } 73 | document.addEventListener('mousedown', requestPointerLock); 74 | document.addEventListener('pointerlockchange', onPointerLockChange, false); 75 | return () => { 76 | document.removeEventListener('mousedown', requestPointerLock); 77 | document.removeEventListener('pointerlockchange', onPointerLockChange, false); 78 | }; 79 | }, []); 80 | 81 | // update direction based on mouse position, position based on direction 82 | const updatePosition = useCallback(() => { 83 | if (!hasPointerLock) return; 84 | const newDirection = vec3.clone(forward); 85 | vec3.rotateX(newDirection, newDirection, origin, mousePosition.current[1] * mouseSensitivity); 86 | vec3.rotateY(newDirection, newDirection, origin, -mousePosition.current[0] * mouseSensitivity); 87 | setDirection(newDirection); 88 | 89 | const deltaPosition = vec3.fromValues(newDirection[0], 0, newDirection[2]); 90 | vec3.scale(deltaPosition, deltaPosition, speed); 91 | 92 | // strafing with keys 93 | const diff = vec3.create(); 94 | if (directionKeys.current.forward) vec3.add(diff, diff, deltaPosition); 95 | if (directionKeys.current.backward) vec3.add(diff, diff, vec3.negate(vec3.create(), deltaPosition)); 96 | if (directionKeys.current.left) vec3.add(diff, diff, vec3.transformMat4(vec3.create(), deltaPosition, rotateLeft)); 97 | if (directionKeys.current.right) vec3.add(diff, diff, vec3.transformMat4(vec3.create(), deltaPosition, rotateRight)); 98 | 99 | setPosition(p => { 100 | // before updating position, check if we're going through a wall 101 | const intersections = getRectangleRayIntersections({ 102 | position: p, 103 | direction: diff 104 | }) 105 | .filter(i => i.distance > 0 && i.distance < 1) 106 | .sort(); 107 | 108 | const offset = 0.05; // small offset to prevent players from being able to look through elements when very close 109 | 110 | if (intersections.length) { 111 | vec3.scale(diff, diff, (intersections[0].distance - offset) / 1); 112 | } 113 | return vec3.add(vec3.create(), p, diff); 114 | }); 115 | }, [directionKeys, setDirection, setPosition, hasPointerLock]); 116 | 117 | useEffect(() => { 118 | const intersections = getRectangleRayIntersections({ 119 | position, 120 | direction 121 | }) 122 | .filter(i => i.distance > 0) 123 | 124 | if (intersections.length) { 125 | setLookAt(intersections[0].key); 126 | } else { 127 | setLookAt(null); 128 | } 129 | }, [direction, position]); 130 | 131 | useEffect(() => { 132 | const onClick = () => { 133 | getRectangleRayIntersections({ 134 | position, 135 | direction 136 | }, 'click'); 137 | } 138 | window.addEventListener('click', onClick); 139 | return () => window.removeEventListener('click', onClick); 140 | }, [position, direction]); 141 | 142 | useTick(updatePosition); 143 | 144 | return ( 145 | <> 146 | { 147 | !hasPointerLock ? 148 |
149 | Click anywhere to start.
150 | Move your cursor to look around.
151 | Use WASD or the arrow keys to walk around.
152 | GitHub 153 |
154 | : null 155 | } 156 | 157 | 158 | 159 | {props.children} 160 | 161 | 162 | 163 | ); 164 | }; 165 | 166 | export default Player; 167 | -------------------------------------------------------------------------------- /src/components/Transform/Rotate.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Transform from './Transform'; 3 | import { mat4, quat } from 'gl-matrix'; 4 | 5 | // Euler rotations 6 | const Rotate: React.FC<{ 7 | x?: number, 8 | y?: number, 9 | z?: number 10 | }> = ({ x = 0, y = 0, z = 0, ...props }) => { 11 | return 12 | { props.children } 13 | ; 14 | }; 15 | 16 | export default Rotate; -------------------------------------------------------------------------------- /src/components/Transform/Transform.module.css: -------------------------------------------------------------------------------- 1 | .transform { 2 | transform-origin: left; 3 | transform-style: preserve-3d; 4 | position: absolute; 5 | } 6 | -------------------------------------------------------------------------------- /src/components/Transform/Transform.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useContext } from 'react'; 2 | import { mat4 } from 'gl-matrix'; 3 | import TransformContext from '../../context/TransformContext'; 4 | import styles from './Transform.module.css'; 5 | 6 | const Transform: React.FC<{ 7 | value: mat4 8 | }> = ({ value, ...props }) => { 9 | const parentWorldTransform = useContext(TransformContext); 10 | const [worldTransform] = useState(mat4.create()); // for reuse 11 | 12 | mat4.multiply(worldTransform, parentWorldTransform, value); 13 | 14 | const transformStyle = { 15 | transform: `matrix3d(${value.join(',')})`, 16 | }; 17 | 18 | return ( 19 |
20 | 21 | { props.children } 22 | 23 |
24 | ); 25 | }; 26 | 27 | export default Transform; -------------------------------------------------------------------------------- /src/components/Transform/Translate.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Transform from './Transform'; 3 | import { vec3, mat4 } from 'gl-matrix'; 4 | 5 | const Translate: React.FC<{ 6 | x?: number, 7 | y?: number, 8 | z?: number 9 | }> = ({ x = 0, y = 0, z = 0, ...props}) => { 10 | return 11 | {props.children} 12 | ; 13 | }; 14 | 15 | export default Translate; -------------------------------------------------------------------------------- /src/components/Wall/Wall.module.css: -------------------------------------------------------------------------------- 1 | .wall { 2 | background: url('./texture.png'); 3 | height: 1000px; 4 | position: absolute; 5 | transform-style: preserve-3d; 6 | } 7 | -------------------------------------------------------------------------------- /src/components/Wall/Wall.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { vec3, mat4 } from 'gl-matrix'; 3 | import styles from './Wall.module.css'; 4 | import Transform from '../Transform/Transform'; 5 | import Intersectable from '../Intersectable/Intersectable'; 6 | 7 | 8 | const Wall: React.FC<{ 9 | position: vec3; 10 | yRotation?: number; 11 | length?: number; 12 | }> = ({ yRotation = 0, position, length = 1000, ...props }) => { 13 | const transform = mat4.fromTranslation(mat4.create(), position); 14 | mat4.rotateY(transform, transform, yRotation); // -yRotation because y is flipped 15 | 16 | const style = { 17 | width: `${length}px` 18 | }; 19 | 20 | return ( 21 | 22 | 23 |
24 | {props.children} 25 |
26 |
27 |
28 | ); 29 | }; 30 | 31 | export default Wall; 32 | -------------------------------------------------------------------------------- /src/components/Wall/texture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ath92/virtual-doom/6dfa1535179a3ecb0ac930dc02efc521161cb8ea/src/components/Wall/texture.png -------------------------------------------------------------------------------- /src/components/World/Floor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ath92/virtual-doom/6dfa1535179a3ecb0ac930dc02efc521161cb8ea/src/components/World/Floor.png -------------------------------------------------------------------------------- /src/components/World/Palm/Palm.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Palm: React.FC = () => { 4 | return ( 5 | <> 6 | ); 7 | }; 8 | 9 | export default Palm; -------------------------------------------------------------------------------- /src/components/World/Palm/leaf.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ath92/virtual-doom/6dfa1535179a3ecb0ac930dc02efc521161cb8ea/src/components/World/Palm/leaf.png -------------------------------------------------------------------------------- /src/components/World/Palm/trunk.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ath92/virtual-doom/6dfa1535179a3ecb0ac930dc02efc521161cb8ea/src/components/World/Palm/trunk.png -------------------------------------------------------------------------------- /src/components/World/SlidingDoor/SlidingDoor.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useContext } from 'react'; 2 | import useTick from '../../../hooks/useTick'; 3 | import Translate from '../../Transform/Translate'; 4 | import Intersectable from '../../Intersectable/Intersectable'; 5 | import LookAtContext from '../../../context/LookAtContext'; 6 | import getUid from '../../../util/uid'; 7 | import slidingDoor from './sliding-door.png'; 8 | 9 | const SlidingDoor: React.FC<{ 10 | width?: number 11 | }> = ({ width = 1000 }) => { 12 | const [x, setX] = useState(0); 13 | const [isOpen, setIsOpen] = useState(false); 14 | const [id] = useState(getUid('door')); 15 | const lookAt = useContext(LookAtContext); 16 | 17 | const targetThreshold = 2; // rounding 18 | 19 | const toggleOpen = (intersectionType?: string) => { 20 | if (intersectionType === 'click') { 21 | setIsOpen(!isOpen); 22 | } 23 | } 24 | 25 | useTick((stopTick) => { 26 | const target = isOpen ? width : 0; 27 | if (Math.abs(x - target) < targetThreshold) { 28 | setX(target); 29 | stopTick(); 30 | return; 31 | } 32 | setX(x + (target - x) / 5); 33 | }); 34 | 35 | return ( 36 | 37 | 38 | 46 | 47 | 48 | ) 49 | } 50 | 51 | export default SlidingDoor; -------------------------------------------------------------------------------- /src/components/World/SlidingDoor/sliding door.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ath92/virtual-doom/6dfa1535179a3ecb0ac930dc02efc521161cb8ea/src/components/World/SlidingDoor/sliding door.png -------------------------------------------------------------------------------- /src/components/World/SlidingDoor/sliding-door.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ath92/virtual-doom/6dfa1535179a3ecb0ac930dc02efc521161cb8ea/src/components/World/SlidingDoor/sliding-door.png -------------------------------------------------------------------------------- /src/components/World/Television/Television.module.css: -------------------------------------------------------------------------------- 1 | .side { 2 | background-image: url('./tv-side.png'); 3 | background-size: 320px; 4 | width: 320px; 5 | height: 320px; 6 | } 7 | 8 | .back { 9 | background-color: #ccc; 10 | height: 240px; 11 | width: 500px; 12 | } 13 | 14 | .furniture{ 15 | background-image: url('./tv-furniture.png'); 16 | background-size: 100%; 17 | width: 500px; 18 | height: 320px; 19 | } 20 | 21 | .furnitureSide { 22 | width: 320px; 23 | } 24 | 25 | -------------------------------------------------------------------------------- /src/components/World/Television/Television.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useCallback } from 'react'; 2 | import Rotate from '../../Transform/Rotate'; 3 | import Translate from '../../Transform/Translate'; 4 | import Intersectable from '../../Intersectable/Intersectable'; 5 | import Youtube from 'react-youtube'; 6 | import styles from './Television.module.css'; 7 | 8 | const Television: React.FC = () => { 9 | const [player, setPlayer] = useState<{ playVideo: () => void, pauseVideo: () => void } | null>(null); 10 | const [isPlaying, setIsPlaying] = useState(false); 11 | 12 | const dimensions = { 13 | width: `500px`, 14 | height: `320px` 15 | }; 16 | 17 | const onReady = useCallback(e => { 18 | setPlayer(e.target); 19 | }, [setPlayer]); 20 | 21 | const onIntersection = useCallback((type?: string) => { 22 | if (type === 'click' && player !== null) { 23 | if (isPlaying) { 24 | player.pauseVideo(); 25 | } else { 26 | player.playVideo(); 27 | } 28 | setIsPlaying(!isPlaying); 29 | } 30 | }, [player, setIsPlaying, isPlaying]); 31 | 32 | return ( 33 | <> 34 | 35 |
36 | 41 |
42 |
43 | 44 | 45 | 46 |
47 |
48 | 49 | 50 |
51 |
52 |
53 | 54 |
55 |
56 |
57 | 58 | 59 |
60 | 61 |
62 |
63 |
64 | 65 | ) 66 | } 67 | 68 | export default Television; -------------------------------------------------------------------------------- /src/components/World/Television/tv-furniture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ath92/virtual-doom/6dfa1535179a3ecb0ac930dc02efc521161cb8ea/src/components/World/Television/tv-furniture.png -------------------------------------------------------------------------------- /src/components/World/Television/tv-side.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ath92/virtual-doom/6dfa1535179a3ecb0ac930dc02efc521161cb8ea/src/components/World/Television/tv-side.png -------------------------------------------------------------------------------- /src/components/World/World.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Wall from '../Wall/Wall'; 3 | import Button from '../Button/Button'; 4 | import { vec3 } from 'gl-matrix'; 5 | import styles from './world.module.css'; 6 | import { degToRad } from '../../util/radDeg'; 7 | import Rotate from '../Transform/Rotate'; 8 | import Translate from '../Transform/Translate'; 9 | import statue from './statue.png'; 10 | import SlidingDoor from './SlidingDoor/SlidingDoor'; 11 | import Television from './Television/Television'; 12 | 13 | // magic numbers everywhere in this file so it's easier to iterate 14 | 15 | const Floor: React.FC = () => { 16 | return ( 17 | 18 | 19 | { 20 | Array(5).fill(0).map((i, x) => ( 21 | Array(12).fill(0).map((j, y) => ( 22 | 23 |
24 |
25 | )) 26 | )) 27 | } 28 |
29 |
30 | ) 31 | }; 32 | 33 | const Statue: React.FC = () => { 34 | return ( 35 | 36 | 37 | 38 | 39 | 40 | ); 41 | } 42 | 43 | const World: React.FC = () => { 44 | return ( 45 | 46 | 47 | {/* */} 48 | {/* right wall */} 49 | 50 | 51 | 52 | 53 | {/* left wall */} 54 | 55 | 56 | 57 | 58 | 59 | {/* Middle wall */} 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | {/* Back wall */} 74 | 75 | 76 | 77 | ); 78 | }; 79 | 80 | export default World; 81 | -------------------------------------------------------------------------------- /src/components/World/statue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ath92/virtual-doom/6dfa1535179a3ecb0ac930dc02efc521161cb8ea/src/components/World/statue.png -------------------------------------------------------------------------------- /src/components/World/world.module.css: -------------------------------------------------------------------------------- 1 | .floor { 2 | background-image: url('./Floor.png'); 3 | background-size: 400px; 4 | height: 400px; 5 | position: absolute; 6 | transform-style: preserve-3d; 7 | width: 400px; 8 | } -------------------------------------------------------------------------------- /src/context/LookAtContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | // which intersectable uid we're looking at 3 | export default createContext(null); -------------------------------------------------------------------------------- /src/context/TransformContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | import { mat4 } from 'gl-matrix'; 3 | 4 | export default createContext(mat4.create()); -------------------------------------------------------------------------------- /src/global.css: -------------------------------------------------------------------------------- 1 | div { 2 | position: absolute; 3 | transform-style: preserve-3d; 4 | } -------------------------------------------------------------------------------- /src/hooks/useTick.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import getUid from '../util/uid'; 3 | 4 | type UseTickCallback = (stopCallback: () => boolean) => void; 5 | 6 | let callbacks = new Map(); 7 | 8 | const loop = () => { 9 | callbacks.forEach((c, key) => c(() => callbacks.delete(key))); 10 | requestAnimationFrame(loop); 11 | }; 12 | 13 | loop(); 14 | 15 | const useTick = (callback: UseTickCallback) => { 16 | const key = getUid('tick'); 17 | useEffect(() => { 18 | callbacks.set(key, callback); 19 | return () => { 20 | callbacks.delete(key); 21 | } 22 | }, [callback]); 23 | }; 24 | 25 | export default useTick; 26 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | 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 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 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 | ReactDOM.render(, document.getElementById('root')); 8 | 9 | // If you want your app to work offline and load faster, you can change 10 | // unregister() to register() below. Note this comes with some pitfalls. 11 | // Learn more about service workers: https://bit.ly/CRA-PWA 12 | serviceWorker.unregister(); 13 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/serviceWorker.ts: -------------------------------------------------------------------------------- 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.0/8 are 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 | type Config = { 24 | onSuccess?: (registration: ServiceWorkerRegistration) => void; 25 | onUpdate?: (registration: ServiceWorkerRegistration) => void; 26 | }; 27 | 28 | export function register(config?: Config) { 29 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 30 | // The URL constructor is available in all browsers that support SW. 31 | const publicUrl = new URL( 32 | process.env.PUBLIC_URL, 33 | window.location.href 34 | ); 35 | if (publicUrl.origin !== window.location.origin) { 36 | // Our service worker won't work if PUBLIC_URL is on a different origin 37 | // from what our page is served on. This might happen if a CDN is used to 38 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 39 | return; 40 | } 41 | 42 | window.addEventListener('load', () => { 43 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 44 | 45 | if (isLocalhost) { 46 | // This is running on localhost. Let's check if a service worker still exists or not. 47 | checkValidServiceWorker(swUrl, config); 48 | 49 | // Add some additional logging to localhost, pointing developers to the 50 | // service worker/PWA documentation. 51 | navigator.serviceWorker.ready.then(() => { 52 | console.log( 53 | 'This web app is being served cache-first by a service ' + 54 | 'worker. To learn more, visit https://bit.ly/CRA-PWA' 55 | ); 56 | }); 57 | } else { 58 | // Is not localhost. Just register service worker 59 | registerValidSW(swUrl, config); 60 | } 61 | }); 62 | } 63 | } 64 | 65 | function registerValidSW(swUrl: string, config?: Config) { 66 | navigator.serviceWorker 67 | .register(swUrl) 68 | .then(registration => { 69 | registration.onupdatefound = () => { 70 | const installingWorker = registration.installing; 71 | if (installingWorker == null) { 72 | return; 73 | } 74 | installingWorker.onstatechange = () => { 75 | if (installingWorker.state === 'installed') { 76 | if (navigator.serviceWorker.controller) { 77 | // At this point, the updated precached content has been fetched, 78 | // but the previous service worker will still serve the older 79 | // content until all client tabs are closed. 80 | console.log( 81 | 'New content is available and will be used when all ' + 82 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' 83 | ); 84 | 85 | // Execute callback 86 | if (config && config.onUpdate) { 87 | config.onUpdate(registration); 88 | } 89 | } else { 90 | // At this point, everything has been precached. 91 | // It's the perfect time to display a 92 | // "Content is cached for offline use." message. 93 | console.log('Content is cached for offline use.'); 94 | 95 | // Execute callback 96 | if (config && config.onSuccess) { 97 | config.onSuccess(registration); 98 | } 99 | } 100 | } 101 | }; 102 | }; 103 | }) 104 | .catch(error => { 105 | console.error('Error during service worker registration:', error); 106 | }); 107 | } 108 | 109 | function checkValidServiceWorker(swUrl: string, config?: Config) { 110 | // Check if the service worker can be found. If it can't reload the page. 111 | fetch(swUrl, { 112 | headers: { 'Service-Worker': 'script' } 113 | }) 114 | .then(response => { 115 | // Ensure service worker exists, and that we really are getting a JS file. 116 | const contentType = response.headers.get('content-type'); 117 | if ( 118 | response.status === 404 || 119 | (contentType != null && contentType.indexOf('javascript') === -1) 120 | ) { 121 | // No service worker found. Probably a different app. Reload the page. 122 | navigator.serviceWorker.ready.then(registration => { 123 | registration.unregister().then(() => { 124 | window.location.reload(); 125 | }); 126 | }); 127 | } else { 128 | // Service worker found. Proceed as normal. 129 | registerValidSW(swUrl, config); 130 | } 131 | }) 132 | .catch(() => { 133 | console.log( 134 | 'No internet connection found. App is running in offline mode.' 135 | ); 136 | }); 137 | } 138 | 139 | export function unregister() { 140 | if ('serviceWorker' in navigator) { 141 | navigator.serviceWorker.ready.then(registration => { 142 | registration.unregister(); 143 | }); 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom/extend-expect'; 6 | -------------------------------------------------------------------------------- /src/util/Vec3.ts: -------------------------------------------------------------------------------- 1 | // very simple 3d vector utility 2 | 3 | class Vec3 { 4 | x: number; 5 | y: number; 6 | z: number; 7 | 8 | constructor(x: number, y: number, z: number) { 9 | this.x = x; 10 | this.y = y; 11 | this.z = z; 12 | } 13 | 14 | length(): number { 15 | return Math.sqrt(this.x ** 2 + this.y ** 2 + this.z ** 2); 16 | } 17 | 18 | add(other: Vec3): Vec3 { 19 | return new Vec3(this.x + other.x, this.y + other.y, this.z + other.z); 20 | } 21 | 22 | multiplyScalar(scalar: number): Vec3 { 23 | return new Vec3(this.x * scalar, this.y * scalar, this.z * scalar); 24 | } 25 | 26 | dot(other: Vec3): number { 27 | return this.x * other.x + this.y * other.y + this.z * other.z; 28 | } 29 | 30 | cross(other: Vec3): Vec3 { 31 | return new Vec3( 32 | this.y * other.z - this.z * other.y, 33 | this.z * other.x - this.x * other.z, 34 | this.x * other.y - this.y * other.x, 35 | ); 36 | } 37 | 38 | average(other: Vec3): Vec3 { 39 | return new Vec3( 40 | (this.x + other.x) / 2, 41 | (this.y + other.y) / 2, 42 | (this.z + other.z) / 2, 43 | ); 44 | } 45 | 46 | rotateAroundAxis(axis: Vec3, angle: number): Vec3 { 47 | // rodriguez method 48 | const term1 = this.multiplyScalar(Math.cos(angle)); 49 | const term2 = this.cross(axis).multiplyScalar(Math.sin(angle)); 50 | const term3 = axis.multiplyScalar(Math.cos(1 - angle) * this.dot(axis)); 51 | return term1.add(term2).add(term3); 52 | } 53 | 54 | normalize(): Vec3 { 55 | return this.multiplyScalar(1 / this.length()); 56 | } 57 | 58 | xAngle(): number { 59 | return Math.atan2(Math.sqrt(this.y ** 2 + this.z ** 2), this.x); 60 | } 61 | 62 | yAngle(): number { 63 | return Math.atan2(Math.sqrt(this.z ** 2 + this.x ** 2), this.y); 64 | } 65 | 66 | zAngle(): number { 67 | return Math.atan2(Math.sqrt(this.x ** 2 + this.y ** 2), this.z); 68 | } 69 | } 70 | 71 | export default Vec3; -------------------------------------------------------------------------------- /src/util/radDeg.ts: -------------------------------------------------------------------------------- 1 | export const radToDeg = (rad: number) => rad * 180 / Math.PI; 2 | export const degToRad = (deg: number) => deg / 180 * Math.PI; -------------------------------------------------------------------------------- /src/util/uid.ts: -------------------------------------------------------------------------------- 1 | let index = 0; 2 | 3 | export default (prefix: string) => { 4 | const current = index; 5 | index++; 6 | return `${prefix}-${current}`; 7 | }; 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "noEmit": true, 20 | "jsx": "react" 21 | }, 22 | "include": [ 23 | "src" 24 | ] 25 | } 26 | --------------------------------------------------------------------------------