diff --git a/.forgejo/workflows/publish-and-deploy.yaml b/.forgejo/workflows/publish-and-deploy.yaml index 2527d3d..3e8a96b 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 "${{ secrets.REGISTRY_USERNAME }}" --password-stdin + echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login ${{ env.REGISTRY }} -u "${{ vars.REGISTRY_USERNAME }}" --password-stdin - name: Determine Version id: version diff --git a/.gitignore b/.gitignore index a9bf588..aed094d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,3 @@ dist/ .DS_Store *.log .vite/ - diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..cad663b --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,7 @@ +# 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 new file mode 100644 index 0000000..a2ca1b7 --- /dev/null +++ b/.lintstagedrc.json @@ -0,0 +1,4 @@ +{ + "*.{ts,tsx,js,jsx}": ["eslint --fix", "prettier --write"], + "*.{json,css,md}": ["prettier --write"] +} diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..3f2322c --- /dev/null +++ b/.prettierignore @@ -0,0 +1,5 @@ +node_modules +dist +*.min.js +package-lock.json + diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..59eb508 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,9 @@ +{ + "semi": true, + "trailingComma": "es5", + "singleQuote": true, + "printWidth": 100, + "tabWidth": 2, + "useTabs": false, + "arrowParens": "always" +} diff --git a/README.md b/README.md index 2eff69f..f6283c4 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,54 @@ # Slime Genesis - ECS RPG PoC -A complete proof of concept for **Slime Genesis: The Awakening of the Entity** built with vanilla JavaScript using an Entity Component System (ECS) architecture. +A complete proof of concept for **Slime Genesis: The Awakening of the Entity** built with TypeScript 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 @@ -27,6 +60,7 @@ 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 @@ -35,15 +69,19 @@ 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 @@ -58,6 +96,7 @@ 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 @@ -67,32 +106,65 @@ This architecture makes it easy to: ``` src/ -├── 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 +├── 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 ``` ## Gameplay Loop @@ -103,6 +175,7 @@ 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 @@ -110,13 +183,30 @@ 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 -- **No External Dependencies**: Pure vanilla JavaScript (except Vite for dev server) +- **TypeScript**: Full type safety and modern JavaScript features +- **Vite**: Fast development server and build tool - **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 6e8bf73..8f0916f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0 +0.5.0 diff --git a/eslint.config.js b/eslint.config.js index 1462103..4165506 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,26 +1,39 @@ -import js from "@eslint/js"; -import globals from "globals"; +import js from '@eslint/js'; +import globals from 'globals'; +import tseslint from 'typescript-eslint'; +import prettier from 'eslint-config-prettier'; export default [ - 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], - }, + 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: '^_', + }, + ], + '@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 21eb741..3fbeb35 100644 --- a/index.html +++ b/index.html @@ -1,31 +1,57 @@ + Slime Genesis - PoC +
- + - + \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 9cce95d..c895d29 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,17 @@ "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" } }, @@ -1080,6 +1088,263 @@ "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", @@ -1121,6 +1386,35 @@ "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", @@ -1162,6 +1456,19 @@ "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", @@ -1196,6 +1503,39 @@ "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", @@ -1216,6 +1556,13 @@ "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", @@ -1270,6 +1617,26 @@ "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", @@ -1386,6 +1753,22 @@ } } }, + "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", @@ -1480,6 +1863,13 @@ "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", @@ -1532,6 +1922,19 @@ "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", @@ -1585,6 +1988,19 @@ "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", @@ -1621,6 +2037,22 @@ "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", @@ -1668,6 +2100,22 @@ "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", @@ -1681,6 +2129,16 @@ "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", @@ -1746,6 +2204,59 @@ "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", @@ -1769,6 +2280,66 @@ "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", @@ -1789,6 +2360,19 @@ "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", @@ -1815,6 +2399,22 @@ "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", @@ -1919,6 +2519,19 @@ "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", @@ -1958,6 +2571,22 @@ "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", @@ -1978,6 +2607,30 @@ "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", @@ -2023,6 +2676,19 @@ "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", @@ -2046,6 +2712,49 @@ "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", @@ -2077,6 +2786,49 @@ "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", @@ -2140,6 +2892,32 @@ "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", @@ -2153,6 +2931,45 @@ "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", @@ -2264,6 +3081,71 @@ "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 36a494f..514ccca 100644 --- a/package.json +++ b/package.json @@ -7,14 +7,25 @@ "dev": "vite", "build": "vite build", "preview": "vite preview", - "lint": "eslint src", - "lint:fix": "eslint src --fix" + "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" }, "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 8295a7a..ace98e9 100644 --- a/portainer.yml +++ b/portainer.yml @@ -2,7 +2,7 @@ name: slime services: app: - image: git.jusemon.com/jusemon/slime:0.1.0 + image: git.jusemon.com/jusemon/slime:0.5.0 restart: unless-stopped networks: diff --git a/src/GameConfig.js b/src/GameConfig.ts similarity index 59% rename from src/GameConfig.js rename to src/GameConfig.ts index 4995f4f..0f9cc3f 100644 --- a/src/GameConfig.js +++ b/src/GameConfig.ts @@ -1,38 +1,41 @@ /** - * Centralized Game Configuration - * Thresholds, rates, and balancing constants + * Centralized Game Configuration containing 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: 80, + range: 30, healPercentMin: 0.1, healPercentMax: 0.2, skillAbsorptionChance: 0.3, mutationChance: 0.1, - removalDelay: 3.0, // Seconds after death + removalDelay: 3.0, }, + /** 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 deleted file mode 100644 index dbe67f1..0000000 --- a/src/components/AI.js +++ /dev/null @@ -1,52 +0,0 @@ -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 new file mode 100644 index 0000000..adc38b7 --- /dev/null +++ b/src/components/AI.ts @@ -0,0 +1,73 @@ +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 deleted file mode 100644 index ef4b6f0..0000000 --- a/src/components/Absorbable.js +++ /dev/null @@ -1,54 +0,0 @@ -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 new file mode 100644 index 0000000..ffc2fe3 --- /dev/null +++ b/src/components/Absorbable.ts @@ -0,0 +1,87 @@ +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 new file mode 100644 index 0000000..e8afa88 --- /dev/null +++ b/src/components/Camera.ts @@ -0,0 +1,58 @@ +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 deleted file mode 100644 index 516caa0..0000000 --- a/src/components/Combat.js +++ /dev/null @@ -1,52 +0,0 @@ -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 new file mode 100644 index 0000000..1020bb9 --- /dev/null +++ b/src/components/Combat.ts @@ -0,0 +1,87 @@ +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 deleted file mode 100644 index e3a9c63..0000000 --- a/src/components/Evolution.js +++ /dev/null @@ -1,105 +0,0 @@ -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 new file mode 100644 index 0000000..e4ea66a --- /dev/null +++ b/src/components/Evolution.ts @@ -0,0 +1,140 @@ +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 deleted file mode 100644 index 063f0d0..0000000 --- a/src/components/Health.js +++ /dev/null @@ -1,29 +0,0 @@ -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 new file mode 100644 index 0000000..8356a16 --- /dev/null +++ b/src/components/Health.ts @@ -0,0 +1,57 @@ +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 new file mode 100644 index 0000000..5b409ca --- /dev/null +++ b/src/components/Intent.ts @@ -0,0 +1,39 @@ +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 deleted file mode 100644 index 7b619d7..0000000 --- a/src/components/Inventory.js +++ /dev/null @@ -1,71 +0,0 @@ -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 new file mode 100644 index 0000000..45e5644 --- /dev/null +++ b/src/components/Inventory.ts @@ -0,0 +1,97 @@ +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 new file mode 100644 index 0000000..35b9621 --- /dev/null +++ b/src/components/Music.ts @@ -0,0 +1,234 @@ +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 deleted file mode 100644 index 5713b4b..0000000 --- a/src/components/Position.js +++ /dev/null @@ -1,11 +0,0 @@ -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 new file mode 100644 index 0000000..d39e84f --- /dev/null +++ b/src/components/Position.ts @@ -0,0 +1,23 @@ +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 deleted file mode 100644 index cb27042..0000000 --- a/src/components/SkillProgress.js +++ /dev/null @@ -1,45 +0,0 @@ -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 new file mode 100644 index 0000000..2ed2ca0 --- /dev/null +++ b/src/components/SkillProgress.ts @@ -0,0 +1,56 @@ +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 deleted file mode 100644 index 6c1869f..0000000 --- a/src/components/Skills.js +++ /dev/null @@ -1,69 +0,0 @@ -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 new file mode 100644 index 0000000..969dc78 --- /dev/null +++ b/src/components/Skills.ts @@ -0,0 +1,86 @@ +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 new file mode 100644 index 0000000..c605140 --- /dev/null +++ b/src/components/SoundEffects.ts @@ -0,0 +1,73 @@ +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 deleted file mode 100644 index 94ab18c..0000000 --- a/src/components/Sprite.js +++ /dev/null @@ -1,18 +0,0 @@ -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 new file mode 100644 index 0000000..43a7c22 --- /dev/null +++ b/src/components/Sprite.ts @@ -0,0 +1,43 @@ +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 deleted file mode 100644 index 9c22ee0..0000000 --- a/src/components/Stats.js +++ /dev/null @@ -1,55 +0,0 @@ -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 new file mode 100644 index 0000000..286fde0 --- /dev/null +++ b/src/components/Stats.ts @@ -0,0 +1,68 @@ +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 deleted file mode 100644 index f1de5ad..0000000 --- a/src/components/Stealth.js +++ /dev/null @@ -1,48 +0,0 @@ -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 new file mode 100644 index 0000000..490db21 --- /dev/null +++ b/src/components/Stealth.ts @@ -0,0 +1,80 @@ +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 deleted file mode 100644 index d6ebd46..0000000 --- a/src/components/Velocity.js +++ /dev/null @@ -1,11 +0,0 @@ -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 new file mode 100644 index 0000000..daf0207 --- /dev/null +++ b/src/components/Velocity.ts @@ -0,0 +1,26 @@ +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 new file mode 100644 index 0000000..e1798b2 --- /dev/null +++ b/src/config/MusicConfig.ts @@ -0,0 +1,156 @@ +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 new file mode 100644 index 0000000..0ade875 --- /dev/null +++ b/src/config/SFXConfig.ts @@ -0,0 +1,35 @@ +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 new file mode 100644 index 0000000..43081ab --- /dev/null +++ b/src/core/ColorSampler.ts @@ -0,0 +1,102 @@ +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 deleted file mode 100644 index b44eac9..0000000 --- a/src/core/Component.js +++ /dev/null @@ -1,14 +0,0 @@ -/** - * 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 new file mode 100644 index 0000000..0a45b2b --- /dev/null +++ b/src/core/Component.ts @@ -0,0 +1,25 @@ +/** + * 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 new file mode 100644 index 0000000..d64e312 --- /dev/null +++ b/src/core/Constants.ts @@ -0,0 +1,88 @@ +/** + * 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 deleted file mode 100644 index f9720cc..0000000 --- a/src/core/Engine.js +++ /dev/null @@ -1,139 +0,0 @@ -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 new file mode 100644 index 0000000..50b07f7 --- /dev/null +++ b/src/core/Engine.ts @@ -0,0 +1,171 @@ +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 deleted file mode 100644 index 0671938..0000000 --- a/src/core/Entity.js +++ /dev/null @@ -1,58 +0,0 @@ -/** - * 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 new file mode 100644 index 0000000..ae39174 --- /dev/null +++ b/src/core/Entity.ts @@ -0,0 +1,82 @@ +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 deleted file mode 100644 index 6950164..0000000 --- a/src/core/EventBus.js +++ /dev/null @@ -1,57 +0,0 @@ -/** - * 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 new file mode 100644 index 0000000..6ae2906 --- /dev/null +++ b/src/core/EventBus.ts @@ -0,0 +1,104 @@ +/** + * 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 new file mode 100644 index 0000000..fe11fef --- /dev/null +++ b/src/core/LevelLoader.ts @@ -0,0 +1,125 @@ +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 new file mode 100644 index 0000000..656fbf3 --- /dev/null +++ b/src/core/Music.ts @@ -0,0 +1,269 @@ +/** + * 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 new file mode 100644 index 0000000..0a619ca --- /dev/null +++ b/src/core/Palette.ts @@ -0,0 +1,35 @@ +/** + * 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 new file mode 100644 index 0000000..d4cd05c --- /dev/null +++ b/src/core/PixelFont.ts @@ -0,0 +1,107 @@ +/** + * 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 new file mode 100644 index 0000000..8b60945 --- /dev/null +++ b/src/core/SpriteLibrary.ts @@ -0,0 +1,182 @@ +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 deleted file mode 100644 index 6b18131..0000000 --- a/src/core/System.js +++ /dev/null @@ -1,45 +0,0 @@ -/** - * 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 new file mode 100644 index 0000000..6c1761a --- /dev/null +++ b/src/core/System.ts @@ -0,0 +1,64 @@ +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 new file mode 100644 index 0000000..7f96251 --- /dev/null +++ b/src/core/TileMap.ts @@ -0,0 +1,68 @@ +/** + * 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 deleted file mode 100644 index bbf314b..0000000 --- a/src/items/Item.js +++ /dev/null @@ -1,13 +0,0 @@ -/** - * 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 new file mode 100644 index 0000000..0cd2c7b --- /dev/null +++ b/src/items/Item.ts @@ -0,0 +1,46 @@ +/** + * 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 deleted file mode 100644 index 91e44cb..0000000 --- a/src/items/ItemRegistry.js +++ /dev/null @@ -1,53 +0,0 @@ -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 new file mode 100644 index 0000000..691a1af --- /dev/null +++ b/src/items/ItemRegistry.ts @@ -0,0 +1,78 @@ +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 deleted file mode 100644 index 7c6f278..0000000 --- a/src/main.js +++ /dev/null @@ -1,158 +0,0 @@ -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 new file mode 100644 index 0000000..dc16e33 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,236 @@ +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 deleted file mode 100644 index 2ed22ee..0000000 --- a/src/skills/Skill.js +++ /dev/null @@ -1,32 +0,0 @@ -/** - * 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 new file mode 100644 index 0000000..9df4c75 --- /dev/null +++ b/src/skills/Skill.ts @@ -0,0 +1,48 @@ +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 deleted file mode 100644 index 7dc7fe2..0000000 --- a/src/skills/SkillRegistry.js +++ /dev/null @@ -1,36 +0,0 @@ -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 new file mode 100644 index 0000000..ce951cb --- /dev/null +++ b/src/skills/SkillRegistry.ts @@ -0,0 +1,53 @@ +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 deleted file mode 100644 index e9f4603..0000000 --- a/src/skills/skills/FireBreath.js +++ /dev/null @@ -1,83 +0,0 @@ -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 new file mode 100644 index 0000000..894a854 --- /dev/null +++ b/src/skills/skills/FireBreath.ts @@ -0,0 +1,110 @@ +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 deleted file mode 100644 index a82d7d7..0000000 --- a/src/skills/skills/Pounce.js +++ /dev/null @@ -1,97 +0,0 @@ -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 new file mode 100644 index 0000000..163e682 --- /dev/null +++ b/src/skills/skills/Pounce.ts @@ -0,0 +1,161 @@ +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 deleted file mode 100644 index 368104b..0000000 --- a/src/skills/skills/StealthMode.js +++ /dev/null @@ -1,35 +0,0 @@ -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 new file mode 100644 index 0000000..09567b7 --- /dev/null +++ b/src/skills/skills/StealthMode.ts @@ -0,0 +1,49 @@ +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 deleted file mode 100644 index e5341e3..0000000 --- a/src/skills/skills/WaterGun.js +++ /dev/null @@ -1,84 +0,0 @@ -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 new file mode 100644 index 0000000..944c472 --- /dev/null +++ b/src/skills/skills/WaterGun.ts @@ -0,0 +1,101 @@ +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 deleted file mode 100644 index c156861..0000000 --- a/src/systems/AISystem.js +++ /dev/null @@ -1,205 +0,0 @@ -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 new file mode 100644 index 0000000..15a3744 --- /dev/null +++ b/src/systems/AISystem.ts @@ -0,0 +1,256 @@ +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 deleted file mode 100644 index 0bbcd97..0000000 --- a/src/systems/AbsorptionSystem.js +++ /dev/null @@ -1,176 +0,0 @@ -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 new file mode 100644 index 0000000..e7af650 --- /dev/null +++ b/src/systems/AbsorptionSystem.ts @@ -0,0 +1,172 @@ +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 new file mode 100644 index 0000000..316a3e9 --- /dev/null +++ b/src/systems/CameraSystem.ts @@ -0,0 +1,50 @@ +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 deleted file mode 100644 index 2334a89..0000000 --- a/src/systems/CombatSystem.js +++ /dev/null @@ -1,190 +0,0 @@ -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 new file mode 100644 index 0000000..fb037bc --- /dev/null +++ b/src/systems/CombatSystem.ts @@ -0,0 +1,182 @@ +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 deleted file mode 100644 index b5908b7..0000000 --- a/src/systems/DeathSystem.js +++ /dev/null @@ -1,56 +0,0 @@ -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 new file mode 100644 index 0000000..d7b512f --- /dev/null +++ b/src/systems/DeathSystem.ts @@ -0,0 +1,75 @@ +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 deleted file mode 100644 index 47468be..0000000 --- a/src/systems/HealthRegenerationSystem.js +++ /dev/null @@ -1,27 +0,0 @@ -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 new file mode 100644 index 0000000..f555a83 --- /dev/null +++ b/src/systems/HealthRegenerationSystem.ts @@ -0,0 +1,32 @@ +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 deleted file mode 100644 index 9a45fc6..0000000 --- a/src/systems/InputSystem.js +++ /dev/null @@ -1,153 +0,0 @@ -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 new file mode 100644 index 0000000..e9f069c --- /dev/null +++ b/src/systems/InputSystem.ts @@ -0,0 +1,200 @@ +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 deleted file mode 100644 index 4c44103..0000000 --- a/src/systems/MenuSystem.js +++ /dev/null @@ -1,110 +0,0 @@ -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 new file mode 100644 index 0000000..e918e71 --- /dev/null +++ b/src/systems/MenuSystem.ts @@ -0,0 +1,200 @@ +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 deleted file mode 100644 index b705139..0000000 --- a/src/systems/MovementSystem.js +++ /dev/null @@ -1,62 +0,0 @@ -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 new file mode 100644 index 0000000..6008ee8 --- /dev/null +++ b/src/systems/MovementSystem.ts @@ -0,0 +1,119 @@ +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 new file mode 100644 index 0000000..439d31c --- /dev/null +++ b/src/systems/MusicSystem.ts @@ -0,0 +1,76 @@ +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 deleted file mode 100644 index 397dfe0..0000000 --- a/src/systems/PlayerControllerSystem.js +++ /dev/null @@ -1,69 +0,0 @@ -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 new file mode 100644 index 0000000..41fdaf6 --- /dev/null +++ b/src/systems/PlayerControllerSystem.ts @@ -0,0 +1,113 @@ +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 deleted file mode 100644 index 6e4e9ee..0000000 --- a/src/systems/ProjectileSystem.js +++ /dev/null @@ -1,83 +0,0 @@ -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 new file mode 100644 index 0000000..44aa4bf --- /dev/null +++ b/src/systems/ProjectileSystem.ts @@ -0,0 +1,122 @@ +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 deleted file mode 100644 index dcedd95..0000000 --- a/src/systems/RenderSystem.js +++ /dev/null @@ -1,437 +0,0 @@ -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 new file mode 100644 index 0000000..e73820a --- /dev/null +++ b/src/systems/RenderSystem.ts @@ -0,0 +1,660 @@ +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 deleted file mode 100644 index 181a384..0000000 --- a/src/systems/SkillEffectSystem.js +++ /dev/null @@ -1,41 +0,0 @@ -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 new file mode 100644 index 0000000..d303321 --- /dev/null +++ b/src/systems/SkillEffectSystem.ts @@ -0,0 +1,74 @@ +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 deleted file mode 100644 index bdf8598..0000000 --- a/src/systems/SkillSystem.js +++ /dev/null @@ -1,53 +0,0 @@ -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 new file mode 100644 index 0000000..107130c --- /dev/null +++ b/src/systems/SkillSystem.ts @@ -0,0 +1,62 @@ +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 new file mode 100644 index 0000000..bd4c753 --- /dev/null +++ b/src/systems/SoundEffectsSystem.ts @@ -0,0 +1,105 @@ +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 deleted file mode 100644 index 9ea7ec9..0000000 --- a/src/systems/StealthSystem.js +++ /dev/null @@ -1,74 +0,0 @@ -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 new file mode 100644 index 0000000..51a5e0b --- /dev/null +++ b/src/systems/StealthSystem.ts @@ -0,0 +1,126 @@ +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 deleted file mode 100644 index c6ea2d4..0000000 --- a/src/systems/UISystem.js +++ /dev/null @@ -1,311 +0,0 @@ -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 new file mode 100644 index 0000000..b97d5d3 --- /dev/null +++ b/src/systems/UISystem.ts @@ -0,0 +1,344 @@ +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 new file mode 100644 index 0000000..6ede34a --- /dev/null +++ b/src/systems/VFXSystem.ts @@ -0,0 +1,139 @@ +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 deleted file mode 100644 index 9afa6e8..0000000 --- a/src/world/World.js +++ /dev/null @@ -1,27 +0,0 @@ -/** - * 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 new file mode 100644 index 0000000..0119048 --- /dev/null +++ b/src/world/World.ts @@ -0,0 +1,49 @@ +/** + * 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 new file mode 100644 index 0000000..b20352f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,31 @@ +{ + "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 5f09b7e..3dfac46 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,25 +1,27 @@ import { defineConfig } from 'vite'; export default defineConfig({ - build: { - minify: 'terser', - terserOptions: { - compress: { - drop_console: true, - drop_debugger: true, - }, - mangle: { - toplevel: true, - }, - format: { - comments: false, - }, - }, - rollupOptions: { - output: { - manualChunks: undefined, - }, - }, - sourcemap: false, + build: { + minify: 'terser', + terserOptions: { + ecma: 2020, + compress: { + drop_console: true, + drop_debugger: true, + }, + mangle: { + toplevel: true, + properties: true, + }, + format: { + comments: false, + }, }, + rollupOptions: { + output: { + manualChunks: undefined, + }, + }, + sourcemap: false, + }, });