diff --git a/.forgejo/workflows/publish-and-deploy.yaml b/.forgejo/workflows/publish-and-deploy.yaml index 3e8a96b..2527d3d 100644 --- a/.forgejo/workflows/publish-and-deploy.yaml +++ b/.forgejo/workflows/publish-and-deploy.yaml @@ -22,7 +22,7 @@ jobs: - name: Login to Registry run: | - echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login ${{ env.REGISTRY }} -u "${{ vars.REGISTRY_USERNAME }}" --password-stdin + echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login ${{ env.REGISTRY }} -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin - name: Determine Version id: version diff --git a/.gitignore b/.gitignore index aed094d..a9bf588 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ dist/ .DS_Store *.log .vite/ + diff --git a/.husky/pre-commit b/.husky/pre-commit deleted file mode 100755 index cad663b..0000000 --- a/.husky/pre-commit +++ /dev/null @@ -1,7 +0,0 @@ -# Skip in CI environments -if [ -n "$CI" ] || [ -n "$FORGEJO_ACTIONS" ] || [ -n "$GITHUB_ACTIONS" ]; then - echo "Skipping pre-commit hooks in CI environment" - exit 0 -fi - -npx lint-staged diff --git a/.lintstagedrc.json b/.lintstagedrc.json deleted file mode 100644 index a2ca1b7..0000000 --- a/.lintstagedrc.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "*.{ts,tsx,js,jsx}": ["eslint --fix", "prettier --write"], - "*.{json,css,md}": ["prettier --write"] -} diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index 3f2322c..0000000 --- a/.prettierignore +++ /dev/null @@ -1,5 +0,0 @@ -node_modules -dist -*.min.js -package-lock.json - diff --git a/.prettierrc.json b/.prettierrc.json deleted file mode 100644 index 59eb508..0000000 --- a/.prettierrc.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "semi": true, - "trailingComma": "es5", - "singleQuote": true, - "printWidth": 100, - "tabWidth": 2, - "useTabs": false, - "arrowParens": "always" -} diff --git a/README.md b/README.md index f6283c4..2eff69f 100644 --- a/README.md +++ b/README.md @@ -1,54 +1,21 @@ # Slime Genesis - ECS RPG PoC -A complete proof of concept for **Slime Genesis: The Awakening of the Entity** built with TypeScript using an Entity Component System (ECS) architecture. +A complete proof of concept for **Slime Genesis: The Awakening of the Entity** built with vanilla JavaScript using an Entity Component System (ECS) architecture. ## Quick Start 1. **Install dependencies:** - ```bash npm install ``` 2. **Run the development server:** - ```bash npm run dev ``` 3. **Open your browser** to the URL shown (usually `http://localhost:5173`) -## Development - -- **Format code:** - - ```bash - npm run format - ``` - -- **Check formatting:** - - ```bash - npm run format:check - ``` - -- **Lint code:** - - ```bash - npm run lint - ``` - -- **Fix linting issues:** - - ```bash - npm run lint:fix - ``` - -- **Build for production:** - ```bash - npm run build - ``` - ## Controls - **WASD** or **Arrow Keys**: Move your slime @@ -60,7 +27,6 @@ A complete proof of concept for **Slime Genesis: The Awakening of the Entity** b ## Features ### Core Systems - - ✅ **ECS Architecture**: Entity Component System for flexible game design - ✅ **Real-time Combat**: Fast-paced action combat with form-specific styles - ✅ **Evolution System**: Three paths (Human, Beast, Slime) that change based on absorption @@ -69,19 +35,15 @@ A complete proof of concept for **Slime Genesis: The Awakening of the Entity** b - ✅ **RPG Systems**: Stats, leveling, XP, inventory, equipment - ✅ **AI System**: Intelligent creature behaviors (wander, chase, combat, flee) - ✅ **Projectile System**: Skills can create projectiles (Water Gun, etc.) -- ✅ **Mutation System**: Gain mutations like Hardened Shell, Electric Skin, Bioluminescence ### Graphics & Polish - - ✅ **Animated Slime**: Smooth morphing blob with jiggle physics - ✅ **Combat Effects**: Damage numbers, attack indicators, particle effects - ✅ **Absorption Visuals**: Swirling particles and color transitions - ✅ **Stealth Indicators**: Visibility meters and detection warnings -- ✅ **Glow Effects**: Bioluminescence mutation creates a pulsing glow effect - ✅ **Polished UI**: Health bars, XP bars, skill hotbar, stat displays ### Skills - - **Water Gun**: Shoot a jet of water at enemies - **Fire Breath**: Breathe fire in a cone - **Pounce**: Leap forward and damage enemies @@ -96,7 +58,6 @@ This game uses a pure ECS (Entity Component System) architecture: - **Systems**: Logic processors (RenderSystem, CombatSystem, AbsorptionSystem, etc.) This architecture makes it easy to: - - Add new skills and abilities - Create mutations and combinations - Extend creature behaviors @@ -106,65 +67,32 @@ This architecture makes it easy to: ``` src/ -├── core/ # ECS framework and utilities -│ ├── Engine.ts # Main game loop -│ ├── Entity.ts # Entity manager -│ ├── Component.ts # Base component -│ ├── System.ts # Base system -│ ├── Constants.ts # Enums and constants -│ ├── EventBus.ts # Event system -│ ├── LevelLoader.ts # Level loading -│ ├── Palette.ts # Color palette -│ ├── PixelFont.ts # Pixel font rendering -│ ├── SpriteLibrary.ts # Sprite definitions -│ └── TileMap.ts # Tile map system -├── components/ # All game components -│ ├── Position.ts # Position and rotation -│ ├── Velocity.ts # Movement velocity -│ ├── Health.ts # Health and regeneration -│ ├── Sprite.ts # Visual representation -│ ├── Stats.ts # Attributes and leveling -│ ├── Combat.ts # Combat stats and attacks -│ ├── Evolution.ts # Evolution paths and mutations -│ ├── Skills.ts # Skill management -│ ├── SkillProgress.ts # Skill learning progress -│ ├── Absorbable.ts # Absorption mechanics -│ ├── Stealth.ts # Stealth state -│ ├── Intent.ts # Action intent -│ ├── Inventory.ts # Items and equipment -│ └── AI.ts # AI behavior data -├── systems/ # All game systems -│ ├── RenderSystem.ts # Rendering -│ ├── InputSystem.ts # Input handling -│ ├── PlayerControllerSystem.ts # Player control -│ ├── MovementSystem.ts # Movement physics -│ ├── CombatSystem.ts # Combat logic -│ ├── AISystem.ts # AI behavior -│ ├── AbsorptionSystem.ts # Absorption mechanics -│ ├── StealthSystem.ts # Stealth mechanics -│ ├── SkillSystem.ts # Skill activation -│ ├── SkillEffectSystem.ts # Skill visual effects -│ ├── ProjectileSystem.ts # Projectile physics -│ ├── DeathSystem.ts # Death handling -│ ├── HealthRegenerationSystem.ts # Health regen -│ ├── VFXSystem.ts # Visual effects -│ ├── UISystem.ts # UI rendering -│ └── MenuSystem.ts # Menu management -├── skills/ # Skill system -│ ├── Skill.ts # Base skill class -│ ├── SkillRegistry.ts # Skill registry -│ └── skills/ # Individual skills -│ ├── FireBreath.ts -│ ├── Pounce.ts -│ ├── StealthMode.ts -│ └── WaterGun.ts -├── items/ # Item system -│ ├── Item.ts # Base item class -│ └── ItemRegistry.ts # Item registry -├── world/ # World management -│ └── World.ts # World setup -├── GameConfig.ts # Game configuration -└── main.ts # Entry point +├── core/ # ECS framework +│ ├── Engine.js # Main game loop +│ ├── Entity.js # Entity manager +│ ├── Component.js # Base component +│ └── System.js # Base system +├── components/ # All game components +│ ├── Position.js +│ ├── Health.js +│ ├── Stats.js +│ ├── Evolution.js +│ └── ... +├── systems/ # All game systems +│ ├── RenderSystem.js +│ ├── CombatSystem.js +│ ├── AbsorptionSystem.js +│ └── ... +├── skills/ # Skill system +│ ├── Skill.js +│ ├── SkillRegistry.js +│ └── skills/ # Individual skills +├── items/ # Item system +│ ├── Item.js +│ └── ItemRegistry.js +├── world/ # World management +│ └── World.js +└── main.js # Entry point ``` ## Gameplay Loop @@ -175,7 +103,6 @@ src/ 4. **Evolve**: Your form changes based on what you absorb 5. **Level Up**: Gain XP, increase stats, unlock new possibilities 6. **Stealth**: Use form-specific stealth to avoid or ambush enemies -7. **Mutate**: Gain powerful mutations like Hardened Shell, Electric Skin, or Bioluminescence ## Evolution Paths @@ -183,30 +110,13 @@ src/ - **Beast Path**: Absorb beasts to become a predator, gain physical power - **Slime Path**: Maintain your original form, gain unique abilities -## Mutations - -- **Hardened Shell**: Increased defense (requires high Constitution) -- **Electric Skin**: Damage reflection (requires high Intelligence) -- **Bioluminescence**: Glowing light source (requires high Human evolution) - ## Technical Details -- **TypeScript**: Full type safety and modern JavaScript features -- **Vite**: Fast development server and build tool +- **No External Dependencies**: Pure vanilla JavaScript (except Vite for dev server) - **Canvas 2D**: High-performance rendering with Canvas API -- **ESLint**: Code linting with TypeScript support -- **Prettier**: Code formatting -- **Husky**: Pre-commit hooks (skips in CI environments) - **Modular Design**: Easy to extend and modify - **ECS Pattern**: Scalable architecture for complex game mechanics -## Code Quality - -- **TypeScript**: Full type coverage, no `any` types -- **ESLint**: Zero warnings policy -- **Prettier**: Consistent code formatting -- **Pre-commit Hooks**: Automatic formatting and linting before commits - ## Future Enhancements - More skills and mutations diff --git a/VERSION b/VERSION index 8f0916f..6e8bf73 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.0 +0.1.0 diff --git a/eslint.config.js b/eslint.config.js index 4165506..1462103 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,39 +1,26 @@ -import js from '@eslint/js'; -import globals from 'globals'; -import tseslint from 'typescript-eslint'; -import prettier from 'eslint-config-prettier'; +import js from "@eslint/js"; +import globals from "globals"; export default [ - js.configs.recommended, - ...tseslint.configs.recommended, - prettier, - { - languageOptions: { - ecmaVersion: 2022, - sourceType: 'module', - globals: { - ...globals.browser, - ...globals.node, - performance: 'readonly', - requestAnimationFrame: 'readonly', - }, - }, - files: ['**/*.ts', '**/*.tsx'], - rules: { - '@typescript-eslint/no-unused-vars': [ - 'error', - { - argsIgnorePattern: '^_', - varsIgnorePattern: '^_', + js.configs.recommended, + { + languageOptions: { + ecmaVersion: 2022, + sourceType: "module", + globals: { + ...globals.browser, + ...globals.node, + performance: "readonly", + requestAnimationFrame: "readonly", + }, + }, + rules: { + "no-unused-vars": ["error", { + "argsIgnorePattern": "^_", + "varsIgnorePattern": "^_" + }], + "no-console": "off", + "indent": ["error", 2], }, - ], - '@typescript-eslint/no-explicit-any': 'warn', - 'no-console': 'off', - '@typescript-eslint/explicit-module-boundary-types': 'off', - '@typescript-eslint/no-non-null-assertion': 'warn', }, - }, - { - ignores: ['dist/**', 'node_modules/**', '*.config.js'], - }, ]; diff --git a/index.html b/index.html index 3fbeb35..21eb741 100644 --- a/index.html +++ b/index.html @@ -1,57 +1,31 @@ - Slime Genesis - PoC -
- + + - \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index c895d29..9cce95d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,17 +9,9 @@ "version": "0.1.0", "devDependencies": { "@eslint/js": "^9.39.2", - "@typescript-eslint/eslint-plugin": "^8.52.0", - "@typescript-eslint/parser": "^8.52.0", "eslint": "^9.39.2", - "eslint-config-prettier": "^10.1.8", "globals": "^17.0.0", - "husky": "^9.1.7", - "lint-staged": "^16.2.7", - "prettier": "^3.7.4", "terser": "^5.44.1", - "typescript": "^5.9.3", - "typescript-eslint": "^8.52.0", "vite": "^7.3.0" } }, @@ -1088,263 +1080,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.52.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.52.0.tgz", - "integrity": "sha512-okqtOgqu2qmZJ5iN4TWlgfF171dZmx2FzdOv2K/ixL2LZWDStL8+JgQerI2sa8eAEfoydG9+0V96m7V+P8yE1Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.52.0", - "@typescript-eslint/type-utils": "8.52.0", - "@typescript-eslint/utils": "8.52.0", - "@typescript-eslint/visitor-keys": "8.52.0", - "ignore": "^7.0.5", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.4.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^8.52.0", - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/@typescript-eslint/parser": { - "version": "8.52.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.52.0.tgz", - "integrity": "sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@typescript-eslint/scope-manager": "8.52.0", - "@typescript-eslint/types": "8.52.0", - "@typescript-eslint/typescript-estree": "8.52.0", - "@typescript-eslint/visitor-keys": "8.52.0", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/project-service": { - "version": "8.52.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.52.0.tgz", - "integrity": "sha512-xD0MfdSdEmeFa3OmVqonHi+Cciab96ls1UhIF/qX/O/gPu5KXD0bY9lu33jj04fjzrXHcuvjBcBC+D3SNSadaw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.52.0", - "@typescript-eslint/types": "^8.52.0", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.52.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.52.0.tgz", - "integrity": "sha512-ixxqmmCcc1Nf8S0mS0TkJ/3LKcC8mruYJPOU6Ia2F/zUUR4pApW7LzrpU3JmtePbRUTes9bEqRc1Gg4iyRnDzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.52.0", - "@typescript-eslint/visitor-keys": "8.52.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.52.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.52.0.tgz", - "integrity": "sha512-jl+8fzr/SdzdxWJznq5nvoI7qn2tNYV/ZBAEcaFMVXf+K6jmXvAFrgo/+5rxgnL152f//pDEAYAhhBAZGrVfwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.52.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.52.0.tgz", - "integrity": "sha512-JD3wKBRWglYRQkAtsyGz1AewDu3mTc7NtRjR/ceTyGoPqmdS5oCdx/oZMWD5Zuqmo6/MpsYs0wp6axNt88/2EQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.52.0", - "@typescript-eslint/typescript-estree": "8.52.0", - "@typescript-eslint/utils": "8.52.0", - "debug": "^4.4.3", - "ts-api-utils": "^2.4.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/types": { - "version": "8.52.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.52.0.tgz", - "integrity": "sha512-LWQV1V4q9V4cT4H5JCIx3481iIFxH1UkVk+ZkGGAV1ZGcjGI9IoFOfg3O6ywz8QqCDEp7Inlg6kovMofsNRaGg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.52.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.52.0.tgz", - "integrity": "sha512-XP3LClsCc0FsTK5/frGjolyADTh3QmsLp6nKd476xNI9CsSsLnmn4f0jrzNoAulmxlmNIpeXuHYeEQv61Q6qeQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/project-service": "8.52.0", - "@typescript-eslint/tsconfig-utils": "8.52.0", - "@typescript-eslint/types": "8.52.0", - "@typescript-eslint/visitor-keys": "8.52.0", - "debug": "^4.4.3", - "minimatch": "^9.0.5", - "semver": "^7.7.3", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.4.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@typescript-eslint/utils": { - "version": "8.52.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.52.0.tgz", - "integrity": "sha512-wYndVMWkweqHpEpwPhwqE2lnD2DxC6WVLupU/DOt/0/v+/+iQbbzO3jOHjmBMnhu0DgLULvOaU4h4pwHYi2oRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.52.0", - "@typescript-eslint/types": "8.52.0", - "@typescript-eslint/typescript-estree": "8.52.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.52.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.52.0.tgz", - "integrity": "sha512-ink3/Zofus34nmBsPjow63FP5M7IGff0RKAgqR6+CFpdk22M7aLwC9gOcLGYqr7MczLPzZVERW9hRog3O4n1sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.52.0", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -1386,35 +1121,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/ansi-escapes": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.2.0.tgz", - "integrity": "sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "environment": "^1.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -1456,19 +1162,6 @@ "concat-map": "0.0.1" } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -1503,39 +1196,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/cli-cursor": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", - "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", - "dev": true, - "license": "MIT", - "dependencies": { - "restore-cursor": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-truncate": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.1.1.tgz", - "integrity": "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==", - "dev": true, - "license": "MIT", - "dependencies": { - "slice-ansi": "^7.1.0", - "string-width": "^8.0.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -1556,13 +1216,6 @@ "dev": true, "license": "MIT" }, - "node_modules/colorette": { - "version": "2.0.20", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", - "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", - "dev": true, - "license": "MIT" - }, "node_modules/commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", @@ -1617,26 +1270,6 @@ "dev": true, "license": "MIT" }, - "node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "dev": true, - "license": "MIT" - }, - "node_modules/environment": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", - "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/esbuild": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", @@ -1753,22 +1386,6 @@ } } }, - "node_modules/eslint-config-prettier": { - "version": "10.1.8", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", - "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", - "dev": true, - "license": "MIT", - "bin": { - "eslint-config-prettier": "bin/cli.js" - }, - "funding": { - "url": "https://opencollective.com/eslint-config-prettier" - }, - "peerDependencies": { - "eslint": ">=7.0.0" - } - }, "node_modules/eslint-scope": { "version": "8.4.0", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", @@ -1863,13 +1480,6 @@ "node": ">=0.10.0" } }, - "node_modules/eventemitter3": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", - "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", - "dev": true, - "license": "MIT" - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -1922,19 +1532,6 @@ "node": ">=16.0.0" } }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -1988,19 +1585,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/get-east-asian-width": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", - "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2037,22 +1621,6 @@ "node": ">=8" } }, - "node_modules/husky": { - "version": "9.1.7", - "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", - "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", - "dev": true, - "license": "MIT", - "bin": { - "husky": "bin.js" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/typicode" - } - }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2100,22 +1668,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-fullwidth-code-point": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", - "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.3.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -2129,16 +1681,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -2204,59 +1746,6 @@ "node": ">= 0.8.0" } }, - "node_modules/lint-staged": { - "version": "16.2.7", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.2.7.tgz", - "integrity": "sha512-lDIj4RnYmK7/kXMya+qJsmkRFkGolciXjrsZ6PC25GdTfWOAWetR0ZbsNXRAj1EHHImRSalc+whZFg56F5DVow==", - "dev": true, - "license": "MIT", - "dependencies": { - "commander": "^14.0.2", - "listr2": "^9.0.5", - "micromatch": "^4.0.8", - "nano-spawn": "^2.0.0", - "pidtree": "^0.6.0", - "string-argv": "^0.3.2", - "yaml": "^2.8.1" - }, - "bin": { - "lint-staged": "bin/lint-staged.js" - }, - "engines": { - "node": ">=20.17" - }, - "funding": { - "url": "https://opencollective.com/lint-staged" - } - }, - "node_modules/lint-staged/node_modules/commander": { - "version": "14.0.2", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz", - "integrity": "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - } - }, - "node_modules/listr2": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz", - "integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "cli-truncate": "^5.0.0", - "colorette": "^2.0.20", - "eventemitter3": "^5.0.1", - "log-update": "^6.1.0", - "rfdc": "^1.4.1", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=20.0.0" - } - }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -2280,66 +1769,6 @@ "dev": true, "license": "MIT" }, - "node_modules/log-update": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", - "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-escapes": "^7.0.0", - "cli-cursor": "^5.0.0", - "slice-ansi": "^7.1.0", - "strip-ansi": "^7.1.0", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/mimic-function": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", - "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -2360,19 +1789,6 @@ "dev": true, "license": "MIT" }, - "node_modules/nano-spawn": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/nano-spawn/-/nano-spawn-2.0.0.tgz", - "integrity": "sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20.17" - }, - "funding": { - "url": "https://github.com/sindresorhus/nano-spawn?sponsor=1" - } - }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -2399,22 +1815,6 @@ "dev": true, "license": "MIT" }, - "node_modules/onetime": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", - "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-function": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -2519,19 +1919,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pidtree": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz", - "integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==", - "dev": true, - "license": "MIT", - "bin": { - "pidtree": "bin/pidtree.js" - }, - "engines": { - "node": ">=0.10" - } - }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -2571,22 +1958,6 @@ "node": ">= 0.8.0" } }, - "node_modules/prettier": { - "version": "3.7.4", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", - "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", - "dev": true, - "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -2607,30 +1978,6 @@ "node": ">=4" } }, - "node_modules/restore-cursor": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", - "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", - "dev": true, - "license": "MIT", - "dependencies": { - "onetime": "^7.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/rfdc": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", - "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", - "dev": true, - "license": "MIT" - }, "node_modules/rollup": { "version": "4.55.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz", @@ -2676,19 +2023,6 @@ "fsevents": "~2.3.2" } }, - "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -2712,49 +2046,6 @@ "node": ">=8" } }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/slice-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", - "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/slice-ansi/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -2786,49 +2077,6 @@ "source-map": "^0.6.0" } }, - "node_modules/string-argv": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", - "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.6.19" - } - }, - "node_modules/string-width": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz", - "integrity": "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.3.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -2892,32 +2140,6 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/ts-api-utils": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", - "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" - } - }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -2931,45 +2153,6 @@ "node": ">= 0.8.0" } }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/typescript-eslint": { - "version": "8.52.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.52.0.tgz", - "integrity": "sha512-atlQQJ2YkO4pfTVQmQ+wvYQwexPDOIgo+RaVcD7gHgzy/IQA+XTyuxNM9M9TVXvttkF7koBHmcwisKdOAf2EcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/eslint-plugin": "8.52.0", - "@typescript-eslint/parser": "8.52.0", - "@typescript-eslint/typescript-estree": "8.52.0", - "@typescript-eslint/utils": "8.52.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -3081,71 +2264,6 @@ "node": ">=0.10.0" } }, - "node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", - "dev": true, - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 514ccca..36a494f 100644 --- a/package.json +++ b/package.json @@ -7,25 +7,14 @@ "dev": "vite", "build": "vite build", "preview": "vite preview", - "lint": "eslint src --max-warnings 0", - "lint:fix": "eslint src --fix", - "format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"", - "format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"", - "prepare": "husky || true" + "lint": "eslint src", + "lint:fix": "eslint src --fix" }, "devDependencies": { "@eslint/js": "^9.39.2", - "@typescript-eslint/eslint-plugin": "^8.52.0", - "@typescript-eslint/parser": "^8.52.0", "eslint": "^9.39.2", - "eslint-config-prettier": "^10.1.8", "globals": "^17.0.0", - "husky": "^9.1.7", - "lint-staged": "^16.2.7", - "prettier": "^3.7.4", "terser": "^5.44.1", - "typescript": "^5.9.3", - "typescript-eslint": "^8.52.0", "vite": "^7.3.0" } } diff --git a/portainer.yml b/portainer.yml index ace98e9..8295a7a 100644 --- a/portainer.yml +++ b/portainer.yml @@ -2,7 +2,7 @@ name: slime services: app: - image: git.jusemon.com/jusemon/slime:0.5.0 + image: git.jusemon.com/jusemon/slime:0.1.0 restart: unless-stopped networks: diff --git a/src/GameConfig.ts b/src/GameConfig.js similarity index 59% rename from src/GameConfig.ts rename to src/GameConfig.js index 0f9cc3f..4995f4f 100644 --- a/src/GameConfig.ts +++ b/src/GameConfig.js @@ -1,41 +1,38 @@ /** - * Centralized Game Configuration containing thresholds, rates, and balancing constants. + * Centralized Game Configuration + * Thresholds, rates, and balancing constants */ export const GameConfig = { - /** Evolution related constants */ Evolution: { totalTarget: 150, thresholds: { hardenedShell: { constitution: 25 }, electricSkin: { intelligence: 25 }, - glowingBody: { human: 50 }, - }, + glowingBody: { human: 50 } + } }, - /** Absorption related constants */ Absorption: { - range: 30, + range: 80, healPercentMin: 0.1, healPercentMax: 0.2, skillAbsorptionChance: 0.3, mutationChance: 0.1, - removalDelay: 3.0, + removalDelay: 3.0, // Seconds after death }, - /** Combat related constants */ Combat: { knockbackPower: 150, defaultAttackArc: 0.5, damageReflectionPercent: 0.2, - hardenedShellReduction: 0.7, + hardenedShellReduction: 0.7 }, - /** AI behavior related constants */ AI: { detectionAwarenessThreshold: 0.7, passiveAwarenessThreshold: 0.95, fleeAwarenessThreshold: 0.5, awarenessLossRate: 0.5, - awarenessGainMultiplier: 2.0, - }, + awarenessGainMultiplier: 2.0 + } }; diff --git a/src/components/AI.js b/src/components/AI.js new file mode 100644 index 0000000..dbe67f1 --- /dev/null +++ b/src/components/AI.js @@ -0,0 +1,52 @@ +import { Component } from '../core/Component.js'; + +export class AI extends Component { + constructor(behaviorType = 'wander') { + super('AI'); + this.behaviorType = behaviorType; // 'wander', 'patrol', 'chase', 'flee', 'combat' + this.state = 'idle'; // 'idle', 'moving', 'attacking', 'fleeing' + this.target = null; // Entity ID to target + this.awareness = 0; // 0-1, how aware of player + this.alertRadius = 150; + this.chaseRadius = 300; + this.fleeRadius = 100; + + // Behavior parameters + this.wanderSpeed = 50; + this.wanderDirection = Math.random() * Math.PI * 2; + this.wanderChangeTime = 0; + this.wanderChangeInterval = 2.0; // seconds + } + + /** + * Set behavior type + */ + setBehavior(type) { + this.behaviorType = type; + } + + /** + * Set target entity + */ + setTarget(entityId) { + this.target = entityId; + } + + /** + * Clear target + */ + clearTarget() { + this.target = null; + } + + /** + * Update awareness + */ + updateAwareness(delta, maxAwareness = 1.0) { + this.awareness = Math.min(maxAwareness, this.awareness + delta); + if (this.awareness <= 0) { + this.awareness = 0; + } + } +} + diff --git a/src/components/AI.ts b/src/components/AI.ts deleted file mode 100644 index adc38b7..0000000 --- a/src/components/AI.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { Component } from '../core/Component.ts'; -import { ComponentType } from '../core/Constants.ts'; - -/** - * Component for AI behavior and state. - */ -export class AI extends Component { - behaviorType: string; - state: string; - target: number | null; - awareness: number; - alertRadius: number; - chaseRadius: number; - fleeRadius: number; - wanderSpeed: number; - wanderDirection: number; - wanderChangeTime: number; - wanderChangeInterval: number; - - /** - * @param behaviorType - The initial behavior type - */ - constructor(behaviorType = 'wander') { - super(ComponentType.AI); - this.behaviorType = behaviorType; - this.state = 'idle'; - this.target = null; - this.awareness = 0; - this.alertRadius = 60; - this.chaseRadius = 120; - this.fleeRadius = 40; - - this.wanderSpeed = 20; - this.wanderDirection = Math.random() * Math.PI * 2; - this.wanderChangeTime = 0; - this.wanderChangeInterval = 2.0; - } - - /** - * Set the behavior type for this AI. - * @param type - The new behavior type (e.g., 'wander', 'patrol', 'chase') - */ - setBehavior(type: string): void { - this.behaviorType = type; - } - - /** - * Set a target entity for the AI to interact with. - * @param entityId - The ID of the target entity - */ - setTarget(entityId: number): void { - this.target = entityId; - } - - /** - * Clear the current target. - */ - clearTarget(): void { - this.target = null; - } - - /** - * Update the AI's awareness level. - * @param delta - The amount to change awareness by - * @param maxAwareness - The maximum awareness level - */ - updateAwareness(delta: number, maxAwareness = 1.0): void { - this.awareness = Math.min(maxAwareness, this.awareness + delta); - if (this.awareness <= 0) { - this.awareness = 0; - } - } -} diff --git a/src/components/Absorbable.js b/src/components/Absorbable.js new file mode 100644 index 0000000..ef4b6f0 --- /dev/null +++ b/src/components/Absorbable.js @@ -0,0 +1,54 @@ +import { Component } from '../core/Component.js'; + +export class Absorbable extends Component { + constructor() { + super('Absorbable'); + this.evolutionData = { + human: 0, + beast: 0, + slime: 0 + }; + this.skillsGranted = []; // Array of skill IDs that can be absorbed + this.skillAbsorptionChance = 0.3; // 30% chance to absorb a skill + this.mutationChance = 0.1; // 10% chance for mutation + this.absorbed = false; + } + + /** + * Set evolution data + */ + setEvolutionData(human, beast, slime) { + this.evolutionData = { human, beast, slime }; + } + + /** + * Add a skill that can be absorbed + */ + addSkill(skillId, chance = null) { + this.skillsGranted.push({ + id: skillId, + chance: chance || this.skillAbsorptionChance + }); + } + + /** + * Get skills that were successfully absorbed (rolls for each) + */ + getAbsorbedSkills() { + const absorbed = []; + for (const skill of this.skillsGranted) { + if (Math.random() < skill.chance) { + absorbed.push(skill.id); + } + } + return absorbed; + } + + /** + * Check if should mutate + */ + shouldMutate() { + return Math.random() < this.mutationChance; + } +} + diff --git a/src/components/Absorbable.ts b/src/components/Absorbable.ts deleted file mode 100644 index ffc2fe3..0000000 --- a/src/components/Absorbable.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { Component } from '../core/Component.ts'; -import { ComponentType } from '../core/Constants.ts'; - -/** - * Evolution data structure - */ -export interface EvolutionData { - human: number; - beast: number; - slime: number; -} - -/** - * Skill grant structure - */ -export interface SkillGrant { - id: string; - chance: number; -} - -/** - * Component for entities that can be absorbed by the player. - */ -export class Absorbable extends Component { - evolutionData: EvolutionData; - skillsGranted: SkillGrant[]; - skillAbsorptionChance: number; - mutationChance: number; - absorbed: boolean; - - constructor() { - super(ComponentType.ABSORBABLE); - this.evolutionData = { - human: 0, - beast: 0, - slime: 0, - }; - this.skillsGranted = []; - this.skillAbsorptionChance = 0.3; - this.mutationChance = 0.1; - this.absorbed = false; - } - - /** - * Set evolution data granted upon absorption. - * @param human - Human evolution points - * @param beast - Beast evolution points - * @param slime - Slime evolution points - */ - setEvolutionData(human: number, beast: number, slime: number): void { - this.evolutionData = { human, beast, slime }; - } - - /** - * Add a skill that can be granted upon absorption. - * @param skillId - The ID of the skill - * @param chance - The probability of absorbing this skill (0.0 to 1.0) - */ - addSkill(skillId: string, chance: number | null = null): void { - this.skillsGranted.push({ - id: skillId, - chance: chance || this.skillAbsorptionChance, - }); - } - - /** - * Calculate which skills are successfully absorbed based on their chances. - * @returns Array of successfully absorbed skill IDs - */ - getAbsorbedSkills(): string[] { - const absorbed: string[] = []; - for (const skill of this.skillsGranted) { - if (Math.random() < skill.chance) { - absorbed.push(skill.id); - } - } - return absorbed; - } - - /** - * Determine if a mutation should occur upon absorption. - * @returns True if mutation should occur - */ - shouldMutate(): boolean { - return Math.random() < this.mutationChance; - } -} diff --git a/src/components/Camera.ts b/src/components/Camera.ts deleted file mode 100644 index e8afa88..0000000 --- a/src/components/Camera.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { Component } from '../core/Component.ts'; -import { ComponentType } from '../core/Constants.ts'; - -/** - * Component for camera/viewport management. - */ -export class Camera extends Component { - x: number; - y: number; - targetX: number; - targetY: number; - smoothness: number; - bounds: { - minX: number; - maxX: number; - minY: number; - maxY: number; - }; - viewportWidth: number; - viewportHeight: number; - - constructor(viewportWidth: number, viewportHeight: number, smoothness = 0.15) { - super(ComponentType.CAMERA); - this.x = 0; - this.y = 0; - this.targetX = 0; - this.targetY = 0; - this.smoothness = smoothness; - this.viewportWidth = viewportWidth; - this.viewportHeight = viewportHeight; - this.bounds = { - minX: 0, - maxX: 0, - minY: 0, - maxY: 0, - }; - } - - /** - * Set camera bounds based on map size. - * @param mapWidth - Total map width in pixels - * @param mapHeight - Total map height in pixels - */ - setBounds(mapWidth: number, mapHeight: number): void { - this.bounds.minX = this.viewportWidth / 2; - this.bounds.maxX = mapWidth - this.viewportWidth / 2; - this.bounds.minY = this.viewportHeight / 2; - this.bounds.maxY = mapHeight - this.viewportHeight / 2; - } - - /** - * Clamp camera position to bounds. - */ - clampToBounds(): void { - this.x = Math.max(this.bounds.minX, Math.min(this.bounds.maxX, this.x)); - this.y = Math.max(this.bounds.minY, Math.min(this.bounds.maxY, this.y)); - } -} diff --git a/src/components/Combat.js b/src/components/Combat.js new file mode 100644 index 0000000..516caa0 --- /dev/null +++ b/src/components/Combat.js @@ -0,0 +1,52 @@ +import { Component } from '../core/Component.js'; + +export class Combat extends Component { + constructor() { + super('Combat'); + this.attackDamage = 10; + this.defense = 5; + this.attackSpeed = 1.0; // Attacks per second + this.attackRange = 50; + this.lastAttackTime = 0; + this.attackCooldown = 0; + + // Combat state + this.isAttacking = false; + this.attackDirection = 0; // Angle in radians + this.knockbackResistance = 0.5; + } + + /** + * Check if can attack + */ + canAttack(currentTime) { + return (currentTime - this.lastAttackTime) >= (1.0 / this.attackSpeed); + } + + /** + * Perform attack + */ + attack(currentTime, direction) { + if (!this.canAttack(currentTime)) return false; + + this.lastAttackTime = currentTime; + this.isAttacking = true; + this.attackDirection = direction; + this.attackCooldown = 0.3; // Attack animation duration + + return true; + } + + /** + * Update attack state + */ + update(deltaTime) { + if (this.attackCooldown > 0) { + this.attackCooldown -= deltaTime; + if (this.attackCooldown <= 0) { + this.isAttacking = false; + } + } + } +} + diff --git a/src/components/Combat.ts b/src/components/Combat.ts deleted file mode 100644 index 1020bb9..0000000 --- a/src/components/Combat.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { Component } from '../core/Component.ts'; -import { ComponentType } from '../core/Constants.ts'; - -/** - * Component for combat-related properties and actions. - */ -export class Combat extends Component { - /** The amount of damage dealt by an attack. */ - attackDamage: number; - - /** The amount of damage reduced from incoming attacks. */ - defense: number; - - /** The rate of attacks per second. */ - attackSpeed: number; - - /** The maximum distance an attack can reach. */ - attackRange: number; - - /** The timestamp of the last successful attack. */ - lastAttackTime: number; - - /** The remaining duration of the attack animation/cooldown. */ - attackCooldown: number; - - /** True if the entity is currently in an attack animation/state. */ - isAttacking: boolean; - - /** The direction of the current attack in radians. */ - attackDirection: number; - - /** The resistance to knockback effects (0.0 to 1.0). */ - knockbackResistance: number; - - constructor() { - super(ComponentType.COMBAT); - this.attackDamage = 10; - this.defense = 5; - this.attackSpeed = 1.0; - this.attackRange = 15; - this.lastAttackTime = 0; - this.attackCooldown = 0; - - this.isAttacking = false; - this.attackDirection = 0; - this.knockbackResistance = 0.5; - } - - /** - * Check if the entity can perform an attack based on its attack speed. - * @param currentTime - The current game time in seconds. - * @returns True if the attack speed cooldown has elapsed, false otherwise. - */ - canAttack(currentTime: number): boolean { - return currentTime - this.lastAttackTime >= 1.0 / this.attackSpeed; - } - - /** - * Initiate an attack sequence. - * @param currentTime - The current game time in seconds. - * @param direction - The angle of the attack in radians. - * @returns True if the attack was successfully initiated, false if still on cooldown. - */ - attack(currentTime: number, direction: number): boolean { - if (!this.canAttack(currentTime)) return false; - - this.lastAttackTime = currentTime; - this.isAttacking = true; - this.attackDirection = direction; - this.attackCooldown = 0.3; - - return true; - } - - /** - * Update the internal attack state and timers. - * @param deltaTime - Time elapsed since last frame in seconds. - */ - update(deltaTime: number): void { - if (this.attackCooldown > 0) { - this.attackCooldown -= deltaTime; - if (this.attackCooldown <= 0) { - this.isAttacking = false; - } - } - } -} diff --git a/src/components/Evolution.js b/src/components/Evolution.js new file mode 100644 index 0000000..e3a9c63 --- /dev/null +++ b/src/components/Evolution.js @@ -0,0 +1,105 @@ +import { Component } from '../core/Component.js'; +import { GameConfig } from '../GameConfig.js'; +import { Events } from '../core/EventBus.js'; + +export class Evolution extends Component { + constructor() { + super('Evolution'); + this.human = 0; + this.beast = 0; + this.slime = 100; // Start as pure slime + + // Mutation tracking + this.mutations = new Set(); // Use Set for unique functional mutations + this.mutationEffects = { + electricSkin: false, + glowingBody: false, + hardenedShell: false + }; + } + + /** + * Check and apply functional mutations + */ + checkMutations(stats, engine = null) { + if (!stats) return; + + const config = GameConfig.Evolution.thresholds; + + // Threshold-based functional mutations + if (stats.constitution > config.hardenedShell.constitution && !this.mutationEffects.hardenedShell) { + this.mutationEffects.hardenedShell = true; + this.mutations.add('Hardened Shell'); + if (engine) engine.emit(Events.MUTATION_GAINED, { name: 'Hardened Shell', description: 'Defense Up' }); + console.log("Mutation Gained: Hardened Shell (Defense Up)"); + } + + if (stats.intelligence > config.electricSkin.intelligence && !this.mutationEffects.electricSkin) { + this.mutationEffects.electricSkin = true; + this.mutations.add('Electric Skin'); + if (engine) engine.emit(Events.MUTATION_GAINED, { name: 'Electric Skin', description: 'Damage Reflection' }); + console.log("Mutation Gained: Electric Skin (Damage Reflection)"); + } + + if (this.human > config.glowingBody.human && !this.mutationEffects.glowingBody) { + this.mutationEffects.glowingBody = true; + this.mutations.add('Bioluminescence'); + if (engine) engine.emit(Events.MUTATION_GAINED, { name: 'Bioluminescence', description: 'Light Source' }); + console.log("Mutation Gained: Bioluminescence (Light Source)"); + } + } + + /** + * Add evolution points + */ + addEvolution(human = 0, beast = 0, slime = 0) { + this.human += human; + this.beast += beast; + this.slime += slime; + + // Normalize to keep total around target + const target = GameConfig.Evolution.totalTarget; + const total = this.human + this.beast + this.slime; + if (total > target) { + const factor = target / total; + this.human *= factor; + this.beast *= factor; + this.slime *= factor; + } + + // Ensure no negative values + this.human = Math.max(0, this.human); + this.beast = Math.max(0, this.beast); + this.slime = Math.max(0, this.slime); + } + + /** + * Get dominant form + */ + getDominantForm() { + if (this.human > this.beast && this.human > this.slime) { + return 'human'; + } else if (this.beast > this.human && this.beast > this.slime) { + return 'beast'; + } else { + return 'slime'; + } + } + + /** + * Get form percentage (0-1) + */ + getFormPercentage(form) { + const total = this.human + this.beast + this.slime; + if (total === 0) return 0; + return this[form] / total; + } + + /** + * Add a mutation + */ + addMutation(mutation) { + this.mutations.push(mutation); + } +} + diff --git a/src/components/Evolution.ts b/src/components/Evolution.ts deleted file mode 100644 index e4ea66a..0000000 --- a/src/components/Evolution.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { Component } from '../core/Component.ts'; -import { ComponentType } from '../core/Constants.ts'; -import { GameConfig } from '../GameConfig.ts'; -import { Events } from '../core/EventBus.ts'; -import type { Stats } from './Stats.ts'; -import type { Engine } from '../core/Engine.ts'; - -/** - * Component for tracking evolution and mutations. - */ -export class Evolution extends Component { - human: number; - beast: number; - slime: number; - mutations: Set; - mutationEffects: { - electricSkin: boolean; - glowingBody: boolean; - hardenedShell: boolean; - }; - - constructor() { - super(ComponentType.EVOLUTION); - this.human = 0; - this.beast = 0; - this.slime = 100; - - this.mutations = new Set(); - this.mutationEffects = { - electricSkin: false, - glowingBody: false, - hardenedShell: false, - }; - } - - /** - * Check and apply functional mutations based on stats and evolution levels. - * @param stats - The stats component to check against - * @param engine - The game engine to emit events - */ - checkMutations(stats: Stats, engine: Engine | null = null): void { - if (!stats) return; - - const config = GameConfig.Evolution.thresholds; - - if ( - stats.constitution > config.hardenedShell.constitution && - !this.mutationEffects.hardenedShell - ) { - this.mutationEffects.hardenedShell = true; - this.mutations.add('Hardened Shell'); - if (engine) - engine.emit(Events.MUTATION_GAINED, { name: 'Hardened Shell', description: 'Defense Up' }); - console.log('Mutation Gained: Hardened Shell (Defense Up)'); - } - - if ( - stats.intelligence > config.electricSkin.intelligence && - !this.mutationEffects.electricSkin - ) { - this.mutationEffects.electricSkin = true; - this.mutations.add('Electric Skin'); - if (engine) - engine.emit(Events.MUTATION_GAINED, { - name: 'Electric Skin', - description: 'Damage Reflection', - }); - console.log('Mutation Gained: Electric Skin (Damage Reflection)'); - } - - if (this.human > config.glowingBody.human && !this.mutationEffects.glowingBody) { - this.mutationEffects.glowingBody = true; - this.mutations.add('Bioluminescence'); - if (engine) - engine.emit(Events.MUTATION_GAINED, { - name: 'Bioluminescence', - description: 'Light Source', - }); - console.log('Mutation Gained: Bioluminescence (Light Source)'); - } - } - - /** - * Add evolution points for different forms. - * @param human - Human evolution points - * @param beast - Beast evolution points - * @param slime - Slime evolution points - */ - addEvolution(human = 0, beast = 0, slime = 0): void { - this.human += human; - this.beast += beast; - this.slime += slime; - - const target = GameConfig.Evolution.totalTarget; - const total = this.human + this.beast + this.slime; - if (total > target) { - const factor = target / total; - this.human *= factor; - this.beast *= factor; - this.slime *= factor; - } - - this.human = Math.max(0, this.human); - this.beast = Math.max(0, this.beast); - this.slime = Math.max(0, this.slime); - } - - /** - * Determine the current dominant form of the entity. - * @returns The name of the dominant form ('human', 'beast', or 'slime') - */ - getDominantForm(): string { - if (this.human > this.beast && this.human > this.slime) { - return 'human'; - } else if (this.beast > this.human && this.beast > this.slime) { - return 'beast'; - } else { - return 'slime'; - } - } - - /** - * Get the percentage of a specific form. - * @param form - The form name ('human', 'beast', or 'slime') - * @returns The percentage (0.0 to 1.0) - */ - getFormPercentage(form: 'human' | 'beast' | 'slime'): number { - const total = this.human + this.beast + this.slime; - if (total === 0) return 0; - return this[form] / total; - } - - /** - * Record a new mutation. - * @param mutation - The name of the mutation - */ - addMutation(mutation: string): void { - this.mutations.add(mutation); - } -} diff --git a/src/components/Health.js b/src/components/Health.js new file mode 100644 index 0000000..063f0d0 --- /dev/null +++ b/src/components/Health.js @@ -0,0 +1,29 @@ +import { Component } from '../core/Component.js'; + +export class Health extends Component { + constructor(maxHp = 100, hp = null) { + super('Health'); + this.maxHp = maxHp; + this.hp = hp !== null ? hp : maxHp; + this.regeneration = 2; // HP per second (slime regenerates) + this.lastDamageTime = 0; + this.invulnerable = false; + this.invulnerabilityDuration = 0; + } + + takeDamage(amount) { + if (this.invulnerable) return 0; + this.hp = Math.max(0, this.hp - amount); + this.lastDamageTime = Date.now(); + return amount; + } + + heal(amount) { + this.hp = Math.min(this.maxHp, this.hp + amount); + } + + isDead() { + return this.hp <= 0; + } +} + diff --git a/src/components/Health.ts b/src/components/Health.ts deleted file mode 100644 index 8356a16..0000000 --- a/src/components/Health.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { Component } from '../core/Component.ts'; -import { ComponentType } from '../core/Constants.ts'; - -/** - * Component for tracking entity health and healing. - */ -export class Health extends Component { - maxHp: number; - hp: number; - regeneration: number; - lastDamageTime: number; - invulnerable: boolean; - invulnerabilityDuration: number; - isProjectile?: boolean; - - /** - * @param maxHp - The maximum health points - * @param hp - The current health points (defaults to maxHp) - */ - constructor(maxHp = 100, hp: number | null = null) { - super(ComponentType.HEALTH); - this.maxHp = maxHp; - this.hp = hp !== null ? hp : maxHp; - this.regeneration = 2; - this.lastDamageTime = 0; - this.invulnerable = false; - this.invulnerabilityDuration = 0; - } - - /** - * Reduce health points. - * @param amount - The amount of damage to take - * @returns The actual damage taken (0 if invulnerable) - */ - takeDamage(amount: number): number { - if (this.invulnerable) return 0; - this.hp = Math.max(0, this.hp - amount); - this.lastDamageTime = Date.now(); - return amount; - } - - /** - * Increase health points, capped at maxHp. - * @param amount - The amount to heal - */ - heal(amount: number): void { - this.hp = Math.min(this.maxHp, this.hp + amount); - } - - /** - * Check if the entity is dead. - * @returns True if hp is 0 or less - */ - isDead(): boolean { - return this.hp <= 0; - } -} diff --git a/src/components/Intent.ts b/src/components/Intent.ts deleted file mode 100644 index 5b409ca..0000000 --- a/src/components/Intent.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Component } from '../core/Component.ts'; -import { ComponentType } from '../core/Constants.ts'; - -/** - * Component to track intended actions (Skills, Attacks, Interactions). - * This decouples input from execution. - */ -export class Intent extends Component { - action: string | null; - data: Record; - - /** - * @param action - The initial intended action - * @param data - Additional data for the action - */ - constructor(action: string | null = null, data: Record = {}) { - super(ComponentType.INTENT); - this.action = action; - this.data = data; - } - - /** - * Set a new intended action. - * @param action - The action name (e.g., 'skill_use', 'attack') - * @param data - Additional data for the action - */ - setIntent(action: string, data: Record = {}): void { - this.action = action; - this.data = data; - } - - /** - * Clear the current intent. - */ - clear(): void { - this.action = null; - this.data = {}; - } -} diff --git a/src/components/Inventory.js b/src/components/Inventory.js new file mode 100644 index 0000000..7b619d7 --- /dev/null +++ b/src/components/Inventory.js @@ -0,0 +1,71 @@ +import { Component } from '../core/Component.js'; + +export class Inventory extends Component { + constructor() { + super('Inventory'); + this.items = []; // Array of item objects + this.maxSize = 20; + this.equipped = { + weapon: null, + armor: null, + accessory: null + }; + } + + /** + * Add an item to inventory + */ + addItem(item) { + if (this.items.length < this.maxSize) { + this.items.push(item); + return true; + } + return false; + } + + /** + * Remove an item + */ + removeItem(itemId) { + const index = this.items.findIndex(item => item.id === itemId); + if (index > -1) { + return this.items.splice(index, 1)[0]; + } + return null; + } + + /** + * Equip an item + */ + equipItem(itemId, slot) { + const item = this.items.find(i => i.id === itemId); + if (!item) return false; + + // Unequip current item in slot + if (this.equipped[slot]) { + this.items.push(this.equipped[slot]); + } + + // Equip new item + this.equipped[slot] = item; + const index = this.items.indexOf(item); + if (index > -1) { + this.items.splice(index, 1); + } + + return true; + } + + /** + * Unequip an item + */ + unequipItem(slot) { + if (this.equipped[slot]) { + this.items.push(this.equipped[slot]); + this.equipped[slot] = null; + return true; + } + return false; + } +} + diff --git a/src/components/Inventory.ts b/src/components/Inventory.ts deleted file mode 100644 index 45e5644..0000000 --- a/src/components/Inventory.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { Component } from '../core/Component.ts'; -import { ComponentType } from '../core/Constants.ts'; -import type { Item } from '../items/Item.ts'; - -/** - * Equipment slots structure - */ -export interface Equipment { - weapon: Item | null; - armor: Item | null; - accessory: Item | null; -} - -/** - * Component for managing an entity's items and equipment. - */ -export class Inventory extends Component { - items: Item[]; - maxSize: number; - equipped: Equipment; - - constructor() { - super(ComponentType.INVENTORY); - this.items = []; - this.maxSize = 20; - this.equipped = { - weapon: null, - armor: null, - accessory: null, - }; - } - - /** - * Add an item to the inventory. - * @param item - The item to add - * @returns True if the item was added, false if inventory is full - */ - addItem(item: Item): boolean { - if (this.items.length < this.maxSize) { - this.items.push(item); - return true; - } - return false; - } - - /** - * Remove an item from the inventory. - * @param itemId - The ID of the item to remove - * @returns The removed item, or null if not found - */ - removeItem(itemId: string): Item | null { - const index = this.items.findIndex((item) => item.id === itemId); - if (index > -1) { - return this.items.splice(index, 1)[0]; - } - return null; - } - - /** - * Equip an item from the inventory into a specific slot. - * @param itemId - The ID of the item to equip - * @param slot - The equipment slot - * @returns True if the item was successfully equipped - */ - equipItem(itemId: string, slot: keyof Equipment): boolean { - const item = this.items.find((i) => i.id === itemId); - if (!item) return false; - - const currentItem = this.equipped[slot]; - if (currentItem) { - this.items.push(currentItem); - } - - this.equipped[slot] = item; - const index = this.items.indexOf(item); - if (index > -1) { - this.items.splice(index, 1); - } - - return true; - } - - /** - * Unequip an item from a slot and return it to the inventory. - * @param slot - The equipment slot to clear - * @returns True if an item was unequipped - */ - unequipItem(slot: keyof Equipment): boolean { - const item = this.equipped[slot]; - if (item) { - this.items.push(item); - this.equipped[slot] = null; - return true; - } - return false; - } -} diff --git a/src/components/Music.ts b/src/components/Music.ts deleted file mode 100644 index 35b9621..0000000 --- a/src/components/Music.ts +++ /dev/null @@ -1,234 +0,0 @@ -import { Component } from '../core/Component.ts'; -import { ComponentType } from '../core/Constants.ts'; -import type { Sequence } from '../core/Music.ts'; - -/** - * Component for managing background music and sound effects. - */ -export class Music extends Component { - sequences: Map; - currentSequence: Sequence | null; - activeSequences: Set; - volume: number; - enabled: boolean; - private sequenceChain: string[]; - private currentChainIndex: number; - private sequenceVolumes: Map; - - constructor() { - super(ComponentType.MUSIC); - this.sequences = new Map(); - this.currentSequence = null; - this.activeSequences = new Set(); - this.volume = 0.5; - this.enabled = true; - this.sequenceChain = []; - this.currentChainIndex = 0; - this.sequenceVolumes = new Map(); - } - - /** - * Add a music sequence. - * @param name - Unique identifier for the sequence - * @param sequence - The sequence instance - */ - addSequence(name: string, sequence: Sequence): void { - this.sequences.set(name, sequence); - if (sequence.gain) { - sequence.gain.gain.value = this.volume; - } - } - - /** - * Play a sequence by name. - * @param name - The sequence identifier - */ - playSequence(name: string): void { - if (!this.enabled) return; - - const sequence = this.sequences.get(name); - if (sequence) { - this.stop(); - this.currentSequence = sequence; - if (sequence.gain) { - sequence.gain.gain.value = this.volume; - } - sequence.play(); - } - } - - /** - * Play multiple sequences simultaneously (polyphony). - * @param sequenceConfigs - Array of configs with name, optional delay in beats, and optional loop - */ - playSequences(sequenceConfigs: Array<{ name: string; delay?: number; loop?: boolean }>): void { - if (!this.enabled || sequenceConfigs.length === 0) return; - - const firstSeq = this.sequences.get(sequenceConfigs[0].name); - if (!firstSeq || !firstSeq.ac) return; - - const ac = firstSeq.ac; - const when = ac.currentTime; - const tempo = firstSeq.tempo || 120; - - sequenceConfigs.forEach((config) => { - const sequence = this.sequences.get(config.name); - if (!sequence) return; - - if (config.loop !== undefined) { - sequence.loop = config.loop; - } - if (sequence.gain) { - sequence.gain.gain.value = this.volume; - } - - const delaySeconds = config.delay ? (60 / tempo) * config.delay : 0; - sequence.play(when + delaySeconds); - this.activeSequences.add(sequence); - - if (!this.currentSequence) { - this.currentSequence = sequence; - } - }); - } - - /** - * Chain multiple sequences together in order (sequential playback). - * @param sequenceNames - Array of sequence names to play in order - */ - chainSequences(sequenceNames: string[]): void { - if (!this.enabled || sequenceNames.length === 0) return; - - this.stop(); - this.sequenceChain = sequenceNames; - this.currentChainIndex = 0; - - this.playNextInChain(); - } - - /** - * Play the next sequence in the chain. - */ - private playNextInChain(): void { - if (!this.enabled || this.sequenceChain.length === 0) return; - - const seqName = this.sequenceChain[this.currentChainIndex]; - const sequence = this.sequences.get(seqName); - if (!sequence) return; - - this.currentSequence = sequence; - sequence.loop = false; - if (sequence.gain) { - sequence.gain.gain.value = this.volume; - } - - sequence.play(); - if (sequence.osc) { - const nextIndex = (this.currentChainIndex + 1) % this.sequenceChain.length; - sequence.osc.onended = () => { - if (this.enabled) { - this.currentChainIndex = nextIndex; - this.playNextInChain(); - } - }; - } - } - - /** - * Stop current playback. - */ - stop(): void { - this.activeSequences.forEach((seq) => { - seq.stop(); - }); - this.activeSequences.clear(); - if (this.currentSequence) { - this.currentSequence.stop(); - this.currentSequence = null; - } - } - - /** - * Set the volume (0.0 to 1.0). - * @param volume - Volume level - */ - setVolume(volume: number): void { - this.volume = Math.max(0, Math.min(1, volume)); - if (this.currentSequence && this.currentSequence.gain) { - this.currentSequence.gain.gain.value = this.volume; - } - this.sequences.forEach((seq) => { - if (seq.gain) { - seq.gain.gain.value = this.volume; - } - }); - } - - /** - * Enable or disable music playback. - * @param enabled - Whether music should be enabled - */ - setEnabled(enabled: boolean): void { - this.enabled = enabled; - if (!enabled) { - this.stop(); - } - } - - /** - * Pause all active sequences by setting their gain to 0. - */ - pause(): void { - this.activeSequences.forEach((seq) => { - if (seq.gain) { - const currentVolume = seq.gain.gain.value; - this.sequenceVolumes.set(seq, currentVolume); - seq.gain.gain.value = 0; - } - }); - if (this.currentSequence && this.currentSequence.gain) { - const currentVolume = this.currentSequence.gain.gain.value; - this.sequenceVolumes.set(this.currentSequence, currentVolume); - this.currentSequence.gain.gain.value = 0; - } - this.sequences.forEach((seq) => { - if (seq.gain && seq.gain.gain.value > 0) { - const currentVolume = seq.gain.gain.value; - this.sequenceVolumes.set(seq, currentVolume); - seq.gain.gain.value = 0; - } - }); - } - - /** - * Resume all active sequences by restoring their volume. - */ - resume(): void { - this.activeSequences.forEach((seq) => { - if (seq.gain) { - const savedVolume = this.sequenceVolumes.get(seq); - if (savedVolume !== undefined) { - seq.gain.gain.value = savedVolume; - } else { - seq.gain.gain.value = this.volume; - } - } - }); - if (this.currentSequence && this.currentSequence.gain) { - const savedVolume = this.sequenceVolumes.get(this.currentSequence); - if (savedVolume !== undefined) { - this.currentSequence.gain.gain.value = savedVolume; - } else { - this.currentSequence.gain.gain.value = this.volume; - } - } - this.sequences.forEach((seq) => { - if (seq.gain) { - const savedVolume = this.sequenceVolumes.get(seq); - if (savedVolume !== undefined) { - seq.gain.gain.value = savedVolume; - } - } - }); - } -} diff --git a/src/components/Position.js b/src/components/Position.js new file mode 100644 index 0000000..5713b4b --- /dev/null +++ b/src/components/Position.js @@ -0,0 +1,11 @@ +import { Component } from '../core/Component.js'; + +export class Position extends Component { + constructor(x = 0, y = 0, rotation = 0) { + super('Position'); + this.x = x; + this.y = y; + this.rotation = rotation; + } +} + diff --git a/src/components/Position.ts b/src/components/Position.ts deleted file mode 100644 index d39e84f..0000000 --- a/src/components/Position.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Component } from '../core/Component.ts'; -import { ComponentType } from '../core/Constants.ts'; - -/** - * Component for tracking entity position and rotation in the game world. - */ -export class Position extends Component { - x: number; - y: number; - rotation: number; - - /** - * @param x - The X-coordinate - * @param y - The Y-coordinate - * @param rotation - The rotation in radians - */ - constructor(x = 0, y = 0, rotation = 0) { - super(ComponentType.POSITION); - this.x = x; - this.y = y; - this.rotation = rotation; - } -} diff --git a/src/components/SkillProgress.js b/src/components/SkillProgress.js new file mode 100644 index 0000000..cb27042 --- /dev/null +++ b/src/components/SkillProgress.js @@ -0,0 +1,45 @@ +import { Component } from '../core/Component.js'; + +/** + * Tracks progress toward learning skills + * Need to absorb multiple enemies with the same skill to learn it + */ +export class SkillProgress extends Component { + constructor() { + super('SkillProgress'); + this.skillProgress = new Map(); // skillId -> count (how many times absorbed) + this.requiredAbsorptions = 5; // Need to absorb 5 enemies with a skill to learn it + } + + /** + * Add progress toward learning a skill + */ + addSkillProgress(skillId) { + const current = this.skillProgress.get(skillId) || 0; + this.skillProgress.set(skillId, current + 1); + return this.skillProgress.get(skillId); + } + + /** + * Check if skill can be learned + */ + canLearnSkill(skillId) { + const progress = this.skillProgress.get(skillId) || 0; + return progress >= this.requiredAbsorptions; + } + + /** + * Get progress for a skill + */ + getSkillProgress(skillId) { + return this.skillProgress.get(skillId) || 0; + } + + /** + * Get all skill progress + */ + getAllProgress() { + return this.skillProgress; + } +} + diff --git a/src/components/SkillProgress.ts b/src/components/SkillProgress.ts deleted file mode 100644 index 2ed2ca0..0000000 --- a/src/components/SkillProgress.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { Component } from '../core/Component.ts'; -import { ComponentType } from '../core/Constants.ts'; - -/** - * Tracks progress toward learning skills. - * Need to absorb multiple enemies with the same skill to learn it. - */ -export class SkillProgress extends Component { - skillProgress: Map; - requiredAbsorptions: number; - - constructor() { - super(ComponentType.SKILL_PROGRESS); - this.skillProgress = new Map(); - this.requiredAbsorptions = 5; - } - - /** - * Add progress toward learning a skill. - * @param skillId - The ID of the skill - * @returns The current absorption count for this skill - */ - addSkillProgress(skillId: string): number { - const current = this.skillProgress.get(skillId) || 0; - const newValue = current + 1; - this.skillProgress.set(skillId, newValue); - return newValue; - } - - /** - * Check if a skill has been absorbed enough times to be learned. - * @param skillId - The ID of the skill - * @returns True if progress is greater than or equal to requirements - */ - canLearnSkill(skillId: string): boolean { - const progress = this.skillProgress.get(skillId) || 0; - return progress >= this.requiredAbsorptions; - } - - /** - * Get the current absorption count for a specific skill. - * @param skillId - The ID of the skill - * @returns The current progress count - */ - getSkillProgress(skillId: string): number { - return this.skillProgress.get(skillId) || 0; - } - - /** - * Get the entire skill progress map. - * @returns The progress map - */ - getAllProgress(): Map { - return this.skillProgress; - } -} diff --git a/src/components/Skills.js b/src/components/Skills.js new file mode 100644 index 0000000..6c1869f --- /dev/null +++ b/src/components/Skills.js @@ -0,0 +1,69 @@ +import { Component } from '../core/Component.js'; + +export class Skills extends Component { + constructor() { + super('Skills'); + this.activeSkills = []; // Array of skill IDs + this.passiveSkills = []; // Array of passive skill IDs + this.skillCooldowns = new Map(); // skillId -> remaining cooldown time + } + + /** + * Add a skill + */ + addSkill(skillId, isPassive = false) { + if (isPassive) { + if (!this.passiveSkills.includes(skillId)) { + this.passiveSkills.push(skillId); + } + } else { + if (!this.activeSkills.includes(skillId)) { + this.activeSkills.push(skillId); + } + } + } + + /** + * Check if entity has a skill + */ + hasSkill(skillId) { + return this.activeSkills.includes(skillId) || + this.passiveSkills.includes(skillId); + } + + /** + * Set skill cooldown + */ + setCooldown(skillId, duration) { + this.skillCooldowns.set(skillId, duration); + } + + /** + * Update cooldowns + */ + updateCooldowns(deltaTime) { + for (const [skillId, cooldown] of this.skillCooldowns.entries()) { + const newCooldown = cooldown - deltaTime; + if (newCooldown <= 0) { + this.skillCooldowns.delete(skillId); + } else { + this.skillCooldowns.set(skillId, newCooldown); + } + } + } + + /** + * Check if skill is on cooldown + */ + isOnCooldown(skillId) { + return this.skillCooldowns.has(skillId); + } + + /** + * Get remaining cooldown + */ + getCooldown(skillId) { + return this.skillCooldowns.get(skillId) || 0; + } +} + diff --git a/src/components/Skills.ts b/src/components/Skills.ts deleted file mode 100644 index 969dc78..0000000 --- a/src/components/Skills.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { Component } from '../core/Component.ts'; -import { ComponentType } from '../core/Constants.ts'; - -/** - * Component for managing an entity's active and passive skills. - */ -export class Skills extends Component { - activeSkills: string[]; - passiveSkills: string[]; - skillCooldowns: Map; - - constructor() { - super(ComponentType.SKILLS); - this.activeSkills = []; - this.passiveSkills = []; - this.skillCooldowns = new Map(); - } - - /** - * Add a skill to the entity. - * @param skillId - The ID of the skill to add - * @param isPassive - Whether the skill is passive - */ - addSkill(skillId: string, isPassive = false): void { - if (isPassive) { - if (!this.passiveSkills.includes(skillId)) { - this.passiveSkills.push(skillId); - } - } else { - if (!this.activeSkills.includes(skillId)) { - this.activeSkills.push(skillId); - } - } - } - - /** - * Check if the entity has a specific skill. - * @param skillId - The ID of the skill to check - * @returns True if the entity has the skill (active or passive) - */ - hasSkill(skillId: string): boolean { - return this.activeSkills.includes(skillId) || this.passiveSkills.includes(skillId); - } - - /** - * Set the remaining cooldown duration for a skill. - * @param skillId - The ID of the skill - * @param duration - The cooldown duration in seconds - */ - setCooldown(skillId: string, duration: number): void { - this.skillCooldowns.set(skillId, duration); - } - - /** - * Update all active cooldowns. - * @param deltaTime - Time elapsed since last frame in seconds - */ - updateCooldowns(deltaTime: number): void { - for (const [skillId, cooldown] of this.skillCooldowns.entries()) { - const newCooldown = cooldown - deltaTime; - if (newCooldown <= 0) { - this.skillCooldowns.delete(skillId); - } else { - this.skillCooldowns.set(skillId, newCooldown); - } - } - } - - /** - * Check if a skill is currently on cooldown. - * @param skillId - The ID of the skill - * @returns True if the skill is on cooldown - */ - isOnCooldown(skillId: string): boolean { - return this.skillCooldowns.has(skillId); - } - - /** - * Get the remaining cooldown duration for a skill. - * @param skillId - The ID of the skill - * @returns The remaining cooldown in seconds, or 0 if not on cooldown - */ - getCooldown(skillId: string): number { - return this.skillCooldowns.get(skillId) || 0; - } -} diff --git a/src/components/SoundEffects.ts b/src/components/SoundEffects.ts deleted file mode 100644 index c605140..0000000 --- a/src/components/SoundEffects.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { Component } from '../core/Component.ts'; -import { ComponentType } from '../core/Constants.ts'; -import type { Sequence } from '../core/Music.ts'; - -/** - * Component for managing sound effects. - * Sound effects are short, one-shot audio sequences. - */ -export class SoundEffects extends Component { - sounds: Map; - volume: number; - enabled: boolean; - audioContext: AudioContext | null; - - constructor(audioContext?: AudioContext) { - super(ComponentType.SOUND_EFFECTS); - this.sounds = new Map(); - this.volume = 0.15; // Reduced default volume - this.enabled = true; - this.audioContext = audioContext || null; - } - - /** - * Add a sound effect sequence. - * @param name - Unique identifier for the sound - * @param sequence - The sequence instance (should be short, non-looping) - */ - addSound(name: string, sequence: Sequence): void { - sequence.loop = false; // SFX should never loop - this.sounds.set(name, sequence); - if (sequence.gain) { - sequence.gain.gain.value = this.volume; - } - } - - /** - * Play a sound effect by name. - * @param name - The sound identifier - */ - play(name: string): void { - if (!this.enabled) return; - - const sound = this.sounds.get(name); - if (sound) { - sound.stop(); - if (sound.gain) { - sound.gain.gain.value = this.volume; - } - sound.play(); - } - } - - /** - * Set the volume (0.0 to 1.0). - * @param volume - Volume level - */ - setVolume(volume: number): void { - this.volume = Math.max(0, Math.min(1, volume)); - this.sounds.forEach((seq) => { - if (seq.gain) { - seq.gain.gain.value = this.volume; - } - }); - } - - /** - * Enable or disable sound effects. - * @param enabled - Whether sound effects should be enabled - */ - setEnabled(enabled: boolean): void { - this.enabled = enabled; - } -} diff --git a/src/components/Sprite.js b/src/components/Sprite.js new file mode 100644 index 0000000..94ab18c --- /dev/null +++ b/src/components/Sprite.js @@ -0,0 +1,18 @@ +import { Component } from '../core/Component.js'; + +export class Sprite extends Component { + constructor(color = '#00ff96', width = 30, height = 30, shape = 'circle') { + super('Sprite'); + this.color = color; + this.width = width; + this.height = height; + this.shape = shape; // 'circle', 'rect', 'slime' + this.alpha = 1.0; + this.scale = 1.0; + + // Animation properties + this.animationTime = 0; + this.morphAmount = 0; // For slime morphing + } +} + diff --git a/src/components/Sprite.ts b/src/components/Sprite.ts deleted file mode 100644 index 43a7c22..0000000 --- a/src/components/Sprite.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Component } from '../core/Component.ts'; -import { ComponentType } from '../core/Constants.ts'; -import { EntityType } from '../core/Constants.ts'; -import { AnimationState } from '../core/Constants.ts'; - -/** - * Component for entity visual representation and animation. - */ -export class Sprite extends Component { - color: string; - width: number; - height: number; - shape: string | EntityType; - alpha: number; - scale: number; - animationTime: number; - animationState: string | AnimationState; - animationSpeed: number; - morphAmount: number; - yOffset: number; - - /** - * @param color - The CSS color or hex code - * @param width - The width of the sprite - * @param height - The height of the sprite - * @param shape - The shape type ('circle', 'rect', 'slime', or EntityType) - */ - constructor(color = '#00ff96', width = 30, height = 30, shape: string | EntityType = 'circle') { - super(ComponentType.SPRITE); - this.color = color; - this.width = width; - this.height = height; - this.shape = shape; - this.alpha = 1.0; - this.scale = 1.0; - - this.animationTime = 0; - this.animationState = AnimationState.IDLE; - this.animationSpeed = 4; - this.morphAmount = 0; - this.yOffset = 0; - } -} diff --git a/src/components/Stats.js b/src/components/Stats.js new file mode 100644 index 0000000..9c22ee0 --- /dev/null +++ b/src/components/Stats.js @@ -0,0 +1,55 @@ +import { Component } from '../core/Component.js'; + +export class Stats extends Component { + constructor() { + super('Stats'); + this.strength = 10; // Physical damage + this.agility = 10; // Movement speed, attack speed + this.intelligence = 10; // Magic damage, skill effectiveness + this.constitution = 10; // Max HP, defense + this.perception = 10; // Detection range, stealth detection + + // Derived stats + this.level = 1; + this.experience = 0; + this.experienceToNext = 100; + } + + /** + * Add experience and handle level ups + */ + addExperience(amount) { + this.experience += amount; + let leveledUp = false; + + while (this.experience >= this.experienceToNext) { + this.experience -= this.experienceToNext; + this.levelUp(); + leveledUp = true; + } + + return leveledUp; + } + + /** + * Level up - increase stats + */ + levelUp() { + this.level++; + this.strength += 2; + this.agility += 2; + this.intelligence += 2; + this.constitution += 2; + this.perception += 2; + this.experienceToNext = Math.floor(this.experienceToNext * 1.5); + } + + /** + * Get total stat points + */ + getTotalStats() { + return this.strength + this.agility + this.intelligence + + this.constitution + this.perception; + } +} + diff --git a/src/components/Stats.ts b/src/components/Stats.ts deleted file mode 100644 index 286fde0..0000000 --- a/src/components/Stats.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { Component } from '../core/Component.ts'; -import { ComponentType } from '../core/Constants.ts'; - -/** - * Component for entity base attributes and leveling. - */ -export class Stats extends Component { - strength: number; - agility: number; - intelligence: number; - constitution: number; - perception: number; - level: number; - experience: number; - experienceToNext: number; - - constructor() { - super(ComponentType.STATS); - this.strength = 10; - this.agility = 10; - this.intelligence = 10; - this.constitution = 10; - this.perception = 10; - - this.level = 1; - this.experience = 0; - this.experienceToNext = 100; - } - - /** - * Add experience points and level up if threshold is reached. - * @param amount - The amount of experience to add - * @returns True if the entity leveled up - */ - addExperience(amount: number): boolean { - this.experience += amount; - let leveledUp = false; - - while (this.experience >= this.experienceToNext) { - this.experience -= this.experienceToNext; - this.levelUp(); - leveledUp = true; - } - - return leveledUp; - } - - /** - * Increase level and all base attributes. - */ - levelUp(): void { - this.level++; - this.strength += 2; - this.agility += 2; - this.intelligence += 2; - this.constitution += 2; - this.perception += 2; - this.experienceToNext = Math.floor(this.experienceToNext * 1.5); - } - - /** - * Calculate the sum of all base attributes. - * @returns The total attribute points - */ - getTotalStats(): number { - return this.strength + this.agility + this.intelligence + this.constitution + this.perception; - } -} diff --git a/src/components/Stealth.js b/src/components/Stealth.js new file mode 100644 index 0000000..f1de5ad --- /dev/null +++ b/src/components/Stealth.js @@ -0,0 +1,48 @@ +import { Component } from '../core/Component.js'; + +export class Stealth extends Component { + constructor() { + super('Stealth'); + this.visibility = 1.0; // 0 = fully hidden, 1 = fully visible + this.stealthType = 'slime'; // 'slime', 'beast', 'human' + this.isStealthed = false; + this.stealthLevel = 0; // 0-100 + this.detectionRadius = 100; // How far others can detect this entity + } + + /** + * Enter stealth mode + */ + enterStealth(type) { + this.stealthType = type; + this.isStealthed = true; + this.visibility = 0.3; + } + + /** + * Exit stealth mode + */ + exitStealth() { + this.isStealthed = false; + this.visibility = 1.0; + } + + /** + * Update stealth based on movement and actions + */ + updateStealth(isMoving, isInCombat) { + if (isInCombat) { + this.exitStealth(); + return; + } + + if (this.isStealthed) { + if (isMoving) { + this.visibility = Math.min(1.0, this.visibility + 0.1); + } else { + this.visibility = Math.max(0.1, this.visibility - 0.05); + } + } + } +} + diff --git a/src/components/Stealth.ts b/src/components/Stealth.ts deleted file mode 100644 index 490db21..0000000 --- a/src/components/Stealth.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { Component } from '../core/Component.ts'; -import { ComponentType } from '../core/Constants.ts'; - -/** - * Component for managing entity visibility and stealth mechanics. - */ -export class Stealth extends Component { - visibility: number; - stealthType: string; - isStealthed: boolean; - stealthLevel: number; - detectionRadius: number; - camouflageColor: string | null; - baseColor: string | null; - sizeMultiplier: number; - formAppearance: string | null; - - constructor() { - super(ComponentType.STEALTH); - this.visibility = 1.0; - this.stealthType = 'slime'; - this.isStealthed = false; - this.stealthLevel = 0; - this.detectionRadius = 100; - this.camouflageColor = null; - this.baseColor = null; - this.sizeMultiplier = 1.0; - this.formAppearance = null; - } - - /** - * Enter stealth mode. - * @param type - The type of stealth (e.g., 'slime', 'human') - * @param baseColor - Original entity color to restore later - */ - enterStealth(type: string, baseColor?: string): void { - this.stealthType = type; - this.isStealthed = true; - this.visibility = 0.3; - if (baseColor) { - this.baseColor = baseColor; - } - if (type === 'slime') { - this.sizeMultiplier = 0.6; - } else { - this.sizeMultiplier = 1.0; - } - } - - /** - * Exit stealth mode and restore full visibility. - */ - exitStealth(): void { - this.isStealthed = false; - this.visibility = 1.0; - this.camouflageColor = null; - this.sizeMultiplier = 1.0; - this.formAppearance = null; - } - - /** - * Update visibility levels based on movement and combat state. - * @param isMoving - Whether the entity is currently moving - * @param isInCombat - Whether the entity is currently in combat - */ - updateStealth(isMoving: boolean, isInCombat: boolean): void { - if (isInCombat) { - this.exitStealth(); - return; - } - - if (this.isStealthed) { - if (isMoving) { - this.visibility = Math.min(1.0, this.visibility + 0.1); - } else { - this.visibility = Math.max(0.1, this.visibility - 0.05); - } - } - } -} diff --git a/src/components/Velocity.js b/src/components/Velocity.js new file mode 100644 index 0000000..d6ebd46 --- /dev/null +++ b/src/components/Velocity.js @@ -0,0 +1,11 @@ +import { Component } from '../core/Component.js'; + +export class Velocity extends Component { + constructor(vx = 0, vy = 0) { + super('Velocity'); + this.vx = vx; + this.vy = vy; + this.maxSpeed = 200; + } +} + diff --git a/src/components/Velocity.ts b/src/components/Velocity.ts deleted file mode 100644 index daf0207..0000000 --- a/src/components/Velocity.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Component } from '../core/Component.ts'; -import { ComponentType } from '../core/Constants.ts'; - -/** - * Component for tracking entity velocity and movement speed limits. - */ -export class Velocity extends Component { - vx: number; - vy: number; - maxSpeed: number; - isLocked: boolean; - lockTimer: number; - - /** - * @param vx - Initial X velocity - * @param vy - Initial Y velocity - */ - constructor(vx = 0, vy = 0) { - super(ComponentType.VELOCITY); - this.vx = vx; - this.vy = vy; - this.maxSpeed = 200; - this.isLocked = false; - this.lockTimer = 0; - } -} diff --git a/src/config/MusicConfig.ts b/src/config/MusicConfig.ts deleted file mode 100644 index e1798b2..0000000 --- a/src/config/MusicConfig.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { Sequence } from '../core/Music.ts'; -import type { Music } from '../components/Music.ts'; -import type { MusicSystem } from '../systems/MusicSystem.ts'; - -/** - * Configure and setup background music. - * @param music - Music component instance - * @param audioCtx - AudioContext instance - */ -export function setupMusic(music: Music, audioCtx: AudioContext): void { - const tempo = 132; - - const lead = new Sequence(audioCtx, tempo, [ - 'F4 e', - 'Ab4 e', - 'C5 e', - 'F5 e', - 'C5 e', - 'Ab4 e', - 'F4 e', - 'C4 e', - 'F4 e', - 'Ab4 e', - 'C5 e', - 'F5 e', - 'C5 e', - 'Ab4 e', - 'F4 e', - 'C4 e', - 'G4 e', - 'Bb4 e', - 'D5 e', - 'G5 e', - 'D5 e', - 'Bb4 e', - 'G4 e', - 'D4 e', - 'F4 e', - 'Ab4 e', - 'C5 e', - 'F5 e', - 'C5 e', - 'Ab4 e', - 'F4 e', - 'C4 e', - ]); - lead.staccato = 0.1; - lead.smoothing = 0.3; - lead.waveType = 'triangle'; - lead.loop = true; - if (lead.gain) { - lead.gain.gain.value = 0.8; - } - music.addSequence('lead', lead); - - const harmony = new Sequence(audioCtx, tempo, [ - 'C4 e', - 'Eb4 e', - 'F4 e', - 'Ab4 e', - 'F4 e', - 'Eb4 e', - 'C4 e', - 'Ab3 e', - 'C4 e', - 'Eb4 e', - 'F4 e', - 'Ab4 e', - 'F4 e', - 'Eb4 e', - 'C4 e', - 'Ab3 e', - 'D4 e', - 'F4 e', - 'G4 e', - 'Bb4 e', - 'G4 e', - 'F4 e', - 'D4 e', - 'Bb3 e', - 'C4 e', - 'Eb4 e', - 'F4 e', - 'Ab4 e', - 'F4 e', - 'Eb4 e', - 'C4 e', - 'Ab3 e', - ]); - harmony.staccato = 0.15; - harmony.smoothing = 0.4; - harmony.waveType = 'triangle'; - harmony.loop = true; - if (harmony.gain) { - harmony.gain.gain.value = 0.6; - } - music.addSequence('harmony', harmony); - - const bass = new Sequence(audioCtx, tempo, [ - 'F2 q', - 'C3 q', - 'F2 q', - 'C3 q', - 'G2 q', - 'D3 q', - 'G2 q', - 'D3 q', - 'F2 q', - 'C3 q', - 'F2 q', - 'C3 q', - ]); - bass.staccato = 0.05; - bass.smoothing = 0.5; - bass.waveType = 'triangle'; - bass.loop = true; - if (bass.gain) { - bass.gain.gain.value = 0.7; - } - if (bass.bass) { - bass.bass.gain.value = 4; - bass.bass.frequency.value = 80; - } - music.addSequence('bass', bass); - - music.playSequences([ - { name: 'lead', loop: true }, - { name: 'harmony', loop: true }, - { name: 'bass', loop: true }, - ]); - music.setVolume(0.02); -} - -/** - * Setup music event handlers for canvas interaction. - * @param music - Music component instance - * @param musicSystem - MusicSystem instance - * @param canvas - Canvas element - */ -export function setupMusicHandlers( - music: Music, - musicSystem: MusicSystem, - canvas: HTMLCanvasElement -): void { - canvas.addEventListener('click', () => { - musicSystem.resumeAudioContext(); - if (music.enabled && music.activeSequences.size === 0) { - music.playSequences([ - { name: 'lead', loop: true }, - { name: 'harmony', loop: true }, - { name: 'bass', loop: true }, - ]); - } - canvas.focus(); - }); -} diff --git a/src/config/SFXConfig.ts b/src/config/SFXConfig.ts deleted file mode 100644 index 0ade875..0000000 --- a/src/config/SFXConfig.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { Sequence } from '../core/Music.ts'; -import type { SoundEffects } from '../components/SoundEffects.ts'; - -/** - * Configure and setup sound effects. - * @param sfx - SoundEffects component instance - * @param ac - AudioContext instance - */ -export function setupSFX(sfx: SoundEffects, ac: AudioContext): void { - const attackSound = new Sequence(ac, 120, ['C5 s']); - attackSound.staccato = 0.8; - sfx.addSound('attack', attackSound); - - const absorbSound = new Sequence(ac, 120, ['G4 e']); - absorbSound.staccato = 0.5; - sfx.addSound('absorb', absorbSound); - - const skillSound = new Sequence(ac, 120, ['A4 e']); - skillSound.staccato = 0.6; - sfx.addSound('skill', skillSound); - - const damageSound = new Sequence(ac, 120, ['F4 s']); - damageSound.staccato = 0.8; - sfx.addSound('damage', damageSound); - - const shootSound = new Sequence(ac, 120, ['C5 s']); - shootSound.staccato = 0.9; - sfx.addSound('shoot', shootSound); - - const impactSound = new Sequence(ac, 120, ['G4 s']); - impactSound.staccato = 0.7; - sfx.addSound('impact', impactSound); - - sfx.setVolume(0.02); -} diff --git a/src/core/ColorSampler.ts b/src/core/ColorSampler.ts deleted file mode 100644 index 43081ab..0000000 --- a/src/core/ColorSampler.ts +++ /dev/null @@ -1,102 +0,0 @@ -import type { TileMap } from './TileMap.ts'; -import { Palette } from './Palette.ts'; - -/** - * Utility for sampling colors from the background and tile map. - */ -export class ColorSampler { - private static cache: Map = new Map(); - private static cacheFrame: number = 0; - - /** - * Sample the dominant color from a region around a position based on tile map and background. - * @param tileMap - The tile map to sample from - * @param x - Center X coordinate in world space - * @param y - Center Y coordinate in world space - * @param radius - Sampling radius in pixels - * @returns Dominant color as hex string (e.g., '#1a1a2e') - */ - static sampleDominantColor( - tileMap: TileMap | null, - x: number, - y: number, - radius: number - ): string { - const cacheKey = `${Math.floor(x / 20)}_${Math.floor(y / 20)}`; - const currentFrame = Math.floor(Date.now() / 200); - - if (currentFrame !== this.cacheFrame) { - this.cache.clear(); - this.cacheFrame = currentFrame; - } - - if (this.cache.has(cacheKey)) { - return this.cache.get(cacheKey) || Palette.VOID; - } - - if (!tileMap) { - return Palette.VOID; - } - - const tileSize = tileMap.tileSize; - const startCol = Math.max(0, Math.floor((x - radius) / tileSize)); - const endCol = Math.min(tileMap.cols, Math.ceil((x + radius) / tileSize)); - const startRow = Math.max(0, Math.floor((y - radius) / tileSize)); - const endRow = Math.min(tileMap.rows, Math.ceil((y + radius) / tileSize)); - - const colorCounts: Map = new Map(); - let totalTiles = 0; - - for (let r = startRow; r < endRow; r++) { - for (let c = startCol; c < endCol; c++) { - const tileType = tileMap.getTile(c, r); - let color: string; - - if (tileType === 1) { - color = Palette.DARK_BLUE; - } else { - const distFromCenter = Math.sqrt( - Math.pow(c * tileSize - x, 2) + Math.pow(r * tileSize - y, 2) - ); - if (distFromCenter < radius) { - const noise = Math.sin(c * 0.1 + r * 0.1) * 0.1; - if (Math.random() < 0.3 + noise) { - color = Palette.DARKER_BLUE; - } else { - color = Palette.VOID; - } - } else { - continue; - } - } - - colorCounts.set(color, (colorCounts.get(color) || 0) + 1); - totalTiles++; - } - } - - if (totalTiles === 0) { - return Palette.VOID; - } - - let dominantColor = Palette.VOID; - let maxCount = 0; - - colorCounts.forEach((count, color) => { - if (count > maxCount) { - maxCount = count; - dominantColor = color; - } - }); - - this.cache.set(cacheKey, dominantColor); - return dominantColor; - } - - /** - * Clear the color sampling cache. - */ - static clearCache(): void { - this.cache.clear(); - } -} diff --git a/src/core/Component.js b/src/core/Component.js new file mode 100644 index 0000000..b44eac9 --- /dev/null +++ b/src/core/Component.js @@ -0,0 +1,14 @@ +/** + * Base Component class for ECS architecture + * Components are pure data containers + */ +export class Component { + constructor(type) { + this.type = type; + } + + static getType() { + return this.name; + } +} + diff --git a/src/core/Component.ts b/src/core/Component.ts deleted file mode 100644 index 0a45b2b..0000000 --- a/src/core/Component.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Base Component class for ECS architecture. - * Components are pure data containers. - */ -export class Component { - /** - * The unique type identifier for this component - */ - readonly type: string; - - /** - * @param type - The unique type identifier for this component - */ - constructor(type: string) { - this.type = type; - } - - /** - * Get the type name of the component class. - * @returns The name of the class - */ - static getType(): string { - return this.name; - } -} diff --git a/src/core/Constants.ts b/src/core/Constants.ts deleted file mode 100644 index d64e312..0000000 --- a/src/core/Constants.ts +++ /dev/null @@ -1,88 +0,0 @@ -/** - * Game state enumeration. - */ -export enum GameState { - /** Initial start screen */ - START = 'start', - /** Active gameplay */ - PLAYING = 'playing', - /** Game paused */ - PAUSED = 'paused', - /** Player death screen */ - GAME_OVER = 'gameOver', -} - -/** - * Component type identifiers. - */ -export enum ComponentType { - POSITION = 'Position', - VELOCITY = 'Velocity', - SPRITE = 'Sprite', - HEALTH = 'Health', - COMBAT = 'Combat', - AI = 'AI', - EVOLUTION = 'Evolution', - STATS = 'Stats', - SKILLS = 'Skills', - SKILL_PROGRESS = 'SkillProgress', - ABSORBABLE = 'Absorbable', - STEALTH = 'Stealth', - INTENT = 'Intent', - INVENTORY = 'Inventory', - MUSIC = 'Music', - SOUND_EFFECTS = 'SoundEffects', - CAMERA = 'Camera', -} - -/** - * Entity type identifiers for sprites and behaviors. - */ -export enum EntityType { - SLIME = 'slime', - HUMANOID = 'humanoid', - BEAST = 'beast', - ELEMENTAL = 'elemental', - PROJECTILE = 'projectile', -} - -/** - * Animation states for sprites. - */ -export enum AnimationState { - IDLE = 'idle', - WALK = 'walk', -} - -/** - * Visual effect types. - */ -export enum VFXType { - IMPACT = 'impact', - ABSORPTION = 'absorption', -} - -/** - * System name identifiers. - */ -export enum SystemName { - MENU = 'MenuSystem', - UI = 'UISystem', - PLAYER_CONTROLLER = 'PlayerControllerSystem', - ABSORPTION = 'AbsorptionSystem', - COMBAT = 'CombatSystem', - PROJECTILE = 'ProjectileSystem', - VFX = 'VFXSystem', - MOVEMENT = 'MovementSystem', - AI = 'AISystem', - DEATH = 'DeathSystem', - RENDER = 'RenderSystem', - INPUT = 'InputSystem', - SKILL_EFFECT = 'SkillEffectSystem', - SKILL = 'SkillSystem', - STEALTH = 'StealthSystem', - HEALTH_REGEN = 'HealthRegenerationSystem', - MUSIC = 'MusicSystem', - SOUND_EFFECTS = 'SoundEffectsSystem', - CAMERA = 'CameraSystem', -} diff --git a/src/core/Engine.js b/src/core/Engine.js new file mode 100644 index 0000000..f9720cc --- /dev/null +++ b/src/core/Engine.js @@ -0,0 +1,139 @@ +import { System } from './System.js'; +import { Entity } from './Entity.js'; +import { EventBus } from './EventBus.js'; + +/** + * Main game engine - manages ECS, game loop, and systems + */ +export class Engine { + constructor(canvas) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + this.entities = []; + this.systems = []; + this.events = new EventBus(); + this.running = false; + this.lastTime = 0; + + // Set canvas size + this.canvas.width = 1024; + this.canvas.height = 768; + + // Game state + this.deltaTime = 0; + } + + /** + * Add a system to the engine + */ + addSystem(system) { + if (system instanceof System) { + system.init(this); + this.systems.push(system); + // Sort by priority (lower priority runs first) + this.systems.sort((a, b) => a.priority - b.priority); + } + return this; + } + + /** + * Emit an event locally + */ + emit(event, data) { + this.events.emit(event, data); + } + + /** + * Subscribe to an event + */ + on(event, callback) { + return this.events.on(event, callback); + } + + /** + * Create and add an entity + */ + createEntity() { + const entity = new Entity(); + this.entities.push(entity); + return entity; + } + + /** + * Remove an entity + */ + removeEntity(entity) { + const index = this.entities.indexOf(entity); + if (index > -1) { + this.entities.splice(index, 1); + } + } + + /** + * Get all entities + */ + getEntities() { + return this.entities.filter(e => e.active); + } + + /** + * Main game loop + */ + start() { + if (this.running) return; + this.running = true; + this.lastTime = performance.now(); + this.gameLoop(); + } + + /** + * Stop the game loop + */ + stop() { + this.running = false; + } + + /** + * Game loop using requestAnimationFrame + */ + gameLoop = (currentTime = 0) => { + if (!this.running) return; + + // Calculate delta time + this.deltaTime = (currentTime - this.lastTime) / 1000; // Convert to seconds + this.lastTime = currentTime; + + // Clamp delta time to prevent large jumps + this.deltaTime = Math.min(this.deltaTime, 0.1); + + // Update all systems + const menuSystem = this.systems.find(s => s.name === 'MenuSystem'); + const gameState = menuSystem ? menuSystem.getGameState() : 'playing'; + const isPaused = gameState === 'paused' || gameState === 'start'; + + this.systems.forEach(system => { + // Skip game systems if paused/start menu (but allow MenuSystem, UISystem, and RenderSystem) + if (isPaused && system.name !== 'MenuSystem' && system.name !== 'UISystem' && system.name !== 'RenderSystem') { + return; + } + system.update(this.deltaTime, this.entities); + }); + + // Update input system's previous states at end of frame + const inputSystem = this.systems.find(s => s.name === 'InputSystem'); + if (inputSystem && inputSystem.updatePreviousStates) { + inputSystem.updatePreviousStates(); + } + + // Continue loop + requestAnimationFrame(this.gameLoop); + } + + /** + * Clear the canvas + */ + clear() { + this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); + } +} + diff --git a/src/core/Engine.ts b/src/core/Engine.ts deleted file mode 100644 index 50b07f7..0000000 --- a/src/core/Engine.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { System } from './System.ts'; -import { Entity } from './Entity.ts'; -import { EventBus } from './EventBus.ts'; -import { LevelLoader } from './LevelLoader.ts'; -import { GameState, SystemName } from './Constants.ts'; -import type { TileMap } from './TileMap.ts'; -import type { MenuSystem } from '../systems/MenuSystem.ts'; -import type { InputSystem } from '../systems/InputSystem.ts'; - -/** - * Main game engine responsible for managing the ECS lifecycle, game loop, and system execution. - */ -export class Engine { - canvas: HTMLCanvasElement; - ctx: CanvasRenderingContext2D; - entities: Entity[]; - systems: System[]; - events: EventBus; - running: boolean; - lastTime: number; - deltaTime: number; - tileMap: TileMap | null; - - /** - * @param canvas - The canvas element to render to - */ - constructor(canvas: HTMLCanvasElement) { - this.canvas = canvas; - const ctx = canvas.getContext('2d'); - if (!ctx) { - throw new Error('Failed to get 2d rendering context from canvas'); - } - this.ctx = ctx; - this.entities = []; - this.systems = []; - this.events = new EventBus(); - this.running = false; - this.lastTime = 0; - - this.canvas.width = 320; - this.canvas.height = 240; - - this.canvas.style.imageRendering = 'pixelated'; - this.ctx.imageSmoothingEnabled = false; - - this.deltaTime = 0; - this.tileMap = LevelLoader.loadDesignedLevel(200, 150, 16); - } - - /** - * Add a system and sort systems by priority. - * @param system - The system to add - * @returns This engine instance - */ - addSystem(system: System): Engine { - if (system instanceof System) { - system.init(this); - this.systems.push(system); - this.systems.sort((a, b) => a.priority - b.priority); - } - return this; - } - - /** - * Emit a global event. - * @param event - Unique event identifier - * @param data - Optional event payload - */ - emit(event: string, data?: unknown): void { - this.events.emit(event, data); - } - - /** - * Subscribe to a global event. - * @param event - Unique event identifier - * @param callback - Function to execute when event is emitted - * @returns Unsubscribe function - */ - on(event: string, callback: (data?: unknown) => void): () => void { - return this.events.on(event, callback); - } - - /** - * Create a new entity and track it. - * @returns The newly created entity - */ - createEntity(): Entity { - const entity = new Entity(); - this.entities.push(entity); - return entity; - } - - /** - * Permanently remove an entity from the engine. - * @param entity - The entity to remove - */ - removeEntity(entity: Entity): void { - const index = this.entities.indexOf(entity); - if (index > -1) { - this.entities.splice(index, 1); - } - } - - /** - * Retrieve all currently active entities. - * @returns List of active entities - */ - getEntities(): Entity[] { - return this.entities.filter((e) => e.active); - } - - /** - * Start the game loop. - */ - start(): void { - if (this.running) return; - this.running = true; - this.lastTime = performance.now(); - this.gameLoop(); - } - - /** - * Terminate the game loop. - */ - stop(): void { - this.running = false; - } - - /** - * Core game loop executing system updates. - * @param currentTime - High-resolution timestamp - */ - gameLoop = (currentTime = 0): void => { - if (!this.running) return; - - this.deltaTime = (currentTime - this.lastTime) / 1000; - this.lastTime = currentTime; - - this.deltaTime = Math.min(this.deltaTime, 0.1); - - const menuSystem = this.systems.find((s) => s.name === SystemName.MENU) as - | MenuSystem - | undefined; - const gameState = menuSystem ? menuSystem.getGameState() : GameState.PLAYING; - const isPaused = [GameState.PAUSED, GameState.START, GameState.GAME_OVER].includes(gameState); - const unskippedSystems = [SystemName.MENU, SystemName.UI, SystemName.RENDER, SystemName.MUSIC]; - - this.systems.forEach((system) => { - if (isPaused && !unskippedSystems.includes(system.name as SystemName)) { - return; - } - system.update(this.deltaTime, this.entities); - }); - - const inputSystem = this.systems.find((s) => s.name === SystemName.INPUT) as - | InputSystem - | undefined; - if (inputSystem && inputSystem.updatePreviousStates) { - inputSystem.updatePreviousStates(); - } - - requestAnimationFrame(this.gameLoop); - }; - - /** - * Clear the rendering surface. - */ - clear(): void { - this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); - } -} diff --git a/src/core/Entity.js b/src/core/Entity.js new file mode 100644 index 0000000..0671938 --- /dev/null +++ b/src/core/Entity.js @@ -0,0 +1,58 @@ +/** + * Entity class - represents a game object with a unique ID + * Entities are just containers for components + */ +export class Entity { + static nextId = 0; + + constructor() { + this.id = Entity.nextId++; + this.components = new Map(); + this.active = true; + } + + /** + * Add a component to this entity + */ + addComponent(component) { + this.components.set(component.type, component); + return this; + } + + /** + * Get a component by type + */ + getComponent(type) { + return this.components.get(type); + } + + /** + * Check if entity has a component + */ + hasComponent(type) { + return this.components.has(type); + } + + /** + * Check if entity has all specified components + */ + hasComponents(...types) { + return types.every(type => this.components.has(type)); + } + + /** + * Remove a component + */ + removeComponent(type) { + this.components.delete(type); + return this; + } + + /** + * Get all components + */ + getAllComponents() { + return Array.from(this.components.values()); + } +} + diff --git a/src/core/Entity.ts b/src/core/Entity.ts deleted file mode 100644 index ae39174..0000000 --- a/src/core/Entity.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { Component } from './Component.ts'; - -/** - * Entity class - represents a game object with a unique ID. - * Entities are containers for components. - */ -export class Entity { - private static nextId = 0; - - readonly id: number; - private components: Map; - active: boolean; - - owner?: number; - startX?: number; - startY?: number; - maxRange?: number; - lifetime?: number; - damage?: number; - deathTime?: number; - - constructor() { - this.id = Entity.nextId++; - this.components = new Map(); - this.active = true; - } - - /** - * Add a component to this entity. - * @param component - The component to add - * @returns This entity for chaining - */ - addComponent(component: Component): Entity { - this.components.set(component.type, component); - return this; - } - - /** - * Get a component by type. - * @param type - The component type name - * @returns The component instance if found - */ - getComponent(type: string): T | undefined { - return this.components.get(type) as T | undefined; - } - - /** - * Check if entity has a component. - * @param type - The component type name - * @returns True if the component exists - */ - hasComponent(type: string): boolean { - return this.components.has(type); - } - - /** - * Check if entity has all specified components. - * @param types - List of component type names - * @returns True if all components exist - */ - hasComponents(...types: string[]): boolean { - return types.every((type) => this.components.has(type)); - } - - /** - * Remove a component. - * @param type - The component type name - * @returns This entity for chaining - */ - removeComponent(type: string): Entity { - this.components.delete(type); - return this; - } - - /** - * Get all components attached to this entity. - * @returns Array of components - */ - getAllComponents(): Component[] { - return Array.from(this.components.values()); - } -} diff --git a/src/core/EventBus.js b/src/core/EventBus.js new file mode 100644 index 0000000..6950164 --- /dev/null +++ b/src/core/EventBus.js @@ -0,0 +1,57 @@ +/** + * Lightweight EventBus for pub/sub communication between systems + */ +export const Events = { + // Combat Events + DAMAGE_DEALT: 'combat:damage_dealt', + ENTITY_DIED: 'combat:entity_died', + + // Evolution Events + EVOLVED: 'evolution:evolved', + MUTATION_GAINED: 'evolution:mutation_gained', + + // Leveling Events + EXP_GAINED: 'stats:exp_gained', + LEVEL_UP: 'stats:level_up', + + // Skill Events + SKILL_LEARNED: 'skills:learned', + SKILL_COOLDOWN_STARTED: 'skills:cooldown_started' +}; + +export class EventBus { + constructor() { + this.listeners = new Map(); + } + + /** + * Subscribe to an event + */ + on(event, callback) { + if (!this.listeners.has(event)) { + this.listeners.set(event, []); + } + this.listeners.get(event).push(callback); + return () => this.off(event, callback); + } + + /** + * Unsubscribe from an event + */ + off(event, callback) { + if (!this.listeners.has(event)) return; + const callbacks = this.listeners.get(event); + const index = callbacks.indexOf(callback); + if (index > -1) { + callbacks.splice(index, 1); + } + } + + /** + * Emit an event + */ + emit(event, data) { + if (!this.listeners.has(event)) return; + this.listeners.get(event).forEach(callback => callback(data)); + } +} diff --git a/src/core/EventBus.ts b/src/core/EventBus.ts deleted file mode 100644 index 6ae2906..0000000 --- a/src/core/EventBus.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * Enum for game-wide event types. - */ -export enum Events { - DAMAGE_DEALT = 'combat:damage_dealt', - ENTITY_DIED = 'combat:entity_died', - EVOLVED = 'evolution:evolved', - MUTATION_GAINED = 'evolution:mutation_gained', - EXP_GAINED = 'stats:exp_gained', - LEVEL_UP = 'stats:level_up', - SKILL_LEARNED = 'skills:learned', - ATTACK_PERFORMED = 'combat:attack_performed', - SKILL_COOLDOWN_STARTED = 'skills:cooldown_started', - ABSORPTION = 'absorption:absorbed', - PROJECTILE_CREATED = 'projectile:created', - PROJECTILE_IMPACT = 'projectile:impact', -} - -/** - * Event data types - */ -export interface DamageDealtEvent { - x: number; - y: number; - value: number; - color: string; -} - -export interface MutationGainedEvent { - name: string; - description?: string; -} - -export interface SkillLearnedEvent { - id: string; -} - -export interface EntityDiedEvent { - entity: unknown; -} - -export interface AttackPerformedEvent { - entity: unknown; - angle: number; -} - -type EventCallback = (data?: unknown) => void; - -/** - * Lightweight EventBus for pub/sub communication between systems. - */ -export class EventBus { - private listeners: Map; - - constructor() { - this.listeners = new Map(); - } - - /** - * Subscribe to an event with a callback. - * @param event - The event name from the Events enum - * @param callback - The function to call when the event is emitted - * @returns An unsubscribe function - */ - on(event: string, callback: EventCallback): () => void { - if (!this.listeners.has(event)) { - this.listeners.set(event, []); - } - const callbacks = this.listeners.get(event); - if (callbacks) { - callbacks.push(callback); - } - return () => this.off(event, callback); - } - - /** - * Unsubscribe a specific callback from an event. - * @param event - The event name - * @param callback - The original callback function to remove - */ - off(event: string, callback: EventCallback): void { - if (!this.listeners.has(event)) return; - const callbacks = this.listeners.get(event); - if (callbacks) { - const index = callbacks.indexOf(callback); - if (index > -1) { - callbacks.splice(index, 1); - } - } - } - - /** - * Emit an event to all subscribers. - * @param event - The event name - * @param data - Data to pass to the callbacks - */ - emit(event: string, data?: unknown): void { - if (!this.listeners.has(event)) return; - const callbacks = this.listeners.get(event); - if (callbacks) { - callbacks.forEach((callback) => callback(data)); - } - } -} diff --git a/src/core/LevelLoader.ts b/src/core/LevelLoader.ts deleted file mode 100644 index fe11fef..0000000 --- a/src/core/LevelLoader.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { TileMap } from './TileMap.ts'; - -/** - * Utility class responsible for generating or loading level tile maps. - */ -export class LevelLoader { - /** - * Generates a simple arena level with walls at the boundaries and random obstacles. - * @param cols - Map width in tiles - * @param rows - Map height in tiles - * @param tileSize - Tile size in pixels - * @returns The generated tile map - */ - static loadSimpleLevel(cols: number, rows: number, tileSize: number): TileMap { - const map = new TileMap(cols, rows, tileSize); - - for (let r = 0; r < rows; r++) { - for (let c = 0; c < cols; c++) { - if (r === 0 || r === rows - 1 || c === 0 || c === cols - 1) { - map.setTile(c, r, 1); - } else { - if (Math.random() < 0.1) { - map.setTile(c, r, 1); - } - } - } - } - return map; - } - - /** - * Generates a larger designed map with rooms, corridors, and interesting layout. - * @param cols - Map width in tiles (default 200) - * @param rows - Map height in tiles (default 150) - * @param tileSize - Tile size in pixels (default 16) - * @returns The generated tile map - */ - static loadDesignedLevel(cols = 200, rows = 150, tileSize = 16): TileMap { - const map = new TileMap(cols, rows, tileSize); - - for (let r = 0; r < rows; r++) { - for (let c = 0; c < cols; c++) { - if (r === 0 || r === rows - 1 || c === 0 || c === cols - 1) { - map.setTile(c, r, 1); - } else { - map.setTile(c, r, 0); - } - } - } - - const roomCount = 15; - const rooms: Array<{ x: number; y: number; w: number; h: number }> = []; - - for (let i = 0; i < roomCount; i++) { - const roomW = 8 + Math.floor(Math.random() * 12); - const roomH = 8 + Math.floor(Math.random() * 12); - const roomX = 2 + Math.floor(Math.random() * (cols - roomW - 4)); - const roomY = 2 + Math.floor(Math.random() * (rows - roomH - 4)); - - let overlaps = false; - for (const existingRoom of rooms) { - if ( - roomX < existingRoom.x + existingRoom.w + 2 && - roomX + roomW + 2 > existingRoom.x && - roomY < existingRoom.y + existingRoom.h + 2 && - roomY + roomH + 2 > existingRoom.y - ) { - overlaps = true; - break; - } - } - - if (!overlaps) { - rooms.push({ x: roomX, y: roomY, w: roomW, h: roomH }); - - for (let ry = roomY; ry < roomY + roomH; ry++) { - for (let rx = roomX; rx < roomX + roomW; rx++) { - if (rx > 0 && rx < cols - 1 && ry > 0 && ry < rows - 1) { - map.setTile(rx, ry, 0); - } - } - } - } - } - - for (let i = 1; i < rooms.length; i++) { - const prevRoom = rooms[i - 1]; - const currRoom = rooms[i]; - - const startX = Math.floor(prevRoom.x + prevRoom.w / 2); - const startY = Math.floor(prevRoom.y + prevRoom.h / 2); - const endX = Math.floor(currRoom.x + currRoom.w / 2); - const endY = Math.floor(currRoom.y + currRoom.h / 2); - - let x = startX; - let y = startY; - - while (x !== endX || y !== endY) { - if (x > 0 && x < cols - 1 && y > 0 && y < rows - 1) { - map.setTile(x, y, 0); - } - - if (x < endX) x++; - else if (x > endX) x--; - - if (y < endY) y++; - else if (y > endY) y--; - } - - if (x > 0 && x < cols - 1 && y > 0 && y < rows - 1) { - map.setTile(x, y, 0); - } - } - - for (let r = 1; r < rows - 1; r++) { - for (let c = 1; c < cols - 1; c++) { - if (map.getTile(c, r) === 0 && Math.random() < 0.03) { - map.setTile(c, r, 1); - } - } - } - - return map; - } -} diff --git a/src/core/Music.ts b/src/core/Music.ts deleted file mode 100644 index 656fbf3..0000000 --- a/src/core/Music.ts +++ /dev/null @@ -1,269 +0,0 @@ -/** - * Note class - represents a single musical note - */ -export class Note { - frequency: number; - duration: number; - - constructor(str: string) { - const couple = str.split(/\s+/); - this.frequency = Note.getFrequency(couple[0]) || 0; - this.duration = Note.getDuration(couple[1]) || 0; - } - - /** - * Convert a note name (e.g. 'A4') to a frequency (e.g. 440.00) - */ - static getFrequency(name: string): number { - const enharmonics = 'B#-C|C#-Db|D|D#-Eb|E-Fb|E#-F|F#-Gb|G|G#-Ab|A|A#-Bb|B-Cb'; - const middleC = 440 * Math.pow(Math.pow(2, 1 / 12), -9); - const octaveOffset = 4; - const num = /(\d+)/; - const offsets: Record = {}; - - enharmonics.split('|').forEach((val, i) => { - val.split('-').forEach((note) => { - offsets[note] = i; - }); - }); - - const couple = name.split(num); - const distance = offsets[couple[0]] ?? 0; - const octaveDiff = parseInt(couple[1] || String(octaveOffset), 10) - octaveOffset; - const freq = middleC * Math.pow(Math.pow(2, 1 / 12), distance); - return freq * Math.pow(2, octaveDiff); - } - - /** - * Convert a duration string (e.g. 'q') to a number (e.g. 1) - */ - static getDuration(symbol: string): number { - const numeric = /^[0-9.]+$/; - if (numeric.test(symbol)) { - return parseFloat(symbol); - } - return symbol - .toLowerCase() - .split('') - .reduce((prev, curr) => { - return ( - prev + - (curr === 'w' - ? 4 - : curr === 'h' - ? 2 - : curr === 'q' - ? 1 - : curr === 'e' - ? 0.5 - : curr === 's' - ? 0.25 - : 0) - ); - }, 0); - } -} - -/** - * Sequence class - manages playback of musical sequences - */ -export class Sequence { - ac: AudioContext; - tempo: number; - loop: boolean; - smoothing: number; - staccato: number; - notes: Note[]; - gain: GainNode; - bass: BiquadFilterNode | null; - mid: BiquadFilterNode | null; - treble: BiquadFilterNode | null; - waveType: OscillatorType | 'custom'; - customWave?: [Float32Array, Float32Array]; - osc: OscillatorNode | null; - - constructor(ac?: AudioContext, tempo = 120, arr?: (Note | string)[]) { - this.ac = ac || new AudioContext(); - this.tempo = tempo; - this.loop = true; - this.smoothing = 0; - this.staccato = 0; - this.notes = []; - this.bass = null; - this.mid = null; - this.treble = null; - this.osc = null; - this.waveType = 'square'; - this.gain = this.ac.createGain(); - this.createFxNodes(); - if (arr) { - this.push(...arr); - } - } - - /** - * Create gain and EQ nodes, then connect them - */ - createFxNodes(): void { - const eq: Array<[string, number]> = [ - ['bass', 100], - ['mid', 1000], - ['treble', 2500], - ]; - let prev: AudioNode = this.gain; - - eq.forEach((config) => { - const filter = this.ac.createBiquadFilter(); - filter.type = 'peaking'; - filter.frequency.value = config[1]; - prev.connect(filter); - prev = filter; - - if (config[0] === 'bass') { - this.bass = filter; - } else if (config[0] === 'mid') { - this.mid = filter; - } else if (config[0] === 'treble') { - this.treble = filter; - } - }); - - prev.connect(this.ac.destination); - } - - /** - * Accepts Note instances or strings (e.g. 'A4 e') - */ - push(...notes: (Note | string)[]): this { - notes.forEach((note) => { - this.notes.push(note instanceof Note ? note : new Note(note)); - }); - return this; - } - - /** - * Create a custom waveform - */ - createCustomWave(real: number[], imag?: number[]): void { - if (!imag) { - imag = real; - } - this.waveType = 'custom'; - this.customWave = [new Float32Array(real), new Float32Array(imag)]; - } - - /** - * Recreate the oscillator node (happens on every play) - */ - createOscillator(): this { - this.stop(); - this.osc = this.ac.createOscillator(); - - if (this.customWave) { - this.osc.setPeriodicWave(this.ac.createPeriodicWave(this.customWave[0], this.customWave[1])); - } else { - this.osc.type = this.waveType === 'custom' ? 'square' : this.waveType; - } - - if (this.gain) { - this.osc.connect(this.gain); - } - return this; - } - - /** - * Schedule a note to play at the given time - */ - scheduleNote(index: number, when: number): number { - const duration = (60 / this.tempo) * this.notes[index].duration; - const cutoff = duration * (1 - (this.staccato || 0)); - - this.setFrequency(this.notes[index].frequency, when); - - if (this.smoothing && this.notes[index].frequency) { - this.slide(index, when, cutoff); - } - - this.setFrequency(0, when + cutoff); - return when + duration; - } - - /** - * Get the next note - */ - getNextNote(index: number): Note { - return this.notes[index < this.notes.length - 1 ? index + 1 : 0]; - } - - /** - * How long do we wait before beginning the slide? - */ - getSlideStartDelay(duration: number): number { - return duration - Math.min(duration, (60 / this.tempo) * this.smoothing); - } - - /** - * Slide the note at index into the next note - */ - slide(index: number, when: number, cutoff: number): this { - const next = this.getNextNote(index); - const start = this.getSlideStartDelay(cutoff); - this.setFrequency(this.notes[index].frequency, when + start); - this.rampFrequency(next.frequency, when + cutoff); - return this; - } - - /** - * Set frequency at time - */ - setFrequency(freq: number, when: number): this { - if (this.osc) { - this.osc.frequency.setValueAtTime(freq, when); - } - return this; - } - - /** - * Ramp to frequency at time - */ - rampFrequency(freq: number, when: number): this { - if (this.osc) { - this.osc.frequency.linearRampToValueAtTime(freq, when); - } - return this; - } - - /** - * Run through all notes in the sequence and schedule them - */ - play(when?: number): this { - const startTime = typeof when === 'number' ? when : this.ac.currentTime; - - this.createOscillator(); - if (this.osc) { - this.osc.start(startTime); - - let currentTime = startTime; - this.notes.forEach((_note, i) => { - currentTime = this.scheduleNote(i, currentTime); - }); - - this.osc.stop(currentTime); - this.osc.onended = this.loop ? () => this.play(currentTime) : null; - } - - return this; - } - - /** - * Stop playback - */ - stop(): this { - if (this.osc) { - this.osc.onended = null; - this.osc.disconnect(); - this.osc = null; - } - return this; - } -} diff --git a/src/core/Palette.ts b/src/core/Palette.ts deleted file mode 100644 index 0a619ca..0000000 --- a/src/core/Palette.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Limited color palette used throughout the game to ensure a consistent aesthetic. - */ -export const Palette = { - /** Highlights and UI Text */ - WHITE: '#ffffff', - /** Energy and Slime core */ - CYAN: '#0ce6f2', - /** Water and friendly elements */ - SKY_BLUE: '#0098db', - /** Shadows and visual depth */ - ROYAL_BLUE: '#1e579c', - /** Walls and obstacles */ - DARK_BLUE: '#203562', - /** Background details */ - DARKER_BLUE: '#252446', - /** Deep background and void */ - VOID: '#201533', - - /** - * Get all colors in the palette as an array. - * @returns Array of hex color strings - */ - getAll(): string[] { - return [ - this.WHITE, - this.CYAN, - this.SKY_BLUE, - this.ROYAL_BLUE, - this.DARK_BLUE, - this.DARKER_BLUE, - this.VOID, - ]; - }, -}; diff --git a/src/core/PixelFont.ts b/src/core/PixelFont.ts deleted file mode 100644 index d4cd05c..0000000 --- a/src/core/PixelFont.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * Simple 5x7 Matrix Pixel Font data. - * Each character is represented by an array of 7 integers, where each integer is a 5-bit mask. - * Using Map for better minification/mangling support. - */ -const FONT_DATA = new Map([ - ['A', [0x0e, 0x11, 0x11, 0x1f, 0x11, 0x11, 0x11]], - ['B', [0x1e, 0x11, 0x11, 0x1e, 0x11, 0x11, 0x1e]], - ['C', [0x0e, 0x11, 0x11, 0x10, 0x11, 0x11, 0x0e]], - ['D', [0x1e, 0x11, 0x11, 0x11, 0x11, 0x11, 0x1e]], - ['E', [0x1f, 0x10, 0x10, 0x1e, 0x10, 0x10, 0x1f]], - ['F', [0x1f, 0x10, 0x10, 0x1e, 0x10, 0x10, 0x10]], - ['G', [0x0f, 0x10, 0x10, 0x17, 0x11, 0x11, 0x0f]], - ['H', [0x11, 0x11, 0x11, 0x1f, 0x11, 0x11, 0x11]], - ['I', [0x0e, 0x04, 0x04, 0x04, 0x04, 0x04, 0x0e]], - ['J', [0x07, 0x02, 0x02, 0x02, 0x02, 0x12, 0x0c]], - ['K', [0x11, 0x12, 0x14, 0x18, 0x14, 0x12, 0x11]], - ['L', [0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x1f]], - ['M', [0x11, 0x1b, 0x15, 0x15, 0x11, 0x11, 0x11]], - ['N', [0x11, 0x11, 0x19, 0x15, 0x13, 0x11, 0x11]], - ['O', [0x0e, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0e]], - ['P', [0x1e, 0x11, 0x11, 0x1e, 0x10, 0x10, 0x10]], - ['Q', [0x0e, 0x11, 0x11, 0x11, 0x15, 0x12, 0x0d]], - ['R', [0x1e, 0x11, 0x11, 0x1e, 0x14, 0x12, 0x11]], - ['S', [0x0e, 0x11, 0x10, 0x0e, 0x01, 0x11, 0x0e]], - ['T', [0x1f, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04]], - ['U', [0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0e]], - ['V', [0x11, 0x11, 0x11, 0x11, 0x11, 0x0a, 0x04]], - ['W', [0x11, 0x11, 0x11, 0x15, 0x15, 0x1b, 0x11]], - ['X', [0x11, 0x11, 0x0a, 0x04, 0x0a, 0x11, 0x11]], - ['Y', [0x11, 0x11, 0x0a, 0x04, 0x04, 0x04, 0x04]], - ['Z', [0x1f, 0x01, 0x02, 0x04, 0x08, 0x10, 0x1f]], - ['0', [0x0e, 0x11, 0x13, 0x15, 0x19, 0x11, 0x0e]], - ['1', [0x04, 0x0c, 0x04, 0x04, 0x04, 0x04, 0x0e]], - ['2', [0x0e, 0x11, 0x01, 0x02, 0x04, 0x08, 0x1f]], - ['3', [0x1f, 0x02, 0x04, 0x02, 0x01, 0x11, 0x0e]], - ['4', [0x02, 0x06, 0x0a, 0x12, 0x1f, 0x02, 0x02]], - ['5', [0x1f, 0x10, 0x1e, 0x01, 0x01, 0x11, 0x0e]], - ['6', [0x06, 0x08, 0x10, 0x1e, 0x11, 0x11, 0x0e]], - ['7', [0x1f, 0x01, 0x02, 0x04, 0x08, 0x08, 0x08]], - ['8', [0x0e, 0x11, 0x11, 0x0e, 0x11, 0x11, 0x0e]], - ['9', [0x0e, 0x11, 0x11, 0x0f, 0x01, 0x02, 0x0c]], - [':', [0x00, 0x04, 0x00, 0x00, 0x00, 0x04, 0x00]], - ['.', [0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x00]], - [',', [0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x08]], - ['!', [0x04, 0x04, 0x04, 0x04, 0x04, 0x00, 0x04]], - ['?', [0x0e, 0x11, 0x01, 0x02, 0x04, 0x00, 0x04]], - ['+', [0x00, 0x04, 0x04, 0x1f, 0x04, 0x04, 0x00]], - ['-', [0x00, 0x00, 0x00, 0x1f, 0x00, 0x00, 0x00]], - ['/', [0x01, 0x02, 0x04, 0x08, 0x10, 0x10, 0x10]], - ['(', [0x02, 0x04, 0x04, 0x04, 0x04, 0x04, 0x02]], - [')', [0x08, 0x04, 0x04, 0x04, 0x04, 0x04, 0x08]], - [' ', [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]], - ['|', [0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04]], -]); - -/** - * Utility class for rendering text using a custom pixel font. - */ -export const PixelFont = { - /** - * Render a string of text to the canvas. - * @param ctx - The rendering context - * @param text - The text to draw - * @param x - Horizontal start position - * @param y - Vertical start position - * @param color - The color of the text - * @param scale - Pixel scale factor - */ - drawText( - ctx: CanvasRenderingContext2D, - text: string, - x: number, - y: number, - color = '#ffffff', - scale = 1 - ): void { - ctx.save(); - ctx.fillStyle = color; - let cursorX = x; - - const chars = text.toUpperCase().split(''); - chars.forEach((char) => { - const glyph = FONT_DATA.get(char) || FONT_DATA.get('?'); - if (!glyph) return; - for (let row = 0; row < 7; row++) { - for (let col = 0; col < 5; col++) { - if ((glyph[row] >> (4 - col)) & 1) { - ctx.fillRect(cursorX + col * scale, y + row * scale, scale, scale); - } - } - } - cursorX += 6 * scale; - }); - ctx.restore(); - }, - - /** - * Calculate the total width of a string of text when rendered. - * @param text - The text string - * @param scale - Pixel scale factor - * @returns Width in pixels - */ - getTextWidth(text: string, scale = 1): number { - return text.length * 6 * scale; - }, -}; diff --git a/src/core/SpriteLibrary.ts b/src/core/SpriteLibrary.ts deleted file mode 100644 index 8b60945..0000000 --- a/src/core/SpriteLibrary.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { EntityType, AnimationState } from './Constants.ts'; - -/** - * A 2D grid of pixel values (0-3) - */ -export type SpriteFrame = number[][]; - -/** - * An array of frames forming an animation - */ -export type SpriteAnimation = SpriteFrame[]; - -/** - * A map of animation states to animations - */ -export type EntitySpriteData = Record; - -/** - * Sprite Library defining pixel art grids as 2D arrays. - * - * Pixel Values: - * 0: Transparent - * 1: Primary Color (Entity Color) - * 2: Highlight (White / Shine) - * 3: Detail/Shade (Darker Blue / Eyes) - */ -export const SpriteLibrary: Record = { - [EntityType.SLIME]: { - [AnimationState.IDLE]: [ - [ - [0, 0, 0, 0, 0, 0, 0, 0], - [0, 0, 1, 1, 1, 1, 0, 0], - [0, 1, 1, 1, 1, 1, 1, 0], - [1, 1, 2, 1, 1, 2, 1, 1], - [1, 1, 3, 1, 1, 3, 1, 1], - [1, 1, 1, 1, 1, 1, 1, 1], - [1, 1, 1, 1, 1, 1, 1, 1], - [0, 1, 1, 1, 1, 1, 1, 0], - ], - [ - [0, 0, 0, 0, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 0, 0, 0], - [0, 0, 1, 1, 1, 1, 0, 0], - [0, 1, 1, 1, 1, 1, 1, 0], - [1, 1, 2, 1, 1, 2, 1, 1], - [1, 1, 3, 1, 1, 3, 1, 1], - [1, 1, 1, 1, 1, 1, 1, 1], - [1, 1, 1, 1, 1, 1, 1, 1], - ], - ], - [AnimationState.WALK]: [ - [ - [0, 0, 1, 1, 1, 1, 0, 0], - [0, 1, 1, 1, 1, 1, 1, 0], - [1, 1, 2, 1, 1, 2, 1, 1], - [1, 1, 3, 1, 1, 3, 1, 1], - [1, 1, 1, 1, 1, 1, 1, 1], - [1, 1, 1, 1, 1, 1, 1, 1], - [1, 3, 1, 1, 1, 1, 3, 1], - [0, 1, 1, 1, 1, 1, 1, 0], - ], - [ - [0, 0, 0, 0, 0, 0, 0, 0], - [0, 0, 1, 1, 1, 1, 0, 0], - [0, 1, 1, 1, 1, 1, 1, 0], - [1, 1, 2, 1, 1, 2, 1, 1], - [1, 1, 3, 1, 1, 3, 1, 1], - [1, 1, 1, 1, 1, 1, 1, 1], - [1, 1, 1, 1, 1, 1, 1, 1], - [1, 1, 1, 1, 1, 1, 1, 1], - ], - ], - }, - - [EntityType.HUMANOID]: { - [AnimationState.IDLE]: [ - [ - [0, 0, 0, 1, 1, 0, 0, 0], - [0, 0, 2, 1, 1, 2, 0, 0], - [0, 0, 0, 1, 1, 0, 0, 0], - [0, 1, 1, 1, 1, 1, 1, 0], - [1, 0, 2, 1, 1, 2, 0, 1], - [1, 0, 1, 1, 1, 1, 0, 1], - [0, 0, 1, 0, 0, 1, 0, 0], - [0, 0, 1, 0, 0, 1, 0, 0], - ], - ], - [AnimationState.WALK]: [ - [ - [0, 0, 0, 1, 1, 0, 0, 0], - [0, 0, 2, 1, 1, 2, 0, 0], - [0, 0, 0, 1, 1, 0, 0, 0], - [0, 1, 1, 1, 1, 1, 1, 0], - [0, 0, 2, 1, 1, 2, 0, 1], - [0, 0, 1, 1, 1, 1, 0, 1], - [0, 0, 1, 0, 0, 0, 0, 0], - [0, 1, 1, 0, 0, 0, 0, 0], - ], - [ - [0, 0, 0, 1, 1, 0, 0, 0], - [0, 0, 2, 1, 1, 2, 0, 0], - [0, 0, 0, 1, 1, 0, 0, 0], - [0, 1, 1, 1, 1, 1, 1, 0], - [1, 0, 2, 1, 1, 2, 0, 0], - [1, 0, 1, 1, 1, 1, 0, 0], - [0, 0, 0, 0, 0, 1, 0, 0], - [0, 0, 0, 0, 0, 1, 1, 0], - ], - ], - }, - - [EntityType.BEAST]: { - [AnimationState.IDLE]: [ - [ - [0, 0, 0, 0, 0, 0, 0, 0], - [1, 0, 0, 0, 0, 0, 0, 1], - [0, 1, 1, 1, 1, 1, 1, 0], - [1, 3, 1, 1, 1, 1, 3, 1], - [1, 1, 1, 1, 1, 1, 1, 1], - [0, 1, 1, 2, 2, 1, 1, 0], - [0, 1, 0, 0, 0, 0, 1, 0], - [0, 1, 0, 0, 0, 0, 1, 0], - ], - ], - [AnimationState.WALK]: [ - [ - [1, 0, 0, 0, 0, 0, 0, 1], - [0, 1, 1, 1, 1, 1, 1, 0], - [1, 3, 1, 1, 1, 1, 3, 1], - [1, 1, 1, 1, 1, 1, 1, 1], - [0, 1, 1, 2, 2, 1, 1, 0], - [0, 1, 0, 0, 0, 0, 1, 0], - [1, 1, 0, 0, 0, 0, 1, 1], - [0, 0, 0, 0, 0, 0, 0, 0], - ], - [ - [0, 0, 0, 0, 0, 0, 0, 0], - [1, 0, 0, 0, 0, 0, 0, 1], - [0, 1, 1, 1, 1, 1, 1, 0], - [1, 3, 1, 1, 1, 1, 3, 1], - [1, 1, 1, 1, 1, 1, 1, 1], - [0, 1, 1, 2, 2, 1, 1, 0], - [0, 0, 1, 0, 0, 1, 0, 0], - [0, 0, 1, 0, 0, 1, 0, 0], - ], - ], - }, - - [EntityType.ELEMENTAL]: { - [AnimationState.IDLE]: [ - [ - [0, 0, 2, 1, 1, 2, 0, 0], - [0, 1, 1, 2, 2, 1, 1, 0], - [1, 2, 1, 1, 1, 1, 2, 1], - [1, 1, 3, 3, 3, 3, 1, 1], - [1, 1, 1, 3, 3, 1, 1, 1], - [0, 1, 1, 1, 1, 1, 1, 0], - [0, 0, 1, 1, 1, 1, 0, 0], - [0, 0, 0, 1, 1, 0, 0, 0], - ], - [ - [0, 0, 0, 0, 0, 0, 0, 0], - [0, 0, 2, 1, 1, 2, 0, 0], - [0, 1, 1, 2, 2, 1, 1, 0], - [1, 2, 3, 3, 3, 3, 2, 1], - [1, 1, 1, 3, 3, 1, 1, 1], - [0, 1, 1, 1, 1, 1, 1, 0], - [0, 0, 1, 1, 1, 1, 0, 0], - [0, 0, 0, 1, 1, 0, 0, 0], - ], - ], - }, - - [EntityType.PROJECTILE]: { - [AnimationState.IDLE]: [ - [ - [1, 1], - [1, 1], - ], - ], - }, -}; diff --git a/src/core/System.js b/src/core/System.js new file mode 100644 index 0000000..6b18131 --- /dev/null +++ b/src/core/System.js @@ -0,0 +1,45 @@ +/** + * Base System class for ECS architecture + * Systems contain logic that operates on entities with specific components + */ +export class System { + constructor(name) { + this.name = name; + this.requiredComponents = []; + this.priority = 0; // Lower priority runs first + } + + /** + * Check if an entity matches this system's requirements + */ + matches(entity) { + if (!entity.active) return false; + return this.requiredComponents.every(componentType => + entity.hasComponent(componentType) + ); + } + + /** + * Update method - override in subclasses + */ + update(deltaTime, entities) { + // Filter entities that match this system's requirements + const matchingEntities = entities.filter(entity => this.matches(entity)); + this.process(deltaTime, matchingEntities); + } + + /** + * Process matching entities - override in subclasses + */ + process(_deltaTime, _entities) { + // Override in subclasses + } + + /** + * Called when system is added to engine + */ + init(engine) { + this.engine = engine; + } +} + diff --git a/src/core/System.ts b/src/core/System.ts deleted file mode 100644 index 6c1761a..0000000 --- a/src/core/System.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { Entity } from './Entity.ts'; -import type { Engine } from './Engine.ts'; - -/** - * Base System class for ECS architecture. - * Systems contain logic that operates on entities with specific components. - */ -export class System { - /** Unique identifier for the system */ - readonly name: string; - - /** List of component types required by this system */ - requiredComponents: string[]; - - /** Execution priority (lower runs first) */ - priority: number; - - /** Reference to the game engine */ - protected engine!: Engine; - - /** - * @param name - The unique name of the system - */ - constructor(name: string) { - this.name = name; - this.requiredComponents = []; - this.priority = 0; - } - - /** - * Check if an entity matches this system's requirements. - * @param entity - The entity to check - * @returns True if the entity is active and has all required components - */ - matches(entity: Entity): boolean { - if (!entity.active) return false; - return this.requiredComponents.every((componentType) => entity.hasComponent(componentType)); - } - - /** - * Main update entry point called every frame. - * @param deltaTime - Time elapsed since last frame in seconds - * @param entities - All entities in the engine - */ - update(deltaTime: number, entities: Entity[]): void { - const matchingEntities = entities.filter((entity) => this.matches(entity)); - this.process(deltaTime, matchingEntities); - } - - /** - * Process matching entities. To be implemented by subclasses. - * @param _deltaTime - Time elapsed since last frame in seconds - * @param _entities - Filtered entities that match this system - */ - process(_deltaTime: number, _entities: Entity[]): void {} - - /** - * Called when system is added to engine. - * @param engine - The game engine instance - */ - init(engine: Engine): void { - this.engine = engine; - } -} diff --git a/src/core/TileMap.ts b/src/core/TileMap.ts deleted file mode 100644 index 7f96251..0000000 --- a/src/core/TileMap.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * Class representing a grid-based tile map for the game level. - */ -export class TileMap { - cols: number; - rows: number; - tileSize: number; - tiles: number[]; - - /** - * @param cols - Number of columns - * @param rows - Number of rows - * @param tileSize - Size of each tile in pixels - */ - constructor(cols: number, rows: number, tileSize: number) { - this.cols = cols; - this.rows = rows; - this.tileSize = tileSize; - this.tiles = new Array(cols * rows).fill(0); - } - - /** - * Set the value of a specific tile. - * @param col - Tile column - * @param row - Tile row - * @param value - Tile type value - */ - setTile(col: number, row: number, value: number): void { - if (this.isValid(col, row)) { - this.tiles[row * this.cols + col] = value; - } - } - - /** - * Get the type value of a tile. Treats out-of-bounds as solid. - * @param col - Tile column - * @param row - Tile row - * @returns The tile type value - */ - getTile(col: number, row: number): number { - if (this.isValid(col, row)) { - return this.tiles[row * this.cols + col]; - } - return 1; - } - - /** - * Check if a tile coordinate is within the map boundaries. - * @param col - Tile column - * @param row - Tile row - * @returns True if coordinates are valid - */ - isValid(col: number, row: number): boolean { - return col >= 0 && col < this.cols && row >= 0 && row < this.rows; - } - - /** - * Check if a world position (x, y) collides with a solid tile. - * @param x - World X coordinate - * @param y - World Y coordinate - * @returns True if the position is solid - */ - isSolid(x: number, y: number): boolean { - const col = Math.floor(x / this.tileSize); - const row = Math.floor(y / this.tileSize); - return this.getTile(col, row) !== 0; - } -} diff --git a/src/items/Item.js b/src/items/Item.js new file mode 100644 index 0000000..bbf314b --- /dev/null +++ b/src/items/Item.js @@ -0,0 +1,13 @@ +/** + * Base Item class + */ +export class Item { + constructor(id, name, type = 'consumable') { + this.id = id; + this.name = name; + this.type = type; // 'consumable', 'weapon', 'armor', 'accessory' + this.description = ''; + this.value = 0; + } +} + diff --git a/src/items/Item.ts b/src/items/Item.ts deleted file mode 100644 index 0cd2c7b..0000000 --- a/src/items/Item.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * Base class for all obtainable items in the game. - */ -export class Item { - id: string; - name: string; - type: string; - description: string; - value: number; - - /** - * @param id - Unique identifier for the item - * @param name - Display name of the item - * @param type - Item category (e.g., 'consumable', 'weapon', 'armor', 'accessory') - */ - constructor(id: string, name: string, type = 'consumable') { - this.id = id; - this.name = name; - this.type = type; - this.description = ''; - this.value = 0; - } -} - -/** - * Weapon item with damage and attack speed properties - */ -export interface WeaponItem extends Item { - damage: number; - attackSpeed: number; -} - -/** - * Armor item with defense and HP properties - */ -export interface ArmorItem extends Item { - defense: number; - maxHp: number; -} - -/** - * Consumable item with heal amount property - */ -export interface ConsumableItem extends Item { - healAmount: number; -} diff --git a/src/items/ItemRegistry.js b/src/items/ItemRegistry.js new file mode 100644 index 0000000..91e44cb --- /dev/null +++ b/src/items/ItemRegistry.js @@ -0,0 +1,53 @@ +import { Item } from './Item.js'; + +/** + * Registry for all items in the game + */ +export class ItemRegistry { + static items = new Map(); + + static { + // Register items + // Weapons + this.register(this.createWeapon('iron_sword', 'Iron Sword', 15, 5)); + this.register(this.createWeapon('steel_claw', 'Steel Claw', 20, 3)); + + // Armor + this.register(this.createArmor('leather_armor', 'Leather Armor', 10, 5)); + + // Consumables + this.register(this.createConsumable('health_potion', 'Health Potion', 50)); + } + + static register(item) { + this.items.set(item.id, item); + } + + static get(id) { + return this.items.get(id); + } + + static createWeapon(id, name, damage, speed) { + const item = new Item(id, name, 'weapon'); + item.damage = damage; + item.attackSpeed = speed; + item.description = `Weapon: +${damage} damage, ${speed} speed`; + return item; + } + + static createArmor(id, name, defense, hp) { + const item = new Item(id, name, 'armor'); + item.defense = defense; + item.maxHp = hp; + item.description = `Armor: +${defense} defense, +${hp} HP`; + return item; + } + + static createConsumable(id, name, healAmount) { + const item = new Item(id, name, 'consumable'); + item.healAmount = healAmount; + item.description = `Restores ${healAmount} HP`; + return item; + } +} + diff --git a/src/items/ItemRegistry.ts b/src/items/ItemRegistry.ts deleted file mode 100644 index 691a1af..0000000 --- a/src/items/ItemRegistry.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { Item, WeaponItem, ArmorItem, ConsumableItem } from './Item.ts'; - -/** - * Static registry responsible for defining and storing all available items in the game. - */ -export class ItemRegistry { - static items = new Map(); - - static { - this.register(this.createWeapon('iron_sword', 'Iron Sword', 15, 5)); - this.register(this.createWeapon('steel_claw', 'Steel Claw', 20, 3)); - this.register(this.createArmor('leather_armor', 'Leather Armor', 10, 5)); - this.register(this.createConsumable('health_potion', 'Health Potion', 50)); - } - - /** - * Register an item in the registry. - * @param item - The item instance to register - */ - static register(item: Item): void { - this.items.set(item.id, item); - } - - /** - * Get an item template by its ID. - * @param id - The item identifier - * @returns The item template or undefined if not found - */ - static get(id: string): Item | undefined { - return this.items.get(id); - } - - /** - * Create a weapon item template. - * @param id - Item identifier - * @param name - Display name - * @param damage - Attack damage bonus - * @param speed - Attack speed bonus - * @returns The generated weapon item - */ - static createWeapon(id: string, name: string, damage: number, speed: number): WeaponItem { - const item = new Item(id, name, 'weapon') as WeaponItem; - item.damage = damage; - item.attackSpeed = speed; - item.description = `Weapon: +${damage} damage, ${speed} speed`; - return item; - } - - /** - * Create an armor item template. - * @param id - Item identifier - * @param name - Display name - * @param defense - Defense bonus - * @param hp - Bonus HP - * @returns The generated armor item - */ - static createArmor(id: string, name: string, defense: number, hp: number): ArmorItem { - const item = new Item(id, name, 'armor') as ArmorItem; - item.defense = defense; - item.maxHp = hp; - item.description = `Armor: +${defense} defense, +${hp} HP`; - return item; - } - - /** - * Create a consumable item template. - * @param id - Item identifier - * @param name - Display name - * @param healAmount - Healing amount - * @returns The generated consumable item - */ - static createConsumable(id: string, name: string, healAmount: number): ConsumableItem { - const item = new Item(id, name, 'consumable') as ConsumableItem; - item.healAmount = healAmount; - item.description = `Restores ${healAmount} HP`; - return item; - } -} diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..7c6f278 --- /dev/null +++ b/src/main.js @@ -0,0 +1,158 @@ +import { Engine } from './core/Engine.js'; +import { InputSystem } from './systems/InputSystem.js'; +import { MovementSystem } from './systems/MovementSystem.js'; +import { PlayerControllerSystem } from './systems/PlayerControllerSystem.js'; +import { CombatSystem } from './systems/CombatSystem.js'; +import { AISystem } from './systems/AISystem.js'; +import { AbsorptionSystem } from './systems/AbsorptionSystem.js'; +import { SkillSystem } from './systems/SkillSystem.js'; +import { StealthSystem } from './systems/StealthSystem.js'; +import { ProjectileSystem } from './systems/ProjectileSystem.js'; +import { SkillEffectSystem } from './systems/SkillEffectSystem.js'; +import { HealthRegenerationSystem } from './systems/HealthRegenerationSystem.js'; +import { DeathSystem } from './systems/DeathSystem.js'; +import { MenuSystem } from './systems/MenuSystem.js'; +import { RenderSystem } from './systems/RenderSystem.js'; +import { UISystem } from './systems/UISystem.js'; + +// Components +import { Position } from './components/Position.js'; +import { Velocity } from './components/Velocity.js'; +import { Sprite } from './components/Sprite.js'; +import { Health } from './components/Health.js'; +import { Stats } from './components/Stats.js'; +import { Evolution } from './components/Evolution.js'; +import { Skills } from './components/Skills.js'; +import { Inventory } from './components/Inventory.js'; +import { Combat } from './components/Combat.js'; +import { Stealth } from './components/Stealth.js'; +import { AI } from './components/AI.js'; +import { Absorbable } from './components/Absorbable.js'; +import { SkillProgress } from './components/SkillProgress.js'; + +// Initialize game +const canvas = document.getElementById('game-canvas'); +if (!canvas) { + console.error('Canvas element not found!'); +} else { + const engine = new Engine(canvas); + + // Add systems in order + engine.addSystem(new MenuSystem(engine)); + engine.addSystem(new InputSystem()); + engine.addSystem(new PlayerControllerSystem()); + engine.addSystem(new StealthSystem()); + engine.addSystem(new AISystem()); + engine.addSystem(new MovementSystem()); + engine.addSystem(new CombatSystem()); + engine.addSystem(new ProjectileSystem()); + engine.addSystem(new AbsorptionSystem()); + engine.addSystem(new SkillSystem()); + engine.addSystem(new SkillEffectSystem()); + engine.addSystem(new HealthRegenerationSystem()); + engine.addSystem(new DeathSystem()); + engine.addSystem(new RenderSystem(engine)); + engine.addSystem(new UISystem(engine)); + + // Create player entity + const player = engine.createEntity(); + player.addComponent(new Position(512, 384)); + player.addComponent(new Velocity(0, 0, 200)); + player.addComponent(new Sprite('#00ff96', 40, 40, 'slime')); + player.addComponent(new Health(100)); + player.addComponent(new Stats()); + player.addComponent(new Evolution()); + + // Give player a starting skill so they can test it + const playerSkills = new Skills(); + playerSkills.addSkill('slime_gun', false); // Add slime_gun as starting skill (basic slime ability) + player.addComponent(playerSkills); + + player.addComponent(new Inventory()); + player.addComponent(new Combat()); + player.addComponent(new Stealth()); + player.addComponent(new SkillProgress()); // Track skill learning progress + + // Create creatures + function createCreature(engine, x, y, type) { + const creature = engine.createEntity(); + creature.addComponent(new Position(x, y)); + creature.addComponent(new Velocity(0, 0, 100)); + + let color, evolutionData, skills; + + switch (type) { + case 'humanoid': + color = '#ff5555'; // Humanoid red + evolutionData = { human: 10, beast: 0, slime: -2 }; + skills = ['fire_breath']; + break; + case 'beast': + color = '#ffaa00'; // Beast orange + evolutionData = { human: 0, beast: 10, slime: -2 }; + skills = ['pounce']; + break; + case 'elemental': + color = '#00bfff'; + evolutionData = { human: 3, beast: 3, slime: 8 }; + skills = ['fire_breath']; + break; + default: + color = '#888888'; + evolutionData = { human: 2, beast: 2, slime: 2 }; + skills = []; + } + + creature.addComponent(new Sprite(color, 25, 25, 'circle')); + creature.addComponent(new Health(50 + Math.random() * 30)); + creature.addComponent(new Stats()); + creature.addComponent(new Combat()); + creature.addComponent(new AI('wander')); + + const absorbable = new Absorbable(); + absorbable.setEvolutionData(evolutionData.human, evolutionData.beast, evolutionData.slime); + skills.forEach(skill => absorbable.addSkill(skill, 0.3)); + creature.addComponent(absorbable); + + return creature; + } + + // Spawn initial creatures + for (let i = 0; i < 8; i++) { + const x = 100 + Math.random() * 824; + const y = 100 + Math.random() * 568; + const types = ['humanoid', 'beast', 'elemental']; + const type = types[Math.floor(Math.random() * types.length)]; + createCreature(engine, x, y, type); + } + + // Spawn new creatures periodically + setInterval(() => { + const existingCreatures = engine.getEntities().filter(e => + e.hasComponent('AI') && e !== player + ); + + if (existingCreatures.length < 10) { + const x = 100 + Math.random() * 824; + const y = 100 + Math.random() * 568; + const types = ['humanoid', 'beast', 'elemental']; + const type = types[Math.floor(Math.random() * types.length)]; + createCreature(engine, x, y, type); + } + }, 5000); + + // Focus canvas for keyboard input + canvas.focus(); + + // Start engine but MenuSystem will control when gameplay begins + engine.start(); + + // Make engine globally available for debugging + window.gameEngine = engine; + window.player = player; + + // Re-focus canvas on click + canvas.addEventListener('click', () => { + canvas.focus(); + }); +} diff --git a/src/main.ts b/src/main.ts deleted file mode 100644 index dc16e33..0000000 --- a/src/main.ts +++ /dev/null @@ -1,236 +0,0 @@ -import { Engine } from './core/Engine.ts'; -import { InputSystem } from './systems/InputSystem.ts'; -import { MovementSystem } from './systems/MovementSystem.ts'; -import { PlayerControllerSystem } from './systems/PlayerControllerSystem.ts'; -import { CombatSystem } from './systems/CombatSystem.ts'; -import { AISystem } from './systems/AISystem.ts'; -import { AbsorptionSystem } from './systems/AbsorptionSystem.ts'; -import { SkillSystem } from './systems/SkillSystem.ts'; -import { StealthSystem } from './systems/StealthSystem.ts'; -import { ProjectileSystem } from './systems/ProjectileSystem.ts'; -import { SkillEffectSystem } from './systems/SkillEffectSystem.ts'; -import { HealthRegenerationSystem } from './systems/HealthRegenerationSystem.ts'; -import { DeathSystem } from './systems/DeathSystem.ts'; -import { MenuSystem } from './systems/MenuSystem.ts'; -import { RenderSystem } from './systems/RenderSystem.ts'; -import { UISystem } from './systems/UISystem.ts'; -import { VFXSystem } from './systems/VFXSystem.ts'; -import { MusicSystem } from './systems/MusicSystem.ts'; -import { SoundEffectsSystem } from './systems/SoundEffectsSystem.ts'; -import { CameraSystem } from './systems/CameraSystem.ts'; - -import { Position } from './components/Position.ts'; -import { Velocity } from './components/Velocity.ts'; -import { Sprite } from './components/Sprite.ts'; -import { Health } from './components/Health.ts'; -import { Stats } from './components/Stats.ts'; -import { Evolution } from './components/Evolution.ts'; -import { Skills } from './components/Skills.ts'; -import { Inventory } from './components/Inventory.ts'; -import { Combat } from './components/Combat.ts'; -import { Stealth } from './components/Stealth.ts'; -import { AI } from './components/AI.ts'; -import { Absorbable } from './components/Absorbable.ts'; -import { SkillProgress } from './components/SkillProgress.ts'; -import { Intent } from './components/Intent.ts'; -import { Music } from './components/Music.ts'; -import { SoundEffects } from './components/SoundEffects.ts'; -import { Camera } from './components/Camera.ts'; - -import { EntityType, ComponentType } from './core/Constants.ts'; -import type { Entity } from './core/Entity.ts'; -import { setupMusic, setupMusicHandlers } from './config/MusicConfig.ts'; -import { setupSFX } from './config/SFXConfig.ts'; - -const canvas = document.getElementById('game-canvas') as HTMLCanvasElement; -if (!canvas) { - console.error('Canvas element not found!'); -} else { - const engine = new Engine(canvas); - - engine.addSystem(new MenuSystem(engine)); - engine.addSystem(new InputSystem()); - engine.addSystem(new MusicSystem()); - engine.addSystem(new SoundEffectsSystem()); - engine.addSystem(new CameraSystem()); - engine.addSystem(new PlayerControllerSystem()); - engine.addSystem(new StealthSystem()); - engine.addSystem(new AISystem()); - engine.addSystem(new MovementSystem()); - engine.addSystem(new CombatSystem()); - engine.addSystem(new ProjectileSystem()); - engine.addSystem(new AbsorptionSystem()); - engine.addSystem(new SkillSystem()); - engine.addSystem(new SkillEffectSystem()); - engine.addSystem(new HealthRegenerationSystem()); - engine.addSystem(new DeathSystem()); - engine.addSystem(new VFXSystem()); - engine.addSystem(new RenderSystem(engine)); - engine.addSystem(new UISystem(engine)); - - const player = engine.createEntity(); - const startX = engine.tileMap ? (engine.tileMap.cols * engine.tileMap.tileSize) / 2 : 160; - const startY = engine.tileMap ? (engine.tileMap.rows * engine.tileMap.tileSize) / 2 : 120; - player.addComponent(new Position(startX, startY)); - player.addComponent(new Velocity(0, 0)); - player.addComponent(new Sprite('#00ff96', 14, 14, EntityType.SLIME)); - player.addComponent(new Health(100)); - player.addComponent(new Stats()); - player.addComponent(new Evolution()); - - const playerSkills = new Skills(); - playerSkills.addSkill('slime_gun', false); - player.addComponent(playerSkills); - - player.addComponent(new Inventory()); - player.addComponent(new Combat()); - player.addComponent(new Stealth()); - player.addComponent(new SkillProgress()); - player.addComponent(new Intent()); - - const cameraEntity = engine.createEntity(); - const camera = new Camera(canvas.width, canvas.height, 0.15); - if (engine.tileMap) { - const mapWidth = engine.tileMap.cols * engine.tileMap.tileSize; - const mapHeight = engine.tileMap.rows * engine.tileMap.tileSize; - camera.setBounds(mapWidth, mapHeight); - camera.x = startX; - camera.y = startY; - } - cameraEntity.addComponent(camera); - - function createCreature(engine: Engine, x: number, y: number, type: EntityType): Entity { - const creature = engine.createEntity(); - creature.addComponent(new Position(x, y)); - creature.addComponent(new Velocity(0, 0)); - - let color: string; - let evolutionData: { human: number; beast: number; slime: number }; - let skills: string[]; - - switch (type) { - case EntityType.HUMANOID: - color = '#ff5555'; - evolutionData = { human: 10, beast: 0, slime: -2 }; - skills = ['fire_breath']; - break; - case EntityType.BEAST: - color = '#ffaa00'; - evolutionData = { human: 0, beast: 10, slime: -2 }; - skills = ['pounce']; - break; - case EntityType.ELEMENTAL: - color = '#00bfff'; - evolutionData = { human: 3, beast: 3, slime: 8 }; - skills = ['fire_breath']; - break; - default: - color = '#888888'; - evolutionData = { human: 2, beast: 2, slime: 2 }; - skills = []; - } - - creature.addComponent(new Sprite(color, 10, 10, type)); - creature.addComponent(new Health(15 + Math.random() * 10)); - creature.addComponent(new Stats()); - creature.addComponent(new Combat()); - creature.addComponent(new AI('wander')); - creature.addComponent(new Intent()); - - const absorbable = new Absorbable(); - absorbable.setEvolutionData(evolutionData.human, evolutionData.beast, evolutionData.slime); - skills.forEach((skillId) => absorbable.addSkill(skillId, 0.3)); - creature.addComponent(absorbable); - - return creature; - } - - const mapWidth = engine.tileMap ? engine.tileMap.cols * engine.tileMap.tileSize : 320; - const mapHeight = engine.tileMap ? engine.tileMap.rows * engine.tileMap.tileSize : 240; - - function spawnEnemyNearPlayer(): void { - const playerPos = player.getComponent(ComponentType.POSITION); - if (!playerPos) return; - - const spawnRadius = 150; - const minDistance = 80; - const maxAttempts = 10; - - for (let attempt = 0; attempt < maxAttempts; attempt++) { - const angle = Math.random() * Math.PI * 2; - const distance = minDistance + Math.random() * (spawnRadius - minDistance); - const x = playerPos.x + Math.cos(angle) * distance; - const y = playerPos.y + Math.sin(angle) * distance; - - if (x >= 50 && x <= mapWidth - 50 && y >= 50 && y <= mapHeight - 50) { - const types = [EntityType.HUMANOID, EntityType.BEAST, EntityType.ELEMENTAL]; - const type = types[Math.floor(Math.random() * types.length)]; - createCreature(engine, x, y, type); - return; - } - } - } - - const numberOfEnemies = 20; - - for (let i = 0; i < numberOfEnemies / 2; i++) { - spawnEnemyNearPlayer(); - } - - setInterval(() => { - const existingCreatures = engine - .getEntities() - .filter((e) => e.hasComponent(ComponentType.AI) && e !== player); - - if (existingCreatures.length < numberOfEnemies) { - spawnEnemyNearPlayer(); - } - }, 5000); - - const musicSystem = engine.systems.find((s) => s.name === 'MusicSystem') as - | MusicSystem - | undefined; - if (musicSystem) { - const musicEntity = engine.createEntity(); - const music = new Music(); - const audioCtx = musicSystem.getAudioContext(); - - setupMusic(music, audioCtx); - musicEntity.addComponent(music); - setupMusicHandlers(music, musicSystem, canvas); - - const sfxSystem = engine.systems.find((s) => s.name === 'SoundEffectsSystem') as - | SoundEffectsSystem - | undefined; - if (sfxSystem) { - const sfxEntity = engine.createEntity(); - const sfx = new SoundEffects(audioCtx); - setupSFX(sfx, audioCtx); - sfxEntity.addComponent(sfx); - } - } else { - canvas.addEventListener('click', () => { - canvas.focus(); - }); - } - - canvas.focus(); - engine.start(); - - interface WindowWithGame { - gameEngine?: Engine; - player?: Entity; - music?: Music; - } - (window as WindowWithGame).gameEngine = engine; - (window as WindowWithGame).player = player; - if (musicSystem) { - const musicEntity = engine.getEntities().find((e) => e.hasComponent(ComponentType.MUSIC)); - if (musicEntity) { - const music = musicEntity.getComponent(ComponentType.MUSIC); - if (music) { - (window as WindowWithGame).music = music; - } - } - } -} diff --git a/src/skills/Skill.js b/src/skills/Skill.js new file mode 100644 index 0000000..2ed22ee --- /dev/null +++ b/src/skills/Skill.js @@ -0,0 +1,32 @@ +/** + * Base Skill class + */ +export class Skill { + constructor(id, name, cooldown = 2.0) { + this.id = id; + this.name = name; + this.cooldown = cooldown; + this.description = ''; + } + + /** + * Activate the skill + * @param {Entity} caster - Entity using the skill + * @param {Engine} engine - Game engine + * @returns {boolean} - Whether skill was successfully activated + */ + activate(_caster, _engine) { + // Override in subclasses + return false; + } + + + /** + * Check if skill can be used + */ + canUse(caster, _engine) { + const skills = caster.getComponent('Skills'); + if (!skills) return false; + return !skills.isOnCooldown(this.id); + } +} diff --git a/src/skills/Skill.ts b/src/skills/Skill.ts deleted file mode 100644 index 9df4c75..0000000 --- a/src/skills/Skill.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { Entity } from '../core/Entity.ts'; -import type { Engine } from '../core/Engine.ts'; -import { ComponentType } from '../core/Constants.ts'; -import type { Skills } from '../components/Skills.ts'; - -/** - * Base class for all skills in the game. - */ -export class Skill { - id: string; - name: string; - cooldown: number; - description: string; - - /** - * @param id - Unique identifier for the skill - * @param name - Display name of the skill - * @param cooldown - Cooldown duration in seconds - */ - constructor(id: string, name: string, cooldown = 2.0) { - this.id = id; - this.name = name; - this.cooldown = cooldown; - this.description = ''; - } - - /** - * Activate the skill's effects. - * @param _caster - Entity using the skill - * @param _engine - Game engine - * @returns Whether skill was successfully activated - */ - activate(_caster: Entity, _engine: Engine): boolean { - return false; - } - - /** - * Check if the skill can be used by the caster. - * @param caster - The caster entity - * @param _engine - Game engine - * @returns True if the skill is not on cooldown - */ - canUse(caster: Entity, _engine: Engine): boolean { - const skills = caster.getComponent(ComponentType.SKILLS); - if (!skills) return false; - return !skills.isOnCooldown(this.id); - } -} diff --git a/src/skills/SkillRegistry.js b/src/skills/SkillRegistry.js new file mode 100644 index 0000000..7dc7fe2 --- /dev/null +++ b/src/skills/SkillRegistry.js @@ -0,0 +1,36 @@ +import { SlimeGun } from './skills/WaterGun.js'; // File still named WaterGun.js but class is SlimeGun +import { FireBreath } from './skills/FireBreath.js'; +import { Pounce } from './skills/Pounce.js'; +import { StealthMode } from './skills/StealthMode.js'; + +/** + * Registry for all skills in the game + */ +export class SkillRegistry { + static skills = new Map(); + + static { + // Register all skills + this.register(new SlimeGun()); + this.register(new FireBreath()); + this.register(new Pounce()); + this.register(new StealthMode()); + } + + static register(skill) { + this.skills.set(skill.id, skill); + } + + static get(id) { + return this.skills.get(id); + } + + static getAll() { + return Array.from(this.skills.values()); + } + + static has(id) { + return this.skills.has(id); + } +} + diff --git a/src/skills/SkillRegistry.ts b/src/skills/SkillRegistry.ts deleted file mode 100644 index ce951cb..0000000 --- a/src/skills/SkillRegistry.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { SlimeGun } from './skills/WaterGun.ts'; -import { FireBreath } from './skills/FireBreath.ts'; -import { Pounce } from './skills/Pounce.ts'; -import { StealthMode } from './skills/StealthMode.ts'; -import type { Skill } from './Skill.ts'; - -/** - * Static registry responsible for storing and retrieving all available skills. - */ -export class SkillRegistry { - static skills = new Map(); - - static { - this.register(new SlimeGun()); - this.register(new FireBreath()); - this.register(new Pounce()); - this.register(new StealthMode()); - } - - /** - * Register a skill instance in the registry. - * @param skill - The skill instance to register - */ - static register(skill: Skill): void { - this.skills.set(skill.id, skill); - } - - /** - * Retrieve a skill instance by its unique ID. - * @param id - The skill identifier - * @returns The skill instance or undefined if not found - */ - static get(id: string): Skill | undefined { - return this.skills.get(id); - } - - /** - * Get an array of all registered skills. - * @returns Array of all skills - */ - static getAll(): Skill[] { - return Array.from(this.skills.values()); - } - - /** - * Check if a skill ID is registered. - * @param id - The skill identifier - * @returns True if the skill exists in the registry - */ - static has(id: string): boolean { - return this.skills.has(id); - } -} diff --git a/src/skills/skills/FireBreath.js b/src/skills/skills/FireBreath.js new file mode 100644 index 0000000..e9f4603 --- /dev/null +++ b/src/skills/skills/FireBreath.js @@ -0,0 +1,83 @@ +import { Skill } from '../Skill.js'; + +export class FireBreath extends Skill { + constructor() { + super('fire_breath', 'Fire Breath', 3.0); + this.description = 'Breathe fire in a cone'; + this.damage = 25; + this.range = 150; + this.coneAngle = Math.PI / 3; // 60 degrees + this.duration = 0.5; // Animation duration + } + + activate(caster, engine) { + if (!this.canUse(caster, engine)) return false; + + const position = caster.getComponent('Position'); + const stats = caster.getComponent('Stats'); + const skills = caster.getComponent('Skills'); + const inputSystem = engine.systems.find(s => s.name === 'InputSystem'); + + if (!position || !skills) return false; + + // Calculate direction from player to mouse + let fireAngle = position.rotation; + if (inputSystem) { + const mouse = inputSystem.getMousePosition(); + const dx = mouse.x - position.x; + const dy = mouse.y - position.y; + if (Math.abs(dx) > 0.1 || Math.abs(dy) > 0.1) { + fireAngle = Math.atan2(dy, dx); + position.rotation = fireAngle; + } + } + + skills.setCooldown(this.id, this.cooldown); + + // Add visual effect + const skillEffectSystem = engine.systems.find(s => s.name === 'SkillEffectSystem'); + if (skillEffectSystem) { + skillEffectSystem.addEffect({ + type: 'fire_breath', + x: position.x, + y: position.y, + angle: fireAngle, + range: this.range, + coneAngle: this.coneAngle, + lifetime: this.duration, + time: 0 + }); + } + + // Damage all enemies in cone + const entities = engine.getEntities(); + const damage = this.damage + (stats ? stats.intelligence * 0.5 : 0); + + entities.forEach(entity => { + if (entity.id === caster.id) return; + if (!entity.hasComponent('Health')) return; + + const targetPos = entity.getComponent('Position'); + if (!targetPos) return; + + const dx = targetPos.x - position.x; + const dy = targetPos.y - position.y; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (distance <= this.range) { + const angle = Math.atan2(dy, dx); + const angleDiff = Math.abs(angle - fireAngle); + const normalizedDiff = Math.abs(((angleDiff % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2)); + const minDiff = Math.min(normalizedDiff, Math.PI * 2 - normalizedDiff); + + if (minDiff < this.coneAngle / 2) { + const health = entity.getComponent('Health'); + health.takeDamage(damage); + } + } + }); + + return true; + } +} + diff --git a/src/skills/skills/FireBreath.ts b/src/skills/skills/FireBreath.ts deleted file mode 100644 index 894a854..0000000 --- a/src/skills/skills/FireBreath.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { Skill } from '../Skill.ts'; -import { ComponentType, SystemName } from '../../core/Constants.ts'; -import type { Entity } from '../../core/Entity.ts'; -import type { Engine } from '../../core/Engine.ts'; -import type { Position } from '../../components/Position.ts'; -import type { Stats } from '../../components/Stats.ts'; -import type { Skills } from '../../components/Skills.ts'; -import type { Health } from '../../components/Health.ts'; -import type { InputSystem } from '../../systems/InputSystem.ts'; -import type { SkillEffectSystem } from '../../systems/SkillEffectSystem.ts'; - -/** - * Skill that deals damage in a cone-shaped area in front of the caster. - */ -export class FireBreath extends Skill { - damage: number; - range: number; - coneAngle: number; - duration: number; - - constructor() { - super('fire_breath', 'Fire Breath', 3.0); - this.description = 'Breathe fire in a cone'; - this.damage = 25; - this.range = 80; - this.coneAngle = Math.PI / 3; - this.duration = 0.5; - } - - /** - * Activate the fire breath effect, damaging enemies within the cone. - * @param caster - The caster entity - * @param engine - The game engine - * @returns True if the skill was activated - */ - activate(caster: Entity, engine: Engine): boolean { - if (!this.canUse(caster, engine)) return false; - - const position = caster.getComponent(ComponentType.POSITION); - const stats = caster.getComponent(ComponentType.STATS); - const skills = caster.getComponent(ComponentType.SKILLS); - const inputSystem = engine.systems.find((s) => s.name === SystemName.INPUT) as - | InputSystem - | undefined; - - if (!position || !skills) return false; - - let fireAngle = position.rotation; - if (inputSystem) { - const mouse = inputSystem.getMousePosition(); - const dx = mouse.x - position.x; - const dy = mouse.y - position.y; - if (Math.abs(dx) > 0.1 || Math.abs(dy) > 0.1) { - fireAngle = Math.atan2(dy, dx); - position.rotation = fireAngle; - } - } - - skills.setCooldown(this.id, this.cooldown); - - const skillEffectSystem = engine.systems.find((s) => s.name === SystemName.SKILL_EFFECT) as - | SkillEffectSystem - | undefined; - if (skillEffectSystem) { - skillEffectSystem.addEffect({ - type: 'fire_breath', - x: position.x, - y: position.y, - angle: fireAngle, - range: this.range, - coneAngle: this.coneAngle, - lifetime: this.duration, - time: 0, - }); - } - - const entities = engine.getEntities(); - const damage = this.damage + (stats ? stats.intelligence * 0.5 : 0); - - entities.forEach((entity) => { - if (entity.id === caster.id) return; - if (!entity.hasComponent(ComponentType.HEALTH)) return; - - const targetPos = entity.getComponent(ComponentType.POSITION); - if (!targetPos) return; - - const dx = targetPos.x - position.x; - const dy = targetPos.y - position.y; - const distance = Math.sqrt(dx * dx + dy * dy); - - if (distance <= this.range) { - const angle = Math.atan2(dy, dx); - const angleDiff = Math.abs(angle - fireAngle); - const normalizedDiff = Math.abs( - ((angleDiff % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2) - ); - const minDiff = Math.min(normalizedDiff, Math.PI * 2 - normalizedDiff); - - if (minDiff < this.coneAngle / 2) { - const health = entity.getComponent(ComponentType.HEALTH); - if (health) { - health.takeDamage(damage); - } - } - } - }); - - return true; - } -} diff --git a/src/skills/skills/Pounce.js b/src/skills/skills/Pounce.js new file mode 100644 index 0000000..a82d7d7 --- /dev/null +++ b/src/skills/skills/Pounce.js @@ -0,0 +1,97 @@ +import { Skill } from '../Skill.js'; + +export class Pounce extends Skill { + constructor() { + super('pounce', 'Pounce', 2.0); + this.description = 'Leap forward and damage enemies'; + this.damage = 20; + this.range = 100; + this.dashSpeed = 400; + this.dashDuration = 0.2; // How long the dash lasts + } + + activate(caster, engine) { + if (!this.canUse(caster, engine)) return false; + + const position = caster.getComponent('Position'); + const velocity = caster.getComponent('Velocity'); + const stats = caster.getComponent('Stats'); + const skills = caster.getComponent('Skills'); + const inputSystem = engine.systems.find(s => s.name === 'InputSystem'); + + if (!position || !velocity || !skills) return false; + + // Calculate direction from player to mouse + let dashAngle = position.rotation; + if (inputSystem) { + const mouse = inputSystem.getMousePosition(); + const dx = mouse.x - position.x; + const dy = mouse.y - position.y; + if (Math.abs(dx) > 0.1 || Math.abs(dy) > 0.1) { + dashAngle = Math.atan2(dy, dx); + position.rotation = dashAngle; + } + } + + skills.setCooldown(this.id, this.cooldown); + + // Store start position for effect + const startX = position.x; + const startY = position.y; + + // Dash forward + velocity.vx = Math.cos(dashAngle) * this.dashSpeed; + velocity.vy = Math.sin(dashAngle) * this.dashSpeed; + + // Add visual effect + const skillEffectSystem = engine.systems.find(s => s.name === 'SkillEffectSystem'); + if (skillEffectSystem) { + skillEffectSystem.addEffect({ + type: 'pounce', + startX: startX, + startY: startY, + angle: dashAngle, + speed: this.dashSpeed, + lifetime: this.dashDuration, + time: 0 + }); + } + + // Damage enemies at destination after dash + setTimeout(() => { + const entities = engine.getEntities(); + const damage = this.damage + (stats ? stats.strength * 0.4 : 0); + + entities.forEach(entity => { + if (entity.id === caster.id) return; + if (!entity.hasComponent('Health')) return; + + const targetPos = entity.getComponent('Position'); + if (!targetPos) return; + + const dx = targetPos.x - position.x; + const dy = targetPos.y - position.y; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (distance <= this.range) { + const health = entity.getComponent('Health'); + health.takeDamage(damage); + + // Add impact effect + if (skillEffectSystem) { + skillEffectSystem.addEffect({ + type: 'pounce_impact', + x: position.x, + y: position.y, + lifetime: 0.3, + time: 0 + }); + } + } + }); + }, this.dashDuration * 1000); + + return true; + } +} + diff --git a/src/skills/skills/Pounce.ts b/src/skills/skills/Pounce.ts deleted file mode 100644 index 163e682..0000000 --- a/src/skills/skills/Pounce.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { Skill } from '../Skill.ts'; -import { ComponentType, SystemName } from '../../core/Constants.ts'; -import type { Entity } from '../../core/Entity.ts'; -import type { Engine } from '../../core/Engine.ts'; -import type { Position } from '../../components/Position.ts'; -import type { Velocity } from '../../components/Velocity.ts'; -import type { Sprite } from '../../components/Sprite.ts'; -import type { Stats } from '../../components/Stats.ts'; -import type { Skills } from '../../components/Skills.ts'; -import type { Health } from '../../components/Health.ts'; -import type { InputSystem } from '../../systems/InputSystem.ts'; -import type { SkillEffectSystem, SkillEffect } from '../../systems/SkillEffectSystem.ts'; - -/** - * Skill that allows the caster to leap forward, dealing damage and knockback upon landing. - */ -export class Pounce extends Skill { - damage: number; - range: number; - dashSpeed: number; - dashDuration: number; - - constructor() { - super('pounce', 'Pounce', 2.0); - this.description = 'Leap forward and damage enemies'; - this.damage = 25; - this.range = 30; - this.dashSpeed = 300; - this.dashDuration = 0.2; - } - - /** - * Activate the pounce leap, setting velocity and adding visual effects. - * @param caster - The caster entity - * @param engine - The game engine - * @returns True if the skill was activated - */ - activate(caster: Entity, engine: Engine): boolean { - if (!this.canUse(caster, engine)) return false; - - const position = caster.getComponent(ComponentType.POSITION); - const velocity = caster.getComponent(ComponentType.VELOCITY); - const sprite = caster.getComponent(ComponentType.SPRITE); - const skills = caster.getComponent(ComponentType.SKILLS); - const inputSystem = engine.systems.find((s) => s.name === SystemName.INPUT) as - | InputSystem - | undefined; - - if (!position || !velocity || !skills) return false; - - let dashAngle = position.rotation; - if (inputSystem) { - const mouse = inputSystem.getMousePosition(); - const dx = mouse.x - position.x; - const dy = mouse.y - position.y; - if (Math.abs(dx) > 1 || Math.abs(dy) > 1) { - dashAngle = Math.atan2(dy, dx); - position.rotation = dashAngle; - } - } - - skills.setCooldown(this.id, this.cooldown); - - velocity.isLocked = true; - velocity.lockTimer = this.dashDuration; - - velocity.vx = Math.cos(dashAngle) * this.dashSpeed; - velocity.vy = Math.sin(dashAngle) * this.dashSpeed; - - const skillEffectSystem = engine.systems.find((s) => s.name === SystemName.SKILL_EFFECT) as - | SkillEffectSystem - | undefined; - if (skillEffectSystem) { - skillEffectSystem.addEffect({ - type: 'pounce', - caster: caster, - startX: position.x, - startY: position.y, - angle: dashAngle, - lifetime: this.dashDuration, - time: 0, - onUpdate: (_deltaTime: number) => { - const effects = skillEffectSystem.getEffects(); - const effect = effects.find( - (e: SkillEffect) => e.caster === caster && e.type === 'pounce' - ); - if (!effect) return; - - const progress = effect.time / effect.lifetime; - const jumpHeight = 15; - if (sprite) { - sprite.yOffset = -Math.sin(Math.PI * progress) * jumpHeight; - sprite.scale = 1.0 + Math.sin(Math.PI * progress) * 0.4; - } - }, - onComplete: () => { - this.triggerImpact(caster, engine); - if (sprite) { - sprite.yOffset = 0; - sprite.scale = 1.0; - } - }, - }); - } - - return true; - } - - /** - * Execute the impact damage and knockback upon landing. - * @param caster - The caster entity - * @param engine - The game engine - */ - triggerImpact(caster: Entity, engine: Engine): void { - const position = caster.getComponent(ComponentType.POSITION); - const stats = caster.getComponent(ComponentType.STATS); - if (!position) return; - - const skillEffectSystem = engine.systems.find((s) => s.name === SystemName.SKILL_EFFECT) as - | SkillEffectSystem - | undefined; - if (skillEffectSystem) { - skillEffectSystem.addEffect({ - type: 'pounce_impact', - x: position.x, - y: position.y, - lifetime: 0.3, - time: 0, - }); - } - - const damage = this.damage + (stats ? stats.strength * 0.5 : 0); - const entities = engine.getEntities(); - - entities.forEach((entity) => { - if (entity === caster) return; - if (!entity.hasComponent(ComponentType.HEALTH)) return; - - const targetPos = entity.getComponent(ComponentType.POSITION); - if (!targetPos) return; - - const dx = targetPos.x - position.x; - const dy = targetPos.y - position.y; - const distance = Math.sqrt(dx * dx + dy * dy); - - if (distance <= this.range) { - const health = entity.getComponent(ComponentType.HEALTH); - if (health) { - health.takeDamage(damage); - } - - const targetVel = entity.getComponent(ComponentType.VELOCITY); - if (targetVel) { - const angle = Math.atan2(dy, dx); - targetVel.vx += Math.cos(angle) * 150; - targetVel.vy += Math.sin(angle) * 150; - } - } - }); - } -} diff --git a/src/skills/skills/StealthMode.js b/src/skills/skills/StealthMode.js new file mode 100644 index 0000000..368104b --- /dev/null +++ b/src/skills/skills/StealthMode.js @@ -0,0 +1,35 @@ +import { Skill } from '../Skill.js'; + +export class StealthMode extends Skill { + constructor() { + super('stealth_mode', 'Stealth Mode', 5.0); + this.description = 'Enter stealth mode'; + this.duration = 10.0; + } + + activate(caster, engine) { + if (!this.canUse(caster, engine)) return false; + + const stealth = caster.getComponent('Stealth'); + const skills = caster.getComponent('Skills'); + const evolution = caster.getComponent('Evolution'); + + if (!stealth || !skills) return false; + + skills.setCooldown(this.id, this.cooldown); + + // Determine stealth type from evolution + const form = evolution ? evolution.getDominantForm() : 'slime'; + stealth.enterStealth(form); + + // Auto-exit after duration + setTimeout(() => { + if (stealth.isStealthed) { + stealth.exitStealth(); + } + }, this.duration * 1000); + + return true; + } +} + diff --git a/src/skills/skills/StealthMode.ts b/src/skills/skills/StealthMode.ts deleted file mode 100644 index 09567b7..0000000 --- a/src/skills/skills/StealthMode.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { Skill } from '../Skill.ts'; -import { ComponentType } from '../../core/Constants.ts'; -import type { Entity } from '../../core/Entity.ts'; -import type { Engine } from '../../core/Engine.ts'; -import type { Stealth } from '../../components/Stealth.ts'; -import type { Skills } from '../../components/Skills.ts'; -import type { Evolution } from '../../components/Evolution.ts'; - -/** - * Skill that allows the caster to enter a stealthed state, reducing visibility. - */ -export class StealthMode extends Skill { - duration: number; - - constructor() { - super('stealth_mode', 'Stealth Mode', 5.0); - this.description = 'Enter stealth mode'; - this.duration = 10.0; - } - - /** - * Activate stealth mode, applying form-based stealth to the caster. - * @param caster - The caster entity - * @param _engine - The game engine - * @returns True if the skill was activated - */ - activate(caster: Entity, _engine: Engine): boolean { - if (!this.canUse(caster, _engine)) return false; - - const stealth = caster.getComponent(ComponentType.STEALTH); - const skills = caster.getComponent(ComponentType.SKILLS); - const evolution = caster.getComponent(ComponentType.EVOLUTION); - - if (!stealth || !skills) return false; - - skills.setCooldown(this.id, this.cooldown); - - const form = evolution ? evolution.getDominantForm() : 'slime'; - stealth.enterStealth(form); - - setTimeout(() => { - if (stealth.isStealthed) { - stealth.exitStealth(); - } - }, this.duration * 1000); - - return true; - } -} diff --git a/src/skills/skills/WaterGun.js b/src/skills/skills/WaterGun.js new file mode 100644 index 0000000..e5341e3 --- /dev/null +++ b/src/skills/skills/WaterGun.js @@ -0,0 +1,84 @@ +import { Skill } from '../Skill.js'; +import { Position } from '../../components/Position.js'; +import { Velocity } from '../../components/Velocity.js'; +import { Sprite } from '../../components/Sprite.js'; +import { Health } from '../../components/Health.js'; + +export class SlimeGun extends Skill { + constructor() { + super('slime_gun', 'Slime Gun', 1.0); + this.description = 'Shoot a blob of slime at enemies (costs 1 HP)'; + this.damage = 15; + this.range = 800; // Long range for a gun + this.speed = 600; // Faster projectile + this.hpCost = 1; + } + + activate(caster, engine) { + if (!this.canUse(caster, engine)) return false; + + const position = caster.getComponent('Position'); + const health = caster.getComponent('Health'); + const stats = caster.getComponent('Stats'); + const skills = caster.getComponent('Skills'); + const inputSystem = engine.systems.find(s => s.name === 'InputSystem'); + + if (!position || !skills || !health) return false; + + // Check if we have enough HP + if (health.hp <= this.hpCost) { + return false; // Can't use if it would kill us + } + + // Calculate direction from player to mouse + let shootAngle = position.rotation; + if (inputSystem) { + const mouse = inputSystem.getMousePosition(); + const dx = mouse.x - position.x; + const dy = mouse.y - position.y; + if (Math.abs(dx) > 0.1 || Math.abs(dy) > 0.1) { + shootAngle = Math.atan2(dy, dx); + } + } + + // Cost HP (sacrificing slime) + health.takeDamage(this.hpCost); + + // Set cooldown + skills.setCooldown(this.id, this.cooldown); + + // Create projectile (slime blob) + const projectile = engine.createEntity(); + const startX = position.x; + const startY = position.y; + projectile.addComponent(new Position(startX, startY)); + + // Create velocity with high maxSpeed for projectiles + const projectileVelocity = new Velocity( + Math.cos(shootAngle) * this.speed, + Math.sin(shootAngle) * this.speed + ); + projectileVelocity.maxSpeed = this.speed * 2; // Allow projectiles to move fast + projectile.addComponent(projectileVelocity); + // Slime-colored projectile + projectile.addComponent(new Sprite('#00ff96', 10, 10, 'slime')); + + // Projectile has temporary health for collision detection + const projectileHealth = new Health(1); + projectileHealth.isProjectile = true; + projectile.addComponent(projectileHealth); + + // Store projectile data + projectile.damage = this.damage + (stats ? stats.intelligence * 0.3 : 0); + projectile.owner = caster.id; + projectile.startX = startX; + projectile.startY = startY; + projectile.maxRange = this.range; + projectile.speed = this.speed; + // Lifetime as backup (should be longer than range travel time) + projectile.lifetime = (this.range / this.speed) + 1.0; + + return true; + } +} + diff --git a/src/skills/skills/WaterGun.ts b/src/skills/skills/WaterGun.ts deleted file mode 100644 index 944c472..0000000 --- a/src/skills/skills/WaterGun.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { Skill } from '../Skill.ts'; -import { ComponentType, SystemName, EntityType } from '../../core/Constants.ts'; -import { Events } from '../../core/EventBus.ts'; -import { Position } from '../../components/Position.ts'; -import { Velocity } from '../../components/Velocity.ts'; -import { Sprite } from '../../components/Sprite.ts'; -import { Health } from '../../components/Health.ts'; -import type { Entity } from '../../core/Entity.ts'; -import type { Engine } from '../../core/Engine.ts'; -import type { Stats } from '../../components/Stats.ts'; -import type { Skills } from '../../components/Skills.ts'; -import type { InputSystem } from '../../systems/InputSystem.ts'; - -/** - * Skill that fires a projectile, costing health but dealing ranged damage. - */ -export class SlimeGun extends Skill { - damage: number; - range: number; - speed: number; - hpCost: number; - - constructor() { - super('slime_gun', 'Slime Gun', 1.0); - this.description = 'Shoot a blob of slime at enemies (costs 1 HP)'; - this.damage = 15; - this.range = 250; - this.speed = 250; - this.hpCost = 1; - } - - /** - * Activate the slime gun, sacrificing health to create a projectile. - * @param caster - The caster entity - * @param engine - The game engine - * @returns True if the projectile was successfully created - */ - activate(caster: Entity, engine: Engine): boolean { - if (!this.canUse(caster, engine)) return false; - - const position = caster.getComponent(ComponentType.POSITION); - const health = caster.getComponent(ComponentType.HEALTH); - const stats = caster.getComponent(ComponentType.STATS); - const skills = caster.getComponent(ComponentType.SKILLS); - const inputSystem = engine.systems.find((s) => s.name === SystemName.INPUT) as - | InputSystem - | undefined; - - if (!position || !skills || !health) return false; - - if (health.hp <= this.hpCost) { - return false; - } - - let shootAngle = position.rotation; - if (inputSystem) { - const mouse = inputSystem.getMousePosition(); - const dx = mouse.x - position.x; - const dy = mouse.y - position.y; - if (Math.abs(dx) > 0.1 || Math.abs(dy) > 0.1) { - shootAngle = Math.atan2(dy, dx); - } - } - - health.takeDamage(this.hpCost); - skills.setCooldown(this.id, this.cooldown); - - const projectile = engine.createEntity(); - const startX = position.x; - const startY = position.y; - projectile.addComponent(new Position(startX, startY)); - - const projectileVelocity = new Velocity( - Math.cos(shootAngle) * this.speed, - Math.sin(shootAngle) * this.speed - ); - projectileVelocity.maxSpeed = this.speed * 2; - projectile.addComponent(projectileVelocity); - - projectile.addComponent(new Sprite('#00ff96', 4, 4, EntityType.PROJECTILE)); - - const projectileHealth = new Health(1); - projectileHealth.isProjectile = true; - projectile.addComponent(projectileHealth); - - projectile.damage = this.damage + (stats ? stats.intelligence * 0.3 : 0); - projectile.owner = caster.id; - projectile.startX = startX; - projectile.startY = startY; - projectile.maxRange = this.range; - projectile.lifetime = this.range / this.speed + 1.0; - - engine.emit(Events.PROJECTILE_CREATED, { - x: startX, - y: startY, - angle: shootAngle, - }); - - return true; - } -} diff --git a/src/systems/AISystem.js b/src/systems/AISystem.js new file mode 100644 index 0000000..c156861 --- /dev/null +++ b/src/systems/AISystem.js @@ -0,0 +1,205 @@ +import { System } from '../core/System.js'; +import { GameConfig } from '../GameConfig.js'; + +export class AISystem extends System { + constructor() { + super('AISystem'); + this.requiredComponents = ['Position', 'Velocity', 'AI']; + this.priority = 15; + } + + process(deltaTime, entities) { + const playerController = this.engine.systems.find(s => s.name === 'PlayerControllerSystem'); + const player = playerController ? playerController.getPlayerEntity() : null; + const playerPos = player?.getComponent('Position'); + const config = GameConfig.AI; + + entities.forEach(entity => { + const ai = entity.getComponent('AI'); + const position = entity.getComponent('Position'); + const velocity = entity.getComponent('Velocity'); + const _stealth = entity.getComponent('Stealth'); + + if (!ai || !position || !velocity) return; + + // Update wander timer + ai.wanderChangeTime += deltaTime; + + // Detect player + if (playerPos) { + const dx = playerPos.x - position.x; + const dy = playerPos.y - position.y; + const distance = Math.sqrt(dx * dx + dy * dy); + + // Update awareness based on distance and player stealth + const playerStealth = player?.getComponent('Stealth'); + const playerVisibility = playerStealth ? playerStealth.visibility : 1.0; + + if (distance < ai.alertRadius) { + const detectionChance = (1 - distance / ai.alertRadius) * playerVisibility; + ai.updateAwareness(detectionChance * deltaTime * config.awarenessGainMultiplier); + } else { + ai.updateAwareness(-deltaTime * config.awarenessLossRate); // Lose awareness over time + } + + // Biological Reputation Logic + const playerEvolution = player?.getComponent('Evolution'); + const playerForm = playerEvolution ? playerEvolution.getDominantForm() : 'slime'; + const entityType = entity.getComponent('Sprite')?.color === '#ffaa00' ? 'beast' : + entity.getComponent('Sprite')?.color === '#ff5555' ? 'humanoid' : 'other'; + + // Check if player is "one of us" or "too scary" + let isPassive = false; + let shouldFlee = false; + + if (entityType === 'humanoid' && playerForm === 'human') { + // Humanoids are passive to human-form slime unless awareness is maxed (hostile action taken) + if (ai.awareness < config.passiveAwarenessThreshold) isPassive = true; + } else if (entityType === 'beast' && playerForm === 'beast') { + // Beasts might flee from a dominant beast player + const playerStats = player?.getComponent('Stats'); + const entityStats = entity.getComponent('Stats'); + if (playerStats && entityStats && playerStats.level > entityStats.level) { + shouldFlee = true; + } + } + + // Behavior based on awareness, reputation, and distance + if (shouldFlee && ai.awareness > config.fleeAwarenessThreshold) { + ai.setBehavior('flee'); + ai.state = 'fleeing'; + ai.setTarget(player.id); + } else if (isPassive) { + if (ai.behaviorType === 'chase' || ai.behaviorType === 'combat') { + ai.setBehavior('wander'); + ai.state = 'idle'; + ai.clearTarget(); + } + } else if (ai.awareness > config.detectionAwarenessThreshold && distance < ai.chaseRadius) { + if (ai.behaviorType !== 'flee') { + // Check if in attack range - if so, use combat behavior + const combat = entity.getComponent('Combat'); + if (combat && distance <= combat.attackRange) { + ai.setBehavior('combat'); + ai.state = 'combat'; + } else { + ai.setBehavior('chase'); + ai.state = 'chasing'; + } + ai.setTarget(player.id); + } + } else if (ai.awareness < 0.3) { + if (ai.behaviorType === 'chase' || ai.behaviorType === 'combat') { + ai.setBehavior('wander'); + ai.state = 'idle'; + ai.clearTarget(); + } + } else if (ai.behaviorType === 'chase') { + // Update from chase to combat if in range + const combat = entity.getComponent('Combat'); + if (combat && distance <= combat.attackRange) { + ai.setBehavior('combat'); + ai.state = 'combat'; + } + } + } + + // Execute behavior + switch (ai.behaviorType) { + case 'wander': + this.wander(entity, ai, velocity, deltaTime); + break; + case 'chase': + this.chase(entity, ai, velocity, position, playerPos); + break; + case 'flee': + this.flee(entity, ai, velocity, position, playerPos); + break; + case 'combat': + this.combat(entity, ai, velocity, position, playerPos); + break; + } + }); + } + + wander(entity, ai, velocity, _deltaTime) { + ai.state = 'moving'; + + // Change direction periodically + if (ai.wanderChangeTime >= ai.wanderChangeInterval) { + ai.wanderDirection = Math.random() * Math.PI * 2; + ai.wanderChangeTime = 0; + ai.wanderChangeInterval = 1 + Math.random() * 2; + } + + velocity.vx = Math.cos(ai.wanderDirection) * ai.wanderSpeed; + velocity.vy = Math.sin(ai.wanderDirection) * ai.wanderSpeed; + } + + chase(entity, ai, velocity, position, targetPos) { + if (!targetPos) return; + + const dx = targetPos.x - position.x; + const dy = targetPos.y - position.y; + const distance = Math.sqrt(dx * dx + dy * dy); + + // Check if we should switch to combat + const combat = entity.getComponent('Combat'); + if (combat && distance <= combat.attackRange) { + ai.setBehavior('combat'); + ai.state = 'combat'; + return; + } + + ai.state = 'chasing'; + if (distance > 0.1) { + const speed = ai.wanderSpeed * 1.5; + velocity.vx = (dx / distance) * speed; + velocity.vy = (dy / distance) * speed; + } else { + velocity.vx = 0; + velocity.vy = 0; + } + } + + flee(entity, ai, velocity, position, targetPos) { + if (!targetPos) return; + + ai.state = 'fleeing'; + const dx = position.x - targetPos.x; + const dy = position.y - targetPos.y; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (distance > 0.1) { + const speed = ai.wanderSpeed * 1.2; + velocity.vx = (dx / distance) * speed; + velocity.vy = (dy / distance) * speed; + } + } + + combat(entity, ai, velocity, position, targetPos) { + if (!targetPos) return; + + ai.state = 'attacking'; + // Stop moving when in combat range - let CombatSystem handle attacks + const dx = targetPos.x - position.x; + const dy = targetPos.y - position.y; + const distance = Math.sqrt(dx * dx + dy * dy); + + const combat = entity.getComponent('Combat'); + if (combat && distance > combat.attackRange) { + // Move closer if out of range + const speed = ai.wanderSpeed; + velocity.vx = (dx / distance) * speed; + velocity.vy = (dy / distance) * speed; + } else { + // Stop and face target + velocity.vx *= 0.5; + velocity.vy *= 0.5; + if (position) { + position.rotation = Math.atan2(dy, dx); + } + } + } +} + diff --git a/src/systems/AISystem.ts b/src/systems/AISystem.ts deleted file mode 100644 index 15a3744..0000000 --- a/src/systems/AISystem.ts +++ /dev/null @@ -1,256 +0,0 @@ -import { System } from '../core/System.ts'; -import { GameConfig } from '../GameConfig.ts'; -import { SystemName, ComponentType } from '../core/Constants.ts'; -import type { Entity } from '../core/Entity.ts'; -import type { Health } from '../components/Health.ts'; -import type { AI } from '../components/AI.ts'; -import type { Position } from '../components/Position.ts'; -import type { Velocity } from '../components/Velocity.ts'; -import type { Stealth } from '../components/Stealth.ts'; -import type { Evolution } from '../components/Evolution.ts'; -import type { Sprite } from '../components/Sprite.ts'; -import type { Stats } from '../components/Stats.ts'; -import type { Combat } from '../components/Combat.ts'; -import type { Intent } from '../components/Intent.ts'; -import type { PlayerControllerSystem } from './PlayerControllerSystem.ts'; - -/** - * System responsible for managing AI behaviors (wandering, chasing, fleeing, combat). - */ -export class AISystem extends System { - constructor() { - super(SystemName.AI); - this.requiredComponents = [ComponentType.POSITION, ComponentType.VELOCITY, ComponentType.AI]; - this.priority = 15; - } - - /** - * Process AI logic for all entities with an AI component. - * @param deltaTime - Time elapsed since last frame in seconds - * @param entities - Entities matching system requirements - */ - process(deltaTime: number, entities: Entity[]): void { - const playerController = this.engine.systems.find( - (s) => s.name === SystemName.PLAYER_CONTROLLER - ) as PlayerControllerSystem | undefined; - const player = playerController?.getPlayerEntity(); - if (!player) return; - const playerPos = player?.getComponent(ComponentType.POSITION); - const config = GameConfig.AI; - - entities.forEach((entity) => { - const health = entity.getComponent(ComponentType.HEALTH); - const ai = entity.getComponent(ComponentType.AI); - const position = entity.getComponent(ComponentType.POSITION); - const velocity = entity.getComponent(ComponentType.VELOCITY); - - if (!ai || !position || !velocity) return; - - if (health && health.isDead() && !health.isProjectile) { - velocity.vx = 0; - velocity.vy = 0; - return; - } - - ai.wanderChangeTime += deltaTime; - - if (playerPos) { - const dx = playerPos.x - position.x; - const dy = playerPos.y - position.y; - const distance = Math.sqrt(dx * dx + dy * dy); - - const playerStealth = player?.getComponent(ComponentType.STEALTH); - const playerVisibility = playerStealth ? playerStealth.visibility : 1.0; - - if (distance < ai.alertRadius) { - const detectionChance = (1 - distance / ai.alertRadius) * playerVisibility; - ai.updateAwareness(detectionChance * deltaTime * config.awarenessGainMultiplier); - } else { - ai.updateAwareness(-deltaTime * config.awarenessLossRate); - } - - const playerEvolution = player?.getComponent(ComponentType.EVOLUTION); - const playerForm = playerEvolution ? playerEvolution.getDominantForm() : 'slime'; - const sprite = entity.getComponent(ComponentType.SPRITE); - const entityType = - sprite?.color === '#ffaa00' - ? 'beast' - : sprite?.color === '#ff5555' - ? 'humanoid' - : 'other'; - - let isPassive = false; - let shouldFlee = false; - - if (entityType === 'humanoid' && playerForm === 'human') { - if (ai.awareness < config.passiveAwarenessThreshold) isPassive = true; - } else if (entityType === 'beast' && playerForm === 'beast') { - const playerStats = player?.getComponent(ComponentType.STATS); - const entityStats = entity.getComponent(ComponentType.STATS); - if (playerStats && entityStats && playerStats.level > entityStats.level) { - shouldFlee = true; - } - } - - if (shouldFlee && ai.awareness > config.fleeAwarenessThreshold) { - ai.setBehavior('flee'); - ai.state = 'fleeing'; - ai.setTarget(player.id); - } else if (isPassive) { - if (ai.behaviorType === 'chase' || ai.behaviorType === 'combat') { - ai.setBehavior('wander'); - ai.state = 'idle'; - ai.clearTarget(); - } - } else if (ai.awareness > config.detectionAwarenessThreshold && distance < ai.chaseRadius) { - if (ai.behaviorType !== 'flee') { - const combat = entity.getComponent(ComponentType.COMBAT); - if (combat && distance <= combat.attackRange) { - ai.setBehavior('combat'); - ai.state = 'combat'; - } else { - ai.setBehavior('chase'); - ai.state = 'chasing'; - } - ai.setTarget(player.id); - } - } else if (ai.awareness < 0.3) { - if (ai.behaviorType === 'chase' || ai.behaviorType === 'combat') { - ai.setBehavior('wander'); - ai.state = 'idle'; - ai.clearTarget(); - } - } else if (ai.behaviorType === 'chase') { - const combat = entity.getComponent(ComponentType.COMBAT); - if (combat && distance <= combat.attackRange) { - ai.setBehavior('combat'); - ai.state = 'combat'; - } - } - } - - switch (ai.behaviorType) { - case 'wander': - this.wander(entity, ai, velocity, deltaTime); - break; - case 'chase': - this.chase(entity, ai, velocity, position, playerPos); - break; - case 'flee': - this.flee(entity, ai, velocity, position, playerPos); - break; - case 'combat': - this.combat(entity, ai, velocity, position, playerPos); - break; - } - }); - } - - /** - * Execute wandering behavior, moving in a random direction. - */ - wander(_entity: Entity, ai: AI, velocity: Velocity, _deltaTime: number): void { - ai.state = 'moving'; - - if (ai.wanderChangeTime >= ai.wanderChangeInterval) { - ai.wanderDirection = Math.random() * Math.PI * 2; - ai.wanderChangeTime = 0; - ai.wanderChangeInterval = 1 + Math.random() * 2; - } - - velocity.vx = Math.cos(ai.wanderDirection) * ai.wanderSpeed; - velocity.vy = Math.sin(ai.wanderDirection) * ai.wanderSpeed; - } - - /** - * Execute chasing behavior, moving toward a target. - */ - chase( - entity: Entity, - ai: AI, - velocity: Velocity, - position: Position, - targetPos: Position | undefined - ): void { - if (!targetPos) return; - - const dx = targetPos.x - position.x; - const dy = targetPos.y - position.y; - const distance = Math.sqrt(dx * dx + dy * dy); - - const combat = entity.getComponent(ComponentType.COMBAT); - if (combat && distance <= combat.attackRange) { - ai.setBehavior('combat'); - ai.state = 'combat'; - return; - } - - ai.state = 'chasing'; - if (distance > 0.1) { - const speed = ai.wanderSpeed * 1.5; - velocity.vx = (dx / distance) * speed; - velocity.vy = (dy / distance) * speed; - } else { - velocity.vx = 0; - velocity.vy = 0; - } - } - - /** - * Execute fleeing behavior, moving away from a target. - */ - flee( - _entity: Entity, - ai: AI, - velocity: Velocity, - position: Position, - targetPos: Position | undefined - ): void { - if (!targetPos) return; - - ai.state = 'fleeing'; - const dx = position.x - targetPos.x; - const dy = position.y - targetPos.y; - const distance = Math.sqrt(dx * dx + dy * dy); - - if (distance > 0.1) { - const speed = ai.wanderSpeed * 1.2; - velocity.vx = (dx / distance) * speed; - velocity.vy = (dy / distance) * speed; - } - } - - /** - * Execute combat behavior, moving into range and setting attack intent. - */ - combat( - entity: Entity, - ai: AI, - velocity: Velocity, - position: Position, - targetPos: Position | undefined - ): void { - if (!targetPos) return; - - ai.state = 'attacking'; - const dx = targetPos.x - position.x; - const dy = targetPos.y - position.y; - const distance = Math.sqrt(dx * dx + dy * dy); - - const combat = entity.getComponent(ComponentType.COMBAT); - if (combat && distance > combat.attackRange) { - const speed = ai.wanderSpeed; - velocity.vx = (dx / distance) * speed; - velocity.vy = (dy / distance) * speed; - } else { - velocity.vx *= 0.5; - velocity.vy *= 0.5; - position.rotation = Math.atan2(dy, dx); - - const intent = entity.getComponent(ComponentType.INTENT); - if (intent) { - intent.setIntent('attack', { targetX: targetPos.x, targetY: targetPos.y }); - } - } - } -} diff --git a/src/systems/AbsorptionSystem.js b/src/systems/AbsorptionSystem.js new file mode 100644 index 0000000..0bbcd97 --- /dev/null +++ b/src/systems/AbsorptionSystem.js @@ -0,0 +1,176 @@ +import { System } from '../core/System.js'; +import { GameConfig } from '../GameConfig.js'; +import { Events } from '../core/EventBus.js'; + +export class AbsorptionSystem extends System { + constructor() { + super('AbsorptionSystem'); + this.requiredComponents = ['Position', 'Absorbable']; + this.priority = 25; + this.absorptionEffects = []; // Visual effects + } + + process(deltaTime, _entities) { + const playerController = this.engine.systems.find(s => s.name === 'PlayerControllerSystem'); + const player = playerController ? playerController.getPlayerEntity() : null; + + if (!player) return; + + const playerPos = player.getComponent('Position'); + const playerEvolution = player.getComponent('Evolution'); + const playerSkills = player.getComponent('Skills'); + const playerStats = player.getComponent('Stats'); + const skillProgress = player.getComponent('SkillProgress'); + + if (!playerPos || !playerEvolution) return; + + // Get ALL entities (including inactive ones) for absorption check + const allEntities = this.engine.entities; // Get raw entities array, not filtered + + const config = GameConfig.Absorption; + + // Check for absorbable entities near player + allEntities.forEach(entity => { + if (entity === player) return; + // Allow inactive entities if they're dead and absorbable + if (!entity.active) { + const health = entity.getComponent('Health'); + const absorbable = entity.getComponent('Absorbable'); + // Only process inactive entities if they're dead and not yet absorbed + if (!health || !health.isDead() || !absorbable || absorbable.absorbed) { + return; + } + } + if (!entity.hasComponent('Absorbable')) return; + if (!entity.hasComponent('Health')) return; + + const absorbable = entity.getComponent('Absorbable'); + const health = entity.getComponent('Health'); + const entityPos = entity.getComponent('Position'); + + if (!entityPos) return; + + // Check if creature is dead and in absorption range + if (health.isDead() && !absorbable.absorbed) { + const dx = playerPos.x - entityPos.x; + const dy = playerPos.y - entityPos.y; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (distance <= config.range) { + this.absorbEntity(player, entity, absorbable, playerEvolution, playerSkills, playerStats, skillProgress); + } + } + }); + + // Update visual effects + this.updateEffects(deltaTime); + } + + absorbEntity(player, entity, absorbable, evolution, skills, stats, skillProgress) { + if (absorbable.absorbed) return; + + absorbable.absorbed = true; + const entityPos = entity.getComponent('Position'); + const health = player.getComponent('Health'); + const config = GameConfig.Absorption; + + // Add evolution points + evolution.addEvolution( + absorbable.evolutionData.human, + absorbable.evolutionData.beast, + absorbable.evolutionData.slime + ); + + // Track skill progress (need to absorb multiple times to learn) + // Always track progress for ALL skills the enemy has, regardless of roll + if (skillProgress && absorbable.skillsGranted && absorbable.skillsGranted.length > 0) { + absorbable.skillsGranted.forEach(skill => { + // Always add progress when absorbing an enemy with this skill + const currentProgress = skillProgress.addSkillProgress(skill.id); + const required = skillProgress.requiredAbsorptions; + + // If we've absorbed enough, learn the skill + if (currentProgress >= required && !skills.hasSkill(skill.id)) { + skills.addSkill(skill.id, false); + this.engine.emit(Events.SKILL_LEARNED, { id: skill.id }); + console.log(`Learned skill: ${skill.id}!`); + } + }); + } + + // Heal from absorption (slime recovers by consuming) + if (health) { + const healPercent = config.healPercentMin + Math.random() * (config.healPercentMax - config.healPercentMin); + const healAmount = health.maxHp * healPercent; + health.heal(healAmount); + } + + // Check for mutation + if (absorbable.shouldMutate() && stats) { + this.applyMutation(stats); + evolution.checkMutations(stats, this.engine); + } + + // Visual effect + if (entityPos) { + this.addAbsorptionEffect(entityPos.x, entityPos.y); + } + + // Mark as absorbed - DeathSystem will handle removal after absorption window + // Don't remove immediately, let DeathSystem handle it + } + + applyMutation(stats) { + // Random stat mutation + const mutations = [ + { stat: 'strength', amount: 5 }, + { stat: 'agility', amount: 5 }, + { stat: 'intelligence', amount: 5 }, + { stat: 'constitution', amount: 5 }, + { stat: 'perception', amount: 5 }, + ]; + + const mutation = mutations[Math.floor(Math.random() * mutations.length)]; + stats[mutation.stat] += mutation.amount; + + // Could also add negative mutations + if (Math.random() < 0.3) { + const negativeStat = mutations[Math.floor(Math.random() * mutations.length)]; + stats[negativeStat.stat] = Math.max(1, stats[negativeStat.stat] - 2); + } + } + + addAbsorptionEffect(x, y) { + for (let i = 0; i < 20; i++) { + this.absorptionEffects.push({ + x, + y, + vx: (Math.random() - 0.5) * 200, + vy: (Math.random() - 0.5) * 200, + lifetime: 0.5 + Math.random() * 0.5, + size: 3 + Math.random() * 5, + color: `hsl(${120 + Math.random() * 60}, 100%, 50%)` + }); + } + } + + updateEffects(deltaTime) { + for (let i = this.absorptionEffects.length - 1; i >= 0; i--) { + const effect = this.absorptionEffects[i]; + effect.x += effect.vx * deltaTime; + effect.y += effect.vy * deltaTime; + effect.lifetime -= deltaTime; + effect.vx *= 0.95; + effect.vy *= 0.95; + + if (effect.lifetime <= 0) { + this.absorptionEffects.splice(i, 1); + } + } + } + + getEffects() { + return this.absorptionEffects; + } +} + diff --git a/src/systems/AbsorptionSystem.ts b/src/systems/AbsorptionSystem.ts deleted file mode 100644 index e7af650..0000000 --- a/src/systems/AbsorptionSystem.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { System } from '../core/System.ts'; -import { GameConfig } from '../GameConfig.ts'; -import { Events } from '../core/EventBus.ts'; -import { SystemName, ComponentType } from '../core/Constants.ts'; -import type { Entity } from '../core/Entity.ts'; -import type { Position } from '../components/Position.ts'; -import type { Evolution } from '../components/Evolution.ts'; -import type { Skills } from '../components/Skills.ts'; -import type { Stats } from '../components/Stats.ts'; -import type { SkillProgress } from '../components/SkillProgress.ts'; -import type { Absorbable } from '../components/Absorbable.ts'; -import type { Health } from '../components/Health.ts'; -import type { PlayerControllerSystem } from './PlayerControllerSystem.ts'; -import type { VFXSystem } from './VFXSystem.ts'; - -/** - * System responsible for identifying dead absorbable entities near the player and processing absorption. - */ -export class AbsorptionSystem extends System { - constructor() { - super(SystemName.ABSORPTION); - this.requiredComponents = [ComponentType.POSITION, ComponentType.ABSORBABLE]; - this.priority = 25; - } - - /** - * Check for absorbable entities within range of the player and initiate absorption if applicable. - * @param _deltaTime - Time elapsed since last frame - * @param _entities - Matching entities (not used, uses raw engine entities) - */ - process(_deltaTime: number, _entities: Entity[]): void { - const playerController = this.engine.systems.find( - (s) => s.name === SystemName.PLAYER_CONTROLLER - ) as PlayerControllerSystem | undefined; - const player = playerController ? playerController.getPlayerEntity() : null; - - if (!player) return; - - const playerPos = player.getComponent(ComponentType.POSITION); - const playerEvolution = player.getComponent(ComponentType.EVOLUTION); - const playerSkills = player.getComponent(ComponentType.SKILLS); - const playerStats = player.getComponent(ComponentType.STATS); - const skillProgress = player.getComponent(ComponentType.SKILL_PROGRESS); - - if (!playerPos || !playerEvolution) return; - - const allEntities = this.engine.entities; - const config = GameConfig.Absorption; - - allEntities.forEach((entity) => { - if (entity === player) return; - if (!entity.active) { - const health = entity.getComponent(ComponentType.HEALTH); - const absorbable = entity.getComponent(ComponentType.ABSORBABLE); - if (!health || !health.isDead() || !absorbable || absorbable.absorbed) { - return; - } - } - if (!entity.hasComponent(ComponentType.ABSORBABLE)) return; - if (!entity.hasComponent(ComponentType.HEALTH)) return; - - const absorbable = entity.getComponent(ComponentType.ABSORBABLE); - const health = entity.getComponent(ComponentType.HEALTH); - const entityPos = entity.getComponent(ComponentType.POSITION); - - if (!entityPos) return; - - if (health && health.isDead() && absorbable && !absorbable.absorbed) { - const dx = playerPos.x - entityPos.x; - const dy = playerPos.y - entityPos.y; - const distance = Math.sqrt(dx * dx + dy * dy); - - if (distance <= config.range) { - this.absorbEntity( - player, - entity, - absorbable, - playerEvolution, - playerSkills, - playerStats, - skillProgress - ); - } - } - }); - } - - /** - * Process the absorption of an entity by the player. - */ - absorbEntity( - player: Entity, - entity: Entity, - absorbable: Absorbable, - evolution: Evolution, - skills: Skills | undefined, - stats: Stats | undefined, - skillProgress: SkillProgress | undefined - ): void { - if (absorbable.absorbed) return; - - absorbable.absorbed = true; - const entityPos = entity.getComponent(ComponentType.POSITION); - const health = player.getComponent(ComponentType.HEALTH); - const config = GameConfig.Absorption; - - evolution.addEvolution( - absorbable.evolutionData.human, - absorbable.evolutionData.beast, - absorbable.evolutionData.slime - ); - - if (skillProgress && absorbable.skillsGranted && absorbable.skillsGranted.length > 0) { - absorbable.skillsGranted.forEach((skill) => { - const currentProgress = skillProgress.addSkillProgress(skill.id); - const required = skillProgress.requiredAbsorptions; - - if (currentProgress >= required && skills && !skills.hasSkill(skill.id)) { - skills.addSkill(skill.id, false); - this.engine.emit(Events.SKILL_LEARNED, { id: skill.id }); - console.log(`Learned skill: ${skill.id}!`); - } - }); - } - - if (health) { - const healPercent = - config.healPercentMin + Math.random() * (config.healPercentMax - config.healPercentMin); - const healAmount = health.maxHp * healPercent; - health.heal(healAmount); - } - - if (absorbable.shouldMutate() && stats) { - this.applyMutation(stats); - evolution.checkMutations(stats, this.engine); - } - - if (entityPos) { - const vfxSystem = this.engine.systems.find((s) => s.name === SystemName.VFX) as - | VFXSystem - | undefined; - if (vfxSystem) { - vfxSystem.createAbsorption(entityPos.x, entityPos.y); - } - } - - this.engine.emit(Events.ABSORPTION, { entity }); - } - - /** - * Apply a random stat mutation (positive or negative) to an entity's stats. - * @param stats - The stats component to mutate - */ - applyMutation(stats: Stats): void { - type StatName = 'strength' | 'agility' | 'intelligence' | 'constitution' | 'perception'; - const mutations: Array<{ stat: StatName; amount: number }> = [ - { stat: 'strength', amount: 5 }, - { stat: 'agility', amount: 5 }, - { stat: 'intelligence', amount: 5 }, - { stat: 'constitution', amount: 5 }, - { stat: 'perception', amount: 5 }, - ]; - - const mutation = mutations[Math.floor(Math.random() * mutations.length)]; - stats[mutation.stat] += mutation.amount; - - if (Math.random() < 0.3) { - const negativeStat = mutations[Math.floor(Math.random() * mutations.length)]; - stats[negativeStat.stat] = Math.max(1, stats[negativeStat.stat] - 2); - } - } -} diff --git a/src/systems/CameraSystem.ts b/src/systems/CameraSystem.ts deleted file mode 100644 index 316a3e9..0000000 --- a/src/systems/CameraSystem.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { System } from '../core/System.ts'; -import { SystemName, ComponentType } from '../core/Constants.ts'; -import type { Entity } from '../core/Entity.ts'; -import type { Camera } from '../components/Camera.ts'; -import type { Position } from '../components/Position.ts'; -import type { PlayerControllerSystem } from './PlayerControllerSystem.ts'; - -/** - * System responsible for camera movement and following the player. - */ -export class CameraSystem extends System { - constructor() { - super(SystemName.CAMERA); - this.requiredComponents = [ComponentType.CAMERA]; - this.priority = 0; - } - - /** - * Update camera position to smoothly follow the player. - * @param deltaTime - Time elapsed since last frame in seconds - * @param entities - Filtered entities with Camera component - */ - process(deltaTime: number, entities: Entity[]): void { - const playerController = this.engine.systems.find( - (s) => s.name === SystemName.PLAYER_CONTROLLER - ) as PlayerControllerSystem | undefined; - const player = playerController ? playerController.getPlayerEntity() : null; - - if (!player) return; - - const playerPos = player.getComponent(ComponentType.POSITION); - if (!playerPos) return; - - entities.forEach((entity) => { - const camera = entity.getComponent(ComponentType.CAMERA); - if (!camera) return; - - camera.targetX = playerPos.x; - camera.targetY = playerPos.y; - - const dx = camera.targetX - camera.x; - const dy = camera.targetY - camera.y; - - camera.x += dx * camera.smoothness; - camera.y += dy * camera.smoothness; - - camera.clampToBounds(); - }); - } -} diff --git a/src/systems/CombatSystem.js b/src/systems/CombatSystem.js new file mode 100644 index 0000000..2334a89 --- /dev/null +++ b/src/systems/CombatSystem.js @@ -0,0 +1,190 @@ +import { System } from '../core/System.js'; +import { GameConfig } from '../GameConfig.js'; +import { Events } from '../core/EventBus.js'; + +export class CombatSystem extends System { + constructor() { + super('CombatSystem'); + this.requiredComponents = ['Position', 'Combat', 'Health']; + this.priority = 20; + } + + process(deltaTime, entities) { + // Update combat cooldowns + entities.forEach(entity => { + const combat = entity.getComponent('Combat'); + if (combat) { + combat.update(deltaTime); + } + }); + + // Handle player attacks + const playerController = this.engine.systems.find(s => s.name === 'PlayerControllerSystem'); + const player = playerController ? playerController.getPlayerEntity() : null; + + if (player && player.hasComponent('Combat')) { + this.handlePlayerCombat(player, deltaTime); + } + + // Handle creature attacks + const creatures = entities.filter(e => + e.hasComponent('AI') && + e.hasComponent('Combat') && + e !== player + ); + + creatures.forEach(creature => { + this.handleCreatureCombat(creature, player, deltaTime); + }); + + // Check for collisions and apply damage + this.processCombatCollisions(entities, deltaTime); + } + + handlePlayerCombat(player, _deltaTime) { + const inputSystem = this.engine.systems.find(s => s.name === 'InputSystem'); + const combat = player.getComponent('Combat'); + const position = player.getComponent('Position'); + + if (!inputSystem || !combat || !position) return; + + const currentTime = Date.now() / 1000; + + // Attack on mouse click or space (use justPressed to prevent spam) + const mouseClick = inputSystem.isMouseButtonJustPressed(0); + const spacePress = inputSystem.isKeyJustPressed(' ') || inputSystem.isKeyJustPressed('space'); + + if ((mouseClick || spacePress) && combat.canAttack(currentTime)) { + // Calculate attack direction from player to mouse + const mouse = inputSystem.getMousePosition(); + const dx = mouse.x - position.x; + const dy = mouse.y - position.y; + const attackAngle = Math.atan2(dy, dx); + + // Update player rotation to face attack direction + position.rotation = attackAngle; + + combat.attack(currentTime, attackAngle); + + // Check for nearby enemies to damage + this.performAttack(player, combat, position); + } + } + + handleCreatureCombat(creature, player, _deltaTime) { + const ai = creature.getComponent('AI'); + const combat = creature.getComponent('Combat'); + const position = creature.getComponent('Position'); + const playerPos = player?.getComponent('Position'); + + if (!ai || !combat || !position) return; + + // Attack player if in range and aware (check both combat state and chase behavior) + if (playerPos && ai.awareness > 0.5 && (ai.state === 'combat' || ai.behaviorType === 'combat' || (ai.behaviorType === 'chase' && ai.awareness > 0.7))) { + const dx = playerPos.x - position.x; + const dy = playerPos.y - position.y; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (distance <= combat.attackRange) { + const currentTime = Date.now() / 1000; + if (combat.canAttack(currentTime)) { + const angle = Math.atan2(dy, dx); + combat.attack(currentTime, angle); + this.performAttack(creature, combat, position); + } + } + } + } + + performAttack(attacker, combat, attackerPos) { + const entities = this.engine.getEntities(); + const stats = attacker.getComponent('Stats'); + const _baseDamage = stats ? + (combat.attackDamage + stats.strength * 0.5) : + combat.attackDamage; + + entities.forEach(target => { + if (target === attacker) return; + if (!target.hasComponent('Health')) return; + + const targetPos = target.getComponent('Position'); + if (!targetPos) return; + + // Check if in attack range and angle + const dx = targetPos.x - attackerPos.x; + const dy = targetPos.y - attackerPos.y; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (distance <= combat.attackRange) { + const angle = Math.atan2(dy, dx); + const angleDiff = Math.abs(angle - combat.attackDirection); + const normalizedDiff = Math.abs(((angleDiff % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2)); + const minDiff = Math.min(normalizedDiff, Math.PI * 2 - normalizedDiff); + + // Attack arc + const attackArc = GameConfig.Combat.defaultAttackArc; + if (minDiff < attackArc) { + const health = target.getComponent('Health'); + const config = GameConfig.Combat; + const stats = attacker.getComponent('Stats'); + const baseDamage = stats ? (combat.attackDamage + stats.strength * 0.5) : combat.attackDamage; + + // Defense bonus from Hardened Shell + let finalDamage = baseDamage; + const targetEvolution = target.getComponent('Evolution'); + + if (targetEvolution && targetEvolution.mutationEffects.hardenedShell) { + finalDamage *= config.hardenedShellReduction; + } + + const actualDamage = health.takeDamage(finalDamage); + + // Emit event for UI/VFX + this.engine.emit(Events.DAMAGE_DEALT, { + x: targetPos.x, + y: targetPos.y, + value: actualDamage, + color: '#ffffff' + }); + + // Damage reflection from Electric Skin + if (targetEvolution && targetEvolution.mutationEffects.electricSkin) { + const attackerHealth = attacker.getComponent('Health'); + if (attackerHealth) { + const reflectedDamage = actualDamage * config.damageReflectionPercent; + attackerHealth.takeDamage(reflectedDamage); + + this.engine.emit(Events.DAMAGE_DEALT, { + x: attackerPos.x, + y: attackerPos.y, + value: reflectedDamage, + color: '#00ffff' + }); + } + } + + // If target is dead, emit event + if (health.isDead()) { + this.engine.emit(Events.ENTITY_DIED, { entity: target }); + target.active = false; + } + + // Apply knockback + const velocity = target.getComponent('Velocity'); + if (velocity) { + const knockbackPower = config.knockbackPower; + const kx = Math.cos(angle) * knockbackPower; + const ky = Math.sin(angle) * knockbackPower; + velocity.vx += kx; + velocity.vy += ky; + } + } + } + }); + } + + processCombatCollisions(_entities, _deltaTime) { + // This can be expanded for projectile collisions, area effects, etc. + } +} + diff --git a/src/systems/CombatSystem.ts b/src/systems/CombatSystem.ts deleted file mode 100644 index fb037bc..0000000 --- a/src/systems/CombatSystem.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { System } from '../core/System.ts'; -import { GameConfig } from '../GameConfig.ts'; -import { Events } from '../core/EventBus.ts'; -import { SystemName, ComponentType } from '../core/Constants.ts'; -import type { Entity } from '../core/Entity.ts'; -import type { Combat } from '../components/Combat.ts'; -import type { Intent } from '../components/Intent.ts'; -import type { Position } from '../components/Position.ts'; -import type { Health } from '../components/Health.ts'; -import type { Sprite } from '../components/Sprite.ts'; -import type { Stats } from '../components/Stats.ts'; -import type { Evolution } from '../components/Evolution.ts'; -import type { Velocity } from '../components/Velocity.ts'; - -/** - * System responsible for managing combat interactions, attack intent processing, and damage application. - */ -export class CombatSystem extends System { - constructor() { - super(SystemName.COMBAT); - this.requiredComponents = [ComponentType.POSITION, ComponentType.COMBAT, ComponentType.HEALTH]; - this.priority = 20; - } - - /** - * Process combat updates, timers, and entity attack intents. - * @param deltaTime - Time elapsed since last frame in seconds - * @param entities - Entities matching system requirements - */ - process(deltaTime: number, entities: Entity[]): void { - const currentTime = Date.now() / 1000; - - entities.forEach((entity) => { - const combat = entity.getComponent(ComponentType.COMBAT); - const intent = entity.getComponent(ComponentType.INTENT); - const position = entity.getComponent(ComponentType.POSITION); - - if (combat) { - combat.update(deltaTime); - } - - if (intent && intent.action === 'attack' && combat && position) { - if (combat.canAttack(currentTime)) { - const { targetX, targetY } = intent.data; - const dx = targetX - position.x; - const dy = targetY - position.y; - const angle = Math.atan2(dy, dx); - - this.performAttack(entity, angle); - } - intent.clear(); - } - }); - - this.processCombatCollisions(entities, deltaTime); - } - - /** - * Initiate an attack from an entity at a specific angle. - * @param attacker - The entity performing the attack - * @param angle - The attack angle in radians - */ - performAttack(attacker: Entity, angle: number): void { - const attackerPos = attacker.getComponent(ComponentType.POSITION); - const combat = attacker.getComponent(ComponentType.COMBAT); - if (!attackerPos || !combat) return; - - const currentTime = Date.now() / 1000; - combat.attack(currentTime, angle); - - this.engine.emit(Events.ATTACK_PERFORMED, { entity: attacker, angle }); - - const entities = this.engine.getEntities(); - entities.forEach((target) => { - if (target === attacker) return; - if (!target.hasComponent(ComponentType.HEALTH)) return; - - const targetPos = target.getComponent(ComponentType.POSITION); - if (!targetPos) return; - - const dx = targetPos.x - attackerPos.x; - const dy = targetPos.y - attackerPos.y; - const distance = Math.sqrt(dx * dx + dy * dy); - - const targetSprite = target.getComponent(ComponentType.SPRITE); - const targetRadius = targetSprite ? Math.max(targetSprite.width, targetSprite.height) / 2 : 0; - - if (distance <= combat.attackRange + targetRadius) { - const targetAngle = Math.atan2(dy, dx); - const angleDiff = Math.abs(targetAngle - angle); - const normalizedDiff = Math.abs( - ((angleDiff % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2) - ); - const minDiff = Math.min(normalizedDiff, Math.PI * 2 - normalizedDiff); - - const attackArc = GameConfig.Combat.defaultAttackArc || Math.PI / 3; - - if (minDiff < attackArc) { - this.applyDamage(attacker, target); - - const vfxSystem = this.engine.systems.find((s) => s.name === SystemName.VFX) as - | VFXSystem - | undefined; - if (vfxSystem) { - vfxSystem.createImpact(targetPos.x, targetPos.y, '#ffffff', targetAngle); - } - } - } - }); - } - - /** - * Apply damage and effects from one entity to another. - * @param attacker - The attacking entity - * @param target - The target entity being damaged - */ - applyDamage(attacker: Entity, target: Entity): void { - const health = target.getComponent(ComponentType.HEALTH); - const combat = attacker.getComponent(ComponentType.COMBAT); - const stats = attacker.getComponent(ComponentType.STATS); - const targetPos = target.getComponent(ComponentType.POSITION); - const attackerPos = attacker.getComponent(ComponentType.POSITION); - - if (!health || !combat || !targetPos || !attackerPos) return; - - const config = GameConfig.Combat; - const baseDamage = stats ? combat.attackDamage + stats.strength * 0.5 : combat.attackDamage; - - let finalDamage = baseDamage; - const targetEvolution = target.getComponent(ComponentType.EVOLUTION); - - if (targetEvolution && targetEvolution.mutationEffects.hardenedShell) { - finalDamage *= config.hardenedShellReduction; - } - - const actualDamage = health.takeDamage(finalDamage); - - this.engine.emit(Events.DAMAGE_DEALT, { - x: targetPos.x, - y: targetPos.y, - value: actualDamage, - color: '#ffffff', - }); - - if (targetEvolution && targetEvolution.mutationEffects.electricSkin) { - const attackerHealth = attacker.getComponent(ComponentType.HEALTH); - if (attackerHealth) { - const reflectedDamage = actualDamage * config.damageReflectionPercent; - attackerHealth.takeDamage(reflectedDamage); - - this.engine.emit(Events.DAMAGE_DEALT, { - x: attackerPos.x, - y: attackerPos.y, - value: reflectedDamage, - color: '#00ffff', - }); - } - } - - const velocity = target.getComponent(ComponentType.VELOCITY); - if (velocity) { - const dx = targetPos.x - attackerPos.x; - const dy = targetPos.y - attackerPos.y; - const dist = Math.sqrt(dx * dx + dy * dy); - const kx = (dx / dist) * config.knockbackPower; - const ky = (dy / dist) * config.knockbackPower; - velocity.vx += kx; - velocity.vy += ky; - } - - if (health.isDead()) { - this.engine.emit(Events.ENTITY_DIED, { entity: target }); - } - } - - /** - * Process collision-based combat events. Placeholder for future expansion. - * @param _entities - The entities to check - * @param _deltaTime - Time elapsed since last frame - */ - processCombatCollisions(_entities: Entity[], _deltaTime: number): void {} -} diff --git a/src/systems/DeathSystem.js b/src/systems/DeathSystem.js new file mode 100644 index 0000000..b5908b7 --- /dev/null +++ b/src/systems/DeathSystem.js @@ -0,0 +1,56 @@ +import { System } from '../core/System.js'; + +/** + * System to handle entity death - removes dead entities immediately + */ +export class DeathSystem extends System { + constructor() { + super('DeathSystem'); + this.requiredComponents = ['Health']; + this.priority = 50; // Run after absorption (absorption is priority 25) + } + + update(deltaTime, _entities) { + // Override to check ALL entities, not just active ones + // Get all entities including inactive ones to check dead entities + const allEntities = this.engine.entities; + this.process(deltaTime, allEntities); + } + + process(deltaTime, allEntities) { + allEntities.forEach(entity => { + const health = entity.getComponent('Health'); + if (!health) return; + + // Check if entity is dead + if (health.isDead()) { + // Don't remove player + const evolution = entity.getComponent('Evolution'); + if (evolution) return; // Player has Evolution component + + // Mark as inactive immediately so it stops being processed by other systems + if (entity.active) { + entity.active = false; + entity.deathTime = Date.now(); // Set death time when first marked dead + } + + // Check if it's absorbable - if so, give a short window for absorption + const absorbable = entity.getComponent('Absorbable'); + if (absorbable && !absorbable.absorbed) { + // Give 3 seconds for player to absorb, then remove + const timeSinceDeath = (Date.now() - entity.deathTime) / 1000; + if (timeSinceDeath > 3.0) { + this.engine.removeEntity(entity); + } + } else { + // Not absorbable or already absorbed - remove after short delay + const timeSinceDeath = (Date.now() - entity.deathTime) / 1000; + if (timeSinceDeath > 0.5) { + this.engine.removeEntity(entity); + } + } + } + }); + } +} + diff --git a/src/systems/DeathSystem.ts b/src/systems/DeathSystem.ts deleted file mode 100644 index d7b512f..0000000 --- a/src/systems/DeathSystem.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { System } from '../core/System.ts'; -import { SystemName, ComponentType } from '../core/Constants.ts'; -import type { Entity } from '../core/Entity.ts'; -import type { Health } from '../components/Health.ts'; -import type { Evolution } from '../components/Evolution.ts'; -import type { Absorbable } from '../components/Absorbable.ts'; -import type { MenuSystem } from './MenuSystem.ts'; - -/** - * System responsible for managing entity death, game over states, and cleanup of dead entities. - */ -export class DeathSystem extends System { - constructor() { - super(SystemName.DEATH); - this.requiredComponents = [ComponentType.HEALTH]; - this.priority = 50; - } - - /** - * Override update to process all entities (including inactive/dead ones). - * @param deltaTime - Time elapsed since last frame - * @param _entities - Filtered active entities (not used, uses raw engine entities) - */ - update(deltaTime: number, _entities: Entity[]): void { - const allEntities = this.engine.entities; - this.process(deltaTime, allEntities); - } - - /** - * Process death logic for all entities. - * @param _deltaTime - Time elapsed since last frame - * @param allEntities - All entities in the engine - */ - process(_deltaTime: number, allEntities: Entity[]): void { - allEntities.forEach((entity) => { - const health = entity.getComponent(ComponentType.HEALTH); - if (!health) return; - - if (health.isDead()) { - const evolution = entity.getComponent(ComponentType.EVOLUTION); - if (evolution) { - const menuSystem = this.engine.systems.find((s) => s.name === SystemName.MENU) as - | MenuSystem - | undefined; - if (menuSystem) { - menuSystem.showGameOver(); - } - return; - } - - if (entity.active || !entity.deathTime) { - if (entity.active) { - entity.active = false; - } - if (!entity.deathTime) { - entity.deathTime = Date.now(); - } - } - - const absorbable = entity.getComponent(ComponentType.ABSORBABLE); - if (absorbable && !absorbable.absorbed) { - const timeSinceDeath = (Date.now() - (entity.deathTime || 0)) / 1000; - if (timeSinceDeath > 3.0) { - this.engine.removeEntity(entity); - } - } else { - const timeSinceDeath = (Date.now() - (entity.deathTime || 0)) / 1000; - if (timeSinceDeath > 0.5) { - this.engine.removeEntity(entity); - } - } - } - }); - } -} diff --git a/src/systems/HealthRegenerationSystem.js b/src/systems/HealthRegenerationSystem.js new file mode 100644 index 0000000..47468be --- /dev/null +++ b/src/systems/HealthRegenerationSystem.js @@ -0,0 +1,27 @@ +import { System } from '../core/System.js'; + +/** + * System to handle health regeneration + */ +export class HealthRegenerationSystem extends System { + constructor() { + super('HealthRegenerationSystem'); + this.requiredComponents = ['Health']; + this.priority = 35; + } + + process(deltaTime, entities) { + entities.forEach(entity => { + const health = entity.getComponent('Health'); + if (!health || health.regeneration <= 0) return; + + // Regenerate health over time + // Only regenerate if not recently damaged (5 seconds) + const timeSinceDamage = (Date.now() - health.lastDamageTime) / 1000; + if (timeSinceDamage > 5) { + health.heal(health.regeneration * deltaTime); + } + }); + } +} + diff --git a/src/systems/HealthRegenerationSystem.ts b/src/systems/HealthRegenerationSystem.ts deleted file mode 100644 index f555a83..0000000 --- a/src/systems/HealthRegenerationSystem.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { System } from '../core/System.ts'; -import { SystemName, ComponentType } from '../core/Constants.ts'; -import type { Entity } from '../core/Entity.ts'; -import type { Health } from '../components/Health.ts'; - -/** - * System responsible for managing health regeneration for entities over time. - */ -export class HealthRegenerationSystem extends System { - constructor() { - super(SystemName.HEALTH_REGEN); - this.requiredComponents = [ComponentType.HEALTH]; - this.priority = 35; - } - - /** - * Process health regeneration for entities that haven't been damaged recently. - * @param deltaTime - Time elapsed since last frame in seconds - * @param entities - Entities matching system requirements - */ - process(deltaTime: number, entities: Entity[]): void { - entities.forEach((entity) => { - const health = entity.getComponent(ComponentType.HEALTH); - if (!health || health.regeneration <= 0) return; - - const timeSinceDamage = (Date.now() - health.lastDamageTime) / 1000; - if (timeSinceDamage > 5) { - health.heal(health.regeneration * deltaTime); - } - }); - } -} diff --git a/src/systems/InputSystem.js b/src/systems/InputSystem.js new file mode 100644 index 0000000..9a45fc6 --- /dev/null +++ b/src/systems/InputSystem.js @@ -0,0 +1,153 @@ +import { System } from '../core/System.js'; + +export class InputSystem extends System { + constructor() { + super('InputSystem'); + this.requiredComponents = []; // No required components - handles input globally + this.priority = 0; // Run first + + this.keys = {}; + this.keysPrevious = {}; // Track previous frame key states + this.mouse = { + x: 0, + y: 0, + buttons: {}, + buttonsPrevious: {} + }; + } + + init(engine) { + super.init(engine); + this.setupEventListeners(); + } + + setupEventListeners() { + window.addEventListener('keydown', (e) => { + const key = e.key.toLowerCase(); + const code = e.code.toLowerCase(); + + // Store by key name + this.keys[key] = true; + this.keys[code] = true; + + // Handle special keys + if (key === ' ') { + this.keys['space'] = true; + } + if (code === 'space') { + this.keys['space'] = true; + } + + // Arrow keys + if (code === 'arrowup') this.keys['arrowup'] = true; + if (code === 'arrowdown') this.keys['arrowdown'] = true; + if (code === 'arrowleft') this.keys['arrowleft'] = true; + if (code === 'arrowright') this.keys['arrowright'] = true; + + // Prevent default for game keys + if ([' ', 'w', 'a', 's', 'd', '1', '2', '3', '4', '5', '6', '7', '8', '9'].includes(key)) { + e.preventDefault(); + } + }); + + window.addEventListener('keyup', (e) => { + const key = e.key.toLowerCase(); + const code = e.code.toLowerCase(); + + this.keys[key] = false; + this.keys[code] = false; + + // Handle special keys + if (key === ' ') { + this.keys['space'] = false; + } + if (code === 'space') { + this.keys['space'] = false; + } + + // Arrow keys + if (code === 'arrowup') this.keys['arrowup'] = false; + if (code === 'arrowdown') this.keys['arrowdown'] = false; + if (code === 'arrowleft') this.keys['arrowleft'] = false; + if (code === 'arrowright') this.keys['arrowright'] = false; + }); + + window.addEventListener('mousemove', (e) => { + if (this.engine && this.engine.canvas) { + const canvas = this.engine.canvas; + const rect = canvas.getBoundingClientRect(); + this.mouse.x = e.clientX - rect.left; + this.mouse.y = e.clientY - rect.top; + } + }); + + window.addEventListener('mousedown', (e) => { + this.mouse.buttons[e.button] = true; + }); + + window.addEventListener('mouseup', (e) => { + this.mouse.buttons[e.button] = false; + }); + } + + process(_deltaTime, _entities) { + // Don't update previous states here - that happens at end of frame + // This allows other systems to check isKeyJustPressed during the frame + } + + /** + * Update previous states - called at end of frame + */ + updatePreviousStates() { + // Deep copy current states to previous for next frame + this.keysPrevious = {}; + for (const key in this.keys) { + this.keysPrevious[key] = this.keys[key]; + } + this.mouse.buttonsPrevious = {}; + for (const button in this.mouse.buttons) { + this.mouse.buttonsPrevious[button] = this.mouse.buttons[button]; + } + } + + /** + * Check if a key is currently pressed + */ + isKeyPressed(key) { + return this.keys[key.toLowerCase()] === true; + } + + /** + * Check if a key was just pressed (not held from previous frame) + */ + isKeyJustPressed(key) { + const keyLower = key.toLowerCase(); + const isPressed = this.keys[keyLower] === true; + const wasPressed = this.keysPrevious[keyLower] === true; + return isPressed && !wasPressed; + } + + /** + * Get mouse position + */ + getMousePosition() { + return { x: this.mouse.x, y: this.mouse.y }; + } + + /** + * Check if mouse button is pressed + */ + isMouseButtonPressed(button = 0) { + return this.mouse.buttons[button] === true; + } + + /** + * Check if mouse button was just pressed + */ + isMouseButtonJustPressed(button = 0) { + const isPressed = this.mouse.buttons[button] === true; + const wasPressed = this.mouse.buttonsPrevious[button] === true; + return isPressed && !wasPressed; + } +} + diff --git a/src/systems/InputSystem.ts b/src/systems/InputSystem.ts deleted file mode 100644 index e9f069c..0000000 --- a/src/systems/InputSystem.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { System } from '../core/System.ts'; -import { SystemName, ComponentType } from '../core/Constants.ts'; -import type { Engine } from '../core/Engine.ts'; -import type { Entity } from '../core/Entity.ts'; -import type { Camera } from '../components/Camera.ts'; - -interface MouseState { - x: number; - y: number; - buttons: Record; - buttonsPrevious: Record; -} - -/** - * System responsible for capturing and managing keyboard and mouse input. - */ -export class InputSystem extends System { - keys: Record; - keysPrevious: Record; - mouse: MouseState; - - constructor() { - super(SystemName.INPUT); - this.requiredComponents = []; - this.priority = 0; - - this.keys = {}; - this.keysPrevious = {}; - this.mouse = { - x: 0, - y: 0, - buttons: {}, - buttonsPrevious: {}, - }; - } - - /** - * Initialize the system and set up event listeners. - * @param engine - The game engine instance - */ - init(engine: Engine): void { - super.init(engine); - this.setupEventListeners(); - } - - /** - * Set up browser event listeners for keyboard and mouse. - */ - setupEventListeners(): void { - window.addEventListener('keydown', (e) => { - const key = e.key.toLowerCase(); - const code = e.code.toLowerCase(); - - this.keys[key] = true; - this.keys[code] = true; - - if (key === ' ') { - this.keys['space'] = true; - } - if (code === 'space') { - this.keys['space'] = true; - } - - if (code === 'arrowup') this.keys['arrowup'] = true; - if (code === 'arrowdown') this.keys['arrowdown'] = true; - if (code === 'arrowleft') this.keys['arrowleft'] = true; - if (code === 'arrowright') this.keys['arrowright'] = true; - - if ([' ', 'w', 'a', 's', 'd', '1', '2', '3', '4', '5', '6', '7', '8', '9'].includes(key)) { - e.preventDefault(); - } - }); - - window.addEventListener('keyup', (e) => { - const key = e.key.toLowerCase(); - const code = e.code.toLowerCase(); - - this.keys[key] = false; - this.keys[code] = false; - - if (key === ' ') { - this.keys['space'] = false; - } - if (code === 'space') { - this.keys['space'] = false; - } - - if (code === 'arrowup') this.keys['arrowup'] = false; - if (code === 'arrowdown') this.keys['arrowdown'] = false; - if (code === 'arrowleft') this.keys['arrowleft'] = false; - if (code === 'arrowright') this.keys['arrowright'] = false; - }); - - window.addEventListener('mousemove', (e) => { - if (this.engine && this.engine.canvas) { - const canvas = this.engine.canvas; - const rect = canvas.getBoundingClientRect(); - const scaleX = canvas.width / rect.width; - const scaleY = canvas.height / rect.height; - - this.mouse.x = (e.clientX - rect.left) * scaleX; - this.mouse.y = (e.clientY - rect.top) * scaleY; - } - }); - - window.addEventListener('mousedown', (e) => { - this.mouse.buttons[e.button] = true; - }); - - window.addEventListener('mouseup', (e) => { - this.mouse.buttons[e.button] = false; - }); - } - - /** - * Process input state (placeholder as processing happens via events). - * @param _deltaTime - Time elapsed - * @param _entities - Matching entities - */ - process(_deltaTime: number, _entities: Entity[]): void {} - - /** - * Update previous frame states. Should be called at the end of each frame. - */ - updatePreviousStates(): void { - this.keysPrevious = {}; - for (const key in this.keys) { - this.keysPrevious[key] = this.keys[key]; - } - this.mouse.buttonsPrevious = {}; - for (const button in this.mouse.buttons) { - this.mouse.buttonsPrevious[button] = this.mouse.buttons[button]; - } - } - - /** - * Check if a key is currently being held down. - * @param key - The key name or code - * @returns True if the key is pressed - */ - isKeyPressed(key: string): boolean { - return this.keys[key.toLowerCase()] === true; - } - - /** - * Check if a key was pressed in the current frame. - * @param key - The key name or code - * @returns True if the key was just pressed - */ - isKeyJustPressed(key: string): boolean { - const keyLower = key.toLowerCase(); - const isPressed = this.keys[keyLower] === true; - const wasPressed = this.keysPrevious[keyLower] === true; - return isPressed && !wasPressed; - } - - /** - * Get the current mouse position in world coordinates. - * @returns The mouse coordinates in world space - */ - getMousePosition(): { x: number; y: number } { - if (!this.engine) { - return { x: this.mouse.x, y: this.mouse.y }; - } - - const cameraEntity = this.engine.entities.find((e) => e.hasComponent(ComponentType.CAMERA)); - if (!cameraEntity) { - return { x: this.mouse.x, y: this.mouse.y }; - } - - const camera = cameraEntity.getComponent(ComponentType.CAMERA); - if (!camera) { - return { x: this.mouse.x, y: this.mouse.y }; - } - - const worldX = this.mouse.x + camera.x - camera.viewportWidth / 2; - const worldY = this.mouse.y + camera.y - camera.viewportHeight / 2; - return { x: worldX, y: worldY }; - } - - /** - * Check if a mouse button is currently being held down. - * @param button - The button index (0=left, 1=middle, 2=right) - * @returns True if the button is pressed - */ - isMouseButtonPressed(button = 0): boolean { - return this.mouse.buttons[button] === true; - } - - /** - * Check if a mouse button was pressed in the current frame. - * @param button - The button index - * @returns True if the button was just pressed - */ - isMouseButtonJustPressed(button = 0): boolean { - const isPressed = this.mouse.buttons[button] === true; - const wasPressed = this.mouse.buttonsPrevious[button] === true; - return isPressed && !wasPressed; - } -} diff --git a/src/systems/MenuSystem.js b/src/systems/MenuSystem.js new file mode 100644 index 0000000..4c44103 --- /dev/null +++ b/src/systems/MenuSystem.js @@ -0,0 +1,110 @@ +import { System } from '../core/System.js'; + +/** + * System to handle game menus (start, pause) + */ +export class MenuSystem extends System { + constructor(engine) { + super('MenuSystem'); + this.requiredComponents = []; // No required components + this.priority = 1; // Run early + this.engine = engine; + this.ctx = engine.ctx; + this.gameState = 'start'; // 'start', 'playing', 'paused' + this.paused = false; + } + + init(engine) { + super.init(engine); + this.setupInput(); + } + + setupInput() { + window.addEventListener('keydown', (e) => { + if (e.key === 'Escape' || e.key === 'p' || e.key === 'P') { + if (this.gameState === 'playing') { + this.togglePause(); + } + } + if (e.key === 'Enter' || e.key === ' ') { + if (this.gameState === 'start') { + this.startGame(); + } else if (this.gameState === 'paused') { + this.resumeGame(); + } + } + }); + } + + startGame() { + this.gameState = 'playing'; + this.paused = false; + if (!this.engine.running) { + this.engine.start(); + } + } + + togglePause() { + if (this.gameState === 'playing') { + this.gameState = 'paused'; + this.paused = true; + } else if (this.gameState === 'paused') { + this.resumeGame(); + } + } + + resumeGame() { + this.gameState = 'playing'; + this.paused = false; + } + + process(_deltaTime, _entities) { + // Don't update game systems if paused or at start menu + if (this.gameState === 'paused' || this.gameState === 'start') { + // Pause all other systems + this.engine.systems.forEach(system => { + if (system !== this && system.name !== 'MenuSystem' && system.name !== 'UISystem') { + // Systems will check game state themselves + } + }); + } + } + + drawMenu() { + const ctx = this.ctx; + const width = this.engine.canvas.width; + const height = this.engine.canvas.height; + + // Dark overlay + ctx.fillStyle = 'rgba(0, 0, 0, 0.7)'; + ctx.fillRect(0, 0, width, height); + + ctx.fillStyle = '#ffffff'; + ctx.font = 'bold 48px Courier New'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + + if (this.gameState === 'start') { + ctx.fillText('SLIME GENESIS', width / 2, height / 2 - 100); + ctx.font = '24px Courier New'; + ctx.fillText('Press ENTER or SPACE to Start', width / 2, height / 2); + ctx.font = '16px Courier New'; + ctx.fillText('WASD: Move | Mouse: Aim | Click/Space: Attack', width / 2, height / 2 + 50); + ctx.fillText('Shift: Stealth | 1-9: Skills | ESC: Pause', width / 2, height / 2 + 80); + } else if (this.gameState === 'paused') { + ctx.fillText('PAUSED', width / 2, height / 2 - 50); + ctx.font = '24px Courier New'; + ctx.fillText('Press ENTER or SPACE to Resume', width / 2, height / 2); + ctx.fillText('Press ESC to Pause/Unpause', width / 2, height / 2 + 40); + } + } + + getGameState() { + return this.gameState; + } + + isPaused() { + return this.paused || this.gameState === 'start'; + } +} + diff --git a/src/systems/MenuSystem.ts b/src/systems/MenuSystem.ts deleted file mode 100644 index e918e71..0000000 --- a/src/systems/MenuSystem.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { System } from '../core/System.ts'; -import { PixelFont } from '../core/PixelFont.ts'; -import { Palette } from '../core/Palette.ts'; -import { GameState, ComponentType, SystemName } from '../core/Constants.ts'; -import type { Entity } from '../core/Entity.ts'; -import type { Engine } from '../core/Engine.ts'; -import type { UISystem } from './UISystem.ts'; - -/** - * System responsible for managing game states (start, playing, paused, game over) and rendering menus. - */ -export class MenuSystem extends System { - gameState: GameState; - paused: boolean; - ctx: CanvasRenderingContext2D; - - /** - * @param engine - The game engine instance - */ - constructor(engine: Engine) { - super(SystemName.MENU); - this.requiredComponents = []; - this.priority = 1; - this.engine = engine; - this.ctx = engine.ctx; - this.gameState = GameState.START; - this.paused = false; - } - - /** - * Initialize the menu system and set up input listeners. - * @param engine - The game engine instance - */ - init(engine: Engine): void { - super.init(engine); - this.setupInput(); - } - - /** - * Set up keyboard event listeners for menu navigation and state toggling. - */ - setupInput(): void { - window.addEventListener('keydown', (e) => { - if (e.key === 'Escape' || e.key === 'p' || e.key === 'P') { - if (this.gameState === GameState.PLAYING) { - this.togglePause(); - } - } - if (e.key === 'Enter' || e.key === ' ') { - if (this.gameState === GameState.START) { - this.startGame(); - } else if (this.gameState === GameState.PAUSED) { - this.resumeGame(); - } else if (this.gameState === GameState.GAME_OVER) { - this.restartGame(); - } - } - }); - } - - /** - * Transition the game state to Game Over. - */ - showGameOver(): void { - this.gameState = GameState.GAME_OVER; - this.paused = true; - } - - /** - * Restart the game by reloading the page. - */ - restartGame(): void { - window.location.reload(); - } - - /** - * Transition the game state from Start menu to Playing. - */ - startGame(): void { - this.gameState = GameState.PLAYING; - this.paused = false; - if (!this.engine.running) { - this.engine.start(); - } - } - - /** - * Toggle between Paused and Playing states. - */ - togglePause(): void { - if (this.gameState === GameState.PLAYING) { - this.gameState = GameState.PAUSED; - this.paused = true; - } else if (this.gameState === GameState.PAUSED) { - this.resumeGame(); - } - } - - /** - * Transition the game state from Paused to Playing. - */ - resumeGame(): void { - this.gameState = GameState.PLAYING; - this.paused = false; - } - - /** - * Process menu logic (handled via events). - * @param _deltaTime - Time elapsed - * @param _entities - Filtered entities - */ - process(_deltaTime: number, _entities: Entity[]): void {} - - /** - * Draw the current menu screen based on the game state. - */ - drawMenu(): void { - const ctx = this.ctx; - const width = this.engine.canvas.width; - const height = this.engine.canvas.height; - - ctx.fillStyle = 'rgba(32, 21, 51, 0.8)'; - ctx.fillRect(0, 0, width, height); - - if (this.gameState === GameState.START) { - const title = 'SLIME GENESIS'; - const titleW = PixelFont.getTextWidth(title, 2); - PixelFont.drawText(ctx, title, (width - titleW) / 2, height / 2 - 40, Palette.CYAN, 2); - - const start = 'PRESS ENTER TO START'; - const startW = PixelFont.getTextWidth(start, 1); - PixelFont.drawText(ctx, start, (width - startW) / 2, height / 2, Palette.WHITE, 1); - - const instructions = [ - 'WASD: MOVE | CLICK: ATTACK', - 'NUMS: SKILLS | ESC: PAUSE', - 'COLLECT DNA TO EVOLVE', - ]; - - instructions.forEach((line, i) => { - const lineW = PixelFont.getTextWidth(line, 1); - PixelFont.drawText( - ctx, - line, - (width - lineW) / 2, - height / 2 + 25 + i * 10, - Palette.ROYAL_BLUE, - 1 - ); - }); - } else if (this.gameState === GameState.PAUSED) { - const paused = 'PAUSED'; - const pausedW = PixelFont.getTextWidth(paused, 2); - PixelFont.drawText(ctx, paused, (width - pausedW) / 2, 20, Palette.SKY_BLUE, 2); - - const resume = 'PRESS ENTER TO RESUME'; - const resumeW = PixelFont.getTextWidth(resume, 1); - PixelFont.drawText(ctx, resume, (width - resumeW) / 2, 45, Palette.WHITE, 1); - - const player = this.engine.getEntities().find((e) => e.hasComponent(ComponentType.EVOLUTION)); - const uiSystem = this.engine.systems.find((s) => s.name === SystemName.UI) as - | UISystem - | undefined; - - if (player && uiSystem) { - uiSystem.drawStats(player, 20, 80); - uiSystem.drawSkillProgress(player, width - 110, 80); - uiSystem.drawMutations(player, 20, 185); - } - } else if (this.gameState === GameState.GAME_OVER) { - const dead = 'YOU PERISHED'; - const deadW = PixelFont.getTextWidth(dead, 2); - PixelFont.drawText(ctx, dead, (width - deadW) / 2, height / 2 - 30, Palette.WHITE, 2); - - const sub = 'YOUR DNA SUSTAINS THE CYCLE'; - const subW = PixelFont.getTextWidth(sub, 1); - PixelFont.drawText(ctx, sub, (width - subW) / 2, height / 2 - 5, Palette.ROYAL_BLUE, 1); - - const restart = 'PRESS ENTER TO REBORN'; - const restartW = PixelFont.getTextWidth(restart, 1); - PixelFont.drawText(ctx, restart, (width - restartW) / 2, height / 2 + 30, Palette.CYAN, 1); - } - } - - /** - * Get the current game state identifier. - * @returns The game state string - */ - getGameState(): GameState { - return this.gameState; - } - - /** - * Check if the game is currently paused or in the start menu. - * @returns True if paused or at start - */ - isPaused(): boolean { - return this.paused || this.gameState === GameState.START; - } -} diff --git a/src/systems/MovementSystem.js b/src/systems/MovementSystem.js new file mode 100644 index 0000000..b705139 --- /dev/null +++ b/src/systems/MovementSystem.js @@ -0,0 +1,62 @@ +import { System } from '../core/System.js'; + +export class MovementSystem extends System { + constructor() { + super('MovementSystem'); + this.requiredComponents = ['Position', 'Velocity']; + this.priority = 10; + } + + process(deltaTime, entities) { + entities.forEach(entity => { + const position = entity.getComponent('Position'); + const velocity = entity.getComponent('Velocity'); + const health = entity.getComponent('Health'); + + if (!position || !velocity) return; + + // Check if this is a projectile + const isProjectile = health && health.isProjectile; + + // Apply velocity with max speed limit (skip for projectiles) + if (!isProjectile) { + const speed = Math.sqrt(velocity.vx * velocity.vx + velocity.vy * velocity.vy); + if (speed > velocity.maxSpeed) { + const factor = velocity.maxSpeed / speed; + velocity.vx *= factor; + velocity.vy *= factor; + } + } + + // Update position + position.x += velocity.vx * deltaTime; + position.y += velocity.vy * deltaTime; + + // Apply friction (skip for projectiles - they should maintain speed) + if (!isProjectile) { + const friction = 0.9; + velocity.vx *= Math.pow(friction, deltaTime * 60); + velocity.vy *= Math.pow(friction, deltaTime * 60); + } + + // Boundary checking + const canvas = this.engine.canvas; + if (position.x < 0) { + position.x = 0; + velocity.vx = 0; + } else if (position.x > canvas.width) { + position.x = canvas.width; + velocity.vx = 0; + } + + if (position.y < 0) { + position.y = 0; + velocity.vy = 0; + } else if (position.y > canvas.height) { + position.y = canvas.height; + velocity.vy = 0; + } + }); + } +} + diff --git a/src/systems/MovementSystem.ts b/src/systems/MovementSystem.ts deleted file mode 100644 index 6008ee8..0000000 --- a/src/systems/MovementSystem.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { System } from '../core/System.ts'; -import { SystemName, ComponentType } from '../core/Constants.ts'; -import type { Entity } from '../core/Entity.ts'; -import type { Position } from '../components/Position.ts'; -import type { Velocity } from '../components/Velocity.ts'; -import type { Health } from '../components/Health.ts'; - -/** - * System responsible for moving entities based on their velocity and handling collisions. - */ -export class MovementSystem extends System { - constructor() { - super(SystemName.MOVEMENT); - this.requiredComponents = [ComponentType.POSITION, ComponentType.VELOCITY]; - this.priority = 10; - } - - /** - * Update the position of entities based on their velocity, applying friction and collision detection. - * @param deltaTime - Time elapsed since last frame in seconds - * @param entities - Entities matching system requirements - */ - process(deltaTime: number, entities: Entity[]): void { - entities.forEach((entity) => { - const position = entity.getComponent(ComponentType.POSITION); - const velocity = entity.getComponent(ComponentType.VELOCITY); - const health = entity.getComponent(ComponentType.HEALTH); - - if (!position || !velocity) return; - - if (velocity.lockTimer > 0) { - velocity.lockTimer -= deltaTime; - if (velocity.lockTimer <= 0) { - velocity.lockTimer = 0; - velocity.isLocked = false; - } - } - - const isProjectile = health && health.isProjectile; - - if (!isProjectile && !velocity.isLocked) { - const speed = Math.sqrt(velocity.vx * velocity.vx + velocity.vy * velocity.vy); - if (speed > velocity.maxSpeed) { - const factor = velocity.maxSpeed / speed; - velocity.vx *= factor; - velocity.vy *= factor; - } - } - - const tileMap = this.engine.tileMap; - - const nextX = position.x + velocity.vx * deltaTime; - if (tileMap && tileMap.isSolid(nextX, position.y)) { - velocity.vx = 0; - if (velocity.isLocked) { - velocity.lockTimer = 0; - velocity.isLocked = false; - } - } else { - position.x = nextX; - } - - const nextY = position.y + velocity.vy * deltaTime; - if (tileMap && tileMap.isSolid(position.x, nextY)) { - velocity.vy = 0; - if (velocity.isLocked) { - velocity.lockTimer = 0; - velocity.isLocked = false; - } - } else { - position.y = nextY; - } - - if (!isProjectile) { - const friction = velocity.isLocked ? 0.98 : 0.9; - velocity.vx *= Math.pow(friction, deltaTime * 60); - velocity.vy *= Math.pow(friction, deltaTime * 60); - } - - if (tileMap) { - const mapWidth = tileMap.cols * tileMap.tileSize; - const mapHeight = tileMap.rows * tileMap.tileSize; - - if (position.x < 0) { - position.x = 0; - velocity.vx = 0; - } else if (position.x > mapWidth) { - position.x = mapWidth; - velocity.vx = 0; - } - - if (position.y < 0) { - position.y = 0; - velocity.vy = 0; - } else if (position.y > mapHeight) { - position.y = mapHeight; - velocity.vy = 0; - } - } else { - const canvas = this.engine.canvas; - if (position.x < 0) { - position.x = 0; - velocity.vx = 0; - } else if (position.x > canvas.width) { - position.x = canvas.width; - velocity.vx = 0; - } - - if (position.y < 0) { - position.y = 0; - velocity.vy = 0; - } else if (position.y > canvas.height) { - position.y = canvas.height; - velocity.vy = 0; - } - } - }); - } -} diff --git a/src/systems/MusicSystem.ts b/src/systems/MusicSystem.ts deleted file mode 100644 index 439d31c..0000000 --- a/src/systems/MusicSystem.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { System } from '../core/System.ts'; -import { SystemName, ComponentType, GameState } from '../core/Constants.ts'; -import type { Entity } from '../core/Entity.ts'; -import type { Engine } from '../core/Engine.ts'; -import type { Music } from '../components/Music.ts'; -import type { MenuSystem } from './MenuSystem.ts'; - -/** - * System responsible for managing background music playback. - */ -export class MusicSystem extends System { - private audioContext: AudioContext | null; - private wasPaused: boolean; - - constructor() { - super(SystemName.MUSIC); - this.requiredComponents = [ComponentType.MUSIC]; - this.priority = 5; - this.audioContext = null; - this.wasPaused = false; - } - - /** - * Initialize the audio context when system is added to engine. - */ - init(engine: Engine): void { - super.init(engine); - } - - /** - * Process music entities - ensures audio context exists and handles pause/resume. - */ - process(_deltaTime: number, entities: Entity[]): void { - const menuSystem = this.engine.systems.find((s) => s.name === SystemName.MENU) as - | MenuSystem - | undefined; - const gameState = menuSystem ? menuSystem.getGameState() : GameState.PLAYING; - const isPaused = gameState === GameState.PAUSED; - - entities.forEach((entity) => { - const music = entity.getComponent(ComponentType.MUSIC); - if (!music) return; - - if (!this.audioContext) { - this.audioContext = new AudioContext(); - } - - if (isPaused && !this.wasPaused) { - music.pause(); - this.wasPaused = true; - } else if (!isPaused && this.wasPaused) { - music.resume(); - this.wasPaused = false; - } - }); - } - - /** - * Get or create the shared audio context. - */ - getAudioContext(): AudioContext { - if (!this.audioContext) { - this.audioContext = new AudioContext(); - } - return this.audioContext; - } - - /** - * Resume audio context (required after user interaction). - */ - resumeAudioContext(): void { - if (this.audioContext && this.audioContext.state === 'suspended') { - this.audioContext.resume(); - } - } -} diff --git a/src/systems/PlayerControllerSystem.js b/src/systems/PlayerControllerSystem.js new file mode 100644 index 0000000..397dfe0 --- /dev/null +++ b/src/systems/PlayerControllerSystem.js @@ -0,0 +1,69 @@ +import { System } from '../core/System.js'; + +export class PlayerControllerSystem extends System { + constructor() { + super('PlayerControllerSystem'); + this.requiredComponents = ['Position', 'Velocity']; + this.priority = 5; + this.playerEntity = null; + } + + process(deltaTime, entities) { + // Find player entity (first entity with player tag or specific component) + if (!this.playerEntity) { + this.playerEntity = entities.find(e => e.hasComponent('Evolution')); + } + + if (!this.playerEntity) return; + + const inputSystem = this.engine.systems.find(s => s.name === 'InputSystem'); + if (!inputSystem) return; + + const velocity = this.playerEntity.getComponent('Velocity'); + const position = this.playerEntity.getComponent('Position'); + if (!velocity || !position) return; + + // Movement input + let moveX = 0; + let moveY = 0; + const moveSpeed = 200; + + if (inputSystem.isKeyPressed('w') || inputSystem.isKeyPressed('arrowup')) { + moveY -= 1; + } + if (inputSystem.isKeyPressed('s') || inputSystem.isKeyPressed('arrowdown')) { + moveY += 1; + } + if (inputSystem.isKeyPressed('a') || inputSystem.isKeyPressed('arrowleft')) { + moveX -= 1; + } + if (inputSystem.isKeyPressed('d') || inputSystem.isKeyPressed('arrowright')) { + moveX += 1; + } + + // Normalize diagonal movement + if (moveX !== 0 && moveY !== 0) { + moveX *= 0.707; + moveY *= 0.707; + } + + // Apply movement + velocity.vx = moveX * moveSpeed; + velocity.vy = moveY * moveSpeed; + + // Face mouse or movement direction + const mouse = inputSystem.getMousePosition(); + const dx = mouse.x - position.x; + const dy = mouse.y - position.y; + if (Math.abs(dx) > 0.1 || Math.abs(dy) > 0.1) { + position.rotation = Math.atan2(dy, dx); + } else if (moveX !== 0 || moveY !== 0) { + position.rotation = Math.atan2(moveY, moveX); + } + } + + getPlayerEntity() { + return this.playerEntity; + } +} + diff --git a/src/systems/PlayerControllerSystem.ts b/src/systems/PlayerControllerSystem.ts deleted file mode 100644 index 41fdaf6..0000000 --- a/src/systems/PlayerControllerSystem.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { System } from '../core/System.ts'; -import { SystemName, ComponentType } from '../core/Constants.ts'; -import type { Entity } from '../core/Entity.ts'; -import type { Velocity } from '../components/Velocity.ts'; -import type { Position } from '../components/Position.ts'; -import type { Intent } from '../components/Intent.ts'; -import type { Skills } from '../components/Skills.ts'; -import type { InputSystem } from './InputSystem.ts'; - -/** - * System responsible for translating player input into movement and action intents. - */ -export class PlayerControllerSystem extends System { - playerEntity: Entity | null; - - constructor() { - super(SystemName.PLAYER_CONTROLLER); - this.requiredComponents = [ComponentType.POSITION, ComponentType.VELOCITY]; - this.priority = 5; - this.playerEntity = null; - } - - /** - * Process player input and update the player entity's velocity and intent. - * @param deltaTime - Time elapsed since last frame in seconds - * @param entities - Entities matching system requirements - */ - process(deltaTime: number, entities: Entity[]): void { - if (!this.playerEntity) { - this.playerEntity = entities.find((e) => e.hasComponent(ComponentType.EVOLUTION)) || null; - } - - if (!this.playerEntity) return; - - const inputSystem = this.engine.systems.find((s) => s.name === SystemName.INPUT) as - | InputSystem - | undefined; - if (!inputSystem) return; - - const velocity = this.playerEntity.getComponent(ComponentType.VELOCITY); - const position = this.playerEntity.getComponent(ComponentType.POSITION); - if (!velocity || !position) return; - - let moveX = 0; - let moveY = 0; - const moveSpeed = 100; - - if (!velocity.isLocked) { - if (inputSystem.isKeyPressed('w') || inputSystem.isKeyPressed('arrowup')) { - moveY -= 1; - } - if (inputSystem.isKeyPressed('s') || inputSystem.isKeyPressed('arrowdown')) { - moveY += 1; - } - if (inputSystem.isKeyPressed('a') || inputSystem.isKeyPressed('arrowleft')) { - moveX -= 1; - } - if (inputSystem.isKeyPressed('d') || inputSystem.isKeyPressed('arrowright')) { - moveX += 1; - } - - if (moveX !== 0 && moveY !== 0) { - moveX *= 0.707; - moveY *= 0.707; - } - - velocity.vx = moveX * moveSpeed; - velocity.vy = moveY * moveSpeed; - } - - const mouse = inputSystem.getMousePosition(); - const dx = mouse.x - position.x; - const dy = mouse.y - position.y; - if (Math.abs(dx) > 0.1 || Math.abs(dy) > 0.1) { - position.rotation = Math.atan2(dy, dx); - } else if (moveX !== 0 || moveY !== 0) { - position.rotation = Math.atan2(moveY, moveX); - } - - const intent = this.playerEntity.getComponent(ComponentType.INTENT); - const skills = this.playerEntity.getComponent(ComponentType.SKILLS); - - if (intent && skills) { - for (let i = 1; i <= 9; i++) { - const key = i.toString(); - if (inputSystem.isKeyJustPressed(key)) { - const skillIndex = i - 1; - if (skillIndex < skills.activeSkills.length) { - const skillId = skills.activeSkills[skillIndex]; - intent.setIntent('skill_use', { skillId }); - break; - } - } - } - - if ( - inputSystem.isMouseButtonJustPressed(0) || - inputSystem.isKeyJustPressed(' ') || - inputSystem.isKeyJustPressed('space') - ) { - intent.setIntent('attack', { targetX: mouse.x, targetY: mouse.y }); - } - } - } - - /** - * Get the current player entity. - * @returns The player entity - */ - getPlayerEntity(): Entity | null { - return this.playerEntity; - } -} diff --git a/src/systems/ProjectileSystem.js b/src/systems/ProjectileSystem.js new file mode 100644 index 0000000..6e4e9ee --- /dev/null +++ b/src/systems/ProjectileSystem.js @@ -0,0 +1,83 @@ +import { System } from '../core/System.js'; + +export class ProjectileSystem extends System { + constructor() { + super('ProjectileSystem'); + this.requiredComponents = ['Position', 'Velocity']; + this.priority = 18; + } + + process(deltaTime, entities) { + const playerController = this.engine.systems.find(s => s.name === 'PlayerControllerSystem'); + const _player = playerController ? playerController.getPlayerEntity() : null; + + entities.forEach(entity => { + const health = entity.getComponent('Health'); + if (!health || !health.isProjectile) return; + + const position = entity.getComponent('Position'); + if (!position) return; + + // Check range - remove if traveled beyond max range + if (entity.startX !== undefined && entity.startY !== undefined && entity.maxRange !== undefined) { + const dx = position.x - entity.startX; + const dy = position.y - entity.startY; + const distanceTraveled = Math.sqrt(dx * dx + dy * dy); + + if (distanceTraveled >= entity.maxRange) { + this.engine.removeEntity(entity); + return; + } + } + + // Check lifetime as backup + if (entity.lifetime !== undefined) { + entity.lifetime -= deltaTime; + if (entity.lifetime <= 0) { + this.engine.removeEntity(entity); + return; + } + } + + // Check collisions with enemies + const allEntities = this.engine.getEntities(); + allEntities.forEach(target => { + if (target.id === entity.owner) return; + if (target.id === entity.id) return; + if (!target.hasComponent('Health')) return; + if (target.getComponent('Health').isProjectile) return; + + const targetPos = target.getComponent('Position'); + if (!targetPos) return; + + const dx = targetPos.x - position.x; + const dy = targetPos.y - position.y; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (distance < 20) { + // Hit! + const targetHealth = target.getComponent('Health'); + const damage = entity.damage || 10; + targetHealth.takeDamage(damage); + + // If target is dead, mark it for immediate removal + if (targetHealth.isDead()) { + target.active = false; + // DeathSystem will handle removal + } + + // Remove projectile + this.engine.removeEntity(entity); + } + }); + + // Boundary check + const canvas = this.engine.canvas; + if (position.x < 0 || position.x > canvas.width || + position.y < 0 || position.y > canvas.height) { + this.engine.removeEntity(entity); + } + }); + } +} + diff --git a/src/systems/ProjectileSystem.ts b/src/systems/ProjectileSystem.ts deleted file mode 100644 index 44aa4bf..0000000 --- a/src/systems/ProjectileSystem.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { System } from '../core/System.ts'; -import { Events } from '../core/EventBus.ts'; -import { Palette } from '../core/Palette.ts'; -import { SystemName, ComponentType } from '../core/Constants.ts'; -import type { Entity } from '../core/Entity.ts'; -import type { Health } from '../components/Health.ts'; -import type { Position } from '../components/Position.ts'; -import type { Velocity } from '../components/Velocity.ts'; -import type { VFXSystem } from './VFXSystem.ts'; - -/** - * System responsible for managing projectile movement, range limits, lifetimes, and collisions. - */ -export class ProjectileSystem extends System { - constructor() { - super(SystemName.PROJECTILE); - this.requiredComponents = [ComponentType.POSITION, ComponentType.VELOCITY]; - this.priority = 18; - } - - /** - * Process logic for all projectiles, checking for range, lifetime, and target collisions. - * @param deltaTime - Time elapsed since last frame in seconds - * @param entities - Entities matching system requirements - */ - process(deltaTime: number, entities: Entity[]): void { - entities.forEach((entity) => { - const health = entity.getComponent(ComponentType.HEALTH); - if (!health || !health.isProjectile) return; - - const position = entity.getComponent(ComponentType.POSITION); - if (!position) return; - - if ( - entity.startX !== undefined && - entity.startY !== undefined && - entity.maxRange !== undefined - ) { - const dx = position.x - entity.startX; - const dy = position.y - entity.startY; - const distanceTraveled = Math.sqrt(dx * dx + dy * dy); - - if (distanceTraveled >= entity.maxRange) { - this.engine.removeEntity(entity); - return; - } - } - - if (entity.lifetime !== undefined) { - entity.lifetime -= deltaTime; - if (entity.lifetime <= 0) { - this.engine.removeEntity(entity); - return; - } - } - - const allEntities = this.engine.getEntities(); - allEntities.forEach((target) => { - if (target.id === entity.owner) return; - if (target.id === entity.id) return; - if (!target.hasComponent(ComponentType.HEALTH)) return; - const targetHealth = target.getComponent(ComponentType.HEALTH); - if (targetHealth && targetHealth.isProjectile) return; - - const targetPos = target.getComponent(ComponentType.POSITION); - if (!targetPos) return; - - const dx = targetPos.x - position.x; - const dy = targetPos.y - position.y; - const distance = Math.sqrt(dx * dx + dy * dy); - - if (distance < 8) { - const targetHealthComp = target.getComponent(ComponentType.HEALTH); - const damage = entity.damage || 10; - if (targetHealthComp) { - targetHealthComp.takeDamage(damage); - - this.engine.emit(Events.PROJECTILE_IMPACT, { - x: position.x, - y: position.y, - damage, - }); - - const vfxSystem = this.engine.systems.find((s) => s.name === SystemName.VFX) as - | VFXSystem - | undefined; - const velocity = entity.getComponent(ComponentType.VELOCITY); - if (vfxSystem) { - const angle = velocity ? Math.atan2(velocity.vy, velocity.vx) : null; - vfxSystem.createImpact(position.x, position.y, Palette.CYAN, angle); - } - - if (targetHealthComp.isDead()) { - this.engine.emit(Events.ENTITY_DIED, { entity: target }); - } - } - - this.engine.removeEntity(entity); - } - }); - - const tileMap = this.engine.tileMap; - if (tileMap) { - const mapWidth = tileMap.cols * tileMap.tileSize; - const mapHeight = tileMap.rows * tileMap.tileSize; - if (position.x < 0 || position.x > mapWidth || position.y < 0 || position.y > mapHeight) { - this.engine.removeEntity(entity); - } - } else { - const canvas = this.engine.canvas; - if ( - position.x < 0 || - position.x > canvas.width || - position.y < 0 || - position.y > canvas.height - ) { - this.engine.removeEntity(entity); - } - } - }); - } -} diff --git a/src/systems/RenderSystem.js b/src/systems/RenderSystem.js new file mode 100644 index 0000000..dcedd95 --- /dev/null +++ b/src/systems/RenderSystem.js @@ -0,0 +1,437 @@ +import { System } from '../core/System.js'; + +export class RenderSystem extends System { + constructor(engine) { + super('RenderSystem'); + this.requiredComponents = ['Position', 'Sprite']; + this.priority = 100; // Render last + this.engine = engine; + this.ctx = engine.ctx; + } + + process(deltaTime, _entities) { + // Clear canvas + this.engine.clear(); + + // Draw background + this.drawBackground(); + + // Draw entities + // Get all entities including inactive ones for rendering dead absorbable entities + const allEntities = this.engine.entities; + allEntities.forEach(entity => { + const health = entity.getComponent('Health'); + const evolution = entity.getComponent('Evolution'); + + // Skip inactive entities UNLESS they're dead and absorbable (for absorption window) + if (!entity.active) { + const absorbable = entity.getComponent('Absorbable'); + if (health && health.isDead() && absorbable && !absorbable.absorbed) { + // Render dead absorbable entities even if inactive (fade them out) + this.drawEntity(entity, deltaTime, true); // Pass fade flag + return; + } + return; // Skip other inactive entities + } + + // Don't render dead non-player entities (unless they're absorbable, handled above) + if (health && health.isDead() && !evolution) { + const absorbable = entity.getComponent('Absorbable'); + if (!absorbable || absorbable.absorbed) { + return; // Skip dead non-absorbable entities + } + } + + this.drawEntity(entity, deltaTime); + }); + + // Draw skill effects + this.drawSkillEffects(); + } + + drawBackground() { + const ctx = this.ctx; + const width = this.engine.canvas.width; + const height = this.engine.canvas.height; + + // Cave background with gradient + const gradient = ctx.createLinearGradient(0, 0, 0, height); + gradient.addColorStop(0, '#0f0f1f'); + gradient.addColorStop(1, '#1a1a2e'); + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, width, height); + + // Add cave features with better visuals + ctx.fillStyle = '#2a2a3e'; + for (let i = 0; i < 20; i++) { + const x = (i * 70 + Math.sin(i) * 30) % width; + const y = (i * 50 + Math.cos(i) * 40) % height; + const size = 25 + (i % 4) * 15; + + // Add shadow + ctx.shadowBlur = 20; + ctx.shadowColor = 'rgba(0, 0, 0, 0.5)'; + ctx.beginPath(); + ctx.arc(x, y, size, 0, Math.PI * 2); + ctx.fill(); + ctx.shadowBlur = 0; + } + + // Add some ambient lighting + const lightGradient = ctx.createRadialGradient(width / 2, height / 2, 0, width / 2, height / 2, 400); + lightGradient.addColorStop(0, 'rgba(100, 150, 200, 0.1)'); + lightGradient.addColorStop(1, 'rgba(0, 0, 0, 0)'); + ctx.fillStyle = lightGradient; + ctx.fillRect(0, 0, width, height); + } + + drawEntity(entity, deltaTime, isDeadFade = false) { + const position = entity.getComponent('Position'); + const sprite = entity.getComponent('Sprite'); + const health = entity.getComponent('Health'); + + if (!position || !sprite) return; + + this.ctx.save(); + // Fade out dead entities + let alpha = sprite.alpha; + if (isDeadFade && health && health.isDead()) { + const absorbable = entity.getComponent('Absorbable'); + if (absorbable && !absorbable.absorbed) { + // Calculate fade based on time since death + const deathTime = entity.deathTime || Date.now(); + const timeSinceDeath = (Date.now() - deathTime) / 1000; + const fadeTime = 3.0; // 3 seconds to fade (matches DeathSystem removal time) + alpha = Math.max(0.3, 1.0 - (timeSinceDeath / fadeTime)); + } + } + this.ctx.globalAlpha = alpha; + this.ctx.translate(position.x, position.y); + this.ctx.rotate(position.rotation); + this.ctx.scale(sprite.scale, sprite.scale); + + // Update animation time for slime morphing + if (sprite.shape === 'slime') { + sprite.animationTime += deltaTime; + sprite.morphAmount = Math.sin(sprite.animationTime * 3) * 0.2 + 0.8; + } + + // Draw based on shape + this.ctx.fillStyle = sprite.color; + + if (sprite.shape === 'circle' || sprite.shape === 'slime') { + this.drawSlime(sprite); + } else if (sprite.shape === 'rect') { + this.ctx.fillRect(-sprite.width / 2, -sprite.height / 2, sprite.width, sprite.height); + } + + // Draw health bar if entity has health + if (health && health.maxHp > 0) { + this.drawHealthBar(health, sprite); + } + + // Draw combat indicator if attacking + const combat = entity.getComponent('Combat'); + if (combat && combat.isAttacking) { + // Draw attack indicator relative to entity's current rotation + // Since we're already rotated, we need to draw relative to 0,0 forward + this.drawAttackIndicator(combat, position); + } + + // Draw stealth indicator + const stealth = entity.getComponent('Stealth'); + if (stealth && stealth.isStealthed) { + this.drawStealthIndicator(stealth, sprite); + } + + // Mutation Visual Effects + const evolution = entity.getComponent('Evolution'); + if (evolution) { + if (evolution.mutationEffects.glowingBody) { + // Draw light aura + const auraGradient = this.ctx.createRadialGradient(0, 0, 0, 0, 0, sprite.width * 2); + auraGradient.addColorStop(0, 'rgba(255, 255, 200, 0.2)'); + auraGradient.addColorStop(1, 'rgba(255, 255, 200, 0)'); + this.ctx.fillStyle = auraGradient; + this.ctx.beginPath(); + this.ctx.arc(0, 0, sprite.width * 2, 0, Math.PI * 2); + this.ctx.fill(); + } + if (evolution.mutationEffects.electricSkin) { + // Add tiny sparks + if (Math.random() < 0.2) { + this.ctx.strokeStyle = '#00ffff'; + this.ctx.lineWidth = 2; + this.ctx.beginPath(); + const sparkX = (Math.random() - 0.5) * sprite.width; + const sparkY = (Math.random() - 0.5) * sprite.height; + this.ctx.moveTo(sparkX, sparkY); + this.ctx.lineTo(sparkX + (Math.random() - 0.5) * 10, sparkY + (Math.random() - 0.5) * 10); + this.ctx.stroke(); + } + } + if (evolution.mutationEffects.hardenedShell) { + // Darker, thicker border + this.ctx.strokeStyle = 'rgba(0, 0, 0, 0.5)'; + this.ctx.lineWidth = 3; + this.ctx.stroke(); + } + } + + this.ctx.restore(); + } + + drawSlime(sprite) { + const ctx = this.ctx; + const baseRadius = Math.min(sprite.width, sprite.height) / 2; + + if (sprite.shape === 'slime') { + // Animated slime blob with morphing and better visuals + ctx.shadowBlur = 15; + ctx.shadowColor = sprite.color; + + // Main body with morphing + ctx.beginPath(); + const points = 16; + for (let i = 0; i < points; i++) { + const angle = (i / points) * Math.PI * 2; + const morph1 = Math.sin(angle * 2 + sprite.animationTime * 2) * 0.15; + const morph2 = Math.cos(angle * 3 + sprite.animationTime * 1.5) * 0.1; + const radius = baseRadius * (sprite.morphAmount + morph1 + morph2); + const x = Math.cos(angle) * radius; + const y = Math.sin(angle) * radius; + + if (i === 0) { + ctx.moveTo(x, y); + } else { + ctx.lineTo(x, y); + } + } + ctx.closePath(); + ctx.fill(); + + // Inner glow + const innerGradient = ctx.createRadialGradient(0, 0, 0, 0, 0, baseRadius * 0.8); + innerGradient.addColorStop(0, 'rgba(255, 255, 255, 0.4)'); + innerGradient.addColorStop(1, 'rgba(255, 255, 255, 0)'); + ctx.fillStyle = innerGradient; + ctx.beginPath(); + ctx.arc(0, 0, baseRadius * 0.8, 0, Math.PI * 2); + ctx.fill(); + + // Highlight + ctx.fillStyle = 'rgba(255, 255, 255, 0.5)'; + ctx.beginPath(); + ctx.arc(-baseRadius * 0.3, -baseRadius * 0.3, baseRadius * 0.35, 0, Math.PI * 2); + ctx.fill(); + + ctx.fillStyle = sprite.color; + ctx.shadowBlur = 0; + } else { + // Simple circle with glow + ctx.shadowBlur = 10; + ctx.shadowColor = sprite.color; + ctx.beginPath(); + ctx.arc(0, 0, baseRadius, 0, Math.PI * 2); + ctx.fill(); + ctx.shadowBlur = 0; + } + } + + drawHealthBar(health, sprite) { + const ctx = this.ctx; + const barWidth = sprite.width * 1.5; + const barHeight = 4; + const yOffset = sprite.height / 2 + 10; + + // Background + ctx.fillStyle = 'rgba(0, 0, 0, 0.5)'; + ctx.fillRect(-barWidth / 2, -yOffset, barWidth, barHeight); + + // Health fill + const healthPercent = health.hp / health.maxHp; + ctx.fillStyle = healthPercent > 0.5 ? '#00ff00' : healthPercent > 0.25 ? '#ffff00' : '#ff0000'; + ctx.fillRect(-barWidth / 2, -yOffset, barWidth * healthPercent, barHeight); + + // Border + ctx.strokeStyle = '#ffffff'; + ctx.lineWidth = 1; + ctx.strokeRect(-barWidth / 2, -yOffset, barWidth, barHeight); + } + + drawAttackIndicator(combat, _position) { + const ctx = this.ctx; + const length = 50; + const attackProgress = 1.0 - (combat.attackCooldown / 0.3); // 0 to 1 during attack animation + + // Since we're already in entity's rotated coordinate space (ctx.rotate was applied), + // and position.rotation should match combat.attackDirection (set in CombatSystem), + // we just draw forward (angle 0) in local space + const angle = 0; // Forward in local rotated space + + // Draw slime tentacle/extension + ctx.strokeStyle = `rgba(0, 255, 150, ${0.8 * attackProgress})`; + ctx.fillStyle = `rgba(0, 255, 150, ${0.6 * attackProgress})`; + ctx.lineWidth = 8; + ctx.lineCap = 'round'; + + // Tentacle extends outward during attack (forward from entity) + const tentacleLength = length * attackProgress; + const tentacleEndX = Math.cos(angle) * tentacleLength; + const tentacleEndY = Math.sin(angle) * tentacleLength; + + // Draw curved tentacle + ctx.beginPath(); + ctx.moveTo(0, 0); + // Add slight curve to tentacle + const midX = Math.cos(angle) * tentacleLength * 0.5; + const midY = Math.sin(angle) * tentacleLength * 0.5; + const perpX = -Math.sin(angle) * 5 * attackProgress; + const perpY = Math.cos(angle) * 5 * attackProgress; + ctx.quadraticCurveTo(midX + perpX, midY + perpY, tentacleEndX, tentacleEndY); + ctx.stroke(); + + // Draw impact point + if (attackProgress > 0.5) { + ctx.beginPath(); + ctx.arc(tentacleEndX, tentacleEndY, 6 * attackProgress, 0, Math.PI * 2); + ctx.fill(); + } + } + + drawStealthIndicator(stealth, sprite) { + const ctx = this.ctx; + const radius = Math.max(sprite.width, sprite.height) / 2 + 5; + + // Stealth ring + ctx.strokeStyle = `rgba(0, 255, 150, ${1 - stealth.visibility})`; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(0, 0, radius, 0, Math.PI * 2); + ctx.stroke(); + + // Visibility indicator + if (stealth.visibility > 0.3) { + ctx.fillStyle = `rgba(255, 0, 0, ${(stealth.visibility - 0.3) * 2})`; + ctx.beginPath(); + ctx.arc(0, -radius - 10, 3, 0, Math.PI * 2); + ctx.fill(); + } + } + + drawSkillEffects() { + const skillEffectSystem = this.engine.systems.find(s => s.name === 'SkillEffectSystem'); + if (!skillEffectSystem) return; + + const effects = skillEffectSystem.getEffects(); + const ctx = this.ctx; + + effects.forEach(effect => { + ctx.save(); + + switch (effect.type) { + case 'fire_breath': + this.drawFireBreath(ctx, effect); + break; + case 'pounce': + this.drawPounce(ctx, effect); + break; + case 'pounce_impact': + this.drawPounceImpact(ctx, effect); + break; + } + + ctx.restore(); + }); + } + + drawFireBreath(ctx, effect) { + const progress = Math.min(1.0, effect.time / effect.lifetime); // Clamp to 0-1 + const alpha = Math.max(0, 1.0 - progress); // Ensure non-negative + + // Draw fire cone + ctx.translate(effect.x, effect.y); + ctx.rotate(effect.angle); + + // Cone gradient + const gradient = ctx.createRadialGradient(0, 0, 0, 0, 0, effect.range); + gradient.addColorStop(0, `rgba(255, 100, 0, ${alpha * 0.8})`); + gradient.addColorStop(0.5, `rgba(255, 200, 0, ${alpha * 0.6})`); + gradient.addColorStop(1, `rgba(255, 50, 0, ${alpha * 0.3})`); + + ctx.fillStyle = gradient; + ctx.beginPath(); + ctx.moveTo(0, 0); + ctx.arc(0, 0, effect.range, -effect.coneAngle / 2, effect.coneAngle / 2); + ctx.closePath(); + ctx.fill(); + + // Fire particles + for (let i = 0; i < 20; i++) { + const angle = (Math.random() - 0.5) * effect.coneAngle; + const dist = Math.random() * effect.range * progress; + const x = Math.cos(angle) * dist; + const y = Math.sin(angle) * dist; + const size = 3 + Math.random() * 5; + + ctx.fillStyle = `rgba(255, ${150 + Math.random() * 100}, 0, ${alpha})`; + ctx.beginPath(); + ctx.arc(x, y, size, 0, Math.PI * 2); + ctx.fill(); + } + } + + drawPounce(ctx, effect) { + const progress = Math.min(1.0, effect.time / effect.lifetime); // Clamp to 0-1 + const currentX = effect.startX + Math.cos(effect.angle) * effect.speed * effect.time; + const currentY = effect.startY + Math.sin(effect.angle) * effect.speed * effect.time; + + // Draw dash trail + const alpha = Math.max(0, 1.0 - progress); // Ensure non-negative + ctx.strokeStyle = `rgba(255, 200, 0, ${alpha})`; + ctx.lineWidth = 4; + ctx.lineCap = 'round'; + ctx.beginPath(); + ctx.moveTo(effect.startX, effect.startY); + ctx.lineTo(currentX, currentY); + ctx.stroke(); + + // Draw impact point + const radius = Math.max(0, 15 * (1 - progress)); // Ensure non-negative radius + if (radius > 0) { + ctx.fillStyle = `rgba(255, 150, 0, ${alpha})`; + ctx.beginPath(); + ctx.arc(currentX, currentY, radius, 0, Math.PI * 2); + ctx.fill(); + } + } + + drawPounceImpact(ctx, effect) { + const progress = Math.min(1.0, effect.time / effect.lifetime); // Clamp to 0-1 + const alpha = Math.max(0, 1.0 - progress); // Ensure non-negative + const size = Math.max(0, 30 * (1 - progress)); // Ensure non-negative size + + if (size > 0 && alpha > 0) { + // Impact ring + ctx.strokeStyle = `rgba(255, 200, 0, ${alpha})`; + ctx.lineWidth = 3; + ctx.beginPath(); + ctx.arc(effect.x, effect.y, size, 0, Math.PI * 2); + ctx.stroke(); + + // Impact particles + for (let i = 0; i < 8; i++) { + const angle = (i / 8) * Math.PI * 2; + const dist = size * 0.7; + const x = effect.x + Math.cos(angle) * dist; + const y = effect.y + Math.sin(angle) * dist; + + ctx.fillStyle = `rgba(255, 150, 0, ${alpha})`; + ctx.beginPath(); + ctx.arc(x, y, 4, 0, Math.PI * 2); + ctx.fill(); + } + } + } +} + diff --git a/src/systems/RenderSystem.ts b/src/systems/RenderSystem.ts deleted file mode 100644 index e73820a..0000000 --- a/src/systems/RenderSystem.ts +++ /dev/null @@ -1,660 +0,0 @@ -import { System } from '../core/System.ts'; -import { Palette } from '../core/Palette.ts'; -import { SpriteLibrary } from '../core/SpriteLibrary.ts'; -import { - ComponentType, - SystemName, - AnimationState, - VFXType, - EntityType, -} from '../core/Constants.ts'; -import type { Entity } from '../core/Entity.ts'; -import type { Engine } from '../core/Engine.ts'; -import type { Position } from '../components/Position.ts'; -import type { Sprite } from '../components/Sprite.ts'; -import type { Health } from '../components/Health.ts'; -import type { Velocity } from '../components/Velocity.ts'; -import type { Combat } from '../components/Combat.ts'; -import type { Stealth } from '../components/Stealth.ts'; -import type { Evolution } from '../components/Evolution.ts'; -import type { Absorbable } from '../components/Absorbable.ts'; -import type { Camera } from '../components/Camera.ts'; -import type { VFXSystem } from './VFXSystem.ts'; -import type { SkillEffectSystem, SkillEffect } from './SkillEffectSystem.ts'; - -/** - * System responsible for rendering all game elements, including background, map, entities, and VFX. - */ -export class RenderSystem extends System { - ctx: CanvasRenderingContext2D; - private camera: Camera | null; - - /** - * @param engine - The game engine instance - */ - constructor(engine: Engine) { - super(SystemName.RENDER); - this.requiredComponents = [ComponentType.POSITION, ComponentType.SPRITE]; - this.priority = 100; - this.engine = engine; - this.ctx = engine.ctx; - this.camera = null; - } - - /** - * Get the active camera from the engine. - */ - private getCamera(): Camera | null { - if (this.camera) return this.camera; - - const cameraEntity = this.engine.entities.find((e) => e.hasComponent(ComponentType.CAMERA)); - if (cameraEntity) { - this.camera = cameraEntity.getComponent(ComponentType.CAMERA); - } - return this.camera; - } - - /** - * Transform world coordinates to screen coordinates using camera. - * @param worldX - World X coordinate - * @param worldY - World Y coordinate - * @returns Screen coordinates {x, y} - */ - private worldToScreen(worldX: number, worldY: number): { x: number; y: number } { - const camera = this.getCamera(); - if (!camera) { - return { x: worldX, y: worldY }; - } - - const screenX = worldX - camera.x + camera.viewportWidth / 2; - const screenY = worldY - camera.y + camera.viewportHeight / 2; - return { x: screenX, y: screenY }; - } - - /** - * Execute the rendering pipeline: clear, draw background, map, entities, and effects. - * @param deltaTime - Time elapsed since last frame in seconds - * @param _entities - Filtered active entities - */ - process(deltaTime: number, _entities: Entity[]): void { - this.engine.clear(); - - this.drawBackground(); - this.drawMap(); - - const allEntities = this.engine.entities; - allEntities.forEach((entity) => { - const health = entity.getComponent(ComponentType.HEALTH); - const evolution = entity.getComponent(ComponentType.EVOLUTION); - - if (!entity.active) { - const absorbable = entity.getComponent(ComponentType.ABSORBABLE); - if (health && health.isDead() && absorbable && !absorbable.absorbed) { - this.drawEntity(entity, deltaTime, true); - return; - } - return; - } - - if (health && health.isDead() && !evolution) { - const absorbable = entity.getComponent(ComponentType.ABSORBABLE); - if (!absorbable || absorbable.absorbed) { - return; - } - } - - this.drawEntity(entity, deltaTime); - }); - - this.drawSkillEffects(); - this.drawVFX(); - } - - /** - * Draw the cave background with dithered patterns. - */ - drawBackground(): void { - const ctx = this.ctx; - const width = this.engine.canvas.width; - const height = this.engine.canvas.height; - - ctx.fillStyle = Palette.VOID; - ctx.fillRect(0, 0, width, height); - - ctx.fillStyle = Palette.DARKER_BLUE; - for (let i = 0; i < 20; i++) { - const worldX = (i * 70 + Math.sin(i) * 30) % 2000; - const worldY = (i * 50 + Math.cos(i) * 40) % 1500; - const screen = this.worldToScreen(worldX, worldY); - const size = Math.floor(25 + (i % 4) * 15); - - if (screen.x + size > 0 && screen.x < width && screen.y + size > 0 && screen.y < height) { - ctx.fillRect(screen.x, screen.y, size, size); - } - } - } - - /** - * Draw the static tile-based map walls and highlights. - */ - drawMap(): void { - const tileMap = this.engine.tileMap; - if (!tileMap) return; - - const camera = this.getCamera(); - const ctx = this.ctx; - const tileSize = tileMap.tileSize; - - const viewportLeft = camera ? camera.x - camera.viewportWidth / 2 : 0; - const viewportRight = camera ? camera.x + camera.viewportWidth / 2 : this.engine.canvas.width; - const viewportTop = camera ? camera.y - camera.viewportHeight / 2 : 0; - const viewportBottom = camera - ? camera.y + camera.viewportHeight / 2 - : this.engine.canvas.height; - - const startCol = Math.max(0, Math.floor(viewportLeft / tileSize) - 1); - const endCol = Math.min(tileMap.cols, Math.ceil(viewportRight / tileSize) + 1); - const startRow = Math.max(0, Math.floor(viewportTop / tileSize) - 1); - const endRow = Math.min(tileMap.rows, Math.ceil(viewportBottom / tileSize) + 1); - - ctx.fillStyle = Palette.DARK_BLUE; - - for (let r = startRow; r < endRow; r++) { - for (let c = startCol; c < endCol; c++) { - if (tileMap.getTile(c, r) === 1) { - const worldX = c * tileSize; - const worldY = r * tileSize; - const screen = this.worldToScreen(worldX, worldY); - - ctx.fillRect(screen.x, screen.y, tileSize, tileSize); - - ctx.fillStyle = Palette.ROYAL_BLUE; - ctx.fillRect(screen.x, screen.y, tileSize, 2); - ctx.fillStyle = Palette.DARK_BLUE; - } - } - } - } - - /** - * Draw an individual entity, including its pixel-art sprite, health bar, and indicators. - * @param entity - The entity to render - * @param deltaTime - Time elapsed - * @param isDeadFade - Whether to apply death fade effect - */ - drawEntity(entity: Entity, deltaTime: number, isDeadFade = false): void { - const position = entity.getComponent(ComponentType.POSITION); - const sprite = entity.getComponent(ComponentType.SPRITE); - const health = entity.getComponent(ComponentType.HEALTH); - - if (!position || !sprite) return; - - this.ctx.save(); - - const screen = this.worldToScreen(position.x, position.y); - const drawX = Math.floor(screen.x); - const drawY = Math.floor(screen.y); - - let alpha = sprite.alpha; - if (isDeadFade && health && health.isDead()) { - const absorbable = entity.getComponent(ComponentType.ABSORBABLE); - if (absorbable && !absorbable.absorbed) { - const deathTime = entity.deathTime || Date.now(); - const timeSinceDeath = (Date.now() - deathTime) / 1000; - const fadeTime = 3.0; - alpha = Math.max(0.3, 1.0 - timeSinceDeath / fadeTime); - } - } - this.ctx.globalAlpha = alpha; - this.ctx.translate(drawX, drawY + (sprite.yOffset || 0)); - this.ctx.scale(sprite.scale, sprite.scale); - - const stealth = entity.getComponent(ComponentType.STEALTH); - let effectiveShape = sprite.shape; - if (stealth && stealth.isStealthed && stealth.formAppearance) { - effectiveShape = stealth.formAppearance; - } - - if (effectiveShape === EntityType.SLIME) { - sprite.animationTime += deltaTime; - sprite.morphAmount = Math.sin(sprite.animationTime * 3) * 0.2 + 0.8; - } - - let drawColor = sprite.color; - if (effectiveShape === EntityType.SLIME && (!stealth || !stealth.isStealthed)) { - drawColor = Palette.CYAN; - } - - this.ctx.fillStyle = drawColor; - - const velocity = entity.getComponent(ComponentType.VELOCITY); - if (velocity) { - const isMoving = Math.abs(velocity.vx) > 1 || Math.abs(velocity.vy) > 1; - sprite.animationState = isMoving ? AnimationState.WALK : AnimationState.IDLE; - } - - let spriteData = SpriteLibrary[effectiveShape as string]; - if (!spriteData) { - spriteData = SpriteLibrary[EntityType.SLIME]; - } - - let frames = spriteData[sprite.animationState as string] || spriteData[AnimationState.IDLE]; - - if (!frames || !Array.isArray(frames)) { - frames = SpriteLibrary[EntityType.SLIME][AnimationState.IDLE]; - } - - if (!health || !health.isDead()) { - sprite.animationTime += deltaTime; - } - const currentFrameIdx = - Math.floor(sprite.animationTime * sprite.animationSpeed) % frames.length; - const grid = frames[currentFrameIdx]; - - if (!grid || !grid.length) { - this.ctx.restore(); - return; - } - - const rows = grid.length; - const cols = grid[0].length; - - const pixelW = sprite.width / cols; - const pixelH = sprite.height / rows; - - const offsetX = -sprite.width / 2; - const offsetY = -sprite.height / 2; - - const isFlipped = Math.cos(position.rotation) < 0; - - this.ctx.save(); - if (isFlipped) { - this.ctx.scale(-1, 1); - } - - for (let r = 0; r < rows; r++) { - for (let c = 0; c < cols; c++) { - const value = grid[r][c]; - if (value === 0) continue; - - if (value === 1) { - this.ctx.fillStyle = drawColor; - } else if (value === 2) { - this.ctx.fillStyle = Palette.WHITE; - } else if (value === 3) { - this.ctx.fillStyle = Palette.DARKER_BLUE; - } - - this.ctx.fillRect( - offsetX + c * pixelW, - offsetY + r * pixelH, - Math.ceil(pixelW), - Math.ceil(pixelH) - ); - } - } - this.ctx.restore(); - - if (health && health.maxHp > 0 && !health.isProjectile) { - this.drawHealthBar(health, sprite); - } - - const combat = entity.getComponent(ComponentType.COMBAT); - if (combat && combat.isAttacking) { - this.ctx.save(); - this.ctx.rotate(position.rotation); - this.drawAttackIndicator(combat, entity); - this.ctx.restore(); - } - - if (stealth && stealth.isStealthed) { - this.drawStealthIndicator(stealth, sprite); - } - - const evolution = entity.getComponent(ComponentType.EVOLUTION); - if (evolution) { - if (evolution.mutationEffects.glowingBody) { - this.drawGlowEffect(sprite); - } - if (evolution.mutationEffects.electricSkin) { - if (Math.random() < 0.2) { - this.ctx.fillStyle = Palette.CYAN; - const sparkX = Math.floor((Math.random() - 0.5) * sprite.width); - const sparkY = Math.floor((Math.random() - 0.5) * sprite.height); - this.ctx.fillRect(sparkX, sparkY, 2, 2); - } - } - } - - this.ctx.restore(); - } - - /** - * Draw all active visual effects particles. - */ - drawVFX(): void { - const vfxSystem = this.engine.systems.find((s) => s.name === SystemName.VFX) as - | VFXSystem - | undefined; - if (!vfxSystem) return; - - const ctx = this.ctx; - const particles = vfxSystem.getParticles(); - - particles.forEach((p) => { - ctx.fillStyle = p.color; - ctx.globalAlpha = p.type === VFXType.IMPACT ? Math.min(1, p.lifetime / 0.3) : 0.8; - - const screen = this.worldToScreen(p.x, p.y); - const x = Math.floor(screen.x); - const y = Math.floor(screen.y); - const size = Math.floor(p.size); - - ctx.fillRect(x - size / 2, y - size / 2, size, size); - }); - - ctx.globalAlpha = 1.0; - } - - /** - * Draw a health bar above an entity. - */ - drawHealthBar(health: Health, sprite: Sprite): void { - const ctx = this.ctx; - const barWidth = Math.floor(sprite.width * 1.2); - const barHeight = 2; - const yOffset = Math.floor(sprite.height / 2 + 3); - - const startX = -Math.floor(barWidth / 2); - const startY = -yOffset; - - ctx.fillStyle = Palette.DARK_BLUE; - ctx.fillRect(startX, startY, barWidth, barHeight); - - const healthPercent = Math.max(0, health.hp / health.maxHp); - const fillWidth = Math.floor(barWidth * healthPercent); - - if (healthPercent > 0.5) { - ctx.fillStyle = Palette.CYAN; - } else if (healthPercent > 0.25) { - ctx.fillStyle = Palette.SKY_BLUE; - } else { - ctx.fillStyle = Math.floor(Date.now() / 200) % 2 === 0 ? Palette.WHITE : Palette.ROYAL_BLUE; - } - - ctx.fillRect(startX, startY, fillWidth, barHeight); - } - - /** - * Draw an animation indicating a melee attack. - */ - drawAttackIndicator(combat: Combat, entity: Entity): void { - const ctx = this.ctx; - const sprite = entity.getComponent(ComponentType.SPRITE); - if (!sprite) return; - - const t = 1.0 - combat.attackCooldown / 0.3; - const alpha = Math.sin(Math.PI * t); - const range = combat.attackRange; - - ctx.save(); - - const stealth = entity.getComponent(ComponentType.STEALTH); - let effectiveShape = sprite.shape; - if (stealth && stealth.isStealthed && stealth.formAppearance) { - effectiveShape = stealth.formAppearance; - } - - if (effectiveShape === EntityType.SLIME) { - ctx.strokeStyle = Palette.CYAN; - ctx.lineWidth = 3; - ctx.lineCap = 'round'; - ctx.globalAlpha = alpha; - - const length = range * Math.sin(Math.PI * t); - ctx.beginPath(); - ctx.moveTo(0, 0); - ctx.lineTo(length, 0); - ctx.stroke(); - - ctx.fillStyle = Palette.WHITE; - ctx.beginPath(); - ctx.arc(length, 0, 2, 0, Math.PI * 2); - ctx.fill(); - } else if (effectiveShape === EntityType.BEAST) { - ctx.strokeStyle = Palette.WHITE; - ctx.lineWidth = 2; - ctx.globalAlpha = alpha; - - const radius = range; - const angleRange = Math.PI * 0.6; - const start = -angleRange / 2 + t * angleRange; - - ctx.beginPath(); - ctx.arc(0, 0, radius, start - 0.5, start + 0.5); - ctx.stroke(); - } else if (effectiveShape === EntityType.HUMANOID) { - ctx.strokeStyle = `rgba(255, 255, 255, ${alpha})`; - ctx.lineWidth = 4; - - const radius = range; - const sweep = Math.PI * 0.8; - const startAngle = -sweep / 2; - - ctx.beginPath(); - ctx.arc(0, 0, radius, startAngle, startAngle + sweep * t); - ctx.stroke(); - } else { - ctx.fillStyle = `rgba(255, 255, 255, ${alpha})`; - const size = 15 * t; - ctx.beginPath(); - ctx.arc(10, 0, size, 0, Math.PI * 2); - ctx.fill(); - } - - ctx.restore(); - } - - /** - * Draw an indicator circle around a stealthed entity. - */ - drawStealthIndicator(stealth: Stealth, sprite: Sprite): void { - const ctx = this.ctx; - const radius = Math.max(sprite.width, sprite.height) / 2 + 5; - - ctx.strokeStyle = `rgba(0, 255, 150, ${1 - stealth.visibility})`; - ctx.lineWidth = 2; - ctx.beginPath(); - ctx.arc(0, 0, radius, 0, Math.PI * 2); - ctx.stroke(); - - if (stealth.visibility > 0.3) { - ctx.fillStyle = `rgba(255, 0, 0, ${(stealth.visibility - 0.3) * 2})`; - ctx.beginPath(); - ctx.arc(0, -radius - 10, 3, 0, Math.PI * 2); - ctx.fill(); - } - } - - /** - * Draw a glowing effect around an entity with bioluminescence. - */ - drawGlowEffect(sprite: Sprite): void { - const ctx = this.ctx; - const time = Date.now() * 0.001; - const pulse = 0.5 + Math.sin(time * 3) * 0.3; - const baseRadius = Math.max(sprite.width, sprite.height) / 2; - const glowRadius = baseRadius + 4 + pulse * 2; - - const gradient = ctx.createRadialGradient(0, 0, baseRadius, 0, 0, glowRadius); - gradient.addColorStop(0, `rgba(255, 255, 255, ${0.4 * pulse})`); - gradient.addColorStop(0.5, `rgba(0, 230, 255, ${0.3 * pulse})`); - gradient.addColorStop(1, 'rgba(0, 230, 255, 0)'); - - ctx.save(); - ctx.globalCompositeOperation = 'screen'; - - ctx.fillStyle = gradient; - ctx.beginPath(); - ctx.arc(0, 0, glowRadius, 0, Math.PI * 2); - ctx.fill(); - - const innerGradient = ctx.createRadialGradient(0, 0, 0, 0, 0, baseRadius * 0.6); - innerGradient.addColorStop(0, `rgba(255, 255, 255, ${0.6 * pulse})`); - innerGradient.addColorStop(1, 'rgba(0, 230, 255, 0)'); - ctx.fillStyle = innerGradient; - ctx.beginPath(); - ctx.arc(0, 0, baseRadius * 0.6, 0, Math.PI * 2); - ctx.fill(); - - ctx.restore(); - } - - /** - * Draw active skill effects (cones, impacts, etc.). - */ - drawSkillEffects(): void { - const skillEffectSystem = this.engine.systems.find( - (s) => s.name === SystemName.SKILL_EFFECT - ) as SkillEffectSystem | undefined; - if (!skillEffectSystem) return; - - const effects = skillEffectSystem.getEffects(); - const ctx = this.ctx; - - effects.forEach((effect) => { - ctx.save(); - - switch (effect.type) { - case 'fire_breath': - this.drawFireBreath(ctx, effect); - break; - case 'pounce': - this.drawPounce(ctx, effect); - break; - case 'pounce_impact': - this.drawPounceImpact(ctx, effect); - break; - } - - ctx.restore(); - }); - } - - /** - * Draw a fire breath cone effect. - */ - drawFireBreath(ctx: CanvasRenderingContext2D, effect: SkillEffect): void { - if (!effect.x || !effect.y || !effect.angle || !effect.range || !effect.coneAngle) return; - const progress = Math.min(1.0, effect.time / effect.lifetime); - const alpha = Math.max(0, 1.0 - progress); - - const screen = this.worldToScreen(effect.x, effect.y); - ctx.translate(screen.x, screen.y); - ctx.rotate(effect.angle); - - const gradient = ctx.createRadialGradient(0, 0, 0, 0, 0, effect.range); - gradient.addColorStop(0, `rgba(255, 100, 0, ${alpha * 0.8})`); - gradient.addColorStop(0.5, `rgba(255, 200, 0, ${alpha * 0.6})`); - gradient.addColorStop(1, `rgba(255, 50, 0, ${alpha * 0.3})`); - - ctx.fillStyle = gradient; - ctx.beginPath(); - ctx.moveTo(0, 0); - ctx.arc(0, 0, effect.range, -effect.coneAngle / 2, effect.coneAngle / 2); - ctx.closePath(); - ctx.fill(); - - for (let i = 0; i < 20; i++) { - const angle = (Math.random() - 0.5) * effect.coneAngle; - const dist = Math.random() * effect.range * progress; - const x = Math.cos(angle) * dist; - const y = Math.sin(angle) * dist; - const size = 3 + Math.random() * 5; - - ctx.fillStyle = `rgba(255, ${150 + Math.random() * 100}, 0, ${alpha})`; - ctx.beginPath(); - ctx.arc(x, y, size, 0, Math.PI * 2); - ctx.fill(); - } - } - - /** - * Draw a pounce dash effect with trails. - */ - drawPounce(ctx: CanvasRenderingContext2D, effect: SkillEffect): void { - if (!effect.startX || !effect.startY || !effect.angle) return; - const progress = Math.min(1.0, effect.time / effect.lifetime); - - let currentX: number, currentY: number; - if (effect.caster) { - const pos = effect.caster.getComponent(ComponentType.POSITION); - if (pos) { - currentX = pos.x; - currentY = pos.y; - } else { - return; - } - } else { - currentX = effect.startX + Math.cos(effect.angle) * (effect.speed || 400) * effect.time; - currentY = effect.startY + Math.sin(effect.angle) * (effect.speed || 400) * effect.time; - } - - const startScreen = this.worldToScreen(effect.startX, effect.startY); - const currentScreen = this.worldToScreen(currentX, currentY); - - ctx.globalAlpha = Math.max(0, 0.3 * (1 - progress)); - ctx.fillStyle = Palette.VOID; - ctx.beginPath(); - ctx.ellipse(startScreen.x, startScreen.y, 10, 5, 0, 0, Math.PI * 2); - ctx.fill(); - - const alpha = Math.max(0, 0.8 * (1.0 - progress)); - ctx.strokeStyle = `rgba(255, 200, 0, ${alpha})`; - ctx.lineWidth = 2; - ctx.lineCap = 'round'; - ctx.beginPath(); - ctx.moveTo(startScreen.x, startScreen.y); - ctx.lineTo(currentScreen.x, currentScreen.y); - ctx.stroke(); - - const ringSize = progress * 40; - ctx.strokeStyle = `rgba(255, 255, 255, ${0.4 * (1 - progress)})`; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.arc(startScreen.x, startScreen.y, ringSize, 0, Math.PI * 2); - ctx.stroke(); - } - - /** - * Draw an impact ring and particles from a pounce landing. - */ - drawPounceImpact(ctx: CanvasRenderingContext2D, effect: SkillEffect): void { - if (!effect.x || !effect.y) return; - const progress = Math.min(1.0, effect.time / effect.lifetime); - const alpha = Math.max(0, 1.0 - progress); - const size = Math.max(0, 30 * (1 - progress)); - - const screen = this.worldToScreen(effect.x, effect.y); - - if (size > 0 && alpha > 0) { - ctx.strokeStyle = `rgba(255, 200, 0, ${alpha})`; - ctx.lineWidth = 3; - ctx.beginPath(); - ctx.arc(screen.x, screen.y, size, 0, Math.PI * 2); - ctx.stroke(); - - for (let i = 0; i < 8; i++) { - const angle = (i / 8) * Math.PI * 2; - const dist = size * 0.7; - const x = screen.x + Math.cos(angle) * dist; - const y = screen.y + Math.sin(angle) * dist; - - ctx.fillStyle = `rgba(255, 150, 0, ${alpha})`; - ctx.beginPath(); - ctx.arc(x, y, 4, 0, Math.PI * 2); - ctx.fill(); - } - } - } -} diff --git a/src/systems/SkillEffectSystem.js b/src/systems/SkillEffectSystem.js new file mode 100644 index 0000000..181a384 --- /dev/null +++ b/src/systems/SkillEffectSystem.js @@ -0,0 +1,41 @@ +import { System } from '../core/System.js'; + +/** + * System to track and render skill effects (Fire Breath, Pounce, etc.) + */ +export class SkillEffectSystem extends System { + constructor() { + super('SkillEffectSystem'); + this.requiredComponents = []; // No required components + this.priority = 50; // Run after skills but before rendering + this.activeEffects = []; + } + + process(deltaTime, _entities) { + // Update all active effects + for (let i = this.activeEffects.length - 1; i >= 0; i--) { + const effect = this.activeEffects[i]; + effect.lifetime -= deltaTime; + effect.time += deltaTime; + + if (effect.lifetime <= 0) { + this.activeEffects.splice(i, 1); + } + } + } + + /** + * Add a skill effect + */ + addEffect(effect) { + this.activeEffects.push(effect); + } + + /** + * Get all active effects + */ + getEffects() { + return this.activeEffects; + } +} + diff --git a/src/systems/SkillEffectSystem.ts b/src/systems/SkillEffectSystem.ts deleted file mode 100644 index d303321..0000000 --- a/src/systems/SkillEffectSystem.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { System } from '../core/System.ts'; -import { SystemName } from '../core/Constants.ts'; -import type { Entity } from '../core/Entity.ts'; - -export interface SkillEffect { - lifetime: number; - time: number; - type?: string; - x?: number; - y?: number; - angle?: number; - range?: number; - coneAngle?: number; - caster?: Entity; - startX?: number; - startY?: number; - speed?: number; - onUpdate?: (deltaTime: number) => void; - onComplete?: () => void; -} - -/** - * System responsible for tracking and updating temporary active skill effects like fire breath or pounce trails. - */ -export class SkillEffectSystem extends System { - activeEffects: SkillEffect[]; - - constructor() { - super(SystemName.SKILL_EFFECT); - this.requiredComponents = []; - this.priority = 50; - this.activeEffects = []; - } - - /** - * Update all active effects, removing them when their lifetime expires. - * @param deltaTime - Time elapsed since last frame in seconds - * @param _entities - Filtered entities - */ - process(deltaTime: number, _entities: Entity[]): void { - for (let i = this.activeEffects.length - 1; i >= 0; i--) { - const effect = this.activeEffects[i]; - effect.lifetime -= deltaTime; - effect.time += deltaTime; - - if (effect.onUpdate) { - effect.onUpdate(deltaTime); - } - - if (effect.lifetime <= 0) { - if (effect.onComplete) { - effect.onComplete(); - } - this.activeEffects.splice(i, 1); - } - } - } - - /** - * Add a new visual skill effect to the system. - * @param effect - The effect data object - */ - addEffect(effect: SkillEffect): void { - this.activeEffects.push(effect); - } - - /** - * Get the list of currently active skill effects. - * @returns Array of active effects - */ - getEffects(): SkillEffect[] { - return this.activeEffects; - } -} diff --git a/src/systems/SkillSystem.js b/src/systems/SkillSystem.js new file mode 100644 index 0000000..bdf8598 --- /dev/null +++ b/src/systems/SkillSystem.js @@ -0,0 +1,53 @@ +import { System } from '../core/System.js'; +import { SkillRegistry } from '../skills/SkillRegistry.js'; + +export class SkillSystem extends System { + constructor() { + super('SkillSystem'); + this.requiredComponents = ['Skills']; + this.priority = 30; + } + + process(deltaTime, entities) { + const inputSystem = this.engine.systems.find(s => s.name === 'InputSystem'); + if (!inputSystem) return; + + entities.forEach(entity => { + const skills = entity.getComponent('Skills'); + if (!skills) return; + + // Update cooldowns + skills.updateCooldowns(deltaTime); + + // Check for skill activation (number keys 1-9) + for (let i = 1; i <= 9; i++) { + const key = i.toString(); + if (inputSystem.isKeyJustPressed(key)) { + const skillIndex = i - 1; + if (skillIndex < skills.activeSkills.length) { + const skillId = skills.activeSkills[skillIndex]; + if (!skills.isOnCooldown(skillId)) { + this.activateSkill(entity, skillId); + } + } + } + } + }); + } + + activateSkill(entity, skillId) { + const skill = SkillRegistry.get(skillId); + if (!skill) { + console.warn(`Skill not found: ${skillId}`); + return; + } + + if (skill.activate(entity, this.engine)) { + const skills = entity.getComponent('Skills'); + if (skills) { + skills.setCooldown(skillId, skill.cooldown); + } + } + } +} + diff --git a/src/systems/SkillSystem.ts b/src/systems/SkillSystem.ts deleted file mode 100644 index 107130c..0000000 --- a/src/systems/SkillSystem.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { System } from '../core/System.ts'; -import { SkillRegistry } from '../skills/SkillRegistry.ts'; -import { SystemName, ComponentType } from '../core/Constants.ts'; -import { Events } from '../core/EventBus.ts'; -import type { Entity } from '../core/Entity.ts'; -import type { Skills } from '../components/Skills.ts'; -import type { Intent } from '../components/Intent.ts'; - -/** - * System responsible for managing skill cooldowns and activating skills based on entity intent. - */ -export class SkillSystem extends System { - constructor() { - super(SystemName.SKILL); - this.requiredComponents = [ComponentType.SKILLS]; - this.priority = 30; - } - - /** - * Process all entities with skills, updating cooldowns and activating skills if intended. - * @param deltaTime - Time elapsed since last frame in seconds - * @param entities - Entities matching system requirements - */ - process(deltaTime: number, entities: Entity[]): void { - entities.forEach((entity) => { - const skills = entity.getComponent(ComponentType.SKILLS); - const intent = entity.getComponent(ComponentType.INTENT); - if (!skills) return; - - skills.updateCooldowns(deltaTime); - - if (intent && intent.action === 'skill_use') { - const skillId = intent.data.skillId; - if (skillId && !skills.isOnCooldown(skillId)) { - this.activateSkill(entity, skillId); - } - intent.clear(); - } - }); - } - - /** - * Activate a specific skill for an entity. - * @param entity - The entity performing the skill - * @param skillId - The ID of the skill to activate - */ - activateSkill(entity: Entity, skillId: string): void { - const skill = SkillRegistry.get(skillId); - if (!skill) { - console.warn(`Skill not found: ${skillId}`); - return; - } - - if (skill.activate(entity, this.engine)) { - const skills = entity.getComponent(ComponentType.SKILLS); - if (skills) { - skills.setCooldown(skillId, skill.cooldown); - } - this.engine.emit(Events.SKILL_COOLDOWN_STARTED, { skillId }); - } - } -} diff --git a/src/systems/SoundEffectsSystem.ts b/src/systems/SoundEffectsSystem.ts deleted file mode 100644 index bd4c753..0000000 --- a/src/systems/SoundEffectsSystem.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { System } from '../core/System.ts'; -import { SystemName, ComponentType } from '../core/Constants.ts'; -import { Events } from '../core/EventBus.ts'; -import type { Entity } from '../core/Entity.ts'; -import type { Engine } from '../core/Engine.ts'; -import type { SoundEffects } from '../components/SoundEffects.ts'; -import type { MusicSystem } from './MusicSystem.ts'; - -/** - * System responsible for managing sound effects playback. - * Follows ECS pattern: system processes entities with SoundEffects component. - * Listens to game events and plays appropriate sound effects. - */ -export class SoundEffectsSystem extends System { - private sfxEntity: Entity | null; - - constructor() { - super(SystemName.SOUND_EFFECTS); - this.requiredComponents = [ComponentType.SOUND_EFFECTS]; - this.priority = 5; - this.sfxEntity = null; - } - - /** - * Initialize event listeners when system is added to engine. - */ - init(engine: Engine): void { - super.init(engine); - - this.engine.on(Events.ATTACK_PERFORMED, () => { - this.playSound('attack'); - }); - - this.engine.on(Events.DAMAGE_DEALT, () => { - this.playSound('damage'); - }); - - this.engine.on(Events.ABSORPTION, () => { - this.playSound('absorb'); - }); - - this.engine.on(Events.SKILL_LEARNED, () => { - this.playSound('skill'); - }); - - this.engine.on(Events.SKILL_COOLDOWN_STARTED, () => { - this.playSound('skill'); - }); - - this.engine.on(Events.PROJECTILE_CREATED, () => { - this.playSound('shoot'); - }); - - this.engine.on(Events.PROJECTILE_IMPACT, () => { - this.playSound('impact'); - }); - } - - /** - * Process sound effect entities - ensures audio context is available. - */ - process(_deltaTime: number, entities: Entity[]): void { - entities.forEach((entity) => { - const sfx = entity.getComponent(ComponentType.SOUND_EFFECTS); - if (!sfx) return; - - if (!this.sfxEntity) { - this.sfxEntity = entity; - } - - if (!sfx.audioContext) { - const musicSystem = this.engine.systems.find((s) => s.name === SystemName.MUSIC) as - | MusicSystem - | undefined; - if (musicSystem) { - sfx.audioContext = musicSystem.getAudioContext(); - } - } - }); - } - - /** - * Play a sound effect from any entity with SoundEffects component. - * @param soundName - The name of the sound to play - */ - playSound(soundName: string): void { - if (this.sfxEntity) { - const sfx = this.sfxEntity.getComponent(ComponentType.SOUND_EFFECTS); - if (sfx) { - sfx.play(soundName); - return; - } - } - - const entities = this.engine.getEntities(); - for (const entity of entities) { - const sfx = entity.getComponent(ComponentType.SOUND_EFFECTS); - if (sfx) { - this.sfxEntity = entity; - sfx.play(soundName); - break; - } - } - } -} diff --git a/src/systems/StealthSystem.js b/src/systems/StealthSystem.js new file mode 100644 index 0000000..9ea7ec9 --- /dev/null +++ b/src/systems/StealthSystem.js @@ -0,0 +1,74 @@ +import { System } from '../core/System.js'; + +export class StealthSystem extends System { + constructor() { + super('StealthSystem'); + this.requiredComponents = ['Stealth']; + this.priority = 12; + } + + process(deltaTime, entities) { + const inputSystem = this.engine.systems.find(s => s.name === 'InputSystem'); + const playerController = this.engine.systems.find(s => s.name === 'PlayerControllerSystem'); + const player = playerController ? playerController.getPlayerEntity() : null; + + entities.forEach(entity => { + const stealth = entity.getComponent('Stealth'); + const velocity = entity.getComponent('Velocity'); + const combat = entity.getComponent('Combat'); + const evolution = entity.getComponent('Evolution'); + + if (!stealth) return; + + // Determine stealth type based on evolution + if (evolution) { + const form = evolution.getDominantForm(); + stealth.stealthType = form; + } + + // Check if player wants to toggle stealth + if (entity === player && inputSystem) { + const shiftPress = inputSystem.isKeyJustPressed('shift'); + if (shiftPress) { + if (stealth.isStealthed) { + stealth.exitStealth(); + } else { + stealth.enterStealth(stealth.stealthType); + } + } + } + + // Update stealth based on movement and combat + const isMoving = velocity && (Math.abs(velocity.vx) > 1 || Math.abs(velocity.vy) > 1); + const isInCombat = combat && combat.isAttacking; + + stealth.updateStealth(isMoving, isInCombat); + + // Form-specific stealth bonuses + if (stealth.isStealthed) { + switch (stealth.stealthType) { + case 'slime': + // Slime can be very hidden when not moving + if (!isMoving) { + stealth.visibility = Math.max(0.05, stealth.visibility - deltaTime * 0.2); + } + break; + case 'beast': + // Beast stealth is better when moving slowly + if (isMoving && velocity) { + const speed = Math.sqrt(velocity.vx * velocity.vx + velocity.vy * velocity.vy); + if (speed < 50) { + stealth.visibility = Math.max(0.1, stealth.visibility - deltaTime * 0.1); + } + } + break; + case 'human': + // Human stealth is more consistent + stealth.visibility = Math.max(0.2, stealth.visibility - deltaTime * 0.05); + break; + } + } + }); + } +} + diff --git a/src/systems/StealthSystem.ts b/src/systems/StealthSystem.ts deleted file mode 100644 index 51a5e0b..0000000 --- a/src/systems/StealthSystem.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { System } from '../core/System.ts'; -import { SystemName, ComponentType, EntityType } from '../core/Constants.ts'; -import { ColorSampler } from '../core/ColorSampler.ts'; -import type { Entity } from '../core/Entity.ts'; -import type { Stealth } from '../components/Stealth.ts'; -import type { Velocity } from '../components/Velocity.ts'; -import type { Combat } from '../components/Combat.ts'; -import type { Evolution } from '../components/Evolution.ts'; -import type { Sprite } from '../components/Sprite.ts'; -import type { Position } from '../components/Position.ts'; -import type { InputSystem } from './InputSystem.ts'; -import type { PlayerControllerSystem } from './PlayerControllerSystem.ts'; - -/** - * System responsible for managing stealth mechanics, including visibility updates based on form, movement, and combat. - */ -export class StealthSystem extends System { - constructor() { - super(SystemName.STEALTH); - this.requiredComponents = [ComponentType.STEALTH]; - this.priority = 12; - } - - /** - * Update stealth state for entities, handling toggle input and form-specific visibility changes. - * @param deltaTime - Time elapsed since last frame in seconds - * @param entities - Entities matching active required components - */ - process(deltaTime: number, entities: Entity[]): void { - const inputSystem = this.engine.systems.find((s) => s.name === SystemName.INPUT) as - | InputSystem - | undefined; - const playerController = this.engine.systems.find( - (s) => s.name === SystemName.PLAYER_CONTROLLER - ) as PlayerControllerSystem | undefined; - const player = playerController ? playerController.getPlayerEntity() : null; - - entities.forEach((entity) => { - const stealth = entity.getComponent(ComponentType.STEALTH); - const velocity = entity.getComponent(ComponentType.VELOCITY); - const combat = entity.getComponent(ComponentType.COMBAT); - const evolution = entity.getComponent(ComponentType.EVOLUTION); - - if (!stealth) return; - - if (evolution) { - const form = evolution.getDominantForm(); - stealth.stealthType = form; - } - - const sprite = entity.getComponent(ComponentType.SPRITE); - const position = entity.getComponent(ComponentType.POSITION); - - if (entity === player && inputSystem) { - const shiftPress = inputSystem.isKeyJustPressed('shift'); - if (shiftPress) { - if (stealth.isStealthed) { - stealth.exitStealth(); - if (sprite && stealth.baseColor) { - sprite.color = stealth.baseColor; - } - } else { - if (sprite) { - stealth.enterStealth(stealth.stealthType, sprite.color); - } else { - stealth.enterStealth(stealth.stealthType); - } - } - } - } - - const isMoving = velocity && (Math.abs(velocity.vx) > 1 || Math.abs(velocity.vy) > 1); - const isInCombat = combat && combat.isAttacking; - - stealth.updateStealth(isMoving || false, isInCombat || false); - - if (stealth.isStealthed && sprite && position) { - switch (stealth.stealthType) { - case 'slime': { - if (!isMoving) { - stealth.visibility = Math.max(0.05, stealth.visibility - deltaTime * 0.2); - } - - const sampledColor = ColorSampler.sampleDominantColor( - this.engine.tileMap, - position.x, - position.y, - 30 - ); - - if (stealth.camouflageColor !== sampledColor) { - stealth.camouflageColor = sampledColor; - sprite.color = sampledColor; - } - - sprite.scale = stealth.sizeMultiplier; - break; - } - case 'beast': { - if (isMoving && velocity) { - const speed = Math.sqrt(velocity.vx * velocity.vx + velocity.vy * velocity.vy); - if (speed < 50) { - stealth.visibility = Math.max(0.1, stealth.visibility - deltaTime * 0.1); - } - } - - stealth.formAppearance = EntityType.BEAST; - sprite.scale = 1.0; - break; - } - case 'human': { - stealth.visibility = Math.max(0.2, stealth.visibility - deltaTime * 0.05); - stealth.formAppearance = EntityType.HUMANOID; - sprite.scale = 1.0; - break; - } - } - } else if (!stealth.isStealthed && sprite) { - sprite.scale = 1.0; - if (stealth.baseColor) { - sprite.color = stealth.baseColor; - } - } - }); - } -} diff --git a/src/systems/UISystem.js b/src/systems/UISystem.js new file mode 100644 index 0000000..c6ea2d4 --- /dev/null +++ b/src/systems/UISystem.js @@ -0,0 +1,311 @@ +import { System } from '../core/System.js'; +import { SkillRegistry } from '../skills/SkillRegistry.js'; +import { Events } from '../core/EventBus.js'; + +export class UISystem extends System { + constructor(engine) { + super('UISystem'); + this.requiredComponents = []; // No required components - renders UI + this.priority = 200; // Render after everything else + this.engine = engine; + this.ctx = engine.ctx; + + this.damageNumbers = []; + this.notifications = []; + + // Subscribe to events + engine.on(Events.DAMAGE_DEALT, (data) => this.addDamageNumber(data)); + engine.on(Events.MUTATION_GAINED, (data) => this.addNotification(`Mutation Gained: ${data.name}`)); + } + + addDamageNumber(data) { + this.damageNumbers.push({ + x: data.x, + y: data.y, + value: Math.floor(data.value), + color: data.color || '#ffffff', + lifetime: 1.0, + vy: -50 + }); + } + + addNotification(text) { + this.notifications.push({ + text, + lifetime: 3.0, + alpha: 1.0 + }); + } + + process(deltaTime, _entities) { + // Update damage numbers + this.updateDamageNumbers(deltaTime); + this.updateNotifications(deltaTime); + + const menuSystem = this.engine.systems.find(s => s.name === 'MenuSystem'); + const gameState = menuSystem ? menuSystem.getGameState() : 'playing'; + + // Only draw menu overlay if in start or paused state + if (gameState === 'start' || gameState === 'paused') { + if (menuSystem) { + menuSystem.drawMenu(); + } + // Don't draw game UI when menu is showing + return; + } + + const playerController = this.engine.systems.find(s => s.name === 'PlayerControllerSystem'); + const player = playerController ? playerController.getPlayerEntity() : null; + + if (!player) return; + + // Draw UI + this.drawHUD(player); + this.drawSkills(player); + this.drawStats(player); + this.drawSkillProgress(player); + this.drawDamageNumbers(); + this.drawNotifications(); + this.drawAbsorptionEffects(); + } + + drawHUD(player) { + const health = player.getComponent('Health'); + const stats = player.getComponent('Stats'); + const evolution = player.getComponent('Evolution'); + const skills = player.getComponent('Skills'); + + if (!health || !stats || !evolution) return; + + const ctx = this.ctx; + const _width = this.engine.canvas.width; + const _height = this.engine.canvas.height; + + // Health bar + const barWidth = 200; + const barHeight = 20; + const barX = 20; + const barY = 20; + + ctx.fillStyle = 'rgba(0, 0, 0, 0.7)'; + ctx.fillRect(barX, barY, barWidth, barHeight); + + const healthPercent = health.hp / health.maxHp; + ctx.fillStyle = healthPercent > 0.5 ? '#00ff00' : healthPercent > 0.25 ? '#ffff00' : '#ff0000'; + ctx.fillRect(barX, barY, barWidth * healthPercent, barHeight); + + ctx.strokeStyle = '#ffffff'; + ctx.lineWidth = 2; + ctx.strokeRect(barX, barY, barWidth, barHeight); + + ctx.fillStyle = '#ffffff'; + ctx.font = '14px Courier New'; + ctx.fillText(`HP: ${Math.ceil(health.hp)}/${health.maxHp}`, barX + 5, barY + 15); + + // Evolution display + const form = evolution.getDominantForm(); + const formY = barY + barHeight + 10; + ctx.fillStyle = '#ffffff'; + ctx.font = '12px Courier New'; + ctx.fillText(`Form: ${form.toUpperCase()}`, barX, formY); + ctx.fillText(`Human: ${evolution.human.toFixed(1)} | Beast: ${evolution.beast.toFixed(1)} | Slime: ${evolution.slime.toFixed(1)}`, barX, formY + 15); + + // Instructions + const instructionsY = formY + 40; + ctx.fillStyle = 'rgba(255, 255, 255, 0.7)'; + ctx.font = '11px Courier New'; + ctx.fillText('WASD: Move | Mouse: Aim | Click/Space: Attack', barX, instructionsY); + ctx.fillText('Shift: Stealth | 1-9: Skills (Press 1 for Slime Gun)', barX, instructionsY + 15); + + // Show skill hint if player has skills + if (skills && skills.activeSkills.length > 0) { + ctx.fillStyle = '#00ff96'; + ctx.fillText(`You have ${skills.activeSkills.length} skill(s)! Press 1-${skills.activeSkills.length} to use them.`, barX, instructionsY + 30); + } else { + ctx.fillStyle = '#ffaa00'; + ctx.fillText('Defeat and absorb creatures 5 times to learn their skills!', barX, instructionsY + 30); + } + + // Health regeneration hint + ctx.fillStyle = '#00aaff'; + ctx.fillText('Health regenerates when not in combat', barX, instructionsY + 45); + } + + drawSkills(player) { + const skills = player.getComponent('Skills'); + if (!skills) return; + + const ctx = this.ctx; + const width = this.engine.canvas.width; + const startX = width - 250; + const startY = 20; + + ctx.fillStyle = 'rgba(0, 0, 0, 0.7)'; + ctx.fillRect(startX, startY, 230, 30 + skills.activeSkills.length * 30); + + ctx.fillStyle = '#ffffff'; + ctx.font = '14px Courier New'; + ctx.fillText('Skills:', startX + 10, startY + 20); + + skills.activeSkills.forEach((skillId, index) => { + const y = startY + 40 + index * 30; + const key = (index + 1).toString(); + const onCooldown = skills.isOnCooldown(skillId); + const cooldown = skills.getCooldown(skillId); + + // Get skill name from registry for display + const skill = SkillRegistry.get(skillId); + const skillName = skill ? skill.name : skillId.replace('_', ' '); + + ctx.fillStyle = onCooldown ? '#888888' : '#00ff96'; + ctx.fillText(`${key}. ${skillName}${onCooldown ? ` (${cooldown.toFixed(1)}s)` : ''}`, startX + 10, y); + }); + } + + drawStats(player) { + const stats = player.getComponent('Stats'); + if (!stats) return; + + const ctx = this.ctx; + const width = this.engine.canvas.width; + const startX = width - 250; + const startY = 200; + + ctx.fillStyle = 'rgba(0, 0, 0, 0.7)'; + ctx.fillRect(startX, startY, 230, 150); + + ctx.fillStyle = '#ffffff'; + ctx.font = '12px Courier New'; + let y = startY + 20; + ctx.fillText('Stats:', startX + 10, y); + y += 20; + ctx.fillText(`STR: ${stats.strength}`, startX + 10, y); + y += 15; + ctx.fillText(`AGI: ${stats.agility}`, startX + 10, y); + y += 15; + ctx.fillText(`INT: ${stats.intelligence}`, startX + 10, y); + y += 15; + ctx.fillText(`CON: ${stats.constitution}`, startX + 10, y); + y += 15; + ctx.fillText(`PER: ${stats.perception}`, startX + 10, y); + } + + drawSkillProgress(player) { + const skillProgress = player.getComponent('SkillProgress'); + if (!skillProgress) return; + + const ctx = this.ctx; + const width = this.engine.canvas.width; + const startX = width - 250; + const startY = 360; + + const progress = skillProgress.getAllProgress(); + if (progress.size === 0) return; + + ctx.fillStyle = 'rgba(0, 0, 0, 0.7)'; + ctx.fillRect(startX, startY, 230, 30 + progress.size * 25); + + ctx.fillStyle = '#ffffff'; + ctx.font = '12px Courier New'; + ctx.fillText('Skill Progress:', startX + 10, startY + 20); + + let y = startY + 35; + progress.forEach((count, skillId) => { + const required = skillProgress.requiredAbsorptions; + const _percent = Math.min(100, (count / required) * 100); + const skill = SkillRegistry.get(skillId); + const skillName = skill ? skill.name : skillId.replace('_', ' '); + + ctx.fillStyle = count >= required ? '#00ff00' : '#ffff00'; + ctx.fillText(`${skillName}: ${count}/${required}`, startX + 10, y); + y += 20; + }); + } + + updateDamageNumbers(deltaTime) { + for (let i = this.damageNumbers.length - 1; i >= 0; i--) { + const num = this.damageNumbers[i]; + num.lifetime -= deltaTime; + num.y += num.vy * deltaTime; + num.vy *= 0.95; + if (num.lifetime <= 0) this.damageNumbers.splice(i, 1); + } + } + + updateNotifications(deltaTime) { + for (let i = this.notifications.length - 1; i >= 0; i--) { + const note = this.notifications[i]; + note.lifetime -= deltaTime; + if (note.lifetime < 0.5) note.alpha = note.lifetime * 2; + if (note.lifetime <= 0) this.notifications.splice(i, 1); + } + } + + drawDamageNumbers() { + const ctx = this.ctx; + this.damageNumbers.forEach(num => { + const alpha = Math.min(1, num.lifetime); + const size = 14 + Math.min(num.value / 2, 10); + + ctx.font = `bold ${size}px Courier New`; + // Shadow + ctx.fillStyle = `rgba(0, 0, 0, ${alpha * 0.5})`; + ctx.fillText(num.value.toString(), num.x + 2, num.y + 2); + + // Main text + ctx.fillStyle = num.color.startsWith('rgba') ? num.color : `rgba(${this.hexToRgb(num.color)}, ${alpha})`; + ctx.fillText(num.value.toString(), num.x, num.y); + }); + } + + hexToRgb(hex) { + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + return `${r}, ${g}, ${b}`; + } + + drawNotifications() { + const ctx = this.ctx; + const width = this.engine.canvas.width; + + this.notifications.forEach((note, index) => { + ctx.fillStyle = `rgba(255, 255, 0, ${note.alpha})`; + ctx.font = 'bold 20px Courier New'; + ctx.textAlign = 'center'; + ctx.fillText(note.text, width / 2, 100 + index * 30); + ctx.textAlign = 'left'; + }); + } + + drawAbsorptionEffects() { + const absorptionSystem = this.engine.systems.find(s => s.name === 'AbsorptionSystem'); + if (!absorptionSystem || !absorptionSystem.getEffects) return; + + const effects = absorptionSystem.getEffects(); + const ctx = this.ctx; + + effects.forEach(effect => { + const alpha = Math.min(1, effect.lifetime * 2); + + // Glow effect + ctx.shadowBlur = 10; + ctx.shadowColor = effect.color; + ctx.fillStyle = effect.color; + ctx.globalAlpha = alpha; + ctx.beginPath(); + ctx.arc(effect.x, effect.y, effect.size, 0, Math.PI * 2); + ctx.fill(); + + // Inner bright core + ctx.fillStyle = 'rgba(255, 255, 255, 0.8)'; + ctx.beginPath(); + ctx.arc(effect.x, effect.y, effect.size * 0.5, 0, Math.PI * 2); + ctx.fill(); + + ctx.globalAlpha = 1.0; + ctx.shadowBlur = 0; + }); + } +} + diff --git a/src/systems/UISystem.ts b/src/systems/UISystem.ts deleted file mode 100644 index b97d5d3..0000000 --- a/src/systems/UISystem.ts +++ /dev/null @@ -1,344 +0,0 @@ -import { System } from '../core/System.ts'; -import { SkillRegistry } from '../skills/SkillRegistry.ts'; -import { Events, DamageDealtEvent, MutationGainedEvent } from '../core/EventBus.ts'; -import { PixelFont } from '../core/PixelFont.ts'; -import { Palette } from '../core/Palette.ts'; -import { GameState, ComponentType, SystemName } from '../core/Constants.ts'; -import type { Entity } from '../core/Entity.ts'; -import type { Engine } from '../core/Engine.ts'; -import type { Health } from '../components/Health.ts'; -import type { Stats } from '../components/Stats.ts'; -import type { Evolution } from '../components/Evolution.ts'; -import type { Skills } from '../components/Skills.ts'; -import type { SkillProgress } from '../components/SkillProgress.ts'; -import type { MenuSystem } from './MenuSystem.ts'; -import type { PlayerControllerSystem } from './PlayerControllerSystem.ts'; - -interface DamageNumber { - x: number; - y: number; - value: number; - color: string; - lifetime: number; - vy: number; -} - -interface Notification { - text: string; - lifetime: number; - alpha: number; -} - -/** - * System responsible for rendering all UI elements, including HUD, skill bars, notifications, and damage numbers. - */ -export class UISystem extends System { - ctx: CanvasRenderingContext2D; - damageNumbers: DamageNumber[]; - notifications: Notification[]; - - /** - * @param engine - The game engine instance - */ - constructor(engine: Engine) { - super(SystemName.UI); - this.requiredComponents = []; - this.priority = 200; - this.engine = engine; - this.ctx = engine.ctx; - - this.damageNumbers = []; - this.notifications = []; - - engine.on(Events.DAMAGE_DEALT, (data: unknown) => { - const damageData = data as DamageDealtEvent; - this.addDamageNumber(damageData); - }); - engine.on(Events.MUTATION_GAINED, (data: unknown) => { - const mutationData = data as MutationGainedEvent; - this.addNotification(`Mutation Gained: ${mutationData.name} `); - }); - } - - /** - * Add a floating damage number effect. - * @param data - Object containing x, y, value, and optional color - */ - addDamageNumber(data: DamageDealtEvent): void { - this.damageNumbers.push({ - x: data.x, - y: data.y, - value: Math.floor(data.value), - color: data.color || '#ffffff', - lifetime: 1.0, - vy: -50, - }); - } - - /** - * Add a screen notification message. - * @param text - The message text - */ - addNotification(text: string): void { - this.notifications.push({ - text, - lifetime: 3.0, - alpha: 1.0, - }); - } - - /** - * Update UI states and execute rendering of all UI components. - * @param deltaTime - Time elapsed since last frame in seconds - * @param _entities - Filtered entities - */ - process(deltaTime: number, _entities: Entity[]): void { - this.updateDamageNumbers(deltaTime); - this.updateNotifications(deltaTime); - - const menuSystem = this.engine.systems.find((s) => s.name === SystemName.MENU) as - | MenuSystem - | undefined; - const gameState = menuSystem ? menuSystem.getGameState() : GameState.PLAYING; - - if ( - gameState === GameState.START || - gameState === GameState.PAUSED || - gameState === GameState.GAME_OVER - ) { - if (menuSystem) { - menuSystem.drawMenu(); - } - return; - } - - const playerController = this.engine.systems.find( - (s) => s.name === SystemName.PLAYER_CONTROLLER - ) as PlayerControllerSystem | undefined; - const player = playerController ? playerController.getPlayerEntity() : null; - - if (!player) return; - - this.drawHUD(player); - this.drawSkills(player); - this.drawDamageNumbers(); - this.drawNotifications(); - } - - /** - * Draw the main player heads-up display (health, form). - */ - drawHUD(player: Entity): void { - const health = player.getComponent(ComponentType.HEALTH); - const stats = player.getComponent(ComponentType.STATS); - const evolution = player.getComponent(ComponentType.EVOLUTION); - - if (!health || !stats || !evolution) return; - - const ctx = this.ctx; - const barWidth = 64; - const barHeight = 6; - const barX = 4; - const barY = 4; - - ctx.fillStyle = Palette.DARK_BLUE; - ctx.fillRect(barX - 1, barY - 1, barWidth + 2, barHeight + 2); - - ctx.fillStyle = Palette.VOID; - ctx.fillRect(barX, barY, barWidth, barHeight); - - const healthPercent = health.hp / health.maxHp; - ctx.fillStyle = - healthPercent > 0.5 ? Palette.CYAN : healthPercent > 0.25 ? Palette.SKY_BLUE : Palette.WHITE; - ctx.fillRect(barX, barY, Math.floor(barWidth * healthPercent), barHeight); - - PixelFont.drawText( - ctx, - `${Math.ceil(health.hp)}/${health.maxHp}`, - barX, - barY + barHeight + 3, - Palette.WHITE, - 1 - ); - - const form = evolution.getDominantForm(); - const formY = barY + barHeight + 14; - PixelFont.drawText(ctx, form.toUpperCase(), barX, formY, Palette.SKY_BLUE, 1); - } - - /** - * Draw the skill slots and their active cooldowns. - */ - drawSkills(player: Entity): void { - const skills = player.getComponent(ComponentType.SKILLS); - if (!skills) return; - - const ctx = this.ctx; - const width = this.engine.canvas.width; - const startX = width - 80; - const startY = 4; - - PixelFont.drawText(ctx, 'SKILLS', startX, startY, Palette.WHITE, 1); - - skills.activeSkills.forEach((skillId, index) => { - const y = startY + 10 + index * 9; - const onCooldown = skills.isOnCooldown(skillId); - const cooldown = skills.getCooldown(skillId); - - const skill = SkillRegistry.get(skillId); - let skillName = skill ? skill.name : skillId.replace('_', ' '); - if (skillName.length > 10) skillName = skillName.substring(0, 10); - - const color = onCooldown ? Palette.ROYAL_BLUE : Palette.CYAN; - const text = `${index + 1} ${skillName}${onCooldown ? ` ${cooldown.toFixed(0)}` : ''}`; - PixelFont.drawText(ctx, text, startX, y, color, 1); - }); - } - - /** - * Draw detailed player statistics. - */ - drawStats(player: Entity, x: number, y: number): void { - const stats = player.getComponent(ComponentType.STATS); - const evolution = player.getComponent(ComponentType.EVOLUTION); - if (!stats || !evolution) return; - - const ctx = this.ctx; - PixelFont.drawText(ctx, 'STATISTICS', x, y, Palette.WHITE, 1); - PixelFont.drawText(ctx, `STR ${stats.strength}`, x, y + 10, Palette.ROYAL_BLUE, 1); - PixelFont.drawText(ctx, `AGI ${stats.agility}`, x, y + 20, Palette.ROYAL_BLUE, 1); - PixelFont.drawText(ctx, `INT ${stats.intelligence}`, x, y + 30, Palette.ROYAL_BLUE, 1); - PixelFont.drawText(ctx, `CON ${stats.constitution}`, x, y + 40, Palette.ROYAL_BLUE, 1); - - PixelFont.drawText(ctx, 'EVOLUTION', x, y + 60, Palette.WHITE, 1); - PixelFont.drawText( - ctx, - `HUMAN: ${Math.floor(evolution.human)}`, - x, - y + 70, - Palette.ROYAL_BLUE, - 1 - ); - PixelFont.drawText( - ctx, - `BEAST: ${Math.floor(evolution.beast)}`, - x, - y + 80, - Palette.ROYAL_BLUE, - 1 - ); - PixelFont.drawText( - ctx, - `SLIME: ${Math.floor(evolution.slime)}`, - x, - y + 90, - Palette.ROYAL_BLUE, - 1 - ); - } - - /** - * Draw the progress towards learning new skills from absorption. - */ - drawSkillProgress(player: Entity, x: number, y: number): void { - const skillProgress = player.getComponent(ComponentType.SKILL_PROGRESS); - if (!skillProgress) return; - - const ctx = this.ctx; - const progress = skillProgress.getAllProgress(); - - PixelFont.drawText(ctx, 'KNOWLEDGE', x, y, Palette.CYAN, 1); - - if (progress.size === 0) { - PixelFont.drawText(ctx, 'NONE YET', x, y + 10, Palette.DARK_BLUE, 1); - return; - } - - let idx = 0; - progress.forEach((count, skillId) => { - const required = skillProgress.requiredAbsorptions; - const skill = SkillRegistry.get(skillId); - let name = skill ? skill.name : skillId; - if (name.length > 10) name = name.substring(0, 10); - - const py = y + 10 + idx * 9; - PixelFont.drawText(ctx, `${name}: ${count}/${required}`, x, py, Palette.SKY_BLUE, 1); - idx++; - }); - } - - /** - * Draw the list of currently active mutations. - */ - drawMutations(player: Entity, x: number, y: number): void { - const evolution = player.getComponent(ComponentType.EVOLUTION); - if (!evolution) return; - - const ctx = this.ctx; - PixelFont.drawText(ctx, 'MUTATIONS', x, y, Palette.CYAN, 1); - - if (evolution.mutations.size === 0) { - PixelFont.drawText(ctx, 'NONE', x, y + 10, Palette.DARK_BLUE, 1); - return; - } - - let idx = 0; - evolution.mutations.forEach((mutation) => { - const py = y + 10 + idx * 9; - PixelFont.drawText(ctx, `> ${mutation.toUpperCase()}`, x, py, Palette.SKY_BLUE, 1); - idx++; - }); - } - - /** - * Update active damage numbers position and lifetimes. - */ - updateDamageNumbers(deltaTime: number): void { - for (let i = this.damageNumbers.length - 1; i >= 0; i--) { - const num = this.damageNumbers[i]; - num.lifetime -= deltaTime; - num.y += num.vy * deltaTime; - num.vy *= 0.95; - if (num.lifetime <= 0) this.damageNumbers.splice(i, 1); - } - } - - /** - * Update notification messages lifetimes and transparency. - */ - updateNotifications(deltaTime: number): void { - for (let i = this.notifications.length - 1; i >= 0; i--) { - const note = this.notifications[i]; - note.lifetime -= deltaTime; - if (note.lifetime < 0.5) note.alpha = note.lifetime * 2; - if (note.lifetime <= 0) this.notifications.splice(i, 1); - } - } - - /** - * Draw floating damage numbers on screen. - */ - drawDamageNumbers(): void { - const ctx = this.ctx; - this.damageNumbers.forEach((num) => { - const color = num.color.startsWith('rgba') ? num.color : Palette.WHITE; - PixelFont.drawText(ctx, num.value.toString(), Math.floor(num.x), Math.floor(num.y), color, 1); - }); - } - - /** - * Draw active notification messages at the center of the screen. - */ - drawNotifications(): void { - const ctx = this.ctx; - const width = this.engine.canvas.width; - - this.notifications.forEach((note, index) => { - const textWidth = PixelFont.getTextWidth(note.text, 1); - const x = Math.floor((width - textWidth) / 2); - const y = 40 + index * 10; - - PixelFont.drawText(ctx, note.text, x, y, Palette.WHITE, 1); - }); - } -} diff --git a/src/systems/VFXSystem.ts b/src/systems/VFXSystem.ts deleted file mode 100644 index 6ede34a..0000000 --- a/src/systems/VFXSystem.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { System } from '../core/System.ts'; -import { Palette } from '../core/Palette.ts'; -import { SystemName, ComponentType, VFXType } from '../core/Constants.ts'; -import type { Entity } from '../core/Entity.ts'; -import type { Position } from '../components/Position.ts'; -import type { PlayerControllerSystem } from './PlayerControllerSystem.ts'; - -interface Particle { - x: number; - y: number; - vx: number; - vy: number; - lifetime: number; - size: number; - color: string; - type: VFXType; -} - -/** - * System responsible for creating and updating visual effects particles. - */ -export class VFXSystem extends System { - particles: Particle[]; - - constructor() { - super(SystemName.VFX); - this.requiredComponents = []; - this.priority = 40; - this.particles = []; - } - - /** - * Update all active particles, applying movement, attraction, and drag. - * @param deltaTime - Time elapsed since last frame in seconds - * @param _entities - Filtered entities - */ - process(deltaTime: number, _entities: Entity[]): void { - const playerController = this.engine.systems.find( - (s) => s.name === SystemName.PLAYER_CONTROLLER - ) as PlayerControllerSystem | undefined; - const player = playerController ? playerController.getPlayerEntity() : null; - const playerPos = player ? player.getComponent(ComponentType.POSITION) : null; - - for (let i = this.particles.length - 1; i >= 0; i--) { - const p = this.particles[i]; - - p.lifetime -= deltaTime; - if (p.lifetime <= 0) { - this.particles.splice(i, 1); - continue; - } - - if (p.type === VFXType.ABSORPTION && playerPos) { - const dx = playerPos.x - p.x; - const dy = playerPos.y - p.y; - const dist = Math.sqrt(dx * dx + dy * dy); - - if (dist > 5) { - p.vx += (dx / dist) * 800 * deltaTime; - p.vy += (dy / dist) * 800 * deltaTime; - p.vx *= 0.95; - p.vy *= 0.95; - } else { - this.particles.splice(i, 1); - continue; - } - } else { - p.vx *= 0.9; - p.vy *= 0.9; - } - - p.x += p.vx * deltaTime; - p.y += p.vy * deltaTime; - } - } - - /** - * Create an impact particle explosion at a specific location. - * @param x - Horizontal coordinate - * @param y - Vertical coordinate - * @param color - The color of particles - * @param angle - The direction of the impact splash - */ - createImpact(x: number, y: number, color = Palette.WHITE, angle: number | null = null): void { - const count = 8; - for (let i = 0; i < count; i++) { - let vx: number, vy: number; - if (angle !== null) { - const spread = (Math.random() - 0.5) * 2; - const speed = 50 + Math.random() * 150; - vx = Math.cos(angle + spread) * speed; - vy = Math.sin(angle + spread) * speed; - } else { - vx = (Math.random() - 0.5) * 150; - vy = (Math.random() - 0.5) * 150; - } - - this.particles.push({ - x, - y, - vx, - vy, - lifetime: 0.2 + Math.random() * 0.3, - size: 1 + Math.random() * 2, - color: color, - type: VFXType.IMPACT, - }); - } - } - - /** - * Create absorption particles that fly towards the player. - * @param x - Starting horizontal coordinate - * @param y - Starting vertical coordinate - * @param color - The color of particles - */ - createAbsorption(x: number, y: number, color = Palette.CYAN): void { - for (let i = 0; i < 12; i++) { - this.particles.push({ - x, - y, - vx: (Math.random() - 0.5) * 100, - vy: (Math.random() - 0.5) * 100, - lifetime: 1.5, - size: 2, - color: color, - type: VFXType.ABSORPTION, - }); - } - } - - /** - * Get all currently active particles. - * @returns Array of particle data objects - */ - getParticles(): Particle[] { - return this.particles; - } -} diff --git a/src/world/World.js b/src/world/World.js new file mode 100644 index 0000000..9afa6e8 --- /dev/null +++ b/src/world/World.js @@ -0,0 +1,27 @@ +/** + * World manager - handles areas and world state + */ +export class World { + constructor() { + this.areas = []; + this.currentArea = null; + this.areas.push({ + id: 'cave', + name: 'Dark Cave', + type: 'cave', + spawnTypes: ['humanoid', 'beast', 'elemental'], + spawnRate: 0.5 + }); + + this.currentArea = this.areas[0]; + } + + getCurrentArea() { + return this.currentArea; + } + + getSpawnTypes() { + return this.currentArea ? this.currentArea.spawnTypes : ['beast']; + } +} + diff --git a/src/world/World.ts b/src/world/World.ts deleted file mode 100644 index 0119048..0000000 --- a/src/world/World.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * Area configuration structure - */ -export interface Area { - id: string; - name: string; - type: string; - spawnTypes: string[]; - spawnRate: number; -} - -/** - * World manager responsible for handling different map areas and overall world state. - */ -export class World { - areas: Area[]; - currentArea: Area | null; - - constructor() { - this.areas = []; - this.currentArea = null; - - this.areas.push({ - id: 'cave', - name: 'Dark Cave', - type: 'cave', - spawnTypes: ['humanoid', 'beast', 'elemental'], - spawnRate: 0.5, - }); - - this.currentArea = this.areas[0]; - } - - /** - * Get the current active area. - * @returns The current area configuration - */ - getCurrentArea(): Area | null { - return this.currentArea; - } - - /** - * Get the list of entity types permitted to spawn in the current area. - * @returns Array of entity type strings - */ - getSpawnTypes(): string[] { - return this.currentArea ? this.currentArea.spawnTypes : ['beast']; - } -} diff --git a/tsconfig.json b/tsconfig.json deleted file mode 100644 index b20352f..0000000 --- a/tsconfig.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "compilerOptions": { - // Target modern browsers - "target": "ES2020", - "module": "ES2020", - "lib": ["ES2020", "DOM"], - - // Module resolution - "moduleResolution": "bundler", - "resolveJsonModule": true, - "allowImportingTsExtensions": true, - - // Emit - "noEmit": true, // Vite handles compilation - "isolatedModules": true, - - // Type checking - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true, - "noImplicitReturns": true, - - // Interop - "esModuleInterop": true, - "skipLibCheck": true, - "allowSyntheticDefaultImports": true - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] -} diff --git a/vite.config.js b/vite.config.js index 3dfac46..5f09b7e 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,27 +1,25 @@ import { defineConfig } from 'vite'; export default defineConfig({ - build: { - minify: 'terser', - terserOptions: { - ecma: 2020, - compress: { - drop_console: true, - drop_debugger: true, - }, - mangle: { - toplevel: true, - properties: true, - }, - format: { - comments: false, - }, + build: { + minify: 'terser', + terserOptions: { + compress: { + drop_console: true, + drop_debugger: true, + }, + mangle: { + toplevel: true, + }, + format: { + comments: false, + }, + }, + rollupOptions: { + output: { + manualChunks: undefined, + }, + }, + sourcemap: false, }, - rollupOptions: { - output: { - manualChunks: undefined, - }, - }, - sourcemap: false, - }, });