├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── config ├── eslint.config.mjs └── vite.config.js ├── docs ├── logo.png ├── planning │ ├── map-layout.png │ ├── movement.png │ ├── state-machine.png │ ├── state_machine.excalidraw │ ├── zelda_map_layout.excalidraw │ └── zelda_movement.excalidraw ├── screenshot1.png ├── screenshot2.png └── screenshot3.png ├── index.html ├── package.json ├── pnpm-lock.yaml ├── project-task.todo ├── public └── assets │ ├── data │ └── assets.json │ ├── fonts │ └── Press_Start_2P │ │ ├── OFL.txt │ │ └── PressStart2P-Regular.ttf │ └── images │ ├── enemies │ ├── drow.json │ ├── drow.png │ ├── enemy_death.png │ ├── spider.json │ ├── spider.png │ ├── wisp.json │ └── wisp.png │ ├── hud │ ├── heart_and_hud_numbers.json │ └── heart_and_hud_numbers.png │ ├── levels │ ├── common │ │ ├── collision.png │ │ ├── dungeon_objects.json │ │ ├── dungeon_objects.png │ │ ├── pot.png │ │ └── pot_break.png │ ├── dungeon_1 │ │ ├── dungeon_1.json │ │ ├── dungeon_1_background.png │ │ └── dungeon_1_foreground.png │ └── world │ │ ├── world.json │ │ ├── world_background.png │ │ └── world_foreground.png │ ├── player │ ├── dagger.png │ ├── main_green.json │ └── main_green.png │ └── ui │ ├── cursor_white.png │ ├── dialog_ui.png │ └── icons.png ├── src ├── common │ ├── assets.ts │ ├── common.ts │ ├── config.ts │ ├── data-manager.ts │ ├── event-bus.ts │ ├── juice-utils.ts │ ├── tiled │ │ ├── common.ts │ │ ├── tiled-utils.ts │ │ └── types.ts │ ├── types.ts │ └── utils.ts ├── components │ ├── game-object │ │ ├── animation-component.ts │ │ ├── base-game-object-component.ts │ │ ├── colliding-objects-component.ts │ │ ├── controls-component.ts │ │ ├── direction-component.ts │ │ ├── held-game-object-component.ts │ │ ├── interactive-object-component.ts │ │ ├── invulnerable-component.ts │ │ ├── life-component.ts │ │ ├── speed-component.ts │ │ ├── throwable-object-component.ts │ │ └── weapon-component.ts │ ├── input │ │ ├── input-component.ts │ │ └── keyboard-component.ts │ ├── inventory │ │ └── inventory-manager.ts │ └── state-machine │ │ ├── state-machine.ts │ │ └── states │ │ └── character │ │ ├── attack-state.ts │ │ ├── base-character-state.ts │ │ ├── base-move-state.ts │ │ ├── boss │ │ └── drow │ │ │ ├── boss-drow-hidden-state.ts │ │ │ ├── boss-drow-idle-state.ts │ │ │ ├── boss-drow-prepare-attack-state.ts │ │ │ └── boss-drow-teleport-state.ts │ │ ├── bounce-move-state.ts │ │ ├── character-states.ts │ │ ├── death-state.ts │ │ ├── hurt-state.ts │ │ ├── idle-holding-state.ts │ │ ├── idle-state.ts │ │ ├── lift-state.ts │ │ ├── move-holding-state.ts │ │ ├── move-state.ts │ │ ├── open-chest-state.ts │ │ └── throw-state.ts ├── game-objects │ ├── common │ │ └── character-game-object.ts │ ├── enemies │ │ ├── boss │ │ │ └── drow.ts │ │ ├── spider.ts │ │ └── wisp.ts │ ├── objects │ │ ├── button.ts │ │ ├── chest.ts │ │ ├── door.ts │ │ └── pot.ts │ ├── player │ │ └── player.ts │ └── weapons │ │ ├── base-weapon.ts │ │ ├── dagger.ts │ │ └── sword.ts ├── main.ts └── scenes │ ├── game-over-scene.ts │ ├── game-scene.ts │ ├── preload-scene.ts │ ├── scene-keys.ts │ └── ui-scene.ts ├── tsconfig.json └── vite.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | project-task-tracker.todo 5 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.format.enable": true, 3 | "eslint.options": { 4 | "overrideConfigFile": "config/eslint.config.mjs" 5 | }, 6 | "editor.defaultFormatter": "dbaeumer.vscode-eslint", 7 | "editor.codeActionsOnSave": { 8 | "source.fixAll.eslint": "explicit" 9 | }, 10 | "cSpell.words": [ 11 | "anims", 12 | "Aseprite", 13 | "devshareacademy", 14 | "DROW", 15 | "firstgid", 16 | "Interactable", 17 | "respawn", 18 | "spritesheet", 19 | "Tilemap", 20 | "Tilemaps", 21 | "tileset", 22 | "tweens" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Dev Share Academy 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 | # Legend of the Wispguard 2 | 3 | ![License](https://img.shields.io/badge/license-MIT-green) 4 | 5 | ![Legend of the Wispguard Logo](/docs/logo.png?raw=true 'Legend of the Wispguard Logo') 6 | 7 | Legend of the Wispguard - Zelda-like Tutorial with [Phaser 3](https://github.com/photonstorm/phaser)! 8 | 9 | This repo is the official code repository for the Legend of the Wispguard: Build a Zelda-Like Game in Phaser 3 Course that is available on YouTube. 10 | 11 | ## Demo 12 | 13 | You can find a playable demo of the game on Itch.io here: [Legend of the Wispguard](https://galemius.itch.io/legend-of-the-wispguard) 14 | 15 | ![Game play Screenshot 1](/docs/screenshot1.png?raw=true 'Screenshot 1') 16 | ![Game play Screenshot 2](/docs/screenshot2.png?raw=true 'Screenshot 2') 17 | ![Game play Screenshot 3](/docs/screenshot3.png?raw=true 'Screenshot 3') 18 | 19 | ## How To Play 20 | 21 | Currently, the only supported way to play the game is with a Keyboard. 22 | 23 | ### Controls 24 | 25 | | Keys | Description | 26 | | -------------------------------------- | ----------------------------------------------------------------------------------------------------- | 27 | | Arrow Keys (Up, Down, Left, and Right) | Moves the player. Navigate menu. | 28 | | Z | Attack | 29 | | X | Lift/Throw | 30 | | Enter | Select menu option. | 31 | 32 | 33 | ## Local Development 34 | 35 | ### Requirements 36 | 37 | Node.js and pnpm are required to install dependencies and run scripts via `pnpm`. 38 | 39 | **Note:** You can also use `npm` to install the required project dependencies. To do this, replace the commands listed below with the relevant `npm` command, such as `npm install` or `npm run start`. 40 | 41 | Vite is required to bundle and serve the web application. This is included as part of the projects dev dependencies. 42 | 43 | ### Available Commands 44 | 45 | | Command | Description | 46 | |---------|-------------| 47 | | `pnpm install --frozen-lockfile` | Install project dependencies | 48 | | `pnpm start` | Build project and open web server running project | 49 | | `pnpm build` | Builds code bundle for production | 50 | | `pnpm lint` | Uses ESLint to lint code | 51 | 52 | ### Writing Code 53 | 54 | After cloning the repo, run `pnpm install --frozen-lockfile` from your project directory. Then, you can start the local development 55 | server by running `pnpm start`. 56 | 57 | After starting the development server with `pnpm start`, you can edit any files in the `src` folder 58 | and parcel will automatically recompile and reload your server (available at `http://localhost:8080` 59 | by default). 60 | 61 | ### Deploying Code 62 | 63 | After you run the `pnpm build` command, your code will be built into a single bundle located at 64 | `dist/*` along with any other assets you project depended. 65 | 66 | If you put the contents of the `dist` folder in a publicly-accessible location (say something like `http://myserver.com`), 67 | you should be able to open `http://myserver.com/index.html` and play your game. 68 | 69 | ### Static Assets 70 | 71 | Any static assets like images or audio files should be placed in the `public` folder. It'll then be served at `http://localhost:8080/path-to-file-your-file/file-name.file-type`. 72 | 73 | ## Credits 74 | 75 | This project would have not been possible without the use of some awesome assets created by some amazing artists! This project would not have been possible without the following people/resources: 76 | 77 | | Asset | Author | Link | 78 | | --------------------------- | ---------------- | ---------------------------------------------------------------------- | 79 | | Press Start 2P Font | CodeMan38 | [Google Fonts](https://fonts.google.com/specimen/Press+Start+2P) | 80 | | Player | Foozle | [Legend Main Character](https://foozlecc.itch.io/legend-main-character)| 81 | | Enemies | Foozle | [Legend Enemy Pack 1](https://foozlecc.itch.io/legend-enemy-pack-1) | 82 | | Dungeon Pack | Foozle | [Legend Spider Dungeon](https://foozlecc.itch.io/legend-spider-dungeon)| 83 | | UI Icons | Foozle | [Legend UI Icons](https://foozlecc.itch.io/legend-ui-icons) | 84 | 85 | ## Issues 86 | 87 | For any issues you encounter, please open a new [GitHub Issue](https://github.com/devshareacademy/phaser-zelda-like-tutorial/issues) on this project. 88 | -------------------------------------------------------------------------------- /config/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | import { fileURLToPath } from "node:url"; 3 | import js from "@eslint/js"; 4 | import { FlatCompat } from "@eslint/eslintrc"; 5 | 6 | const __filename = fileURLToPath(import.meta.url); 7 | const __dirname = path.dirname(__filename); 8 | const compat = new FlatCompat({ 9 | baseDirectory: __dirname, 10 | recommendedConfig: js.configs.recommended, 11 | allConfig: js.configs.all 12 | }); 13 | 14 | export default [{ 15 | ignores: ["**/node_modules", "**/dist"], 16 | }, ...compat.extends("@devshareacademy/eslint-config"), { 17 | languageOptions: { 18 | ecmaVersion: 5, 19 | sourceType: "script", 20 | 21 | parserOptions: { 22 | project: "./tsconfig.json", 23 | }, 24 | }, 25 | 26 | rules: { 27 | "@typescript-eslint/unbound-method": "off", 28 | }, 29 | 30 | files: ["**/*.ts", "**/*.tsx"] 31 | }]; 32 | -------------------------------------------------------------------------------- /config/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | 3 | export default defineConfig({ 4 | base: "./", 5 | build: { 6 | chunkSizeWarningLimit: 1600, 7 | rollupOptions: { 8 | output: { 9 | entryFileNames: 'assets/js/[name]-[hash].js', 10 | }, 11 | }, 12 | }, 13 | server: { 14 | port: 3000, 15 | }, 16 | }); 17 | -------------------------------------------------------------------------------- /docs/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devshareacademy/phaser-zelda-like-tutorial/63b62db25fb9e6ffdff6f54e5230ed304ddfe9cc/docs/logo.png -------------------------------------------------------------------------------- /docs/planning/map-layout.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devshareacademy/phaser-zelda-like-tutorial/63b62db25fb9e6ffdff6f54e5230ed304ddfe9cc/docs/planning/map-layout.png -------------------------------------------------------------------------------- /docs/planning/movement.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devshareacademy/phaser-zelda-like-tutorial/63b62db25fb9e6ffdff6f54e5230ed304ddfe9cc/docs/planning/movement.png -------------------------------------------------------------------------------- /docs/planning/state-machine.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devshareacademy/phaser-zelda-like-tutorial/63b62db25fb9e6ffdff6f54e5230ed304ddfe9cc/docs/planning/state-machine.png -------------------------------------------------------------------------------- /docs/screenshot1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devshareacademy/phaser-zelda-like-tutorial/63b62db25fb9e6ffdff6f54e5230ed304ddfe9cc/docs/screenshot1.png -------------------------------------------------------------------------------- /docs/screenshot2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devshareacademy/phaser-zelda-like-tutorial/63b62db25fb9e6ffdff6f54e5230ed304ddfe9cc/docs/screenshot2.png -------------------------------------------------------------------------------- /docs/screenshot3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devshareacademy/phaser-zelda-like-tutorial/63b62db25fb9e6ffdff6f54e5230ed304ddfe9cc/docs/screenshot3.png -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Phaser 3 - zelda_legend 6 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@devshareacademy/phaser-3-zelda_legend", 3 | "version": "1.0.0", 4 | "description": "A Phaser 3 implementation of zelda_legend.", 5 | "scripts": { 6 | "start": "vite --config config/vite.config.js", 7 | "build": "tsc && vite build --config config/vite.config.js", 8 | "serve": "vite preview --config config/vite.config.js", 9 | "lint": "eslint ./src -c ./config/eslint.config.mjs" 10 | }, 11 | "author": "scottwestover", 12 | "license": "MIT", 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/devshareacademy/phaser-zelda-like-tutorial.git" 16 | }, 17 | "homepage": "https://github.com/devshareacademy/phaser-zelda-like-tutorial", 18 | "devDependencies": { 19 | "@devshareacademy/eslint-config": "0.0.19", 20 | "@devshareacademy/prettier-config": "0.0.6", 21 | "@devshareacademy/tsconfig": "0.0.3", 22 | "@typescript-eslint/eslint-plugin": "8.20.0", 23 | "@typescript-eslint/parser": "8.20.0", 24 | "eslint": "9.18.0", 25 | "eslint-config-prettier": "10.0.1", 26 | "eslint-plugin-prettier": "5.2.3", 27 | "prettier": "3.4.2", 28 | "typescript": "5.7.3", 29 | "vite": "6.0.7" 30 | }, 31 | "dependencies": { 32 | "phaser": "3.87.0" 33 | }, 34 | "resolutions": {}, 35 | "prettier": "@devshareacademy/prettier-config", 36 | "volta": { 37 | "node": "20.11.0", 38 | "yarn": "1.22.11", 39 | "pnpm": "8.14.3" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /project-task.todo: -------------------------------------------------------------------------------- 1 | Intro: 2 | ☐ demo game that will be built 3 | ☐ project requirements 4 | ☐ review project task list 5 | ☐ credit for assets used in project 6 | 7 | Project Setup: 8 | ☐ download basic project files 9 | ☐ download tiled for level editing 10 | ☐ install project dependencies 11 | ☐ running project on local web server 12 | ☐ adding assets to project 13 | 14 | Player: 15 | ☐ create player game object 16 | ☐ handle player input 17 | ☐ player movement 18 | ☐ character state machine 19 | ☐ components for animation, speed, and direction 20 | 21 | Enemies: 22 | ☐ create character base class 23 | ☐ update spider and player class to use 24 | ☐ create spider 25 | ☐ state machine 26 | ☐ components for animation, speed, and direction 27 | ☐ basic random enemy movement 28 | ☐ create wisp 29 | ☐ state machine 30 | ☐ add common components 31 | ☐ invulnerable component 32 | ☐ collisions between player and enemies 33 | ☐ life component and taking damage 34 | 35 | Interactable Items: 36 | ☐ create breakable pot 37 | ☐ create chest 38 | ☐ add collision between player and items 39 | ☐ add player states for interacting with items 40 | ☐ open chests 41 | ☐ pickup and throw pots 42 | ☐ drop pots when hurt 43 | 44 | Level Design & Creation: 45 | ☐ review dungeon and over world level designs 46 | ☐ review data we will need for each level 47 | ☐ review how levels are connected together 48 | ☐ tiled 49 | ☐ create dungeon 50 | ☐ add background image for a guide 51 | ☐ build dungeon rooms and outline 52 | ☐ add collision layers 53 | ☐ import custom types 54 | ☐ add game objects for doors, chests, enemies, traps, etc 55 | ☐ create foreground layer 56 | ☐ add room layers 57 | ☐ export json data and images 58 | ☐ create over world level (same steps as creating dungeon) 59 | 60 | Levels: 61 | ☐ create background and foreground images 62 | ☐ loading tiled map data 63 | ☐ add collision layers 64 | ☐ camera follow player 65 | ☐ bind camera to room 66 | ☐ room transitions 67 | ☐ level transitions 68 | 69 | Level Objects: 70 | ☐ create pots 71 | ☐ create chests 72 | ☐ create enemies 73 | ☐ create doors 74 | ☐ create buttons 75 | 76 | Dungeon Mechanics: 77 | ☐ traps 78 | ☐ button press 79 | ☐ enemies defeated 80 | ☐ inventory manager 81 | ☐ chest items 82 | ☐ locked doors 83 | ☐ small key door 84 | ☐ boss key door 85 | ☐ respawn select objects 86 | ☐ show/hide objects dynamically 87 | 88 | Attacking: 89 | ☐ attack state 90 | ☐ weapon component 91 | ☐ base weapon class 92 | ☐ sword weapon 93 | ☐ weapon collisions 94 | 95 | Data Manager: 96 | ☐ player details 97 | ☐ level details 98 | ☐ game scene updates 99 | 100 | UI: 101 | ☐ player health 102 | ☐ game over 103 | ☐ dialog ui 104 | 105 | Boss: 106 | ☐ create boss character 107 | ☐ add boss states 108 | ☐ dagger weapon 109 | ☐ boss defeated 110 | 111 | 112 | 113 | 114 | ------------------------------- 115 | Premium Content: 116 | ☐ asset pack splitting and dynamic asset loading 117 | ☐ saving and loading game state 118 | ☐ tile scene 119 | ☐ saved data scene 120 | ☐ additional weapons & items 121 | ☐ bombs 122 | ☐ bow 123 | ☐ 2nd dungeon 124 | ☐ flying tile enemies 125 | ☐ additional boss 126 | ☐ inventory screen 127 | ☐ map screen 128 | ☐ hud enhancements - items, equipped item, etc. 129 | ☐ new mechanics 130 | ☐ bombable entrances 131 | ☐ falling floor 132 | ☐ level 2 tier items 133 | ☐ deal more damage 134 | ☐ receive less damage 135 | 136 | ------------------------------- 137 | 138 | ------------------------------- 139 | ☐ ✔ ✘ 140 | ------------------------------- 141 | -------------------------------------------------------------------------------- /public/assets/data/assets.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "path": "assets/fonts", 4 | "files": [ 5 | { 6 | "type": "font", 7 | "key": "FONT_PRESS_START_2P", 8 | "url": "Press_Start_2P/PressStart2P-Regular.ttf", 9 | "format": "truetype" 10 | } 11 | ] 12 | }, 13 | { 14 | "path": "assets/images/hud", 15 | "files": [ 16 | { 17 | "type": "aseprite", 18 | "key": "HUD_NUMBERS", 19 | "textureURL": "heart_and_hud_numbers.png", 20 | "atlasURL": "heart_and_hud_numbers.json" 21 | } 22 | ] 23 | }, 24 | { 25 | "path": "assets/images/ui", 26 | "files": [ 27 | { 28 | "type": "image", 29 | "key": "UI_DIALOG", 30 | "url": "dialog_ui.png" 31 | }, 32 | { 33 | "type": "image", 34 | "key": "UI_CURSOR", 35 | "url": "cursor_white.png" 36 | }, 37 | { 38 | "type": "spritesheet", 39 | "key": "UI_ICONS", 40 | "url": "icons.png", 41 | "frameConfig": { "frameWidth": 16, "frameHeight": 16 } 42 | } 43 | ] 44 | }, 45 | { 46 | "path": "assets/images/player", 47 | "files": [ 48 | { 49 | "type": "aseprite", 50 | "key": "PLAYER", 51 | "textureURL": "main_green.png", 52 | "atlasURL": "main_green.json" 53 | }, 54 | { 55 | "type": "spritesheet", 56 | "key": "DAGGER", 57 | "url": "dagger.png", 58 | "frameConfig": { "frameWidth": 16, "frameHeight": 16 } 59 | } 60 | ] 61 | }, 62 | { 63 | "path": "assets/images/levels/common", 64 | "files": [ 65 | { 66 | "type": "image", 67 | "key": "COLLISION", 68 | "url": "collision.png" 69 | }, 70 | { 71 | "type": "atlas", 72 | "key": "DUNGEON_OBJECTS", 73 | "textureURL": "dungeon_objects.png", 74 | "atlasURL": "dungeon_objects.json" 75 | }, 76 | { 77 | "type": "image", 78 | "key": "POT", 79 | "url": "pot.png" 80 | }, 81 | { 82 | "type": "spritesheet", 83 | "key": "POT_BREAK", 84 | "url": "pot_break.png", 85 | "frameConfig": { "frameWidth": 32, "frameHeight": 32 } 86 | } 87 | ] 88 | }, 89 | { 90 | "path": "assets/images/enemies", 91 | "files": [ 92 | { 93 | "type": "aseprite", 94 | "key": "SPIDER", 95 | "textureURL": "spider.png", 96 | "atlasURL": "spider.json" 97 | }, 98 | { 99 | "type": "aseprite", 100 | "key": "WISP", 101 | "textureURL": "wisp.png", 102 | "atlasURL": "wisp.json" 103 | }, 104 | { 105 | "type": "spritesheet", 106 | "key": "ENEMY_DEATH", 107 | "url": "enemy_death.png", 108 | "frameConfig": { "frameWidth": 16, "frameHeight": 16 } 109 | }, 110 | { 111 | "type": "aseprite", 112 | "key": "DROW", 113 | "textureURL": "drow.png", 114 | "atlasURL": "drow.json" 115 | } 116 | ] 117 | }, 118 | { 119 | "path": "assets/images/levels/dungeon_1", 120 | "files": [ 121 | { 122 | "type": "image", 123 | "key": "DUNGEON_1_BACKGROUND", 124 | "url": "dungeon_1_background.png" 125 | }, 126 | { 127 | "type": "image", 128 | "key": "DUNGEON_1_FOREGROUND", 129 | "url": "dungeon_1_foreground.png" 130 | }, 131 | { 132 | "type": "tilemapTiledJSON", 133 | "key": "DUNGEON_1_LEVEL", 134 | "url": "dungeon_1.json" 135 | } 136 | ] 137 | }, 138 | { 139 | "path": "assets/images/levels/world", 140 | "files": [ 141 | { 142 | "type": "image", 143 | "key": "WORLD_BACKGROUND", 144 | "url": "world_background.png" 145 | }, 146 | { 147 | "type": "image", 148 | "key": "WORLD_FOREGROUND", 149 | "url": "world_foreground.png" 150 | }, 151 | { 152 | "type": "tilemapTiledJSON", 153 | "key": "WORLD_LEVEL", 154 | "url": "world.json" 155 | } 156 | ] 157 | } 158 | ] 159 | -------------------------------------------------------------------------------- /public/assets/fonts/Press_Start_2P/OFL.txt: -------------------------------------------------------------------------------- 1 | Copyright 2012 The Press Start 2P Project Authors (cody@zone38.net), with Reserved Font Name "Press Start 2P". 2 | 3 | This Font Software is licensed under the SIL Open Font License, Version 1.1. 4 | This license is copied below, and is also available with a FAQ at: 5 | https://openfontlicense.org 6 | 7 | 8 | ----------------------------------------------------------- 9 | SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 10 | ----------------------------------------------------------- 11 | 12 | PREAMBLE 13 | The goals of the Open Font License (OFL) are to stimulate worldwide 14 | development of collaborative font projects, to support the font creation 15 | efforts of academic and linguistic communities, and to provide a free and 16 | open framework in which fonts may be shared and improved in partnership 17 | with others. 18 | 19 | The OFL allows the licensed fonts to be used, studied, modified and 20 | redistributed freely as long as they are not sold by themselves. The 21 | fonts, including any derivative works, can be bundled, embedded, 22 | redistributed and/or sold with any software provided that any reserved 23 | names are not used by derivative works. The fonts and derivatives, 24 | however, cannot be released under any other type of license. The 25 | requirement for fonts to remain under this license does not apply 26 | to any document created using the fonts or their derivatives. 27 | 28 | DEFINITIONS 29 | "Font Software" refers to the set of files released by the Copyright 30 | Holder(s) under this license and clearly marked as such. This may 31 | include source files, build scripts and documentation. 32 | 33 | "Reserved Font Name" refers to any names specified as such after the 34 | copyright statement(s). 35 | 36 | "Original Version" refers to the collection of Font Software components as 37 | distributed by the Copyright Holder(s). 38 | 39 | "Modified Version" refers to any derivative made by adding to, deleting, 40 | or substituting -- in part or in whole -- any of the components of the 41 | Original Version, by changing formats or by porting the Font Software to a 42 | new environment. 43 | 44 | "Author" refers to any designer, engineer, programmer, technical 45 | writer or other person who contributed to the Font Software. 46 | 47 | PERMISSION & CONDITIONS 48 | Permission is hereby granted, free of charge, to any person obtaining 49 | a copy of the Font Software, to use, study, copy, merge, embed, modify, 50 | redistribute, and sell modified and unmodified copies of the Font 51 | Software, subject to the following conditions: 52 | 53 | 1) Neither the Font Software nor any of its individual components, 54 | in Original or Modified Versions, may be sold by itself. 55 | 56 | 2) Original or Modified Versions of the Font Software may be bundled, 57 | redistributed and/or sold with any software, provided that each copy 58 | contains the above copyright notice and this license. These can be 59 | included either as stand-alone text files, human-readable headers or 60 | in the appropriate machine-readable metadata fields within text or 61 | binary files as long as those fields can be easily viewed by the user. 62 | 63 | 3) No Modified Version of the Font Software may use the Reserved Font 64 | Name(s) unless explicit written permission is granted by the corresponding 65 | Copyright Holder. This restriction only applies to the primary font name as 66 | presented to the users. 67 | 68 | 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font 69 | Software shall not be used to promote, endorse or advertise any 70 | Modified Version, except to acknowledge the contribution(s) of the 71 | Copyright Holder(s) and the Author(s) or with their explicit written 72 | permission. 73 | 74 | 5) The Font Software, modified or unmodified, in part or in whole, 75 | must be distributed entirely under this license, and must not be 76 | distributed under any other license. The requirement for fonts to 77 | remain under this license does not apply to any document created 78 | using the Font Software. 79 | 80 | TERMINATION 81 | This license becomes null and void if any of the above conditions are 82 | not met. 83 | 84 | DISCLAIMER 85 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 86 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF 87 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT 88 | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE 89 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 90 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL 91 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 92 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM 93 | OTHER DEALINGS IN THE FONT SOFTWARE. 94 | -------------------------------------------------------------------------------- /public/assets/fonts/Press_Start_2P/PressStart2P-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devshareacademy/phaser-zelda-like-tutorial/63b62db25fb9e6ffdff6f54e5230ed304ddfe9cc/public/assets/fonts/Press_Start_2P/PressStart2P-Regular.ttf -------------------------------------------------------------------------------- /public/assets/images/enemies/drow.json: -------------------------------------------------------------------------------- 1 | { "frames": { 2 | "0": { 3 | "frame": { "x": 81, "y": 28, "w": 16, "h": 26 }, 4 | "rotated": false, 5 | "trimmed": true, 6 | "spriteSourceSize": { "x": 8, "y": 6, "w": 16, "h": 26 }, 7 | "sourceSize": { "w": 32, "h": 32 }, 8 | "duration": 200 9 | }, 10 | "1": { 11 | "frame": { "x": 56, "y": 55, "w": 16, "h": 25 }, 12 | "rotated": false, 13 | "trimmed": true, 14 | "spriteSourceSize": { "x": 8, "y": 7, "w": 16, "h": 25 }, 15 | "sourceSize": { "w": 32, "h": 32 }, 16 | "duration": 200 17 | }, 18 | "2": { 19 | "frame": { "x": 81, "y": 28, "w": 16, "h": 26 }, 20 | "rotated": false, 21 | "trimmed": true, 22 | "spriteSourceSize": { "x": 8, "y": 6, "w": 16, "h": 26 }, 23 | "sourceSize": { "w": 32, "h": 32 }, 24 | "duration": 200 25 | }, 26 | "3": { 27 | "frame": { "x": 73, "y": 55, "w": 16, "h": 25 }, 28 | "rotated": false, 29 | "trimmed": true, 30 | "spriteSourceSize": { "x": 8, "y": 7, "w": 16, "h": 25 }, 31 | "sourceSize": { "w": 32, "h": 32 }, 32 | "duration": 200 33 | }, 34 | "4": { 35 | "frame": { "x": 99, "y": 81, "w": 14, "h": 25 }, 36 | "rotated": false, 37 | "trimmed": true, 38 | "spriteSourceSize": { "x": 9, "y": 7, "w": 14, "h": 25 }, 39 | "sourceSize": { "w": 32, "h": 32 }, 40 | "duration": 200 41 | }, 42 | "5": { 43 | "frame": { "x": 35, "y": 81, "w": 15, "h": 26 }, 44 | "rotated": false, 45 | "trimmed": true, 46 | "spriteSourceSize": { "x": 8, "y": 6, "w": 15, "h": 26 }, 47 | "sourceSize": { "w": 32, "h": 32 }, 48 | "duration": 200 49 | }, 50 | "6": { 51 | "frame": { "x": 83, "y": 81, "w": 15, "h": 25 }, 52 | "rotated": false, 53 | "trimmed": true, 54 | "spriteSourceSize": { "x": 8, "y": 7, "w": 15, "h": 25 }, 55 | "sourceSize": { "w": 32, "h": 32 }, 56 | "duration": 200 57 | }, 58 | "7": { 59 | "frame": { "x": 51, "y": 81, "w": 15, "h": 26 }, 60 | "rotated": false, 61 | "trimmed": true, 62 | "spriteSourceSize": { "x": 8, "y": 6, "w": 15, "h": 26 }, 63 | "sourceSize": { "w": 32, "h": 32 }, 64 | "duration": 200 65 | }, 66 | "8": { 67 | "frame": { "x": 98, "y": 28, "w": 16, "h": 26 }, 68 | "rotated": false, 69 | "trimmed": true, 70 | "spriteSourceSize": { "x": 8, "y": 6, "w": 16, "h": 26 }, 71 | "sourceSize": { "w": 32, "h": 32 }, 72 | "duration": 200 73 | }, 74 | "9": { 75 | "frame": { "x": 64, "y": 27, "w": 16, "h": 27 }, 76 | "rotated": false, 77 | "trimmed": true, 78 | "spriteSourceSize": { "x": 8, "y": 5, "w": 16, "h": 27 }, 79 | "sourceSize": { "w": 32, "h": 32 }, 80 | "duration": 200 81 | }, 82 | "10": { 83 | "frame": { "x": 98, "y": 28, "w": 16, "h": 26 }, 84 | "rotated": false, 85 | "trimmed": true, 86 | "spriteSourceSize": { "x": 8, "y": 6, "w": 16, "h": 26 }, 87 | "sourceSize": { "w": 32, "h": 32 }, 88 | "duration": 200 89 | }, 90 | "11": { 91 | "frame": { "x": 22, "y": 28, "w": 16, "h": 27 }, 92 | "rotated": false, 93 | "trimmed": true, 94 | "spriteSourceSize": { "x": 8, "y": 5, "w": 16, "h": 27 }, 95 | "sourceSize": { "w": 32, "h": 32 }, 96 | "duration": 200 97 | }, 98 | "12": { 99 | "frame": { "x": 81, "y": 28, "w": 16, "h": 26 }, 100 | "rotated": false, 101 | "trimmed": true, 102 | "spriteSourceSize": { "x": 8, "y": 6, "w": 16, "h": 26 }, 103 | "sourceSize": { "w": 32, "h": 32 }, 104 | "duration": 200 105 | }, 106 | "13": { 107 | "frame": { "x": 90, "y": 55, "w": 16, "h": 25 }, 108 | "rotated": false, 109 | "trimmed": true, 110 | "spriteSourceSize": { "x": 8, "y": 6, "w": 16, "h": 25 }, 111 | "sourceSize": { "w": 32, "h": 32 }, 112 | "duration": 200 113 | }, 114 | "14": { 115 | "frame": { "x": 18, "y": 56, "w": 16, "h": 25 }, 116 | "rotated": false, 117 | "trimmed": true, 118 | "spriteSourceSize": { "x": 8, "y": 7, "w": 16, "h": 25 }, 119 | "sourceSize": { "w": 32, "h": 32 }, 120 | "duration": 200 121 | }, 122 | "15": { 123 | "frame": { "x": 1, "y": 33, "w": 16, "h": 26 }, 124 | "rotated": false, 125 | "trimmed": true, 126 | "spriteSourceSize": { "x": 8, "y": 6, "w": 16, "h": 26 }, 127 | "sourceSize": { "w": 32, "h": 32 }, 128 | "duration": 200 129 | }, 130 | "16": { 131 | "frame": { "x": 18, "y": 82, "w": 14, "h": 25 }, 132 | "rotated": false, 133 | "trimmed": true, 134 | "spriteSourceSize": { "x": 9, "y": 7, "w": 14, "h": 25 }, 135 | "sourceSize": { "w": 32, "h": 32 }, 136 | "duration": 200 137 | }, 138 | "17": { 139 | "frame": { "x": 67, "y": 81, "w": 15, "h": 26 }, 140 | "rotated": false, 141 | "trimmed": true, 142 | "spriteSourceSize": { "x": 8, "y": 6, "w": 15, "h": 26 }, 143 | "sourceSize": { "w": 32, "h": 32 }, 144 | "duration": 200 145 | }, 146 | "18": { 147 | "frame": { "x": 70, "y": 1, "w": 22, "h": 25 }, 148 | "rotated": false, 149 | "trimmed": true, 150 | "spriteSourceSize": { "x": 8, "y": 7, "w": 22, "h": 25 }, 151 | "sourceSize": { "w": 32, "h": 32 }, 152 | "duration": 200 153 | }, 154 | "19": { 155 | "frame": { "x": 46, "y": 1, "w": 23, "h": 25 }, 156 | "rotated": false, 157 | "trimmed": true, 158 | "spriteSourceSize": { "x": 9, "y": 7, "w": 23, "h": 25 }, 159 | "sourceSize": { "w": 32, "h": 32 }, 160 | "duration": 200 161 | }, 162 | "20": { 163 | "frame": { "x": 98, "y": 28, "w": 16, "h": 26 }, 164 | "rotated": false, 165 | "trimmed": true, 166 | "spriteSourceSize": { "x": 8, "y": 6, "w": 16, "h": 26 }, 167 | "sourceSize": { "w": 32, "h": 32 }, 168 | "duration": 200 169 | }, 170 | "21": { 171 | "frame": { "x": 1, "y": 60, "w": 16, "h": 25 }, 172 | "rotated": false, 173 | "trimmed": true, 174 | "spriteSourceSize": { "x": 8, "y": 7, "w": 16, "h": 25 }, 175 | "sourceSize": { "w": 32, "h": 32 }, 176 | "duration": 200 177 | }, 178 | "22": { 179 | "frame": { "x": 22, "y": 1, "w": 23, "h": 26 }, 180 | "rotated": false, 181 | "trimmed": true, 182 | "spriteSourceSize": { "x": 7, "y": 6, "w": 23, "h": 26 }, 183 | "sourceSize": { "w": 32, "h": 32 }, 184 | "duration": 200 185 | }, 186 | "23": { 187 | "frame": { "x": 1, "y": 1, "w": 20, "h": 31 }, 188 | "rotated": false, 189 | "trimmed": true, 190 | "spriteSourceSize": { "x": 7, "y": 1, "w": 20, "h": 31 }, 191 | "sourceSize": { "w": 32, "h": 32 }, 192 | "duration": 200 193 | }, 194 | "24": { 195 | "frame": { "x": 93, "y": 1, "w": 21, "h": 26 }, 196 | "rotated": false, 197 | "trimmed": true, 198 | "spriteSourceSize": { "x": 6, "y": 5, "w": 21, "h": 26 }, 199 | "sourceSize": { "w": 32, "h": 32 }, 200 | "duration": 200 201 | }, 202 | "25": { 203 | "frame": { "x": 46, "y": 27, "w": 17, "h": 26 }, 204 | "rotated": false, 205 | "trimmed": true, 206 | "spriteSourceSize": { "x": 8, "y": 5, "w": 17, "h": 26 }, 207 | "sourceSize": { "w": 32, "h": 32 }, 208 | "duration": 200 209 | }, 210 | "26": { 211 | "frame": { "x": 39, "y": 54, "w": 16, "h": 26 }, 212 | "rotated": false, 213 | "trimmed": true, 214 | "spriteSourceSize": { "x": 8, "y": 5, "w": 16, "h": 26 }, 215 | "sourceSize": { "w": 32, "h": 32 }, 216 | "duration": 200 217 | }, 218 | "27": { 219 | "frame": { "x": 81, "y": 28, "w": 16, "h": 26 }, 220 | "rotated": false, 221 | "trimmed": true, 222 | "spriteSourceSize": { "x": 8, "y": 6, "w": 16, "h": 26 }, 223 | "sourceSize": { "w": 32, "h": 32 }, 224 | "duration": 200 225 | } 226 | }, 227 | "meta": { 228 | "app": "https://www.aseprite.org/", 229 | "version": "1.3.7-x64", 230 | "image": "drow.png", 231 | "format": "RGBA8888", 232 | "size": { "w": 115, "h": 108 }, 233 | "scale": "1", 234 | "frameTags": [ 235 | { "name": "drow_walk_down", "from": 0, "to": 3, "direction": "forward", "color": "#000000ff" }, 236 | { "name": "drow_idle_down", "from": 0, "to": 0, "direction": "forward", "color": "#000000ff" }, 237 | { "name": "drow_walk_right", "from": 4, "to": 7, "direction": "forward", "color": "#000000ff" }, 238 | { "name": "drow_idle_right", "from": 4, "to": 4, "direction": "forward", "color": "#000000ff" }, 239 | { "name": "drow_walk_up", "from": 8, "to": 11, "direction": "forward", "color": "#000000ff" }, 240 | { "name": "drow_idle_up", "from": 8, "to": 8, "direction": "forward", "color": "#000000ff" }, 241 | { "name": "drow_atk_down", "from": 12, "to": 15, "direction": "forward", "color": "#000000ff" }, 242 | { "name": "drow_atk_right", "from": 16, "to": 19, "direction": "forward", "color": "#000000ff" }, 243 | { "name": "drow_atk_up", "from": 20, "to": 23, "direction": "forward", "color": "#000000ff" }, 244 | { "name": "drow_hit", "from": 24, "to": 27, "direction": "forward", "color": "#000000ff" } 245 | ], 246 | "layers": [ 247 | { "name": "Layer 2", "opacity": 255, "blendMode": "normal" }, 248 | { "name": "left arm", "opacity": 255, "blendMode": "normal" }, 249 | { "name": "body", "opacity": 255, "blendMode": "normal" }, 250 | { "name": "right arm", "opacity": 255, "blendMode": "normal" }, 251 | { "name": "Head", "opacity": 255, "blendMode": "normal" }, 252 | { "name": "Hair", "opacity": 255, "blendMode": "normal" }, 253 | { "name": "Layer 3", "opacity": 255, "blendMode": "normal" }, 254 | { "name": "Layer 5", "opacity": 255, "blendMode": "normal" }, 255 | { "name": "Layer 4", "opacity": 255, "blendMode": "normal" } 256 | ], 257 | "slices": [ 258 | ] 259 | } 260 | } 261 | -------------------------------------------------------------------------------- /public/assets/images/enemies/drow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devshareacademy/phaser-zelda-like-tutorial/63b62db25fb9e6ffdff6f54e5230ed304ddfe9cc/public/assets/images/enemies/drow.png -------------------------------------------------------------------------------- /public/assets/images/enemies/enemy_death.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devshareacademy/phaser-zelda-like-tutorial/63b62db25fb9e6ffdff6f54e5230ed304ddfe9cc/public/assets/images/enemies/enemy_death.png -------------------------------------------------------------------------------- /public/assets/images/enemies/spider.json: -------------------------------------------------------------------------------- 1 | { "frames": { 2 | "0": { 3 | "frame": { "x": 1, "y": 1, "w": 18, "h": 18 }, 4 | "rotated": false, 5 | "trimmed": false, 6 | "spriteSourceSize": { "x": 0, "y": 0, "w": 16, "h": 16 }, 7 | "sourceSize": { "w": 16, "h": 16 }, 8 | "duration": 200 9 | }, 10 | "1": { 11 | "frame": { "x": 20, "y": 1, "w": 18, "h": 18 }, 12 | "rotated": false, 13 | "trimmed": false, 14 | "spriteSourceSize": { "x": 0, "y": 0, "w": 16, "h": 16 }, 15 | "sourceSize": { "w": 16, "h": 16 }, 16 | "duration": 200 17 | }, 18 | "2": { 19 | "frame": { "x": 39, "y": 1, "w": 18, "h": 18 }, 20 | "rotated": false, 21 | "trimmed": false, 22 | "spriteSourceSize": { "x": 0, "y": 0, "w": 16, "h": 16 }, 23 | "sourceSize": { "w": 16, "h": 16 }, 24 | "duration": 200 25 | }, 26 | "3": { 27 | "frame": { "x": 1, "y": 20, "w": 18, "h": 18 }, 28 | "rotated": false, 29 | "trimmed": false, 30 | "spriteSourceSize": { "x": 0, "y": 0, "w": 16, "h": 16 }, 31 | "sourceSize": { "w": 16, "h": 16 }, 32 | "duration": 200 33 | }, 34 | "4": { 35 | "frame": { "x": 39, "y": 20, "w": 18, "h": 17 }, 36 | "rotated": false, 37 | "trimmed": true, 38 | "spriteSourceSize": { "x": 0, "y": 0, "w": 16, "h": 15 }, 39 | "sourceSize": { "w": 16, "h": 16 }, 40 | "duration": 200 41 | }, 42 | "5": { 43 | "frame": { "x": 39, "y": 38, "w": 18, "h": 17 }, 44 | "rotated": false, 45 | "trimmed": true, 46 | "spriteSourceSize": { "x": 0, "y": 0, "w": 16, "h": 15 }, 47 | "sourceSize": { "w": 16, "h": 16 }, 48 | "duration": 200 49 | }, 50 | "6": { 51 | "frame": { "x": 20, "y": 20, "w": 18, "h": 18 }, 52 | "rotated": false, 53 | "trimmed": false, 54 | "spriteSourceSize": { "x": 0, "y": 0, "w": 16, "h": 16 }, 55 | "sourceSize": { "w": 16, "h": 16 }, 56 | "duration": 200 57 | }, 58 | "7": { 59 | "frame": { "x": 1, "y": 1, "w": 18, "h": 18 }, 60 | "rotated": false, 61 | "trimmed": false, 62 | "spriteSourceSize": { "x": 0, "y": 0, "w": 16, "h": 16 }, 63 | "sourceSize": { "w": 16, "h": 16 }, 64 | "duration": 200 65 | } 66 | }, 67 | "meta": { 68 | "app": "https://www.aseprite.org/", 69 | "version": "1.3.7-x64", 70 | "image": "spider.png", 71 | "format": "RGBA8888", 72 | "size": { "w": 58, "h": 56 }, 73 | "scale": "1", 74 | "frameTags": [ 75 | { "name": "spider_walk", "from": 0, "to": 3, "direction": "forward", "color": "#000000ff" }, 76 | { "name": "spider_hit", "from": 4, "to": 7, "direction": "forward", "color": "#000000ff" } 77 | ], 78 | "layers": [ 79 | { "name": "Layer 1", "opacity": 255, "blendMode": "normal" }, 80 | { "name": "Layer 2", "opacity": 255, "blendMode": "normal" } 81 | ], 82 | "slices": [ 83 | ] 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /public/assets/images/enemies/spider.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devshareacademy/phaser-zelda-like-tutorial/63b62db25fb9e6ffdff6f54e5230ed304ddfe9cc/public/assets/images/enemies/spider.png -------------------------------------------------------------------------------- /public/assets/images/enemies/wisp.json: -------------------------------------------------------------------------------- 1 | { "frames": { 2 | "0": { 3 | "frame": { "x": 1, "y": 1, "w": 18, "h": 18 }, 4 | "rotated": false, 5 | "trimmed": false, 6 | "spriteSourceSize": { "x": 0, "y": 0, "w": 16, "h": 16 }, 7 | "sourceSize": { "w": 16, "h": 16 }, 8 | "duration": 50 9 | }, 10 | "1": { 11 | "frame": { "x": 20, "y": 1, "w": 18, "h": 18 }, 12 | "rotated": false, 13 | "trimmed": false, 14 | "spriteSourceSize": { "x": 0, "y": 0, "w": 16, "h": 16 }, 15 | "sourceSize": { "w": 16, "h": 16 }, 16 | "duration": 100 17 | }, 18 | "2": { 19 | "frame": { "x": 39, "y": 1, "w": 18, "h": 18 }, 20 | "rotated": false, 21 | "trimmed": false, 22 | "spriteSourceSize": { "x": 0, "y": 0, "w": 16, "h": 16 }, 23 | "sourceSize": { "w": 16, "h": 16 }, 24 | "duration": 100 25 | }, 26 | "3": { 27 | "frame": { "x": 1, "y": 20, "w": 18, "h": 18 }, 28 | "rotated": false, 29 | "trimmed": false, 30 | "spriteSourceSize": { "x": 0, "y": 0, "w": 16, "h": 16 }, 31 | "sourceSize": { "w": 16, "h": 16 }, 32 | "duration": 100 33 | }, 34 | "4": { 35 | "frame": { "x": 20, "y": 20, "w": 18, "h": 18 }, 36 | "rotated": false, 37 | "trimmed": false, 38 | "spriteSourceSize": { "x": 0, "y": 0, "w": 16, "h": 16 }, 39 | "sourceSize": { "w": 16, "h": 16 }, 40 | "duration": 100 41 | }, 42 | "5": { 43 | "frame": { "x": 39, "y": 20, "w": 18, "h": 18 }, 44 | "rotated": false, 45 | "trimmed": false, 46 | "spriteSourceSize": { "x": 0, "y": 0, "w": 16, "h": 16 }, 47 | "sourceSize": { "w": 16, "h": 16 }, 48 | "duration": 50 49 | } 50 | }, 51 | "meta": { 52 | "app": "https://www.aseprite.org/", 53 | "version": "1.3.7-x64", 54 | "image": "wisp.png", 55 | "format": "RGBA8888", 56 | "size": { "w": 58, "h": 39 }, 57 | "scale": "1", 58 | "frameTags": [ 59 | { "name": "wisp_idle", "from": 0, "to": 5, "direction": "forward", "color": "#000000ff" } 60 | ], 61 | "layers": [ 62 | { "name": "reference", "opacity": 255, "blendMode": "normal" }, 63 | { "name": "wisp", "opacity": 255, "blendMode": "normal" } 64 | ], 65 | "slices": [ 66 | ] 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /public/assets/images/enemies/wisp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devshareacademy/phaser-zelda-like-tutorial/63b62db25fb9e6ffdff6f54e5230ed304ddfe9cc/public/assets/images/enemies/wisp.png -------------------------------------------------------------------------------- /public/assets/images/hud/heart_and_hud_numbers.json: -------------------------------------------------------------------------------- 1 | { "frames": { 2 | "0": { 3 | "frame": { "x": 40, "y": 1, "w": 6, "h": 9 }, 4 | "rotated": false, 5 | "trimmed": true, 6 | "spriteSourceSize": { "x": 1, "y": 0, "w": 4, "h": 7 }, 7 | "sourceSize": { "w": 7, "h": 7 }, 8 | "duration": 100 9 | }, 10 | "1": { 11 | "frame": { "x": 10, "y": 21, "w": 7, "h": 9 }, 12 | "rotated": false, 13 | "trimmed": true, 14 | "spriteSourceSize": { "x": 1, "y": 0, "w": 5, "h": 7 }, 15 | "sourceSize": { "w": 7, "h": 7 }, 16 | "duration": 100 17 | }, 18 | "2": { 19 | "frame": { "x": 31, "y": 1, "w": 8, "h": 9 }, 20 | "rotated": false, 21 | "trimmed": true, 22 | "spriteSourceSize": { "x": 1, "y": 0, "w": 6, "h": 7 }, 23 | "sourceSize": { "w": 7, "h": 7 }, 24 | "duration": 100 25 | }, 26 | "3": { 27 | "frame": { "x": 18, "y": 21, "w": 7, "h": 9 }, 28 | "rotated": false, 29 | "trimmed": true, 30 | "spriteSourceSize": { "x": 1, "y": 0, "w": 5, "h": 7 }, 31 | "sourceSize": { "w": 7, "h": 7 }, 32 | "duration": 100 33 | }, 34 | "4": { 35 | "frame": { "x": 1, "y": 11, "w": 8, "h": 9 }, 36 | "rotated": false, 37 | "trimmed": true, 38 | "spriteSourceSize": { "x": 0, "y": 0, "w": 6, "h": 7 }, 39 | "sourceSize": { "w": 7, "h": 7 }, 40 | "duration": 100 41 | }, 42 | "5": { 43 | "frame": { "x": 10, "y": 11, "w": 8, "h": 9 }, 44 | "rotated": false, 45 | "trimmed": true, 46 | "spriteSourceSize": { "x": 1, "y": 0, "w": 6, "h": 7 }, 47 | "sourceSize": { "w": 7, "h": 7 }, 48 | "duration": 100 49 | }, 50 | "6": { 51 | "frame": { "x": 19, "y": 11, "w": 8, "h": 9 }, 52 | "rotated": false, 53 | "trimmed": true, 54 | "spriteSourceSize": { "x": 1, "y": 0, "w": 6, "h": 7 }, 55 | "sourceSize": { "w": 7, "h": 7 }, 56 | "duration": 100 57 | }, 58 | "7": { 59 | "frame": { "x": 28, "y": 11, "w": 8, "h": 9 }, 60 | "rotated": false, 61 | "trimmed": true, 62 | "spriteSourceSize": { "x": 1, "y": 0, "w": 6, "h": 7 }, 63 | "sourceSize": { "w": 7, "h": 7 }, 64 | "duration": 100 65 | }, 66 | "8": { 67 | "frame": { "x": 37, "y": 11, "w": 8, "h": 9 }, 68 | "rotated": false, 69 | "trimmed": true, 70 | "spriteSourceSize": { "x": 0, "y": 0, "w": 6, "h": 7 }, 71 | "sourceSize": { "w": 7, "h": 7 }, 72 | "duration": 100 73 | }, 74 | "9": { 75 | "frame": { "x": 1, "y": 21, "w": 8, "h": 9 }, 76 | "rotated": false, 77 | "trimmed": true, 78 | "spriteSourceSize": { "x": 1, "y": 0, "w": 6, "h": 7 }, 79 | "sourceSize": { "w": 7, "h": 7 }, 80 | "duration": 100 81 | }, 82 | "10": { 83 | "frame": { "x": 1, "y": 1, "w": 9, "h": 9 }, 84 | "rotated": false, 85 | "trimmed": false, 86 | "spriteSourceSize": { "x": 0, "y": 0, "w": 7, "h": 7 }, 87 | "sourceSize": { "w": 7, "h": 7 }, 88 | "duration": 100 89 | }, 90 | "11": { 91 | "frame": { "x": 34, "y": 21, "w": 5, "h": 9 }, 92 | "rotated": false, 93 | "trimmed": true, 94 | "spriteSourceSize": { "x": 2, "y": 0, "w": 3, "h": 7 }, 95 | "sourceSize": { "w": 7, "h": 7 }, 96 | "duration": 100 97 | }, 98 | "12": { 99 | "frame": { "x": 11, "y": 1, "w": 9, "h": 9 }, 100 | "rotated": false, 101 | "trimmed": false, 102 | "spriteSourceSize": { "x": 0, "y": 0, "w": 7, "h": 7 }, 103 | "sourceSize": { "w": 7, "h": 7 }, 104 | "duration": 100 105 | }, 106 | "13": { 107 | "frame": { "x": 26, "y": 21, "w": 7, "h": 9 }, 108 | "rotated": false, 109 | "trimmed": true, 110 | "spriteSourceSize": { "x": 1, "y": 0, "w": 5, "h": 7 }, 111 | "sourceSize": { "w": 7, "h": 7 }, 112 | "duration": 100 113 | }, 114 | "14": { 115 | "frame": { "x": 21, "y": 1, "w": 9, "h": 9 }, 116 | "rotated": false, 117 | "trimmed": false, 118 | "spriteSourceSize": { "x": 0, "y": 0, "w": 7, "h": 7 }, 119 | "sourceSize": { "w": 7, "h": 7 }, 120 | "duration": 100 121 | }, 122 | "15": { 123 | "frame": { "x": 40, "y": 21, "w": 3, "h": 3 }, 124 | "rotated": false, 125 | "trimmed": true, 126 | "spriteSourceSize": { "x": 0, "y": 0, "w": 1, "h": 1 }, 127 | "sourceSize": { "w": 7, "h": 7 }, 128 | "duration": 100 129 | } 130 | }, 131 | "meta": { 132 | "app": "https://www.aseprite.org/", 133 | "version": "1.3.7-x64", 134 | "image": "heart_and_hud_numbers.png", 135 | "format": "RGBA8888", 136 | "size": { "w": 47, "h": 31 }, 137 | "scale": "1", 138 | "frameTags": [ 139 | { "name": "0", "from": 0, "to": 0, "direction": "forward", "color": "#000000ff" }, 140 | { "name": "1", "from": 1, "to": 1, "direction": "forward", "color": "#000000ff" }, 141 | { "name": "2", "from": 2, "to": 2, "direction": "forward", "color": "#000000ff" }, 142 | { "name": "3", "from": 3, "to": 3, "direction": "forward", "color": "#000000ff" }, 143 | { "name": "4", "from": 4, "to": 4, "direction": "forward", "color": "#000000ff" }, 144 | { "name": "5", "from": 5, "to": 5, "direction": "forward", "color": "#000000ff" }, 145 | { "name": "6", "from": 6, "to": 6, "direction": "forward", "color": "#000000ff" }, 146 | { "name": "7", "from": 7, "to": 7, "direction": "forward", "color": "#000000ff" }, 147 | { "name": "8", "from": 8, "to": 8, "direction": "forward", "color": "#000000ff" }, 148 | { "name": "9", "from": 9, "to": 9, "direction": "forward", "color": "#000000ff" }, 149 | { "name": "heart_lose_full", "from": 10, "to": 14, "direction": "forward", "color": "#000000ff" }, 150 | { "name": "heart_lost_first_half", "from": 10, "to": 12, "direction": "forward", "color": "#000000ff" }, 151 | { "name": "heart_full", "from": 10, "to": 10, "direction": "forward", "color": "#000000ff" }, 152 | { "name": "heart_lose_last_half", "from": 12, "to": 14, "direction": "forward", "color": "#000000ff" }, 153 | { "name": "heart_half", "from": 12, "to": 12, "direction": "forward", "color": "#000000ff" }, 154 | { "name": "heart_empty", "from": 14, "to": 14, "direction": "forward", "color": "#000000ff" }, 155 | { "name": "heart_none", "from": 15, "to": 15, "direction": "forward", "color": "#000000ff" } 156 | ], 157 | "layers": [ 158 | { "name": "hud_numbers_and_hearts", "opacity": 255, "blendMode": "normal" } 159 | ], 160 | "slices": [ 161 | ] 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /public/assets/images/hud/heart_and_hud_numbers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devshareacademy/phaser-zelda-like-tutorial/63b62db25fb9e6ffdff6f54e5230ed304ddfe9cc/public/assets/images/hud/heart_and_hud_numbers.png -------------------------------------------------------------------------------- /public/assets/images/levels/common/collision.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devshareacademy/phaser-zelda-like-tutorial/63b62db25fb9e6ffdff6f54e5230ed304ddfe9cc/public/assets/images/levels/common/collision.png -------------------------------------------------------------------------------- /public/assets/images/levels/common/dungeon_objects.json: -------------------------------------------------------------------------------- 1 | { 2 | "frames": { 3 | "big_chest_closed.png": { 4 | "frame": { 5 | "x": 0, 6 | "y": 0, 7 | "w": 32, 8 | "h": 32 9 | }, 10 | "rotated": false, 11 | "trimmed": false, 12 | "spriteSourceSize": { 13 | "x": 0, 14 | "y": 0, 15 | "w": 32, 16 | "h": 32 17 | }, 18 | "sourceSize": { 19 | "w": 32, 20 | "h": 32 21 | } 22 | }, 23 | "big_chest_open.png": { 24 | "frame": { 25 | "x": 32, 26 | "y": 0, 27 | "w": 32, 28 | "h": 32 29 | }, 30 | "rotated": false, 31 | "trimmed": false, 32 | "spriteSourceSize": { 33 | "x": 0, 34 | "y": 0, 35 | "w": 32, 36 | "h": 32 37 | }, 38 | "sourceSize": { 39 | "w": 32, 40 | "h": 32 41 | } 42 | }, 43 | "boss_down.png": { 44 | "frame": { 45 | "x": 64, 46 | "y": 0, 47 | "w": 32, 48 | "h": 32 49 | }, 50 | "rotated": false, 51 | "trimmed": false, 52 | "spriteSourceSize": { 53 | "x": 0, 54 | "y": 0, 55 | "w": 32, 56 | "h": 32 57 | }, 58 | "sourceSize": { 59 | "w": 32, 60 | "h": 32 61 | } 62 | }, 63 | "boss_left.png": { 64 | "frame": { 65 | "x": 96, 66 | "y": 0, 67 | "w": 32, 68 | "h": 32 69 | }, 70 | "rotated": false, 71 | "trimmed": false, 72 | "spriteSourceSize": { 73 | "x": 0, 74 | "y": 0, 75 | "w": 32, 76 | "h": 32 77 | }, 78 | "sourceSize": { 79 | "w": 32, 80 | "h": 32 81 | } 82 | }, 83 | "boss_right.png": { 84 | "frame": { 85 | "x": 128, 86 | "y": 0, 87 | "w": 32, 88 | "h": 32 89 | }, 90 | "rotated": false, 91 | "trimmed": false, 92 | "spriteSourceSize": { 93 | "x": 0, 94 | "y": 0, 95 | "w": 32, 96 | "h": 32 97 | }, 98 | "sourceSize": { 99 | "w": 32, 100 | "h": 32 101 | } 102 | }, 103 | "boss_up.png": { 104 | "frame": { 105 | "x": 0, 106 | "y": 32, 107 | "w": 32, 108 | "h": 32 109 | }, 110 | "rotated": false, 111 | "trimmed": false, 112 | "spriteSourceSize": { 113 | "x": 0, 114 | "y": 0, 115 | "w": 32, 116 | "h": 32 117 | }, 118 | "sourceSize": { 119 | "w": 32, 120 | "h": 32 121 | } 122 | }, 123 | "chest_closed.png": { 124 | "frame": { 125 | "x": 160, 126 | "y": 0, 127 | "w": 16, 128 | "h": 16 129 | }, 130 | "rotated": false, 131 | "trimmed": false, 132 | "spriteSourceSize": { 133 | "x": 0, 134 | "y": 0, 135 | "w": 16, 136 | "h": 16 137 | }, 138 | "sourceSize": { 139 | "w": 16, 140 | "h": 16 141 | } 142 | }, 143 | "chest_open.png": { 144 | "frame": { 145 | "x": 160, 146 | "y": 16, 147 | "w": 16, 148 | "h": 16 149 | }, 150 | "rotated": false, 151 | "trimmed": false, 152 | "spriteSourceSize": { 153 | "x": 0, 154 | "y": 0, 155 | "w": 16, 156 | "h": 16 157 | }, 158 | "sourceSize": { 159 | "w": 16, 160 | "h": 16 161 | } 162 | }, 163 | "floor_switch.png": { 164 | "frame": { 165 | "x": 32, 166 | "y": 32, 167 | "w": 16, 168 | "h": 16 169 | }, 170 | "rotated": false, 171 | "trimmed": false, 172 | "spriteSourceSize": { 173 | "x": 0, 174 | "y": 0, 175 | "w": 16, 176 | "h": 16 177 | }, 178 | "sourceSize": { 179 | "w": 16, 180 | "h": 16 181 | } 182 | }, 183 | "lock_down.png": { 184 | "frame": { 185 | "x": 48, 186 | "y": 32, 187 | "w": 32, 188 | "h": 32 189 | }, 190 | "rotated": false, 191 | "trimmed": false, 192 | "spriteSourceSize": { 193 | "x": 0, 194 | "y": 0, 195 | "w": 32, 196 | "h": 32 197 | }, 198 | "sourceSize": { 199 | "w": 32, 200 | "h": 32 201 | } 202 | }, 203 | "lock_left.png": { 204 | "frame": { 205 | "x": 80, 206 | "y": 32, 207 | "w": 32, 208 | "h": 32 209 | }, 210 | "rotated": false, 211 | "trimmed": false, 212 | "spriteSourceSize": { 213 | "x": 0, 214 | "y": 0, 215 | "w": 32, 216 | "h": 32 217 | }, 218 | "sourceSize": { 219 | "w": 32, 220 | "h": 32 221 | } 222 | }, 223 | "lock_right.png": { 224 | "frame": { 225 | "x": 112, 226 | "y": 32, 227 | "w": 32, 228 | "h": 32 229 | }, 230 | "rotated": false, 231 | "trimmed": false, 232 | "spriteSourceSize": { 233 | "x": 0, 234 | "y": 0, 235 | "w": 32, 236 | "h": 32 237 | }, 238 | "sourceSize": { 239 | "w": 32, 240 | "h": 32 241 | } 242 | }, 243 | "lock_up.png": { 244 | "frame": { 245 | "x": 144, 246 | "y": 32, 247 | "w": 32, 248 | "h": 32 249 | }, 250 | "rotated": false, 251 | "trimmed": false, 252 | "spriteSourceSize": { 253 | "x": 0, 254 | "y": 0, 255 | "w": 32, 256 | "h": 32 257 | }, 258 | "sourceSize": { 259 | "w": 32, 260 | "h": 32 261 | } 262 | }, 263 | "open_down.png": { 264 | "frame": { 265 | "x": 0, 266 | "y": 64, 267 | "w": 32, 268 | "h": 32 269 | }, 270 | "rotated": false, 271 | "trimmed": false, 272 | "spriteSourceSize": { 273 | "x": 0, 274 | "y": 0, 275 | "w": 32, 276 | "h": 32 277 | }, 278 | "sourceSize": { 279 | "w": 32, 280 | "h": 32 281 | } 282 | }, 283 | "open_left.png": { 284 | "frame": { 285 | "x": 32, 286 | "y": 64, 287 | "w": 32, 288 | "h": 32 289 | }, 290 | "rotated": false, 291 | "trimmed": false, 292 | "spriteSourceSize": { 293 | "x": 0, 294 | "y": 0, 295 | "w": 32, 296 | "h": 32 297 | }, 298 | "sourceSize": { 299 | "w": 32, 300 | "h": 32 301 | } 302 | }, 303 | "open_right.png": { 304 | "frame": { 305 | "x": 64, 306 | "y": 64, 307 | "w": 32, 308 | "h": 32 309 | }, 310 | "rotated": false, 311 | "trimmed": false, 312 | "spriteSourceSize": { 313 | "x": 0, 314 | "y": 0, 315 | "w": 32, 316 | "h": 32 317 | }, 318 | "sourceSize": { 319 | "w": 32, 320 | "h": 32 321 | } 322 | }, 323 | "open_up.png": { 324 | "frame": { 325 | "x": 96, 326 | "y": 64, 327 | "w": 32, 328 | "h": 32 329 | }, 330 | "rotated": false, 331 | "trimmed": false, 332 | "spriteSourceSize": { 333 | "x": 0, 334 | "y": 0, 335 | "w": 32, 336 | "h": 32 337 | }, 338 | "sourceSize": { 339 | "w": 32, 340 | "h": 32 341 | } 342 | }, 343 | "plate_switch.png": { 344 | "frame": { 345 | "x": 128, 346 | "y": 64, 347 | "w": 16, 348 | "h": 16 349 | }, 350 | "rotated": false, 351 | "trimmed": false, 352 | "spriteSourceSize": { 353 | "x": 0, 354 | "y": 0, 355 | "w": 16, 356 | "h": 16 357 | }, 358 | "sourceSize": { 359 | "w": 16, 360 | "h": 16 361 | } 362 | }, 363 | "plate_swtich_pressed.png": { 364 | "frame": { 365 | "x": 144, 366 | "y": 64, 367 | "w": 16, 368 | "h": 16 369 | }, 370 | "rotated": false, 371 | "trimmed": false, 372 | "spriteSourceSize": { 373 | "x": 0, 374 | "y": 0, 375 | "w": 16, 376 | "h": 16 377 | }, 378 | "sourceSize": { 379 | "w": 16, 380 | "h": 16 381 | } 382 | }, 383 | "stair_down_down.png": { 384 | "frame": { 385 | "x": 128, 386 | "y": 80, 387 | "w": 32, 388 | "h": 32 389 | }, 390 | "rotated": false, 391 | "trimmed": false, 392 | "spriteSourceSize": { 393 | "x": 0, 394 | "y": 0, 395 | "w": 32, 396 | "h": 32 397 | }, 398 | "sourceSize": { 399 | "w": 32, 400 | "h": 32 401 | } 402 | }, 403 | "stair_down_left.png": { 404 | "frame": { 405 | "x": 0, 406 | "y": 112, 407 | "w": 32, 408 | "h": 32 409 | }, 410 | "rotated": false, 411 | "trimmed": false, 412 | "spriteSourceSize": { 413 | "x": 0, 414 | "y": 0, 415 | "w": 32, 416 | "h": 32 417 | }, 418 | "sourceSize": { 419 | "w": 32, 420 | "h": 32 421 | } 422 | }, 423 | "stair_down_right.png": { 424 | "frame": { 425 | "x": 32, 426 | "y": 112, 427 | "w": 32, 428 | "h": 32 429 | }, 430 | "rotated": false, 431 | "trimmed": false, 432 | "spriteSourceSize": { 433 | "x": 0, 434 | "y": 0, 435 | "w": 32, 436 | "h": 32 437 | }, 438 | "sourceSize": { 439 | "w": 32, 440 | "h": 32 441 | } 442 | }, 443 | "stair_down_up.png": { 444 | "frame": { 445 | "x": 64, 446 | "y": 112, 447 | "w": 32, 448 | "h": 32 449 | }, 450 | "rotated": false, 451 | "trimmed": false, 452 | "spriteSourceSize": { 453 | "x": 0, 454 | "y": 0, 455 | "w": 32, 456 | "h": 32 457 | }, 458 | "sourceSize": { 459 | "w": 32, 460 | "h": 32 461 | } 462 | }, 463 | "stair_downleft_down.png": { 464 | "frame": { 465 | "x": 96, 466 | "y": 112, 467 | "w": 32, 468 | "h": 32 469 | }, 470 | "rotated": false, 471 | "trimmed": false, 472 | "spriteSourceSize": { 473 | "x": 0, 474 | "y": 0, 475 | "w": 32, 476 | "h": 32 477 | }, 478 | "sourceSize": { 479 | "w": 32, 480 | "h": 32 481 | } 482 | }, 483 | "stair_downleft_left.png": { 484 | "frame": { 485 | "x": 128, 486 | "y": 112, 487 | "w": 32, 488 | "h": 32 489 | }, 490 | "rotated": false, 491 | "trimmed": false, 492 | "spriteSourceSize": { 493 | "x": 0, 494 | "y": 0, 495 | "w": 32, 496 | "h": 32 497 | }, 498 | "sourceSize": { 499 | "w": 32, 500 | "h": 32 501 | } 502 | }, 503 | "stair_downleft_right.png": { 504 | "frame": { 505 | "x": 0, 506 | "y": 144, 507 | "w": 32, 508 | "h": 32 509 | }, 510 | "rotated": false, 511 | "trimmed": false, 512 | "spriteSourceSize": { 513 | "x": 0, 514 | "y": 0, 515 | "w": 32, 516 | "h": 32 517 | }, 518 | "sourceSize": { 519 | "w": 32, 520 | "h": 32 521 | } 522 | }, 523 | "stair_downleft_up.png": { 524 | "frame": { 525 | "x": 32, 526 | "y": 144, 527 | "w": 32, 528 | "h": 32 529 | }, 530 | "rotated": false, 531 | "trimmed": false, 532 | "spriteSourceSize": { 533 | "x": 0, 534 | "y": 0, 535 | "w": 32, 536 | "h": 32 537 | }, 538 | "sourceSize": { 539 | "w": 32, 540 | "h": 32 541 | } 542 | }, 543 | "stair_upright_down.png": { 544 | "frame": { 545 | "x": 64, 546 | "y": 144, 547 | "w": 32, 548 | "h": 32 549 | }, 550 | "rotated": false, 551 | "trimmed": false, 552 | "spriteSourceSize": { 553 | "x": 0, 554 | "y": 0, 555 | "w": 32, 556 | "h": 32 557 | }, 558 | "sourceSize": { 559 | "w": 32, 560 | "h": 32 561 | } 562 | }, 563 | "stair_upright_left.png": { 564 | "frame": { 565 | "x": 96, 566 | "y": 144, 567 | "w": 32, 568 | "h": 32 569 | }, 570 | "rotated": false, 571 | "trimmed": false, 572 | "spriteSourceSize": { 573 | "x": 0, 574 | "y": 0, 575 | "w": 32, 576 | "h": 32 577 | }, 578 | "sourceSize": { 579 | "w": 32, 580 | "h": 32 581 | } 582 | }, 583 | "stair_upright_right.png": { 584 | "frame": { 585 | "x": 128, 586 | "y": 144, 587 | "w": 32, 588 | "h": 32 589 | }, 590 | "rotated": false, 591 | "trimmed": false, 592 | "spriteSourceSize": { 593 | "x": 0, 594 | "y": 0, 595 | "w": 32, 596 | "h": 32 597 | }, 598 | "sourceSize": { 599 | "w": 32, 600 | "h": 32 601 | } 602 | }, 603 | "stair_upright_up.png": { 604 | "frame": { 605 | "x": 176, 606 | "y": 0, 607 | "w": 32, 608 | "h": 32 609 | }, 610 | "rotated": false, 611 | "trimmed": false, 612 | "spriteSourceSize": { 613 | "x": 0, 614 | "y": 0, 615 | "w": 32, 616 | "h": 32 617 | }, 618 | "sourceSize": { 619 | "w": 32, 620 | "h": 32 621 | } 622 | }, 623 | "trap_down.png": { 624 | "frame": { 625 | "x": 176, 626 | "y": 32, 627 | "w": 32, 628 | "h": 32 629 | }, 630 | "rotated": false, 631 | "trimmed": false, 632 | "spriteSourceSize": { 633 | "x": 0, 634 | "y": 0, 635 | "w": 32, 636 | "h": 32 637 | }, 638 | "sourceSize": { 639 | "w": 32, 640 | "h": 32 641 | } 642 | }, 643 | "trap_left.png": { 644 | "frame": { 645 | "x": 160, 646 | "y": 64, 647 | "w": 32, 648 | "h": 32 649 | }, 650 | "rotated": false, 651 | "trimmed": false, 652 | "spriteSourceSize": { 653 | "x": 0, 654 | "y": 0, 655 | "w": 32, 656 | "h": 32 657 | }, 658 | "sourceSize": { 659 | "w": 32, 660 | "h": 32 661 | } 662 | }, 663 | "trap_right.png": { 664 | "frame": { 665 | "x": 160, 666 | "y": 96, 667 | "w": 32, 668 | "h": 32 669 | }, 670 | "rotated": false, 671 | "trimmed": false, 672 | "spriteSourceSize": { 673 | "x": 0, 674 | "y": 0, 675 | "w": 32, 676 | "h": 32 677 | }, 678 | "sourceSize": { 679 | "w": 32, 680 | "h": 32 681 | } 682 | }, 683 | "trap_up.png": { 684 | "frame": { 685 | "x": 160, 686 | "y": 128, 687 | "w": 32, 688 | "h": 32 689 | }, 690 | "rotated": false, 691 | "trimmed": false, 692 | "spriteSourceSize": { 693 | "x": 0, 694 | "y": 0, 695 | "w": 32, 696 | "h": 32 697 | }, 698 | "sourceSize": { 699 | "w": 32, 700 | "h": 32 701 | } 702 | } 703 | }, 704 | "meta": { 705 | "app": "http://www.codeandweb.com/texturepacker", 706 | "version": "1.0", 707 | "image": "spritesheet.png", 708 | "format": "RGBA8888", 709 | "size": { 710 | "w": 208, 711 | "h": 176 712 | }, 713 | "scale": "1" 714 | } 715 | } -------------------------------------------------------------------------------- /public/assets/images/levels/common/dungeon_objects.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devshareacademy/phaser-zelda-like-tutorial/63b62db25fb9e6ffdff6f54e5230ed304ddfe9cc/public/assets/images/levels/common/dungeon_objects.png -------------------------------------------------------------------------------- /public/assets/images/levels/common/pot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devshareacademy/phaser-zelda-like-tutorial/63b62db25fb9e6ffdff6f54e5230ed304ddfe9cc/public/assets/images/levels/common/pot.png -------------------------------------------------------------------------------- /public/assets/images/levels/common/pot_break.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devshareacademy/phaser-zelda-like-tutorial/63b62db25fb9e6ffdff6f54e5230ed304ddfe9cc/public/assets/images/levels/common/pot_break.png -------------------------------------------------------------------------------- /public/assets/images/levels/dungeon_1/dungeon_1_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devshareacademy/phaser-zelda-like-tutorial/63b62db25fb9e6ffdff6f54e5230ed304ddfe9cc/public/assets/images/levels/dungeon_1/dungeon_1_background.png -------------------------------------------------------------------------------- /public/assets/images/levels/dungeon_1/dungeon_1_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devshareacademy/phaser-zelda-like-tutorial/63b62db25fb9e6ffdff6f54e5230ed304ddfe9cc/public/assets/images/levels/dungeon_1/dungeon_1_foreground.png -------------------------------------------------------------------------------- /public/assets/images/levels/world/world_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devshareacademy/phaser-zelda-like-tutorial/63b62db25fb9e6ffdff6f54e5230ed304ddfe9cc/public/assets/images/levels/world/world_background.png -------------------------------------------------------------------------------- /public/assets/images/levels/world/world_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devshareacademy/phaser-zelda-like-tutorial/63b62db25fb9e6ffdff6f54e5230ed304ddfe9cc/public/assets/images/levels/world/world_foreground.png -------------------------------------------------------------------------------- /public/assets/images/player/dagger.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devshareacademy/phaser-zelda-like-tutorial/63b62db25fb9e6ffdff6f54e5230ed304ddfe9cc/public/assets/images/player/dagger.png -------------------------------------------------------------------------------- /public/assets/images/player/main_green.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devshareacademy/phaser-zelda-like-tutorial/63b62db25fb9e6ffdff6f54e5230ed304ddfe9cc/public/assets/images/player/main_green.png -------------------------------------------------------------------------------- /public/assets/images/ui/cursor_white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devshareacademy/phaser-zelda-like-tutorial/63b62db25fb9e6ffdff6f54e5230ed304ddfe9cc/public/assets/images/ui/cursor_white.png -------------------------------------------------------------------------------- /public/assets/images/ui/dialog_ui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devshareacademy/phaser-zelda-like-tutorial/63b62db25fb9e6ffdff6f54e5230ed304ddfe9cc/public/assets/images/ui/dialog_ui.png -------------------------------------------------------------------------------- /public/assets/images/ui/icons.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devshareacademy/phaser-zelda-like-tutorial/63b62db25fb9e6ffdff6f54e5230ed304ddfe9cc/public/assets/images/ui/icons.png -------------------------------------------------------------------------------- /src/common/assets.ts: -------------------------------------------------------------------------------- 1 | export const ASSET_PACK_KEYS = { 2 | MAIN: 'MAIN', 3 | } as const; 4 | 5 | export const ASSET_KEYS = { 6 | PLAYER: 'PLAYER', 7 | POT: 'POT', 8 | POT_BREAK: 'POT_BREAK', 9 | SPIDER: 'SPIDER', 10 | WISP: 'WISP', 11 | DROW: 'DROW', 12 | DAGGER: 'DAGGER', 13 | DUNGEON_1_BACKGROUND: 'DUNGEON_1_BACKGROUND', 14 | DUNGEON_1_FOREGROUND: 'DUNGEON_1_FOREGROUND', 15 | DUNGEON_1_LEVEL: 'DUNGEON_1_LEVEL', 16 | COLLISION: 'COLLISION', 17 | DUNGEON_OBJECTS: 'DUNGEON_OBJECTS', 18 | ENEMY_DEATH: 'ENEMY_DEATH', 19 | UI_DIALOG: 'UI_DIALOG', 20 | UI_ICONS: 'UI_ICONS', 21 | UI_CURSOR: 'UI_CURSOR', 22 | WORLD_BACKGROUND: 'WORLD_BACKGROUND', 23 | WORLD_FOREGROUND: 'WORLD_FOREGROUND', 24 | WORLD_LEVEL: 'WORLD_LEVEL', 25 | HUD_NUMBERS: 'HUD_NUMBERS', 26 | FONT_PRESS_START_2P: 'FONT_PRESS_START_2P', 27 | } as const; 28 | 29 | export const PLAYER_ANIMATION_KEYS = { 30 | WALK_DOWN: 'player_walk_down', 31 | WALK_UP: 'player_walk_up', 32 | WALK_SIDE: 'player_walk_side', 33 | IDLE_DOWN: 'player_idle_down', 34 | IDLE_UP: 'player_idle_up', 35 | IDLE_SIDE: 'player_idle_side', 36 | IDLE_HOLD_DOWN: 'player_hand_in_air_down', 37 | IDLE_HOLD_UP: 'player_hand_in_air_up', 38 | IDLE_HOLD_SIDE: 'player_hand_in_air_side', 39 | WALK_HOLD_DOWN: 'player_walk_hand_in_air_down', 40 | WALK_HOLD_UP: 'player_walk_hand_in_air_up', 41 | WALK_HOLD_SIDE: 'player_walk_hand_in_air_side', 42 | LIFT_DOWN: 'player_open_chest_down', 43 | LIFT_UP: 'player_open_chest_up', 44 | LIFT_SIDE: 'player_open_chest_side', 45 | HURT_DOWN: 'player_hit_down', 46 | HURT_UP: 'player_hit_up', 47 | HURT_SIDE: 'player_hit_side', 48 | DIE_DOWN: 'player_die_down', 49 | DIE_UP: 'player_die_up', 50 | DIE_SIDE: 'player_die_side', 51 | SWORD_1_ATTACK_DOWN: 'player_atk_1_down', 52 | SWORD_1_ATTACK_UP: 'player_atk_1_up', 53 | SWORD_1_ATTACK_SIDE: 'player_atk_1_side', 54 | } as const; 55 | 56 | export const SPIDER_ANIMATION_KEYS = { 57 | WALK: 'spider_walk', 58 | HIT: 'spider_hit', 59 | DEATH: ASSET_KEYS.ENEMY_DEATH, 60 | } as const; 61 | 62 | export const DROW_ANIMATION_KEYS = { 63 | WALK_DOWN: 'drow_walk_down', 64 | WALK_UP: 'drow_walk_up', 65 | WALK_LEFT: 'drow_walk_left', 66 | WALK_RIGHT: 'drow_walk_right', 67 | IDLE_DOWN: 'drow_idle_down', 68 | IDLE_UP: 'drow_idle_up', 69 | IDLE_SIDE: 'drow_idle_right', 70 | HIT: 'drow_hit', 71 | ATTACK_DOWN: 'drow_atk_down', 72 | ATTACK_UP: 'drow_atk_up', 73 | ATTACK_SIDE: 'drow_atk_right', 74 | } as const; 75 | 76 | export const WISP_ANIMATION_KEYS = { 77 | IDLE: 'wisp_idle', 78 | } as const; 79 | 80 | export const CHARACTER_ANIMATIONS = { 81 | IDLE_DOWN: 'IDLE_DOWN', 82 | IDLE_UP: 'IDLE_UP', 83 | IDLE_LEFT: 'IDLE_LEFT', 84 | IDLE_RIGHT: 'IDLE_RIGHT', 85 | WALK_DOWN: 'WALK_DOWN', 86 | WALK_UP: 'WALK_UP', 87 | WALK_LEFT: 'WALK_LEFT', 88 | WALK_RIGHT: 'WALK_RIGHT', 89 | IDLE_HOLD_DOWN: 'IDLE_HOLD_DOWN', 90 | IDLE_HOLD_UP: 'IDLE_HOLD_UP', 91 | IDLE_HOLD_LEFT: 'IDLE_HOLD_LEFT', 92 | IDLE_HOLD_RIGHT: 'IDLE_HOLD_RIGHT', 93 | WALK_HOLD_DOWN: 'WALK_HOLD_DOWN', 94 | WALK_HOLD_UP: 'WALK_HOLD_UP', 95 | WALK_HOLD_LEFT: 'WALK_HOLD_LEFT', 96 | WALK_HOLD_RIGHT: 'WALK_HOLD_RIGHT', 97 | LIFT_DOWN: 'LIFT_DOWN', 98 | LIFT_UP: 'LIFT_UP', 99 | LIFT_LEFT: 'LIFT_LEFT', 100 | LIFT_RIGHT: 'LIFT_RIGHT', 101 | HURT_DOWN: 'HURT_DOWN', 102 | HURT_UP: 'HURT_UP', 103 | HURT_LEFT: 'HURT_LEFT', 104 | HURT_RIGHT: 'HURT_RIGHT', 105 | DIE_DOWN: 'DIE_DOWN', 106 | DIE_UP: 'DIE_UP', 107 | DIE_LEFT: 'DIE_LEFT', 108 | DIE_RIGHT: 'DIE_RIGHT', 109 | } as const; 110 | 111 | export const CHEST_FRAME_KEYS = { 112 | BIG_CHEST_CLOSED: 'big_chest_closed.png', 113 | SMALL_CHEST_CLOSED: 'chest_closed.png', 114 | BIG_CHEST_OPEN: 'big_chest_open.png', 115 | SMALL_CHEST_OPEN: 'chest_open.png', 116 | } as const; 117 | 118 | export const DOOR_FRAME_KEYS = { 119 | TRAP_LEFT: 'trap_left.png', 120 | TRAP_RIGHT: 'trap_right.png', 121 | TRAP_UP: 'trap_up.png', 122 | TRAP_DOWN: 'trap_down.png', 123 | BOSS_LEFT: 'boss_left.png', 124 | BOSS_RIGHT: 'boss_right.png', 125 | BOSS_UP: 'boss_up.png', 126 | BOSS_DOWN: 'boss_down.png', 127 | LOCK_LEFT: 'lock_left.png', 128 | LOCK_RIGHT: 'lock_right.png', 129 | LOCK_UP: 'lock_up.png', 130 | LOCK_DOWN: 'lock_down.png', 131 | } as const; 132 | 133 | export const BUTTON_FRAME_KEYS = { 134 | FLOOR_SWITCH: 'floor_switch.png', 135 | PLATE_SWITCH: 'plate_switch.png', 136 | } as const; 137 | 138 | export const CHEST_REWARD_TO_TEXTURE_FRAME = { 139 | SMALL_KEY: 119, 140 | BOSS_KEY: 121, 141 | MAP: 117, 142 | COMPASS: 118, 143 | NOTHING: 126, 144 | } as const; 145 | 146 | export const HEART_ANIMATIONS = { 147 | LOSE_LAST_HALF: 'heart_lose_last_half', 148 | LOSE_FIRST_HALF: 'heart_lost_first_half', 149 | }; 150 | 151 | export const HEART_TEXTURE_FRAME = { 152 | NONE: '15', 153 | FULL: '10', 154 | EMPTY: '14', 155 | HALF: '12', 156 | } as const; 157 | -------------------------------------------------------------------------------- /src/common/common.ts: -------------------------------------------------------------------------------- 1 | import * as Phaser from 'phaser'; 2 | import { ASSET_KEYS } from './assets'; 3 | 4 | export const DIRECTION = { 5 | UP: 'UP', 6 | DOWN: 'DOWN', 7 | LEFT: 'LEFT', 8 | RIGHT: 'RIGHT', 9 | } as const; 10 | 11 | export const CHEST_STATE = { 12 | HIDDEN: 'HIDDEN', 13 | REVEALED: 'REVEALED', 14 | OPEN: 'OPEN', 15 | } as const; 16 | 17 | export const INTERACTIVE_OBJECT_TYPE = { 18 | AUTO: 'AUTO', 19 | PICKUP: 'PICKUP', 20 | OPEN: 'OPEN', 21 | } as const; 22 | 23 | export const LEVEL_NAME = { 24 | WORLD: 'WORLD', 25 | DUNGEON_1: 'DUNGEON_1', 26 | } as const; 27 | 28 | export const DUNGEON_ITEM = { 29 | SMALL_KEY: 'SMALL_KEY', 30 | BOSS_KEY: 'BOSS_KEY', 31 | MAP: 'MAP', 32 | COMPASS: 'COMPASS', 33 | } as const; 34 | 35 | export const DEFAULT_UI_TEXT_STYLE: Phaser.Types.GameObjects.Text.TextStyle = { 36 | align: 'center', 37 | fontFamily: ASSET_KEYS.FONT_PRESS_START_2P, 38 | fontSize: 8, 39 | wordWrap: { width: 170 }, 40 | color: '#FFFFFF', 41 | }; 42 | 43 | export const CHEST_REWARD_TO_DIALOG_MAP = { 44 | SMALL_KEY: 'You found a small key! You can use this to open locked doors.', 45 | BOSS_KEY: 46 | 'You got the Big Key! This is the master key of the dungeon. It can open many locks that small keys cannot.', 47 | MAP: 'You got the Map! You can use it to see your current position and the rest of the dungeon (Press X).', 48 | COMPASS: "You fond the Compass! Now you can pinpoint the lair of the dungeon's evil master!", 49 | NOTHING: '...The chest was empty!', 50 | } as const; 51 | -------------------------------------------------------------------------------- /src/common/config.ts: -------------------------------------------------------------------------------- 1 | export const ENABLE_LOGGING = false; 2 | export const ENABLE_DEBUG_ZONE_AREA = false; 3 | export const DEBUG_COLLISION_ALPHA = 0; 4 | 5 | export const PLAYER_SPEED = 80; 6 | export const PLAYER_INVULNERABLE_AFTER_HIT_DURATION = 1000; 7 | export const PLAYER_HURT_PUSH_BACK_SPEED = 50; 8 | export const PLAYER_START_MAX_HEALTH = 6; 9 | export const PLAYER_ATTACK_DAMAGE = 1; 10 | 11 | export const ENEMY_SPIDER_SPEED = 80; 12 | export const ENEMY_SPIDER_CHANGE_DIRECTION_DELAY_MIN = 500; 13 | export const ENEMY_SPIDER_CHANGE_DIRECTION_DELAY_MAX = 1500; 14 | export const ENEMY_SPIDER_CHANGE_DIRECTION_DELAY_WAIT = 200; 15 | export const ENEMY_SPIDER_HURT_PUSH_BACK_SPEED = 50; 16 | export const ENEMY_SPIDER_MAX_HEALTH = 2; 17 | 18 | export const ENEMY_WISP_SPEED = 50; 19 | export const ENEMY_WISP_PULSE_ANIMATION_SCALE_X = 1.2; 20 | export const ENEMY_WISP_PULSE_ANIMATION_SCALE_Y = 1.2; 21 | export const ENEMY_WISP_PULSE_ANIMATION_DURATION = 500; 22 | export const ENEMY_WISP_MAX_HEALTH = 1; 23 | 24 | export const ENEMY_BOSS_DROW_SPEED = 80; 25 | export const ENEMY_BOSS_DROW_MAX_HEALTH = 6; 26 | export const ENEMY_BOSS_DROW_DEATH_ANIMATION_DURATION = 3000; 27 | export const ENEMY_BOSS_IDLE_STATE_DURATION = 3000; 28 | export const ENEMY_BOSS_HIDDEN_STATE_DURATION = 1000; 29 | export const ENEMY_BOSS_TELEPORT_STATE_INITIAL_DELAY = 150; 30 | export const ENEMY_BOSS_TELEPORT_STATE_FINISHED_DELAY = 500; 31 | export const ENEMY_BOSS_PREPARE_ATTACK_STATE_FINISHED_DELAY = 500; 32 | export const ENEMY_BOSS_ATTACK_DAMAGE = 1; 33 | export const ENEMY_BOSS_ATTACK_SPEED = 160; 34 | export const ENEMY_BOSS_START_INITIAL_DELAY = 1000; 35 | 36 | export const HURT_PUSH_BACK_DELAY = 200; 37 | export const BOSS_HURT_PUSH_BACK_DELAY = 50; 38 | 39 | export const THROW_ITEM_SPEED = 300; 40 | export const THROW_ITEM_DELAY_BEFORE_CALLBACK = 200; 41 | 42 | export const LIFT_ITEM_ANIMATION_DELAY = 0; 43 | export const LIFT_ITEM_ANIMATION_DURATION = 250; 44 | export const LIFT_ITEM_ANIMATION_ENABLE_DEBUGGING = false; 45 | 46 | export const ROOM_TRANSITION_PLAYER_INTO_HALL_DURATION = 750; 47 | export const ROOM_TRANSITION_PLAYER_INTO_HALL_DELAY = 250; 48 | export const ROOM_TRANSITION_PLAYER_INTO_NEXT_ROOM_DURATION = 1000; 49 | export const ROOM_TRANSITION_PLAYER_INTO_NEXT_ROOM_DELAY = 1200; 50 | export const ROOM_TRANSITION_CAMERA_ANIMATION_DURATION = 1000; 51 | export const ROOM_TRANSITION_CAMERA_ANIMATION_DELAY = 500; 52 | -------------------------------------------------------------------------------- /src/common/data-manager.ts: -------------------------------------------------------------------------------- 1 | import { LEVEL_NAME } from './common'; 2 | import { PLAYER_START_MAX_HEALTH } from './config'; 3 | import { 4 | CUSTOM_EVENTS, 5 | EVENT_BUS, 6 | PLAYER_HEALTH_UPDATE_TYPE, 7 | PlayerHealthUpdated, 8 | PlayerHealthUpdateType, 9 | } from './event-bus'; 10 | import { LevelName } from './types'; 11 | 12 | export type PlayerData = { 13 | currentHealth: number; 14 | maxHealth: number; 15 | currentArea: { 16 | name: LevelName; 17 | startRoomId: number; 18 | startDoorId: number; 19 | }; 20 | areaDetails: { 21 | [key in LevelName]: { 22 | [key: number]: { 23 | chests: { 24 | [key: string]: { 25 | revealed: boolean; 26 | opened: boolean; 27 | }; 28 | }; 29 | doors: { 30 | [key: string]: { 31 | unlocked: boolean; 32 | }; 33 | }; 34 | }; 35 | bossDefeated?: boolean; 36 | }; 37 | }; 38 | }; 39 | 40 | export class DataManager { 41 | static #instance: DataManager; 42 | 43 | #data: PlayerData; 44 | 45 | private constructor() { 46 | this.#data = { 47 | currentHealth: PLAYER_START_MAX_HEALTH, 48 | maxHealth: PLAYER_START_MAX_HEALTH, 49 | currentArea: { 50 | name: LEVEL_NAME.DUNGEON_1, 51 | startRoomId: 3, 52 | startDoorId: 3, 53 | }, 54 | areaDetails: { 55 | DUNGEON_1: { 56 | bossDefeated: false, 57 | }, 58 | WORLD: {}, 59 | }, 60 | }; 61 | } 62 | 63 | public static get instance(): DataManager { 64 | if (!DataManager.#instance) { 65 | DataManager.#instance = new DataManager(); 66 | } 67 | return DataManager.#instance; 68 | } 69 | 70 | get data(): PlayerData { 71 | return { ...this.#data }; 72 | } 73 | 74 | set data(data: PlayerData) { 75 | this.#data = { ...data }; 76 | } 77 | 78 | public updateAreaData(area: LevelName, startRoomId: number, startDoorId: number): void { 79 | this.#data.currentArea = { 80 | name: area, 81 | startDoorId, 82 | startRoomId, 83 | }; 84 | } 85 | 86 | public updateChestData(roomId: number, chestId: number, revealed: boolean, opened: boolean): void { 87 | this.#populateDefaultRoomData(roomId); 88 | this.#data.areaDetails[this.#data.currentArea.name][roomId].chests[chestId] = { 89 | revealed, 90 | opened, 91 | }; 92 | } 93 | 94 | public updateDoorData(roomId: number, doorId: number, unlocked: boolean): void { 95 | this.#populateDefaultRoomData(roomId); 96 | this.#data.areaDetails[this.#data.currentArea.name][roomId].doors[doorId] = { 97 | unlocked, 98 | }; 99 | } 100 | 101 | public resetPlayerHealthToMin(): void { 102 | this.#data.currentHealth = PLAYER_START_MAX_HEALTH; 103 | } 104 | 105 | public updatePlayerCurrentHealth(health: number): void { 106 | if (health === this.#data.currentHealth) { 107 | return; 108 | } 109 | let healthUpdateType: PlayerHealthUpdateType = PLAYER_HEALTH_UPDATE_TYPE.DECREASE; 110 | if (health > this.#data.currentHealth) { 111 | healthUpdateType = PLAYER_HEALTH_UPDATE_TYPE.INCREASE; 112 | } 113 | const dataToPass: PlayerHealthUpdated = { 114 | previousHealth: this.#data.currentHealth, 115 | currentHealth: health, 116 | type: healthUpdateType, 117 | }; 118 | EVENT_BUS.emit(CUSTOM_EVENTS.PLAYER_HEALTH_UPDATED, dataToPass); 119 | this.#data.currentHealth = health; 120 | } 121 | 122 | public defeatedCurrentAreaBoss(): void { 123 | this.#data.areaDetails[this.#data.currentArea.name].bossDefeated = true; 124 | } 125 | 126 | #populateDefaultRoomData(roomId: number): void { 127 | if (this.#data.areaDetails[this.#data.currentArea.name][roomId] === undefined) { 128 | this.#data.areaDetails[this.#data.currentArea.name][roomId] = { 129 | chests: {}, 130 | doors: {}, 131 | }; 132 | } 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/common/event-bus.ts: -------------------------------------------------------------------------------- 1 | import * as Phaser from 'phaser'; 2 | 3 | export const EVENT_BUS = new Phaser.Events.EventEmitter(); 4 | 5 | export const CUSTOM_EVENTS = { 6 | OPENED_CHEST: 'OPENED_CHEST', 7 | ENEMY_DESTROYED: 'ENEMY_DESTROYED', 8 | PLAYER_DEFEATED: 'PLAYER_DEFEATED', 9 | PLAYER_HEALTH_UPDATED: 'PLAYER_HEALTH_UPDATED', 10 | SHOW_DIALOG: 'SHOW_DIALOG', 11 | DIALOG_CLOSED: 'DIALOG_CLOSED', 12 | BOSS_DEFEATED: 'BOSS_DEFEATED', 13 | } as const; 14 | 15 | export const PLAYER_HEALTH_UPDATE_TYPE = { 16 | INCREASE: 'INCREASE', 17 | DECREASE: 'DECREASE', 18 | } as const; 19 | 20 | export type PlayerHealthUpdateType = keyof typeof PLAYER_HEALTH_UPDATE_TYPE; 21 | 22 | export type PlayerHealthUpdated = { 23 | currentHealth: number; 24 | previousHealth: number; 25 | type: PlayerHealthUpdateType; 26 | }; 27 | -------------------------------------------------------------------------------- /src/common/juice-utils.ts: -------------------------------------------------------------------------------- 1 | import * as Phaser from 'phaser'; 2 | 3 | /** 4 | * Creates a flash animation effect by using the built in Phaser 3 Timer Events. The provided game object 5 | * will be the target of the effect that is created. 6 | * @param {Phaser.GameObjects.Image | Phaser.GameObjects.Sprite} target The target game object that the effect will be applied to. 7 | * @param {() => void} [callback] The callback that will be invoked when the tween is finished 8 | * @returns {void} 9 | */ 10 | export function flash(target: Phaser.GameObjects.Image | Phaser.GameObjects.Sprite, callback?: () => void): void { 11 | const timeEvent = target.scene.time.addEvent({ 12 | delay: 250, 13 | callback: () => { 14 | target.setTintFill(0xffffff); 15 | target.setAlpha(0.7); 16 | 17 | target.scene.time.addEvent({ 18 | delay: 150, 19 | callback: () => { 20 | target.setTint(0xffffff); 21 | target.setAlpha(1); 22 | if (timeEvent.getOverallProgress() === 1 && callback) { 23 | callback(); 24 | } 25 | }, 26 | }); 27 | }, 28 | startAt: 150, 29 | repeat: 3, 30 | }); 31 | } 32 | -------------------------------------------------------------------------------- /src/common/tiled/common.ts: -------------------------------------------------------------------------------- 1 | export const TILED_ROOM_OBJECT_PROPERTY = { 2 | ID: 'id', 3 | } as const; 4 | 5 | export const TILED_LAYER_NAMES = { 6 | ROOMS: 'rooms', 7 | SWITCHES: 'switches', 8 | POTS: 'pots', 9 | DOORS: 'doors', 10 | CHESTS: 'chests', 11 | ENEMIES: 'enemies', 12 | COLLISION: 'collision', 13 | ENEMY_COLLISION: 'enemy_collision', 14 | } as const; 15 | 16 | export const TILED_TILESET_NAMES = { 17 | COLLISION: 'collision', 18 | } as const; 19 | 20 | export const DOOR_TYPE = { 21 | OPEN: 'OPEN', 22 | LOCK: 'LOCK', 23 | TRAP: 'TRAP', 24 | BOSS: 'BOSS', 25 | OPEN_ENTRANCE: 'OPEN_ENTRANCE', 26 | } as const; 27 | 28 | export const TRAP_TYPE = { 29 | NONE: 'NONE', 30 | ENEMIES_DEFEATED: 'ENEMIES_DEFEATED', 31 | SWITCH: 'SWITCH', 32 | BOSS_DEFEATED: 'BOSS_DEFEATED', 33 | } as const; 34 | 35 | export const TILED_DOOR_OBJECT_PROPERTY = { 36 | TARGET_DOOR_ID: 'targetDoorId', 37 | TARGET_ROOM_ID: 'targetRoomId', 38 | TARGET_LEVEL: 'targetLevel', 39 | ID: 'id', 40 | DIRECTION: 'direction', 41 | DOOR_TYPE: 'doorType', 42 | TRAP_DOOR_TRIGGER: 'trapDoorTrigger', 43 | IS_LEVEL_TRANSITION: 'isLevelTransition', 44 | } as const; 45 | 46 | export const CHEST_REWARD = { 47 | SMALL_KEY: 'SMALL_KEY', 48 | BOSS_KEY: 'BOSS_KEY', 49 | MAP: 'MAP', 50 | COMPASS: 'COMPASS', 51 | NOTHING: 'NOTHING', 52 | } as const; 53 | 54 | export const TILED_CHEST_OBJECT_PROPERTY = { 55 | CONTENTS: 'contents', 56 | ID: 'id', 57 | REVEAL_CHEST_TRIGGER: 'revealChestTrigger', 58 | REQUIRES_BOSS_KEY: 'requiresBossKey', 59 | } as const; 60 | 61 | export const TILED_SWITCH_OBJECT_PROPERTY = { 62 | TARGET_IDS: 'targetIds', 63 | ACTION: 'action', 64 | TEXTURE: 'texture', 65 | } as const; 66 | 67 | export const SWITCH_TEXTURE = { 68 | PLATE: 'PLATE', 69 | FLOOR: 'FLOOR', 70 | } as const; 71 | 72 | export const SWITCH_ACTION = { 73 | NOTHING: 'NOTHING', 74 | OPEN_DOOR: 'OPEN_DOOR', 75 | REVEAL_CHEST: 'REVEAL_CHEST', 76 | REVEAL_KEY: 'REVEAL_KEY', 77 | } as const; 78 | -------------------------------------------------------------------------------- /src/common/tiled/tiled-utils.ts: -------------------------------------------------------------------------------- 1 | import * as Phaser from 'phaser'; 2 | import { 3 | ChestReward, 4 | DoorType, 5 | SwitchAction, 6 | SwitchTexture, 7 | TILED_ENEMY_OBJECT_PROPERTY, 8 | TiledChestObject, 9 | TiledDoorObject, 10 | TiledEnemyObject, 11 | TiledObjectProperty, 12 | TiledObjectWithProperties, 13 | TiledPotObject, 14 | TiledRoomObject, 15 | TiledSwitchObject, 16 | TrapType, 17 | } from './types'; 18 | import { 19 | CHEST_REWARD, 20 | DOOR_TYPE, 21 | SWITCH_ACTION, 22 | SWITCH_TEXTURE, 23 | TILED_CHEST_OBJECT_PROPERTY, 24 | TILED_DOOR_OBJECT_PROPERTY, 25 | TILED_ROOM_OBJECT_PROPERTY, 26 | TILED_SWITCH_OBJECT_PROPERTY, 27 | TRAP_TYPE, 28 | } from './common'; 29 | import { isDirection } from '../utils'; 30 | import { LevelName } from '../types'; 31 | 32 | /** 33 | * Validates that the provided property is of the type TiledObjectProperty. 34 | */ 35 | export function isTiledObjectProperty(property: unknown): property is TiledObjectProperty { 36 | if (typeof property !== 'object' || property === null || property === undefined) { 37 | return false; 38 | } 39 | return property['name'] !== undefined && property['type'] !== undefined && property['value'] !== undefined; 40 | } 41 | 42 | /** 43 | * Returns an array of validated TiledObjectProperty objects from the provided Phaser Tiled Object properties. 44 | */ 45 | export function getTiledProperties(properties: unknown): TiledObjectProperty[] { 46 | const validProperties: TiledObjectProperty[] = []; 47 | if (typeof properties !== 'object' || properties === null || properties === undefined || !Array.isArray(properties)) { 48 | return validProperties; 49 | } 50 | properties.forEach((property) => { 51 | if (!isTiledObjectProperty(property)) { 52 | return; 53 | } 54 | validProperties.push(property); 55 | }); 56 | return validProperties; 57 | } 58 | 59 | /** 60 | * Returns the value of the given Tiled property name on an object. In Tiled the object properties are 61 | * stored on an array, and we need to loop through the Array to find the property we are looking for. 62 | */ 63 | export function getTiledPropertyByName(properties: TiledObjectProperty[], propertyName: string): T | undefined { 64 | const tiledProperty = properties.find((prop) => { 65 | return prop.name === propertyName; 66 | }); 67 | if (tiledProperty === undefined) { 68 | return undefined; 69 | } 70 | return tiledProperty.value as T; 71 | } 72 | 73 | /** 74 | * Finds all of the Tiled Objects for a given layer of a Tilemap, and filters to only objects that include 75 | * the basic properties for an objects position, width, and height. 76 | */ 77 | export function getTiledObjectsFromLayer(map: Phaser.Tilemaps.Tilemap, layerName: string): TiledObjectWithProperties[] { 78 | const validTiledObjects: TiledObjectWithProperties[] = []; 79 | // get the Tiled object layer by its name 80 | const tiledObjectLayer = map.getObjectLayer(layerName); 81 | if (!tiledObjectLayer) { 82 | return validTiledObjects; 83 | } 84 | 85 | // loop through each object and validate object has basic properties for position, width, height, etc 86 | const tiledObjects = tiledObjectLayer.objects; 87 | tiledObjects.forEach((tiledObject) => { 88 | if ( 89 | tiledObject.x === undefined || 90 | tiledObject.y === undefined || 91 | tiledObject.width === undefined || 92 | tiledObject.height === undefined 93 | ) { 94 | return; 95 | } 96 | validTiledObjects.push({ 97 | x: tiledObject.x, 98 | y: tiledObject.y, 99 | width: tiledObject.width, 100 | height: tiledObject.height, 101 | properties: getTiledProperties(tiledObject.properties), 102 | }); 103 | }); 104 | 105 | return validTiledObjects; 106 | } 107 | 108 | /** 109 | * Finds all of the valid 'Room' Tiled Objects on a given layer of a Tilemap. 110 | */ 111 | export function getTiledRoomObjectsFromMap(map: Phaser.Tilemaps.Tilemap, layerName: string): TiledRoomObject[] { 112 | const roomObjects: TiledRoomObject[] = []; 113 | 114 | // loop through each object and validate object has properties for the object we are planning to build 115 | const tiledObjects = getTiledObjectsFromLayer(map, layerName); 116 | tiledObjects.forEach((tiledObject) => { 117 | const id = getTiledPropertyByName(tiledObject.properties, TILED_ROOM_OBJECT_PROPERTY.ID); 118 | if (id === undefined) { 119 | return; 120 | } 121 | 122 | roomObjects.push({ 123 | x: tiledObject.x, 124 | y: tiledObject.y, 125 | width: tiledObject.width, 126 | height: tiledObject.height, 127 | id, 128 | }); 129 | }); 130 | 131 | return roomObjects; 132 | } 133 | 134 | /** 135 | * Parses the provided Phaser Tilemap and returns all Object layer names with the provided prefix. 136 | * This function expects the layer names to be in a format like: rooms/1/enemies. 137 | */ 138 | export function getAllLayerNamesWithPrefix(map: Phaser.Tilemaps.Tilemap, prefix: string): string[] { 139 | return map 140 | .getObjectLayerNames() 141 | .filter((layerName) => layerName.startsWith(`${prefix}/`)) 142 | .filter((layerName) => { 143 | const layerData = layerName.split('/'); 144 | if (layerData.length !== 3) { 145 | return false; 146 | } 147 | return true; 148 | }); 149 | } 150 | 151 | /** 152 | * Finds all of the valid 'Door' Tiled Objects on a given layer of a Tilemap. 153 | */ 154 | export function getTiledDoorObjectsFromMap(map: Phaser.Tilemaps.Tilemap, layerName: string): TiledDoorObject[] { 155 | const doorObjects: TiledDoorObject[] = []; 156 | 157 | // loop through each object and validate object has properties for the object we are planning to build 158 | const tiledObjects = getTiledObjectsFromLayer(map, layerName); 159 | tiledObjects.forEach((tiledObject) => { 160 | const doorId = getTiledPropertyByName(tiledObject.properties, TILED_DOOR_OBJECT_PROPERTY.ID); 161 | const targetDoorId = getTiledPropertyByName( 162 | tiledObject.properties, 163 | TILED_DOOR_OBJECT_PROPERTY.TARGET_DOOR_ID, 164 | ); 165 | const doorDirection = getTiledPropertyByName(tiledObject.properties, TILED_DOOR_OBJECT_PROPERTY.DIRECTION); 166 | const doorType = getTiledPropertyByName(tiledObject.properties, TILED_DOOR_OBJECT_PROPERTY.DOOR_TYPE); 167 | const trapDoorType = getTiledPropertyByName( 168 | tiledObject.properties, 169 | TILED_DOOR_OBJECT_PROPERTY.TRAP_DOOR_TRIGGER, 170 | ); 171 | const isLevelTransition = getTiledPropertyByName( 172 | tiledObject.properties, 173 | TILED_DOOR_OBJECT_PROPERTY.IS_LEVEL_TRANSITION, 174 | ); 175 | const targetLevel = getTiledPropertyByName( 176 | tiledObject.properties, 177 | TILED_DOOR_OBJECT_PROPERTY.TARGET_LEVEL, 178 | ); 179 | const targetRoomId = getTiledPropertyByName( 180 | tiledObject.properties, 181 | TILED_DOOR_OBJECT_PROPERTY.TARGET_ROOM_ID, 182 | ); 183 | if ( 184 | doorId === undefined || 185 | targetDoorId === undefined || 186 | doorDirection === undefined || 187 | doorType === undefined || 188 | trapDoorType === undefined || 189 | isLevelTransition === undefined || 190 | targetLevel === undefined || 191 | targetRoomId === undefined || 192 | !isDirection(doorDirection) || 193 | !isDoorType(doorType) || 194 | !isTrapType(trapDoorType) 195 | ) { 196 | return; 197 | } 198 | 199 | doorObjects.push({ 200 | x: tiledObject.x, 201 | y: tiledObject.y, 202 | width: tiledObject.width, 203 | height: tiledObject.height, 204 | id: doorId, 205 | targetDoorId: targetDoorId, 206 | direction: doorDirection, 207 | doorType: doorType, 208 | trapDoorTrigger: trapDoorType, 209 | isLevelTransition, 210 | targetLevel, 211 | targetRoomId, 212 | isUnlocked: false, 213 | }); 214 | }); 215 | 216 | return doorObjects; 217 | } 218 | 219 | export function isDoorType(doorType: string): doorType is DoorType { 220 | return DOOR_TYPE[doorType] !== undefined; 221 | } 222 | 223 | export function isTrapType(trapType: string): trapType is TrapType { 224 | return TRAP_TYPE[trapType] !== undefined; 225 | } 226 | 227 | /** 228 | * Finds all of the valid 'Pot' Tiled Objects on a given layer of a Tilemap. 229 | */ 230 | export function getTiledPotObjectsFromMap(map: Phaser.Tilemaps.Tilemap, layerName: string): TiledPotObject[] { 231 | const potObjects: TiledPotObject[] = []; 232 | 233 | // loop through each object and validate object has properties for the object we are planning to build 234 | const tiledObjects = getTiledObjectsFromLayer(map, layerName); 235 | tiledObjects.forEach((tiledObject) => { 236 | potObjects.push({ 237 | x: tiledObject.x, 238 | y: tiledObject.y, 239 | width: tiledObject.width, 240 | height: tiledObject.height, 241 | }); 242 | }); 243 | 244 | return potObjects; 245 | } 246 | 247 | /** 248 | * Finds all of the valid 'Chest' Tiled Objects on a given layer of a Tilemap. 249 | */ 250 | export function getTiledChestObjectsFromMap(map: Phaser.Tilemaps.Tilemap, layerName: string): TiledChestObject[] { 251 | const chestObjects: TiledChestObject[] = []; 252 | 253 | // loop through each object and validate object has properties for the object we are planning to build 254 | const tiledObjects = getTiledObjectsFromLayer(map, layerName); 255 | tiledObjects.forEach((tiledObject) => { 256 | const contents = getTiledPropertyByName(tiledObject.properties, TILED_CHEST_OBJECT_PROPERTY.CONTENTS); 257 | const id = getTiledPropertyByName(tiledObject.properties, TILED_CHEST_OBJECT_PROPERTY.ID); 258 | const revealChestTrigger = getTiledPropertyByName( 259 | tiledObject.properties, 260 | TILED_CHEST_OBJECT_PROPERTY.REVEAL_CHEST_TRIGGER, 261 | ); 262 | const requiresBossKey = getTiledPropertyByName( 263 | tiledObject.properties, 264 | TILED_CHEST_OBJECT_PROPERTY.REQUIRES_BOSS_KEY, 265 | ); 266 | if ( 267 | contents === undefined || 268 | id === undefined || 269 | revealChestTrigger === undefined || 270 | requiresBossKey === undefined || 271 | !isTrapType(revealChestTrigger) || 272 | !isChestReward(contents) 273 | ) { 274 | return; 275 | } 276 | 277 | chestObjects.push({ 278 | x: tiledObject.x, 279 | y: tiledObject.y, 280 | width: tiledObject.width, 281 | height: tiledObject.height, 282 | id, 283 | revealChestTrigger, 284 | contents, 285 | requiresBossKey, 286 | }); 287 | }); 288 | 289 | return chestObjects; 290 | } 291 | 292 | export function isChestReward(reward: string): reward is ChestReward { 293 | return CHEST_REWARD[reward] !== undefined; 294 | } 295 | 296 | /** 297 | * Finds all of the valid 'Enemy' Tiled Objects on a given layer of a Tilemap. 298 | */ 299 | export function getTiledEnemyObjectsFromMap(map: Phaser.Tilemaps.Tilemap, layerName: string): TiledEnemyObject[] { 300 | const enemyObjects: TiledEnemyObject[] = []; 301 | 302 | // loop through each object and validate object has properties for the object we are planning to build 303 | const tiledObjects = getTiledObjectsFromLayer(map, layerName); 304 | tiledObjects.forEach((tiledObject) => { 305 | const enemyType = getTiledPropertyByName(tiledObject.properties, TILED_ENEMY_OBJECT_PROPERTY.TYPE); 306 | if (enemyType === undefined) { 307 | return; 308 | } 309 | 310 | enemyObjects.push({ 311 | x: tiledObject.x, 312 | y: tiledObject.y, 313 | width: tiledObject.width, 314 | height: tiledObject.height, 315 | type: enemyType, 316 | }); 317 | }); 318 | 319 | return enemyObjects; 320 | } 321 | 322 | /** 323 | * Finds all of the valid 'Switch' Tiled Objects on a given layer of a Tilemap. 324 | */ 325 | export function getTiledSwitchObjectsFromMap(map: Phaser.Tilemaps.Tilemap, layerName: string): TiledSwitchObject[] { 326 | const switchObjects: TiledSwitchObject[] = []; 327 | 328 | // loop through each object and validate object has properties for the object we are planning to build 329 | const tiledObjects = getTiledObjectsFromLayer(map, layerName); 330 | tiledObjects.forEach((tiledObject) => { 331 | const action = getTiledPropertyByName(tiledObject.properties, TILED_SWITCH_OBJECT_PROPERTY.ACTION); 332 | const targetIds = getTiledPropertyByName(tiledObject.properties, TILED_SWITCH_OBJECT_PROPERTY.TARGET_IDS); 333 | const texture = getTiledPropertyByName(tiledObject.properties, TILED_SWITCH_OBJECT_PROPERTY.TEXTURE); 334 | 335 | if ( 336 | action === undefined || 337 | targetIds === undefined || 338 | texture === undefined || 339 | !isSwitchAction(action) || 340 | !isSwitchTexture(texture) 341 | ) { 342 | return; 343 | } 344 | 345 | switchObjects.push({ 346 | x: tiledObject.x, 347 | y: tiledObject.y, 348 | width: tiledObject.width, 349 | height: tiledObject.height, 350 | action, 351 | targetIds: targetIds.split(',').map((value) => parseInt(value, 10)), 352 | texture, 353 | }); 354 | }); 355 | 356 | return switchObjects; 357 | } 358 | 359 | export function isSwitchTexture(switchTexture: string): switchTexture is SwitchTexture { 360 | return SWITCH_TEXTURE[switchTexture] !== undefined; 361 | } 362 | 363 | export function isSwitchAction(switchAction: string): switchAction is SwitchAction { 364 | return SWITCH_ACTION[switchAction] !== undefined; 365 | } 366 | -------------------------------------------------------------------------------- /src/common/tiled/types.ts: -------------------------------------------------------------------------------- 1 | import { Direction } from '../types'; 2 | import { CHEST_REWARD, DOOR_TYPE, SWITCH_ACTION, SWITCH_TEXTURE, TRAP_TYPE } from './common'; 3 | 4 | export type TiledObject = { 5 | x: number; 6 | y: number; 7 | width: number; 8 | height: number; 9 | }; 10 | 11 | export type TiledObjectProperty = { 12 | name: string; 13 | type: string; 14 | value: string | number | boolean; 15 | }; 16 | 17 | export type TiledObjectWithProperties = { 18 | properties: TiledObjectProperty[]; 19 | } & TiledObject; 20 | 21 | export type TiledRoomObject = { 22 | id: number; 23 | } & TiledObject; 24 | 25 | export type TiledDoorObject = { 26 | id: number; 27 | targetDoorId: number; 28 | isUnlocked: boolean; 29 | doorType: DoorType; 30 | direction: Direction; 31 | trapDoorTrigger: TrapType; 32 | isLevelTransition: boolean; 33 | targetLevel: string; 34 | targetRoomId: number; 35 | } & TiledObject; 36 | 37 | export type DoorType = keyof typeof DOOR_TYPE; 38 | 39 | export type TrapType = keyof typeof TRAP_TYPE; 40 | 41 | export type TiledPotObject = TiledObject; 42 | 43 | export type TiledChestObject = { 44 | contents: ChestReward; 45 | id: number; 46 | revealChestTrigger: TrapType; 47 | requiresBossKey: boolean; 48 | } & TiledObject; 49 | 50 | export type ChestReward = keyof typeof CHEST_REWARD; 51 | 52 | export type TiledEnemyObject = { 53 | type: number; 54 | } & TiledObject; 55 | 56 | export const TILED_ENEMY_OBJECT_PROPERTY = { 57 | TYPE: 'type', 58 | } as const; 59 | 60 | export type TiledSwitchObject = { 61 | targetIds: number[]; 62 | action: SwitchAction; 63 | texture: SwitchTexture; 64 | } & TiledObject; 65 | 66 | export type SwitchTexture = keyof typeof SWITCH_TEXTURE; 67 | 68 | export type SwitchAction = keyof typeof SWITCH_ACTION; 69 | -------------------------------------------------------------------------------- /src/common/types.ts: -------------------------------------------------------------------------------- 1 | import * as Phaser from 'phaser'; 2 | import { CHARACTER_ANIMATIONS } from './assets'; 3 | import { CHEST_STATE, DIRECTION, DUNGEON_ITEM, INTERACTIVE_OBJECT_TYPE, LEVEL_NAME } from './common'; 4 | 5 | export type CharacterAnimation = keyof typeof CHARACTER_ANIMATIONS; 6 | 7 | export type Position = { 8 | x: number; 9 | y: number; 10 | }; 11 | 12 | export type GameObject = Phaser.GameObjects.Sprite | Phaser.GameObjects.Image; 13 | 14 | export type Direction = keyof typeof DIRECTION; 15 | 16 | export type ChestState = keyof typeof CHEST_STATE; 17 | 18 | export type InteractiveObjectType = keyof typeof INTERACTIVE_OBJECT_TYPE; 19 | 20 | export interface CustomGameObject { 21 | enableObject(): void; 22 | disableObject(): void; 23 | } 24 | 25 | export type LevelName = keyof typeof LEVEL_NAME; 26 | 27 | export type LevelData = { 28 | level: LevelName; 29 | doorId: number; 30 | roomId: number; 31 | }; 32 | 33 | export type DungeonItem = keyof typeof DUNGEON_ITEM; 34 | -------------------------------------------------------------------------------- /src/common/utils.ts: -------------------------------------------------------------------------------- 1 | import * as Phaser from 'phaser'; 2 | import { DIRECTION, LEVEL_NAME } from './common'; 3 | import { CustomGameObject, Direction, GameObject, LevelName, Position } from './types'; 4 | 5 | /** 6 | * Utility function to ensure we handle the full possible range of types when checking a variable for a possible 7 | * type in a union. 8 | * 9 | * A good example of this is when we check for all of the possible values in a `switch` statement, and we want 10 | * to ensure we check for all possible values in an enum type object. 11 | */ 12 | export function exhaustiveGuard(_value: never): never { 13 | throw new Error(`Error! Reached forbidden guard function with unexpected value: ${JSON.stringify(_value)}`); 14 | } 15 | 16 | export function isArcadePhysicsBody( 17 | body: Phaser.Physics.Arcade.Body | Phaser.Physics.Arcade.StaticBody | MatterJS.BodyType | null, 18 | ): body is Phaser.Physics.Arcade.Body { 19 | if (body === undefined || body === null) { 20 | return false; 21 | } 22 | return body instanceof Phaser.Physics.Arcade.Body; 23 | } 24 | 25 | export function isDirection(direction: string): direction is Direction { 26 | return DIRECTION[direction] !== undefined; 27 | } 28 | 29 | export function isCustomGameObject(gameObject: GameObject): gameObject is GameObject & CustomGameObject { 30 | return gameObject['disableObject'] !== undefined && gameObject['enableObject'] !== undefined; 31 | } 32 | 33 | export function getDirectionOfObjectFromAnotherObject(object: Position, targetObject: Position): Direction { 34 | if (object.y < targetObject.y) { 35 | return DIRECTION.DOWN; 36 | } 37 | if (object.y > targetObject.y) { 38 | return DIRECTION.UP; 39 | } 40 | if (object.x < targetObject.x) { 41 | return DIRECTION.RIGHT; 42 | } 43 | return DIRECTION.LEFT; 44 | } 45 | 46 | export function isLevelName(levelName: string): levelName is LevelName { 47 | return LEVEL_NAME[levelName] !== undefined; 48 | } 49 | -------------------------------------------------------------------------------- /src/components/game-object/animation-component.ts: -------------------------------------------------------------------------------- 1 | import { CharacterAnimation, GameObject } from '../../common/types'; 2 | import { BaseGameObjectComponent } from './base-game-object-component'; 3 | 4 | export type AnimationConfig = { 5 | [key in CharacterAnimation]?: { key: string; repeat: number; ignoreIfPlaying: boolean }; 6 | }; 7 | 8 | export class AnimationComponent extends BaseGameObjectComponent { 9 | declare protected gameObject: Phaser.GameObjects.Sprite; 10 | 11 | #config: AnimationConfig; 12 | 13 | constructor(gameObject: GameObject, config: AnimationConfig) { 14 | super(gameObject); 15 | this.#config = config; 16 | } 17 | 18 | public getAnimationKey(characterAnimationKey: CharacterAnimation): string | undefined { 19 | if (this.#config[characterAnimationKey] === undefined) { 20 | return undefined; 21 | } 22 | return this.#config[characterAnimationKey].key; 23 | } 24 | 25 | public playAnimation(characterAnimationKey: CharacterAnimation, callback?: () => void): void { 26 | if (this.#config[characterAnimationKey] === undefined) { 27 | if (callback) { 28 | callback(); 29 | } 30 | return; 31 | } 32 | const animationConfig: Phaser.Types.Animations.PlayAnimationConfig = { 33 | key: this.#config[characterAnimationKey].key, 34 | repeat: this.#config[characterAnimationKey].repeat, 35 | timeScale: 1, 36 | }; 37 | if (callback) { 38 | const animationKey = Phaser.Animations.Events.ANIMATION_COMPLETE_KEY + this.#config[characterAnimationKey].key; 39 | this.gameObject.once(animationKey, () => { 40 | callback(); 41 | }); 42 | } 43 | this.gameObject.play(animationConfig, this.#config[characterAnimationKey].ignoreIfPlaying); 44 | } 45 | 46 | public playAnimationInReverse(characterAnimationKey: CharacterAnimation, callback?: () => void): void { 47 | if (this.#config[characterAnimationKey] === undefined) { 48 | if (callback) { 49 | callback(); 50 | } 51 | return; 52 | } 53 | const animationConfig: Phaser.Types.Animations.PlayAnimationConfig = { 54 | key: this.#config[characterAnimationKey].key, 55 | repeat: this.#config[characterAnimationKey].repeat, 56 | timeScale: 1.75, 57 | }; 58 | if (callback) { 59 | const animationKey = Phaser.Animations.Events.ANIMATION_COMPLETE_KEY + this.#config[characterAnimationKey].key; 60 | this.gameObject.once(animationKey, () => { 61 | callback(); 62 | }); 63 | } 64 | this.gameObject.playReverse(animationConfig, this.#config[characterAnimationKey].ignoreIfPlaying); 65 | } 66 | 67 | public isAnimationPlaying(): boolean { 68 | return this.gameObject.anims.isPlaying; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/components/game-object/base-game-object-component.ts: -------------------------------------------------------------------------------- 1 | import * as Phaser from 'phaser'; 2 | import { GameObject } from '../../common/types'; 3 | 4 | export class BaseGameObjectComponent { 5 | protected scene: Phaser.Scene; 6 | protected gameObject: GameObject; 7 | 8 | constructor(gameObject: GameObject) { 9 | this.scene = gameObject.scene; 10 | this.gameObject = gameObject; 11 | this.assignComponentToObject(gameObject); 12 | } 13 | 14 | static getComponent(gameObject: GameObject): T { 15 | return gameObject[`_${this.name}`] as T; 16 | } 17 | 18 | static removeComponent(gameObject: GameObject): void { 19 | delete gameObject[`_${this.name}`]; 20 | } 21 | 22 | protected assignComponentToObject(object: GameObject | Phaser.Physics.Arcade.Body): void { 23 | object[`_${this.constructor.name}`] = this; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/components/game-object/colliding-objects-component.ts: -------------------------------------------------------------------------------- 1 | import { GameObject } from '../../common/types'; 2 | import { BaseGameObjectComponent } from './base-game-object-component'; 3 | 4 | export class CollidingObjectsComponent extends BaseGameObjectComponent { 5 | #objects: GameObject[]; 6 | 7 | constructor(gameObject: GameObject) { 8 | super(gameObject); 9 | this.#objects = []; 10 | } 11 | 12 | get objects(): GameObject[] { 13 | return this.#objects; 14 | } 15 | 16 | public add(gameObject: GameObject): void { 17 | this.#objects.push(gameObject); 18 | } 19 | 20 | public reset(): void { 21 | this.#objects = []; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/components/game-object/controls-component.ts: -------------------------------------------------------------------------------- 1 | import { GameObject } from '../../common/types'; 2 | import { InputComponent } from '../input/input-component'; 3 | import { BaseGameObjectComponent } from './base-game-object-component'; 4 | 5 | export class ControlsComponent extends BaseGameObjectComponent { 6 | #inputComponent: InputComponent; 7 | 8 | constructor(gameObject: GameObject, inputComponent: InputComponent) { 9 | super(gameObject); 10 | this.#inputComponent = inputComponent; 11 | } 12 | 13 | get controls(): InputComponent { 14 | return this.#inputComponent; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/components/game-object/direction-component.ts: -------------------------------------------------------------------------------- 1 | import { DIRECTION } from '../../common/common'; 2 | import { Direction, GameObject } from '../../common/types'; 3 | import { BaseGameObjectComponent } from './base-game-object-component'; 4 | 5 | export class DirectionComponent extends BaseGameObjectComponent { 6 | #direction: Direction; 7 | #callback: (direction: Direction) => void; 8 | 9 | constructor(gameObject: GameObject, onDirectionCallback = () => undefined) { 10 | super(gameObject); 11 | this.#direction = DIRECTION.DOWN; 12 | this.#callback = onDirectionCallback; 13 | } 14 | 15 | get direction(): Direction { 16 | return this.#direction; 17 | } 18 | 19 | set direction(direction: Direction) { 20 | this.#direction = direction; 21 | this.#callback(this.#direction); 22 | } 23 | 24 | set callback(callback: (direction: Direction) => void) { 25 | this.#callback = callback; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/components/game-object/held-game-object-component.ts: -------------------------------------------------------------------------------- 1 | import { GameObject } from '../../common/types'; 2 | import { BaseGameObjectComponent } from './base-game-object-component'; 3 | 4 | export class HeldGameObjectComponent extends BaseGameObjectComponent { 5 | #object: GameObject | undefined; 6 | 7 | constructor(gameObject: GameObject) { 8 | super(gameObject); 9 | } 10 | 11 | get object(): GameObject | undefined { 12 | return this.#object; 13 | } 14 | 15 | set object(object: GameObject) { 16 | this.#object = object; 17 | } 18 | 19 | public drop(): void { 20 | this.#object = undefined; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/components/game-object/interactive-object-component.ts: -------------------------------------------------------------------------------- 1 | import { GameObject, InteractiveObjectType } from '../../common/types'; 2 | import { BaseGameObjectComponent } from './base-game-object-component'; 3 | 4 | export class InteractiveObjectComponent extends BaseGameObjectComponent { 5 | #objectType: InteractiveObjectType; 6 | #callback: () => void; 7 | #canInteractCheck: () => boolean; 8 | 9 | constructor( 10 | gameObject: GameObject, 11 | objectType: InteractiveObjectType, 12 | canInteractCheck = () => true, 13 | callback = () => undefined, 14 | ) { 15 | super(gameObject); 16 | this.#objectType = objectType; 17 | this.#callback = callback; 18 | this.#canInteractCheck = canInteractCheck; 19 | } 20 | 21 | get objectType(): InteractiveObjectType { 22 | return this.#objectType; 23 | } 24 | 25 | public interact(): void { 26 | this.#callback(); 27 | } 28 | 29 | public canInteractWith(): boolean { 30 | return this.#canInteractCheck(); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/components/game-object/invulnerable-component.ts: -------------------------------------------------------------------------------- 1 | import { GameObject } from '../../common/types'; 2 | import { BaseGameObjectComponent } from './base-game-object-component'; 3 | 4 | export class InvulnerableComponent extends BaseGameObjectComponent { 5 | #invulnerable: boolean; 6 | #invulnerableAfterHitAnimationDuration: number; 7 | 8 | constructor(gameObject: GameObject, invulnerable = false, invulnerableAfterHitAnimationDuration = 0) { 9 | super(gameObject); 10 | this.#invulnerable = invulnerable; 11 | this.#invulnerableAfterHitAnimationDuration = invulnerableAfterHitAnimationDuration; 12 | } 13 | 14 | get invulnerable(): boolean { 15 | return this.#invulnerable; 16 | } 17 | 18 | set invulnerable(val: boolean) { 19 | this.#invulnerable = val; 20 | } 21 | 22 | get invulnerableAfterHitAnimationDuration(): number { 23 | return this.#invulnerableAfterHitAnimationDuration; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/components/game-object/life-component.ts: -------------------------------------------------------------------------------- 1 | import { GameObject } from '../../common/types'; 2 | import { BaseGameObjectComponent } from './base-game-object-component'; 3 | 4 | export class LifeComponent extends BaseGameObjectComponent { 5 | #maxLife: number; 6 | #currentLife: number; 7 | 8 | constructor(gameObject: GameObject, maxLife: number, currentLife = maxLife) { 9 | super(gameObject); 10 | this.#maxLife = maxLife; 11 | this.#currentLife = currentLife; 12 | } 13 | 14 | get life(): number { 15 | return this.#currentLife; 16 | } 17 | 18 | get maxLife(): number { 19 | return this.#maxLife; 20 | } 21 | 22 | public takeDamage(damage: number): void { 23 | if (this.#currentLife === 0) { 24 | return; 25 | } 26 | this.#currentLife -= damage; 27 | if (this.#currentLife < 0) { 28 | this.#currentLife = 0; 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/components/game-object/speed-component.ts: -------------------------------------------------------------------------------- 1 | import { GameObject } from '../../common/types'; 2 | import { BaseGameObjectComponent } from './base-game-object-component'; 3 | 4 | export class SpeedComponent extends BaseGameObjectComponent { 5 | #speed: number; 6 | 7 | constructor(gameObject: GameObject, speed: number) { 8 | super(gameObject); 9 | this.#speed = speed; 10 | } 11 | 12 | get speed(): number { 13 | return this.#speed; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/components/game-object/throwable-object-component.ts: -------------------------------------------------------------------------------- 1 | import { DIRECTION } from '../../common/common'; 2 | import { THROW_ITEM_DELAY_BEFORE_CALLBACK, THROW_ITEM_SPEED } from '../../common/config'; 3 | import { Direction, GameObject } from '../../common/types'; 4 | import { exhaustiveGuard, isArcadePhysicsBody, isCustomGameObject } from '../../common/utils'; 5 | import { BaseGameObjectComponent } from './base-game-object-component'; 6 | 7 | export class ThrowableObjectComponent extends BaseGameObjectComponent { 8 | #callback: () => void; 9 | 10 | constructor(gameObject: GameObject, callback = () => undefined) { 11 | super(gameObject); 12 | this.#callback = callback; 13 | } 14 | 15 | public drop(): void { 16 | this.#callback(); 17 | } 18 | 19 | public throw(direction: Direction): void { 20 | if (!isArcadePhysicsBody(this.gameObject.body) || !isCustomGameObject(this.gameObject)) { 21 | this.#callback(); 22 | return; 23 | } 24 | 25 | const body = this.gameObject.body; 26 | body.velocity.x = 0; 27 | body.velocity.y = 0; 28 | 29 | const throwSpeed = THROW_ITEM_SPEED; 30 | switch (direction) { 31 | case DIRECTION.DOWN: 32 | this.gameObject.y += 20; 33 | body.velocity.y = throwSpeed; 34 | break; 35 | case DIRECTION.UP: 36 | body.velocity.y = throwSpeed * -1; 37 | break; 38 | case DIRECTION.LEFT: 39 | body.velocity.x = throwSpeed * -1; 40 | break; 41 | case DIRECTION.RIGHT: 42 | body.velocity.x = throwSpeed; 43 | break; 44 | default: 45 | exhaustiveGuard(direction); 46 | } 47 | 48 | this.gameObject.enableObject(); 49 | this.gameObject.scene.time.delayedCall(THROW_ITEM_DELAY_BEFORE_CALLBACK, () => { 50 | body.velocity.x = 0; 51 | body.velocity.y = 0; 52 | this.#callback(); 53 | }); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/components/game-object/weapon-component.ts: -------------------------------------------------------------------------------- 1 | import { GameObject } from '../../common/types'; 2 | import { Weapon } from '../../game-objects/weapons/base-weapon'; 3 | import { BaseGameObjectComponent } from './base-game-object-component'; 4 | 5 | export class WeaponComponent extends BaseGameObjectComponent { 6 | #weapon: Weapon | undefined; 7 | #weaponPhysicsBody: Phaser.Physics.Arcade.Body; 8 | 9 | constructor(gameObject: GameObject) { 10 | super(gameObject); 11 | this.#weaponPhysicsBody = gameObject.scene.physics.add.body(gameObject.x, gameObject.y, 1, 1); 12 | this.#weaponPhysicsBody.enable = false; 13 | this.assignComponentToObject(this.#weaponPhysicsBody); 14 | } 15 | 16 | get weapon(): Weapon | undefined { 17 | return this.#weapon; 18 | } 19 | 20 | set weapon(weapon: Weapon | undefined) { 21 | this.#weapon = weapon; 22 | } 23 | 24 | get body(): Phaser.Physics.Arcade.Body { 25 | return this.#weaponPhysicsBody; 26 | } 27 | 28 | get weaponDamage(): number { 29 | if (this.#weapon === undefined) { 30 | return 0; 31 | } 32 | return this.#weapon.baseDamage; 33 | } 34 | 35 | public update(): void { 36 | if (this.#weapon === undefined) { 37 | return; 38 | } 39 | this.#weapon.update(); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/components/input/input-component.ts: -------------------------------------------------------------------------------- 1 | export class InputComponent { 2 | #up: boolean; 3 | #down: boolean; 4 | #left: boolean; 5 | #right: boolean; 6 | #actionKey: boolean; 7 | #attackKey: boolean; 8 | #selectKey: boolean; 9 | #enterKey: boolean; 10 | #isMovementLocked: boolean; 11 | 12 | constructor() { 13 | this.#up = false; 14 | this.#left = false; 15 | this.#right = false; 16 | this.#down = false; 17 | this.#actionKey = false; 18 | this.#attackKey = false; 19 | this.#selectKey = false; 20 | this.#enterKey = false; 21 | this.#isMovementLocked = false; 22 | } 23 | 24 | get isMovementLocked(): boolean { 25 | return this.#isMovementLocked; 26 | } 27 | 28 | set isMovementLocked(val: boolean) { 29 | this.#isMovementLocked = val; 30 | } 31 | 32 | get isUpDown(): boolean { 33 | return this.#up; 34 | } 35 | 36 | get isUpJustDown(): boolean { 37 | return this.#up; 38 | } 39 | 40 | set isUpDown(val: boolean) { 41 | this.#up = val; 42 | } 43 | 44 | get isDownDown(): boolean { 45 | return this.#down; 46 | } 47 | 48 | get isDownJustDown(): boolean { 49 | return this.#down; 50 | } 51 | 52 | set isDownDown(val: boolean) { 53 | this.#down = val; 54 | } 55 | 56 | get isLeftDown(): boolean { 57 | return this.#left; 58 | } 59 | 60 | set isLeftDown(val: boolean) { 61 | this.#left = val; 62 | } 63 | 64 | get isRightDown(): boolean { 65 | return this.#right; 66 | } 67 | 68 | set isRightDown(val: boolean) { 69 | this.#right = val; 70 | } 71 | 72 | get isActionKeyJustDown(): boolean { 73 | return this.#actionKey; 74 | } 75 | 76 | set isActionKeyJustDown(val: boolean) { 77 | this.#actionKey = val; 78 | } 79 | 80 | get isAttackKeyJustDown(): boolean { 81 | return this.#attackKey; 82 | } 83 | 84 | set isAttackKeyJustDown(val: boolean) { 85 | this.#attackKey = val; 86 | } 87 | 88 | get isSelectKeyJustDown(): boolean { 89 | return this.#selectKey; 90 | } 91 | 92 | set isSelectKeyJustDown(val: boolean) { 93 | this.#selectKey = val; 94 | } 95 | 96 | get isEnterKeyJustDown(): boolean { 97 | return this.#enterKey; 98 | } 99 | 100 | set isEnterKeyJustDown(val: boolean) { 101 | this.#enterKey = val; 102 | } 103 | 104 | public reset(): void { 105 | this.#down = false; 106 | this.#up = false; 107 | this.#left = false; 108 | this.#right = false; 109 | this.#attackKey = false; 110 | this.#actionKey = false; 111 | this.#selectKey = false; 112 | this.#enterKey = false; 113 | this.#isMovementLocked = false; 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/components/input/keyboard-component.ts: -------------------------------------------------------------------------------- 1 | import * as Phaser from 'phaser'; 2 | import { InputComponent } from './input-component'; 3 | 4 | export class KeyboardComponent extends InputComponent { 5 | #cursorKeys: Phaser.Types.Input.Keyboard.CursorKeys; 6 | #attackKey: Phaser.Input.Keyboard.Key; 7 | #actionKey: Phaser.Input.Keyboard.Key; 8 | #enterKey: Phaser.Input.Keyboard.Key; 9 | 10 | constructor(keyboardPlugin: Phaser.Input.Keyboard.KeyboardPlugin) { 11 | super(); 12 | this.#cursorKeys = keyboardPlugin.createCursorKeys(); 13 | this.#attackKey = keyboardPlugin.addKey(Phaser.Input.Keyboard.KeyCodes.Z); 14 | this.#actionKey = keyboardPlugin.addKey(Phaser.Input.Keyboard.KeyCodes.X); 15 | this.#enterKey = keyboardPlugin.addKey(Phaser.Input.Keyboard.KeyCodes.ENTER); 16 | 17 | // z = B, Attack 18 | // x = A, Talk, Run, Lift/Throw, Push/Pull 19 | // shift = Select, Open Save Menu 20 | // return/enter = Start, Open Inventory 21 | } 22 | 23 | get isUpDown(): boolean { 24 | return this.#cursorKeys.up.isDown; 25 | } 26 | 27 | get isUpJustDown(): boolean { 28 | return Phaser.Input.Keyboard.JustDown(this.#cursorKeys.up); 29 | } 30 | 31 | get isDownDown(): boolean { 32 | return this.#cursorKeys.down.isDown; 33 | } 34 | 35 | get isDownJustDown(): boolean { 36 | return Phaser.Input.Keyboard.JustDown(this.#cursorKeys.down); 37 | } 38 | 39 | get isLeftDown(): boolean { 40 | return this.#cursorKeys.left.isDown; 41 | } 42 | 43 | get isRightDown(): boolean { 44 | return this.#cursorKeys.right.isDown; 45 | } 46 | 47 | get isActionKeyJustDown(): boolean { 48 | return Phaser.Input.Keyboard.JustDown(this.#actionKey); 49 | } 50 | 51 | get isAttackKeyJustDown(): boolean { 52 | return Phaser.Input.Keyboard.JustDown(this.#attackKey); 53 | } 54 | 55 | get isSelectKeyJustDown(): boolean { 56 | return Phaser.Input.Keyboard.JustDown(this.#cursorKeys.shift); 57 | } 58 | 59 | get isEnterKeyJustDown(): boolean { 60 | return Phaser.Input.Keyboard.JustDown(this.#enterKey); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/components/inventory/inventory-manager.ts: -------------------------------------------------------------------------------- 1 | import { DUNGEON_ITEM } from '../../common/common'; 2 | import { DungeonItem, LevelName } from '../../common/types'; 3 | import { exhaustiveGuard } from '../../common/utils'; 4 | 5 | type AreaInventory = { 6 | map: boolean; 7 | compass: boolean; 8 | bossKey: boolean; 9 | keys: number; 10 | }; 11 | 12 | type ItemInventory = { 13 | sword: boolean; 14 | }; 15 | 16 | export type InventoryData = { 17 | general: ItemInventory; 18 | area: { [key in LevelName]: AreaInventory }; 19 | }; 20 | 21 | export class InventoryManager { 22 | static #instance: InventoryManager; 23 | 24 | #generalInventory: ItemInventory; 25 | #areaInventory: { [key in LevelName]: AreaInventory }; 26 | 27 | private constructor() { 28 | this.#generalInventory = { 29 | sword: true, 30 | }; 31 | this.#areaInventory = { 32 | DUNGEON_1: { 33 | map: false, 34 | bossKey: false, 35 | compass: false, 36 | keys: 0, 37 | }, 38 | WORLD: { 39 | map: false, 40 | bossKey: false, 41 | compass: false, 42 | keys: 0, 43 | }, 44 | }; 45 | } 46 | 47 | public static get instance(): InventoryManager { 48 | if (!InventoryManager.#instance) { 49 | InventoryManager.#instance = new InventoryManager(); 50 | } 51 | return InventoryManager.#instance; 52 | } 53 | 54 | get data(): InventoryData { 55 | return { 56 | general: { ...this.#generalInventory }, 57 | area: { ...this.#areaInventory }, 58 | }; 59 | } 60 | 61 | set data(data: InventoryData) { 62 | this.#areaInventory = { ...data.area }; 63 | this.#generalInventory = { ...data.general }; 64 | } 65 | 66 | public addDungeonItem(area: LevelName, dungeonItem: DungeonItem): void { 67 | switch (dungeonItem) { 68 | case DUNGEON_ITEM.MAP: 69 | this.#areaInventory[area].map = true; 70 | return; 71 | case DUNGEON_ITEM.COMPASS: 72 | this.#areaInventory[area].compass = true; 73 | return; 74 | case DUNGEON_ITEM.BOSS_KEY: 75 | this.#areaInventory[area].bossKey = true; 76 | return; 77 | case DUNGEON_ITEM.SMALL_KEY: 78 | this.#areaInventory[area].keys += 1; 79 | return; 80 | default: 81 | exhaustiveGuard(dungeonItem); 82 | } 83 | } 84 | 85 | public getAreaInventory(area: LevelName): AreaInventory { 86 | return { ...this.#areaInventory[area] }; 87 | } 88 | 89 | public useAreaSmallKey(area: LevelName): void { 90 | if (this.#areaInventory[area].keys > 0) { 91 | this.#areaInventory[area].keys -= 1; 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/components/state-machine/state-machine.ts: -------------------------------------------------------------------------------- 1 | import { ENABLE_LOGGING } from '../../common/config'; 2 | 3 | export interface State { 4 | stateMachine: StateMachine; 5 | name: string; 6 | onEnter?: (args: unknown[]) => void; 7 | onUpdate?: () => void; 8 | } 9 | 10 | export class StateMachine { 11 | #id: string; 12 | #states: Map; 13 | #currentState: State | undefined; 14 | #isChangingState: boolean; 15 | #changingStateQueue: { state: string; args: unknown[] }[]; 16 | 17 | /** 18 | * @param {string} [id] the unique identifier for this state machine instance. 19 | */ 20 | constructor(id?: string) { 21 | if (id === undefined) { 22 | this.#id = Phaser.Math.RND.uuid(); 23 | } else { 24 | this.#id = id; 25 | } 26 | this.#isChangingState = false; 27 | this.#changingStateQueue = []; 28 | this.#currentState = undefined; 29 | this.#states = new Map(); 30 | } 31 | 32 | /** @type {string | undefined} */ 33 | get currentStateName() { 34 | return this.#currentState?.name; 35 | } 36 | 37 | /** 38 | * Used for processing any queued states and is meant to be called during every step of our game loop. 39 | * @returns {void} 40 | */ 41 | public update(): void { 42 | const queuedState = this.#changingStateQueue.shift(); 43 | if (queuedState !== undefined) { 44 | this.setState(queuedState.state, queuedState.args); 45 | } 46 | 47 | if (this.#currentState && this.#currentState.onUpdate) { 48 | this.#currentState.onUpdate(); 49 | } 50 | } 51 | 52 | /** 53 | * Updates the current state of the state machine to the provided state name. If the state machine 54 | * is already transitioning states, or if there is a queue of states, the new state will be added 55 | * to that queue and processed after the queue is processed. 56 | * @param {string} name 57 | * @returns {void} 58 | */ 59 | public setState(name: string, ...args: unknown[]): void { 60 | const methodName = 'setState'; 61 | 62 | if (!this.#states.has(name)) { 63 | console.warn(`[${StateMachine.name}-${this.#id}:${methodName}] tried to change to unknown state: ${name}`); 64 | return; 65 | } 66 | 67 | if (this.#isCurrentState(name)) { 68 | return; 69 | } 70 | 71 | if (this.#isChangingState) { 72 | this.#changingStateQueue.push({ state: name, args }); 73 | return; 74 | } 75 | 76 | this.#isChangingState = true; 77 | this.#log(methodName, `change from ${this.#currentState?.name ?? 'none'} to ${name}`); 78 | 79 | this.#currentState = this.#states.get(name) as State; 80 | 81 | if (this.#currentState.onEnter) { 82 | this.#log(methodName, `${this.#currentState.name} on enter invoked`); 83 | this.#currentState.onEnter(args); 84 | } 85 | 86 | this.#isChangingState = false; 87 | } 88 | 89 | /** 90 | * Adds a new state to the current state machine instance. If a state already exists with the given name 91 | * that previous state will be replaced with the new state that was provided. 92 | * @param {State} state 93 | * @returns {void} 94 | */ 95 | public addState(state: State): void { 96 | state.stateMachine = this; 97 | this.#states.set(state.name, state); 98 | } 99 | 100 | /** 101 | * Checks to see if the provided state name is the state that is currently being handled by the state machine instance. 102 | * @param {string} name 103 | * @returns {boolean} 104 | */ 105 | #isCurrentState(name: string): boolean { 106 | if (!this.#currentState) { 107 | return false; 108 | } 109 | return this.#currentState.name === name; 110 | } 111 | 112 | #log(methodName: string, message: string) { 113 | if (!ENABLE_LOGGING) { 114 | return; 115 | } 116 | console.log(`[${StateMachine.name}-${this.#id}:${methodName}] ${message}`); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/components/state-machine/states/character/attack-state.ts: -------------------------------------------------------------------------------- 1 | import { DIRECTION } from '../../../../common/common'; 2 | import { exhaustiveGuard } from '../../../../common/utils'; 3 | import { CharacterGameObject } from '../../../../game-objects/common/character-game-object'; 4 | import { WeaponComponent } from '../../../game-object/weapon-component'; 5 | import { BaseCharacterState } from './base-character-state'; 6 | import { CHARACTER_STATES } from './character-states'; 7 | 8 | export class AttackState extends BaseCharacterState { 9 | constructor(gameObject: CharacterGameObject) { 10 | super(CHARACTER_STATES.ATTACK_STATE, gameObject); 11 | } 12 | 13 | public onEnter(): void { 14 | // reset game object velocity 15 | this._resetObjectVelocity(); 16 | 17 | const weaponComponent = WeaponComponent.getComponent(this._gameObject); 18 | if (weaponComponent === undefined || weaponComponent.weapon === undefined) { 19 | this._stateMachine.setState(CHARACTER_STATES.IDLE_STATE); 20 | return; 21 | } 22 | 23 | const weapon = weaponComponent.weapon; 24 | switch (this._gameObject.direction) { 25 | case DIRECTION.UP: 26 | return weapon.attackUp(); 27 | case DIRECTION.DOWN: 28 | return weapon.attackDown(); 29 | case DIRECTION.LEFT: 30 | return weapon.attackLeft(); 31 | case DIRECTION.RIGHT: 32 | return weapon.attackRight(); 33 | default: 34 | exhaustiveGuard(this._gameObject.direction); 35 | } 36 | } 37 | 38 | public onUpdate(): void { 39 | const weaponComponent = WeaponComponent.getComponent(this._gameObject); 40 | if (weaponComponent === undefined || weaponComponent.weapon === undefined) { 41 | this._stateMachine.setState(CHARACTER_STATES.IDLE_STATE); 42 | return; 43 | } 44 | // wait until weapon animation is done for attacking 45 | const weapon = weaponComponent.weapon; 46 | if (weapon.isAttacking) { 47 | return; 48 | } 49 | this._stateMachine.setState(CHARACTER_STATES.IDLE_STATE); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/components/state-machine/states/character/base-character-state.ts: -------------------------------------------------------------------------------- 1 | import { isArcadePhysicsBody } from '../../../../common/utils'; 2 | import { CharacterGameObject } from '../../../../game-objects/common/character-game-object'; 3 | import { State, StateMachine } from '../../state-machine'; 4 | 5 | export abstract class BaseCharacterState implements State { 6 | protected _gameObject: CharacterGameObject; 7 | protected _stateMachine!: StateMachine; 8 | #name: string; 9 | 10 | constructor(name: string, gameObject: CharacterGameObject) { 11 | this._gameObject = gameObject; 12 | this.#name = name; 13 | } 14 | 15 | get name(): string { 16 | return this.#name; 17 | } 18 | 19 | set stateMachine(stateMachine: StateMachine) { 20 | this._stateMachine = stateMachine; 21 | } 22 | 23 | protected _resetObjectVelocity(): void { 24 | if (!isArcadePhysicsBody(this._gameObject.body)) { 25 | return; 26 | } 27 | this._gameObject.body.velocity.x = 0; 28 | this._gameObject.body.velocity.y = 0; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/components/state-machine/states/character/base-move-state.ts: -------------------------------------------------------------------------------- 1 | import { DIRECTION } from '../../../../common/common'; 2 | import { Direction } from '../../../../common/types'; 3 | import { isArcadePhysicsBody } from '../../../../common/utils'; 4 | import { CharacterGameObject } from '../../../../game-objects/common/character-game-object'; 5 | import { InputComponent } from '../../../input/input-component'; 6 | import { BaseCharacterState } from './base-character-state'; 7 | 8 | export abstract class BaseMoveState extends BaseCharacterState { 9 | protected _moveAnimationPrefix: 'WALK' | 'WALK_HOLD'; 10 | 11 | constructor(stateName: string, gameObject: CharacterGameObject, moveAnimationPrefix: 'WALK' | 'WALK_HOLD') { 12 | super(stateName, gameObject); 13 | this._moveAnimationPrefix = moveAnimationPrefix; 14 | } 15 | 16 | protected isNoInputMovement(controls: InputComponent): boolean { 17 | return ( 18 | (!controls.isDownDown && !controls.isUpDown && !controls.isLeftDown && !controls.isRightDown) || 19 | controls.isMovementLocked 20 | ); 21 | } 22 | 23 | protected handleCharacterMovement(): void { 24 | const controls = this._gameObject.controls; 25 | 26 | // vertical movement 27 | if (controls.isUpDown) { 28 | this.updateVelocity(false, -1); 29 | this.updateDirection(DIRECTION.UP); 30 | } else if (controls.isDownDown) { 31 | this.updateVelocity(false, 1); 32 | this.updateDirection(DIRECTION.DOWN); 33 | } else { 34 | this.updateVelocity(false, 0); 35 | } 36 | 37 | const isMovingVertically = controls.isDownDown || controls.isUpDown; 38 | // horizontal movement 39 | if (controls.isLeftDown) { 40 | this.flip(true); 41 | this.updateVelocity(true, -1); 42 | if (!isMovingVertically) { 43 | this.updateDirection(DIRECTION.LEFT); 44 | } 45 | } else if (controls.isRightDown) { 46 | this.flip(false); 47 | this.updateVelocity(true, 1); 48 | if (!isMovingVertically) { 49 | this.updateDirection(DIRECTION.RIGHT); 50 | } 51 | } else { 52 | this.updateVelocity(true, 0); 53 | } 54 | 55 | this.normalizeVelocity(); 56 | } 57 | 58 | protected normalizeVelocity(): void { 59 | // if the player is moving diagonally, the resultant vector will have a magnitude greater than the defined speed. 60 | // if we normalize the vector, this will make sure the magnitude matches defined speed 61 | if (!isArcadePhysicsBody(this._gameObject.body)) { 62 | return; 63 | } 64 | this._gameObject.body.velocity.normalize().scale(this._gameObject.speed); 65 | } 66 | 67 | protected flip(value: boolean): void { 68 | this._gameObject.setFlipX(value); 69 | } 70 | 71 | protected updateVelocity(isX: boolean, value: number): void { 72 | if (!isArcadePhysicsBody(this._gameObject.body)) { 73 | return; 74 | } 75 | if (isX) { 76 | this._gameObject.body.velocity.x = value; 77 | return; 78 | } 79 | this._gameObject.body.velocity.y = value; 80 | } 81 | 82 | protected updateDirection(direction: Direction): void { 83 | this._gameObject.direction = direction; 84 | this._gameObject.animationComponent.playAnimation(`${this._moveAnimationPrefix}_${this._gameObject.direction}`); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/components/state-machine/states/character/boss/drow/boss-drow-hidden-state.ts: -------------------------------------------------------------------------------- 1 | import { CharacterGameObject } from '../../../../../../game-objects/common/character-game-object'; 2 | import { BaseCharacterState } from '../../base-character-state'; 3 | import { CHARACTER_STATES } from '../../character-states'; 4 | import { ENEMY_BOSS_HIDDEN_STATE_DURATION } from '../../../../../../common/config'; 5 | 6 | export class BossDrowHiddenState extends BaseCharacterState { 7 | constructor(gameObject: CharacterGameObject) { 8 | super(CHARACTER_STATES.HIDDEN_STATE, gameObject); 9 | } 10 | 11 | public onEnter(): void { 12 | this._gameObject.disableObject(); 13 | 14 | // wait a brief period of time before showing game object and then transition to teleport 15 | this._gameObject.scene.time.delayedCall(ENEMY_BOSS_HIDDEN_STATE_DURATION, () => { 16 | this._gameObject.enableObject(); 17 | this._stateMachine.setState(CHARACTER_STATES.TELEPORT_STATE); 18 | }); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/components/state-machine/states/character/boss/drow/boss-drow-idle-state.ts: -------------------------------------------------------------------------------- 1 | import { CharacterGameObject } from '../../../../../../game-objects/common/character-game-object'; 2 | import { BaseCharacterState } from '../../base-character-state'; 3 | import { CHARACTER_STATES } from '../../character-states'; 4 | import { ENEMY_BOSS_IDLE_STATE_DURATION } from '../../../../../../common/config'; 5 | 6 | export class BossDrowIdleState extends BaseCharacterState { 7 | constructor(gameObject: CharacterGameObject) { 8 | super(CHARACTER_STATES.IDLE_STATE, gameObject); 9 | } 10 | 11 | public onEnter(): void { 12 | // play idle animation based on game object direction 13 | this._gameObject.animationComponent.playAnimation(`IDLE_${this._gameObject.direction}`); 14 | 15 | // wait a brief period of time before showing game object and then transition to teleport 16 | this._gameObject.scene.time.delayedCall(ENEMY_BOSS_IDLE_STATE_DURATION, () => { 17 | if (this._stateMachine.currentStateName === CHARACTER_STATES.IDLE_STATE) { 18 | this._stateMachine.setState(CHARACTER_STATES.TELEPORT_STATE); 19 | } 20 | }); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/components/state-machine/states/character/boss/drow/boss-drow-prepare-attack-state.ts: -------------------------------------------------------------------------------- 1 | import * as Phaser from 'phaser'; 2 | import { DIRECTION } from '../../../../../../common/common'; 3 | import { CharacterGameObject } from '../../../../../../game-objects/common/character-game-object'; 4 | import { GameScene } from '../../../../../../scenes/game-scene'; 5 | import { BaseCharacterState } from '../../base-character-state'; 6 | import { CHARACTER_STATES } from '../../character-states'; 7 | import { ENEMY_BOSS_PREPARE_ATTACK_STATE_FINISHED_DELAY } from '../../../../../../common/config'; 8 | 9 | export class BossDrowPrepareAttackState extends BaseCharacterState { 10 | constructor(gameObject: CharacterGameObject) { 11 | super(CHARACTER_STATES.PREPARE_ATTACK_STATE, gameObject); 12 | } 13 | 14 | public onEnter(): void { 15 | const targetEnemy = (this._gameObject.scene as GameScene).player; 16 | 17 | const vec = new Phaser.Math.Vector2(targetEnemy.x - this._gameObject.x, targetEnemy.y - this._gameObject.y); 18 | const radians = vec.angle(); 19 | const degrees = Phaser.Math.RadToDeg(radians); 20 | 21 | this._gameObject.setFlipX(false); 22 | if (degrees >= 45 && degrees < 135) { 23 | this._gameObject.direction = DIRECTION.DOWN; 24 | } else if (degrees >= 135 && degrees < 225) { 25 | this._gameObject.setFlipX(true); 26 | this._gameObject.direction = DIRECTION.LEFT; 27 | } else if (degrees >= 225 && degrees < 315) { 28 | this._gameObject.direction = DIRECTION.UP; 29 | } else { 30 | this._gameObject.direction = DIRECTION.RIGHT; 31 | } 32 | 33 | if (this._gameObject.direction === DIRECTION.DOWN || this._gameObject.direction === DIRECTION.UP) { 34 | this._gameObject.setX(targetEnemy.x); 35 | } else { 36 | this._gameObject.setY(targetEnemy.y); 37 | } 38 | 39 | this._gameObject.animationComponent.playAnimation(`IDLE_${this._gameObject.direction}`); 40 | this._gameObject.scene.time.delayedCall(ENEMY_BOSS_PREPARE_ATTACK_STATE_FINISHED_DELAY, () => { 41 | this._stateMachine.setState(CHARACTER_STATES.ATTACK_STATE); 42 | }); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/components/state-machine/states/character/boss/drow/boss-drow-teleport-state.ts: -------------------------------------------------------------------------------- 1 | import * as Phaser from 'phaser'; 2 | import { CharacterGameObject } from '../../../../../../game-objects/common/character-game-object'; 3 | import { BaseCharacterState } from '../../base-character-state'; 4 | import { CHARACTER_STATES } from '../../character-states'; 5 | import { 6 | ENEMY_BOSS_TELEPORT_STATE_FINISHED_DELAY, 7 | ENEMY_BOSS_TELEPORT_STATE_INITIAL_DELAY, 8 | } from '../../../../../../common/config'; 9 | import { DIRECTION } from '../../../../../../common/common'; 10 | 11 | export class BossDrowTeleportState extends BaseCharacterState { 12 | #possibleTeleportLocations: Phaser.Math.Vector2[]; 13 | 14 | constructor(gameObject: CharacterGameObject, possibleTeleportLocations: Phaser.Math.Vector2[]) { 15 | super(CHARACTER_STATES.TELEPORT_STATE, gameObject); 16 | this.#possibleTeleportLocations = possibleTeleportLocations; 17 | } 18 | 19 | public onEnter(): void { 20 | this._gameObject.invulnerableComponent.invulnerable = true; 21 | 22 | const timeEvent = this._gameObject.scene.time.addEvent({ 23 | delay: ENEMY_BOSS_TELEPORT_STATE_INITIAL_DELAY, 24 | callback: () => { 25 | if (timeEvent.getOverallProgress() === 1) { 26 | this.#handleTeleportFinished(); 27 | return; 28 | } 29 | this._gameObject.direction = DIRECTION.DOWN; 30 | this._gameObject.animationComponent.playAnimation(`IDLE_${this._gameObject.direction}`); 31 | const location = 32 | this.#possibleTeleportLocations[timeEvent.repeatCount % this.#possibleTeleportLocations.length]; 33 | this._gameObject.setPosition(location.x, location.y); 34 | }, 35 | callbackScope: this, 36 | repeat: this.#possibleTeleportLocations.length * 3 - 1, 37 | }); 38 | } 39 | 40 | #handleTeleportFinished(): void { 41 | this._gameObject.visible = false; 42 | this._gameObject.scene.time.delayedCall(ENEMY_BOSS_TELEPORT_STATE_FINISHED_DELAY, () => { 43 | const randomLocation = Phaser.Utils.Array.GetRandom(this.#possibleTeleportLocations); 44 | this._gameObject.setPosition(randomLocation.x, randomLocation.y); 45 | this._gameObject.visible = true; 46 | this._gameObject.invulnerableComponent.invulnerable = false; 47 | this._stateMachine.setState(CHARACTER_STATES.PREPARE_ATTACK_STATE); 48 | }); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/components/state-machine/states/character/bounce-move-state.ts: -------------------------------------------------------------------------------- 1 | import * as Phaser from 'phaser'; 2 | import { BaseCharacterState } from './base-character-state'; 3 | import { CHARACTER_STATES } from './character-states'; 4 | import { CharacterGameObject } from '../../../../game-objects/common/character-game-object'; 5 | 6 | export class BounceMoveState extends BaseCharacterState { 7 | constructor(gameObject: CharacterGameObject) { 8 | super(CHARACTER_STATES.BOUNCE_MOVE_STATE, gameObject); 9 | } 10 | 11 | public onEnter(): void { 12 | // play idle animation based on game object direction 13 | this._gameObject.animationComponent.playAnimation(`IDLE_${this._gameObject.direction}`); 14 | 15 | // pick a random direction to start moving towards 16 | const speed = this._gameObject.speed; 17 | const randomDirection = Phaser.Math.Between(0, 3); 18 | if (randomDirection === 0) { 19 | this._gameObject.setVelocity(speed, speed * -1); 20 | } else if (randomDirection === 1) { 21 | this._gameObject.setVelocity(speed, speed); 22 | } else if (randomDirection === 2) { 23 | this._gameObject.setVelocity(speed * -1, speed); 24 | } else { 25 | this._gameObject.setVelocity(speed * -1, speed * -1); 26 | } 27 | this._gameObject.setBounce(1); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/components/state-machine/states/character/character-states.ts: -------------------------------------------------------------------------------- 1 | export const CHARACTER_STATES = { 2 | IDLE_STATE: 'IDLE_STATE', 3 | MOVE_STATE: 'MOVE_STATE', 4 | BOUNCE_MOVE_STATE: 'BOUNCE_MOVE_STATE', 5 | HURT_STATE: 'HURT_STATE', 6 | DEATH_STATE: 'DEATH_STATE', 7 | LIFT_STATE: 'LIFT_STATE', 8 | OPEN_CHEST_STATE: 'OPEN_CHEST_STATE', 9 | IDLE_HOLDING_STATE: 'IDLE_HOLDING_STATE', 10 | MOVE_HOLDING_STATE: 'MOVE_HOLDING_STATE', 11 | THROW_STATE: 'THROW_STATE', 12 | ATTACK_STATE: 'ATTACK_STATE', 13 | HIDDEN_STATE: 'HIDDEN_STATE', 14 | TELEPORT_STATE: 'TELEPORT_STATE', 15 | PREPARE_ATTACK_STATE: 'PREPARE_ATTACK_STATE', 16 | } as const; 17 | -------------------------------------------------------------------------------- /src/components/state-machine/states/character/death-state.ts: -------------------------------------------------------------------------------- 1 | import { CharacterGameObject } from '../../../../game-objects/common/character-game-object'; 2 | import { CHARACTER_ANIMATIONS } from '../../../../common/assets'; 3 | import { BaseCharacterState } from './base-character-state'; 4 | import { CHARACTER_STATES } from './character-states'; 5 | import { HeldGameObjectComponent } from '../../../game-object/held-game-object-component'; 6 | import { ThrowableObjectComponent } from '../../../game-object/throwable-object-component'; 7 | import { CUSTOM_EVENTS, EVENT_BUS } from '../../../../common/event-bus'; 8 | 9 | export class DeathState extends BaseCharacterState { 10 | #onDieCallback: () => void; 11 | 12 | constructor(gameObject: CharacterGameObject, onDieCallback: () => void = () => undefined) { 13 | super(CHARACTER_STATES.DEATH_STATE, gameObject); 14 | this.#onDieCallback = onDieCallback; 15 | } 16 | 17 | public onEnter(): void { 18 | // reset game object velocity 19 | this._resetObjectVelocity(); 20 | 21 | // drop held object 22 | const heldComponent = HeldGameObjectComponent.getComponent(this._gameObject); 23 | if (heldComponent !== undefined && heldComponent.object !== undefined) { 24 | const throwObjectComponent = ThrowableObjectComponent.getComponent( 25 | heldComponent.object, 26 | ); 27 | if (throwObjectComponent !== undefined) { 28 | throwObjectComponent.drop(); 29 | } 30 | heldComponent.drop(); 31 | } 32 | 33 | // make character invulnerable after taking a hit 34 | this._gameObject.invulnerableComponent.invulnerable = true; 35 | 36 | // disable body on game object so we stop triggering the collision 37 | (this._gameObject.body as Phaser.Physics.Arcade.Body).enable = false; 38 | 39 | // play animation for character dying 40 | this._gameObject.animationComponent.playAnimation(CHARACTER_ANIMATIONS.DIE_DOWN, () => { 41 | this.#triggerDefeatedEvent(); 42 | }); 43 | } 44 | 45 | #triggerDefeatedEvent(): void { 46 | this._gameObject.disableObject(); 47 | if (this._gameObject.isEnemy) { 48 | EVENT_BUS.emit(CUSTOM_EVENTS.ENEMY_DESTROYED); 49 | } else { 50 | EVENT_BUS.emit(CUSTOM_EVENTS.PLAYER_DEFEATED); 51 | } 52 | this.#onDieCallback(); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/components/state-machine/states/character/hurt-state.ts: -------------------------------------------------------------------------------- 1 | import { CharacterGameObject } from '../../../../game-objects/common/character-game-object'; 2 | import { DIRECTION } from '../../../../common/common'; 3 | import { BaseCharacterState } from './base-character-state'; 4 | import { CHARACTER_STATES } from './character-states'; 5 | import { exhaustiveGuard, isArcadePhysicsBody } from '../../../../common/utils'; 6 | import { Direction } from '../../../../common/types'; 7 | import { HURT_PUSH_BACK_DELAY } from '../../../../common/config'; 8 | import { CHARACTER_ANIMATIONS } from '../../../../common/assets'; 9 | import { HeldGameObjectComponent } from '../../../game-object/held-game-object-component'; 10 | import { ThrowableObjectComponent } from '../../../game-object/throwable-object-component'; 11 | 12 | export class HurtState extends BaseCharacterState { 13 | #hurtPushBackSpeed: number; 14 | #onHurtCallback: () => void; 15 | #nextState: string; 16 | 17 | constructor( 18 | gameObject: CharacterGameObject, 19 | hurtPushBackSpeed: number, 20 | onHurtCallback: () => void = () => undefined, 21 | nextState: string = CHARACTER_STATES.IDLE_STATE, 22 | ) { 23 | super(CHARACTER_STATES.HURT_STATE, gameObject); 24 | this.#hurtPushBackSpeed = hurtPushBackSpeed; 25 | this.#onHurtCallback = onHurtCallback; 26 | this.#nextState = nextState; 27 | } 28 | 29 | public onEnter(args: unknown[]): void { 30 | const attackDirection = args[0] as Direction; 31 | 32 | // reset game object velocity 33 | this._resetObjectVelocity(); 34 | 35 | const heldComponent = HeldGameObjectComponent.getComponent(this._gameObject); 36 | if (heldComponent !== undefined && heldComponent.object !== undefined) { 37 | const throwObjectComponent = ThrowableObjectComponent.getComponent( 38 | heldComponent.object, 39 | ); 40 | if (throwObjectComponent !== undefined) { 41 | throwObjectComponent.drop(); 42 | } 43 | heldComponent.drop(); 44 | } 45 | 46 | if (isArcadePhysicsBody(this._gameObject.body)) { 47 | const body = this._gameObject.body; 48 | 49 | switch (attackDirection) { 50 | case DIRECTION.DOWN: 51 | body.velocity.y = this.#hurtPushBackSpeed; 52 | break; 53 | case DIRECTION.UP: 54 | body.velocity.y = this.#hurtPushBackSpeed * -1; 55 | break; 56 | case DIRECTION.LEFT: 57 | body.velocity.x = this.#hurtPushBackSpeed * -1; 58 | break; 59 | case DIRECTION.RIGHT: 60 | body.velocity.x = this.#hurtPushBackSpeed; 61 | break; 62 | default: 63 | exhaustiveGuard(attackDirection); 64 | } 65 | 66 | // wait a certain amount of time before resetting velocity to stop the push back 67 | this._gameObject.scene.time.delayedCall(HURT_PUSH_BACK_DELAY, () => { 68 | this._resetObjectVelocity(); 69 | }); 70 | } 71 | 72 | // make character invulnerable after taking a hit 73 | this._gameObject.invulnerableComponent.invulnerable = true; 74 | // call callback so we can do custom animations if provided 75 | this.#onHurtCallback(); 76 | 77 | // play animation for character being hurt 78 | this._gameObject.animationComponent.playAnimation(CHARACTER_ANIMATIONS.HURT_DOWN, () => { 79 | this.#transition(); 80 | }); 81 | } 82 | 83 | #transition(): void { 84 | // wait set amount of time before making character vulnerable again 85 | this._gameObject.scene.time.delayedCall( 86 | this._gameObject.invulnerableComponent.invulnerableAfterHitAnimationDuration, 87 | () => { 88 | this._gameObject.invulnerableComponent.invulnerable = false; 89 | }, 90 | ); 91 | this._stateMachine.setState(this.#nextState); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/components/state-machine/states/character/idle-holding-state.ts: -------------------------------------------------------------------------------- 1 | import { CharacterGameObject } from '../../../../game-objects/common/character-game-object'; 2 | import { BaseCharacterState } from './base-character-state'; 3 | import { CHARACTER_STATES } from './character-states'; 4 | 5 | export class IdleHoldingState extends BaseCharacterState { 6 | constructor(gameObject: CharacterGameObject) { 7 | super(CHARACTER_STATES.IDLE_HOLDING_STATE, gameObject); 8 | } 9 | 10 | public onEnter(): void { 11 | // play idle animation based on game object direction 12 | this._gameObject.animationComponent.playAnimation(`IDLE_HOLD_${this._gameObject.direction}`); 13 | 14 | // reset game object velocity 15 | this._resetObjectVelocity(); 16 | } 17 | 18 | public onUpdate(): void { 19 | const controls = this._gameObject.controls; 20 | 21 | // if action key was pressed, throw item 22 | if (controls.isActionKeyJustDown) { 23 | this._stateMachine.setState(CHARACTER_STATES.THROW_STATE); 24 | return; 25 | } 26 | 27 | // if no other input is provided, do nothing 28 | if (!controls.isDownDown && !controls.isUpDown && !controls.isLeftDown && !controls.isRightDown) { 29 | return; 30 | } 31 | 32 | this._stateMachine.setState(CHARACTER_STATES.MOVE_HOLDING_STATE); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/components/state-machine/states/character/idle-state.ts: -------------------------------------------------------------------------------- 1 | import { BaseCharacterState } from './base-character-state'; 2 | import { CHARACTER_STATES } from './character-states'; 3 | import { CharacterGameObject } from '../../../../game-objects/common/character-game-object'; 4 | import { HeldGameObjectComponent } from '../../../game-object/held-game-object-component'; 5 | import { ThrowableObjectComponent } from '../../../game-object/throwable-object-component'; 6 | 7 | export class IdleState extends BaseCharacterState { 8 | constructor(gameObject: CharacterGameObject) { 9 | super(CHARACTER_STATES.IDLE_STATE, gameObject); 10 | } 11 | 12 | public onEnter(): void { 13 | // play idle animation based on game object direction 14 | this._gameObject.animationComponent.playAnimation(`IDLE_${this._gameObject.direction}`); 15 | 16 | // reset game object velocity 17 | this._resetObjectVelocity(); 18 | 19 | const heldComponent = HeldGameObjectComponent.getComponent(this._gameObject); 20 | if (heldComponent !== undefined && heldComponent.object !== undefined) { 21 | const throwObjectComponent = ThrowableObjectComponent.getComponent( 22 | heldComponent.object, 23 | ); 24 | if (throwObjectComponent !== undefined) { 25 | throwObjectComponent.drop(); 26 | } 27 | heldComponent.drop(); 28 | } 29 | } 30 | 31 | public onUpdate(): void { 32 | const controls = this._gameObject.controls; 33 | 34 | if (controls.isMovementLocked) { 35 | return; 36 | } 37 | 38 | // if attack key was pressed, attack with weapon 39 | if (controls.isAttackKeyJustDown) { 40 | this._stateMachine.setState(CHARACTER_STATES.ATTACK_STATE); 41 | return; 42 | } 43 | 44 | // if no other input is provided, do nothing 45 | if (!controls.isDownDown && !controls.isUpDown && !controls.isLeftDown && !controls.isRightDown) { 46 | return; 47 | } 48 | 49 | this._stateMachine.setState(CHARACTER_STATES.MOVE_STATE); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/components/state-machine/states/character/lift-state.ts: -------------------------------------------------------------------------------- 1 | import * as Phaser from 'phaser'; 2 | import { BaseCharacterState } from './base-character-state'; 3 | import { CHARACTER_STATES } from './character-states'; 4 | import { CharacterGameObject } from '../../../../game-objects/common/character-game-object'; 5 | import { isArcadePhysicsBody } from '../../../../common/utils'; 6 | import { HeldGameObjectComponent } from '../../../game-object/held-game-object-component'; 7 | import { GameObject } from '../../../../common/types'; 8 | import { 9 | LIFT_ITEM_ANIMATION_DELAY, 10 | LIFT_ITEM_ANIMATION_DURATION, 11 | LIFT_ITEM_ANIMATION_ENABLE_DEBUGGING, 12 | } from '../../../../common/config'; 13 | 14 | export class LiftState extends BaseCharacterState { 15 | constructor(gameObject: CharacterGameObject) { 16 | super(CHARACTER_STATES.LIFT_STATE, gameObject); 17 | } 18 | 19 | public onEnter(args: unknown[]): void { 20 | const gameObjectBeingPickedUp = args[0] as GameObject; 21 | 22 | // reset game object velocity 23 | this._resetObjectVelocity(); 24 | 25 | const heldComponent = HeldGameObjectComponent.getComponent(this._gameObject); 26 | if (heldComponent === undefined) { 27 | this._stateMachine.setState(CHARACTER_STATES.IDLE_STATE); 28 | return; 29 | } 30 | 31 | // play lift animation and then transition to hold item state 32 | this._gameObject.animationComponent.playAnimation(`LIFT_${this._gameObject.direction}`); 33 | 34 | // store a reference to the lifted up game object 35 | heldComponent.object = gameObjectBeingPickedUp; 36 | 37 | // disable body on the lifted up game object 38 | if (isArcadePhysicsBody(gameObjectBeingPickedUp.body)) { 39 | gameObjectBeingPickedUp.body.enable = false; 40 | } 41 | 42 | // have character carry the object 43 | gameObjectBeingPickedUp.setDepth(2).setOrigin(0.5, 0.5); 44 | // create curved path for ball to follow 45 | const startPoint = new Phaser.Math.Vector2(gameObjectBeingPickedUp.x + 8, gameObjectBeingPickedUp.y - 8); 46 | const controlPoint1 = new Phaser.Math.Vector2(gameObjectBeingPickedUp.x + 8, gameObjectBeingPickedUp.y - 24); 47 | const controlPoint2 = new Phaser.Math.Vector2(gameObjectBeingPickedUp.x + 8, gameObjectBeingPickedUp.y - 24); 48 | const endPoint = new Phaser.Math.Vector2(this._gameObject.x, this._gameObject.y - 8); 49 | const curve = new Phaser.Curves.CubicBezier(startPoint, controlPoint1, controlPoint2, endPoint); 50 | const curvePath = new Phaser.Curves.Path(startPoint.x, startPoint.y).add(curve); 51 | 52 | // draw curve (for debugging) 53 | let g: Phaser.GameObjects.Graphics | undefined; 54 | if (LIFT_ITEM_ANIMATION_ENABLE_DEBUGGING) { 55 | g = this._gameObject.scene.add.graphics(); 56 | g.clear(); 57 | g.lineStyle(4, 0x00ff00, 1); 58 | curvePath.draw(g); 59 | } 60 | gameObjectBeingPickedUp.setAlpha(0); 61 | 62 | const follower = this._gameObject.scene.add 63 | .follower(curvePath, startPoint.x, startPoint.y, gameObjectBeingPickedUp.texture) 64 | .setAlpha(1); 65 | follower.startFollow({ 66 | delay: LIFT_ITEM_ANIMATION_DELAY, 67 | duration: LIFT_ITEM_ANIMATION_DURATION, 68 | onComplete: () => { 69 | follower.destroy(); 70 | if (g !== undefined) { 71 | g.destroy(); 72 | } 73 | gameObjectBeingPickedUp.setPosition(follower.x, follower.y).setAlpha(1); 74 | }, 75 | }); 76 | } 77 | 78 | public onUpdate(): void { 79 | if (this._gameObject.animationComponent.isAnimationPlaying()) { 80 | return; 81 | } 82 | 83 | this._stateMachine.setState(CHARACTER_STATES.IDLE_HOLDING_STATE); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/components/state-machine/states/character/move-holding-state.ts: -------------------------------------------------------------------------------- 1 | import { DIRECTION } from '../../../../common/common'; 2 | import { CharacterGameObject } from '../../../../game-objects/common/character-game-object'; 3 | import { HeldGameObjectComponent } from '../../../game-object/held-game-object-component'; 4 | import { BaseMoveState } from './base-move-state'; 5 | import { CHARACTER_STATES } from './character-states'; 6 | 7 | export class MoveHoldingState extends BaseMoveState { 8 | constructor(gameObject: CharacterGameObject) { 9 | super(CHARACTER_STATES.MOVE_HOLDING_STATE, gameObject, 'WALK_HOLD'); 10 | } 11 | 12 | public onUpdate(): void { 13 | const controls = this._gameObject.controls; 14 | 15 | // if action key was pressed, throw item 16 | if (controls.isActionKeyJustDown) { 17 | this._stateMachine.setState(CHARACTER_STATES.THROW_STATE); 18 | return; 19 | } 20 | 21 | // if no input is provided transition back to idle state 22 | if (this.isNoInputMovement(controls)) { 23 | this._stateMachine.setState(CHARACTER_STATES.IDLE_HOLDING_STATE); 24 | return; 25 | } 26 | 27 | // handle character movement 28 | this.handleCharacterMovement(); 29 | 30 | const heldComponent = HeldGameObjectComponent.getComponent(this._gameObject); 31 | if (heldComponent === undefined || heldComponent.object === undefined) { 32 | this._stateMachine.setState(CHARACTER_STATES.IDLE_STATE); 33 | return; 34 | } 35 | 36 | if (this._gameObject.direction === DIRECTION.DOWN) { 37 | heldComponent.object.setPosition(this._gameObject.x + 1, this._gameObject.y - 2); 38 | } else if (this._gameObject.direction === DIRECTION.UP) { 39 | heldComponent.object.setPosition(this._gameObject.x + 1, this._gameObject.y - 6); 40 | } else { 41 | heldComponent.object.setPosition(this._gameObject.x, this._gameObject.y - 8); 42 | } 43 | if (this._gameObject.flipX) { 44 | heldComponent.object.setX(this._gameObject.x); 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/components/state-machine/states/character/move-state.ts: -------------------------------------------------------------------------------- 1 | import { INTERACTIVE_OBJECT_TYPE } from '../../../../common/common'; 2 | import { CHARACTER_STATES } from './character-states'; 3 | import { exhaustiveGuard } from '../../../../common/utils'; 4 | import { CharacterGameObject } from '../../../../game-objects/common/character-game-object'; 5 | import { CollidingObjectsComponent } from '../../../game-object/colliding-objects-component'; 6 | import { InteractiveObjectComponent } from '../../../game-object/interactive-object-component'; 7 | import { InputComponent } from '../../../input/input-component'; 8 | import { BaseMoveState } from './base-move-state'; 9 | 10 | export class MoveState extends BaseMoveState { 11 | constructor(gameObject: CharacterGameObject) { 12 | super(CHARACTER_STATES.MOVE_STATE, gameObject, 'WALK'); 13 | } 14 | 15 | public onUpdate(): void { 16 | const controls = this._gameObject.controls; 17 | 18 | // if attack key was pressed, attack with weapon 19 | if (controls.isAttackKeyJustDown) { 20 | this._stateMachine.setState(CHARACTER_STATES.ATTACK_STATE); 21 | return; 22 | } 23 | 24 | // if no input is provided transition back to idle state 25 | if (this.isNoInputMovement(controls)) { 26 | this._stateMachine.setState(CHARACTER_STATES.IDLE_STATE); 27 | return; 28 | } 29 | 30 | // if we interacted with an object and switched states, stop processing 31 | if (this.#checkIfObjectWasInteractedWith(controls)) { 32 | return; 33 | } 34 | 35 | // handle character movement 36 | this.handleCharacterMovement(); 37 | } 38 | 39 | #checkIfObjectWasInteractedWith(controls: InputComponent): boolean { 40 | const collideComponent = CollidingObjectsComponent.getComponent(this._gameObject); 41 | if (collideComponent === undefined || collideComponent.objects.length === 0) { 42 | return false; 43 | } 44 | 45 | const collisionObject = collideComponent.objects[0]; 46 | const interactiveObjectComponent = 47 | InteractiveObjectComponent.getComponent(collisionObject); 48 | if (interactiveObjectComponent === undefined) { 49 | return false; 50 | } 51 | 52 | if (!controls.isActionKeyJustDown) { 53 | return false; 54 | } 55 | 56 | // check if game object can be interacted with 57 | if (!interactiveObjectComponent.canInteractWith()) { 58 | return false; 59 | } 60 | interactiveObjectComponent.interact(); 61 | 62 | // we can carry this item 63 | if (interactiveObjectComponent.objectType === INTERACTIVE_OBJECT_TYPE.PICKUP) { 64 | this._stateMachine.setState(CHARACTER_STATES.LIFT_STATE, collisionObject); 65 | return true; 66 | } 67 | 68 | // we can open this item 69 | if (interactiveObjectComponent.objectType === INTERACTIVE_OBJECT_TYPE.OPEN) { 70 | this._stateMachine.setState(CHARACTER_STATES.OPEN_CHEST_STATE, collisionObject); 71 | return true; 72 | } 73 | 74 | if (interactiveObjectComponent.objectType === INTERACTIVE_OBJECT_TYPE.AUTO) { 75 | return false; 76 | } 77 | 78 | // we should never hit this code block 79 | exhaustiveGuard(interactiveObjectComponent.objectType); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/components/state-machine/states/character/open-chest-state.ts: -------------------------------------------------------------------------------- 1 | import { BaseCharacterState } from './base-character-state'; 2 | import { CHARACTER_STATES } from './character-states'; 3 | import { CharacterGameObject } from '../../../../game-objects/common/character-game-object'; 4 | import { Chest } from '../../../../game-objects/objects/chest'; 5 | import { CUSTOM_EVENTS, EVENT_BUS } from '../../../../common/event-bus'; 6 | 7 | export class OpenChestState extends BaseCharacterState { 8 | constructor(gameObject: CharacterGameObject) { 9 | super(CHARACTER_STATES.OPEN_CHEST_STATE, gameObject); 10 | } 11 | 12 | onEnter(args: unknown[]): void { 13 | const chest = args[0] as Chest; 14 | 15 | // make character invulnerable so we can collect the item 16 | this._gameObject.invulnerableComponent.invulnerable = true; 17 | 18 | // reset game object velocity 19 | this._resetObjectVelocity(); 20 | 21 | // play lift animation based on game object direction 22 | this._gameObject.animationComponent.playAnimation(`LIFT_${this._gameObject.direction}`, () => { 23 | // emit event data regarding chest 24 | EVENT_BUS.emit(CUSTOM_EVENTS.OPENED_CHEST, chest); 25 | // after showing message to player, transition to idle state 26 | EVENT_BUS.once(CUSTOM_EVENTS.DIALOG_CLOSED, () => { 27 | // make character vulnerable so we can take damage 28 | this._gameObject.invulnerableComponent.invulnerable = false; 29 | this._stateMachine.setState(CHARACTER_STATES.IDLE_STATE); 30 | }); 31 | }); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/components/state-machine/states/character/throw-state.ts: -------------------------------------------------------------------------------- 1 | import { CharacterGameObject } from '../../../../game-objects/common/character-game-object'; 2 | import { HeldGameObjectComponent } from '../../../game-object/held-game-object-component'; 3 | import { ThrowableObjectComponent } from '../../../game-object/throwable-object-component'; 4 | import { BaseCharacterState } from './base-character-state'; 5 | import { CHARACTER_STATES } from './character-states'; 6 | 7 | export class ThrowState extends BaseCharacterState { 8 | constructor(gameObject: CharacterGameObject) { 9 | super(CHARACTER_STATES.THROW_STATE, gameObject); 10 | } 11 | 12 | public onEnter(): void { 13 | // reset game object velocity 14 | this._resetObjectVelocity(); 15 | 16 | // play lift animation to throw items 17 | this._gameObject.animationComponent.playAnimationInReverse(`LIFT_${this._gameObject.direction}`); 18 | 19 | // get item held by character and see if this is a throwable item 20 | const heldComponent = HeldGameObjectComponent.getComponent(this._gameObject); 21 | if (heldComponent === undefined || heldComponent.object === undefined) { 22 | return; 23 | } 24 | const throwObjectComponent = ThrowableObjectComponent.getComponent(heldComponent.object); 25 | if (throwObjectComponent !== undefined) { 26 | throwObjectComponent.throw(this._gameObject.direction); 27 | } 28 | heldComponent.drop(); 29 | } 30 | 31 | public onUpdate(): void { 32 | if (this._gameObject.animationComponent.isAnimationPlaying()) { 33 | return; 34 | } 35 | 36 | this._stateMachine.setState(CHARACTER_STATES.IDLE_STATE); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/game-objects/common/character-game-object.ts: -------------------------------------------------------------------------------- 1 | import * as Phaser from 'phaser'; 2 | import { CustomGameObject, Direction, Position } from '../../common/types'; 3 | import { InputComponent } from '../../components/input/input-component'; 4 | import { ControlsComponent } from '../../components/game-object/controls-component'; 5 | import { StateMachine } from '../../components/state-machine/state-machine'; 6 | import { SpeedComponent } from '../../components/game-object/speed-component'; 7 | import { DirectionComponent } from '../../components/game-object/direction-component'; 8 | import { AnimationComponent, AnimationConfig } from '../../components/game-object/animation-component'; 9 | import { InvulnerableComponent } from '../../components/game-object/invulnerable-component'; 10 | import { CHARACTER_STATES } from '../../components/state-machine/states/character/character-states'; 11 | import { LifeComponent } from '../../components/game-object/life-component'; 12 | import { DataManager } from '../../common/data-manager'; 13 | import { WeaponComponent } from '../../components/game-object/weapon-component'; 14 | 15 | export type CharacterConfig = { 16 | scene: Phaser.Scene; 17 | position: Position; 18 | assetKey: string; 19 | frame?: number; 20 | inputComponent: InputComponent; 21 | animationConfig: AnimationConfig; 22 | speed: number; 23 | id?: string; 24 | isPlayer: boolean; 25 | isInvulnerable?: boolean; 26 | invulnerableAfterHitAnimationDuration?: number; 27 | maxLife: number; 28 | currentLife?: number; 29 | }; 30 | 31 | export abstract class CharacterGameObject extends Phaser.Physics.Arcade.Sprite implements CustomGameObject { 32 | protected _controlsComponent: ControlsComponent; 33 | protected _speedComponent: SpeedComponent; 34 | protected _directionComponent: DirectionComponent; 35 | protected _animationComponent: AnimationComponent; 36 | protected _invulnerableComponent: InvulnerableComponent; 37 | protected _lifeComponent: LifeComponent; 38 | protected _stateMachine: StateMachine; 39 | protected _isPlayer: boolean; 40 | protected _isDefeated: boolean; 41 | 42 | constructor(config: CharacterConfig) { 43 | const { 44 | scene, 45 | position, 46 | assetKey, 47 | frame, 48 | speed, 49 | animationConfig, 50 | inputComponent, 51 | id, 52 | isPlayer, 53 | invulnerableAfterHitAnimationDuration, 54 | isInvulnerable, 55 | maxLife, 56 | currentLife, 57 | } = config; 58 | const { x, y } = position; 59 | super(scene, x, y, assetKey, frame || 0); 60 | 61 | // add object to scene and enable phaser physics 62 | scene.add.existing(this); 63 | scene.physics.add.existing(this); 64 | 65 | // add shared components 66 | this._controlsComponent = new ControlsComponent(this, inputComponent); 67 | this._speedComponent = new SpeedComponent(this, speed); 68 | this._directionComponent = new DirectionComponent(this); 69 | this._animationComponent = new AnimationComponent(this, animationConfig); 70 | this._invulnerableComponent = new InvulnerableComponent( 71 | this, 72 | isInvulnerable || false, 73 | invulnerableAfterHitAnimationDuration, 74 | ); 75 | this._lifeComponent = new LifeComponent(this, maxLife, currentLife); 76 | 77 | // create state machine 78 | this._stateMachine = new StateMachine(id); 79 | 80 | // general config 81 | this._isPlayer = isPlayer; 82 | this._isDefeated = false; 83 | if (!this._isPlayer) { 84 | this.disableObject(); 85 | } 86 | } 87 | 88 | get isDefeated(): boolean { 89 | return this._isDefeated; 90 | } 91 | 92 | get isEnemy(): boolean { 93 | return !this._isPlayer; 94 | } 95 | 96 | get controls(): InputComponent { 97 | return this._controlsComponent.controls; 98 | } 99 | 100 | get speed(): number { 101 | return this._speedComponent.speed; 102 | } 103 | 104 | get direction(): Direction { 105 | return this._directionComponent.direction; 106 | } 107 | 108 | set direction(value: Direction) { 109 | this._directionComponent.direction = value; 110 | } 111 | 112 | get animationComponent(): AnimationComponent { 113 | return this._animationComponent; 114 | } 115 | 116 | get invulnerableComponent(): InvulnerableComponent { 117 | return this._invulnerableComponent; 118 | } 119 | 120 | get stateMachine(): StateMachine { 121 | return this._stateMachine; 122 | } 123 | 124 | public update(): void { 125 | this._stateMachine.update(); 126 | } 127 | 128 | public hit(direction: Direction, damage: number): void { 129 | if (this._isDefeated) { 130 | return; 131 | } 132 | 133 | // check if character is invulnerable, if not update state to be hurt 134 | if (this._invulnerableComponent.invulnerable) { 135 | return; 136 | } 137 | 138 | // have character take damage and see if the character has died 139 | this._lifeComponent.takeDamage(damage); 140 | if (this._isPlayer) { 141 | DataManager.instance.updatePlayerCurrentHealth(this._lifeComponent.life); 142 | } 143 | if (this._lifeComponent.life === 0) { 144 | this._isDefeated = true; 145 | this._stateMachine.setState(CHARACTER_STATES.DEATH_STATE, direction); 146 | return; 147 | } 148 | 149 | this._stateMachine.setState(CHARACTER_STATES.HURT_STATE, direction); 150 | } 151 | 152 | public disableObject(): void { 153 | // disable body on game object so we stop triggering the collision 154 | (this.body as Phaser.Physics.Arcade.Body).enable = false; 155 | 156 | // make not active and not visible until player re-enters room 157 | this.active = false; 158 | if (!this._isPlayer) { 159 | this.visible = false; 160 | } 161 | 162 | const weaponComponent = WeaponComponent.getComponent(this); 163 | if (weaponComponent !== undefined && weaponComponent.weapon !== undefined && weaponComponent.weapon.isAttacking) { 164 | weaponComponent.weapon.onCollisionCallback(); 165 | } 166 | } 167 | 168 | public enableObject(): void { 169 | if (this._isDefeated) { 170 | return; 171 | } 172 | 173 | (this.body as Phaser.Physics.Arcade.Body).enable = true; 174 | this.active = true; 175 | this.visible = true; 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /src/game-objects/enemies/boss/drow.ts: -------------------------------------------------------------------------------- 1 | import * as Phaser from 'phaser'; 2 | import { ASSET_KEYS, DROW_ANIMATION_KEYS } from '../../../common/assets'; 3 | import { 4 | ENEMY_BOSS_DROW_SPEED, 5 | ENEMY_BOSS_DROW_MAX_HEALTH, 6 | BOSS_HURT_PUSH_BACK_DELAY, 7 | ENEMY_BOSS_DROW_DEATH_ANIMATION_DURATION, 8 | ENEMY_BOSS_START_INITIAL_DELAY, 9 | ENEMY_BOSS_ATTACK_DAMAGE, 10 | ENEMY_BOSS_ATTACK_SPEED, 11 | } from '../../../common/config'; 12 | import { Position } from '../../../common/types'; 13 | import { AnimationConfig } from '../../../components/game-object/animation-component'; 14 | import { InputComponent } from '../../../components/input/input-component'; 15 | import { CHARACTER_STATES } from '../../../components/state-machine/states/character/character-states'; 16 | import { CharacterGameObject } from '../../common/character-game-object'; 17 | import { WeaponComponent } from '../../../components/game-object/weapon-component'; 18 | import { HurtState } from '../../../components/state-machine/states/character/hurt-state'; 19 | import { DeathState } from '../../../components/state-machine/states/character/death-state'; 20 | import { flash } from '../../../common/juice-utils'; 21 | import { BossDrowHiddenState } from '../../../components/state-machine/states/character/boss/drow/boss-drow-hidden-state'; 22 | import { BossDrowPrepareAttackState } from '../../../components/state-machine/states/character/boss/drow/boss-drow-prepare-attack-state'; 23 | import { BossDrowTeleportState } from '../../../components/state-machine/states/character/boss/drow/boss-drow-teleport-state'; 24 | import { AttackState } from '../../../components/state-machine/states/character/attack-state'; 25 | import { BossDrowIdleState } from '../../../components/state-machine/states/character/boss/drow/boss-drow-idle-state'; 26 | import { Dagger } from '../../weapons/dagger'; 27 | import { CUSTOM_EVENTS, EVENT_BUS } from '../../../common/event-bus'; 28 | 29 | type DrowConfig = { 30 | scene: Phaser.Scene; 31 | position: Position; 32 | }; 33 | 34 | export class Drow extends CharacterGameObject { 35 | #weaponComponent: WeaponComponent; 36 | 37 | constructor(config: DrowConfig) { 38 | // create animation config for component 39 | const hurtAnimConfig = { key: DROW_ANIMATION_KEYS.HIT, repeat: 0, ignoreIfPlaying: true }; 40 | const animationConfig: AnimationConfig = { 41 | WALK_DOWN: { key: DROW_ANIMATION_KEYS.WALK_DOWN, repeat: -1, ignoreIfPlaying: true }, 42 | WALK_UP: { key: DROW_ANIMATION_KEYS.WALK_UP, repeat: -1, ignoreIfPlaying: true }, 43 | WALK_LEFT: { key: DROW_ANIMATION_KEYS.WALK_LEFT, repeat: -1, ignoreIfPlaying: true }, 44 | WALK_RIGHT: { key: DROW_ANIMATION_KEYS.WALK_RIGHT, repeat: -1, ignoreIfPlaying: true }, 45 | HURT_DOWN: hurtAnimConfig, 46 | HURT_UP: hurtAnimConfig, 47 | HURT_LEFT: hurtAnimConfig, 48 | HURT_RIGHT: hurtAnimConfig, 49 | IDLE_DOWN: { key: DROW_ANIMATION_KEYS.IDLE_DOWN, repeat: -1, ignoreIfPlaying: true }, 50 | IDLE_UP: { key: DROW_ANIMATION_KEYS.IDLE_UP, repeat: -1, ignoreIfPlaying: true }, 51 | IDLE_LEFT: { key: DROW_ANIMATION_KEYS.IDLE_SIDE, repeat: -1, ignoreIfPlaying: true }, 52 | IDLE_RIGHT: { key: DROW_ANIMATION_KEYS.IDLE_SIDE, repeat: -1, ignoreIfPlaying: true }, 53 | }; 54 | 55 | super({ 56 | scene: config.scene, 57 | position: config.position, 58 | assetKey: ASSET_KEYS.DROW, 59 | frame: 0, 60 | id: `drow-${Phaser.Math.RND.uuid()}`, 61 | isPlayer: false, 62 | animationConfig, 63 | speed: ENEMY_BOSS_DROW_SPEED, 64 | inputComponent: new InputComponent(), 65 | isInvulnerable: false, 66 | maxLife: ENEMY_BOSS_DROW_MAX_HEALTH, 67 | }); 68 | 69 | this.#weaponComponent = new WeaponComponent(this); 70 | this.#weaponComponent.weapon = new Dagger( 71 | this, 72 | this.#weaponComponent, 73 | { 74 | DOWN: DROW_ANIMATION_KEYS.ATTACK_DOWN, 75 | UP: DROW_ANIMATION_KEYS.ATTACK_UP, 76 | LEFT: DROW_ANIMATION_KEYS.ATTACK_SIDE, 77 | RIGHT: DROW_ANIMATION_KEYS.ATTACK_SIDE, 78 | }, 79 | ENEMY_BOSS_ATTACK_DAMAGE, 80 | ENEMY_BOSS_ATTACK_SPEED, 81 | ); 82 | 83 | // add state machine 84 | this._stateMachine.addState(new BossDrowIdleState(this)); 85 | this._stateMachine.addState(new BossDrowHiddenState(this)); 86 | this._stateMachine.addState(new BossDrowPrepareAttackState(this)); 87 | this._stateMachine.addState( 88 | new BossDrowTeleportState(this, [ 89 | new Phaser.Math.Vector2(this.scene.scale.width / 2, 80), 90 | new Phaser.Math.Vector2(64, 180), 91 | new Phaser.Math.Vector2(192, 180), 92 | ]), 93 | ); 94 | this._stateMachine.addState(new AttackState(this)); 95 | this._stateMachine.addState( 96 | new HurtState(this, BOSS_HURT_PUSH_BACK_DELAY, undefined, CHARACTER_STATES.TELEPORT_STATE), 97 | ); 98 | this._stateMachine.addState( 99 | new DeathState(this, () => { 100 | this.visible = true; 101 | flash(this, () => { 102 | const fx = this.postFX.addWipe(0.1, 0, 1); 103 | this.scene.add.tween({ 104 | targets: fx, 105 | progress: 1, 106 | duration: ENEMY_BOSS_DROW_DEATH_ANIMATION_DURATION, 107 | onComplete: () => { 108 | this.visible = false; 109 | EVENT_BUS.emit(CUSTOM_EVENTS.BOSS_DEFEATED); 110 | }, 111 | }); 112 | }); 113 | }), 114 | ); 115 | 116 | this.setScale(1.25); 117 | this.physicsBody.setSize(12, 24, true).setOffset(this.displayWidth / 4, this.displayHeight / 4 - 3); 118 | } 119 | 120 | get physicsBody(): Phaser.Physics.Arcade.Body { 121 | return this.body as Phaser.Physics.Arcade.Body; 122 | } 123 | 124 | public update(): void { 125 | super.update(); 126 | this.#weaponComponent.update(); 127 | } 128 | 129 | public enableObject(): void { 130 | super.enableObject(); 131 | 132 | if (this._isDefeated) { 133 | return; 134 | } 135 | 136 | if (this._stateMachine.currentStateName === undefined) { 137 | this.visible = false; 138 | this.scene.time.delayedCall(ENEMY_BOSS_START_INITIAL_DELAY, () => { 139 | this.visible = true; 140 | this._stateMachine.setState(CHARACTER_STATES.HIDDEN_STATE); 141 | }); 142 | } 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/game-objects/enemies/spider.ts: -------------------------------------------------------------------------------- 1 | import * as Phaser from 'phaser'; 2 | import { Direction, Position } from '../../common/types'; 3 | import { InputComponent } from '../../components/input/input-component'; 4 | import { IdleState } from '../../components/state-machine/states/character/idle-state'; 5 | import { CHARACTER_STATES } from '../../components/state-machine/states/character/character-states'; 6 | import { MoveState } from '../../components/state-machine/states/character/move-state'; 7 | import { 8 | ENEMY_SPIDER_CHANGE_DIRECTION_DELAY_MAX, 9 | ENEMY_SPIDER_CHANGE_DIRECTION_DELAY_MIN, 10 | ENEMY_SPIDER_CHANGE_DIRECTION_DELAY_WAIT, 11 | ENEMY_SPIDER_HURT_PUSH_BACK_SPEED, 12 | ENEMY_SPIDER_MAX_HEALTH, 13 | ENEMY_SPIDER_SPEED, 14 | } from '../../common/config'; 15 | import { AnimationConfig } from '../../components/game-object/animation-component'; 16 | import { ASSET_KEYS, SPIDER_ANIMATION_KEYS } from '../../common/assets'; 17 | import { CharacterGameObject } from '../common/character-game-object'; 18 | import { DIRECTION } from '../../common/common'; 19 | import { exhaustiveGuard } from '../../common/utils'; 20 | import { HurtState } from '../../components/state-machine/states/character/hurt-state'; 21 | import { DeathState } from '../../components/state-machine/states/character/death-state'; 22 | 23 | export type SpiderConfig = { 24 | scene: Phaser.Scene; 25 | position: Position; 26 | }; 27 | 28 | export class Spider extends CharacterGameObject { 29 | constructor(config: SpiderConfig) { 30 | // create animation config for component 31 | const animConfig = { key: SPIDER_ANIMATION_KEYS.WALK, repeat: -1, ignoreIfPlaying: true }; 32 | const hurtAnimConfig = { key: SPIDER_ANIMATION_KEYS.HIT, repeat: 0, ignoreIfPlaying: true }; 33 | const deathAnimConfig = { key: SPIDER_ANIMATION_KEYS.DEATH, repeat: 0, ignoreIfPlaying: true }; 34 | const animationConfig: AnimationConfig = { 35 | WALK_DOWN: animConfig, 36 | WALK_UP: animConfig, 37 | WALK_LEFT: animConfig, 38 | WALK_RIGHT: animConfig, 39 | IDLE_DOWN: animConfig, 40 | IDLE_UP: animConfig, 41 | IDLE_LEFT: animConfig, 42 | IDLE_RIGHT: animConfig, 43 | HURT_DOWN: hurtAnimConfig, 44 | HURT_UP: hurtAnimConfig, 45 | HURT_LEFT: hurtAnimConfig, 46 | HURT_RIGHT: hurtAnimConfig, 47 | DIE_DOWN: deathAnimConfig, 48 | DIE_UP: deathAnimConfig, 49 | DIE_LEFT: deathAnimConfig, 50 | DIE_RIGHT: deathAnimConfig, 51 | }; 52 | 53 | super({ 54 | scene: config.scene, 55 | position: config.position, 56 | assetKey: ASSET_KEYS.SPIDER, 57 | frame: 0, 58 | id: `spider-${Phaser.Math.RND.uuid()}`, 59 | isPlayer: false, 60 | animationConfig, 61 | speed: ENEMY_SPIDER_SPEED, 62 | inputComponent: new InputComponent(), 63 | isInvulnerable: false, 64 | maxLife: ENEMY_SPIDER_MAX_HEALTH, 65 | }); 66 | 67 | // add shared components 68 | this._directionComponent.callback = (direction: Direction) => { 69 | this.#handleDirectionChange(direction); 70 | }; 71 | 72 | // add state machine 73 | this._stateMachine.addState(new IdleState(this)); 74 | this._stateMachine.addState(new MoveState(this)); 75 | this._stateMachine.addState(new HurtState(this, ENEMY_SPIDER_HURT_PUSH_BACK_SPEED)); 76 | this._stateMachine.addState(new DeathState(this)); 77 | this._stateMachine.setState(CHARACTER_STATES.IDLE_STATE); 78 | } 79 | 80 | public enableObject(): void { 81 | super.enableObject(); 82 | 83 | // start simple ai movement pattern 84 | this.scene.time.addEvent({ 85 | delay: Phaser.Math.Between(ENEMY_SPIDER_CHANGE_DIRECTION_DELAY_MIN, ENEMY_SPIDER_CHANGE_DIRECTION_DELAY_MAX), 86 | callback: this.#changeDirection, 87 | callbackScope: this, 88 | loop: false, 89 | }); 90 | } 91 | 92 | #handleDirectionChange(direction: Direction): void { 93 | switch (direction) { 94 | case DIRECTION.DOWN: 95 | this.setAngle(0); 96 | return; 97 | case DIRECTION.UP: 98 | this.setAngle(180); 99 | return; 100 | case DIRECTION.LEFT: 101 | this.setAngle(90); 102 | return; 103 | case DIRECTION.RIGHT: 104 | this.setAngle(270); 105 | return; 106 | default: 107 | exhaustiveGuard(direction); 108 | } 109 | } 110 | 111 | #changeDirection(): void { 112 | // reset existing enemy input 113 | this.controls.reset(); 114 | 115 | if (!this.active) { 116 | return; 117 | } 118 | 119 | // wait a small period of time and then choose a random direction to move 120 | this.scene.time.delayedCall(ENEMY_SPIDER_CHANGE_DIRECTION_DELAY_WAIT, () => { 121 | const randomDirection = Phaser.Math.Between(0, 3); 122 | if (randomDirection === 0) { 123 | this.controls.isUpDown = true; 124 | } else if (randomDirection === 1) { 125 | this.controls.isRightDown = true; 126 | } else if (randomDirection === 2) { 127 | this.controls.isDownDown = true; 128 | } else { 129 | this.controls.isLeftDown = true; 130 | } 131 | 132 | // set up event for next direction change 133 | this.scene.time.addEvent({ 134 | delay: Phaser.Math.Between(ENEMY_SPIDER_CHANGE_DIRECTION_DELAY_MIN, ENEMY_SPIDER_CHANGE_DIRECTION_DELAY_MAX), 135 | callback: this.#changeDirection, 136 | callbackScope: this, 137 | loop: false, 138 | }); 139 | }); 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/game-objects/enemies/wisp.ts: -------------------------------------------------------------------------------- 1 | import * as Phaser from 'phaser'; 2 | import { Position } from '../../common/types'; 3 | import { InputComponent } from '../../components/input/input-component'; 4 | import { CHARACTER_STATES } from '../../components/state-machine/states/character/character-states'; 5 | import { 6 | ENEMY_WISP_MAX_HEALTH, 7 | ENEMY_WISP_PULSE_ANIMATION_DURATION, 8 | ENEMY_WISP_PULSE_ANIMATION_SCALE_X, 9 | ENEMY_WISP_PULSE_ANIMATION_SCALE_Y, 10 | ENEMY_WISP_SPEED, 11 | } from '../../common/config'; 12 | import { AnimationConfig } from '../../components/game-object/animation-component'; 13 | import { ASSET_KEYS, WISP_ANIMATION_KEYS } from '../../common/assets'; 14 | import { CharacterGameObject } from '../common/character-game-object'; 15 | import { BounceMoveState } from '../../components/state-machine/states/character/bounce-move-state'; 16 | 17 | export type WispConfig = { 18 | scene: Phaser.Scene; 19 | position: Position; 20 | }; 21 | 22 | export class Wisp extends CharacterGameObject { 23 | constructor(config: WispConfig) { 24 | // create animation config for component 25 | const animConfig = { key: WISP_ANIMATION_KEYS.IDLE, repeat: -1, ignoreIfPlaying: true }; 26 | const animationConfig: AnimationConfig = { 27 | IDLE_DOWN: animConfig, 28 | IDLE_UP: animConfig, 29 | IDLE_LEFT: animConfig, 30 | IDLE_RIGHT: animConfig, 31 | }; 32 | 33 | super({ 34 | scene: config.scene, 35 | position: config.position, 36 | assetKey: ASSET_KEYS.WISP, 37 | frame: 0, 38 | id: `wisp-${Phaser.Math.RND.uuid()}`, 39 | isPlayer: false, 40 | animationConfig, 41 | speed: ENEMY_WISP_SPEED, 42 | inputComponent: new InputComponent(), 43 | isInvulnerable: true, 44 | maxLife: ENEMY_WISP_MAX_HEALTH, 45 | }); 46 | 47 | // add state machine 48 | this._stateMachine.addState(new BounceMoveState(this)); 49 | this._stateMachine.setState(CHARACTER_STATES.BOUNCE_MOVE_STATE); 50 | 51 | // custom animation for movement 52 | this.scene.tweens.add({ 53 | targets: this, 54 | scaleX: ENEMY_WISP_PULSE_ANIMATION_SCALE_X, 55 | scaleY: ENEMY_WISP_PULSE_ANIMATION_SCALE_Y, 56 | yoyo: true, 57 | repeat: -1, 58 | duration: ENEMY_WISP_PULSE_ANIMATION_DURATION, 59 | }); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/game-objects/objects/button.ts: -------------------------------------------------------------------------------- 1 | import * as Phaser from 'phaser'; 2 | import { SwitchAction, TiledSwitchObject } from '../../common/tiled/types'; 3 | import { CustomGameObject } from '../../common/types'; 4 | import { SWITCH_TEXTURE } from '../../common/tiled/common'; 5 | import { ASSET_KEYS, BUTTON_FRAME_KEYS } from '../../common/assets'; 6 | 7 | type ButtonPressedEvent = { 8 | action: SwitchAction; 9 | targetIds: number[]; 10 | }; 11 | 12 | export class Button extends Phaser.Physics.Arcade.Image implements CustomGameObject { 13 | #switchTargetIds: number[]; 14 | #switchAction: SwitchAction; 15 | 16 | constructor(scene: Phaser.Scene, config: TiledSwitchObject) { 17 | const frame = 18 | config.texture === SWITCH_TEXTURE.FLOOR ? BUTTON_FRAME_KEYS.FLOOR_SWITCH : BUTTON_FRAME_KEYS.PLATE_SWITCH; 19 | super(scene, config.x, config.y, ASSET_KEYS.DUNGEON_OBJECTS, frame); 20 | 21 | // add object to scene and enable phaser physics 22 | scene.add.existing(this); 23 | scene.physics.add.existing(this); 24 | this.setOrigin(0, 1).setImmovable(true); 25 | 26 | this.#switchTargetIds = config.targetIds; 27 | this.#switchAction = config.action; 28 | 29 | // disable physics body and make game objects inactive/not visible 30 | this.disableObject(); 31 | } 32 | 33 | public press(): ButtonPressedEvent { 34 | this.disableObject(); 35 | 36 | // return data about button being pressed with metadata tied to action 37 | return { 38 | action: this.#switchAction, 39 | targetIds: this.#switchTargetIds, 40 | }; 41 | } 42 | 43 | public disableObject(): void { 44 | // disable body on game object so we stop triggering the collision 45 | (this.body as Phaser.Physics.Arcade.Body).enable = false; 46 | // make not visible until player re-enters room 47 | this.active = false; 48 | this.visible = false; 49 | } 50 | 51 | public enableObject(): void { 52 | (this.body as Phaser.Physics.Arcade.Body).enable = true; 53 | this.active = true; 54 | this.visible = true; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/game-objects/objects/chest.ts: -------------------------------------------------------------------------------- 1 | import * as Phaser from 'phaser'; 2 | import { ASSET_KEYS, CHEST_FRAME_KEYS } from '../../common/assets'; 3 | import { CHEST_STATE, INTERACTIVE_OBJECT_TYPE, LEVEL_NAME } from '../../common/common'; 4 | import { ChestState, CustomGameObject } from '../../common/types'; 5 | import { InteractiveObjectComponent } from '../../components/game-object/interactive-object-component'; 6 | import { ChestReward, TiledChestObject, TrapType } from '../../common/tiled/types'; 7 | import { TRAP_TYPE } from '../../common/tiled/common'; 8 | import { InventoryManager } from '../../components/inventory/inventory-manager'; 9 | import { DataManager } from '../../common/data-manager'; 10 | 11 | export class Chest extends Phaser.Physics.Arcade.Image implements CustomGameObject { 12 | #state: ChestState; 13 | #isBossKeyChest: boolean; 14 | #id: number; 15 | #revealTrigger: TrapType; 16 | #contents: ChestReward; 17 | 18 | constructor(scene: Phaser.Scene, config: TiledChestObject, chestState = CHEST_STATE.HIDDEN) { 19 | const frameKey = config.requiresBossKey ? CHEST_FRAME_KEYS.BIG_CHEST_CLOSED : CHEST_FRAME_KEYS.SMALL_CHEST_CLOSED; 20 | super(scene, config.x, config.y, ASSET_KEYS.DUNGEON_OBJECTS, frameKey); 21 | 22 | // add object to scene and enable phaser physics 23 | scene.add.existing(this); 24 | scene.physics.add.existing(this); 25 | this.setOrigin(0, 1).setImmovable(true); 26 | 27 | this.#state = chestState; 28 | this.#isBossKeyChest = config.requiresBossKey; 29 | this.#id = config.id; 30 | this.#revealTrigger = config.revealChestTrigger; 31 | this.#contents = config.contents; 32 | 33 | if (this.#isBossKeyChest) { 34 | (this.body as Phaser.Physics.Arcade.Body).setSize(32, 24).setOffset(0, 8); 35 | } 36 | 37 | // add components 38 | new InteractiveObjectComponent( 39 | this, 40 | INTERACTIVE_OBJECT_TYPE.OPEN, 41 | () => { 42 | // if this is a small chest, then the player can open 43 | if (!this.#isBossKeyChest) { 44 | return true; 45 | } 46 | // use area information from data manager 47 | if (!InventoryManager.instance.getAreaInventory(DataManager.instance.data.currentArea.name).bossKey) { 48 | return false; 49 | } 50 | return true; 51 | }, 52 | () => { 53 | this.open(); 54 | }, 55 | ); 56 | 57 | if (this.#revealTrigger === TRAP_TYPE.NONE) { 58 | if (this.#state === CHEST_STATE.HIDDEN) { 59 | this.#state = CHEST_STATE.REVEALED; 60 | } 61 | return; 62 | } 63 | // disable physics body and make game objects inactive/not visible 64 | this.disableObject(); 65 | } 66 | 67 | get revealTrigger(): TrapType { 68 | return this.#revealTrigger; 69 | } 70 | 71 | get id(): number { 72 | return this.#id; 73 | } 74 | 75 | get contents(): ChestReward { 76 | return this.#contents; 77 | } 78 | 79 | public open(): void { 80 | if (this.#state !== CHEST_STATE.REVEALED) { 81 | return; 82 | } 83 | 84 | this.#state = CHEST_STATE.OPEN; 85 | const frameKey = this.#isBossKeyChest ? CHEST_FRAME_KEYS.BIG_CHEST_OPEN : CHEST_FRAME_KEYS.SMALL_CHEST_OPEN; 86 | this.setFrame(frameKey); 87 | 88 | // after we open the chest, we can no longer interact with it 89 | InteractiveObjectComponent.removeComponent(this); 90 | } 91 | 92 | public disableObject(): void { 93 | // disable body on game object so we stop triggering the collision 94 | (this.body as Phaser.Physics.Arcade.Body).enable = false; 95 | // make not visible until player re-enters room 96 | this.active = false; 97 | this.visible = false; 98 | } 99 | 100 | public enableObject(): void { 101 | if (this.#state === CHEST_STATE.HIDDEN) { 102 | return; 103 | } 104 | 105 | // enable body on game object so we trigger the collision 106 | (this.body as Phaser.Physics.Arcade.Body).enable = true; 107 | // make visible to the player 108 | this.active = true; 109 | this.visible = true; 110 | } 111 | 112 | public reveal(): void { 113 | if (this.#state !== CHEST_STATE.HIDDEN) { 114 | return; 115 | } 116 | this.#state = CHEST_STATE.REVEALED; 117 | this.enableObject(); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/game-objects/objects/door.ts: -------------------------------------------------------------------------------- 1 | import { ASSET_KEYS, DOOR_FRAME_KEYS } from '../../common/assets'; 2 | import { DIRECTION } from '../../common/common'; 3 | import { ENABLE_DEBUG_ZONE_AREA } from '../../common/config'; 4 | import { DOOR_TYPE } from '../../common/tiled/common'; 5 | import { DoorType, TiledDoorObject, TrapType } from '../../common/tiled/types'; 6 | import { CustomGameObject, Direction } from '../../common/types'; 7 | 8 | export class Door implements CustomGameObject { 9 | #scene: Phaser.Scene; 10 | #roomId: number; 11 | #targetDoorId: number; 12 | #targetRoomId: number; 13 | #x: number; 14 | #y: number; 15 | #targetLevel: string; 16 | #doorTransitionZone: Phaser.GameObjects.Zone; 17 | // eslint-disable-next-line no-unused-private-class-members 18 | #debugDoorTransitionZone: Phaser.GameObjects.Rectangle | undefined; 19 | #direction: Direction; 20 | #id: number; 21 | #isUnlocked: boolean; 22 | #doorObject!: Phaser.Types.Physics.Arcade.ImageWithDynamicBody | undefined; 23 | #trapDoorTrigger: TrapType; 24 | #doorType: DoorType; 25 | 26 | constructor(scene: Phaser.Scene, config: TiledDoorObject, roomId: number) { 27 | this.#scene = scene; 28 | this.#id = config.id; 29 | this.#roomId = roomId; 30 | this.#targetDoorId = config.targetDoorId; 31 | this.#targetRoomId = config.targetRoomId; 32 | this.#targetLevel = config.targetLevel; 33 | this.#x = config.x; 34 | this.#y = config.y; 35 | this.#direction = config.direction; 36 | this.#doorType = config.doorType; 37 | this.#isUnlocked = config.isUnlocked; 38 | this.#trapDoorTrigger = config.trapDoorTrigger; 39 | 40 | // create door transition 41 | this.#doorTransitionZone = this.#scene.add 42 | .zone(config.x, config.y, config.width, config.height) 43 | .setOrigin(0, 1) 44 | .setName(config.id.toString(10)); 45 | this.#scene.physics.world.enable(this.#doorTransitionZone); 46 | 47 | if (ENABLE_DEBUG_ZONE_AREA) { 48 | this.#debugDoorTransitionZone = this.#scene.add 49 | .rectangle( 50 | this.#doorTransitionZone.x, 51 | this.#doorTransitionZone.y, 52 | this.#doorTransitionZone.width, 53 | this.#doorTransitionZone.height, 54 | 0xffff00, 55 | 0.6, 56 | ) 57 | .setOrigin(0, 1); 58 | } 59 | 60 | // if door is not open type create sprite for the door 61 | if (this.#doorType !== DOOR_TYPE.OPEN && this.#doorType !== DOOR_TYPE.OPEN_ENTRANCE) { 62 | const frameName = DOOR_FRAME_KEYS[`${this.#doorType}_${this.#direction}`]; 63 | 64 | const door = this.#scene.physics.add 65 | .image(this.#x, this.y, ASSET_KEYS.DUNGEON_OBJECTS, frameName) 66 | .setImmovable(true) 67 | .setName(config.id.toString(10)); 68 | 69 | switch (this.#direction) { 70 | case DIRECTION.UP: 71 | door.setOrigin(0, 0.5); 72 | break; 73 | case DIRECTION.DOWN: 74 | door.setOrigin(0, 0.75); 75 | break; 76 | case DIRECTION.LEFT: 77 | door.setOrigin(0.25, 1); 78 | break; 79 | case DIRECTION.RIGHT: 80 | door.setOrigin(0.5, 1); 81 | break; 82 | } 83 | 84 | this.#doorObject = door; 85 | } 86 | 87 | // disable physics body and make game objects inactive/not visible 88 | this.disableObject(); 89 | } 90 | 91 | get x(): number { 92 | return this.#x; 93 | } 94 | 95 | get y(): number { 96 | return this.#y; 97 | } 98 | 99 | get roomId(): number { 100 | return this.#roomId; 101 | } 102 | 103 | get targetRoomId(): number { 104 | return this.#targetRoomId; 105 | } 106 | 107 | get targetDoorId(): number { 108 | return this.#targetDoorId; 109 | } 110 | 111 | get doorTransitionZone(): Phaser.GameObjects.Zone { 112 | return this.#doorTransitionZone; 113 | } 114 | 115 | get targetLevel(): string { 116 | return this.#targetLevel; 117 | } 118 | 119 | get direction(): Direction { 120 | return this.#direction; 121 | } 122 | 123 | get doorObject(): Phaser.Types.Physics.Arcade.ImageWithDynamicBody | undefined { 124 | return this.#doorObject; 125 | } 126 | 127 | get id(): number { 128 | return this.#id; 129 | } 130 | 131 | get trapDoorTrigger(): TrapType { 132 | return this.#trapDoorTrigger; 133 | } 134 | 135 | get doorType(): DoorType { 136 | return this.#doorType; 137 | } 138 | 139 | public disableObject(disableDoorTrigger = true): void { 140 | if (disableDoorTrigger) { 141 | (this.#doorTransitionZone.body as Phaser.Physics.Arcade.Body).enable = false; 142 | this.#doorTransitionZone.active = true; 143 | } 144 | 145 | if (this.#doorObject !== undefined) { 146 | this.#doorObject.body.enable = false; 147 | this.#doorObject.active = false; 148 | this.#doorObject.visible = false; 149 | } 150 | } 151 | 152 | public enableObject(): void { 153 | (this.#doorTransitionZone.body as Phaser.Physics.Arcade.Body).enable = true; 154 | this.#doorTransitionZone.active = true; 155 | 156 | if (this.#isUnlocked) { 157 | return; 158 | } 159 | 160 | if (this.#doorObject !== undefined) { 161 | this.#doorObject.body.enable = true; 162 | this.#doorObject.active = true; 163 | this.#doorObject.visible = true; 164 | } 165 | } 166 | 167 | public open(): void { 168 | if (this.#doorType === DOOR_TYPE.LOCK || this.#doorType === DOOR_TYPE.BOSS) { 169 | this.#isUnlocked = true; 170 | } 171 | 172 | this.disableObject(false); 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /src/game-objects/objects/pot.ts: -------------------------------------------------------------------------------- 1 | import * as Phaser from 'phaser'; 2 | import { ASSET_KEYS } from '../../common/assets'; 3 | import { CustomGameObject, Position } from '../../common/types'; 4 | import { InteractiveObjectComponent } from '../../components/game-object/interactive-object-component'; 5 | import { INTERACTIVE_OBJECT_TYPE } from '../../common/common'; 6 | import { ThrowableObjectComponent } from '../../components/game-object/throwable-object-component'; 7 | import { TiledPotObject } from '../../common/tiled/types'; 8 | 9 | export class Pot extends Phaser.Physics.Arcade.Sprite implements CustomGameObject { 10 | #position: Position; 11 | 12 | constructor(scene: Phaser.Scene, config: TiledPotObject) { 13 | super(scene, config.x, config.y, ASSET_KEYS.POT, 0); 14 | 15 | // add object to scene and enable phaser physics 16 | scene.add.existing(this); 17 | scene.physics.add.existing(this); 18 | this.setOrigin(0, 1).setImmovable(true); 19 | 20 | // keep track of original position for the pot 21 | this.#position = { x: config.x, y: config.y }; 22 | 23 | // add components 24 | new InteractiveObjectComponent(this, INTERACTIVE_OBJECT_TYPE.PICKUP); 25 | new ThrowableObjectComponent(this, () => { 26 | this.break(); 27 | }); 28 | 29 | // disable physics body and make game objects inactive/not visible 30 | this.disableObject(); 31 | } 32 | 33 | public disableObject(): void { 34 | // disable body on game object so we stop triggering the collision 35 | (this.body as Phaser.Physics.Arcade.Body).enable = false; 36 | // make not visible until player re-enters room 37 | this.active = false; 38 | this.visible = false; 39 | } 40 | 41 | public enableObject(): void { 42 | // enable body on game object so we trigger the collision 43 | (this.body as Phaser.Physics.Arcade.Body).enable = true; 44 | // make visible to the player 45 | this.active = true; 46 | this.visible = true; 47 | } 48 | 49 | public break(): void { 50 | (this.body as Phaser.Physics.Arcade.Body).enable = false; 51 | this.setTexture(ASSET_KEYS.POT_BREAK, 0).play(ASSET_KEYS.POT_BREAK); 52 | // once animation is finished, disable object and reset the initial texture 53 | this.once(Phaser.Animations.Events.ANIMATION_COMPLETE_KEY + ASSET_KEYS.POT_BREAK, () => { 54 | this.setTexture(ASSET_KEYS.POT, 0); 55 | this.disableObject(); 56 | }); 57 | } 58 | 59 | public resetPosition(): void { 60 | this.scene.time.delayedCall(1, () => { 61 | this.setPosition(this.#position.x, this.#position.y).setOrigin(0, 1); 62 | this.enableObject(); 63 | }); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/game-objects/player/player.ts: -------------------------------------------------------------------------------- 1 | import * as Phaser from 'phaser'; 2 | import { GameObject, Position } from '../../common/types'; 3 | import { InputComponent } from '../../components/input/input-component'; 4 | import { IdleState } from '../../components/state-machine/states/character/idle-state'; 5 | import { CHARACTER_STATES } from '../../components/state-machine/states/character/character-states'; 6 | import { MoveState } from '../../components/state-machine/states/character/move-state'; 7 | import { 8 | PLAYER_ATTACK_DAMAGE, 9 | PLAYER_HURT_PUSH_BACK_SPEED, 10 | PLAYER_INVULNERABLE_AFTER_HIT_DURATION, 11 | PLAYER_SPEED, 12 | } from '../../common/config'; 13 | import { AnimationConfig } from '../../components/game-object/animation-component'; 14 | import { ASSET_KEYS, PLAYER_ANIMATION_KEYS } from '../../common/assets'; 15 | import { CharacterGameObject } from '../common/character-game-object'; 16 | import { HurtState } from '../../components/state-machine/states/character/hurt-state'; 17 | import { flash } from '../../common/juice-utils'; 18 | import { DeathState } from '../../components/state-machine/states/character/death-state'; 19 | import { CollidingObjectsComponent } from '../../components/game-object/colliding-objects-component'; 20 | import { LiftState } from '../../components/state-machine/states/character/lift-state'; 21 | import { OpenChestState } from '../../components/state-machine/states/character/open-chest-state'; 22 | import { IdleHoldingState } from '../../components/state-machine/states/character/idle-holding-state'; 23 | import { MoveHoldingState } from '../../components/state-machine/states/character/move-holding-state'; 24 | import { HeldGameObjectComponent } from '../../components/game-object/held-game-object-component'; 25 | import { ThrowState } from '../../components/state-machine/states/character/throw-state'; 26 | import { AttackState } from '../../components/state-machine/states/character/attack-state'; 27 | import { WeaponComponent } from '../../components/game-object/weapon-component'; 28 | import { Sword } from '../weapons/sword'; 29 | 30 | export type PlayerConfig = { 31 | scene: Phaser.Scene; 32 | position: Position; 33 | controls: InputComponent; 34 | maxLife: number; 35 | currentLife: number; 36 | }; 37 | 38 | export class Player extends CharacterGameObject { 39 | #collidingObjectsComponent: CollidingObjectsComponent; 40 | #weaponComponent: WeaponComponent; 41 | 42 | constructor(config: PlayerConfig) { 43 | // create animation config for component 44 | const animationConfig: AnimationConfig = { 45 | WALK_DOWN: { key: PLAYER_ANIMATION_KEYS.WALK_DOWN, repeat: -1, ignoreIfPlaying: true }, 46 | WALK_UP: { key: PLAYER_ANIMATION_KEYS.WALK_UP, repeat: -1, ignoreIfPlaying: true }, 47 | WALK_LEFT: { key: PLAYER_ANIMATION_KEYS.WALK_SIDE, repeat: -1, ignoreIfPlaying: true }, 48 | WALK_RIGHT: { key: PLAYER_ANIMATION_KEYS.WALK_SIDE, repeat: -1, ignoreIfPlaying: true }, 49 | IDLE_DOWN: { key: PLAYER_ANIMATION_KEYS.IDLE_DOWN, repeat: -1, ignoreIfPlaying: true }, 50 | IDLE_UP: { key: PLAYER_ANIMATION_KEYS.IDLE_UP, repeat: -1, ignoreIfPlaying: true }, 51 | IDLE_LEFT: { key: PLAYER_ANIMATION_KEYS.IDLE_SIDE, repeat: -1, ignoreIfPlaying: true }, 52 | IDLE_RIGHT: { key: PLAYER_ANIMATION_KEYS.IDLE_SIDE, repeat: -1, ignoreIfPlaying: true }, 53 | HURT_DOWN: { key: PLAYER_ANIMATION_KEYS.HURT_DOWN, repeat: 0, ignoreIfPlaying: true }, 54 | HURT_UP: { key: PLAYER_ANIMATION_KEYS.HURT_UP, repeat: 0, ignoreIfPlaying: true }, 55 | HURT_LEFT: { key: PLAYER_ANIMATION_KEYS.HURT_SIDE, repeat: 0, ignoreIfPlaying: true }, 56 | HURT_RIGHT: { key: PLAYER_ANIMATION_KEYS.HURT_SIDE, repeat: 0, ignoreIfPlaying: true }, 57 | DIE_DOWN: { key: PLAYER_ANIMATION_KEYS.DIE_DOWN, repeat: 0, ignoreIfPlaying: true }, 58 | DIE_UP: { key: PLAYER_ANIMATION_KEYS.DIE_UP, repeat: 0, ignoreIfPlaying: true }, 59 | DIE_LEFT: { key: PLAYER_ANIMATION_KEYS.DIE_SIDE, repeat: 0, ignoreIfPlaying: true }, 60 | DIE_RIGHT: { key: PLAYER_ANIMATION_KEYS.DIE_SIDE, repeat: 0, ignoreIfPlaying: true }, 61 | IDLE_HOLD_DOWN: { key: PLAYER_ANIMATION_KEYS.IDLE_HOLD_DOWN, repeat: -1, ignoreIfPlaying: true }, 62 | IDLE_HOLD_UP: { key: PLAYER_ANIMATION_KEYS.IDLE_HOLD_UP, repeat: -1, ignoreIfPlaying: true }, 63 | IDLE_HOLD_LEFT: { key: PLAYER_ANIMATION_KEYS.IDLE_HOLD_SIDE, repeat: -1, ignoreIfPlaying: true }, 64 | IDLE_HOLD_RIGHT: { key: PLAYER_ANIMATION_KEYS.IDLE_HOLD_SIDE, repeat: -1, ignoreIfPlaying: true }, 65 | WALK_HOLD_DOWN: { key: PLAYER_ANIMATION_KEYS.WALK_HOLD_DOWN, repeat: -1, ignoreIfPlaying: true }, 66 | WALK_HOLD_UP: { key: PLAYER_ANIMATION_KEYS.WALK_HOLD_UP, repeat: -1, ignoreIfPlaying: true }, 67 | WALK_HOLD_LEFT: { key: PLAYER_ANIMATION_KEYS.WALK_HOLD_SIDE, repeat: -1, ignoreIfPlaying: true }, 68 | WALK_HOLD_RIGHT: { key: PLAYER_ANIMATION_KEYS.WALK_HOLD_SIDE, repeat: -1, ignoreIfPlaying: true }, 69 | LIFT_DOWN: { key: PLAYER_ANIMATION_KEYS.LIFT_DOWN, repeat: 0, ignoreIfPlaying: true }, 70 | LIFT_UP: { key: PLAYER_ANIMATION_KEYS.LIFT_UP, repeat: 0, ignoreIfPlaying: true }, 71 | LIFT_LEFT: { key: PLAYER_ANIMATION_KEYS.LIFT_SIDE, repeat: 0, ignoreIfPlaying: true }, 72 | LIFT_RIGHT: { key: PLAYER_ANIMATION_KEYS.LIFT_SIDE, repeat: 0, ignoreIfPlaying: true }, 73 | }; 74 | 75 | super({ 76 | scene: config.scene, 77 | position: config.position, 78 | assetKey: ASSET_KEYS.PLAYER, 79 | frame: 0, 80 | id: 'player', 81 | isPlayer: true, 82 | animationConfig, 83 | speed: PLAYER_SPEED, 84 | inputComponent: config.controls, 85 | isInvulnerable: false, 86 | invulnerableAfterHitAnimationDuration: PLAYER_INVULNERABLE_AFTER_HIT_DURATION, 87 | maxLife: config.maxLife, 88 | currentLife: config.currentLife, 89 | }); 90 | 91 | // add state machine 92 | this._stateMachine.addState(new IdleState(this)); 93 | this._stateMachine.addState(new MoveState(this)); 94 | this._stateMachine.addState( 95 | new HurtState(this, PLAYER_HURT_PUSH_BACK_SPEED, () => { 96 | flash(this); 97 | }), 98 | ); 99 | this._stateMachine.addState(new DeathState(this)); 100 | this._stateMachine.addState(new LiftState(this)); 101 | this._stateMachine.addState(new OpenChestState(this)); 102 | this._stateMachine.addState(new IdleHoldingState(this)); 103 | this._stateMachine.addState(new MoveHoldingState(this)); 104 | this._stateMachine.addState(new ThrowState(this)); 105 | this._stateMachine.addState(new AttackState(this)); 106 | this._stateMachine.setState(CHARACTER_STATES.IDLE_STATE); 107 | 108 | // add components 109 | this.#collidingObjectsComponent = new CollidingObjectsComponent(this); 110 | new HeldGameObjectComponent(this); 111 | this.#weaponComponent = new WeaponComponent(this); 112 | this.#weaponComponent.weapon = new Sword( 113 | this, 114 | this.#weaponComponent, 115 | { 116 | DOWN: PLAYER_ANIMATION_KEYS.SWORD_1_ATTACK_DOWN, 117 | UP: PLAYER_ANIMATION_KEYS.SWORD_1_ATTACK_UP, 118 | LEFT: PLAYER_ANIMATION_KEYS.SWORD_1_ATTACK_SIDE, 119 | RIGHT: PLAYER_ANIMATION_KEYS.SWORD_1_ATTACK_SIDE, 120 | }, 121 | PLAYER_ATTACK_DAMAGE, 122 | ); 123 | 124 | // enable auto update functionality 125 | config.scene.events.on(Phaser.Scenes.Events.UPDATE, this.update, this); 126 | config.scene.events.once( 127 | Phaser.Scenes.Events.SHUTDOWN, 128 | () => { 129 | config.scene.events.off(Phaser.Scenes.Events.UPDATE, this.update, this); 130 | }, 131 | this, 132 | ); 133 | 134 | // update physics body 135 | this.physicsBody.setSize(12, 16, true).setOffset(this.width / 2 - 5, this.height / 2); 136 | } 137 | 138 | get physicsBody(): Phaser.Physics.Arcade.Body { 139 | return this.body as Phaser.Physics.Arcade.Body; 140 | } 141 | 142 | get weaponComponent(): WeaponComponent { 143 | return this.#weaponComponent; 144 | } 145 | 146 | public collidedWithGameObject(gameObject: GameObject): void { 147 | this.#collidingObjectsComponent.add(gameObject); 148 | } 149 | 150 | public update(): void { 151 | super.update(); 152 | this.#collidingObjectsComponent.reset(); 153 | this.#weaponComponent.update(); 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/game-objects/weapons/base-weapon.ts: -------------------------------------------------------------------------------- 1 | import { Direction } from '../../common/types'; 2 | import { WeaponComponent } from '../../components/game-object/weapon-component'; 3 | 4 | export interface Weapon { 5 | baseDamage: number; 6 | isAttacking: boolean; 7 | attackUp(): void; 8 | attackDown(): void; 9 | attackRight(): void; 10 | attackLeft(): void; 11 | update(): void; 12 | onCollisionCallback(): void; 13 | } 14 | 15 | export type WeaponAttackAnimationConfig = { 16 | [key in Direction]: string; 17 | }; 18 | 19 | export abstract class BaseWeapon implements Weapon { 20 | protected _weaponComponent: WeaponComponent; 21 | protected _attacking: boolean; 22 | protected _sprite: Phaser.GameObjects.Sprite; 23 | protected _attackAnimationConfig: WeaponAttackAnimationConfig; 24 | protected _baseDamage: number; 25 | 26 | constructor( 27 | sprite: Phaser.GameObjects.Sprite, 28 | weaponComponent: WeaponComponent, 29 | animationConfig: WeaponAttackAnimationConfig, 30 | baseDamage: number, 31 | ) { 32 | this._sprite = sprite; 33 | this._weaponComponent = weaponComponent; 34 | this._attackAnimationConfig = animationConfig; 35 | this._baseDamage = baseDamage; 36 | this._attacking = false; 37 | } 38 | 39 | get isAttacking(): boolean { 40 | return this._attacking; 41 | } 42 | 43 | get baseDamage(): number { 44 | return this._baseDamage; 45 | } 46 | 47 | protected attack(direction: Direction): void { 48 | const attackAnimationKey = this._attackAnimationConfig[direction]; 49 | this._attacking = true; 50 | this._sprite.play({ key: attackAnimationKey, repeat: 0 }, true); 51 | this._weaponComponent.body.enable = true; 52 | this._sprite.once(Phaser.Animations.Events.ANIMATION_COMPLETE_KEY + attackAnimationKey, () => { 53 | this.attackAnimationCompleteHandler(); 54 | }); 55 | } 56 | 57 | protected attackAnimationCompleteHandler(): void { 58 | this._attacking = false; 59 | this._weaponComponent.body.enable = false; 60 | } 61 | 62 | // following methods must be implemented by weapon implementations 63 | public abstract attackUp(): void; 64 | 65 | public abstract attackDown(): void; 66 | 67 | public abstract attackRight(): void; 68 | 69 | public abstract attackLeft(): void; 70 | 71 | // following methods to be overridden if needed by weapon implementations 72 | public update(): void { 73 | // not implemented 74 | } 75 | 76 | public onCollisionCallback(): void { 77 | // not implemented 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/game-objects/weapons/dagger.ts: -------------------------------------------------------------------------------- 1 | import { BaseWeapon, WeaponAttackAnimationConfig } from './base-weapon'; 2 | import { DIRECTION } from '../../common/common'; 3 | import { WeaponComponent } from '../../components/game-object/weapon-component'; 4 | import { ASSET_KEYS } from '../../common/assets'; 5 | 6 | export class Dagger extends BaseWeapon { 7 | #weaponSprite: Phaser.GameObjects.Sprite; 8 | #weaponSpeed: number; 9 | 10 | constructor( 11 | sprite: Phaser.GameObjects.Sprite, 12 | weaponComponent: WeaponComponent, 13 | animationConfig: WeaponAttackAnimationConfig, 14 | baseDamage: number, 15 | weaponSpeed: number, 16 | ) { 17 | super(sprite, weaponComponent, animationConfig, baseDamage); 18 | 19 | this.#weaponSprite = sprite.scene.add 20 | .sprite(0, 0, ASSET_KEYS.DAGGER, 0) 21 | .setVisible(false) 22 | .setOrigin(0, 1) 23 | .play(ASSET_KEYS.DAGGER); 24 | this.#weaponSpeed = weaponSpeed; 25 | this._weaponComponent.body.setSize(this.#weaponSprite.width, this.#weaponSprite.height); 26 | } 27 | 28 | public attackUp(): void { 29 | this._weaponComponent.body.position.set(this._sprite.x - 8, this._sprite.y - 25); 30 | this._weaponComponent.body.setVelocityY(this.#weaponSpeed * -1); 31 | this.#weaponSprite 32 | .setPosition(this._weaponComponent.body.position.x, this._weaponComponent.body.y) 33 | .setVisible(true) 34 | .setOrigin(0, 0) 35 | .setAngle(0) 36 | .setFlipY(false); 37 | this.attack(DIRECTION.UP); 38 | } 39 | 40 | public attackDown(): void { 41 | this._weaponComponent.body.position.set(this._sprite.x - 7, this._sprite.y + 20); 42 | this._weaponComponent.body.setVelocityY(this.#weaponSpeed); 43 | this.#weaponSprite 44 | .setPosition(this._weaponComponent.body.position.x, this._weaponComponent.body.y) 45 | .setVisible(true) 46 | .setOrigin(1, 1) 47 | .setAngle(180) 48 | .setFlipY(false); 49 | this.attack(DIRECTION.DOWN); 50 | } 51 | 52 | public attackRight(): void { 53 | this._weaponComponent.body.position.set(this._sprite.x + 10, this._sprite.y - 5); 54 | this._weaponComponent.body.setVelocityX(this.#weaponSpeed); 55 | this.#weaponSprite 56 | .setPosition(this._weaponComponent.body.position.x, this._weaponComponent.body.y) 57 | .setVisible(true) 58 | .setOrigin(0, 1) 59 | .setAngle(90) 60 | .setFlipY(false); 61 | this.attack(DIRECTION.RIGHT); 62 | } 63 | 64 | public attackLeft(): void { 65 | this._weaponComponent.body.position.set(this._sprite.x - 25, this._sprite.y - 5); 66 | this._weaponComponent.body.setVelocityX(this.#weaponSpeed * -1); 67 | this.#weaponSprite 68 | .setPosition(this._weaponComponent.body.position.x, this._weaponComponent.body.y) 69 | .setVisible(true) 70 | .setOrigin(0, 1) 71 | .setAngle(90) 72 | .setFlipY(true); 73 | this.attack(DIRECTION.LEFT); 74 | } 75 | 76 | public update(): void { 77 | this.#weaponSprite.setPosition(this._weaponComponent.body.position.x, this._weaponComponent.body.position.y); 78 | } 79 | 80 | protected attackAnimationCompleteHandler(): void { 81 | super.attackAnimationCompleteHandler(); 82 | this.#weaponSprite.setVisible(false); 83 | this._weaponComponent.body.setVelocityX(0); 84 | this._weaponComponent.body.setVelocityY(0); 85 | } 86 | 87 | public onCollisionCallback(): void { 88 | this.attackAnimationCompleteHandler(); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/game-objects/weapons/sword.ts: -------------------------------------------------------------------------------- 1 | import { BaseWeapon } from './base-weapon'; 2 | import { DIRECTION } from '../../common/common'; 3 | 4 | export class Sword extends BaseWeapon { 5 | public attackUp(): void { 6 | this._weaponComponent.body.setSize(30, 18); 7 | this._weaponComponent.body.position.set(this._sprite.x - 16, this._sprite.y - 22); 8 | this.attack(DIRECTION.UP); 9 | } 10 | 11 | public attackDown(): void { 12 | this._weaponComponent.body.setSize(30, 18); 13 | if (this._sprite.flipX) { 14 | this._weaponComponent.body.position.set(this._sprite.x - 20, this._sprite.y + 10); 15 | } else { 16 | this._weaponComponent.body.position.set(this._sprite.x - 10, this._sprite.y + 10); 17 | } 18 | this.attack(DIRECTION.DOWN); 19 | } 20 | 21 | public attackRight(): void { 22 | this._weaponComponent.body.setSize(18, 30); 23 | this._weaponComponent.body.position.set(this._sprite.x + 10, this._sprite.y - 10); 24 | this.attack(DIRECTION.RIGHT); 25 | } 26 | 27 | public attackLeft(): void { 28 | this._weaponComponent.body.setSize(18, 30); 29 | this._weaponComponent.body.position.set(this._sprite.x - 30, this._sprite.y - 10); 30 | this.attack(DIRECTION.LEFT); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import * as Phaser from 'phaser'; 2 | import { SCENE_KEYS } from './scenes/scene-keys'; 3 | import { PreloadScene } from './scenes/preload-scene'; 4 | import { GameScene } from './scenes/game-scene'; 5 | import { UiScene } from './scenes/ui-scene'; 6 | import { GameOverScene } from './scenes/game-over-scene'; 7 | 8 | const gameConfig: Phaser.Types.Core.GameConfig = { 9 | type: Phaser.WEBGL, 10 | pixelArt: true, 11 | roundPixels: true, 12 | scale: { 13 | parent: 'game-container', 14 | width: 256, 15 | height: 224, 16 | autoCenter: Phaser.Scale.CENTER_BOTH, 17 | mode: Phaser.Scale.HEIGHT_CONTROLS_WIDTH, 18 | }, 19 | backgroundColor: '#000000', 20 | physics: { 21 | default: 'arcade', 22 | arcade: { 23 | gravity: { y: 0, x: 0 }, 24 | debug: false, 25 | }, 26 | }, 27 | }; 28 | 29 | const game = new Phaser.Game(gameConfig); 30 | 31 | game.scene.add(SCENE_KEYS.PRELOAD_SCENE, PreloadScene); 32 | game.scene.add(SCENE_KEYS.GAME_SCENE, GameScene); 33 | game.scene.add(SCENE_KEYS.UI_SCENE, UiScene); 34 | game.scene.add(SCENE_KEYS.GAME_OVER_SCENE, GameOverScene); 35 | game.scene.start(SCENE_KEYS.PRELOAD_SCENE); 36 | -------------------------------------------------------------------------------- /src/scenes/game-over-scene.ts: -------------------------------------------------------------------------------- 1 | import * as Phaser from 'phaser'; 2 | import { SCENE_KEYS } from './scene-keys'; 3 | import { ASSET_KEYS } from '../common/assets'; 4 | import { KeyboardComponent } from '../components/input/keyboard-component'; 5 | import { DataManager } from '../common/data-manager'; 6 | import { DEFAULT_UI_TEXT_STYLE } from '../common/common'; 7 | 8 | export class GameOverScene extends Phaser.Scene { 9 | #menuContainer!: Phaser.GameObjects.Container; 10 | #cursorGameObject!: Phaser.GameObjects.Image; 11 | #controls!: KeyboardComponent; 12 | #selectedMenuOptionIndex!: number; 13 | 14 | constructor() { 15 | super({ 16 | key: SCENE_KEYS.GAME_OVER_SCENE, 17 | }); 18 | } 19 | 20 | public create(): void { 21 | if (!this.input.keyboard) { 22 | return; 23 | } 24 | 25 | this.add.text(this.scale.width / 2, 100, 'Game Over', DEFAULT_UI_TEXT_STYLE).setOrigin(0.5); 26 | 27 | this.#menuContainer = this.add.container(32, 142, [ 28 | this.add.image(0, 0, ASSET_KEYS.UI_DIALOG, 0).setOrigin(0), 29 | this.add.text(32, 16, 'Continue', DEFAULT_UI_TEXT_STYLE).setOrigin(0), 30 | this.add.text(32, 32, 'Quit', DEFAULT_UI_TEXT_STYLE).setOrigin(0), 31 | ]); 32 | this.#cursorGameObject = this.add.image(20, 14, ASSET_KEYS.UI_CURSOR, 0).setOrigin(0); 33 | this.#menuContainer.add(this.#cursorGameObject); 34 | 35 | this.#controls = new KeyboardComponent(this.input.keyboard); 36 | this.#selectedMenuOptionIndex = 0; 37 | DataManager.instance.resetPlayerHealthToMin(); 38 | } 39 | 40 | public update(): void { 41 | if (this.#controls.isActionKeyJustDown || this.#controls.isAttackKeyJustDown || this.#controls.isEnterKeyJustDown) { 42 | if (this.#selectedMenuOptionIndex === 1) { 43 | // this option would be used to take the player back to the title screen for the game 44 | // instead of refreshing the current browser tab 45 | window.location.reload(); 46 | return; 47 | } 48 | 49 | this.scene.start(SCENE_KEYS.GAME_SCENE); 50 | return; 51 | } 52 | 53 | if (this.#controls.isUpJustDown) { 54 | this.#selectedMenuOptionIndex -= 1; 55 | if (this.#selectedMenuOptionIndex < 0) { 56 | this.#selectedMenuOptionIndex = 0; 57 | } 58 | } else if (this.#controls.isDownJustDown) { 59 | this.#selectedMenuOptionIndex += 1; 60 | if (this.#selectedMenuOptionIndex > 1) { 61 | this.#selectedMenuOptionIndex = 1; 62 | } 63 | } else { 64 | return; 65 | } 66 | 67 | this.#cursorGameObject.setY(14 + this.#selectedMenuOptionIndex * 16); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/scenes/preload-scene.ts: -------------------------------------------------------------------------------- 1 | import * as Phaser from 'phaser'; 2 | import { SCENE_KEYS } from './scene-keys'; 3 | import { ASSET_KEYS, ASSET_PACK_KEYS } from '../common/assets'; 4 | import { LevelData } from '../common/types'; 5 | import { DataManager } from '../common/data-manager'; 6 | 7 | export class PreloadScene extends Phaser.Scene { 8 | constructor() { 9 | super({ 10 | key: SCENE_KEYS.PRELOAD_SCENE, 11 | }); 12 | } 13 | 14 | public preload(): void { 15 | // load asset pack that has assets for the rest of the game 16 | this.load.pack(ASSET_PACK_KEYS.MAIN, 'assets/data/assets.json'); 17 | } 18 | 19 | public create(): void { 20 | this.#createAnimations(); 21 | 22 | const sceneData: LevelData = { 23 | level: DataManager.instance.data.currentArea.name, 24 | roomId: DataManager.instance.data.currentArea.startRoomId, 25 | doorId: DataManager.instance.data.currentArea.startDoorId, 26 | }; 27 | this.scene.start(SCENE_KEYS.GAME_SCENE, sceneData); 28 | } 29 | 30 | #createAnimations(): void { 31 | this.anims.createFromAseprite(ASSET_KEYS.HUD_NUMBERS); 32 | this.anims.createFromAseprite(ASSET_KEYS.PLAYER); 33 | this.anims.createFromAseprite(ASSET_KEYS.SPIDER); 34 | this.anims.createFromAseprite(ASSET_KEYS.WISP); 35 | this.anims.createFromAseprite(ASSET_KEYS.DROW); 36 | this.anims.create({ 37 | key: ASSET_KEYS.ENEMY_DEATH, 38 | frames: this.anims.generateFrameNumbers(ASSET_KEYS.ENEMY_DEATH), 39 | frameRate: 6, 40 | repeat: 0, 41 | delay: 0, 42 | }); 43 | this.anims.create({ 44 | key: ASSET_KEYS.POT_BREAK, 45 | frames: this.anims.generateFrameNumbers(ASSET_KEYS.POT_BREAK), 46 | frameRate: 6, 47 | repeat: 0, 48 | delay: 0, 49 | hideOnComplete: true, 50 | }); 51 | this.anims.create({ 52 | key: ASSET_KEYS.DAGGER, 53 | frames: this.anims.generateFrameNumbers(ASSET_KEYS.DAGGER), 54 | frameRate: 16, 55 | repeat: -1, 56 | delay: 0, 57 | }); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/scenes/scene-keys.ts: -------------------------------------------------------------------------------- 1 | export const SCENE_KEYS = { 2 | PRELOAD_SCENE: 'PRELOAD_SCENE', 3 | GAME_SCENE: 'GAME_SCENE', 4 | UI_SCENE: 'UI_SCENE', 5 | GAME_OVER_SCENE: 'GAME_OVER_SCENE', 6 | } as const; 7 | -------------------------------------------------------------------------------- /src/scenes/ui-scene.ts: -------------------------------------------------------------------------------- 1 | import * as Phaser from 'phaser'; 2 | import { SCENE_KEYS } from './scene-keys'; 3 | import { ASSET_KEYS, HEART_ANIMATIONS, HEART_TEXTURE_FRAME } from '../common/assets'; 4 | import { DataManager } from '../common/data-manager'; 5 | import { CUSTOM_EVENTS, EVENT_BUS, PLAYER_HEALTH_UPDATE_TYPE, PlayerHealthUpdated } from '../common/event-bus'; 6 | import { DEFAULT_UI_TEXT_STYLE } from '../common/common'; 7 | 8 | export class UiScene extends Phaser.Scene { 9 | #hudContainer!: Phaser.GameObjects.Container; 10 | #hearts!: Phaser.GameObjects.Sprite[]; 11 | #dialogContainer!: Phaser.GameObjects.Container; 12 | #dialogContainerText!: Phaser.GameObjects.Text; 13 | 14 | constructor() { 15 | super({ 16 | key: SCENE_KEYS.UI_SCENE, 17 | }); 18 | } 19 | 20 | public create(): void { 21 | // create main hud 22 | this.#hudContainer = this.add.container(0, 0, []); 23 | this.#hearts = []; 24 | 25 | const numberOfHearts = Math.floor(DataManager.instance.data.maxHealth / 2); 26 | const numberOfFullHearts = Math.floor(DataManager.instance.data.currentHealth / 2); 27 | const hasHalfHeart = DataManager.instance.data.currentHealth % 2 === 1; 28 | for (let i = 0; i < 20; i += 1) { 29 | let x = 157 + 8 * i; 30 | let y = 25; 31 | if (i >= 10) { 32 | x = 157 + 8 * (i - 10); 33 | y = 33; 34 | } 35 | let frame: string = HEART_TEXTURE_FRAME.NONE; 36 | if (i < numberOfFullHearts) { 37 | frame = HEART_TEXTURE_FRAME.FULL; 38 | } else if (i < numberOfHearts) { 39 | frame = HEART_TEXTURE_FRAME.EMPTY; 40 | } 41 | if (hasHalfHeart && i === numberOfFullHearts) { 42 | frame = HEART_TEXTURE_FRAME.HALF; 43 | } 44 | this.#hearts.push(this.add.sprite(x, y, ASSET_KEYS.HUD_NUMBERS, frame).setOrigin(0)); 45 | } 46 | this.#hudContainer.add(this.#hearts); 47 | 48 | this.#dialogContainer = this.add.container(32, 142, [this.add.image(0, 0, ASSET_KEYS.UI_DIALOG, 0).setOrigin(0)]); 49 | this.#dialogContainerText = this.add.text(14, 14, '', DEFAULT_UI_TEXT_STYLE).setOrigin(0); 50 | this.#dialogContainer.add(this.#dialogContainerText); 51 | this.#dialogContainer.visible = false; 52 | 53 | // register event listeners 54 | EVENT_BUS.on(CUSTOM_EVENTS.PLAYER_HEALTH_UPDATED, this.updateHealthInHud, this); 55 | EVENT_BUS.on(CUSTOM_EVENTS.SHOW_DIALOG, this.showDialog, this); 56 | 57 | this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => { 58 | EVENT_BUS.off(CUSTOM_EVENTS.PLAYER_HEALTH_UPDATED, this.updateHealthInHud, this); 59 | EVENT_BUS.off(CUSTOM_EVENTS.SHOW_DIALOG, this.showDialog, this); 60 | }); 61 | } 62 | 63 | public async updateHealthInHud(data: PlayerHealthUpdated): Promise { 64 | if (data.type === PLAYER_HEALTH_UPDATE_TYPE.INCREASE) { 65 | // if player has increased their health, picking up hearts, new heart container, fairy, etc., 66 | // need to update their health here 67 | return; 68 | } 69 | 70 | // play animation for losing hearts depending on the amount of health lost 71 | const healthDifference = data.previousHealth - data.currentHealth; 72 | let health = data.previousHealth; 73 | for (let i = 0; i < healthDifference; i += 1) { 74 | const heartIndex = Math.round(health / 2) - 1; 75 | const isHalfHeart = health % 2 === 1; 76 | let animationName = HEART_ANIMATIONS.LOSE_LAST_HALF; 77 | if (!isHalfHeart) { 78 | animationName = HEART_ANIMATIONS.LOSE_FIRST_HALF; 79 | } 80 | await new Promise((resolve) => { 81 | this.#hearts[heartIndex].play(animationName); 82 | this.#hearts[heartIndex].once(Phaser.Animations.Events.ANIMATION_COMPLETE_KEY + animationName, () => { 83 | resolve(undefined); 84 | }); 85 | }); 86 | health -= 1; 87 | } 88 | } 89 | 90 | public showDialog(message: string): void { 91 | this.#dialogContainer.visible = true; 92 | this.#dialogContainerText.setText(message); 93 | 94 | this.time.delayedCall(3000, () => { 95 | this.#dialogContainer.visible = false; 96 | EVENT_BUS.emit(CUSTOM_EVENTS.DIALOG_CLOSED); 97 | }); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@devshareacademy/tsconfig/tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "dist", 5 | "typeRoots": [ 6 | "node_modules/@types" 7 | ] 8 | }, 9 | "include": [ 10 | "**/*.ts" 11 | ], 12 | "ts-node": { 13 | "compilerOptions": { 14 | "module": "commonjs" 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | 3 | export default defineConfig({ 4 | build: { 5 | rollupOptions: { 6 | output: { 7 | entryFileNames: 'assets/js/[name]-[hash].js', 8 | }, 9 | }, 10 | }, 11 | }); 12 | --------------------------------------------------------------------------------