├── .github └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .mise.toml ├── .npmignore ├── .npmrc ├── .nvmrc ├── .prettierrc ├── .travis.yml ├── .vscode ├── launch.json └── settings.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── jekyll.config.yml ├── netlify.toml ├── packages ├── ecs │ ├── .gitignore │ ├── README.md │ ├── eslint.config.mjs │ ├── nodemon.json │ ├── package.json │ ├── src │ │ ├── component-map.test.ts │ │ ├── component-map.ts │ │ ├── component.ts │ │ ├── entity.ts │ │ ├── index.ts │ │ ├── system.ts │ │ ├── world.test.ts │ │ └── world.ts │ ├── tsconfig.json │ └── vite.config.ts └── examples │ ├── .gitignore │ ├── README.md │ ├── assets │ └── image │ │ ├── gabe-idle-run.png │ │ ├── pico8_invaders_sprites.png │ │ └── ship.png │ ├── favicon.svg │ ├── index.html │ ├── package.json │ ├── public │ └── vite.svg │ ├── src │ ├── counter.ts │ ├── demos │ │ ├── basic │ │ │ ├── components │ │ │ │ └── ball-tag.ts │ │ │ ├── index.html │ │ │ └── main.ts │ │ ├── bouncy-rectangles │ │ │ ├── index.html │ │ │ └── main.ts │ │ ├── debug-rendering │ │ │ ├── components │ │ │ │ ├── box-collider.ts │ │ │ │ ├── direction.ts │ │ │ │ ├── player-tag.ts │ │ │ │ ├── sprite-animation.ts │ │ │ │ └── sprite.ts │ │ │ ├── index.html │ │ │ ├── main.ts │ │ │ ├── spritesheet.ts │ │ │ ├── structures │ │ │ │ ├── animation-details.ts │ │ │ │ └── frame.ts │ │ │ └── systems │ │ │ │ ├── debug-rendering-system.ts │ │ │ │ ├── movement-system.ts │ │ │ │ ├── player-system.ts │ │ │ │ ├── rendering-system.ts │ │ │ │ └── sprite-animation-system.ts │ │ ├── sprite-animation │ │ │ ├── components │ │ │ │ ├── sprite-animation.ts │ │ │ │ └── sprite.ts │ │ │ ├── index.html │ │ │ ├── main.ts │ │ │ ├── spritesheet.ts │ │ │ ├── structures │ │ │ │ ├── animation-details.ts │ │ │ │ └── frame.ts │ │ │ └── systems │ │ │ │ ├── rendering-system.ts │ │ │ │ └── sprite-animation-system.ts │ │ └── sprite-tweening │ │ │ ├── components │ │ │ ├── sprite.ts │ │ │ ├── tween.ts │ │ │ └── tweens.ts │ │ │ ├── index.html │ │ │ ├── main.ts │ │ │ ├── structures │ │ │ └── frame.ts │ │ │ └── systems │ │ │ └── tweens-system.ts │ ├── lib │ │ ├── asset-loader.ts │ │ ├── dom.ts │ │ ├── tween.ts │ │ └── types │ │ │ └── dotted-paths.ts │ ├── main.ts │ ├── shared │ │ ├── components │ │ │ ├── color.ts │ │ │ ├── index.ts │ │ │ ├── position.ts │ │ │ ├── rectangle.ts │ │ │ ├── transform.ts │ │ │ └── velocity.ts │ │ └── vector2d.ts │ ├── style.css │ ├── typescript.svg │ └── vite-env.d.ts │ ├── tsconfig.json │ └── vite.config.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml └── workspace.code-workspace /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'main' 7 | 8 | pull_request: 9 | branches: 10 | - 'main' 11 | 12 | jobs: 13 | build: 14 | name: Build, lint, and test on Node ${{ matrix.node }} and ${{ matrix.os }} 15 | 16 | runs-on: ${{ matrix.os }} 17 | strategy: 18 | matrix: 19 | node: ['20.x', '22.x', '24.x'] 20 | os: [ubuntu-latest] 21 | 22 | steps: 23 | - uses: actions/checkout@v4.1.1 24 | 25 | - name: Use Node ${{ matrix.node }} 26 | uses: actions/setup-node@v4.0.2 27 | with: 28 | node-version: ${{ matrix.node }} 29 | 30 | - name: Setup 31 | run: npm i -g @antfu/ni 32 | 33 | - name: Install 34 | run: nci 35 | 36 | - name: Lint 37 | run: nr -C packages/ecs lint 38 | 39 | - name: Build 40 | run: nr -C packages/ecs build 41 | 42 | - name: Test 43 | run: nr -C packages/ecs test --run 44 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | release: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4.1.1 12 | with: 13 | fetch-depth: 0 14 | 15 | - uses: actions/setup-node@v4.0.2 16 | with: 17 | node-version: 22.x 18 | registry-url: 'https://registry.npmjs.org' 19 | 20 | - uses: pnpm/action-setup@v3.0.0 21 | with: 22 | version: 10.11.0 23 | 24 | - run: npx changelogithub 25 | env: 26 | GITHUB_TOKEN: ${{secrets.RELEASER_TOKEN}} 27 | 28 | - run: pnpm i 29 | - run: pnpm --filter @jakeklassen/ecs build 30 | - run: pnpm publish --no-git-checks --access public --filter @jakeklassen/ecs 31 | env: 32 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .cache 2 | node_modules 3 | dist 4 | coverage 5 | *.log 6 | .vscode/* 7 | !.vscode/launch.json 8 | !.vscode/settings.json 9 | -------------------------------------------------------------------------------- /.mise.toml: -------------------------------------------------------------------------------- 1 | [tools] 2 | pnpm = "10.11.0" 3 | node = "22.16.0" 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .* 2 | **/tsconfig.json 3 | **/nodemon.json 4 | **/tslint.json 5 | **/webpack.config.ts 6 | **/jest.config.js 7 | demo 8 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | save-exact=true 2 | registry=https://registry.npmjs.org/ 3 | unsafe-perm=true 4 | enable-pre-post-scripts=true 5 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 22 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "trailingComma": "all" 5 | } 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - node 4 | script: 5 | - npm run test:coverage 6 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Debug Current Test File", 11 | "autoAttachChildProcesses": true, 12 | "skipFiles": ["/**", "**/node_modules/**"], 13 | "program": "${workspaceRoot}/node_modules/vitest/vitest.mjs", 14 | "args": ["run", "${relativeFile}"], 15 | "smartStep": true, 16 | "console": "integratedTerminal" 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.preferences.importModuleSpecifierEnding": "js" 3 | } 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [3.0.0] - 2022-06-14 9 | 10 | - Simplify the library for a fresh start 11 | 12 | ## [2.3.7] - 2020-06-28 13 | 14 | ### Removed 15 | 16 | - `docs` folder in favour of github action 17 | 18 | ### Security 19 | 20 | - Package upgrades 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Jake Klassen 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 | ## @jakeklassen/ecs 2 | 3 | TypeScript Entity Component System. 4 | 5 | You might also like [objecs](https://github.com/jakeklassen/objecs). A different take on ECS that I've been working on. 6 | 7 | [Live Demos](https://ecs-examples.netlify.app/) 8 | 9 | [Examples](packages/examples) 10 | 11 | ## Benchmarks 12 | 13 | 🚧 14 | 15 | ## Roadmap 16 | 17 | - Event System 18 | - More tests 19 | - Benchmarks 20 | - More examples 21 | -------------------------------------------------------------------------------- /jekyll.config.yml: -------------------------------------------------------------------------------- 1 | # Used to configure jekyll for typedoc 2 | include: 3 | - '_*_.html' 4 | - '_*_.*.html' 5 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | publish = "packages/examples/dist" 3 | 4 | command = "npm i -g pnpm @antfu/ni && nci && nr -C packages/ecs build && nr -C packages/examples build" -------------------------------------------------------------------------------- /packages/ecs/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /packages/ecs/README.md: -------------------------------------------------------------------------------- 1 | ## @jakeklassen/ecs 2 | 3 | TypeScript Entity Component System. 4 | 5 | You might also like [objecs](https://github.com/jakeklassen/objecs). A different take on ECS that I've been working on. 6 | 7 | [Live Demos](https://ecs-examples.netlify.app/) 8 | 9 | [Examples](../examples/) 10 | 11 | ## Benchmarks 12 | 13 | 🚧 14 | -------------------------------------------------------------------------------- /packages/ecs/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { FlatCompat } from '@eslint/eslintrc'; 2 | import js from '@eslint/js'; 3 | import typescriptEslintPlugin from '@typescript-eslint/eslint-plugin'; 4 | import tsParser from '@typescript-eslint/parser'; 5 | import * as depend from 'eslint-plugin-depend'; 6 | import prettier from 'eslint-plugin-prettier'; 7 | import globals from 'globals'; 8 | import path from 'node:path'; 9 | import { fileURLToPath } from 'node:url'; 10 | 11 | const __filename = fileURLToPath(import.meta.url); 12 | const __dirname = path.dirname(__filename); 13 | const compat = new FlatCompat({ 14 | baseDirectory: __dirname, 15 | recommendedConfig: js.configs.recommended, 16 | allConfig: js.configs.all, 17 | }); 18 | 19 | export default [ 20 | { 21 | ignores: ['**/node_modules', '**/build', '**/dist', '**/public'], 22 | }, 23 | ...compat.extends( 24 | 'eslint:recommended', 25 | 'plugin:prettier/recommended', 26 | 'plugin:@typescript-eslint/recommended', 27 | ), 28 | depend.configs['flat/recommended'], 29 | { 30 | plugins: { 31 | prettier, 32 | typescriptEslintPlugin, 33 | }, 34 | 35 | languageOptions: { 36 | globals: { 37 | ...globals.node, 38 | ...globals.jest, 39 | }, 40 | 41 | parser: tsParser, 42 | ecmaVersion: 2023, 43 | sourceType: 'module', 44 | }, 45 | 46 | rules: { 47 | '@typescript-eslint/interface-name-prefix': 'off', 48 | '@typescript-eslint/explicit-function-return-type': 'off', 49 | '@typescript-eslint/no-explicit-any': 'off', 50 | '@typescript-eslint/camelcase': 'off', 51 | '@typescript-eslint/no-var-requires': 'off', 52 | 'no-unused-vars': 'off', 53 | 54 | '@typescript-eslint/no-unused-vars': [ 55 | 'error', 56 | { 57 | argsIgnorePattern: '^_', 58 | varsIgnorePattern: '^_', 59 | }, 60 | ], 61 | }, 62 | }, 63 | ]; 64 | -------------------------------------------------------------------------------- /packages/ecs/nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "ignore": [ 3 | "./dist/", 4 | "./README.md" 5 | ], 6 | "watch": [ 7 | "./src/" 8 | ], 9 | "ext": "js,json,ts,tsx" 10 | } -------------------------------------------------------------------------------- /packages/ecs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@jakeklassen/ecs", 3 | "version": "4.0.7", 4 | "description": "Entity Component System", 5 | "type": "module", 6 | "main": "./dist/index.cjs", 7 | "module": "./dist/index.js", 8 | "types": "./dist/index.d.ts", 9 | "exports": { 10 | ".": { 11 | "import": "./dist/index.js", 12 | "require": "./dist/index.cjs" 13 | } 14 | }, 15 | "files": [ 16 | "dist", 17 | "src" 18 | ], 19 | "repository": { 20 | "type": "git", 21 | "url": "https://github.com/jakeklassen/ecs.git" 22 | }, 23 | "scripts": { 24 | "lint": "eslint \"src/**/*.{js,jsx,ts,tsx}\" eslint.config.mjs", 25 | "lint:fix": "eslint --fix \"src/**/*.{js,jsx,ts,tsx}\" eslint.config.mjs", 26 | "prebuild": "npm run clean", 27 | "build": "npm run lint && tsup src --format cjs,esm --dts-resolve", 28 | "build:watch": "nodemon --exec 'npm run build || exit 1'", 29 | "test": "vitest", 30 | "pretest:coverage": "npm run clean", 31 | "test:coverage": "vitest run --coverage", 32 | "clean": "rimraf dist", 33 | "check-exports": "attw --pack ." 34 | }, 35 | "author": "Jake Klassen ", 36 | "license": "MIT", 37 | "keywords": [ 38 | "ecs", 39 | "gamedev", 40 | "game development", 41 | "entity component system", 42 | "typescript" 43 | ], 44 | "devDependencies": { 45 | "@arethetypeswrong/cli": "0.18.1", 46 | "@eslint/eslintrc": "3.3.1", 47 | "@eslint/js": "9.27.0", 48 | "@types/benchmark": "2.1.5", 49 | "@types/eslint": "^9.6.1", 50 | "@types/node": "^22.15.21", 51 | "@typescript-eslint/eslint-plugin": "^8.32.1", 52 | "@typescript-eslint/parser": "^8.32.1", 53 | "benchmark": "^2.1.4", 54 | "bumpp": "10.1.1", 55 | "eslint": "^9.27.0", 56 | "eslint-config-prettier": "^10.1.5", 57 | "eslint-plugin-depend": "1.2.0", 58 | "eslint-plugin-prettier": "5.4.0", 59 | "globals": "16.2.0", 60 | "nodemon": "^3.1.10", 61 | "prettier": "^3.5.3", 62 | "rimraf": "^6.0.1", 63 | "ts-node": "^10.9.2", 64 | "typescript": "^5.8.3", 65 | "vite": "6.3.5", 66 | "vitest": "^3.1.4" 67 | }, 68 | "dependencies": { 69 | "tsup": "^8.5.0", 70 | "type-fest": "4.41.0" 71 | } 72 | } -------------------------------------------------------------------------------- /packages/ecs/src/component-map.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { ComponentMap } from './component-map.js'; 3 | import { Component } from './component.js'; 4 | 5 | class Color extends Component { 6 | constructor(public color = 'black') { 7 | super(); 8 | } 9 | } 10 | 11 | class Position extends Component { 12 | constructor( 13 | public x = 0, 14 | public y = 0, 15 | ) { 16 | super(); 17 | } 18 | } 19 | 20 | describe('ComponentMap', () => { 21 | it('should support adding components', () => { 22 | const componentMap = new ComponentMap(); 23 | const red = new Color('red'); 24 | const startingPosition = new Position(); 25 | 26 | componentMap.add(red, startingPosition); 27 | 28 | expect(componentMap.get(Color)).toEqual(red); 29 | expect(componentMap.get(Position)).toEqual(startingPosition); 30 | }); 31 | 32 | it('should support removing components', () => { 33 | const componentMap = new ComponentMap(); 34 | 35 | componentMap.add(new Color(), new Position()); 36 | componentMap.delete(Color, Position); 37 | 38 | expect(componentMap.has(Color)).toEqual(false); 39 | expect(componentMap.has(Position)).toEqual(false); 40 | }); 41 | 42 | it('should support checking multiple components', () => { 43 | const componentMap = new ComponentMap(); 44 | const red = new Color('red'); 45 | const startingPosition = new Position(); 46 | 47 | componentMap.add(red, startingPosition); 48 | 49 | expect(componentMap.has(Color, Position)).toEqual(true); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /packages/ecs/src/component-map.ts: -------------------------------------------------------------------------------- 1 | import { Component } from './component.js'; 2 | import { ComponentConstructor } from './world.js'; 3 | 4 | /** 5 | * A ComponentMap with a guaranteed set of Components. 6 | */ 7 | export type SafeComponentMap = { 8 | // ComponentMap['get'] overload for specific component constructors 9 | get(ctor: C): InstanceType; 10 | } & ComponentMap; 11 | 12 | export class ComponentMap extends Map { 13 | public add(...components: Component[]): void { 14 | components.forEach((component) => { 15 | this.set(component.constructor as ComponentConstructor, component); 16 | }); 17 | } 18 | 19 | public override delete( 20 | ...componentConstructors: ComponentConstructor[] 21 | ): boolean { 22 | componentConstructors.forEach((componentConstructor) => { 23 | super.delete(componentConstructor); 24 | }); 25 | 26 | return true; 27 | } 28 | 29 | public override has( 30 | ...componentConstructors: ComponentConstructor[] 31 | ): boolean { 32 | return componentConstructors.every((componentConstructor) => { 33 | return super.has(componentConstructor); 34 | }); 35 | } 36 | 37 | public override get( 38 | componentConstructor: ComponentConstructor, 39 | ): C | undefined { 40 | return super.get(componentConstructor) as C; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /packages/ecs/src/component.ts: -------------------------------------------------------------------------------- 1 | export abstract class Component { 2 | /** 3 | * The purpose of this property is to somewhat support nominal typing. 4 | * TypeScript is structurally typed, so we add a protected readonly 5 | * property that can be inherited and will satisfy the Component shape. 6 | * This way, we can make some reasonably safe assumptions about the 7 | * type of a Component. 8 | * 9 | * @example 10 | * declare function doStuff(c: Component): void; 11 | * 12 | * // Try to simluate being a Component 13 | * class Foo { 14 | * protected readonly __component = Foo.name; 15 | * } 16 | * 17 | * // Argument of type 'Foo' is not assignable to parameter of type 'Component'. 18 | * doStuff(new Foo()); // ❌ 19 | * 20 | * class Health extends Component { 21 | * accessor health = 100; 22 | * } 23 | * 24 | * doStuff(new Health()); // ✅ 25 | */ 26 | protected readonly __component = Component.name; 27 | } 28 | -------------------------------------------------------------------------------- /packages/ecs/src/entity.ts: -------------------------------------------------------------------------------- 1 | export type EntityId = number; 2 | -------------------------------------------------------------------------------- /packages/ecs/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './entity.js'; 2 | export * from './system.js'; 3 | export * from './world.js'; 4 | export * from './component.js'; 5 | -------------------------------------------------------------------------------- /packages/ecs/src/system.ts: -------------------------------------------------------------------------------- 1 | import { World } from './world.js'; 2 | 3 | /** 4 | * Class for all Systems to derive from 5 | */ 6 | export abstract class System { 7 | /** 8 | * 9 | * @param world World 10 | * @param dt Delta time 11 | */ 12 | public abstract update(world: World, dt: number): void; 13 | } 14 | -------------------------------------------------------------------------------- /packages/ecs/src/world.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { ComponentMap } from './component-map.js'; 3 | import { Component } from './component.js'; 4 | import { World } from './world.js'; 5 | 6 | class Color extends Component { 7 | constructor(public value = 'black') { 8 | super(); 9 | } 10 | } 11 | 12 | class Rectangle extends Component { 13 | constructor( 14 | public width = 0, 15 | public height = 0, 16 | ) { 17 | super(); 18 | } 19 | } 20 | 21 | class Transform extends Component { 22 | constructor(public position = new Position()) { 23 | super(); 24 | } 25 | } 26 | 27 | class Position extends Component { 28 | constructor( 29 | public x = 0, 30 | public y = 0, 31 | ) { 32 | super(); 33 | } 34 | } 35 | 36 | describe('World', () => { 37 | describe('get entities()', () => { 38 | it('returns a readonly map of entites', () => { 39 | const world = new World(); 40 | 41 | world.createEntity(); 42 | world.createEntity(); 43 | 44 | expect(world.entities).toBeInstanceOf(Map); 45 | 46 | for (const [entity, components] of world.entities) { 47 | expect(entity).toBeGreaterThan(0); 48 | expect(components).toBeInstanceOf(ComponentMap); 49 | } 50 | }); 51 | }); 52 | 53 | describe('createEntity()', () => { 54 | it('returns a new entity', () => { 55 | const world = new World(); 56 | expect(world.createEntity()).toBeDefined(); 57 | }); 58 | 59 | it('can return a recycled entity', () => { 60 | const world = new World(); 61 | const entity = world.createEntity(); 62 | 63 | world.deleteEntity(entity); 64 | 65 | const entity2 = world.createEntity(); 66 | 67 | expect(entity).toBe(entity2); 68 | }); 69 | }); 70 | 71 | describe('deleteEntity()', () => { 72 | it('should delete an entity and return true', () => { 73 | const world = new World(); 74 | const entity = world.createEntity(); 75 | 76 | expect(world.deleteEntity(entity)).toBe(true); 77 | }); 78 | 79 | it('should not delete an entity and return false', () => { 80 | const world = new World(); 81 | 82 | expect(world.deleteEntity(Number.MAX_SAFE_INTEGER)).toBe(false); 83 | }); 84 | 85 | it('should return false if the entity has already been deleted', () => { 86 | const world = new World(); 87 | const entity = world.createEntity(); 88 | 89 | expect(world.deleteEntity(entity)).toBe(true); 90 | expect(world.deleteEntity(entity)).toBe(false); 91 | }); 92 | }); 93 | 94 | describe('addEntityComponents()', () => { 95 | it('should add components to entity component map', () => { 96 | const world = new World(); 97 | const entity = world.createEntity(); 98 | world.addEntityComponents(entity, new Color(), new Rectangle()); 99 | 100 | const components = world.getEntityComponents(entity); 101 | 102 | expect(components?.has(Color, Rectangle)).toBe(true); 103 | }); 104 | 105 | it('should return `World` instance for chaining', () => { 106 | const world = new World(); 107 | const entity = world.createEntity(); 108 | 109 | expect(world.addEntityComponents(entity, new Color())).toBeInstanceOf( 110 | World, 111 | ); 112 | }); 113 | 114 | it('should throw an error if entity has been deleted', () => { 115 | const world = new World(); 116 | const entity = world.createEntity(); 117 | world.deleteEntity(entity); 118 | 119 | expect(() => 120 | world.addEntityComponents(entity, new Color()), 121 | ).toThrowError(); 122 | }); 123 | }); 124 | 125 | describe('removeEntityComponents()', () => { 126 | it('should remove entity components', () => { 127 | const world = new World(); 128 | const entity = world.createEntity(); 129 | world.addEntityComponents(entity, new Color(), new Rectangle()); 130 | 131 | let components = world.getEntityComponents(entity); 132 | 133 | expect(components?.has(Color, Rectangle)).toBe(true); 134 | 135 | world.removeEntityComponents(entity, Color); 136 | 137 | components = world.getEntityComponents(entity); 138 | 139 | expect(components?.has(Color)).toBe(false); 140 | expect(components?.has(Rectangle)).toBe(true); 141 | }); 142 | 143 | it('should not error if entity does not have components', () => { 144 | const world = new World(); 145 | const entity = world.createEntity(); 146 | world.addEntityComponents(entity, new Color()); 147 | 148 | expect(() => 149 | world.removeEntityComponents(entity, Rectangle), 150 | ).not.toThrow(); 151 | }); 152 | 153 | it('should not throw if entity does not exist and is not deleted', () => { 154 | const world = new World(); 155 | 156 | expect(() => world.removeEntityComponents(1, Color)).not.toThrowError(); 157 | }); 158 | 159 | it('should throw if entity has been mark for deletion', () => { 160 | const world = new World(); 161 | const entity = world.createEntity(); 162 | world.deleteEntity(entity); 163 | 164 | expect(() => world.removeEntityComponents(entity, Color)).toThrowError(); 165 | }); 166 | }); 167 | 168 | describe('findEntity()', () => { 169 | it('should return the entity', () => { 170 | const world = new World(); 171 | const entity = world.createEntity(); 172 | world.addEntityComponents(entity, new Color()); 173 | 174 | expect(world.findEntity(Color)).toBe(entity); 175 | }); 176 | 177 | it('should return the first matching entity by insertion order', () => { 178 | const world = new World(); 179 | const entity = world.createEntity(); 180 | world.addEntityComponents(entity, new Color()); 181 | 182 | expect(world.findEntity(Color)).toBe(entity); 183 | 184 | const entity2 = world.createEntity(); 185 | world.addEntityComponents(entity2, new Color(), new Rectangle()); 186 | 187 | expect(world.findEntity(Color, Rectangle)).toBe(entity2); 188 | 189 | const entity3 = world.createEntity(); 190 | world.addEntityComponents( 191 | entity3, 192 | new Color(), 193 | new Rectangle(), 194 | new Transform(), 195 | ); 196 | 197 | expect(world.findEntity(Color, Rectangle)).toBe(entity2); 198 | expect(world.findEntity(Color, Rectangle, Transform)).toBe(entity3); 199 | }); 200 | 201 | it('should return undefined when not found', () => { 202 | const world = new World(); 203 | const entity = world.createEntity(); 204 | world.addEntityComponents(entity, new Rectangle()); 205 | 206 | expect(world.findEntity(Color)).toBeUndefined(); 207 | }); 208 | 209 | it('should return undefined when no component constructors are passed', () => { 210 | const world = new World(); 211 | const entity = world.createEntity(); 212 | world.addEntityComponents(entity, new Rectangle()); 213 | 214 | expect(world.findEntity()).toBeUndefined(); 215 | }); 216 | 217 | it('should not return a deleted entities', () => { 218 | const world = new World(); 219 | const entity = world.createEntity(); 220 | world.addEntityComponents(entity, new Color()); 221 | 222 | const entity2 = world.createEntity(); 223 | world.addEntityComponents(entity2, new Color(), new Rectangle()); 224 | 225 | world.deleteEntity(entity); 226 | 227 | expect(world.findEntity(Color)).not.toBe(entity); 228 | }); 229 | }); 230 | 231 | describe('getEntityComponents()', () => { 232 | it('should return ComponentMap for existing entity', () => { 233 | const world = new World(); 234 | const entity = world.createEntity(); 235 | const components = world.getEntityComponents(entity); 236 | 237 | expect(components).toBeInstanceOf(ComponentMap); 238 | }); 239 | 240 | it('should return undefined for non-existing entity', () => { 241 | const world = new World(); 242 | 243 | expect( 244 | world.getEntityComponents(Number.MAX_SAFE_INTEGER), 245 | ).toBeUndefined(); 246 | }); 247 | 248 | it('should return undefined if entity has been deleted', () => { 249 | const world = new World(); 250 | const entity = world.createEntity(); 251 | world.deleteEntity(entity); 252 | 253 | expect(world.getEntityComponents(entity)).toBeUndefined(); 254 | }); 255 | }); 256 | 257 | describe('view()', () => { 258 | it('should return the correct views', () => { 259 | const world = new World(); 260 | const entityId = world.createEntity(); 261 | const testPosition = new Position(); 262 | 263 | world.addEntityComponents(entityId, testPosition); 264 | expect(world.view(Position)).toEqual([ 265 | [entityId, world.getEntityComponents(entityId)], 266 | ]); 267 | 268 | expect(world.view(Position, Color)).toEqual([]); 269 | }); 270 | }); 271 | }); 272 | -------------------------------------------------------------------------------- /packages/ecs/src/world.ts: -------------------------------------------------------------------------------- 1 | import { ComponentMap, SafeComponentMap } from './component-map.js'; 2 | import { Component } from './component.js'; 3 | import { EntityId } from './entity.js'; 4 | import { System } from './system.js'; 5 | 6 | export type ComponentConstructor = new ( 7 | ...args: any[] 8 | ) => T; 9 | 10 | export function* entityIdGenerator(): IterableIterator { 11 | let id = 0; 12 | 13 | while (true) { 14 | ++id; 15 | yield id; 16 | } 17 | } 18 | 19 | /** 20 | * Container for Systems and Entities 21 | */ 22 | export class World { 23 | #systems: System[] = []; 24 | #systemsToRemove: System[] = []; 25 | #systemsToAdd: System[] = []; 26 | #entities: Map = new Map(); 27 | #deletedEntities: Set = new Set(); 28 | #componentEntities: Map> = new Map(); 29 | 30 | /** 31 | * Create a new World instance 32 | * @param idGenerator Unique entity id generator 33 | */ 34 | constructor(private readonly idGenerator = entityIdGenerator()) {} 35 | 36 | public get entities(): ReadonlyMap { 37 | return this.#entities; 38 | } 39 | 40 | /** 41 | * Update all world systems 42 | * @param dt Delta time 43 | */ 44 | public update(dt: number): void { 45 | this.updateSystems(dt); 46 | } 47 | 48 | public createEntity(): EntityId { 49 | if (this.#deletedEntities.size > 0) { 50 | const entity = this.#deletedEntities.values().next().value!; 51 | 52 | this.#deletedEntities.delete(entity); 53 | 54 | return entity; 55 | } 56 | 57 | const entity = this.idGenerator.next().value; 58 | this.#entities.set(entity, new ComponentMap()); 59 | 60 | return entity; 61 | } 62 | 63 | /** 64 | * Delete an entity from the world. Entities can be recycled so do not rely 65 | * on the deleted entity reference after deleting it. 66 | * @param entity Entity to delete 67 | */ 68 | public deleteEntity(entity: EntityId): boolean { 69 | if (this.#deletedEntities.has(entity)) { 70 | return false; 71 | } 72 | 73 | const componentMap = this.#entities.get(entity); 74 | 75 | if (componentMap == null) { 76 | return false; 77 | } 78 | 79 | for (const ctor of componentMap.keys()) { 80 | this.#componentEntities.get(ctor)?.delete(entity); 81 | } 82 | 83 | componentMap.clear(); 84 | this.#deletedEntities.add(entity); 85 | 86 | return true; 87 | } 88 | 89 | public findEntity( 90 | ...componentCtors: ComponentConstructor[] 91 | ): EntityId | undefined { 92 | if (componentCtors.length === 0) { 93 | return; 94 | } 95 | 96 | const hasAllComponents = componentCtors.every((ctor) => 97 | this.#componentEntities.has(ctor), 98 | ); 99 | 100 | if (hasAllComponents === false) { 101 | return; 102 | } 103 | 104 | const componentSets = componentCtors 105 | .map((ctor) => { 106 | return this.#componentEntities.get(ctor); 107 | }) 108 | .filter((entitySet): entitySet is Set => entitySet != null); 109 | 110 | const smallestComponentSet = componentSets.reduce((smallest, set) => { 111 | if (smallest == null) { 112 | smallest = set; 113 | } else if (set.size < smallest.size) { 114 | smallest = set; 115 | } 116 | 117 | return smallest; 118 | }); 119 | 120 | const otherComponentSets = componentSets.filter( 121 | (set) => set !== smallestComponentSet, 122 | ); 123 | 124 | for (const entity of smallestComponentSet.values()) { 125 | const hasAll = otherComponentSets.every((set) => set.has(entity)); 126 | 127 | if (hasAll === true) { 128 | return entity; 129 | } 130 | } 131 | } 132 | 133 | public addEntityComponents( 134 | entity: EntityId, 135 | ...components: Component[] 136 | ): World { 137 | if (this.#deletedEntities.has(entity)) { 138 | throw new Error('Entity has been deleted'); 139 | } 140 | 141 | const entityComponents = this.#entities.get(entity); 142 | 143 | if (entityComponents != null) { 144 | entityComponents.add(...components); 145 | 146 | for (const componentCtor of entityComponents.keys()) { 147 | if (this.#componentEntities.has(componentCtor)) { 148 | this.#componentEntities.get(componentCtor)?.add(entity); 149 | } else { 150 | this.#componentEntities.set(componentCtor, new Set([entity])); 151 | } 152 | } 153 | } 154 | 155 | return this; 156 | } 157 | 158 | public getEntityComponents(entity: EntityId): ComponentMap | undefined { 159 | if (this.#deletedEntities.has(entity)) { 160 | return undefined; 161 | } 162 | 163 | return this.#entities.get(entity); 164 | } 165 | 166 | public removeEntityComponents( 167 | entity: EntityId, 168 | ...components: ComponentConstructor[] 169 | ): World { 170 | if (this.#deletedEntities.has(entity)) { 171 | throw new Error('Entity has been deleted'); 172 | } 173 | 174 | const entityComponents = this.#entities.get(entity); 175 | 176 | if (entityComponents != null) { 177 | entityComponents.delete(...components); 178 | 179 | for (const component of components) { 180 | this.#componentEntities.get(component)?.delete(entity); 181 | } 182 | } 183 | 184 | return this; 185 | } 186 | 187 | /** 188 | * Register a system for addition. Systems are executed linearly in the order added. 189 | * @param system System 190 | */ 191 | public addSystem(system: System): void { 192 | this.#systemsToAdd.push(system); 193 | } 194 | 195 | /** 196 | * Register a system for removal. 197 | * @param system System 198 | */ 199 | public removeSystem(system: System): void { 200 | this.#systemsToRemove.push(system); 201 | } 202 | 203 | public updateSystems(dt: number): void { 204 | if (this.#systemsToRemove.length > 0) { 205 | this.#systems = this.#systems.filter((existing) => 206 | this.#systemsToRemove.includes(existing), 207 | ); 208 | 209 | this.#systemsToRemove = []; 210 | } 211 | 212 | if (this.#systemsToAdd.length > 0) { 213 | this.#systemsToAdd.forEach((newSystem) => { 214 | if (this.#systems.includes(newSystem) === false) { 215 | this.#systems.push(newSystem); 216 | } 217 | }); 218 | 219 | this.#systemsToAdd = []; 220 | } 221 | 222 | for (const system of this.#systems) { 223 | system.update(this, dt); 224 | } 225 | } 226 | 227 | public view( 228 | ...componentCtors: CC 229 | ): Array<[EntityId, SafeComponentMap]> { 230 | const entities: Array<[EntityId, SafeComponentMap]> = []; 231 | 232 | if (componentCtors.length === 0) { 233 | return entities; 234 | } 235 | 236 | const componentSets = componentCtors 237 | .map((ctor) => { 238 | return this.#componentEntities.get(ctor); 239 | }) 240 | .filter((entitySet): entitySet is Set => entitySet != null); 241 | 242 | // Make sure we even have record of all the component constructors 243 | if (componentSets.length !== componentCtors.length) { 244 | return entities; 245 | } 246 | 247 | const smallestComponentSet = componentSets.reduce((smallest, set) => { 248 | if (smallest == null) { 249 | smallest = set; 250 | } else if (set.size < smallest.size) { 251 | smallest = set; 252 | } 253 | 254 | return smallest; 255 | }); 256 | 257 | const otherComponentSets = componentSets.filter( 258 | (set) => set !== smallestComponentSet, 259 | ); 260 | 261 | for (const entity of smallestComponentSet) { 262 | const hasAll = otherComponentSets.every((set) => set.has(entity)); 263 | 264 | if (hasAll === true) { 265 | entities.push([ 266 | entity, 267 | this.getEntityComponents(entity) as SafeComponentMap, 268 | ]); 269 | } 270 | } 271 | 272 | return entities; 273 | } 274 | } 275 | -------------------------------------------------------------------------------- /packages/ecs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "target": "ES2023" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. */, 5 | "module": "NodeNext" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */, 6 | "lib": [] /* Specify library files to be included in the compilation. */, 7 | "allowJs": true /* Allow javascript files to be compiled. */, 8 | "checkJs": true /* Report errors in .js files. */, 9 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 10 | "declaration": true /* Generates corresponding '.d.ts' file. */, 11 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 12 | "declarationDir": "./dist" /* Output directory for generated declaration files. */, 13 | "sourceMap": true /* Generates corresponding '.map' file. */, 14 | // "outFile": "./", /* Concatenate and emit output to single file. */ 15 | "outDir": "./dist" /* Redirect output structure to the directory. */, 16 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 17 | // "composite": true, /* Enable project compilation */ 18 | // "removeComments": true, /* Do not emit comments to output. */ 19 | // "noEmit": true, /* Do not emit outputs. */ 20 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 21 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 22 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 23 | /* Strict Type-Checking Options */ 24 | "strict": true /* Enable all strict type-checking options. */, 25 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 26 | // "strictNullChecks": true, /* Enable strict null checks. */ 27 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 28 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 29 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 30 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 31 | /* Additional Checks */ 32 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 33 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 34 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 35 | "noImplicitOverride": true /* Report error when a class member overrides an inherited member. */, 36 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 37 | /* Module Resolution Options */ 38 | "moduleResolution": "NodeNext" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */, 39 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 40 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 41 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 42 | // "typeRoots": [], /* List of folders to include type definitions from. */ 43 | // "types": [], /* Type declaration files to be included in compilation. */ 44 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 45 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 46 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 47 | /* Source Map Options */ 48 | // "sourceRoot": "./", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 49 | // "mapRoot": "./", /* Specify the location where debugger should locate map files instead of generated locations. */ 50 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 51 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 52 | /* Experimental Options */ 53 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 54 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 55 | }, 56 | "include": ["src/**/*", "vite.config.ts", "eslint.config.mjs"], 57 | "exclude": ["node_modules", "coverage", "dist", "test"] 58 | } 59 | -------------------------------------------------------------------------------- /packages/ecs/vite.config.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import { defineConfig } from 'vite'; 3 | 4 | const config = defineConfig({ 5 | resolve: { 6 | // https://github.com/vitejs/vite/issues/88#issuecomment-784441588 7 | alias: { 8 | '#': path.resolve(__dirname, 'src'), 9 | }, 10 | }, 11 | build: { 12 | target: 'chrome107', 13 | }, 14 | }); 15 | 16 | export default config; 17 | -------------------------------------------------------------------------------- /packages/examples/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /packages/examples/README.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | ## How to Run 4 | 5 | ```sh 6 | $ pnpm i 7 | $ pnpm dev 8 | ``` 9 | 10 | Open your browser to the provided url. 11 | -------------------------------------------------------------------------------- /packages/examples/assets/image/gabe-idle-run.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jakeklassen/ecs/bbf35bf2940838cda2f0663dd364d117be2ac2f8/packages/examples/assets/image/gabe-idle-run.png -------------------------------------------------------------------------------- /packages/examples/assets/image/pico8_invaders_sprites.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jakeklassen/ecs/bbf35bf2940838cda2f0663dd364d117be2ac2f8/packages/examples/assets/image/pico8_invaders_sprites.png -------------------------------------------------------------------------------- /packages/examples/assets/image/ship.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jakeklassen/ecs/bbf35bf2940838cda2f0663dd364d117be2ac2f8/packages/examples/assets/image/ship.png -------------------------------------------------------------------------------- /packages/examples/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /packages/examples/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | ECS Examples 8 | 9 | 10 |
11 |

Demos

12 | Basic
13 | Bouncy Rectangles
14 | Debug Rendering
15 | Sprite Animation
16 | Sprite Tweening 17 |
18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /packages/examples/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "examples", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "preview": "vite preview" 10 | }, 11 | "devDependencies": { 12 | "typescript": "^5.8.3", 13 | "vite": "^6.3.5" 14 | }, 15 | "dependencies": { 16 | "@jakeklassen/ecs": "workspace:^4.0.0", 17 | "@tweakpane/core": "2.0.5", 18 | "just-safe-set": "4.2.1", 19 | "tweakpane": "4.0.5" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/examples/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/examples/src/counter.ts: -------------------------------------------------------------------------------- 1 | export function setupCounter(element: HTMLButtonElement) { 2 | let counter = 0 3 | const setCounter = (count: number) => { 4 | counter = count 5 | element.innerHTML = `count is ${counter}` 6 | } 7 | element.addEventListener('click', () => setCounter(++counter)) 8 | setCounter(0) 9 | } 10 | -------------------------------------------------------------------------------- /packages/examples/src/demos/basic/components/ball-tag.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@jakeklassen/ecs'; 2 | 3 | export class BallTag extends Component {} 4 | -------------------------------------------------------------------------------- /packages/examples/src/demos/basic/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Demos - Basic 8 | 9 | 14 | 15 | 16 |
17 |
18 | Back | 19 | Source 23 |
24 | 25 | 26 |
27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /packages/examples/src/demos/basic/main.ts: -------------------------------------------------------------------------------- 1 | import { System, World } from '@jakeklassen/ecs'; 2 | import { Color, Rectangle, Transform, Velocity } from '#/shared/components'; 3 | import { Vector2d } from '#/shared/vector2d'; 4 | import '../../style.css'; 5 | import { BallTag } from './components/ball-tag'; 6 | 7 | const canvas = document.querySelector('#canvas') as HTMLCanvasElement; 8 | const ctx = canvas.getContext('2d'); 9 | 10 | if (ctx == null) { 11 | throw new Error('failed to obtain canvas 2d context'); 12 | } 13 | 14 | const world = new World(); 15 | const ball = world.createEntity(); 16 | 17 | world.addEntityComponents( 18 | ball, 19 | new BallTag(), 20 | new Transform(new Vector2d(10, 10)), 21 | new Velocity(100, 200), 22 | new Rectangle(12, 12), 23 | new Color('red'), 24 | ); 25 | 26 | class BallMovementSystem extends System { 27 | constructor(private readonly viewport: Rectangle) { 28 | super(); 29 | } 30 | 31 | public update(world: World, dt: number) { 32 | const ball = world.findEntity(BallTag); 33 | 34 | if (ball == null) { 35 | throw new Error('Entity with BallTag not found'); 36 | } 37 | 38 | const components = world.getEntityComponents(ball)!; 39 | 40 | const rectangle = components.get(Rectangle)!; 41 | const transform = components.get(Transform)!; 42 | const velocity = components.get(Velocity)!; 43 | 44 | transform.position.x += velocity.x * dt; 45 | transform.position.y += velocity.y * dt; 46 | 47 | if (transform.position.x + rectangle.width > this.viewport.width) { 48 | transform.position.x = this.viewport.width - rectangle.width; 49 | velocity.flipX(); 50 | } else if (transform.position.x < 0) { 51 | transform.position.x = 0; 52 | velocity.flipX(); 53 | } 54 | 55 | if (transform.position.y + rectangle.height > this.viewport.height) { 56 | transform.position.y = this.viewport.height - rectangle.height; 57 | velocity.flipY(); 58 | } else if (transform.position.y < 0) { 59 | transform.position.y = 0; 60 | velocity.flipY(); 61 | } 62 | } 63 | } 64 | 65 | class RenderingSystem extends System { 66 | constructor(private readonly context: CanvasRenderingContext2D) { 67 | super(); 68 | } 69 | 70 | public update(world: World) { 71 | this.context.clearRect(0, 0, 640, 480); 72 | 73 | for (const [_entity, components] of world.view( 74 | Rectangle, 75 | Color, 76 | Transform, 77 | )) { 78 | const { color } = components.get(Color); 79 | const { width, height } = components.get(Rectangle); 80 | const transform = components.get(Transform); 81 | 82 | this.context.fillStyle = color; 83 | this.context.fillRect( 84 | transform.position.x, 85 | transform.position.y, 86 | width, 87 | height, 88 | ); 89 | } 90 | } 91 | } 92 | 93 | world.addSystem( 94 | new BallMovementSystem(new Rectangle(canvas.width, canvas.height)), 95 | ); 96 | world.addSystem(new RenderingSystem(ctx)); 97 | 98 | let last = performance.now(); 99 | 100 | /** 101 | * The game loop. 102 | */ 103 | const frame = (hrt: DOMHighResTimeStamp) => { 104 | const dt = Math.min(1000, hrt - last) / 1000; 105 | 106 | world.updateSystems(dt); 107 | 108 | last = hrt; 109 | 110 | requestAnimationFrame(frame); 111 | }; 112 | 113 | // Start the game loop. 114 | requestAnimationFrame(frame); 115 | -------------------------------------------------------------------------------- /packages/examples/src/demos/bouncy-rectangles/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Demos - Bouncy Rectangles 8 | 9 | 14 | 15 | 16 |
17 |
18 | Back | 19 | Source 23 |
24 | 25 | 26 |
27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /packages/examples/src/demos/bouncy-rectangles/main.ts: -------------------------------------------------------------------------------- 1 | import { Color } from '#/shared/components/color'; 2 | import { Position } from '#/shared/components/position'; 3 | import { Rectangle } from '#/shared/components/rectangle'; 4 | import { Velocity } from '#/shared/components/velocity'; 5 | import { System, World } from '@jakeklassen/ecs'; 6 | import '../../style.css'; 7 | 8 | const canvas = document.querySelector('canvas')!; 9 | const ctx = canvas.getContext('2d')!; 10 | 11 | let dt = 0; 12 | let last = performance.now(); 13 | 14 | /** 15 | * The game loop. 16 | */ 17 | function frame(hrt: DOMHighResTimeStamp) { 18 | // How much time has elapsed since the last frame? 19 | // Also convert to seconds. 20 | dt = (hrt - last) / 1000; 21 | 22 | // we need to work with our systems 23 | world.updateSystems(dt); 24 | 25 | last = hrt; 26 | 27 | // Keep the game loop going forever 28 | requestAnimationFrame(frame); 29 | } 30 | 31 | // create the world 32 | const world = new World(); 33 | 34 | const getRandom = (max: number, min = 0) => 35 | Math.floor(Math.random() * max) + min; 36 | 37 | // attach components 38 | for (let i = 0; i < 100; ++i) { 39 | world.addEntityComponents( 40 | world.createEntity(), 41 | new Position(getRandom(canvas.width), getRandom(canvas.height)), 42 | new Velocity(getRandom(100, 20), getRandom(100, 20)), 43 | new Color( 44 | `rgba(${getRandom(255, 0)}, ${getRandom(255, 0)}, ${getRandom( 45 | 255, 46 | 0, 47 | )}, 1)`, 48 | ), 49 | new Rectangle(getRandom(20, 10), getRandom(20, 10)), 50 | ); 51 | } 52 | 53 | // SYSTEMS 54 | 55 | class PhysicsSystem extends System { 56 | constructor(public readonly viewport: Rectangle) { 57 | super(); 58 | } 59 | 60 | update(world: World, dt: number) { 61 | for (const [, componentMap] of world.view(Position, Velocity, Rectangle)) { 62 | // Move the position by some velocity 63 | const position = componentMap.get(Position); 64 | const velocity = componentMap.get(Velocity); 65 | const rectangle = componentMap.get(Rectangle); 66 | 67 | position.x += velocity.x * dt; 68 | position.y += velocity.y * dt; 69 | 70 | if (position.x + rectangle.width > this.viewport.width) { 71 | // Snap collider back into viewport 72 | position.x = this.viewport.width - rectangle.width; 73 | velocity.x = -velocity.x; 74 | } else if (position.x < 0) { 75 | position.x = 0; 76 | velocity.x = -velocity.x; 77 | } 78 | 79 | if (position.y + rectangle.height > this.viewport.height) { 80 | position.y = this.viewport.height - rectangle.height; 81 | velocity.y = -velocity.y; 82 | } else if (position.y < 0) { 83 | position.y = 0; 84 | velocity.y = -velocity.y; 85 | } 86 | } 87 | } 88 | } 89 | 90 | // Rendering system 91 | class RenderingSystem extends System { 92 | constructor(private readonly context: CanvasRenderingContext2D) { 93 | super(); 94 | } 95 | 96 | public update(world: World, _dt: number): void { 97 | this.context.clearRect(0, 0, canvas.width, canvas.height); 98 | 99 | for (const [, componentMap] of world.view(Position, Color, Rectangle)) { 100 | const { color } = componentMap.get(Color); 101 | const { width, height } = componentMap.get(Rectangle); 102 | const { x, y } = componentMap.get(Position); 103 | 104 | this.context.fillStyle = color; 105 | this.context.fillRect(x, y, width, height); 106 | } 107 | } 108 | } 109 | 110 | world.addSystem(new PhysicsSystem(new Rectangle(canvas.width, canvas.height))); 111 | world.addSystem(new RenderingSystem(ctx)); 112 | 113 | // we need to start the game 114 | requestAnimationFrame(frame); 115 | -------------------------------------------------------------------------------- /packages/examples/src/demos/debug-rendering/components/box-collider.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@jakeklassen/ecs'; 2 | 3 | export class BoxCollider extends Component { 4 | constructor( 5 | public offsetX: number, 6 | public offsetY: number, 7 | public width: number, 8 | public height: number, 9 | ) { 10 | super(); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/examples/src/demos/debug-rendering/components/direction.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@jakeklassen/ecs'; 2 | 3 | export class Direction extends Component { 4 | constructor(public x: number, public y: number) { 5 | super(); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/examples/src/demos/debug-rendering/components/player-tag.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@jakeklassen/ecs'; 2 | 3 | export class PlayerTag extends Component {} 4 | -------------------------------------------------------------------------------- /packages/examples/src/demos/debug-rendering/components/sprite-animation.ts: -------------------------------------------------------------------------------- 1 | import { AnimationDetails } from '../structures/animation-details'; 2 | import { Frame } from '../structures/frame'; 3 | import { Component } from '@jakeklassen/ecs'; 4 | 5 | export class SpriteAnimation extends Component { 6 | public delta = 0; 7 | public currentFrame = 0; 8 | public finished = false; 9 | public frames: Frame[] = []; 10 | 11 | /** 12 | * The frame rate of the animation in seconds. 13 | */ 14 | public frameRate = 0; 15 | 16 | constructor( 17 | public animationDetails: AnimationDetails, 18 | public durationMs: number, 19 | public loop: boolean = true, 20 | public frameSequence: number[] = [], 21 | ) { 22 | super(); 23 | 24 | const horizontalFrames = 25 | animationDetails.width / animationDetails.frameWidth; 26 | const verticalFrames = 27 | animationDetails.height / animationDetails.frameHeight; 28 | 29 | for (let i = 0; i < verticalFrames; i++) { 30 | const sourceY = 31 | animationDetails.sourceY + i * animationDetails.frameWidth; 32 | 33 | for (let j = 0; j < horizontalFrames; j++) { 34 | const sourceX = 35 | animationDetails.sourceX + j * animationDetails.frameHeight; 36 | 37 | this.frames.push( 38 | new Frame( 39 | sourceX, 40 | sourceY, 41 | animationDetails.frameWidth, 42 | animationDetails.frameHeight, 43 | ), 44 | ); 45 | } 46 | } 47 | 48 | if (frameSequence.length === 0) { 49 | this.frameSequence = this.frames.map((_, i) => i); 50 | } 51 | 52 | // Determine the frame rate based on the duration of the animation 53 | // and the number of frames. 54 | // Also divide by 1000 to convert from milliseconds to seconds. 55 | this.frameRate = this.durationMs / 1_000 / this.frameSequence.length; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /packages/examples/src/demos/debug-rendering/components/sprite.ts: -------------------------------------------------------------------------------- 1 | import { Frame } from '../structures/frame'; 2 | import { Component } from '@jakeklassen/ecs'; 3 | 4 | export class Sprite extends Component { 5 | constructor(public frame: Frame, public opacity = 1) { 6 | super(); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/examples/src/demos/debug-rendering/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Demos - Debug Rendering 8 | 9 | 17 | 18 | 19 |
20 |
21 | Back | 22 | Source 26 |
27 | 28 | 29 |
30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /packages/examples/src/demos/debug-rendering/main.ts: -------------------------------------------------------------------------------- 1 | import gabeRunImageUrl from '#/assets/image/gabe-idle-run.png'; 2 | import { loadImage } from '#/lib/asset-loader'; 3 | import { obtainCanvasAndContext2d } from '#/lib/dom'; 4 | import { Transform } from '#/shared/components/transform'; 5 | import { Velocity } from '#/shared/components/velocity'; 6 | import { Vector2d } from '#/shared/vector2d'; 7 | import { World } from '@jakeklassen/ecs'; 8 | import { Pane } from 'tweakpane'; 9 | import '../../style.css'; 10 | import { BoxCollider } from './components/box-collider'; 11 | import { Direction } from './components/direction'; 12 | import { PlayerTag } from './components/player-tag'; 13 | import { Sprite } from './components/sprite'; 14 | import { SpriteAnimation } from './components/sprite-animation'; 15 | import { SpriteSheet } from './spritesheet'; 16 | import { AnimationDetails } from './structures/animation-details'; 17 | import { Frame } from './structures/frame'; 18 | import { DebugRenderingSystem } from './systems/debug-rendering-system'; 19 | import { MovementSystem } from './systems/movement-system'; 20 | import { PlayerSystem } from './systems/player-system'; 21 | import { RenderingSystem } from './systems/rendering-system'; 22 | import { SpriteAnimationSystem } from './systems/sprite-animation-system'; 23 | 24 | const gabeImage = await loadImage(gabeRunImageUrl); 25 | 26 | const { canvas, context } = obtainCanvasAndContext2d('#canvas'); 27 | 28 | context.imageSmoothingEnabled = false; 29 | 30 | const config = { 31 | debug: false, 32 | }; 33 | 34 | const PARAMS = { 35 | debug: false, 36 | }; 37 | 38 | const pane = new Pane(); 39 | const debugInput = pane.addBinding(PARAMS, 'debug'); 40 | 41 | debugInput.on('change', (event) => { 42 | config.debug = event.value; 43 | }); 44 | 45 | const world = new World(); 46 | 47 | world.addEntityComponents( 48 | world.createEntity(), 49 | new PlayerTag(), 50 | new Transform(new Vector2d(32, 54), 0, new Vector2d(1, 1)), 51 | new Velocity(50, 0), 52 | new Direction(1, 0), 53 | new BoxCollider(4, 2, 16, 22), 54 | new Sprite( 55 | new Frame( 56 | SpriteSheet.gabe.animations.run.sourceX, 57 | SpriteSheet.gabe.animations.run.sourceY, 58 | SpriteSheet.gabe.animations.run.frameWidth, 59 | SpriteSheet.gabe.animations.run.frameHeight, 60 | ), 61 | ), 62 | new SpriteAnimation( 63 | new AnimationDetails( 64 | 'gabe-run', 65 | SpriteSheet.gabe.animations.run.sourceX, 66 | SpriteSheet.gabe.animations.run.sourceY, 67 | SpriteSheet.gabe.animations.run.width, 68 | SpriteSheet.gabe.animations.run.height, 69 | SpriteSheet.gabe.animations.run.frameWidth, 70 | SpriteSheet.gabe.animations.run.frameHeight, 71 | ), 72 | 1000, 73 | ), 74 | ); 75 | 76 | world.addSystem(new SpriteAnimationSystem()); 77 | world.addSystem(new MovementSystem()); 78 | world.addSystem( 79 | new PlayerSystem({ width: canvas.width, height: canvas.height }), 80 | ); 81 | world.addSystem(new RenderingSystem(context, gabeImage)); 82 | world.addSystem(new DebugRenderingSystem(context, config)); 83 | 84 | let last = performance.now(); 85 | 86 | /** 87 | * The game loop. 88 | */ 89 | const frame = (hrt: DOMHighResTimeStamp) => { 90 | const dt = Math.min(1000, hrt - last) / 1000; 91 | 92 | world.updateSystems(dt); 93 | 94 | last = hrt; 95 | 96 | requestAnimationFrame(frame); 97 | }; 98 | 99 | // Start the game loop. 100 | requestAnimationFrame(frame); 101 | -------------------------------------------------------------------------------- /packages/examples/src/demos/debug-rendering/spritesheet.ts: -------------------------------------------------------------------------------- 1 | export const SpriteSheet = { 2 | gabe: { 3 | idle: { 4 | sourceX: 0, 5 | sourceY: 0, 6 | width: 24, 7 | height: 24, 8 | }, 9 | animations: { 10 | run: { 11 | sourceX: 24, 12 | sourceY: 0, 13 | width: 144, 14 | height: 24, 15 | frameWidth: 24, 16 | frameHeight: 24, 17 | }, 18 | }, 19 | }, 20 | enemy: { 21 | littleGreenGuy: { 22 | frame0: { 23 | sourceX: 24, 24 | sourceY: 0, 25 | width: 8, 26 | height: 8, 27 | }, 28 | frame1: { 29 | sourceX: 24, 30 | sourceY: 8, 31 | width: 8, 32 | height: 8, 33 | }, 34 | animations: { 35 | idle: { 36 | sourceX: 24, 37 | sourceY: 0, 38 | width: 8, 39 | height: 16, 40 | frameWidth: 8, 41 | frameHeight: 8, 42 | }, 43 | death: { 44 | sourceX: 0, 45 | sourceY: 128, 46 | width: 256, 47 | height: 160, 48 | frameWidth: 32, 49 | frameHeight: 32, 50 | positionXOffset: -5, 51 | positionYOffset: -5, 52 | }, 53 | }, 54 | }, 55 | }, 56 | } as const; 57 | 58 | export type SpriteSheet = typeof SpriteSheet; 59 | -------------------------------------------------------------------------------- /packages/examples/src/demos/debug-rendering/structures/animation-details.ts: -------------------------------------------------------------------------------- 1 | export class AnimationDetails { 2 | constructor( 3 | public name: string, 4 | public sourceX: number, 5 | public sourceY: number, 6 | public width: number, 7 | public height: number, 8 | public frameWidth: number, 9 | public frameHeight: number, 10 | ) {} 11 | } 12 | -------------------------------------------------------------------------------- /packages/examples/src/demos/debug-rendering/structures/frame.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This represents a frame of a sprite sheet. 3 | */ 4 | export class Frame { 5 | constructor( 6 | public sourceX: number, 7 | public sourceY: number, 8 | public width: number, 9 | public height: number, 10 | ) {} 11 | } 12 | -------------------------------------------------------------------------------- /packages/examples/src/demos/debug-rendering/systems/debug-rendering-system.ts: -------------------------------------------------------------------------------- 1 | import { Transform } from '#/shared/components/transform'; 2 | import { System, World } from '@jakeklassen/ecs'; 3 | import { BoxCollider } from '../components/box-collider'; 4 | 5 | export class DebugRenderingSystem extends System { 6 | constructor( 7 | private readonly context: CanvasRenderingContext2D, 8 | private readonly config: Readonly<{ debug: boolean }>, 9 | ) { 10 | super(); 11 | } 12 | 13 | update(world: World, _dt: number): void { 14 | if (this.config.debug === false) { 15 | return; 16 | } 17 | 18 | for (const [_entity, components] of world.view(Transform, BoxCollider)) { 19 | const transform = components.get(Transform); 20 | const boxCollider = components.get(BoxCollider); 21 | 22 | this.context.translate(transform.position.x, transform.position.y); 23 | this.context.rotate(transform.rotation); 24 | this.context.scale(transform.scale.x, transform.scale.y); 25 | 26 | this.context.globalAlpha = 0.3; 27 | 28 | this.context.fillStyle = 'red'; 29 | this.context.fillRect( 30 | transform.scale.x > 0 31 | ? boxCollider.offsetX 32 | : -boxCollider.offsetX - boxCollider.width, 33 | transform.scale.y > 0 34 | ? boxCollider.offsetY 35 | : -boxCollider.offsetY - boxCollider.height, 36 | boxCollider.width, 37 | boxCollider.height, 38 | ); 39 | 40 | this.context.globalAlpha = 1; 41 | this.context.resetTransform(); 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /packages/examples/src/demos/debug-rendering/systems/movement-system.ts: -------------------------------------------------------------------------------- 1 | import { Transform } from '#/shared/components/transform'; 2 | import { Velocity } from '#/shared/components/velocity'; 3 | import { System, World } from '@jakeklassen/ecs'; 4 | import { Direction } from '../components/direction'; 5 | 6 | export class MovementSystem extends System { 7 | update(world: World, dt: number): void { 8 | for (const [_entity, components] of world.view( 9 | Direction, 10 | Transform, 11 | Velocity, 12 | )) { 13 | const direction = components.get(Direction); 14 | const transform = components.get(Transform); 15 | const velocity = components.get(Velocity); 16 | 17 | transform.position.x += velocity.x * direction.x * dt; 18 | transform.position.y += velocity.y * direction.y * dt; 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/examples/src/demos/debug-rendering/systems/player-system.ts: -------------------------------------------------------------------------------- 1 | import { Transform } from '#/shared/components/transform'; 2 | import { System, World } from '@jakeklassen/ecs'; 3 | import { BoxCollider } from '../components/box-collider'; 4 | import { Direction } from '../components/direction'; 5 | import { PlayerTag } from '../components/player-tag'; 6 | 7 | export class PlayerSystem extends System { 8 | constructor(private readonly viewport: { width: number; height: number }) { 9 | super(); 10 | } 11 | 12 | public update(world: World) { 13 | for (const [_entity, components] of world.view( 14 | PlayerTag, 15 | Transform, 16 | Direction, 17 | BoxCollider, 18 | )) { 19 | const transform = components.get(Transform); 20 | const direction = components.get(Direction); 21 | const boxCollider = components.get(BoxCollider); 22 | 23 | if ( 24 | transform.position.x + boxCollider.offsetX > 25 | this.viewport.width - boxCollider.width 26 | ) { 27 | transform.position.x = 28 | this.viewport.width - boxCollider.width - boxCollider.offsetX; 29 | transform.scale.x = -1; 30 | direction.x = -1; 31 | } else if (transform.position.x + boxCollider.offsetX < 0) { 32 | transform.position.x = -boxCollider.offsetX; 33 | transform.scale.x = 1; 34 | direction.x = 1; 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /packages/examples/src/demos/debug-rendering/systems/rendering-system.ts: -------------------------------------------------------------------------------- 1 | import { Transform } from '#/shared/components/transform'; 2 | import { System, World } from '@jakeklassen/ecs'; 3 | import { BoxCollider } from '../components/box-collider'; 4 | import { Sprite } from '../components/sprite'; 5 | 6 | export class RenderingSystem extends System { 7 | canvas: HTMLCanvasElement; 8 | 9 | constructor( 10 | private readonly context: CanvasRenderingContext2D, 11 | private readonly spriteSheet: HTMLImageElement, 12 | ) { 13 | super(); 14 | 15 | this.canvas = context.canvas; 16 | } 17 | 18 | public update(world: World) { 19 | this.context.clearRect(0, 0, this.canvas.width, this.canvas.height); 20 | 21 | for (const [_entity, components] of world.view( 22 | Sprite, 23 | Transform, 24 | BoxCollider, 25 | )) { 26 | const sprite = components.get(Sprite); 27 | const transform = components.get(Transform); 28 | 29 | this.context.globalAlpha = sprite.opacity; 30 | 31 | this.context.translate(transform.position.x, transform.position.y); 32 | this.context.rotate(transform.rotation); 33 | this.context.scale(transform.scale.x, transform.scale.y); 34 | 35 | this.context.drawImage( 36 | this.spriteSheet, 37 | sprite.frame.sourceX, 38 | sprite.frame.sourceY, 39 | sprite.frame.width, 40 | sprite.frame.height, 41 | transform.scale.x > 0 ? 0 : -sprite.frame.width, 42 | transform.scale.y > 0 ? 0 : -sprite.frame.height, 43 | sprite.frame.width, 44 | sprite.frame.height, 45 | ); 46 | 47 | this.context.globalAlpha = 1; 48 | this.context.resetTransform(); 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /packages/examples/src/demos/debug-rendering/systems/sprite-animation-system.ts: -------------------------------------------------------------------------------- 1 | import { System, World } from '@jakeklassen/ecs'; 2 | import { Sprite } from '../components/sprite'; 3 | import { SpriteAnimation } from '../components/sprite-animation'; 4 | 5 | export class SpriteAnimationSystem extends System { 6 | update(world: World, dt: number): void { 7 | for (const [_entity, components] of world.view(SpriteAnimation, Sprite)) { 8 | const animation = components.get(SpriteAnimation); 9 | const sprite = components.get(Sprite); 10 | 11 | if (animation.finished && !animation.loop) { 12 | // You could do something like spawn a SpriteAnimationFinishedEvent here. 13 | // Then handle it in another system. 14 | // world.addEntityComponents( 15 | // world.createEntity(), 16 | // new SpriteAnimationFinishedEvent(entity, animation), 17 | // ); 18 | } 19 | 20 | animation.delta += dt; 21 | 22 | if (animation.delta >= animation.frameRate) { 23 | animation.delta = 0; 24 | 25 | animation.currentFrame = 26 | (animation.currentFrame + 1) % animation.frameSequence.length; 27 | 28 | if (animation.currentFrame === 0 && !animation.loop) { 29 | animation.finished = true; 30 | continue; 31 | } 32 | 33 | sprite.frame = 34 | animation.frames[animation.frameSequence[animation.currentFrame]]; 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /packages/examples/src/demos/sprite-animation/components/sprite-animation.ts: -------------------------------------------------------------------------------- 1 | import { AnimationDetails } from '../structures/animation-details'; 2 | import { Frame } from '../structures/frame'; 3 | import { Component } from '@jakeklassen/ecs'; 4 | 5 | export class SpriteAnimation extends Component { 6 | public delta = 0; 7 | public currentFrame = 0; 8 | public finished = false; 9 | public frames: Frame[] = []; 10 | 11 | /** 12 | * The frame rate of the animation in seconds. 13 | */ 14 | public frameRate = 0; 15 | 16 | constructor( 17 | public animationDetails: AnimationDetails, 18 | public durationMs: number, 19 | public loop: boolean = true, 20 | public frameSequence: number[] = [], 21 | ) { 22 | super(); 23 | 24 | const horizontalFrames = 25 | animationDetails.width / animationDetails.frameWidth; 26 | const verticalFrames = 27 | animationDetails.height / animationDetails.frameHeight; 28 | 29 | for (let i = 0; i < verticalFrames; i++) { 30 | const sourceY = 31 | animationDetails.sourceY + i * animationDetails.frameWidth; 32 | 33 | for (let j = 0; j < horizontalFrames; j++) { 34 | const sourceX = 35 | animationDetails.sourceX + j * animationDetails.frameHeight; 36 | 37 | this.frames.push( 38 | new Frame( 39 | sourceX, 40 | sourceY, 41 | animationDetails.frameWidth, 42 | animationDetails.frameHeight, 43 | ), 44 | ); 45 | } 46 | } 47 | 48 | if (frameSequence.length === 0) { 49 | this.frameSequence = this.frames.map((_, i) => i); 50 | } 51 | 52 | // Determine the frame rate based on the duration of the animation 53 | // and the number of frames. 54 | // Also divide by 1000 to convert from milliseconds to seconds. 55 | this.frameRate = this.durationMs / 1_000 / this.frameSequence.length; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /packages/examples/src/demos/sprite-animation/components/sprite.ts: -------------------------------------------------------------------------------- 1 | import { Frame } from '../structures/frame'; 2 | import { Component } from '@jakeklassen/ecs'; 3 | 4 | export class Sprite extends Component { 5 | constructor(public frame: Frame, public opacity = 1) { 6 | super(); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/examples/src/demos/sprite-animation/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Demos - Sprite Animation 8 | 9 | 17 | 18 | 19 |
20 |
21 | Back | 22 | Source 26 |
27 | 28 | 29 |
30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /packages/examples/src/demos/sprite-animation/main.ts: -------------------------------------------------------------------------------- 1 | import gabeRunImageUrl from '#/assets/image/gabe-idle-run.png'; 2 | import { loadImage } from '#/lib/asset-loader'; 3 | import { obtainCanvasAndContext2d } from '#/lib/dom'; 4 | import { Transform } from '#/shared/components/transform'; 5 | import { Vector2d } from '#/shared/vector2d'; 6 | import { World } from '@jakeklassen/ecs'; 7 | import '../../style.css'; 8 | import { Sprite } from './components/sprite'; 9 | import { SpriteAnimation } from './components/sprite-animation'; 10 | import { SpriteSheet } from './spritesheet'; 11 | import { AnimationDetails } from './structures/animation-details'; 12 | import { Frame } from './structures/frame'; 13 | import { RenderingSystem } from './systems/rendering-system'; 14 | import { SpriteAnimationSystem } from './systems/sprite-animation-system'; 15 | 16 | const gabeImage = await loadImage(gabeRunImageUrl); 17 | 18 | const { canvas, context } = obtainCanvasAndContext2d('#canvas'); 19 | 20 | context.imageSmoothingEnabled = false; 21 | 22 | const world = new World(); 23 | 24 | world.addEntityComponents( 25 | world.createEntity(), 26 | new Transform( 27 | new Vector2d( 28 | canvas.width / 2 - SpriteSheet.gabe.animations.run.frameWidth / 2, 29 | canvas.height / 2 - SpriteSheet.gabe.animations.run.frameHeight / 2, 30 | ), 31 | ), 32 | new Sprite( 33 | new Frame( 34 | SpriteSheet.gabe.animations.run.sourceX, 35 | SpriteSheet.gabe.animations.run.sourceY, 36 | SpriteSheet.gabe.animations.run.frameWidth, 37 | SpriteSheet.gabe.animations.run.frameHeight, 38 | ), 39 | ), 40 | new SpriteAnimation( 41 | new AnimationDetails( 42 | 'gabe-run', 43 | SpriteSheet.gabe.animations.run.sourceX, 44 | SpriteSheet.gabe.animations.run.sourceY, 45 | SpriteSheet.gabe.animations.run.width, 46 | SpriteSheet.gabe.animations.run.height, 47 | SpriteSheet.gabe.animations.run.frameWidth, 48 | SpriteSheet.gabe.animations.run.frameHeight, 49 | ), 50 | 1000, 51 | ), 52 | ); 53 | 54 | world.addSystem(new SpriteAnimationSystem()); 55 | world.addSystem(new RenderingSystem(context, gabeImage)); 56 | 57 | let last = performance.now(); 58 | 59 | /** 60 | * The game loop. 61 | */ 62 | const frame = (hrt: DOMHighResTimeStamp) => { 63 | const dt = Math.min(1000, hrt - last) / 1000; 64 | 65 | world.updateSystems(dt); 66 | 67 | last = hrt; 68 | 69 | requestAnimationFrame(frame); 70 | }; 71 | 72 | // Start the game loop. 73 | requestAnimationFrame(frame); 74 | -------------------------------------------------------------------------------- /packages/examples/src/demos/sprite-animation/spritesheet.ts: -------------------------------------------------------------------------------- 1 | export const SpriteSheet = { 2 | gabe: { 3 | idle: { 4 | sourceX: 0, 5 | sourceY: 0, 6 | width: 24, 7 | height: 24, 8 | }, 9 | animations: { 10 | run: { 11 | sourceX: 24, 12 | sourceY: 0, 13 | width: 144, 14 | height: 24, 15 | frameWidth: 24, 16 | frameHeight: 24, 17 | }, 18 | }, 19 | }, 20 | enemy: { 21 | littleGreenGuy: { 22 | frame0: { 23 | sourceX: 24, 24 | sourceY: 0, 25 | width: 8, 26 | height: 8, 27 | }, 28 | frame1: { 29 | sourceX: 24, 30 | sourceY: 8, 31 | width: 8, 32 | height: 8, 33 | }, 34 | animations: { 35 | idle: { 36 | sourceX: 24, 37 | sourceY: 0, 38 | width: 8, 39 | height: 16, 40 | frameWidth: 8, 41 | frameHeight: 8, 42 | }, 43 | death: { 44 | sourceX: 0, 45 | sourceY: 128, 46 | width: 256, 47 | height: 160, 48 | frameWidth: 32, 49 | frameHeight: 32, 50 | positionXOffset: -5, 51 | positionYOffset: -5, 52 | }, 53 | }, 54 | }, 55 | }, 56 | } as const; 57 | 58 | export type SpriteSheet = typeof SpriteSheet; 59 | -------------------------------------------------------------------------------- /packages/examples/src/demos/sprite-animation/structures/animation-details.ts: -------------------------------------------------------------------------------- 1 | export class AnimationDetails { 2 | constructor( 3 | public name: string, 4 | public sourceX: number, 5 | public sourceY: number, 6 | public width: number, 7 | public height: number, 8 | public frameWidth: number, 9 | public frameHeight: number, 10 | ) {} 11 | } 12 | -------------------------------------------------------------------------------- /packages/examples/src/demos/sprite-animation/structures/frame.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This represents a frame of a sprite sheet. 3 | */ 4 | export class Frame { 5 | constructor( 6 | public sourceX: number, 7 | public sourceY: number, 8 | public width: number, 9 | public height: number, 10 | ) {} 11 | } 12 | -------------------------------------------------------------------------------- /packages/examples/src/demos/sprite-animation/systems/rendering-system.ts: -------------------------------------------------------------------------------- 1 | import { Transform } from '#/shared/components/transform'; 2 | import { System, World } from '@jakeklassen/ecs'; 3 | import { Sprite } from '../components/sprite'; 4 | 5 | export class RenderingSystem extends System { 6 | canvas: HTMLCanvasElement; 7 | 8 | constructor( 9 | private readonly context: CanvasRenderingContext2D, 10 | private readonly spriteSheet: HTMLImageElement, 11 | ) { 12 | super(); 13 | 14 | this.canvas = context.canvas; 15 | } 16 | 17 | public update(world: World) { 18 | this.context.clearRect(0, 0, this.canvas.width, this.canvas.height); 19 | 20 | for (const [_entity, components] of world.view(Sprite, Transform)) { 21 | const sprite = components.get(Sprite); 22 | const transform = components.get(Transform); 23 | 24 | this.context.globalAlpha = sprite.opacity; 25 | 26 | this.context.translate(transform.position.x, transform.position.y); 27 | this.context.rotate(transform.rotation); 28 | this.context.scale(transform.scale.x, transform.scale.y); 29 | 30 | this.context.drawImage( 31 | this.spriteSheet, 32 | sprite.frame.sourceX, 33 | sprite.frame.sourceY, 34 | sprite.frame.width, 35 | sprite.frame.height, 36 | transform.scale.x > 0 ? 0 : -sprite.frame.width, 37 | transform.scale.y > 0 ? 0 : -sprite.frame.height, 38 | sprite.frame.width, 39 | sprite.frame.height, 40 | ); 41 | 42 | this.context.globalAlpha = 1; 43 | this.context.resetTransform(); 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /packages/examples/src/demos/sprite-animation/systems/sprite-animation-system.ts: -------------------------------------------------------------------------------- 1 | import { System, World } from '@jakeklassen/ecs'; 2 | import { Sprite } from '../components/sprite'; 3 | import { SpriteAnimation } from '../components/sprite-animation'; 4 | 5 | export class SpriteAnimationSystem extends System { 6 | update(world: World, dt: number): void { 7 | for (const [_entity, components] of world.view(SpriteAnimation, Sprite)) { 8 | const animation = components.get(SpriteAnimation); 9 | const sprite = components.get(Sprite); 10 | 11 | if (animation.finished && !animation.loop) { 12 | // You could do something like spawn a SpriteAnimationFinishedEvent here. 13 | // Then handle it in another system. 14 | // world.addEntityComponents( 15 | // world.createEntity(), 16 | // new SpriteAnimationFinishedEvent(entity, animation), 17 | // ); 18 | } 19 | 20 | animation.delta += dt; 21 | 22 | if (animation.delta >= animation.frameRate) { 23 | animation.delta = 0; 24 | 25 | animation.currentFrame = 26 | (animation.currentFrame + 1) % animation.frameSequence.length; 27 | 28 | if (animation.currentFrame === 0 && !animation.loop) { 29 | animation.finished = true; 30 | continue; 31 | } 32 | 33 | sprite.frame = 34 | animation.frames[animation.frameSequence[animation.currentFrame]]; 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /packages/examples/src/demos/sprite-tweening/components/sprite.ts: -------------------------------------------------------------------------------- 1 | import { Frame } from '../structures/frame'; 2 | import { Component } from '@jakeklassen/ecs'; 3 | 4 | export class Sprite extends Component { 5 | constructor(public frame: Frame, public opacity = 1) { 6 | super(); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/examples/src/demos/sprite-tweening/components/tween.ts: -------------------------------------------------------------------------------- 1 | import { Easing } from '#/lib/tween'; 2 | import { DottedPaths } from '#/lib/types/dotted-paths'; 3 | import { Component, ComponentConstructor } from '@jakeklassen/ecs'; 4 | 5 | export interface TweenOptions { 6 | /** 7 | * The duration of the tween in milliseconds. 8 | */ 9 | duration: number; 10 | easing: Easing; 11 | from: number; 12 | to: number; 13 | 14 | /** 15 | * Defaults to Infinity and only applies to yoyo tweens. 16 | */ 17 | maxIterations?: number; 18 | 19 | /** 20 | * Defaults to false. 21 | */ 22 | yoyo?: boolean; 23 | 24 | onComplete?: 'remove' | undefined; 25 | } 26 | 27 | export abstract class Tween< 28 | T extends ComponentConstructor, 29 | I extends InstanceType, 30 | > extends Component { 31 | public completed = false; 32 | public progress = 0; 33 | public iterations = 0; 34 | public options: Required; 35 | public time = 0; 36 | public start: number; 37 | public end: number; 38 | public change: number; 39 | public duration: number; 40 | 41 | constructor( 42 | public component: T, 43 | public property: DottedPaths, 44 | options: TweenOptions, 45 | ) { 46 | super(); 47 | 48 | this.options = { 49 | duration: options.duration, 50 | easing: options.easing, 51 | from: options.from, 52 | to: options.to, 53 | maxIterations: options.maxIterations ?? Infinity, 54 | yoyo: options.yoyo ?? false, 55 | onComplete: options.onComplete ?? 'remove', 56 | }; 57 | 58 | this.start = this.options.from; 59 | this.end = this.options.to; 60 | this.change = this.end - this.start; 61 | this.duration = this.options.duration / 1000; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /packages/examples/src/demos/sprite-tweening/components/tweens.ts: -------------------------------------------------------------------------------- 1 | import { easeLinear, Easing } from '#/lib/tween'; 2 | import { DottedPaths } from '#/lib/types/dotted-paths'; 3 | import { Transform } from '#/shared/components/transform'; 4 | import { Component, ComponentConstructor, World } from '@jakeklassen/ecs'; 5 | import justSafeSet from 'just-safe-set'; 6 | import { Sprite } from './sprite'; 7 | 8 | /** 9 | * Thinking about a new way to represent many tweens in a 10 | * single component, with reasonable type safety. 11 | */ 12 | 13 | type ComponentDottedPaths = T extends ComponentConstructor 14 | ? keyof C extends never 15 | ? string 16 | : DottedPaths 17 | : never; 18 | 19 | interface ITween { 20 | component: T; 21 | property: ComponentDottedPaths; 22 | 23 | /** 24 | * The duration of the tween in milliseconds. 25 | */ 26 | duration: number; 27 | easing: Easing; 28 | from: number; 29 | to: number; 30 | 31 | /** 32 | * Defaults to Infinity and only applies to yoyo tweens. 33 | */ 34 | maxIterations?: number; 35 | 36 | /** 37 | * Defaults to false. 38 | */ 39 | yoyo?: boolean; 40 | 41 | onComplete?: 'remove' | undefined; 42 | } 43 | 44 | export class Tween implements ITween { 45 | public completed = false; 46 | public progress = 0; 47 | public iterations = 0; 48 | public maxIterations: number; 49 | public component: T; 50 | public property: ComponentDottedPaths; 51 | public duration: number; 52 | public time = 0; 53 | public from: number; 54 | public to: number; 55 | public change: number; 56 | public yoyo: boolean; 57 | public onComplete: 'remove'; 58 | public easing: Easing; 59 | 60 | constructor(options: ITween) { 61 | this.component = options.component; 62 | this.property = options.property; 63 | this.duration = options.duration / 1000; 64 | this.from = options.from; 65 | this.to = options.to; 66 | this.change = this.to - this.from; 67 | this.maxIterations = options.maxIterations ?? Infinity; 68 | this.yoyo = options.yoyo ?? false; 69 | this.onComplete = options.onComplete ?? 'remove'; 70 | this.easing = options.easing ?? Easing.Linear; 71 | } 72 | } 73 | 74 | /** 75 | * A component that represents many tweens. 76 | */ 77 | export class Tweens extends Component { 78 | constructor(public tweens: Tween[]) { 79 | super(); 80 | } 81 | } 82 | 83 | new Tweens([ 84 | new Tween({ 85 | component: Sprite, 86 | property: 'opacity', 87 | duration: 1000, 88 | easing: Easing.Linear, 89 | from: 0, 90 | to: 1, 91 | }), 92 | new Tween({ 93 | component: Transform, 94 | property: 'position.y', 95 | duration: 1000, 96 | easing: Easing.Linear, 97 | from: 0, 98 | to: 100, 99 | }), 100 | ]); 101 | 102 | // ============================================================================ 103 | // Usage 104 | // ============================================================================ 105 | 106 | const world = new World(); 107 | 108 | const view = world.view(Tweens); 109 | 110 | for (const [_entity, components] of view) { 111 | const { tweens } = components.get(Tweens); 112 | 113 | for (const tween of tweens) { 114 | const component = tween.component; 115 | 116 | const change = easeLinear( 117 | tween.time, 118 | tween.from, 119 | tween.change, 120 | tween.duration, 121 | ); 122 | 123 | justSafeSet(component, tween.property, change); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /packages/examples/src/demos/sprite-tweening/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Demos - Sprite Tweening 8 | 9 | 17 | 18 | 19 |
20 |
21 | Back | 22 | Source 26 |
27 | 28 | 29 |
30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /packages/examples/src/demos/sprite-tweening/main.ts: -------------------------------------------------------------------------------- 1 | import shipSpriteUrl from '#/assets/image/ship.png'; 2 | import { loadImage } from '#/lib/asset-loader'; 3 | import { Easing } from '#/lib/tween'; 4 | import { Transform } from '#/shared/components/transform'; 5 | import { Vector2d } from '#/shared/vector2d'; 6 | import { System, World } from '@jakeklassen/ecs'; 7 | import '../../style.css'; 8 | import { Sprite } from './components/sprite'; 9 | import { Tween, Tweens } from './components/tweens'; 10 | import { Frame } from './structures/frame'; 11 | import { TweensSystem } from './systems/tweens-system.js'; 12 | 13 | const ship = await loadImage(shipSpriteUrl); 14 | 15 | const canvas = document.querySelector('#canvas') as HTMLCanvasElement; 16 | const ctx = canvas.getContext('2d'); 17 | 18 | if (ctx == null) { 19 | throw new Error('failed to obtain canvas 2d context'); 20 | } 21 | 22 | ctx.imageSmoothingEnabled = false; 23 | 24 | const world = new World(); 25 | 26 | world.addEntityComponents( 27 | world.createEntity(), 28 | new Transform( 29 | new Vector2d(Math.floor(canvas.width / 2), Math.floor(canvas.height / 2)), 30 | ), 31 | new Sprite(new Frame(0, 0, ship.width, ship.height)), 32 | new Tweens([ 33 | new Tween({ 34 | component: Sprite, 35 | property: 'opacity', 36 | duration: 1000, 37 | easing: Easing.Linear, 38 | from: 1, 39 | to: 0, 40 | yoyo: true, 41 | }), 42 | new Tween({ 43 | component: Transform, 44 | property: 'scale.x', 45 | duration: 1000, 46 | easing: Easing.Linear, 47 | from: 1, 48 | to: 2, 49 | yoyo: true, 50 | }), 51 | new Tween({ 52 | component: Transform, 53 | property: 'scale.y', 54 | duration: 1000, 55 | easing: Easing.Linear, 56 | from: 1, 57 | to: 2, 58 | yoyo: true, 59 | }), 60 | new Tween({ 61 | component: Transform, 62 | property: 'position.y', 63 | duration: 1000, 64 | easing: Easing.Linear, 65 | from: Math.floor(canvas.height * 0.25), 66 | to: Math.floor(canvas.height * 0.75), 67 | yoyo: true, 68 | }), 69 | new Tween({ 70 | component: Transform, 71 | property: 'rotation', 72 | duration: 1000, 73 | easing: Easing.Linear, 74 | from: (0 * Math.PI) / 180, 75 | to: (360 * Math.PI) / 180, 76 | yoyo: true, 77 | }), 78 | ]), 79 | ); 80 | 81 | class RenderingSystem extends System { 82 | constructor(private readonly context: CanvasRenderingContext2D) { 83 | super(); 84 | } 85 | 86 | public update(world: World) { 87 | this.context.clearRect(0, 0, canvas.width, canvas.height); 88 | 89 | for (const [_entity, components] of world.view(Sprite, Transform)) { 90 | const sprite = components.get(Sprite); 91 | const transform = components.get(Transform); 92 | 93 | this.context.globalAlpha = sprite.opacity; 94 | this.context.translate(transform.position.x, transform.position.y); 95 | this.context.scale(transform.scale.x, transform.scale.y); 96 | this.context.rotate(transform.rotation); 97 | 98 | this.context.drawImage( 99 | ship, 100 | sprite.frame.sourceX, 101 | sprite.frame.sourceY, 102 | sprite.frame.width, 103 | sprite.frame.height, 104 | -sprite.frame.width / 2, 105 | -sprite.frame.height / 2, 106 | sprite.frame.width, 107 | sprite.frame.height, 108 | ); 109 | 110 | this.context.globalAlpha = 1; 111 | this.context.resetTransform(); 112 | } 113 | } 114 | } 115 | 116 | world.addSystem(new TweensSystem()); 117 | world.addSystem(new RenderingSystem(ctx)); 118 | 119 | let last = performance.now(); 120 | 121 | /** 122 | * The game loop. 123 | */ 124 | const frame = (hrt: DOMHighResTimeStamp) => { 125 | const dt = Math.min(1000, hrt - last) / 1000; 126 | 127 | world.updateSystems(dt); 128 | 129 | last = hrt; 130 | 131 | requestAnimationFrame(frame); 132 | }; 133 | 134 | // Start the game loop. 135 | requestAnimationFrame(frame); 136 | -------------------------------------------------------------------------------- /packages/examples/src/demos/sprite-tweening/structures/frame.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This represents a frame of a sprite sheet. 3 | */ 4 | export class Frame { 5 | constructor( 6 | public sourceX: number, 7 | public sourceY: number, 8 | public width: number, 9 | public height: number, 10 | ) {} 11 | } 12 | -------------------------------------------------------------------------------- /packages/examples/src/demos/sprite-tweening/systems/tweens-system.ts: -------------------------------------------------------------------------------- 1 | import { easeLinear } from '#/lib/tween'; 2 | import { System, World } from '@jakeklassen/ecs'; 3 | import justSafeSet from 'just-safe-set'; 4 | import { Tweens } from '../components/tweens'; 5 | 6 | export class TweensSystem extends System { 7 | update(world: World, dt: number): void { 8 | const view = world.view(Tweens); 9 | 10 | for (const [_entity, components] of view) { 11 | const { tweens } = components.get(Tweens); 12 | 13 | for (const tween of tweens) { 14 | tween.time += dt; 15 | tween.progress = tween.time / tween.duration; 16 | 17 | const component = components.get(tween.component); 18 | 19 | if (component == null) { 20 | throw new Error(`Component ${tween.component} not found.`); 21 | } 22 | 23 | if (tween.progress >= 1) { 24 | tween.iterations++; 25 | tween.completed = true; 26 | justSafeSet(component, tween.property, tween.to); 27 | 28 | if ( 29 | tween.maxIterations !== Infinity && 30 | tween.iterations >= tween.maxIterations && 31 | tween.onComplete === 'remove' 32 | ) { 33 | components.delete(tween.component); 34 | 35 | continue; 36 | } 37 | 38 | if (tween.yoyo === true) { 39 | tween.progress = 0; 40 | tween.completed = false; 41 | tween.time = 0; 42 | [tween.from, tween.to] = [tween.to, tween.from]; 43 | tween.change = tween.to - tween.from; 44 | } 45 | } 46 | 47 | if (tween.completed === false) { 48 | const change = easeLinear( 49 | tween.time, 50 | tween.from, 51 | tween.change, 52 | tween.duration, 53 | ); 54 | 55 | justSafeSet(component, tween.property, change); 56 | } 57 | } 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /packages/examples/src/lib/asset-loader.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Loads an image 3 | * @param path image URL 4 | * @returns 5 | */ 6 | export const loadImage = (path: string): Promise => 7 | new Promise((resolve, reject) => { 8 | const image = new Image(); 9 | image.onload = () => resolve(image); 10 | image.onerror = (err) => reject(err); 11 | 12 | image.src = path; 13 | }); 14 | -------------------------------------------------------------------------------- /packages/examples/src/lib/dom.ts: -------------------------------------------------------------------------------- 1 | export const obtainCanvasAndContext2d = (id?: string) => { 2 | const canvas = document.querySelector(id ?? 'canvas'); 3 | 4 | if (canvas == null) { 5 | throw new Error('failed to obtain canvas element'); 6 | } 7 | 8 | const context = canvas.getContext('2d'); 9 | 10 | if (context == null) { 11 | throw new Error('failed to obtain canvas 2d context'); 12 | } 13 | 14 | return { 15 | canvas, 16 | context, 17 | }; 18 | }; 19 | -------------------------------------------------------------------------------- /packages/examples/src/lib/tween.ts: -------------------------------------------------------------------------------- 1 | export enum Easing { 2 | Linear = 'linear', 3 | } 4 | 5 | export const easeLinear = ( 6 | time: number, 7 | start: number, 8 | change: number, 9 | duration: number, 10 | ): number => (change * time) / duration + start; 11 | -------------------------------------------------------------------------------- /packages/examples/src/lib/types/dotted-paths.ts: -------------------------------------------------------------------------------- 1 | // https://stackoverflow.com/a/65963590 2 | type PathTree = { 3 | [P in keyof T]-?: T[P] extends object ? [P] | [P, ...Path] : [P]; 4 | }; 5 | 6 | type LeafPathTree = { 7 | [P in keyof T]-?: T[P] extends object ? [P, ...LeafPath] : [P]; 8 | }; 9 | 10 | type Path = PathTree[keyof PathTree]; 11 | type LeafPath = LeafPathTree[keyof LeafPathTree]; 12 | 13 | type Join = T extends { 14 | length: 1; 15 | } 16 | ? `${T[0]}` 17 | : T extends { length: 2 } 18 | ? `${T[0]}${D}${T[1]}` 19 | : T extends { length: 3 } 20 | ? `${T[0]}${D}${T[1]}${D}${T[2]}` 21 | : T extends { length: 4 } 22 | ? `${T[0]}${D}${T[1]}${D}${T[2]}${D}${T[3]}` 23 | : T extends { length: 5 } 24 | ? `${T[0]}${D}${T[1]}${D}${T[2]}${D}${T[3]}${D}${T[4]}` 25 | : T extends { length: 6 } 26 | ? `${T[0]}${D}${T[1]}${D}${T[2]}${D}${T[3]}${D}${T[4]}${D}${T[5]}` 27 | : T extends { length: 7 } 28 | ? `${T[0]}${D}${T[1]}${D}${T[2]}${D}${T[3]}${D}${T[4]}${D}${T[5]}${D}${T[6]}` 29 | : T extends { length: 8 } 30 | ? `${T[0]}${D}${T[1]}${D}${T[2]}${D}${T[3]}${D}${T[4]}${D}${T[5]}${D}${T[6]}${D}${T[7]}` 31 | : T extends { length: 9 } 32 | ? `${T[0]}${D}${T[1]}${D}${T[2]}${D}${T[3]}${D}${T[4]}${D}${T[5]}${D}${T[6]}${D}${T[7]}${D}${T[8]}` 33 | : `${T[0]}${D}${T[1]}${D}${T[2]}${D}${T[3]}${D}${T[4]}${D}${T[5]}${D}${T[6]}${D}${T[7]}${D}${T[8]}${D}${T[9]}`; 34 | 35 | export type DottedPaths = LeafPath extends (string | number)[] 36 | ? Join> 37 | : never; 38 | -------------------------------------------------------------------------------- /packages/examples/src/main.ts: -------------------------------------------------------------------------------- 1 | import './style.css'; 2 | -------------------------------------------------------------------------------- /packages/examples/src/shared/components/color.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@jakeklassen/ecs'; 2 | 3 | export class Color extends Component { 4 | constructor(public color: string) { 5 | super(); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/examples/src/shared/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './color'; 2 | export * from './rectangle'; 3 | export * from './transform'; 4 | export * from './velocity'; 5 | -------------------------------------------------------------------------------- /packages/examples/src/shared/components/position.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@jakeklassen/ecs'; 2 | 3 | export class Position extends Component { 4 | constructor(public x = 0, public y = 0) { 5 | super(); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/examples/src/shared/components/rectangle.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@jakeklassen/ecs'; 2 | 3 | export class Rectangle extends Component { 4 | constructor(public readonly width: number, public readonly height: number) { 5 | super(); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/examples/src/shared/components/transform.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@jakeklassen/ecs'; 2 | import { Vector2d } from '../vector2d'; 3 | 4 | export class Transform extends Component { 5 | constructor( 6 | public position = new Vector2d(), 7 | public rotation = 0, 8 | public scale = new Vector2d(1, 1), 9 | ) { 10 | super(); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/examples/src/shared/components/velocity.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@jakeklassen/ecs'; 2 | 3 | export class Velocity extends Component { 4 | constructor(public x = 0, public y = 0) { 5 | super(); 6 | } 7 | 8 | public flipX() { 9 | this.x *= -1; 10 | } 11 | 12 | public flipY() { 13 | this.y *= -1; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/examples/src/shared/vector2d.ts: -------------------------------------------------------------------------------- 1 | export class Vector2d { 2 | constructor(public x = 0, public y = 0) {} 3 | } 4 | -------------------------------------------------------------------------------- /packages/examples/src/style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: Inter, Avenir, Helvetica, Arial, sans-serif; 3 | font-size: 16px; 4 | line-height: 24px; 5 | font-weight: 400; 6 | 7 | color-scheme: light dark; 8 | color: rgba(255, 255, 255, 0.87); 9 | background-color: #242424; 10 | 11 | font-synthesis: none; 12 | text-rendering: optimizeLegibility; 13 | -webkit-font-smoothing: antialiased; 14 | -moz-osx-font-smoothing: grayscale; 15 | -webkit-text-size-adjust: 100%; 16 | } 17 | 18 | a { 19 | font-weight: 500; 20 | color: #646cff; 21 | text-decoration: inherit; 22 | } 23 | a:hover { 24 | color: #535bf2; 25 | } 26 | 27 | body { 28 | margin: 0; 29 | display: flex; 30 | place-items: center; 31 | min-width: 320px; 32 | min-height: 100vh; 33 | } 34 | 35 | h1 { 36 | font-size: 3.2em; 37 | line-height: 1.1; 38 | } 39 | 40 | #app { 41 | max-width: 1280px; 42 | margin: 0 auto; 43 | padding: 2rem; 44 | text-align: center; 45 | } 46 | 47 | .logo { 48 | height: 6em; 49 | padding: 1.5em; 50 | will-change: filter; 51 | } 52 | .logo:hover { 53 | filter: drop-shadow(0 0 2em #646cffaa); 54 | } 55 | .logo.vanilla:hover { 56 | filter: drop-shadow(0 0 2em #f7df1eaa); 57 | } 58 | 59 | .card { 60 | padding: 2em; 61 | } 62 | 63 | .read-the-docs { 64 | color: #888; 65 | } 66 | 67 | button { 68 | border-radius: 8px; 69 | border: 1px solid transparent; 70 | padding: 0.6em 1.2em; 71 | font-size: 1em; 72 | font-weight: 500; 73 | font-family: inherit; 74 | background-color: #1a1a1a; 75 | cursor: pointer; 76 | transition: border-color 0.25s; 77 | } 78 | button:hover { 79 | border-color: #646cff; 80 | } 81 | button:focus, 82 | button:focus-visible { 83 | outline: 4px auto -webkit-focus-ring-color; 84 | } 85 | 86 | @media (prefers-color-scheme: light) { 87 | :root { 88 | color: #213547; 89 | background-color: #ffffff; 90 | } 91 | a:hover { 92 | color: #747bff; 93 | } 94 | button { 95 | background-color: #f9f9f9; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /packages/examples/src/typescript.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/examples/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /packages/examples/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ESNext", "DOM"], 7 | "moduleResolution": "Node", 8 | "strict": true, 9 | "sourceMap": true, 10 | "resolveJsonModule": true, 11 | "isolatedModules": false, 12 | "esModuleInterop": true, 13 | "noEmit": true, 14 | "noUnusedLocals": true, 15 | "noUnusedParameters": true, 16 | "noImplicitReturns": true, 17 | "skipLibCheck": true, 18 | "baseUrl": ".", 19 | "paths": { 20 | "#/*": ["src/*"], 21 | "#/assets/*": ["assets/*"] 22 | } 23 | }, 24 | "include": ["src", "basic"] 25 | } 26 | -------------------------------------------------------------------------------- /packages/examples/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'node:path'; 2 | import fs from 'node:fs/promises'; 3 | import { defineConfig } from 'vite'; 4 | 5 | const demoInputs = await fs 6 | .readdir(resolve(__dirname, './src/demos')) 7 | .then((directories) => 8 | Object.fromEntries( 9 | directories.map((directory) => [ 10 | directory, 11 | `src/demos/${directory}/index.html`, 12 | ]), 13 | ), 14 | ); 15 | 16 | export default defineConfig({ 17 | build: { 18 | rollupOptions: { 19 | input: { 20 | main: resolve(__dirname, 'index.html'), 21 | ...demoInputs, 22 | }, 23 | }, 24 | target: 'esnext', 25 | }, 26 | resolve: { 27 | // https://github.com/vitejs/vite/issues/88#issuecomment-784441588 28 | alias: { 29 | '#/assets': resolve(__dirname, 'assets'), 30 | '#': resolve(__dirname, 'src'), 31 | }, 32 | }, 33 | }); 34 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | # all packages in direct subdirs of packages/ 3 | - 'packages/*' 4 | # exclude packages that are inside test directories 5 | - '!**/test/**' -------------------------------------------------------------------------------- /workspace.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "name": "✨ root", 5 | "path": ".", 6 | }, 7 | { 8 | "name": "📦 ecs", 9 | "path": "packages/ecs", 10 | }, 11 | { 12 | "name": "📦 examples", 13 | "path": "packages/examples", 14 | }, 15 | ], 16 | "settings": { 17 | "typescript.preferences.importModuleSpecifierEnding": "js", 18 | "typescript.tsdk": "📦 ecs/node_modules/typescript/lib", 19 | }, 20 | } 21 | --------------------------------------------------------------------------------