[READ-ONLY] Mirror of https://github.com/flo-bit/shadow-shmup. Fun and casual top-down, roguelike shoot 'em up browsergame with shadow elements and colorful neon effects flo-bit.dev/shadow-shmup/
browsergame pixijs rapier2d roguelike shoot-em-up typescript webgame
0

Configure Feed

Select the types of activity you want to include in your feed.

commit

Florian (Jul 28, 2024, 12:03 PM +0200) eca6e667 b487c240

+658 -175
+62 -65
Readme.md
··· 2 2 3 3 The shadows are coming for you! Can you survive the onslaught? Move with WASD, auto-fires nearest enemy. 4 4 5 - ## Development 6 - 7 - 1. Clone the repository 8 - 9 - ```bash 10 - git clone https://github.com/flo-bit/shadow-shmup.git 11 - ``` 12 - 13 - 2. Install dependencies 14 - 15 - ```bash 16 - npm install 17 - ``` 18 - 19 - 3. Run the development server 20 - 21 - ```bash 22 - npm run dev 23 - ``` 24 - 25 - 4. Open the game in your browser at [http://localhost:5173](http://localhost:5173) 26 - 27 - 28 5 ## TODO 29 6 30 - # shadow shmup 31 - 32 - - count kills, deaths, damage taken, damage dealt 33 - - enemies attack 34 - - [x] shoot (pentagon) 35 - - pulse 36 - - [x] ram/explode (triangle) 37 - - [x] die 38 - - weapons 39 - - recoil on player 40 - - [x] impulse on bullet impact (done for enemies) 41 - - different weapons (different shooting mechanics, colors) 42 - - show weapon 43 - - [x] change player rigidbody to dynamic 44 - - different enemies 45 - - [x] different shapes, attacks 46 - - waves 47 - - [x] eyes blinking 48 - - [x] obstacles 49 - - [x] enemies drop stuff, player can pick it up 50 - - [x] music 51 7 - [ ] sound effects 52 - - [ ] player shooting 8 + - [x] player shooting 53 9 - [ ] player getting hit 10 + - [ ] player dying 54 11 - [ ] enemy getting hit 55 12 - [ ] enemy shooting 56 13 - [ ] enemy dying 57 14 - [ ] special effects 58 - - [x] player getting hit (flash screen) 59 - - [x] menu: solo, co-op, credits 60 - - [x] credits 61 - - [x] items ("potions") 62 - - [x] disappear after a while 63 - - [x] different potions (depending on monster killed?) 15 + - [ ] player getting hit (flash screen -> change to something else) 64 16 - [ ] player upgrades (all upgrades use potions, and have one positive and one negative effect) 65 - - movement speed 66 - - fire rate 17 + - weapon fire rate 18 + - weapon shooting distance 19 + 67 20 - bullet speed 68 21 - bullet damage 22 + - bullet piercing 23 + 24 + - movement speed 69 25 - player health 70 - - viewing distance 71 - - shooting distance 72 - - player size 73 - - bullet count? 74 - - bullet spread? 75 26 - items stay longer 76 - - lights 77 - - make own light class and add to player 78 - - enemies should be lit by light distance instead of player distance 79 27 - [ ] powerups 80 - - [ ] randomly spawn 81 - - [ ] disappear after a while 28 + - [ ] randomly spawn (with some chance in each cell) 82 29 - [ ] can be used for a limited time 83 30 - [ ] different powerups: 84 31 - [ ] invincibility ··· 91 38 - [ ] items stay as long as powerup is active 92 39 - [ ] item magnet 93 40 - [ ] get all items 94 - - [x] scale canvas so that it always shows a 1000x1000 area or something like that 95 41 - [ ] enemy upgrades (some enemies are upgraded every wave) 96 42 - damage 97 43 - speed ··· 100 46 - bullet speed 101 47 - bullet count 102 48 - fire rate 103 - - [ ] indicator for sphere enemy shooting (+pause before shooting) 49 + - [ ] stats 50 + - [ ] count kills, deaths, damage taken, damage dealt 51 + - [ ] show stats at end of game 52 + - weapons 53 + - recoil on player 54 + - [x] impulse on bullet impact (done for enemies) 55 + - [x] indicator for sphere enemy shooting (+pause before shooting) 56 + - [x] move items towards player 57 + - [x] enemies attack 58 + - [x] shoot (pentagon) 59 + - [x] pulse (sphere) 60 + - [x] ram/explode (triangle) 61 + - [x] die 62 + - [x] change player rigidbody to dynamic 63 + - [x] different enemies 64 + - [x] different shapes, attacks 65 + - [x] waves 66 + - [x] eyes blinking 67 + - [x] obstacles 68 + - [x] enemies drop stuff, player can pick it up 69 + - [x] music 70 + - [x] menu: solo, co-op, credits 71 + - [x] credits 72 + - [x] items ("potions") 73 + - [x] disappear after a while 74 + - [x] different potions (depending on monster killed?) 75 + - [x] lights 76 + - [x] make own light class and add to player 77 + - [x] enemies should be lit by light distance instead of player distance 78 + - [x] scale canvas so that it always shows a 1000x1000 area or something like that 79 + 80 + ## Development 81 + 82 + 1. Clone the repository 83 + 84 + ```bash 85 + git clone https://github.com/flo-bit/shadow-shmup.git 86 + ``` 87 + 88 + 2. Install dependencies 89 + 90 + ```bash 91 + npm install 92 + ``` 93 + 94 + 3. Run the development server 95 + 96 + ```bash 97 + npm run dev 98 + ``` 99 + 100 + 4. Open the game in your browser at [http://localhost:5173](http://localhost:5173) 104 101 105 102 106 103 ## credits:
+33 -13
index.html
··· 3 3 <head> 4 4 <meta charset="UTF-8" /> 5 5 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 - <meta name="apple-mobile-web-app-capable" content="yes"> 7 - <meta name="theme-color" content="#000000"/> 6 + <meta name="apple-mobile-web-app-capable" content="yes" /> 7 + <meta name="apple-mobile-web-app-status-bar-style" content="black" /> 8 + <meta name="apple-mobile-web-app-title" content="shadow shooter" /> 8 9 9 - <link rel="icon" href="/icons/favicon.ico" sizes="32x32"> 10 - <link rel="icon" href="/icons/icon.svg" type="image/svg+xml"> 11 - <link rel="apple-touch-icon" href="/icons/apple-touch-icon.png"> 12 - <link rel="manifest" href="/icons/manifest.webmanifest"> 10 + 11 + <link rel="icon" href="/icons/favicon.ico" sizes="32x32" /> 12 + <link rel="icon" href="/icons/icon.svg" type="image/svg+xml" /> 13 + <link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" /> 14 + <link rel="manifest" href="/icons/manifest.webmanifest" /> 13 15 14 16 <title>shadow shooter</title> 15 17 ··· 18 20 <body class="m-0 bg-black overflow-hidden overscroll-none select-none"> 19 21 <script src="src/app.ts" type="module"></script> 20 22 21 - <div id="timer" class="absolute top-1 right-1 text-white font-bold">0</div> 23 + <div id="counter" class="absolute top-1 right-1 text-white font-bold"></div> 22 24 23 - <div id="debug" class="absolute bottom-1 left-1 text-white font-bold hidden"></div> 25 + <div id="debug" class="absolute bottom-1 left-1 text-white font-bold"></div> 24 26 25 27 <!-- ui: title, play button --> 26 28 <div ··· 32 34 <h1 id="title" class="font-bold text-5xl mb-8">shadow shooter</h1> 33 35 <button 34 36 id="play" 35 - class="text-2xl font-semibold border border-white py-2 px-4 bg-white/10 hover:bg-white/20 mb-4" 37 + class="text-2xl font-semibold border border-white/20 rounded-xl py-2 px-4 bg-white/10 hover:bg-white/20 backdrop-blur-sm mb-4" 36 38 > 37 39 solo 38 40 </button> 39 41 <button 40 42 id="play-coop" 41 - class="text-2xl font-semibold border border-white py-2 px-4 bg-white/10 hover:bg-white/20" 43 + class="text-2xl font-semibold border border-white/20 rounded-xl py-2 px-4 bg-white/10 hover:bg-white/20 backdrop-blur-sm mb-4" 42 44 > 43 45 co-op 44 46 </button> ··· 47 49 <div class="flex items-center gap-3"> 48 50 code by <span class="font-semibold">flo-bit</span> 49 51 <a href="https://github.com/flo-bit" target="_blank"> 50 - <svg fill="currentColor" viewBox="0 0 24 24" aria-hidden="true" class="w-5 fill-white hover:fill-cyan-400"> 52 + <svg 53 + fill="currentColor" 54 + viewBox="0 0 24 24" 55 + aria-hidden="true" 56 + class="w-5 fill-white hover:fill-cyan-400" 57 + > 51 58 <path 52 59 fill-rule="evenodd" 53 60 d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" ··· 159 166 </div> 160 167 </div> 161 168 162 - <div id="wave" class="absolute inset-0 h-[100dhv] w-screen z-10 flex items-center justify-center hidden"> 169 + <div 170 + id="wave" 171 + class="absolute inset-0 h-[100dhv] w-screen z-10 flex items-center justify-center hidden" 172 + > 163 173 <div id="wave-text" class="text-8xl font-bold text-neutral-200">Wave</div> 164 - 174 + </div> 175 + 176 + <div id="upgrades" class="hidden z-50 absolute inset-0 overflow-y-scroll py-2 sm:py-16 px-2 bg-black/70"> 177 + <div 178 + id="upgrades-container" 179 + class="overflow-hidden rounded-lg bg-white/5 mx-auto max-w-5xl backdrop-blur-sm shadow sm:grid sm:grid-cols-2" 180 + > 181 + 182 + </div> 165 183 </div> 184 + 185 + <div id="items" class="absolute bottom-1 right-3 text-white font-bold text-xl flex flex-col gap-1"></div> 166 186 </body> 167 187 </html>
+67 -32
src/app.ts
··· 21 21 import { ItemManager } from './item-manager.js'; 22 22 import { LightManager } from './light-manager.js'; 23 23 import { createNoiseSprite } from './helper.js'; 24 + import { addUpgradeOption } from './upgrades.js'; 25 + import { UpgradeManager } from './upgrade-manager.js'; 24 26 25 27 export default class Game { 26 28 container: PIXI.Container; ··· 36 38 waveManager?: WaveManager; 37 39 38 40 itemManager: ItemManager; 41 + 42 + upgradeManager: UpgradeManager; 39 43 40 44 lightManager: LightManager; 41 45 ··· 47 51 48 52 debug: boolean = false; 49 53 50 - showStats: boolean = false; 54 + showStats: boolean = true; 51 55 52 56 stats?: Stats; 53 57 ··· 64 68 startWave = 0; 65 69 66 70 minWidth = 700; 67 - minHeight = 1000; 71 + minHeight = 900; 68 72 69 73 constructor() { 70 74 this.setup(); ··· 76 80 this.obstacleManager = new ObstacleManager(this); 77 81 this.itemManager = new ItemManager(this); 78 82 this.lightManager = new LightManager(this); 83 + this.upgradeManager = new UpgradeManager(this); 79 84 80 85 sound.add('music-intro', { 81 86 url: './music-intro.mp3', ··· 83 88 }); 84 89 sound.add('music', { url: './music.mp3', loop: true, volume: 0.3 }); 85 90 sound.add('laser', { url: './laser.mp3', volume: 0.1 }); 91 + 92 + // get upgrade-container 93 + const upgradeContainer = document.getElementById('upgrades-container'); 94 + // if upgradeContainer exists 95 + if (upgradeContainer) { 96 + for (let i = 0; i < 6; i++) { 97 + let option = addUpgradeOption({ 98 + index: i, 99 + total: 6, 100 + iconName: 'heart', 101 + perk: 'Health', 102 + perkValue: '+10%', 103 + minusStat: 'Speed', 104 + minusStatValue: '-0.1', 105 + price: [ 106 + { color: 'bg-orange-400', amount: Math.floor(Math.random() * 10 + 1) }, 107 + { color: 'bg-sky-400', amount: Math.floor(Math.random() * 10 + 1) } 108 + ], 109 + empty: i == 5 110 + }); 111 + upgradeContainer.appendChild(option); 112 + } 113 + } 86 114 } 87 115 88 116 async setupPhysicsWorld() { ··· 134 162 const bloomFilter = new AdvancedBloomFilter({ 135 163 threshold: 0.2, 136 164 quality: 32, 137 - bloomScale: 1, 165 + bloomScale: 1.4, 138 166 blur: 4 139 167 }); 140 168 app.stage.filters = [bloomFilter]; ··· 186 214 187 215 // move the container so that the player is always in the center 188 216 let position = this.playerManager?.getCenter(); 189 - if (position) { 217 + if (position && this.playing) { 190 218 position.x = -position.x; 191 219 position.y = -position.y; 192 220 193 221 const interpolationSpeed = 0.05; 194 222 const interpolationFactor = 1 - Math.pow(1 - interpolationSpeed, deltaTime / (1000 / 60)); 195 223 196 - //this.container.x += (position.x - this.container.x) * interpolationFactor; 197 - //this.container.y += (position.y - this.container.y) * interpolationFactor; 224 + this.container.x += (position.x - this.container.x) * interpolationFactor; 225 + this.container.y += (position.y - this.container.y) * interpolationFactor; 198 226 199 - this.container.x = position.x; 200 - this.container.y = position.y; 227 + // this.container.x = position.x; 228 + // this.container.y = position.y; 201 229 } 202 230 203 231 if (this.stats) this.stats.end(); ··· 295 323 296 324 enemy.impulse(projectile.vx * 200000, -projectile.vy * 200000); 297 325 enemy.takeDamage(projectile.damage); 298 - //projectile.destroy(); 326 + 327 + projectile.piercing--; 328 + if (projectile.piercing < 0) projectile.destroy(); 299 329 } 300 330 301 331 if (enemy && player && enemy.hitPlayer) { ··· 309 339 projectile.destroy(); 310 340 } 311 341 312 - console.log(player, item); 313 342 if (player && item) { 314 343 item.pickup(player); 315 344 } ··· 344 373 this.playerManager?.update(deltaTime, this.keys); 345 374 346 375 this.playingTime += deltaTime; 347 - if (Math.floor(this.playingTime / 1000) !== Math.floor((this.playingTime - deltaTime) / 1000)) { 348 - let timer = document.getElementById('timer'); 349 - if (timer) timer.innerText = Math.floor(this.playingTime / 1000).toString(); 350 - } 351 376 352 377 this.obstacleManager.update(deltaTime); 353 378 ··· 382 407 const wave = this.waveManager?.getCurrentWave(); 383 408 const waveUI = document.getElementById('wave'); 384 409 const waveText = document.getElementById('wave-text'); 410 + 385 411 if (wave && !wave.isActive && waveText && this.playing) { 386 412 waveUI?.classList.remove('hidden'); 387 413 waveText.innerText = `Wave ${wave.index + 1}`; 414 + 415 + let counter = document.getElementById('counter'); 416 + if (counter) counter.innerText = (wave.index + 1).toString(); 388 417 } else { 389 418 waveUI?.classList.add('hidden'); 390 419 } ··· 392 421 this.projectileManager?.update(deltaTime); 393 422 394 423 if (this.playerManager?.allDead()) { 395 - this.enemyManager?.killAll(); 396 - this.projectileManager?.clearAllProjectiles(); 397 - 398 - this.playing = false; 399 - 400 - const ui = document.getElementById('ui'); 401 - ui?.classList.remove('hidden'); 402 - 403 - const title = document.getElementById('title'); 404 - // set inner text to "Game Over" 405 - if (title) title.innerText = 'Game Over'; 406 - 407 - this.waveManager = new WaveManager(this, this.startWave); 424 + this.gameOver(); 408 425 } 409 426 410 - if (this.debug) { 427 + if (this.debug || this.showStats) { 411 428 // get debug text 412 429 let debugText = document.getElementById('debug'); 413 430 if (debugText) { ··· 416 433 } 417 434 } 418 435 436 + gameOver() { 437 + this.enemyManager?.killAll(); 438 + this.projectileManager?.clearAllProjectiles(); 439 + this.itemManager.destroyAll(); 440 + 441 + this.playing = false; 442 + 443 + const ui = document.getElementById('ui'); 444 + ui?.classList.remove('hidden'); 445 + 446 + const title = document.getElementById('title'); 447 + // set inner text to "Game Over" 448 + if (title) title.innerText = 'Game Over'; 449 + 450 + this.waveManager = new WaveManager(this, this.startWave); 451 + 452 + let counter = document.getElementById('counter'); 453 + if (counter) counter.innerText = ''; 454 + 455 + this.upgradeManager.reset(); 456 + } 457 + 419 458 handleKeyDown(e: KeyboardEvent) { 420 459 this.keys[e.key.toLowerCase()] = true; 421 460 ··· 469 508 470 509 debugGraphics.moveTo(vtx[i * 4], -vtx[i * 4 + 1]); 471 510 debugGraphics.lineTo(vtx[i * 4 + 2], -vtx[i * 4 + 3]); 472 - } 473 - 474 - for (let i = 0; i < vtx.length / 2; i += 2) { 475 - debugGraphics.circle(vtx[i], -vtx[i + 1], 2).fill(0xffffff); 476 511 } 477 512 } 478 513 }
+67 -34
src/controls.ts
··· 5 5 6 6 game: Game; 7 7 8 + deltaTouch: { x: number; y: number } | undefined = undefined; 9 + 8 10 constructor(game: Game) { 9 11 this.game = game; 10 12 ··· 21 23 22 24 let startX: number | undefined, startY: number | undefined; 23 25 // mobile controls, if touch is moved at least 10 pixels in any direction, it will be considered 24 - let min = 20; 26 + let min = 10; 27 + let max = 100; 28 + 25 29 window.addEventListener('touchstart', (e) => { 26 30 let touch = e.touches[0]; 27 31 startX = touch.clientX; 28 32 startY = touch.clientY; 33 + 34 + console.log('touchstart', startX, startY); 35 + 36 + this.deltaTouch = { x: 0, y: 0 }; 29 37 }); 30 38 31 39 window.addEventListener('touchmove', (e) => { 32 40 let touch = e.touches[0]; 33 - if (startX === undefined || startY === undefined) return; 41 + if (startX === undefined || startY === undefined || this.deltaTouch === undefined) return; 34 42 35 43 let dx = touch.clientX - startX; 36 44 let dy = touch.clientY - startY; 37 45 38 46 if (Math.abs(dx) > min) { 39 - this.keys[dx > 0 ? 'd' : 'a'] = true; 47 + this.deltaTouch.x = 48 + Math.max(Math.min((Math.abs(dx) - min) / (max - min), 1), 0) * (dx > 0 ? 1 : -1); 40 49 } else { 41 - this.keys['d'] = false; 42 - this.keys['a'] = false; 50 + this.deltaTouch.x = 0; 43 51 } 44 52 45 53 if (Math.abs(dy) > min) { 46 - this.keys[dy > 0 ? 's' : 'w'] = true; 54 + this.deltaTouch.y = 55 + Math.max(Math.min((Math.abs(dy) - min) / (max - min), 1), 0) * (dy > 0 ? 1 : -1); 47 56 } else { 48 - this.keys['s'] = false; 49 - this.keys['w'] = false; 57 + this.deltaTouch.y = 0; 58 + } 59 + 60 + let dist = Math.hypot(dx, dy); 61 + 62 + if (dist > max) { 63 + // move startX, startY to be max pixels away from current touch 64 + startX += (dx / dist) * (dist - max); 65 + startY += (dy / dist) * (dist - max); 66 + 67 + console.log('moved', startX, startY); 50 68 } 51 69 }); 52 70 53 71 window.addEventListener('touchend', (e) => { 54 - this.keys['w'] = false; 55 - this.keys['a'] = false; 56 - this.keys['s'] = false; 57 - this.keys['d'] = false; 72 + this.deltaTouch = undefined; 73 + startX = undefined; 74 + startY = undefined; 58 75 }); 59 76 } 60 77 ··· 62 79 return this.game.playerManager?.players.length ?? 1; 63 80 } 64 81 65 - left(num: number = 0) { 82 + x(num: number = 0) { 66 83 let gamepad = navigator.getGamepads()[0]; 67 84 let secondGamepad = navigator.getGamepads()[1]; 68 85 69 - if (this.playerCount === 1) 70 - return this.keys['a'] || this.keys['arrowleft'] || (gamepad && gamepad.axes[0] < -0.5); 86 + if (num === 0 && this.deltaTouch) return this.deltaTouch.x; 87 + if (num === 0 && gamepad) return gamepad.axes[0]; 88 + if (num === 1 && secondGamepad) return secondGamepad.axes[0]; 71 89 72 - if (num === 0) return this.keys['a'] || (gamepad && gamepad.axes[0] < -0.5); 73 - if (num === 1) return this.keys['arrowleft'] || (secondGamepad && secondGamepad.axes[0] < -0.5); 90 + return this.left(num) + this.right(num); 74 91 } 75 - right(num: number = 0) { 92 + y(num: number = 0) { 76 93 let gamepad = navigator.getGamepads()[0]; 77 94 let secondGamepad = navigator.getGamepads()[1]; 78 - if (this.playerCount === 1) 79 - return this.keys['d'] || this.keys['arrowright'] || (gamepad && gamepad.axes[0] > 0.5); 95 + 96 + if (num === 0 && this.deltaTouch) return this.deltaTouch.y; 97 + if (num === 0 && gamepad) return gamepad.axes[1]; 98 + if (num === 1 && secondGamepad) return secondGamepad.axes[1]; 99 + 100 + return this.up(num) + this.down(num); 101 + } 102 + 103 + left(num: number = 0) { 104 + if (this.playerCount === 1) return this.keys['a'] || this.keys['arrowleft'] ? -1 : 0; 105 + 106 + if (num === 0) return this.keys['a'] ? -1 : 0; 107 + if (num === 1) return this.keys['arrowleft'] ? -1 : 0; 80 108 81 - if (num === 0) return this.keys['d'] || (gamepad && gamepad.axes[0] > 0.5); 82 - if (num === 1) return this.keys['arrowright'] || (secondGamepad && secondGamepad.axes[0] > 0.5); 109 + return 0; 110 + } 111 + right(num: number = 0) { 112 + if (this.playerCount === 1) return this.keys['d'] || this.keys['arrowright'] ? 1 : 0; 113 + 114 + if (num === 0) return this.keys['d'] ? 1 : 0; 115 + if (num === 1) return this.keys['arrowright'] ? 1 : 0; 116 + 117 + return 0; 83 118 } 84 119 up(num: number = 0) { 85 - let gamepad = navigator.getGamepads()[0]; 86 - let secondGamepad = navigator.getGamepads()[1]; 87 - if (this.playerCount === 1) 88 - return this.keys['w'] || this.keys['arrowup'] || (gamepad && gamepad.axes[1] < -0.5); 120 + if (this.playerCount === 1) return this.keys['w'] || this.keys['arrowup'] ? -1 : 0; 121 + 122 + if (num === 0) return this.keys['w'] ? -1 : 0; 123 + if (num === 1) return this.keys['arrowup'] ? -1 : 0; 89 124 90 - if (num === 0) return this.keys['w'] || (gamepad && gamepad.axes[1] < -0.5); 91 - if (num === 1) return this.keys['arrowup'] || (secondGamepad && secondGamepad.axes[1] < -0.5); 125 + return 0; 92 126 } 93 127 down(num: number = 0) { 94 - let gamepad = navigator.getGamepads()[0]; 95 - let secondGamepad = navigator.getGamepads()[1]; 96 - if (this.playerCount === 1) 97 - return this.keys['s'] || this.keys['arrowdown'] || (gamepad && gamepad.axes[1] > 0.5); 128 + if (this.playerCount === 1) return this.keys['s'] || this.keys['arrowdown'] ? 1 : 0; 98 129 99 - if (num === 0) return this.keys['s'] || (gamepad && gamepad.axes[1] > 0.5); 100 - if (num === 1) return this.keys['arrowdown'] || (secondGamepad && secondGamepad.axes[1] > 0.5); 130 + if (num === 0) return this.keys['s'] ? 1 : 0; 131 + if (num === 1) return this.keys['arrowdown'] ? 1 : 0; 132 + 133 + return 0; 101 134 } 102 135 }
+2 -2
src/enemy-manager.ts
··· 51 51 } 52 52 } 53 53 54 - killAll() { 54 + killAll(dropItems: boolean = false) { 55 55 this.enemies.forEach((enemy) => { 56 - enemy.destroy(); 56 + enemy.destroy(dropItems); 57 57 }); 58 58 59 59 this.enemies = [];
+58 -9
src/enemy.ts
··· 46 46 47 47 color: number = 0xfb923c; 48 48 49 - type: number = 0; 49 + type: number = -1; 50 50 51 51 hitPlayer?(player: Player): void; 52 52 ··· 249 249 this.attack(deltaTime, player, dx, dy, distance); 250 250 } 251 251 252 - destroy() { 252 + destroy(dropItem: boolean = true) { 253 253 if (this.destroyed) return; 254 254 255 - this.game.dropItem({ x: this.x, y: this.y, color: this.color, size: 10, type: this.type }); 255 + if (dropItem) 256 + this.game.dropItem({ x: this.x, y: this.y, color: this.color, size: 10, type: this.type }); 256 257 257 258 this.game.spawnParticles(this.x, this.y, 50, this.color); 258 259 ··· 277 278 export class SphereEnemy extends Enemy { 278 279 weapon: Weapon; 279 280 281 + indicator?: PIXI.Graphics; 282 + 283 + shootingDistance = 20; 284 + 285 + fireDelay: number = 1000; 286 + cooldown: number = 0; 287 + 288 + shooting: boolean = false; 289 + 280 290 constructor(game: Game) { 281 291 super(game); 282 292 ··· 292 302 damage: 5, 293 303 showParticles: true 294 304 }); 305 + 306 + this.createIndicator(); 295 307 296 308 this.speed = 500; 297 309 } 298 310 311 + async createIndicator() { 312 + const texture = await PIXI.Assets.load('./light.png'); 313 + 314 + // this.indicator = PIXI.Sprite.from(texture); 315 + // this.indicator.anchor.set(0.5); 316 + // this.indicator.scale.set(0.0); 317 + // this.indicator.alpha = 0.5; 318 + // this.indicator.tint = this.color; 319 + // this.indicator.zIndex = -10; 320 + 321 + this.indicator = new PIXI.Graphics() 322 + .circle(0, 0, this.size + this.shootingDistance) 323 + .stroke({ color: this.color, width: 5 }); 324 + this.indicator.scale.set(0); 325 + this.indicator.zIndex = -10; 326 + this.enemyContainer.addChild(this.indicator); 327 + } 328 + 299 329 attack(deltaTime: number, nearestPlayer: Player, dx: number, dy: number, distance: number): void { 300 - if (distance < 100 && this.weapon.cooldown < 0) { 330 + if (this.cooldown > 0) { 331 + this.cooldown -= deltaTime; 332 + 333 + if (this.indicator) { 334 + this.indicator.scale.set(1 - this.cooldown / this.fireDelay); 335 + } 336 + } 337 + 338 + if (this.shooting && this.cooldown <= 0 && this.weapon.cooldown <= 0) { 301 339 for (let i = 0; i < 20; i++) { 302 340 this.weapon.cooldown = -1; 303 341 // get angle ··· 306 344 let y = Math.sin(angle) + this.position.y; 307 345 this.weapon.fire(this.position, { x, y }); 308 346 } 347 + 348 + this.shooting = false; 349 + 350 + this.indicator?.scale.set(0); 351 + } 352 + 353 + if (distance < 150 && this.cooldown <= 0) { 354 + this.shooting = true; 355 + this.cooldown = this.fireDelay; 309 356 } 310 357 311 358 this.weapon.update(deltaTime); ··· 318 365 constructor(game: Game) { 319 366 super(game); 320 367 321 - this.type = 2; 368 + this.type = 0; 322 369 this.speed = 2000; 323 370 324 371 let position = { x: this.x, y: this.y }; ··· 420 467 } 421 468 } 422 469 423 - destroy(): void { 470 + destroy(dropItem: boolean = true): void { 471 + if (this.destroyed) return; 472 + 424 473 this.projectile.destroy(); 425 - super.destroy(); 474 + super.destroy(dropItem); 426 475 } 427 476 } 428 477 ··· 432 481 constructor(game: Game) { 433 482 super(game); 434 483 435 - this.type = 3; 484 + this.type = 2; 436 485 437 486 this.weapon = new Weapon(this.game, { 438 487 color: this.color, 439 488 collisionGroups: 0x00100001, 440 489 projectileSpeed: 0.2, 441 490 fireRate: 2000, 442 - projectileSize: 6, 491 + projectileSize: 12, 443 492 damage: 10, 444 493 lifetime: 8000 445 494 });
+8
src/item-manager.ts
··· 21 21 22 22 this.items = this.items.filter((item) => !item.destroyed); 23 23 } 24 + 25 + destroyAll() { 26 + this.items.forEach((item) => { 27 + item.destroy(); 28 + }); 29 + 30 + this.items = []; 31 + } 24 32 }
+55 -1
src/item.ts
··· 43 43 44 44 this.type = options.type; 45 45 46 - this.lifetime = options.lifetime ?? 10000; 46 + this.lifetime = options.lifetime ?? 20000; 47 47 48 48 this.shape = new PIXI.Graphics().circle(0, 0, this.size / 2).fill(this.color); 49 49 ··· 70 70 this.rigidBody.userData = this; 71 71 } 72 72 73 + get x() { 74 + if (this.rigidBody) return this.rigidBody.translation().x; 75 + 76 + return this.shape.x; 77 + } 78 + set x(value) { 79 + this.shape.x = value; 80 + this.rigidBody?.setTranslation({ x: value, y: -this.y }, true); 81 + } 82 + 83 + get y() { 84 + if (this.rigidBody) return -this.rigidBody.translation().y; 85 + 86 + return this.shape.y; 87 + } 88 + set y(value) { 89 + this.shape.y = value; 90 + this.rigidBody?.setTranslation({ x: this.x, y: -value }, true); 91 + } 92 + 93 + get position() { 94 + if (this.rigidBody) { 95 + let v = this.rigidBody.translation(); 96 + return { x: v.x, y: -v.y }; 97 + } 98 + 99 + return this.shape.position; 100 + } 101 + set position(value) { 102 + this.shape.position = value; 103 + 104 + this.rigidBody?.setTranslation({ x: this.x, y: -this.y }, true); 105 + } 106 + 73 107 async addGlow() { 74 108 const texture = await PIXI.Assets.load('./light.png'); 75 109 this.light = PIXI.Sprite.from(texture); ··· 86 120 this.game.spawnParticles(this.shape.x, this.shape.y, 5, this.color); 87 121 88 122 player.items[this.type] += 1; 123 + 124 + this.game.upgradeManager.addItem(this.type); 89 125 this.destroy(); 90 126 } 91 127 ··· 96 132 if (this.life >= this.lifetime) { 97 133 this.destroy(); 98 134 return; 135 + } 136 + 137 + let closestPlayer = this.game.playerManager?.getClosestPlayer({ 138 + x: this.shape.x, 139 + y: this.shape.y 140 + }); 141 + 142 + if (closestPlayer) { 143 + let dx = closestPlayer.x - this.shape.x; 144 + let dy = closestPlayer.y - this.shape.y; 145 + let dist = Math.hypot(dx, dy); 146 + 147 + // move towards player 148 + if (dist < 80) { 149 + let speed = 0.3; 150 + this.x += (dx / dist) * speed * deltaTime; 151 + this.y += (dy / dist) * speed * deltaTime; 152 + } 99 153 } 100 154 /* 101 155 let closestPlayer = this.game.lightManager?.getClosestLight({
+4 -4
src/obstacle-manager.ts
··· 5 5 import Alea from 'alea'; 6 6 import { Light } from './light'; 7 7 8 - const CELL_SIZE = 900; 9 - const OBSTACLE_COUNT_PER_CELL = 20; 10 - const LIGHT_COUNT_PER_CELL = 3; 8 + const CELL_SIZE = 1200; 9 + const OBSTACLE_COUNT_PER_CELL = 40; 10 + const LIGHT_COUNT_PER_CELL = 4; 11 11 12 12 type CellCoord = `${number},${number}`; 13 13 ··· 201 201 x, 202 202 y, 203 203 color: color, //0xffffff * rng(), 204 - alpha: 0.05 + rng() * 0.15, 204 + alpha: 0.08 + rng() * 0.1, 205 205 scale: 0.5 + rng() * 1 206 206 }); 207 207 obstacles.push(light);
+21 -11
src/player.ts
··· 85 85 this.speed = 30000; 86 86 this.shape = shape; 87 87 88 - this.weapon = new Weapon(this.game, { color: this.color, lifetime: 2000 }); 88 + this.weapon = new Weapon(this.game, { color: this.color, lifetime: 2000, piercing: 1 }); 89 89 90 90 this.isPlayer = true; 91 91 ··· 163 163 update(deltaTime: number, keys: Record<string, boolean>) { 164 164 // move the player, wasd 165 165 if (this.game.playing && !this.dead) { 166 - let dx = 0, 167 - dy = 0; 168 - if (this.game.controls.up(this.num)) dy -= 1; 169 - if (this.game.controls.down(this.num)) dy += 1; 170 - if (this.game.controls.left(this.num)) dx -= 1; 171 - if (this.game.controls.right(this.num)) dx += 1; 166 + let dx = this.game.controls.x(this.num), 167 + dy = this.game.controls.y(this.num); 168 + 169 + console.log(dx, dy); 172 170 173 - // Normalize diagonal movement 174 - if (dx !== 0 && dy !== 0) { 175 - dx *= Math.SQRT1_2; 176 - dy *= Math.SQRT1_2; 171 + let dist = Math.hypot(dx, dy); 172 + 173 + if (dist > 1) { 174 + dx /= dist; 175 + dy /= dist; 177 176 } 177 + 178 + // if (this.game.controls.up(this.num)) dy -= 1; 179 + // if (this.game.controls.down(this.num)) dy += 1; 180 + // if (this.game.controls.left(this.num)) dx -= 1; 181 + // if (this.game.controls.right(this.num)) dx += 1; 182 + 183 + // Normalize diagonal movement 184 + // if (dx !== 0 && dy !== 0) { 185 + // dx *= Math.SQRT1_2; 186 + // dy *= Math.SQRT1_2; 187 + // } 178 188 179 189 let mult = deltaTime * 120 * 0.001; 180 190 if (dx || dy)
+2
src/projectile-manager.ts
··· 13 13 collisionGroups?: number; 14 14 showParticles?: boolean; 15 15 hit?: () => void; 16 + 17 + piercing?: number; 16 18 }; 17 19 18 20 export default class ProjectileManager {
+6 -1
src/projectile.ts
··· 23 23 24 24 hit?: () => void; 25 25 26 + piercing: number = 0; 27 + 26 28 constructor( 27 29 game: Game, 28 30 data: ProjectileData ··· 48 50 this.color = data.color ?? 0xffffff; 49 51 50 52 this.shape = new PIXI.Graphics().rect(0, 0, this.size, this.size).fill(this.color); 53 + this.shape.pivot.set(this.size / 2, this.size / 2); 51 54 52 55 this.shape.x = data.position.x; 53 56 this.shape.y = data.position.y; ··· 60 63 this.rigidBody = game.world.createRigidBody(rigidBodyDesc); 61 64 62 65 const colliderDesc = RAPIER() 63 - .ColliderDesc.ball(this.size) 66 + .ColliderDesc.cuboid(this.size / 2, this.size / 2) 64 67 .setCollisionGroups(data.collisionGroups ?? 0x00040002) 65 68 .setSensor(true) 66 69 .setActiveEvents(RAPIER().ActiveEvents.COLLISION_EVENTS); ··· 74 77 this.showParticles = data.showParticles ?? true; 75 78 76 79 this.hit = data.hit; 80 + 81 + if (data.piercing) this.piercing = data.piercing; 77 82 78 83 this.isProjectile = true; 79 84 }
+65
src/upgrade-manager.ts
··· 1 + import Game from './app'; 2 + 3 + export class UpgradeManager { 4 + colors_classes = ['bg-sky-500', 'bg-orange-500', 'bg-emerald-500']; 5 + colors: number[] = []; 6 + 7 + items: number[] = [0, 0, 0]; 8 + 9 + game: Game; 10 + 11 + itemUI?: HTMLElement; 12 + 13 + itemCounters: HTMLElement[] = []; 14 + 15 + constructor(game: Game) { 16 + this.game = game; 17 + 18 + this.itemUI = document.getElementById('items') ?? undefined; 19 + 20 + this.createUI(); 21 + } 22 + 23 + createUI() { 24 + if (!this.itemUI) return; 25 + 26 + this.itemUI.innerHTML = ''; 27 + 28 + let count = this.colors_classes.length; 29 + for (let i = 0; i < count; i++) { 30 + const stat = document.createElement('div'); 31 + stat.className = 'flex items-center gap-2 justify-end'; 32 + 33 + const stat1 = document.createElement('div'); 34 + stat1.className = 'block h-4 w-4 rounded-full relative ' + this.colors_classes[i]; 35 + 36 + const statText = document.createElement('div'); 37 + statText.className = 'block'; 38 + statText.innerText = this.items[i].toString(); 39 + stat.appendChild(statText); 40 + 41 + this.itemCounters.push(statText); 42 + 43 + const statGlow = document.createElement('div'); 44 + statGlow.className = 45 + 'absolute -inset-0.5 h-5 w-5 rounded-full blur-sm ' + this.colors_classes[i]; 46 + stat1.appendChild(statGlow); 47 + 48 + stat.appendChild(stat1); 49 + this.itemUI.appendChild(stat); 50 + } 51 + } 52 + 53 + reset() { 54 + for (let i = 0; i < this.items.length; i++) { 55 + this.items[i] = 0; 56 + this.itemCounters[i].innerText = '0'; 57 + } 58 + } 59 + 60 + addItem(index: number) { 61 + this.items[index]++; 62 + 63 + this.itemCounters[index].innerText = this.items[index].toString(); 64 + } 65 + }
+186
src/upgrades.ts
··· 1 + function getIcon(icon: string) { 2 + return ` 3 + <svg 4 + class="h-6 w-6" 5 + fill="none" 6 + viewBox="0 0 24 24" 7 + stroke-width="1.5" 8 + stroke="currentColor" 9 + aria-hidden="true" 10 + > 11 + <path 12 + stroke-linecap="round" 13 + stroke-linejoin="round" 14 + d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" 15 + /> 16 + </svg> 17 + `; 18 + } 19 + 20 + type UpgradeOption = { 21 + index: number; 22 + total: number; 23 + 24 + iconName?: string; 25 + 26 + disabled?: boolean; 27 + 28 + perk?: string; 29 + perkValue?: string; 30 + 31 + minusStat?: string; 32 + minusStatValue?: string; 33 + 34 + price?: { 35 + color: string; 36 + amount: number; 37 + }[]; 38 + 39 + bought?: boolean; 40 + 41 + empty?: boolean; 42 + }; 43 + 44 + export function addUpgradeOption(options: UpgradeOption) { 45 + // if index === 0 46 + // rounded-tl-lg rounded-tr-lg sm:rounded-tr-none 47 + // if index === 1 48 + // sm:rounded-tr-lg 49 + // if index === total - 2 50 + // sm:rounded-bl-lg 51 + // if index === total - 1 52 + // rounded-bl-lg rounded-br-lg sm:rounded-bl-none 53 + const upgrade = document.createElement('div'); 54 + upgrade.className = 55 + 'group relative ring-1 ring-inset ring-white/10 p-6 hover:ring-2 hover:ring-inset hover:ring-white'; 56 + if (options.index === 0) { 57 + upgrade.classList.add('rounded-tl-lg', 'rounded-tr-lg', 'sm:rounded-tr-none'); 58 + } else if (options.index === 1) { 59 + upgrade.classList.add('sm:rounded-tr-lg'); 60 + } else if (options.index === options.total - 2) { 61 + upgrade.classList.add('sm:rounded-bl-lg'); 62 + } else if (options.index === options.total - 1) { 63 + upgrade.classList.add('rounded-bl-lg', 'rounded-br-lg', 'sm:rounded-bl-none'); 64 + } 65 + 66 + if (options.disabled) { 67 + upgrade.classList.add('opacity-30', 'pointer-events-none'); 68 + } 69 + 70 + if (options.bought) { 71 + upgrade.classList.add('pointer-events-none'); 72 + } 73 + 74 + if (options.empty) { 75 + upgrade.classList.add('pointer-events-none', 'opacity-30', 'hidden', 'sm:block'); 76 + 77 + return upgrade; 78 + } 79 + 80 + const icon = document.createElement('span'); 81 + icon.className = 'inline-flex rounded-lg bg-white/5 p-3 text-rose-500 ring-0'; 82 + icon.innerHTML = getIcon(options.iconName ?? ''); 83 + 84 + const div = document.createElement('div'); 85 + div.className = 'mt-8'; 86 + const title = document.createElement('h3'); 87 + title.className = 'text-base font-semibold leading-6 text-gray-50'; 88 + const button = document.createElement('button'); 89 + button.className = 'focus:outline-none'; 90 + 91 + const span = document.createElement('span'); 92 + span.className = 'absolute inset-0'; 93 + span.setAttribute('aria-hidden', 'true'); 94 + const spanText = document.createElement('span'); 95 + spanText.innerText = options.perk + ' '; 96 + const spanPercentage = document.createElement('span'); 97 + spanPercentage.className = 'text-sky-500'; 98 + spanPercentage.innerText = options.perkValue ?? ''; 99 + 100 + spanText.appendChild(spanPercentage); 101 + button.appendChild(span); 102 + button.appendChild(spanText); 103 + title.appendChild(button); 104 + 105 + div.appendChild(title); 106 + 107 + const description = document.createElement('p'); 108 + description.innerHTML = options.minusStat + ' '; 109 + description.className = 'mt-2 text-sm text-gray-300 font-semibold'; 110 + const descriptionText = document.createElement('span'); 111 + descriptionText.className = 'text-rose-500'; 112 + descriptionText.innerText = options.minusStatValue ?? ''; 113 + description.appendChild(descriptionText); 114 + 115 + const stats = document.createElement('p'); 116 + stats.className = 'mt-2 text-sm text-gray-300 font-semibold flex items-center gap-2 justify-end'; 117 + 118 + let count = (options.price ?? []).length; 119 + for (let i = 0; i < count; i++) { 120 + const stat1 = document.createElement('span'); 121 + stat1.className = 122 + 'h-3 w-3 rounded-full relative ' + options.price?.[i].color + (i < count - 1 ? ' mr-2' : ''); 123 + 124 + stats.innerHTML += options.price?.[i].amount; 125 + 126 + const statGlow = document.createElement('span'); 127 + statGlow.className = 128 + 'absolute -inset-0.5 h-4 w-4 rounded-full blur-sm ' + options.price?.[i].color; 129 + stat1.appendChild(statGlow); 130 + 131 + stats.appendChild(stat1); 132 + } 133 + 134 + upgrade.appendChild(icon); 135 + div.appendChild(title); 136 + div.appendChild(description); 137 + div.appendChild(stats); 138 + 139 + upgrade.appendChild(div); 140 + 141 + const spanPointer = document.createElement('span'); 142 + spanPointer.className = 143 + 'pointer-events-none absolute right-6 top-6 text-gray-50 group-hover:text-gray-200'; 144 + spanPointer.setAttribute('aria-hidden', 'true'); 145 + 146 + if (options.bought) { 147 + spanPointer.innerHTML = ` 148 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"> 149 + <path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" /> 150 + </svg>`; 151 + } else { 152 + spanPointer.innerHTML = `<svg 153 + xmlns="http://www.w3.org/2000/svg" 154 + fill="none" 155 + viewBox="0 0 24 24" 156 + stroke-width="1.5" 157 + stroke="currentColor" 158 + class="size-6" 159 + > 160 + <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /> 161 + </svg>`; 162 + } 163 + 164 + button.addEventListener('click', () => { 165 + if (options.disabled) { 166 + return; 167 + } 168 + if (options.bought) { 169 + return; 170 + } 171 + 172 + // remove hover:ring-2 hover:ring-inset hover:ring-white 173 + upgrade.classList.remove('hover:ring-2', 'hover:ring-inset', 'hover:ring-white'); 174 + 175 + options.bought = true; 176 + upgrade.classList.add('opacity-30', 'pointer-events-none'); 177 + spanPointer.innerHTML = ` 178 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"> 179 + <path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" /> 180 + </svg>`; 181 + }); 182 + 183 + upgrade.appendChild(spanPointer); 184 + 185 + return upgrade; 186 + }
+14 -2
src/wave.ts
··· 96 96 97 97 export const defaultWaves: WaveData[] = [ 98 98 //{ enemies: [{ type: CrossEnemy, num: 1 }], spawnSpeed: 1 }, 99 - { enemies: [{ type: TriangleEnemy, num: 5 }], spawnSpeed: 3 }, 99 + // test wave for measuring performance 100 + // { 101 + // enemies: [ 102 + // { type: TriangleEnemy, num: 100 }, 103 + // { type: SphereEnemy, num: 100 }, 104 + // { 105 + // type: PentagonEnemy, 106 + // num: 100 107 + // } 108 + // ], 109 + // spawnSpeed: 100 110 + // }, 111 + { enemies: [{ type: SphereEnemy, num: 50 }], spawnSpeed: 3 }, 100 112 { 101 113 enemies: [ 102 114 { type: TriangleEnemy, num: 10 }, ··· 270 282 271 283 waves: Wave[]; 272 284 273 - currentWave: number = 0; 285 + currentWave: number = -1; 274 286 275 287 constructor(game: Game, startWave: number = 0, waves: WaveData[] = defaultWaves) { 276 288 this.game = game;
+8 -1
src/weapon.ts
··· 16 16 sound?: boolean; 17 17 18 18 showParticles?: boolean; 19 + 20 + piercing?: number; 19 21 }; 20 22 21 23 export class Weapon { ··· 38 40 39 41 showParticles: boolean = false; 40 42 43 + piercing: number = 0; 44 + 41 45 constructor(game: Game, options: WeaponOptions | undefined = undefined) { 42 46 this.game = game; 43 47 ··· 57 61 if (options.sound !== undefined) this.sound = options.sound; 58 62 59 63 if (options.showParticles !== undefined) this.showParticles = options.showParticles; 64 + 65 + if (options.piercing) this.piercing = options.piercing; 60 66 } 61 67 62 68 fire(position: { x: number; y: number }, enemyPosition: { x: number; y: number }) { ··· 71 77 color: this.color, 72 78 collisionGroups: this.collisionGroups, 73 79 lifetime: this.lifetime, 74 - showParticles: this.showParticles 80 + showParticles: this.showParticles, 81 + piercing: this.piercing 75 82 }); 76 83 this.cooldown = this.fireRate; 77 84