├── .gitattributes ├── .github └── FUNDING.yml ├── CNAME ├── LICENSE ├── README.md ├── car-controls.js ├── car.css ├── car.js ├── index.html └── og.png /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: pakastin # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /CNAME: -------------------------------------------------------------------------------- 1 | car.js.org 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Juha Lindstedt & contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Multiplayer 2d car physics with JavaScript! 2 | 3 | https://car.js.org 4 | Use keyboard (up/down/left/right/space, w/s/a/d/space), touch screen (swipe up, down, left, right + shoot with second finger) or gamepad. 5 | 6 | All code [here](https://github.com/pakastin/car/blob/master/car.js)! Not real car physics, but a simple way to do nice little drifting 😛 7 | 8 | Skidmarks are rendered with canvas and car is just a simple DIV element. Web socket server is here: https://github.com/pakastin/car-ws 9 | 10 | Check out [my other projects](https://github.com/pakastin) and/or [sponsor me](https://github.com/sponsors/pakastin) to keep these coming! 😎 11 | 12 | - https://news.ycombinator.com/item?id=21927076 13 | - https://github.com/manassarpatwar/Self-driving-AI 14 | -------------------------------------------------------------------------------- /car-controls.js: -------------------------------------------------------------------------------- 1 | (() => { 2 | window.getControls = function () { 3 | const gamepad = Object.values(gamepads).find(gamepad => gamepad.active) || touching; 4 | 5 | if (gamepad.active) { 6 | return gamepad; 7 | } 8 | 9 | return { 10 | up: keyActive('up'), 11 | down: keyActive('down'), 12 | left: keyActive('left'), 13 | right: keyActive('right'), 14 | shoot: keyActive('shoot') 15 | }; 16 | }; 17 | 18 | const arrowKeys = { 19 | up: 38, 20 | down: 40, 21 | left: 37, 22 | right: 39, 23 | shoot: 32 24 | }; 25 | const wasdKeys = { 26 | up: 87, 27 | down: 83, 28 | left: 65, 29 | right: 68 30 | }; 31 | 32 | const keyActive = (key) => { 33 | return keysDown[arrowKeys[key]] || keysDown[wasdKeys[key]] || false; 34 | }; 35 | 36 | const keysDown = {}; 37 | 38 | window.addEventListener('keydown', e => { 39 | keysDown[e.which] = true; 40 | }); 41 | 42 | window.addEventListener('keyup', e => { 43 | keysDown[e.which] = false; 44 | }); 45 | 46 | const touching = { 47 | up: 0, 48 | down: 0, 49 | left: 0, 50 | right: 0, 51 | shoot: 0 52 | }; 53 | 54 | let touches = 0; 55 | 56 | window.addEventListener('touchstart', e => { 57 | e.preventDefault(); 58 | 59 | const windowWidth = window.innerWidth; 60 | const windowHeight = window.innerHeight; 61 | 62 | touches++; 63 | 64 | if (touching.active) { 65 | return; 66 | } 67 | touching.active = true; 68 | 69 | const prevPos = { 70 | x: e.touches[0].pageX, 71 | y: e.touches[0].pageY 72 | }; 73 | 74 | const touchmove = e => { 75 | e.preventDefault(); 76 | 77 | const pos = { 78 | x: e.touches[0].pageX, 79 | y: e.touches[0].pageY 80 | }; 81 | 82 | const diff = { 83 | x: pos.x - prevPos.x, 84 | y: pos.y - prevPos.y 85 | }; 86 | 87 | prevPos.x = pos.x; 88 | prevPos.y = pos.y; 89 | 90 | touching.up -= diff.y / (windowHeight / 3); 91 | touching.down += diff.y / (windowHeight / 3); 92 | touching.left -= diff.x / (windowWidth / 3); 93 | touching.right += diff.x / (windowWidth / 3); 94 | 95 | touching.shoot = e.touches[1] != null; 96 | 97 | touching.up = Math.max(0, Math.min(1, touching.up)); 98 | touching.down = Math.max(0, Math.min(1, touching.down)); 99 | touching.left = Math.max(0, Math.min(1, touching.left)); 100 | touching.right = Math.max(0, Math.min(1, touching.right)); 101 | }; 102 | 103 | const touchend = e => { 104 | touches--; 105 | 106 | if (touches) { 107 | return; 108 | } 109 | 110 | touching.active = false; 111 | touching.up = 0; 112 | touching.down = 0; 113 | touching.left = 0; 114 | touching.right = 0; 115 | 116 | window.removeEventListener('touchmove', touchmove); 117 | window.removeEventListener('touchend', touchend); 118 | }; 119 | 120 | window.addEventListener('touchmove', touchmove); 121 | window.addEventListener('touchend', touchend); 122 | }); 123 | 124 | const gamepadIndexes = []; 125 | const gamepads = {}; 126 | 127 | window.addEventListener('gamepadconnected', (e) => { 128 | const { index } = e.gamepad; 129 | gamepadIndexes.push(index); 130 | }); 131 | 132 | window.addEventListener('gamepaddisconnected', (e) => { 133 | const { index } = e.gamepad; 134 | for (let i = 0; i < gamepadIndexes.length; i++) { 135 | if (gamepadIndexes[i] === index) { 136 | gamepadIndexes.splice(i--, 1); 137 | } 138 | } 139 | }); 140 | 141 | function buttonValue (button) { 142 | return button.pressed ? button.value : 0; 143 | } 144 | 145 | function updateGamepads () { 146 | gamepadIndexes.forEach(gamepadIndex => { 147 | const gamepad = navigator.getGamepads()[gamepadIndex]; 148 | if (gamepad) { 149 | const { buttons, axes } = gamepad; 150 | const currentGamepad = gamepads[gamepadIndex] = { 151 | up: Math.max( 152 | buttonValue(buttons[0]), 153 | buttonValue(buttons[12]), 154 | buttonValue(buttons[7]), 155 | (axes[1] < 0 ? -axes[1] : 0), 156 | (axes[3] < 0 ? -axes[3] : 0) 157 | ), 158 | down: Math.max( 159 | buttonValue(buttons[1]), 160 | buttonValue(buttons[13]), 161 | buttonValue(buttons[6]), 162 | (axes[1] > 0 ? axes[1] : 0), 163 | (axes[3] > 0 ? axes[3] : 0) 164 | ), 165 | left: Math.max( 166 | buttonValue(buttons[14]), 167 | (axes[0] < 0 ? -axes[0] : 0), 168 | (axes[2] < 0 ? -axes[2] : 0) 169 | ), 170 | right: Math.max( 171 | buttonValue(buttons[15]), 172 | (axes[0] > 0 ? axes[0] : 0), 173 | (axes[2] > 0 ? axes[2] : 0) 174 | ), 175 | shoot: buttonValue(buttons[2]) || buttonValue(buttons[5]) 176 | }; 177 | currentGamepad.active = (() => { 178 | const { up, down, left, right, shoot } = currentGamepad; 179 | return up || down || left || right || shoot; 180 | })(); 181 | } else { 182 | delete gamepads[gamepadIndex]; 183 | } 184 | }); 185 | setTimeout(updateGamepads, 1000 / 60); 186 | } 187 | 188 | updateGamepads(); 189 | })(); 190 | -------------------------------------------------------------------------------- /car.css: -------------------------------------------------------------------------------- 1 | body { 2 | overflow: hidden; 3 | background-color: hsl(0, 0%, 90%); 4 | font-family: -apple-system, BlinkMacSystemFont, avenir next, avenir, segoe ui, helvetica neue, helvetica, Ubuntu, roboto, noto, arial, sans-serif; 5 | touch-action: none; 6 | user-select: none; 7 | -webkit-user-select: none; 8 | } 9 | 10 | canvas { 11 | position: absolute; 12 | top: 0; 13 | left: 0; 14 | background-color: hsl(0, 0%, 95%); 15 | } 16 | 17 | .wrapper { 18 | position: fixed; 19 | top: 0; 20 | left: 0; 21 | right: 0; 22 | bottom: 0; 23 | overflow: hidden; 24 | } 25 | 26 | .scene { 27 | position: absolute; 28 | width: 1500px; 29 | height: 1500px; 30 | overflow: hidden; 31 | } 32 | 33 | .car { 34 | position: absolute; 35 | } 36 | 37 | .car-body { 38 | position: absolute; 39 | top: -8px; 40 | left: -4px; 41 | width: 8px; 42 | height: 16px; 43 | border-radius: 2px; 44 | background-color: hsl(0, 0%, 50%); 45 | } 46 | 47 | .car-roof { 48 | position: absolute; 49 | top: 6px; 50 | left: 0px; 51 | width: 8px; 52 | height: 6px; 53 | border-radius: 2px; 54 | background-color: hsla(0, 0%, 100%, .375); 55 | } 56 | 57 | .car-body.shot { 58 | opacity: .5; 59 | } 60 | 61 | .car.red .car-body { 62 | background-color: hsl(0, 100%, 50%); 63 | } 64 | 65 | .car-name { 66 | position: absolute; 67 | top: 20px; 68 | left: 0; 69 | font-size: .625rem; 70 | color: hsla(0, 0%, 0%, .5); 71 | transform: translate(-50%, -50%); 72 | white-space: nowrap; 73 | } 74 | 75 | .car.red .car-name { 76 | color: hsl(0, 50%, 50%); 77 | } 78 | 79 | .bullet { 80 | position: absolute; 81 | top: -1px; 82 | left: -1px; 83 | width: 2px; 84 | height: 2px; 85 | border-radius: 1px; 86 | background-color: hsl(0, 0%, 15%); 87 | } 88 | 89 | .buttons { 90 | position: fixed; 91 | left: 0; 92 | top: 0; 93 | } 94 | 95 | .bottomleft { 96 | position: fixed; 97 | bottom: 0; 98 | left: 0; 99 | padding: .5rem; 100 | font-size: .75rem; 101 | } 102 | .name { 103 | position: fixed; 104 | top: 0; 105 | left: 0; 106 | right: 0; 107 | bottom: 0; 108 | } 109 | .name p { 110 | font-size: .75rem; 111 | margin: 0; 112 | margin-bottom: 1rem; 113 | } 114 | .name-bg { 115 | height: 100%; 116 | background-color: hsla(0, 0%, 0%, .75); 117 | } 118 | .name form { 119 | position: absolute; 120 | top: 50%; 121 | left: 50%; 122 | transform: translate(-50%, -50%); 123 | background-color: hsl(0, 0%, 100%); 124 | padding: 1rem; 125 | border-radius: .25rem; 126 | } 127 | .name form input { 128 | display: inline-block; 129 | font: inherit; 130 | padding: .5rem; 131 | } 132 | .name form button { 133 | display: inline-block; 134 | font: inherit; 135 | padding: .5rem; 136 | margin-left: .5rem; 137 | } 138 | .points { 139 | position: fixed; 140 | top: 0; 141 | right: 5rem; 142 | text-align: right; 143 | white-space: pre; 144 | font-size: .625rem; 145 | color: hsl(0, 0, 50%); 146 | padding: .5rem; 147 | } 148 | .map-item { 149 | position: absolute; 150 | top: -0.0625rem; 151 | left: -0.0625rem; 152 | width: 0.125rem; 153 | height: 0.125rem; 154 | background-color: hsl(0, 0%, 50%); 155 | border-radius: 0.0625rem; 156 | } -------------------------------------------------------------------------------- /car.js: -------------------------------------------------------------------------------- 1 | (async () => { 2 | /* global requestAnimationFrame, io */ 3 | 4 | // Physics 5 | 6 | const maxPower = 0.075; 7 | const maxReverse = 0.0375; 8 | const powerFactor = 0.001; 9 | const reverseFactor = 0.0005; 10 | 11 | const drag = 0.95; 12 | const angularDrag = 0.95; 13 | const turnSpeed = 0.002; 14 | 15 | const WIDTH = 1500; 16 | const HEIGHT = 1500; 17 | 18 | const $canvas = document.querySelector("canvas"); 19 | 20 | $canvas.width = WIDTH; 21 | $canvas.height = HEIGHT; 22 | 23 | const ctx = $canvas.getContext("2d"); 24 | 25 | ctx.fillStyle = "hsla(0, 0%, 25%, 0.25)"; 26 | 27 | const $scene = document.querySelector(".scene"); 28 | const $cars = document.querySelector(".cars"); 29 | const $map = document.querySelector(".map"); 30 | const $bullets = document.querySelector(".bullets"); 31 | 32 | const $points = document.querySelector(".points"); 33 | 34 | const localCar = { 35 | $el: document.querySelector(".car"), 36 | x: WIDTH / 2, 37 | y: HEIGHT / 2, 38 | xVelocity: 0, 39 | yVelocity: 0, 40 | power: 0, 41 | reverse: 0, 42 | angle: 0, 43 | angularVelocity: 0, 44 | isThrottling: false, 45 | isReversing: false, 46 | isShooting: false, 47 | points: 0, 48 | }; 49 | 50 | const scene = { 51 | x: window.innerWidth / 2 - localCar.x, 52 | y: window.innerHeight / 2 - localCar.y, 53 | }; 54 | 55 | const cars = [localCar]; 56 | const carsById = {}; 57 | 58 | if (window.location.search === "?test") { 59 | cars.push({ ...localCar }); 60 | cars[1].$el = cars[0].$el.cloneNode(true); 61 | cars[0].$el.parentNode.appendChild(cars[1].$el); 62 | } 63 | 64 | const bullets = []; 65 | 66 | function updateCar(car, i) { 67 | if (car.isHit || car.isShot) { 68 | if (car === localCar) { 69 | car.isHit = false; 70 | car.isShot = false; 71 | car.x = Math.random() * WIDTH; 72 | car.y = Math.random() * HEIGHT; 73 | car.xVelocity = 0; 74 | car.yVelocity = 0; 75 | sendParams(localCar); 76 | } 77 | } 78 | 79 | if (car.isThrottling) { 80 | car.power += powerFactor * car.isThrottling; 81 | } else { 82 | car.power -= powerFactor; 83 | } 84 | if (car.isReversing) { 85 | car.reverse += reverseFactor; 86 | } else { 87 | car.reverse -= reverseFactor; 88 | } 89 | 90 | car.power = Math.max(0, Math.min(maxPower, car.power)); 91 | car.reverse = Math.max(0, Math.min(maxReverse, car.reverse)); 92 | 93 | const direction = car.power > car.reverse ? 1 : -1; 94 | 95 | if (car.isTurningLeft) { 96 | car.angularVelocity -= direction * turnSpeed * car.isTurningLeft; 97 | } 98 | if (car.isTurningRight) { 99 | car.angularVelocity += direction * turnSpeed * car.isTurningRight; 100 | } 101 | 102 | car.xVelocity += Math.sin(car.angle) * (car.power - car.reverse); 103 | car.yVelocity += Math.cos(car.angle) * (car.power - car.reverse); 104 | 105 | car.x += car.xVelocity; 106 | car.y -= car.yVelocity; 107 | car.xVelocity *= drag; 108 | car.yVelocity *= drag; 109 | car.angle += car.angularVelocity; 110 | car.angularVelocity *= angularDrag; 111 | 112 | if (car.isShooting && !car.isShot && !car.isHit) { 113 | if (!car.lastShootAt || car.lastShootAt < Date.now() - 60) { 114 | car.lastShootAt = Date.now(); 115 | const { x, y, angle, xVelocity, yVelocity } = car; 116 | bullets.push({ 117 | local: car === localCar, 118 | x: x + Math.sin(angle) * 10, 119 | y: y - Math.cos(angle) * 10, 120 | angle, 121 | xVelocity: xVelocity + Math.sin(angle) * 1.25, 122 | yVelocity: yVelocity + Math.cos(angle) * 1.25, 123 | shootAt: Date.now(), 124 | }); 125 | } 126 | } 127 | } 128 | 129 | function update() { 130 | cars.forEach(updateCar); 131 | 132 | for (let i = 0; i < bullets.length; i++) { 133 | const bullet = bullets[i]; 134 | 135 | bullet.x += bullet.xVelocity; 136 | bullet.y -= bullet.yVelocity; 137 | } 138 | } 139 | 140 | let lastTime; 141 | let acc = 0; 142 | const step = 1 / 120; 143 | 144 | setInterval(() => { 145 | let changed; 146 | 147 | const canTurn = localCar.power > 0.0025 || localCar.reverse; 148 | 149 | const controls = 150 | localCar.name != null 151 | ? window.getControls() 152 | : { 153 | up: 0, 154 | left: 0, 155 | right: 0, 156 | down: 0, 157 | shoot: 0, 158 | }; 159 | 160 | const throttle = Math.round(controls.up * 10) / 10; 161 | const reverse = Math.round(controls.down * 10) / 10; 162 | const isShooting = controls.shoot; 163 | 164 | if (isShooting !== localCar.isShooting) { 165 | changed = true; 166 | localCar.isShooting = isShooting; 167 | } 168 | 169 | if ( 170 | localCar.isThrottling !== throttle || 171 | localCar.isReversing !== reverse 172 | ) { 173 | changed = true; 174 | localCar.isThrottling = throttle; 175 | localCar.isReversing = reverse; 176 | } 177 | const turnLeft = canTurn && Math.round(controls.left * 10) / 10; 178 | const turnRight = canTurn && Math.round(controls.right * 10) / 10; 179 | 180 | if (localCar.isTurningLeft !== turnLeft) { 181 | changed = true; 182 | localCar.isTurningLeft = turnLeft; 183 | } 184 | if (localCar.isTurningRight !== turnRight) { 185 | changed = true; 186 | localCar.isTurningRight = turnRight; 187 | } 188 | 189 | if (localCar.x > WIDTH + 7.5) { 190 | localCar.x -= WIDTH + 15; 191 | changed = true; 192 | } else if (localCar.x < -7.5) { 193 | localCar.x += WIDTH + 15; 194 | changed = true; 195 | } 196 | 197 | if (localCar.y > HEIGHT + 7.5) { 198 | localCar.y -= HEIGHT + 15; 199 | changed = true; 200 | } else if (localCar.y < -7.5) { 201 | localCar.y += HEIGHT + 15; 202 | changed = true; 203 | } 204 | 205 | for (let i = 0; i < cars.length; i++) { 206 | const car = cars[i]; 207 | 208 | if (localCar === car) { 209 | continue; 210 | } 211 | 212 | if (car.isShot) { 213 | continue; 214 | } 215 | 216 | if ( 217 | circlesHit( 218 | { x: car.x, y: car.y, r: 7.5 }, 219 | { x: localCar.x, y: localCar.y, r: 7.5 } 220 | ) 221 | ) { 222 | localCar.isHit = true; 223 | changed = true; 224 | } 225 | } 226 | 227 | for (let j = 0; j < cars.length; j++) { 228 | const car = cars[j]; 229 | 230 | for (let i = 0; i < bullets.length; i++) { 231 | const bullet = bullets[i]; 232 | 233 | if ( 234 | bullet && 235 | circlesHit( 236 | { x: car.x, y: car.y, r: 7.5 }, 237 | { x: bullet.x, y: bullet.y, r: 2 } 238 | ) 239 | ) { 240 | if (car !== localCar) { 241 | if (!car.isShot) { 242 | car.isShot = true; 243 | if (bullet.local) { 244 | localCar.points++; 245 | } 246 | changed = true; 247 | } 248 | continue; 249 | } 250 | car.isShot = true; 251 | changed = true; 252 | } 253 | } 254 | } 255 | 256 | const ms = Date.now(); 257 | if (lastTime) { 258 | acc += (ms - lastTime) / 1000; 259 | 260 | while (acc > step) { 261 | update(); 262 | 263 | acc -= step; 264 | } 265 | } 266 | 267 | lastTime = ms; 268 | 269 | if (changed) { 270 | sendParams(localCar); 271 | } 272 | }, 1000 / 120); 273 | 274 | function renderCar(car, index) { 275 | const { x, y, angle, power, reverse, angularVelocity } = car; 276 | 277 | if (!car.$body) { 278 | car.$body = car.$el.querySelector(".car-body"); 279 | } 280 | 281 | if (!car.$name) { 282 | car.$name = car.$el.querySelector(".car-name"); 283 | } 284 | 285 | car.$el.style.transform = `translate(${x}px, ${y}px)`; 286 | car.$body.style.transform = `rotate(${(angle * 180) / Math.PI}deg)`; 287 | car.$name.textContent = car.name || ""; 288 | 289 | if (car.isShot) { 290 | car.$body.classList.add("shot"); 291 | } else { 292 | car.$body.classList.remove("shot"); 293 | } 294 | 295 | if (power > 0.0025 || reverse) { 296 | if ( 297 | (maxReverse === reverse || maxPower === power) && 298 | Math.abs(angularVelocity) < 0.002 299 | ) { 300 | return; 301 | } 302 | ctx.fillRect( 303 | x - 304 | Math.cos(angle + (3 * Math.PI) / 2) * 3 + 305 | Math.cos(angle + (2 * Math.PI) / 2) * 3, 306 | y - 307 | Math.sin(angle + (3 * Math.PI) / 2) * 3 + 308 | Math.sin(angle + (2 * Math.PI) / 2) * 3, 309 | 1, 310 | 1 311 | ); 312 | ctx.fillRect( 313 | x - 314 | Math.cos(angle + (3 * Math.PI) / 2) * 3 + 315 | Math.cos(angle + (4 * Math.PI) / 2) * 3, 316 | y - 317 | Math.sin(angle + (3 * Math.PI) / 2) * 3 + 318 | Math.sin(angle + (4 * Math.PI) / 2) * 3, 319 | 1, 320 | 1 321 | ); 322 | } 323 | 324 | if (car !== localCar) { 325 | const angle = Math.atan2(car.y - localCar.y, car.x - localCar.x); 326 | 327 | let $mapitem = $map.childNodes[index - 1]; 328 | 329 | if (!$mapitem) { 330 | $mapitem = document.createElement("div"); 331 | $mapitem.classList.add("map-item"); 332 | $map.appendChild($mapitem); 333 | } 334 | 335 | const x = localCar.x + Math.cos(angle) * 12.5; 336 | const y = localCar.y + Math.sin(angle) * 12.5; 337 | 338 | $mapitem.style.transform = `translate(${x}px, ${y}px)`; 339 | } 340 | } 341 | 342 | function render(ms) { 343 | requestAnimationFrame(render); 344 | 345 | $points.textContent = cars 346 | .slice() 347 | .sort((a, b) => (b.points || 0) - (a.points || 0)) 348 | .map((car) => { 349 | return [car.name || "anonymous", car.points || 0].join(": "); 350 | }) 351 | .join("\n"); 352 | 353 | cars.forEach(renderCar); 354 | 355 | while ($map.childNodes.length > cars.length - 1) { 356 | $map.removeChild($map.childNodes[$map.childNodes.length - 1]); 357 | } 358 | 359 | const now = Date.now(); 360 | 361 | for (let i = 0; i < bullets.length; i++) { 362 | const bullet = bullets[i]; 363 | const { x, y, shootAt } = bullet; 364 | if (!bullet.$el) { 365 | const $el = (bullet.$el = document.createElement("div")); 366 | $el.classList.add("bullet"); 367 | $bullets.appendChild($el); 368 | } 369 | bullet.$el.style.transform = `translate(${x}px, ${y}px)`; 370 | 371 | if (shootAt < now - 600) { 372 | if (bullet.$el) { 373 | $bullets.removeChild(bullet.$el); 374 | bullets.splice(i--, 1); 375 | } 376 | } 377 | } 378 | 379 | scene.x = window.innerWidth / 2 - localCar.x; 380 | scene.y = window.innerHeight / 2 - localCar.y; 381 | 382 | $scene.style.transform = `translate(${scene.x}px, ${scene.y}px)`; 383 | } 384 | 385 | requestAnimationFrame(render); 386 | 387 | const $name = document.querySelector(".name"); 388 | const $p = document.querySelector("p"); 389 | 390 | $name.querySelector("form").onsubmit = (e) => { 391 | e.preventDefault(); 392 | 393 | localCar.name = $name.querySelector("input").value || ""; 394 | 395 | $name.parentNode.removeChild($name); 396 | }; 397 | 398 | const host = await fastestPing( 399 | [ 400 | { 401 | host: "https://car-hel1.pakastin.fi", 402 | city: "Helsinki, Finland", 403 | continent: "Europe", 404 | }, 405 | { 406 | host: "https://car-nbg1.pakastin.fi", 407 | city: "Nuremberg, Germany", 408 | continent: "Europe", 409 | }, 410 | { 411 | host: "https://car-fsn1.pakastin.fi", 412 | city: "Falkenstein, Germany", 413 | continent: "Europe", 414 | }, 415 | ], 416 | { host: "https://car.pakastin.fi", city: "Cloudflare (fallback)" } 417 | ); 418 | 419 | $p.textContent = "Connected to " + host.city + "."; 420 | 421 | const socket = io(host.host); 422 | 423 | socket.on("connect", () => { 424 | sendParams(localCar); 425 | }); 426 | 427 | socket.on("join", () => { 428 | sendParams(localCar); 429 | }); 430 | 431 | socket.on("params", ({ id, params }) => { 432 | let car = carsById[id]; 433 | 434 | if (!car) { 435 | const $el = document.createElement("div"); 436 | $el.classList.add("car"); 437 | const $body = document.createElement("div"); 438 | $body.classList.add("car-body"); 439 | const $roof = document.createElement("div"); 440 | $roof.classList.add("car-roof"); 441 | const $name = document.createElement("div"); 442 | $name.classList.add("car-name"); 443 | $body.appendChild($roof); 444 | $el.appendChild($body); 445 | $el.appendChild($name); 446 | $cars.appendChild($el); 447 | car = { 448 | $el, 449 | }; 450 | carsById[id] = car; 451 | cars.push(car); 452 | } 453 | 454 | for (const key in params) { 455 | if (key !== "el") { 456 | car[key] = params[key]; 457 | } 458 | } 459 | }); 460 | 461 | socket.on("leave", (id) => { 462 | const car = carsById[id]; 463 | 464 | if (!car) { 465 | return console.error("Car not found"); 466 | } 467 | 468 | for (let i = 0; i < cars.length; i++) { 469 | if (cars[i] === car) { 470 | cars.splice(i, 1); 471 | break; 472 | } 473 | } 474 | 475 | if (car.$el.parentNode) { 476 | car.$el.parentNode.removeChild(car.$el); 477 | } 478 | delete carsById[id]; 479 | }); 480 | 481 | function sendParams(car) { 482 | const { 483 | x, 484 | y, 485 | xVelocity, 486 | yVelocity, 487 | power, 488 | reverse, 489 | angle, 490 | angularVelocity, 491 | isThrottling, 492 | isReversing, 493 | isShooting, 494 | isTurningLeft, 495 | isTurningRight, 496 | isHit, 497 | isShot, 498 | name, 499 | points, 500 | } = car; 501 | 502 | socket.emit("params", { 503 | x, 504 | y, 505 | xVelocity, 506 | yVelocity, 507 | power, 508 | reverse, 509 | angle, 510 | angularVelocity, 511 | isThrottling, 512 | isReversing, 513 | isShooting, 514 | isTurningLeft, 515 | isTurningRight, 516 | isHit, 517 | isShot, 518 | name, 519 | points, 520 | }); 521 | } 522 | 523 | const $disconnect = document.querySelector(".disconnect"); 524 | 525 | $disconnect.onclick = () => { 526 | socket.disconnect(); 527 | 528 | localCar.name = ""; 529 | 530 | while (cars.length > 1) { 531 | const car = cars.pop(); 532 | 533 | car.$el.parentNode.removeChild(car.$el); 534 | } 535 | 536 | $disconnect.parentNode.removeChild($disconnect); 537 | }; 538 | 539 | const $clearScreen = document.querySelector(".clearscreen"); 540 | 541 | $clearScreen.onclick = () => { 542 | ctx.clearRect(0, 0, WIDTH, HEIGHT); 543 | }; 544 | 545 | setInterval(() => { 546 | ctx.fillStyle = "hsla(0, 0%, 95%, 0.2)"; 547 | ctx.fillRect(0, 0, WIDTH, HEIGHT); 548 | ctx.fillStyle = "hsla(0, 0%, 25%, 0.5)"; 549 | }, 15 * 1000); 550 | 551 | function circlesHit({ x: x1, y: y1, r: r1 }, { x: x2, y: y2, r: r2 }) { 552 | return Math.sqrt((x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1)) < r1 + r2; 553 | } 554 | })(); 555 | 556 | async function fastestPing(hosts, fallback) { 557 | let result; 558 | 559 | try { 560 | const continent = Intl.DateTimeFormat() 561 | .resolvedOptions() 562 | .timeZone.split("/")[0]; 563 | 564 | hosts.sort((a, b) => { 565 | if (continent === a.continent && continent !== b.continent) { 566 | return -1; 567 | } else if (continent === b.continent && continent !== a.continent) { 568 | return 1; 569 | } else { 570 | return 0; 571 | } 572 | }); 573 | } catch (err) { 574 | console.error(err); 575 | } 576 | 577 | for (const host of hosts) { 578 | const abortController = new AbortController(); 579 | const abortTimeout = setTimeout(() => abortController.abort(), 5000); 580 | try { 581 | const startTime = Date.now(); 582 | await fetch(host.host + "/ping", { 583 | signal: abortController.signal, 584 | }); 585 | clearTimeout(abortTimeout); 586 | const latency = Date.now() - startTime; 587 | 588 | if (result) { 589 | if (latency < result.latency) { 590 | result = { host, latency }; 591 | } 592 | } else { 593 | result = { host, latency }; 594 | } 595 | } catch (err) { 596 | console.error(err); 597 | clearTimeout(abortTimeout); 598 | } 599 | } 600 | 601 | if (result) { 602 | return result.host; 603 | } else { 604 | return fallback; 605 | } 606 | } 607 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | Multiplayer 2d car game! 13 | 14 | 15 | 16 |
17 |
18 | 19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 | 29 | 30 |
31 |

Connecting...

32 |
Use keyboard (up/down/left/right/space), swiping (shoot with second finger) or gamepad to drive the red car with other cars online!
33 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /og.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pakastin/car/77c212d743718a8fa2a01dfd8649b23d66050598/og.png --------------------------------------------------------------------------------