[READ-ONLY] Mirror of https://github.com/excaliburjs/create-excalibur. A repo bootstrapping tool to get you started making your first games!
0

Configure Feed

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

improve ts starter

Manu Hernandez (Jan 19, 2024, 11:38 AM -0400) eaa11234 f6b5baa1

+1126 -335
-24
src/templates/typescript/gitignore
··· 1 - # Logs 2 - logs 3 - *.log 4 - npm-debug.log* 5 - yarn-debug.log* 6 - yarn-error.log* 7 - pnpm-debug.log* 8 - lerna-debug.log* 9 - 10 - node_modules 11 - dist 12 - dist-ssr 13 - *.local 14 - 15 - # Editor directories and files 16 - .vscode/* 17 - !.vscode/extensions.json 18 - .idea 19 - .DS_Store 20 - *.suo 21 - *.ntvs* 22 - *.njsproj 23 - *.sln 24 - *.sw?
+40 -2
src/templates/typescript/index.html
··· 4 4 <meta charset="UTF-8" /> 5 5 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 6 <title>ExcaliburJS</title> 7 + <link rel="stylesheet" href="/style.css" /> 7 8 </head> 8 9 <body> 10 + <!-- --> 9 11 <div id="welcome"> 10 12 <h1> 11 13 <div> ··· 72 74 </a> 73 75 </nav> 74 76 </div> 75 - <!-- --> 76 - <div id="game"> 77 + <div id="game" class="LOADING"> 77 78 <canvas id="main-canvas"></canvas> 79 + <div id="ui"> 80 + <div class="panels"> 81 + <div class="ready-container"> 82 + <button id="btn-play" class="play">Play</button> 83 + </div> 84 + <div class="playing-container"> 85 + <p class="left_pane"> 86 + <span id="label-level"></span> 87 + / 88 + <span id="label-score"></p> 89 + </p> 90 + <div> 91 + <p id="label-balls"></p> 92 + </div> 93 + </div> 94 + <div class="gameover-container"> 95 + <button id="btn-retry" class="retry">Retry</button> 96 + </div> 97 + <div class="completed-container"> 98 + <button id="btn-next-level" class="next-level">Next level</button> 99 + </div> 100 + <div class="done-container"> 101 + <p>🎉Game completed!🎉</p> 102 + <button id="btn-done" class="restart">Reset the Game</button> 103 + </div> 104 + </div> 105 + </div> 106 + </div> 107 + <div id="credits"> 108 + <!-- <p class="title">Credits</p> --> 109 + <p class="credit"> 110 + 💖 Assets by 111 + <a href="https://megacrash.itch.io/breakout-assets" target="_blank"> 112 + MegaCrash 113 + </a> 114 + CC by 0 115 + </p> 78 116 </div> 79 117 <script type="module" src="/src/main.ts"></script> 80 118 </body>
+1 -1
src/templates/typescript/package.json
··· 1 1 { 2 - "name": "ts-stater", 2 + "name": "ts-starter", 3 3 "private": true, 4 4 "version": "0.0.0", 5 5 "type": "module",
+2 -7
src/templates/typescript/src/main.ts
··· 1 - import './style.css'; 2 - import { DevTool } from '@excaliburjs/dev-tools'; 3 - import { breakout_game } from './sample/breakout'; 1 + import { gameManager } from './sample/managers/game.manager'; 4 2 5 - // 6 - if (false) new DevTool(breakout_game.game); 7 - 8 - breakout_game.start(); 3 + gameManager.init();
+45
src/templates/typescript/src/sample/actors/ball.actor.ts
··· 1 + import { 2 + Actor, 3 + Animation, 4 + CollisionType, 5 + Engine, 6 + SpriteSheet, 7 + Vector, 8 + range, 9 + } from 'excalibur'; 10 + import { assetManager } from '../managers/asset.manager'; 11 + 12 + export class Ball extends Actor { 13 + ball_speed!: Vector; 14 + amount!: number; 15 + constructor(x: number, y: number, radius: number, vel: Vector) { 16 + super({ x, y, radius, vel }); 17 + this.ball_speed = vel; 18 + } 19 + 20 + onInitialize(engine: Engine): void { 21 + // 22 + this.body.collisionType = CollisionType.Passive; 23 + this.set_sprite(); 24 + } 25 + 26 + set_sprite() { 27 + const sprite = SpriteSheet.fromImageSource({ 28 + image: assetManager.images.spritesheet, 29 + grid: { 30 + rows: 1, 31 + columns: 1, 32 + spriteWidth: 16, 33 + spriteHeight: 16, 34 + }, 35 + spacing: { 36 + originOffset: { x: 32 * 6, y: 256 - 16 }, 37 + }, 38 + }); 39 + const frames = range(0, 0); 40 + const animation_ms = 0; 41 + const ball_anim = Animation.fromSpriteSheet(sprite, frames, animation_ms); 42 + // 43 + this.graphics.use(ball_anim); 44 + } 45 + }
+114
src/templates/typescript/src/sample/actors/brick.actor.ts
··· 1 + import { 2 + Actor, 3 + Animation, 4 + CollisionType, 5 + Engine, 6 + SpriteSheet, 7 + range, 8 + vec, 9 + } from 'excalibur'; 10 + import { BRICK_TYPE } from '../models'; 11 + import { assetManager } from '../managers/asset.manager'; 12 + 13 + export class Brick extends Actor { 14 + type!: BRICK_TYPE; 15 + sprite_type!: number; 16 + sprite_status!: number; 17 + life!: number; 18 + hit_score = 0; 19 + destroy_score = 0; 20 + constructor( 21 + x: number, 22 + y: number, 23 + width: number, 24 + height: number, 25 + type: BRICK_TYPE 26 + ) { 27 + super({ x, y, width, height }); 28 + this.type = type; 29 + this.sprite_status = 32 * 8; 30 + switch (this.type) { 31 + case BRICK_TYPE.LEVEL_1: 32 + this.life = 1; 33 + this.sprite_type = 1; 34 + this.hit_score = 100; 35 + this.destroy_score = this.life * 200; 36 + 37 + break; 38 + case BRICK_TYPE.LEVEL_2: 39 + this.life = 2; 40 + this.sprite_type = 2; 41 + this.hit_score = 250; 42 + this.destroy_score = this.life * 200; 43 + 44 + break; 45 + case BRICK_TYPE.LEVEL_3: 46 + this.life = 3; 47 + this.sprite_type = 3; 48 + this.hit_score = 250; 49 + this.destroy_score = this.life * 200; 50 + 51 + break; 52 + case BRICK_TYPE.LEVEL_4: 53 + this.life = 4; 54 + this.sprite_type = 4; 55 + this.hit_score = 350; 56 + this.destroy_score = this.life * 200; 57 + 58 + break; 59 + case BRICK_TYPE.LEVEL_5: 60 + this.life = 5; 61 + this.sprite_type = 5; 62 + this.hit_score = 350; 63 + this.destroy_score = this.life * 200; 64 + 65 + break; 66 + case BRICK_TYPE.LEVEL_6: 67 + this.life = 6; 68 + this.sprite_type = 6; 69 + this.hit_score = 350; 70 + this.destroy_score = this.life * 200; 71 + 72 + break; 73 + } 74 + } 75 + onInitialize(engine: Engine): void { 76 + // 77 + this.body.collisionType = CollisionType.Active; 78 + this.set_sprite(); 79 + this.scale = vec(2, 2); 80 + } 81 + set_sprite() { 82 + const sprite = SpriteSheet.fromImageSource({ 83 + image: assetManager.images.spritesheet, 84 + grid: { 85 + rows: 1, 86 + columns: 1, 87 + spriteWidth: 32, 88 + spriteHeight: 16, 89 + }, 90 + spacing: { 91 + originOffset: { x: this.sprite_status, y: this.sprite_type * 16 }, 92 + }, 93 + }); 94 + const frames = range(0, 0); 95 + const animation_ms = 100; 96 + const brick_anim = Animation.fromSpriteSheet(sprite, frames, animation_ms); 97 + this.graphics.use(brick_anim); 98 + } 99 + update_sprite() { 100 + switch (this.life) { 101 + case 3: 102 + this.sprite_status = 32 * 8; 103 + break; 104 + case 2: 105 + this.sprite_status = 32 * 9; 106 + break; 107 + case 1: 108 + this.sprite_status = 32 * 10; 109 + break; 110 + } 111 + 112 + this.set_sprite(); 113 + } 114 + }
+42
src/templates/typescript/src/sample/actors/paddle.actor.ts
··· 1 + import { 2 + Actor, 3 + Animation, 4 + CollisionType, 5 + Engine, 6 + SpriteSheet, 7 + range, 8 + vec, 9 + } from 'excalibur'; 10 + import { assetManager } from '../managers/asset.manager'; 11 + 12 + export class Paddle extends Actor { 13 + constructor(x: number, y: number, width: number, height: number) { 14 + super({ x, y, width, height }); 15 + } 16 + onInitialize(engine: Engine): void { 17 + // 18 + this.body.collisionType = CollisionType.Fixed; 19 + this.set_sprite(); 20 + this.scale = vec(2, 2); 21 + } 22 + 23 + set_sprite() { 24 + const sprite = SpriteSheet.fromImageSource({ 25 + image: assetManager.images.spritesheet, 26 + grid: { 27 + rows: 4, 28 + columns: 14, 29 + spriteWidth: 32, 30 + spriteHeight: 16, 31 + }, 32 + spacing: { 33 + originOffset: { x: 48, y: 256 - 16 }, 34 + }, 35 + }); 36 + const frames = range(0, 0); 37 + const animation_ms = 0; 38 + const paddle_anim = Animation.fromSpriteSheet(sprite, frames, animation_ms); 39 + // 40 + this.graphics.use(paddle_anim); 41 + } 42 + }
-220
src/templates/typescript/src/sample/breakout.ts
··· 1 - import { 2 - Actor, 3 - Animation, 4 - CollisionType, 5 - Color, 6 - Engine, 7 - ImageSource, 8 - Loader, 9 - SpriteSheet, 10 - range, 11 - vec, 12 - } from 'excalibur'; 13 - 14 - // [CREATE_GAME] 15 - const game = new Engine({ 16 - width: 600, 17 - height: 400, 18 - canvasElementId: 'main-canvas', 19 - backgroundColor: Color.Black, 20 - }); 21 - 22 - // LOAD RESOURCES 23 - // assets from https://megacrash.itch.io/breakout-assets 24 - const loader = new Loader(); 25 - loader.playButtonText = 'Play'; 26 - loader.backgroundColor = 'black'; 27 - const resources: any = { 28 - spritesheet: new ImageSource('/sample/breakout_sprite.png'), 29 - }; 30 - for (const key in resources) { 31 - loader.addResource(resources[key]); 32 - } 33 - 34 - // [CREATE_GAME_ACTORS] 35 - const paddle = create_paddle(); 36 - const ball = create_ball(); 37 - let game_bricks = create_bricks(); 38 - 39 - // [ADD_ACTORS_TO_GAME] 40 - game.add(paddle); 41 - game.add(ball); 42 - game_bricks.forEach((brick) => game.add(brick)); 43 - 44 - // [SETUP_PLAYER_CONTROLS] 45 - game.input.pointers.primary.on('move', (e) => { 46 - paddle.pos.x = e.worldPos.x; 47 - }); 48 - 49 - // 50 - function create_bricks() { 51 - const padding = 32 + 32 / 6; 52 - const offset_x = 32 * 2; 53 - const offset_y = 32 * 2; 54 - 55 - const columns = 7; 56 - const brick_width = 32; 57 - const brick_height = 16; 58 - const rows = 3; 59 - 60 - const brickColor = [Color.Viridian, Color.Orange, Color.Vermilion]; 61 - 62 - // const brick_width = game.drawWidth / columns - padding - padding / columns; 63 - const bricks: Actor[] = []; 64 - 65 - // DYNAMIC BRICKS 66 - for (let row = 0; row < rows; row++) { 67 - for (let col = 0; col < columns; col++) { 68 - const x = offset_x + col * (brick_width + padding); 69 - const y = offset_y + row * (brick_height + padding); 70 - const width = brick_width; 71 - const height = brick_height; 72 - const color = brickColor[row]; 73 - // 74 - const brick = new Actor({ x, y, width, height, color }); 75 - brick.body.collisionType = CollisionType.Active; 76 - 77 - const brick_img = SpriteSheet.fromImageSource({ 78 - image: resources.spritesheet, 79 - grid: { 80 - rows: 1, 81 - columns: 1, 82 - spriteWidth: 32, 83 - spriteHeight: 16, 84 - }, 85 - spacing: { 86 - originOffset: { x: 32 * 8, y: row * 16 }, 87 - }, 88 - }); 89 - const brick_anim = Animation.fromSpriteSheet(brick_img, range(0, 0), 100); 90 - brick.graphics.use(brick_anim); 91 - brick.scale = vec(2, 2); 92 - bricks.push(brick); 93 - } 94 - } 95 - 96 - return bricks; 97 - } 98 - function create_ball() { 99 - const ball = new Actor({ 100 - x: 100, 101 - y: 100, 102 - radius: 8, 103 - color: Color.White, 104 - }); 105 - const ball_speed = vec(200, -200); 106 - ball.vel = ball_speed; 107 - const ball_img = SpriteSheet.fromImageSource({ 108 - image: resources.spritesheet, 109 - grid: { 110 - rows: 1, 111 - columns: 1, 112 - spriteWidth: 16, 113 - spriteHeight: 16, 114 - }, 115 - spacing: { 116 - originOffset: { x: 32 * 6, y: 256 - 16 }, 117 - }, 118 - }); 119 - const ball_anim = Animation.fromSpriteSheet(ball_img, range(0, 0), 100); 120 - ball.graphics.use(ball_anim); 121 - // ball.scale = vec(2, 2); 122 - // collision setup 123 - ball.body.collisionType = CollisionType.Passive; 124 - 125 - // EVENTS 126 - ball.on('postupdate', () => { 127 - const hit_limits = { 128 - screen_top: ball.pos.y - ball.height / 2 < 0, 129 - screen_left: ball.pos.x - ball.width / 2 < 0, 130 - screen_right: ball.pos.x + ball.width / 2 > game.drawWidth, 131 - }; 132 - 133 - if (hit_limits.screen_top) { 134 - // send to bottom 135 - ball.vel.y = ball.vel.y = ball_speed.y * -1 + Math.random(); 136 - } else if (hit_limits.screen_left) { 137 - // send to the right 138 - ball.vel.x = ball_speed.x + Math.random(); 139 - } else if (hit_limits.screen_right) { 140 - // send to the left 141 - ball.vel.x = ball_speed.x * -1 + Math.random(); 142 - } 143 - }); 144 - ball.on('pointerdown', function () { 145 - ball.kill(); 146 - }); 147 - 148 - let colliding = false; 149 - 150 - // destroy bricks 151 - ball.on('collisionstart', (e) => { 152 - const collide_with_brick = game_bricks.indexOf(e.other) > -1; 153 - if (collide_with_brick) { 154 - e.other.kill(); 155 - 156 - if (game_bricks.every((b) => b.isKilled())) { 157 - const bricks = create_bricks(); 158 - game_bricks = bricks; 159 - game_bricks.forEach((b) => game.add(b)); 160 - } 161 - } 162 - 163 - if (!colliding) { 164 - colliding = true; 165 - const intersection = e.contact.mtv.normalize(); 166 - const hit_x = Math.abs(intersection.x) > Math.abs(intersection.y); 167 - 168 - if (hit_x) { 169 - // reverse x 170 - ball.vel.x *= -1; 171 - } else { 172 - // reverse y 173 - ball.vel.y *= -1; 174 - } 175 - } 176 - }); 177 - ball.on('collisionend', () => { 178 - colliding = false; 179 - }); 180 - ball.on('exitviewport', () => { 181 - ball.pos.x = Math.random() * game.drawWidth; 182 - ball.pos.y = 10; 183 - }); 184 - 185 - return ball; 186 - } 187 - function create_paddle() { 188 - const paddle = new Actor({ 189 - x: 100, 190 - y: game.drawHeight - 64, 191 - width: 32, 192 - height: 16, 193 - color: Color.ExcaliburBlue, 194 - }); 195 - 196 - const paddle_img = SpriteSheet.fromImageSource({ 197 - image: resources.spritesheet, 198 - grid: { 199 - rows: 4, 200 - columns: 14, 201 - spriteWidth: 32, 202 - spriteHeight: 16, 203 - }, 204 - spacing: { 205 - originOffset: { x: 48, y: 256 - 16 }, 206 - }, 207 - }); 208 - const paddle_anim = Animation.fromSpriteSheet(paddle_img, range(0, 0), 200); 209 - paddle.graphics.use(paddle_anim); 210 - paddle.scale = vec(2, 2); 211 - // collision setup 212 - paddle.body.collisionType = CollisionType.Fixed; 213 - return paddle; 214 - } 215 - 216 - // 217 - export const breakout_game = { 218 - game, 219 - start: () => game.start(loader), 220 - };
+25
src/templates/typescript/src/sample/managers/asset.manager.ts
··· 1 + import { ImageSource, Loader } from 'excalibur'; 2 + import { ImageResource } from '../models'; 3 + 4 + class AssetManager { 5 + loader!: Loader; 6 + images!: ImageResource; 7 + sounds: any; 8 + 9 + constructor() {} 10 + init() { 11 + this.loader = new Loader(); 12 + this.loader.suppressPlayButton = true; 13 + this.loader.backgroundColor = 'black'; 14 + // 15 + this.images = { 16 + spritesheet: new ImageSource('/sample/breakout_sprite.png'), 17 + }; 18 + // 19 + for (const key in this.images) { 20 + this.loader.addResource(this.images[key]); 21 + } 22 + } 23 + } 24 + 25 + export const assetManager = new AssetManager();
+7
src/templates/typescript/src/sample/managers/audio.manager.ts
··· 1 + class AudioManager { 2 + init() { 3 + console.warn('AudioManager not implemented'); 4 + } 5 + } 6 + 7 + export const audioManager = new AudioManager();
+160
src/templates/typescript/src/sample/managers/game.manager.ts
··· 1 + import { DevTool } from '@excaliburjs/dev-tools'; 2 + import { Color, Engine } from 'excalibur'; 3 + 4 + import { GAME_STATES, SCENES_EVENTS, SCENE_STATE } from '../models'; 5 + import { uiManager } from './ui.manager'; 6 + import { levelManager } from './level.manager'; 7 + import { assetManager } from './asset.manager'; 8 + import { audioManager } from './audio.manager'; 9 + import { LevelScene } from '../scenes/level.scene'; 10 + import { Subject } from '../utils'; 11 + 12 + class GameManager { 13 + game!: Engine; 14 + score_global = 0; 15 + score_level = 0; 16 + // 17 + game_state = new Subject(); 18 + scene_state = new Subject(); 19 + 20 + constructor(engine: Engine) { 21 + this.game = engine; 22 + } 23 + 24 + init() { 25 + eventBus.on(SCENES_EVENTS.UPDATE_BALL, (balls: number) => { 26 + uiManager.print_balls(balls); 27 + }); 28 + eventBus.on(SCENES_EVENTS.UPDATE_SCORE, (score: number) => { 29 + this.score_level += score; 30 + uiManager.print_score(this.score_level); 31 + }); 32 + 33 + this.game_state.subscribe((new_game_state: GAME_STATES) => { 34 + console.log(`[${new_game_state}]`); 35 + switch (new_game_state) { 36 + case GAME_STATES.LOADING: 37 + assetManager.init(); 38 + levelManager.init(); 39 + audioManager.init(); 40 + uiManager.init(); 41 + this.load_levels(); 42 + this.activate_debug_mode(false); 43 + this.game.start(assetManager.loader).then(() => { 44 + this.game_state.next(GAME_STATES.READY); 45 + eventBus.emit(SCENE_STATE.READY); 46 + }); 47 + break; 48 + case GAME_STATES.READY: 49 + uiManager.update_state(SCENE_STATE.READY); 50 + break; 51 + case GAME_STATES.PLAYING: 52 + break; 53 + case GAME_STATES.COMPLETED: 54 + uiManager.update_state('DONE'); 55 + break; 56 + case GAME_STATES.ERROR: 57 + break; 58 + } 59 + }); 60 + this.scene_state.subscribe((new_scene_state: SCENE_STATE) => { 61 + console.log(`${this.game_state.current()}/[${new_scene_state}]`); 62 + switch (new_scene_state) { 63 + case SCENE_STATE.LOADING: 64 + break; 65 + case SCENE_STATE.READY: 66 + break; 67 + case SCENE_STATE.PLAYING: 68 + break; 69 + case SCENE_STATE.PAUSED: 70 + break; 71 + case SCENE_STATE.COMPLETED: 72 + this.score_global += this.score_level; 73 + 74 + if (levelManager.levels_completed()) { 75 + this.game_state.next(GAME_STATES.COMPLETED); 76 + return; 77 + } 78 + 79 + break; 80 + case SCENE_STATE.GAMEOVER: 81 + break; 82 + case SCENE_STATE.ERROR: 83 + break; 84 + } 85 + 86 + uiManager.update_state(new_scene_state); 87 + }); 88 + 89 + // start 90 + this.game_state.next(GAME_STATES.LOADING); 91 + } 92 + 93 + private load_levels() { 94 + levelManager.levels.forEach((lvl) => this.game.add(lvl.name, lvl)); 95 + } 96 + private activate_debug_mode(activate: boolean = true) { 97 + if (activate) new DevTool(this.game); 98 + } 99 + 100 + // actions 101 + 102 + private load_level(level: LevelScene) { 103 + this.scene_state.next(SCENE_STATE.LOADING); 104 + 105 + this.game.goToScene(level.name); 106 + this.game.currentScene.onInitialize(this.game); 107 + uiManager.print_UI(level.name, level.balls, this.score_global); 108 + } 109 + start_game() { 110 + const initial_level = levelManager.initial(); 111 + this.load_level(initial_level); 112 + } 113 + retry_level() { 114 + const same_level = levelManager.current(); 115 + this.load_level(same_level); 116 + } 117 + next_level() { 118 + const next_level: any = levelManager.next(); 119 + if (!next_level) { 120 + console.warn('No more levels'); 121 + return; 122 + } 123 + 124 + this.load_level(next_level); 125 + } 126 + reset_game() { 127 + this.score_global = 0; 128 + this.start_game(); 129 + } 130 + } 131 + 132 + class EventBus { 133 + events: Record<string, any> = {}; 134 + 135 + constructor() {} 136 + on(event: string, callback: any) { 137 + this.events[event] = this.events[event] || []; 138 + this.events[event].push(callback); 139 + } 140 + emit(event: string, data: any = {}) { 141 + if (this.events[event]) { 142 + this.events[event].forEach((callback: any) => { 143 + callback(data); 144 + }); 145 + } 146 + } 147 + } 148 + 149 + // 150 + const game = new Engine({ 151 + width: 600, 152 + height: 400, 153 + canvasElementId: 'main-canvas', 154 + backgroundColor: Color.Black, 155 + antialiasing: false, 156 + }); 157 + const gameManager = new GameManager(game); 158 + const eventBus = new EventBus(); 159 + 160 + export { eventBus, gameManager };
+116
src/templates/typescript/src/sample/managers/level.manager.ts
··· 1 + import { LevelScene } from '../scenes/level.scene'; 2 + import { BRICK_TYPE, LevelSetup } from '../models'; 3 + import { Color, vec } from 'excalibur'; 4 + 5 + class LevelManager { 6 + current_level_index = 0; 7 + levels: LevelScene[] = []; 8 + levels_setup!: LevelSetup[]; 9 + 10 + constructor(level_setup: LevelSetup[]) { 11 + this.levels_setup = level_setup; 12 + } 13 + 14 + init() { 15 + levels_setup.forEach((lvl) => this.levels.push(new LevelScene(lvl))); 16 + } 17 + initial() { 18 + this.current_level_index = 0; 19 + return this.levels[this.current_level_index]; 20 + } 21 + next() { 22 + const exist_next = this.levels[this.current_level_index + 1]; 23 + if (!exist_next) return false; 24 + this.current_level_index++; 25 + return exist_next; 26 + } 27 + current() { 28 + return this.levels[this.current_level_index]; 29 + } 30 + 31 + levels_completed() { 32 + return this.current_level_index >= this.levels.length - 1; 33 + } 34 + } 35 + // LEVELS 36 + const level001: LevelSetup = { 37 + name: 'level001', 38 + balls: 3, 39 + ball_speed: vec(200, -200), 40 + bg_color: Color.Black, 41 + bricks_setup: [ 42 + { type: BRICK_TYPE.LEVEL_1, life: 1, cols: 3, width: 32, height: 16 }, 43 + // { type: BRICK_TYPE.LEVEL_2, life: 1, cols: 8, width: 32, height: 16 }, 44 + ], 45 + }; 46 + const level002: LevelSetup = { 47 + name: 'level002', 48 + balls: 3, 49 + ball_speed: vec(250, -250), 50 + bg_color: Color.LightGray, 51 + bricks_setup: [ 52 + { type: BRICK_TYPE.LEVEL_2, life: 1, cols: 2, width: 32, height: 16 }, 53 + // { type: BRICK_TYPE.LEVEL_2, life: 2, cols: 8, width: 32, height: 16 }, 54 + ], 55 + }; 56 + const level003: LevelSetup = { 57 + name: 'level003', 58 + balls: 3, 59 + ball_speed: vec(300, -300), 60 + bg_color: Color.Rose, 61 + bricks_setup: [ 62 + { type: BRICK_TYPE.LEVEL_1, life: 1, cols: 8, width: 32, height: 16 }, 63 + { type: BRICK_TYPE.LEVEL_2, life: 2, cols: 8, width: 32, height: 16 }, 64 + { type: BRICK_TYPE.LEVEL_3, life: 3, cols: 8, width: 32, height: 16 }, 65 + ], 66 + }; 67 + const level004: LevelSetup = { 68 + name: 'level004', 69 + balls: 3, 70 + ball_speed: vec(280, -280), 71 + bg_color: Color.Viridian, 72 + bricks_setup: [ 73 + { type: BRICK_TYPE.LEVEL_1, life: 1, cols: 8, width: 32, height: 16 }, 74 + { type: BRICK_TYPE.LEVEL_2, life: 2, cols: 8, width: 32, height: 16 }, 75 + { type: BRICK_TYPE.LEVEL_3, life: 3, cols: 8, width: 32, height: 16 }, 76 + { type: BRICK_TYPE.LEVEL_4, life: 1, cols: 8, width: 32, height: 16 }, 77 + ], 78 + }; 79 + const level005: LevelSetup = { 80 + name: 'level005', 81 + balls: 3, 82 + ball_speed: vec(280, -280), 83 + bg_color: Color.Violet, 84 + bricks_setup: [ 85 + // { type: BRICK_TYPE.LEVEL_1, life: 1, cols: 8, width: 32, height: 12 }, 86 + // { type: BRICK_TYPE.LEVEL_2, life: 2, cols: 8, width: 32, height: 12 }, 87 + { type: BRICK_TYPE.LEVEL_3, life: 3, cols: 8, width: 32, height: 12 }, 88 + { type: BRICK_TYPE.LEVEL_4, life: 1, cols: 8, width: 32, height: 12 }, 89 + { type: BRICK_TYPE.LEVEL_5, life: 1, cols: 8, width: 32, height: 12 }, 90 + ], 91 + }; 92 + const level006: LevelSetup = { 93 + name: 'level006', 94 + balls: 3, 95 + ball_speed: vec(280, -280), 96 + bg_color: Color.Vermilion, 97 + bricks_setup: [ 98 + // { type: BRICK_TYPE.LEVEL_1, life: 1, cols: 8, width: 32, height: 16 }, 99 + // { type: BRICK_TYPE.LEVEL_2, life: 2, cols: 8, width: 32, height: 16 }, 100 + { type: BRICK_TYPE.LEVEL_3, life: 3, cols: 8, width: 32, height: 16 }, 101 + { type: BRICK_TYPE.LEVEL_4, life: 1, cols: 8, width: 32, height: 16 }, 102 + { type: BRICK_TYPE.LEVEL_5, life: 1, cols: 8, width: 32, height: 16 }, 103 + { type: BRICK_TYPE.LEVEL_6, life: 1, cols: 8, width: 32, height: 16 }, 104 + ], 105 + }; 106 + // LIST 107 + const levels_setup: LevelSetup[] = [ 108 + level001, 109 + level002, 110 + level003, 111 + level004, 112 + level005, 113 + level006, 114 + ]; 115 + 116 + export const levelManager = new LevelManager(levels_setup);
+51
src/templates/typescript/src/sample/managers/ui.manager.ts
··· 1 + import { SCENE_STATE } from '../models'; 2 + import { gameManager } from './game.manager'; 3 + 4 + class UIManager { 5 + game_container: any; 6 + btn_play: any; 7 + btn_retry: any; 8 + btn_next_level: any; 9 + btn_done: any; 10 + label_level: any; 11 + label_score: any; 12 + label_balls: any; 13 + 14 + constructor() { 15 + this.game_container = document.getElementById('game'); 16 + this.btn_play = document.getElementById('btn-play'); 17 + this.btn_retry = document.getElementById('btn-retry'); 18 + this.btn_next_level = document.getElementById('btn-next-level'); 19 + this.btn_done = document.getElementById('btn-done'); 20 + this.label_level = document.getElementById('label-level'); 21 + this.label_score = document.getElementById('label-score'); 22 + this.label_balls = document.getElementById('label-balls'); 23 + } 24 + 25 + init() { 26 + this.btn_play.onclick = () => gameManager.start_game(); 27 + this.btn_retry.onclick = () => gameManager.retry_level(); 28 + this.btn_next_level.onclick = () => gameManager.next_level(); 29 + this.btn_done.onclick = () => gameManager.reset_game(); 30 + } 31 + print_UI(level: string, balls: number, score: number) { 32 + this.print_level(level); 33 + this.print_balls(balls); 34 + this.print_score(score); 35 + } 36 + update_state(state: SCENE_STATE | 'DONE') { 37 + this.game_container.className = state; 38 + } 39 + // 40 + print_balls(amount: number) { 41 + this.label_balls.innerText = '🪩'.repeat(amount); 42 + } 43 + print_score(score: number) { 44 + this.label_score.innerText = score; 45 + } 46 + print_level(lvl: string) { 47 + this.label_level.innerText = `${lvl}`; 48 + } 49 + } 50 + 51 + export const uiManager = new UIManager();
+45
src/templates/typescript/src/sample/models.ts
··· 1 + import { Color, ImageSource, Vector } from 'excalibur'; 2 + 3 + export enum BRICK_TYPE { 4 + LEVEL_1 = 'LEVEL_1', 5 + LEVEL_2 = 'LEVEL_2', 6 + LEVEL_3 = 'LEVEL_3', 7 + LEVEL_4 = 'LEVEL_4', 8 + LEVEL_5 = 'LEVEL_5', 9 + LEVEL_6 = 'LEVEL_6', 10 + } 11 + export enum GAME_STATES { 12 + LOADING = 'LOADING', 13 + READY = 'READY', 14 + PLAYING = 'PLAYING', 15 + COMPLETED = 'COMPLETED', 16 + ERROR = 'ERROR', 17 + } 18 + export enum SCENE_STATE { 19 + LOADING = 'SCENE_STATE__LOADING', 20 + READY = 'SCENE_STATE__READY', 21 + PLAYING = 'SCENE_STATE__PLAYING', 22 + PAUSED = 'SCENE_STATE__PAUSED', 23 + COMPLETED = 'SCENE_STATE__COMPLETED', 24 + GAMEOVER = 'SCENE_STATE__GAMEOVER', 25 + ERROR = 'SCENE_STATE__ERROR', 26 + } 27 + export enum SCENES_EVENTS { 28 + UPDATE_BALL = 'GAME_EVENTS__UPDATE_BALL', 29 + UPDATE_SCORE = 'GAME_EVENTS__UPDATE_SCORE', 30 + } 31 + export interface BrickSetup { 32 + type: BRICK_TYPE; 33 + life: number; 34 + cols: number; 35 + width: number; 36 + height: number; 37 + } 38 + export interface LevelSetup { 39 + balls: number; 40 + name: string; 41 + ball_speed: Vector; 42 + bg_color: Color; 43 + bricks_setup: BrickSetup[]; 44 + } 45 + export type ImageResource = Record<string, ImageSource>;
+182
src/templates/typescript/src/sample/scenes/level.scene.ts
··· 1 + import { Actor, Color, Engine, Scene, Vector } from 'excalibur'; 2 + import { Brick } from '../actors/brick.actor'; 3 + import { Paddle } from '../actors/paddle.actor'; 4 + import { Ball } from '../actors/ball.actor'; 5 + import { BrickSetup, SCENES_EVENTS, LevelSetup, SCENE_STATE } from '../models'; 6 + import { eventBus, gameManager } from '../managers/game.manager'; 7 + 8 + export class LevelScene extends Scene { 9 + name: string; 10 + balls: number; 11 + ball_speed: Vector; 12 + bg_color: Color; 13 + bricks_setup!: BrickSetup[]; 14 + // 15 + level_balls!: number; 16 + level_bricks: Actor[] = []; 17 + state!: SCENE_STATE; 18 + 19 + constructor(setup: LevelSetup) { 20 + super(); 21 + const { balls, name, ball_speed, bg_color, bricks_setup } = setup; 22 + this.name = name; 23 + this.level_balls = balls; 24 + this.balls = this.level_balls; 25 + this.ball_speed = ball_speed; 26 + this.bg_color = bg_color; 27 + this.bricks_setup = bricks_setup; 28 + } 29 + onInitialize(engine: Engine): void { 30 + this.init(engine); 31 + } 32 + 33 + init(engine: Engine) { 34 + this.reset(); 35 + 36 + //create game objects 37 + this.backgroundColor = this.bg_color; 38 + const paddle = this.create_paddle(engine); 39 + 40 + const ball = this.create_ball(engine); 41 + this.level_bricks = this.create_bricks(); 42 + // add game objects to the scene 43 + this.add(paddle); 44 + this.add(ball); 45 + this.level_bricks.forEach((brick) => this.add(brick)); 46 + // setup player controls 47 + engine.input.pointers.primary.on('move', (e) => { 48 + const can_move = this.state === SCENE_STATE.PLAYING; 49 + if (can_move) { 50 + paddle.pos.x = e.worldPos.x; 51 + } 52 + }); 53 + 54 + // setup interactions 55 + ball.on('postupdate', () => { 56 + const hit_limits = { 57 + screen_top: ball.pos.y - ball.height / 2 < 0, 58 + screen_left: ball.pos.x - ball.width / 2 < 0, 59 + screen_right: ball.pos.x + ball.width / 2 > engine.drawWidth, 60 + }; 61 + 62 + if (hit_limits.screen_top) { 63 + // send to bottom 64 + ball.vel.y = ball.vel.y = ball.ball_speed.y * -1 + Math.random(); 65 + } else if (hit_limits.screen_left) { 66 + // send to the right 67 + ball.vel.x = ball.ball_speed.x + Math.random(); 68 + } else if (hit_limits.screen_right) { 69 + // send to the left 70 + ball.vel.x = ball.ball_speed.x * -1 + Math.random(); 71 + } 72 + }); 73 + let colliding = false; 74 + // destroy bricks 75 + ball.on('collisionstart', (e) => { 76 + const collide_with_brick = this.level_bricks.indexOf(e.other) > -1; 77 + if (collide_with_brick) { 78 + let brick: any = e.other; 79 + brick.life--; 80 + 81 + eventBus.emit(SCENES_EVENTS.UPDATE_SCORE, brick.hit_score); 82 + 83 + if (brick.life <= 0) { 84 + brick.kill(); 85 + eventBus.emit(SCENES_EVENTS.UPDATE_SCORE, brick.destroy_score); 86 + } else { 87 + brick.update_sprite(); 88 + } 89 + 90 + if (this.level_completed()) { 91 + ball.kill(); 92 + this.set_state(SCENE_STATE.COMPLETED); 93 + return; 94 + } 95 + } 96 + 97 + if (!colliding) { 98 + colliding = true; 99 + const intersection = e.contact.mtv.normalize(); 100 + const hit_x = Math.abs(intersection.x) > Math.abs(intersection.y); 101 + 102 + if (hit_x) { 103 + // reverse x 104 + ball.vel.x *= -1; 105 + } else { 106 + // reverse y 107 + ball.vel.y *= -1; 108 + } 109 + } 110 + }); 111 + ball.on('collisionend', () => { 112 + colliding = false; 113 + }); 114 + // lose ball 115 + ball.on('exitviewport', () => { 116 + this.balls--; 117 + eventBus.emit(SCENES_EVENTS.UPDATE_BALL, this.balls); 118 + 119 + if (this.gameover()) { 120 + ball.kill(); 121 + this.set_state(SCENE_STATE.GAMEOVER); 122 + } else { 123 + ball.pos.x = Math.random() * engine.drawWidth; 124 + ball.pos.y = -10; 125 + } 126 + }); 127 + 128 + this.set_state(SCENE_STATE.PLAYING); 129 + } 130 + reset() { 131 + this.clear(); 132 + this.balls = this.level_balls; 133 + } 134 + 135 + set_state(new_state: SCENE_STATE) { 136 + if (this.state === new_state) return; 137 + this.state = new_state; 138 + gameManager.scene_state.next(new_state); 139 + } 140 + // game objects 141 + create_bricks() { 142 + const padding = 32 + 24 / 6; 143 + const offset_x = 32 * 2; 144 + const offset_y = 32 + 32; 145 + 146 + const bricks: Actor[] = []; 147 + 148 + this.bricks_setup.forEach((b, row) => { 149 + for (let col = 0; col < b.cols; col++) { 150 + const x = offset_x + col * (b.width + padding); 151 + const y = offset_y + row * (b.height + padding); 152 + const brick = new Brick(x, y, b.width, b.height, b.type); 153 + bricks.push(brick); 154 + } 155 + }); 156 + 157 + return bricks; 158 + } 159 + create_ball(engine: Engine) { 160 + const x = engine.drawWidth / 2; 161 + const y = -10; 162 + const radius = 8; 163 + const vel = this.ball_speed; 164 + const ball = new Ball(x, y, radius, vel); 165 + return ball; 166 + } 167 + create_paddle(engine: Engine) { 168 + const x = 100; 169 + const y = engine.drawHeight - 64; 170 + const width = 32; 171 + const height = 4; 172 + const paddle = new Paddle(x, y, width, height); 173 + return paddle; 174 + } 175 + // state 176 + level_completed() { 177 + return this.level_bricks.every((b) => b.isKilled()); 178 + } 179 + gameover() { 180 + return this.balls <= 0; 181 + } 182 + }
+48
src/templates/typescript/src/sample/utils.ts
··· 1 + export class Subject { 2 + value: any; 3 + subscribers = new Set(); 4 + 5 + current() { 6 + return this.value; 7 + } 8 + next(new_value: any) { 9 + this.value = new_value; 10 + this.subscribers.forEach((cb: any) => cb(this.value)); 11 + } 12 + subscribe(callback: any) { 13 + this.subscribers.add(callback); 14 + 15 + const unsubscribe = () => { 16 + console.log({ callback }); 17 + this.subscribers.delete(callback); 18 + }; 19 + 20 + return { unsubscribe }; 21 + } 22 + } 23 + export class BehaviourSubject { 24 + value: any; 25 + subscribers = new Set(); 26 + 27 + constructor(initial_value: any) { 28 + this.value = initial_value; 29 + } 30 + 31 + current() { 32 + return this.value; 33 + } 34 + next(new_value: any) { 35 + this.value = new_value; 36 + this.subscribers.forEach((cb: any) => cb(this.value)); 37 + } 38 + subscribe(callback: any) { 39 + this.subscribers.add(callback); 40 + callback(this.value); 41 + const unsubscribe = () => { 42 + this.subscribers.delete(callback); 43 + console.log(this.subscribers); 44 + }; 45 + 46 + return { unsubscribe }; 47 + } 48 + }
-80
src/templates/typescript/src/style.css
··· 1 - :root { 2 - --color-primary: #444cf7; 3 - --bg-color: #edf2f7; 4 - --text-color: rgb(42, 41, 41); 5 - --border-radius: 16px/44px; 6 - } 7 - html, 8 - body { 9 - height: 90%; 10 - } 11 - 12 - body { 13 - color: var(--text-color); 14 - padding: 1rem 1rem; 15 - 16 - font-family: sans-serif; 17 - 18 - display: flex; 19 - flex-direction: column; 20 - gap: 1rem; 21 - 22 - background-color: var(--bg-color); 23 - opacity: 0.8; 24 - background-image: radial-gradient( 25 - var(--color-primary) 0.5px, 26 - var(--bg-color) 0.5px 27 - ); 28 - background-size: 10px 10px; 29 - } 30 - 31 - h1 { 32 - display: flex; 33 - align-items: center; 34 - justify-content: center; 35 - 36 - gap: 0.3rem; 37 - } 38 - 39 - nav { 40 - display: flex; 41 - align-items: center; 42 - justify-content: center; 43 - gap: 1rem; 44 - padding: 1rem; 45 - } 46 - 47 - .nav-link { 48 - display: flex; 49 - justify-content: center; 50 - align-items: center; 51 - gap: 0.3rem; 52 - 53 - background-color: var(--color-primary); 54 - color: whitesmoke; 55 - padding: 1rem 1rem; 56 - text-decoration: none; 57 - 58 - height: 16px; 59 - border-radius: var(--border-radius); 60 - transition: filter 0.3s ease-in-out; 61 - border: 2px solid black; 62 - 63 - &:active { 64 - transform: scale(0.98); 65 - } 66 - 67 - &:hover { 68 - filter: saturate(10); 69 - } 70 - } 71 - 72 - #game { 73 - display: flex; 74 - align-items: center; 75 - justify-content: center; 76 - } 77 - #main-canvas { 78 - border-radius: var(--border-radius); 79 - cursor: e-resize; 80 - }
-1
src/templates/typescript/src/typescript.svg
··· 1 - <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="32" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 256"><path fill="#007ACC" d="M0 128v128h256V0H0z"></path><path fill="#FFF" d="m56.612 128.85l-.081 10.483h33.32v94.68h23.568v-94.68h33.321v-10.28c0-5.69-.122-10.444-.284-10.566c-.122-.162-20.4-.244-44.983-.203l-44.74.122l-.121 10.443Zm149.955-10.742c6.501 1.625 11.459 4.51 16.01 9.224c2.357 2.52 5.851 7.111 6.136 8.208c.08.325-11.053 7.802-17.798 11.988c-.244.162-1.22-.894-2.317-2.52c-3.291-4.795-6.745-6.867-12.028-7.233c-7.76-.528-12.759 3.535-12.718 10.321c0 1.992.284 3.17 1.097 4.795c1.707 3.536 4.876 5.649 14.832 9.956c18.326 7.883 26.168 13.084 31.045 20.48c5.445 8.249 6.664 21.415 2.966 31.208c-4.063 10.646-14.14 17.879-28.323 20.276c-4.388.772-14.79.65-19.504-.203c-10.28-1.828-20.033-6.908-26.047-13.572c-2.357-2.6-6.949-9.387-6.664-9.874c.122-.163 1.178-.813 2.356-1.504c1.138-.65 5.446-3.129 9.509-5.485l7.355-4.267l1.544 2.276c2.154 3.29 6.867 7.801 9.712 9.305c8.167 4.307 19.383 3.698 24.909-1.26c2.357-2.153 3.332-4.388 3.332-7.68c0-2.966-.366-4.266-1.91-6.501c-1.99-2.845-6.054-5.242-17.595-10.24c-13.206-5.69-18.895-9.224-24.096-14.832c-3.007-3.25-5.852-8.452-7.03-12.8c-.975-3.617-1.22-12.678-.447-16.335c2.723-12.76 12.353-21.659 26.25-24.3c4.51-.853 14.994-.528 19.424.569Z"></path></svg>
+248
src/templates/typescript/style.css
··· 1 + * { 2 + padding: 0; 3 + margin: 0; 4 + } 5 + :root { 6 + --color-primary: #2085d1; 7 + --bg-color: #edf2f7; 8 + --text-color: rgb(42, 41, 41); 9 + --border-radius: 16px/44px; 10 + } 11 + html, 12 + body { 13 + height: 90%; 14 + } 15 + 16 + body { 17 + color: var(--text-color); 18 + padding: 1rem 1rem; 19 + 20 + font-family: sans-serif; 21 + 22 + display: flex; 23 + flex-direction: column; 24 + gap: 1rem; 25 + 26 + background-color: var(--bg-color); 27 + opacity: 0.8; 28 + background-image: radial-gradient( 29 + var(--color-primary) 0.5px, 30 + var(--bg-color) 0.5px 31 + ); 32 + background-size: 10px 10px; 33 + } 34 + 35 + h1 { 36 + display: flex; 37 + align-items: center; 38 + justify-content: center; 39 + 40 + gap: 0.3rem; 41 + } 42 + 43 + nav { 44 + display: flex; 45 + align-items: center; 46 + justify-content: center; 47 + gap: 1rem; 48 + padding: 1rem; 49 + } 50 + 51 + .nav-link { 52 + display: flex; 53 + justify-content: center; 54 + align-items: center; 55 + gap: 0.3rem; 56 + 57 + background-color: var(--color-primary); 58 + color: whitesmoke; 59 + padding: 1rem 1rem; 60 + text-decoration: none; 61 + 62 + height: 16px; 63 + border-radius: var(--border-radius); 64 + transition: filter 0.3s ease-in-out; 65 + border: 2px solid black; 66 + 67 + &:active { 68 + transform: scale(0.98); 69 + } 70 + 71 + &:hover { 72 + filter: saturate(2); 73 + } 74 + } 75 + 76 + #game { 77 + display: flex; 78 + align-items: center; 79 + justify-content: center; 80 + 81 + position: relative; 82 + 83 + #main-canvas { 84 + border-radius: var(--border-radius); 85 + } 86 + 87 + #ui { 88 + position: absolute; 89 + background-color: rgba(42, 41, 41, 0.3); 90 + width: 100%; 91 + height: 100%; 92 + width: 600px; 93 + border-radius: var(--border-radius); 94 + 95 + display: flex; 96 + align-items: center; 97 + justify-content: center; 98 + 99 + button { 100 + width: 200px; 101 + height: 56px; 102 + border: 3px solid black; 103 + cursor: pointer; 104 + border-radius: var(--border-radius); 105 + transition: all 0.3s ease-in-out; 106 + font-weight: bold; 107 + 108 + &:active { 109 + transform: scale(0.98); 110 + } 111 + 112 + &.play { 113 + background-color: #2085d1; 114 + } 115 + &.retry { 116 + background-color: #6f7378; 117 + } 118 + &.next-level { 119 + background-color: #20d199; 120 + } 121 + &.restart { 122 + background-color: #8420d1; 123 + } 124 + } 125 + .ready-container { 126 + display: none; 127 + } 128 + .playing-container { 129 + display: none; 130 + } 131 + .gameover-container { 132 + display: none; 133 + } 134 + .completed-container { 135 + display: none; 136 + } 137 + .done-container { 138 + display: none; 139 + } 140 + } 141 + 142 + /* */ 143 + &.SCENE_STATE__LOADING { 144 + #ui { 145 + display: none; 146 + } 147 + } 148 + &.SCENE_STATE__READY { 149 + #ui { 150 + .ready-container { 151 + display: flex; 152 + align-items: center; 153 + justify-content: center; 154 + height: 100%; 155 + transition: all 0.3s ease-in-out; 156 + } 157 + } 158 + } 159 + &.SCENE_STATE__PLAYING { 160 + #ui { 161 + display: flex; 162 + pointer-events: none; 163 + background-color: transparent; 164 + .playing-container { 165 + position: absolute; 166 + color: whitesmoke; 167 + /* background-color: whitesmoke; */ 168 + top: 1rem; 169 + left: 2rem; 170 + width: 90%; 171 + 172 + display: flex; 173 + align-items: center; 174 + justify-content: space-between; 175 + 176 + gap: 1rem; 177 + 178 + .left_pane { 179 + background-color: black; 180 + padding: 0.3rem 0.3rem; 181 + opacity: 0.8; 182 + } 183 + } 184 + } 185 + 186 + canvas { 187 + cursor: e-resize; 188 + } 189 + } 190 + &.SCENE_STATE__COMPLETED { 191 + #ui { 192 + display: flex; 193 + 194 + .completed-container { 195 + display: flex; 196 + align-items: center; 197 + justify-content: center; 198 + height: 100%; 199 + 200 + transition: all 0.3s ease-in-out; 201 + } 202 + } 203 + } 204 + &.SCENE_STATE__GAMEOVER { 205 + #ui { 206 + display: flex; 207 + .gameover-container { 208 + display: flex; 209 + align-items: center; 210 + justify-content: center; 211 + height: 100%; 212 + transition: all 0.3s ease-in-out; 213 + } 214 + } 215 + } 216 + &.DONE { 217 + #ui { 218 + display: flex; 219 + 220 + width: 90%; 221 + background-color: whitesmoke; 222 + 223 + .done-container { 224 + display: flex; 225 + align-items: center; 226 + justify-content: center; 227 + flex-direction: column; 228 + gap: 1rem; 229 + 230 + button { 231 + height: 56px; 232 + } 233 + } 234 + } 235 + } 236 + } 237 + /* */ 238 + #credits { 239 + display: flex; 240 + align-items: center; 241 + justify-content: center; 242 + .credit { 243 + background-color: whitesmoke; 244 + padding: 0.5rem; 245 + border: 1px solid; 246 + border-radius: var(--border-radius); 247 + } 248 + }