diff --git a/.gitignore b/.gitignore index 15655b1..aed094d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +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/eslint.config.js b/eslint.config.js index 1462103..87f9419 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,26 +1,38 @@ -import js from "@eslint/js"; -import globals from "globals"; +import js from '@eslint/js'; +import globals from 'globals'; +import tseslint from 'typescript-eslint'; 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, + { + 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', + indent: ['error', 2], + '@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 366bda6..3fbeb35 100644 --- a/index.html +++ b/index.html @@ -51,7 +51,7 @@
- + \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 9cce95d..c187459 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,11 +7,19 @@ "": { "name": "slime-genesis-poc", "version": "0.1.0", + "hasInstallScript": true, "devDependencies": { "@eslint/js": "^9.39.2", + "@typescript-eslint/eslint-plugin": "^8.52.0", + "@typescript-eslint/parser": "^8.52.0", "eslint": "^9.39.2", "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", @@ -1480,6 +1847,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 +1906,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 +1972,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 +2021,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 +2084,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 +2113,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 +2188,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 +2264,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 +2344,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 +2383,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 +2503,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 +2555,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 +2591,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 +2660,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 +2696,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 +2770,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 +2876,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 +2915,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 +3065,72 @@ "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", + "peer": true, + "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..4c6fc04 100644 --- a/package.json +++ b/package.json @@ -7,14 +7,24 @@ "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", "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/src/GameConfig.js b/src/GameConfig.ts similarity index 59% rename from src/GameConfig.js rename to src/GameConfig.ts index b80c407..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: 30, // Scaled down + 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 845570b..0000000 --- a/src/components/AI.js +++ /dev/null @@ -1,53 +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 = 60; // Scaled for 320x240 - this.chaseRadius = 120; - this.fleeRadius = 40; - - // Behavior parameters - this.wanderSpeed = 20; // Slower wander - 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 018f578..0000000 --- a/src/components/Absorbable.js +++ /dev/null @@ -1,55 +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/Combat.js b/src/components/Combat.js deleted file mode 100644 index c518a89..0000000 --- a/src/components/Combat.js +++ /dev/null @@ -1,53 +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 = 15; // Melee range for pixel art - 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 83445db..0000000 --- a/src/components/Evolution.js +++ /dev/null @@ -1,106 +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 3e67352..0000000 --- a/src/components/Inventory.js +++ /dev/null @@ -1,72 +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/Position.js b/src/components/Position.js deleted file mode 100644 index 278e144..0000000 --- a/src/components/Position.js +++ /dev/null @@ -1,12 +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 5da00e8..0000000 --- a/src/components/SkillProgress.js +++ /dev/null @@ -1,46 +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 f50fc14..0000000 --- a/src/components/Skills.js +++ /dev/null @@ -1,70 +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/Sprite.js b/src/components/Sprite.js deleted file mode 100644 index 65306f0..0000000 --- a/src/components/Sprite.js +++ /dev/null @@ -1,21 +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.animationState = 'idle'; // 'idle', 'walk' - this.animationSpeed = 4; // frames per second - this.morphAmount = 0; // Legacy 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 2ece52a..0000000 --- a/src/components/Stats.js +++ /dev/null @@ -1,56 +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 43533c2..0000000 --- a/src/components/Stealth.js +++ /dev/null @@ -1,49 +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..40f6655 --- /dev/null +++ b/src/components/Stealth.ts @@ -0,0 +1,60 @@ +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; + + constructor() { + super(ComponentType.STEALTH); + this.visibility = 1.0; + this.stealthType = 'slime'; + this.isStealthed = false; + this.stealthLevel = 0; + this.detectionRadius = 100; + } + + /** + * Enter stealth mode. + * @param type - The type of stealth (e.g., 'slime', 'human') + */ + enterStealth(type: string): void { + this.stealthType = type; + this.isStealthed = true; + this.visibility = 0.3; + } + + /** + * Exit stealth mode and restore full visibility. + */ + exitStealth(): void { + this.isStealthed = false; + this.visibility = 1.0; + } + + /** + * 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 67a367a..0000000 --- a/src/components/Velocity.js +++ /dev/null @@ -1,12 +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/core/Component.js b/src/core/Component.js deleted file mode 100644 index 88ba0d7..0000000 --- a/src/core/Component.js +++ /dev/null @@ -1,15 +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..400c38e --- /dev/null +++ b/src/core/Constants.ts @@ -0,0 +1,82 @@ +/** + * 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', +} + +/** + * 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', +} diff --git a/src/core/Engine.js b/src/core/Engine.js deleted file mode 100644 index d5a68a3..0000000 --- a/src/core/Engine.js +++ /dev/null @@ -1,150 +0,0 @@ -import { System } from './System.js'; -import { Entity } from './Entity.js'; -import { EventBus } from './EventBus.js'; -import { LevelLoader } from './LevelLoader.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 internal resolution (low-res for pixel art) - this.canvas.width = 320; - this.canvas.height = 240; - - // Apply CSS for sharp pixel scaling - this.canvas.style.imageRendering = 'pixelated'; // Standard - // Fallbacks for other browsers if needed (mostly covered by modern standards, but good to be safe) - this.canvas.style.imageRendering = '-moz-crisp-edges'; - this.canvas.style.imageRendering = 'crisp-edges'; - this.ctx.imageSmoothingEnabled = false; - - // Game state - this.deltaTime = 0; - - // Initialize standard map (320x240 / 16px tiles = 20x15) - this.tileMap = LevelLoader.loadSimpleLevel(20, 15, 16); - } - - /** - * 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' || gameState === 'gameOver'; - - 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..2010c0e --- /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.loadSimpleLevel(20, 15, 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]; + + 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 e0edf56..0000000 --- a/src/core/Entity.js +++ /dev/null @@ -1,59 +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..dedd19a --- /dev/null +++ b/src/core/Entity.ts @@ -0,0 +1,83 @@ +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; + + // Optional dynamic properties for specific entity types + 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..7f4e935 --- /dev/null +++ b/src/core/EventBus.ts @@ -0,0 +1,101 @@ +/** + * 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', +} + +/** + * 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.js b/src/core/LevelLoader.js deleted file mode 100644 index 26fed56..0000000 --- a/src/core/LevelLoader.js +++ /dev/null @@ -1,22 +0,0 @@ -import { TileMap } from './TileMap.js'; - -export class LevelLoader { - static loadSimpleLevel(cols, rows, tileSize) { - const map = new TileMap(cols, rows, tileSize); - - // Create a box arena for testing - 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); // Wall - } else { - // Random obstacles - if (Math.random() < 0.1) { - map.setTile(c, r, 1); - } - } - } - } - return map; - } -} diff --git a/src/core/LevelLoader.ts b/src/core/LevelLoader.ts new file mode 100644 index 0000000..1b211e3 --- /dev/null +++ b/src/core/LevelLoader.ts @@ -0,0 +1,30 @@ +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; + } +} diff --git a/src/core/Palette.js b/src/core/Palette.js deleted file mode 100644 index 84aa67a..0000000 --- a/src/core/Palette.js +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Limited 7-color palette for the game - */ -export const Palette = { - WHITE: '#ffffff', // Highlights, UI Text - CYAN: '#0ce6f2', // Energy, Slime core - SKY_BLUE: '#0098db', // Water, Friendly elements - ROYAL_BLUE: '#1e579c', // Shadows, Depth - DARK_BLUE: '#203562', // Walls, Obstacles - DARKER_BLUE: '#252446', // Background details - VOID: '#201533', // Void, Deep Background - - /** - * Get all colors as an array - */ - getAll() { - return [ - this.WHITE, - this.CYAN, - this.SKY_BLUE, - this.ROYAL_BLUE, - this.DARK_BLUE, - this.DARKER_BLUE, - this.VOID - ]; - } -}; 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.js b/src/core/PixelFont.js deleted file mode 100644 index f8d5e88..0000000 --- a/src/core/PixelFont.js +++ /dev/null @@ -1,80 +0,0 @@ -/** - * Simple 5x7 Matrix Pixel Font - * Each character is a 5x7 bitmask - */ -const FONT_DATA = { - '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] -}; - -export const PixelFont = { - drawText(ctx, text, x, y, color = '#ffffff', scale = 1) { - ctx.save(); - ctx.fillStyle = color; - let cursorX = x; - - const chars = text.toUpperCase().split(''); - chars.forEach(char => { - const glyph = FONT_DATA[char] || FONT_DATA['?']; - 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; // 5 width + 1 spacing - }); - ctx.restore(); - }, - - getTextWidth(text, scale = 1) { - return text.length * 6 * scale; - } -}; diff --git a/src/core/PixelFont.ts b/src/core/PixelFont.ts new file mode 100644 index 0000000..ea0463a --- /dev/null +++ b/src/core/PixelFont.ts @@ -0,0 +1,105 @@ +/** + * Simple 5x7 Matrix Pixel Font data. + * Each character is represented by an array of 7 integers, where each integer is a 5-bit mask. + */ +const FONT_DATA: Record = { + 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[char] || FONT_DATA['?']; + 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.js b/src/core/SpriteLibrary.ts similarity index 68% rename from src/core/SpriteLibrary.js rename to src/core/SpriteLibrary.ts index 748b788..8b60945 100644 --- a/src/core/SpriteLibrary.js +++ b/src/core/SpriteLibrary.ts @@ -1,23 +1,41 @@ +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 = { - // 8x8 Slime - Bottom-heavy blob - slime: { - idle: [ +export const SpriteLibrary: Record = { + [EntityType.SLIME]: { + [AnimationState.IDLE]: [ [ [0, 0, 0, 0, 0, 0, 0, 0], - [0, 0, 1, 1, 1, 1, 0, 0], // Top + [0, 0, 1, 1, 1, 1, 0, 0], [0, 1, 1, 1, 1, 1, 1, 0], - [1, 1, 2, 1, 1, 2, 1, 1], // Highlights - [1, 1, 3, 1, 1, 3, 1, 1], // Eyes + [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] // Flat-ish base + [0, 1, 1, 1, 1, 1, 1, 0], ], [ [0, 0, 0, 0, 0, 0, 0, 0], @@ -27,10 +45,10 @@ export const SpriteLibrary = { [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] // Squashed base - ] + [1, 1, 1, 1, 1, 1, 1, 1], + ], ], - walk: [ + [AnimationState.WALK]: [ [ [0, 0, 1, 1, 1, 1, 0, 0], [0, 1, 1, 1, 1, 1, 1, 0], @@ -39,7 +57,7 @@ export const SpriteLibrary = { [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, 1, 1, 1, 1, 1, 1, 0], ], [ [0, 0, 0, 0, 0, 0, 0, 0], @@ -49,14 +67,13 @@ export const SpriteLibrary = { [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] - ] - ] + [1, 1, 1, 1, 1, 1, 1, 1], + ], + ], }, - // 8x8 Humanoid - Simple Walk Cycle - humanoid: { - idle: [ + [EntityType.HUMANOID]: { + [AnimationState.IDLE]: [ [ [0, 0, 0, 1, 1, 0, 0, 0], [0, 0, 2, 1, 1, 2, 0, 0], @@ -65,10 +82,10 @@ export const SpriteLibrary = { [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] - ] + [0, 0, 1, 0, 0, 1, 0, 0], + ], ], - walk: [ + [AnimationState.WALK]: [ [ [0, 0, 0, 1, 1, 0, 0, 0], [0, 0, 2, 1, 1, 2, 0, 0], @@ -77,7 +94,7 @@ export const SpriteLibrary = { [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, 1, 1, 0, 0, 0, 0, 0], ], [ [0, 0, 0, 1, 1, 0, 0, 0], @@ -87,14 +104,13 @@ export const SpriteLibrary = { [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] - ] - ] + [0, 0, 0, 0, 0, 1, 1, 0], + ], + ], }, - // 8x8 Beast - Bounding Cycle - beast: { - idle: [ + [EntityType.BEAST]: { + [AnimationState.IDLE]: [ [ [0, 0, 0, 0, 0, 0, 0, 0], [1, 0, 0, 0, 0, 0, 0, 1], @@ -103,10 +119,10 @@ export const SpriteLibrary = { [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] - ] + [0, 1, 0, 0, 0, 0, 1, 0], + ], ], - walk: [ + [AnimationState.WALK]: [ [ [1, 0, 0, 0, 0, 0, 0, 1], [0, 1, 1, 1, 1, 1, 1, 0], @@ -115,7 +131,7 @@ export const SpriteLibrary = { [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], ], [ [0, 0, 0, 0, 0, 0, 0, 0], @@ -125,14 +141,13 @@ export const SpriteLibrary = { [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] - ] - ] + [0, 0, 1, 0, 0, 1, 0, 0], + ], + ], }, - // 8x8 Elemental - Floating Pulse - elemental: { - idle: [ + [EntityType.ELEMENTAL]: { + [AnimationState.IDLE]: [ [ [0, 0, 2, 1, 1, 2, 0, 0], [0, 1, 1, 2, 2, 1, 1, 0], @@ -141,7 +156,7 @@ export const SpriteLibrary = { [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, 1, 1, 0, 0, 0], ], [ [0, 0, 0, 0, 0, 0, 0, 0], @@ -151,17 +166,17 @@ export const SpriteLibrary = { [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, 1, 1, 0, 0, 0], + ], + ], }, - projectile: { - idle: [ + [EntityType.PROJECTILE]: { + [AnimationState.IDLE]: [ [ [1, 1], - [1, 1] - ] - ] - } + [1, 1], + ], + ], + }, }; diff --git a/src/core/System.js b/src/core/System.js deleted file mode 100644 index 17429bd..0000000 --- a/src/core/System.js +++ /dev/null @@ -1,46 +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.js b/src/core/TileMap.js deleted file mode 100644 index 74d0ec2..0000000 --- a/src/core/TileMap.js +++ /dev/null @@ -1,34 +0,0 @@ -export class TileMap { - constructor(cols, rows, tileSize) { - this.cols = cols; - this.rows = rows; - this.tileSize = tileSize; - this.tiles = new Array(cols * rows).fill(0); - } - - setTile(col, row, value) { - if (this.isValid(col, row)) { - this.tiles[row * this.cols + col] = value; - } - } - - getTile(col, row) { - if (this.isValid(col, row)) { - return this.tiles[row * this.cols + col]; - } - return 1; // Treat out of bounds as solid wall - } - - isValid(col, row) { - return col >= 0 && col < this.cols && row >= 0 && row < this.rows; - } - - /** - * Check if a world position collides with a solid tile - */ - isSolid(x, y) { - const col = Math.floor(x / this.tileSize); - const row = Math.floor(y / this.tileSize); - return this.getTile(col, row) !== 0; - } -} 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 e320c62..0000000 --- a/src/items/Item.js +++ /dev/null @@ -1,14 +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 b09bced..0000000 --- a/src/items/ItemRegistry.js +++ /dev/null @@ -1,54 +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 8d2dd8c..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(160, 120)); // Center of 320x240 - player.addComponent(new Velocity(0, 0, 100)); // Slower speed for small resolution - player.addComponent(new Sprite('#00ff96', 14, 14, 'slime')); // 14x14 pixel sprite - 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, 50)); // Slower speed - - 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, 10, 10, type)); - creature.addComponent(new Health(15 + Math.random() * 10)); // Adjusted health for smaller enemies - 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 = 20 + Math.random() * 280; // Fit in 320 width - const y = 20 + Math.random() * 200; // Fit in 240 height - 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 = 20 + Math.random() * 280; - const y = 20 + Math.random() * 200; - 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..61cd138 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,159 @@ +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 { 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 { EntityType, ComponentType } from './core/Constants.ts'; +import type { Entity } from './core/Entity.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 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(); + player.addComponent(new Position(160, 120)); + 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()); + + 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; + } + + for (let i = 0; i < 8; i++) { + const x = 20 + Math.random() * 280; + const y = 20 + Math.random() * 200; + const types = [EntityType.HUMANOID, EntityType.BEAST, EntityType.ELEMENTAL]; + const type = types[Math.floor(Math.random() * types.length)]; + createCreature(engine, x, y, type); + } + + setInterval(() => { + const existingCreatures = engine + .getEntities() + .filter((e) => e.hasComponent(ComponentType.AI) && e !== player); + + if (existingCreatures.length < 10) { + const x = 20 + Math.random() * 280; + const y = 20 + Math.random() * 200; + const types = [EntityType.HUMANOID, EntityType.BEAST, EntityType.ELEMENTAL]; + const type = types[Math.floor(Math.random() * types.length)]; + createCreature(engine, x, y, type); + } + }, 5000); + + canvas.focus(); + engine.start(); + + interface WindowWithGame { + gameEngine?: Engine; + player?: Entity; + } + (window as WindowWithGame).gameEngine = engine; + (window as WindowWithGame).player = player; + + canvas.addEventListener('click', () => { + canvas.focus(); + }); +} diff --git a/src/skills/Skill.js b/src/skills/Skill.js deleted file mode 100644 index f0abfe4..0000000 --- a/src/skills/Skill.js +++ /dev/null @@ -1,33 +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 f0971fd..0000000 --- a/src/skills/skills/StealthMode.js +++ /dev/null @@ -1,36 +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 9826b6f..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 = 250; // Screen width approx - this.speed = 250; // Faster than player but readable - 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', 4, 4, 'projectile')); - - // 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..4572c5f --- /dev/null +++ b/src/skills/skills/WaterGun.ts @@ -0,0 +1,94 @@ +import { Skill } from '../Skill.ts'; +import { ComponentType, SystemName, EntityType } from '../../core/Constants.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; + + return true; + } +} diff --git a/src/systems/AISystem.js b/src/systems/AISystem.ts similarity index 52% rename from src/systems/AISystem.js rename to src/systems/AISystem.ts index 6e49588..a38b394 100644 --- a/src/systems/AISystem.js +++ b/src/systems/AISystem.ts @@ -1,77 +1,96 @@ -import { System } from '../core/System.js'; -import { GameConfig } from '../GameConfig.js'; +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('AISystem'); - this.requiredComponents = ['Position', 'Velocity', 'AI']; + super(SystemName.AI); + this.requiredComponents = [ComponentType.POSITION, ComponentType.VELOCITY, ComponentType.AI]; this.priority = 15; } - process(deltaTime, entities) { - const playerController = this.engine.systems.find(s => s.name === 'PlayerControllerSystem'); + /** + * 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 ? playerController.getPlayerEntity() : null; - const playerPos = player?.getComponent('Position'); + const playerPos = player?.getComponent(ComponentType.POSITION); const config = GameConfig.AI; - entities.forEach(entity => { - const health = entity.getComponent('Health'); - const ai = entity.getComponent('AI'); - const position = entity.getComponent('Position'); - const velocity = entity.getComponent('Velocity'); + 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; - // Stop movement for dead entities if (health && health.isDead() && !health.isProjectile) { velocity.vx = 0; velocity.vy = 0; 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 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); // Lose awareness over time + ai.updateAwareness(-deltaTime * config.awarenessLossRate); } - // Biological Reputation Logic - const playerEvolution = player?.getComponent('Evolution'); + const playerEvolution = player?.getComponent(ComponentType.EVOLUTION); const playerForm = playerEvolution ? playerEvolution.getDominantForm() : 'slime'; - const entityType = entity.getComponent('Sprite')?.color === '#ffaa00' ? 'beast' : - entity.getComponent('Sprite')?.color === '#ff5555' ? 'humanoid' : 'other'; + const sprite = entity.getComponent(ComponentType.SPRITE); + const entityType = + sprite?.color === '#ffaa00' + ? 'beast' + : 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'); + const playerStats = player?.getComponent(ComponentType.STATS); + const entityStats = entity.getComponent(ComponentType.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'; @@ -84,8 +103,7 @@ export class AISystem extends System { } } 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'); + const combat = entity.getComponent(ComponentType.COMBAT); if (combat && distance <= combat.attackRange) { ai.setBehavior('combat'); ai.state = 'combat'; @@ -102,8 +120,7 @@ export class AISystem extends System { ai.clearTarget(); } } else if (ai.behaviorType === 'chase') { - // Update from chase to combat if in range - const combat = entity.getComponent('Combat'); + const combat = entity.getComponent(ComponentType.COMBAT); if (combat && distance <= combat.attackRange) { ai.setBehavior('combat'); ai.state = 'combat'; @@ -111,28 +128,29 @@ export class AISystem extends System { } } - // 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; + 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) { + /** + * Execute wandering behavior, moving in a random direction. + */ + wander(_entity: Entity, ai: AI, velocity: Velocity, _deltaTime: number): void { ai.state = 'moving'; - // Change direction periodically if (ai.wanderChangeTime >= ai.wanderChangeInterval) { ai.wanderDirection = Math.random() * Math.PI * 2; ai.wanderChangeTime = 0; @@ -143,15 +161,23 @@ export class AISystem extends System { velocity.vy = Math.sin(ai.wanderDirection) * ai.wanderSpeed; } - chase(entity, ai, velocity, position, targetPos) { + /** + * 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); - // Check if we should switch to combat - const combat = entity.getComponent('Combat'); + const combat = entity.getComponent(ComponentType.COMBAT); if (combat && distance <= combat.attackRange) { ai.setBehavior('combat'); ai.state = 'combat'; @@ -169,7 +195,16 @@ export class AISystem extends System { } } - flee(entity, ai, velocity, position, targetPos) { + /** + * 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'; @@ -184,29 +219,37 @@ export class AISystem extends System { } } - combat(entity, ai, velocity, position, targetPos) { + /** + * 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'; - // 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'); + const combat = entity.getComponent(ComponentType.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); + 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..fd57995 --- /dev/null +++ b/src/systems/AbsorptionSystem.ts @@ -0,0 +1,170 @@ +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); + } + } + } + + /** + * 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/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 1bffa95..0000000 --- a/src/systems/DeathSystem.js +++ /dev/null @@ -1,62 +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()) { - // Check if player died - const evolution = entity.getComponent('Evolution'); - if (evolution) { - const menuSystem = this.engine.systems.find(s => s.name === 'MenuSystem'); - if (menuSystem) { - menuSystem.showGameOver(); - } - return; - } - - // 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 c8d3d0e..0000000 --- a/src/systems/HealthRegenerationSystem.js +++ /dev/null @@ -1,28 +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.ts similarity index 58% rename from src/systems/InputSystem.js rename to src/systems/InputSystem.ts index b5b0d77..9c046b1 100644 --- a/src/systems/InputSystem.js +++ b/src/systems/InputSystem.ts @@ -1,36 +1,58 @@ -import { System } from '../core/System.js'; +import { System } from '../core/System.ts'; +import { SystemName } from '../core/Constants.ts'; +import type { Engine } from '../core/Engine.ts'; +import type { Entity } from '../core/Entity.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('InputSystem'); - this.requiredComponents = []; // No required components - handles input globally - this.priority = 0; // Run first + super(SystemName.INPUT); + this.requiredComponents = []; + this.priority = 0; this.keys = {}; - this.keysPrevious = {}; // Track previous frame key states + this.keysPrevious = {}; this.mouse = { x: 0, y: 0, buttons: {}, - buttonsPrevious: {} + buttonsPrevious: {}, }; } - init(engine) { + /** + * Initialize the system and set up event listeners. + * @param engine - The game engine instance + */ + init(engine: Engine): void { super.init(engine); this.setupEventListeners(); } - 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(); - // Store by key name this.keys[key] = true; this.keys[code] = true; - // Handle special keys if (key === ' ') { this.keys['space'] = true; } @@ -38,13 +60,11 @@ export class InputSystem extends System { 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(); } @@ -57,7 +77,6 @@ export class InputSystem extends System { this.keys[key] = false; this.keys[code] = false; - // Handle special keys if (key === ' ') { this.keys['space'] = false; } @@ -65,7 +84,6 @@ export class InputSystem extends System { 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; @@ -76,7 +94,6 @@ export class InputSystem extends System { if (this.engine && this.engine.canvas) { const canvas = this.engine.canvas; const rect = canvas.getBoundingClientRect(); - // Calculate scale factors between displayed size and internal resolution const scaleX = canvas.width / rect.width; const scaleY = canvas.height / rect.height; @@ -94,16 +111,17 @@ export class InputSystem extends System { }); } - 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 - } + /** + * 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 states - called at end of frame - */ - updatePreviousStates() { - // Deep copy current states to previous for next frame + * 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]; @@ -115,16 +133,20 @@ export class InputSystem extends System { } /** - * Check if a key is currently pressed - */ - isKeyPressed(key) { + * 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 just pressed (not held from previous frame) - */ - isKeyJustPressed(key) { + * 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; @@ -132,26 +154,30 @@ export class InputSystem extends System { } /** - * Get mouse position - */ - getMousePosition() { + * Get the current mouse position in world coordinates. + * @returns The mouse coordinates + */ + getMousePosition(): { x: number; y: number } { return { x: this.mouse.x, y: this.mouse.y }; } /** - * Check if mouse button is pressed - */ - isMouseButtonPressed(button = 0) { + * 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 mouse button was just pressed - */ - isMouseButtonJustPressed(button = 0) { + * 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 9ba7d57..0000000 --- a/src/systems/MenuSystem.js +++ /dev/null @@ -1,158 +0,0 @@ -import { System } from '../core/System.js'; -import { PixelFont } from '../core/PixelFont.js'; -import { Palette } from '../core/Palette.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(); - } else if (this.gameState === 'gameOver') { - this.restartGame(); - } - } - }); - } - - showGameOver() { - this.gameState = 'gameOver'; - this.paused = true; - } - - restartGame() { - window.location.reload(); // Simple and effective for this project - } - - 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; - - // Darker overlay matching palette - ctx.fillStyle = 'rgba(32, 21, 51, 0.8)'; // Semi-transparent VOID - ctx.fillRect(0, 0, width, height); - - if (this.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 === '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); - - // Draw Stats and Knowledge (Moved from HUD) - const player = this.engine.getEntities().find(e => e.hasComponent('Evolution')); - const uiSystem = this.engine.systems.find(s => s.name === 'UISystem'); - - if (player && uiSystem) { - // Draw Stats on the left - uiSystem.drawStats(player, 20, 80); - - // Draw Learning Progress on the right - uiSystem.drawSkillProgress(player, width - 110, 80); - } - } else if (this.gameState === 'gameOver') { - 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); - } - } - - 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 abca20c..0000000 --- a/src/systems/MovementSystem.js +++ /dev/null @@ -1,77 +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 with collision detection - const tileMap = this.engine.tileMap; - - // X Axis - const nextX = position.x + velocity.vx * deltaTime; - if (tileMap && tileMap.isSolid(nextX, position.y)) { - velocity.vx = 0; - } else { - position.x = nextX; - } - - // Y Axis - const nextY = position.y + velocity.vy * deltaTime; - if (tileMap && tileMap.isSolid(position.x, nextY)) { - velocity.vy = 0; - } else { - position.y = nextY; - } - - // 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..6d8b193 --- /dev/null +++ b/src/systems/MovementSystem.ts @@ -0,0 +1,98 @@ +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); + } + + 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/PlayerControllerSystem.js b/src/systems/PlayerControllerSystem.js deleted file mode 100644 index 7cd3a5d..0000000 --- a/src/systems/PlayerControllerSystem.js +++ /dev/null @@ -1,70 +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 = 100; // Scaled down for 320x240 - - 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..4c92f48 --- /dev/null +++ b/src/systems/ProjectileSystem.ts @@ -0,0 +1,107 @@ +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); + + 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 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 1cd8362..0000000 --- a/src/systems/RenderSystem.js +++ /dev/null @@ -1,488 +0,0 @@ -import { System } from '../core/System.js'; -import { Palette } from '../core/Palette.js'; -import { SpriteLibrary } from '../core/SpriteLibrary.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 map - this.drawMap(); - - // 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; - - // Solid background - ctx.fillStyle = Palette.VOID; - ctx.fillRect(0, 0, width, height); - - // Dithered pattern or simple shapes for cave features - ctx.fillStyle = Palette.DARKER_BLUE; - for (let i = 0; i < 20; i++) { - // Snap to grid for pixel art look - const x = Math.floor((i * 70 + Math.sin(i) * 30) % width); - const y = Math.floor((i * 50 + Math.cos(i) * 40) % height); - const size = Math.floor(25 + (i % 4) * 15); - - ctx.beginPath(); - // Draw as rectangles or pixelated circles? Let's use Rects for now to match the style better or keep arcs but accept anti-aliasing - // Use integer coordinates strictly. - // Pixel Art style: use small squares instead of circles - ctx.fillRect(x, y, size, size); - } - } - - drawMap() { - const tileMap = this.engine.tileMap; - if (!tileMap) return; - - const ctx = this.ctx; - const tileSize = tileMap.tileSize; - - // Draw walls - ctx.fillStyle = Palette.DARK_BLUE; - - for (let r = 0; r < tileMap.rows; r++) { - for (let c = 0; c < tileMap.cols; c++) { - if (tileMap.getTile(c, r) === 1) { // 1 is wall - ctx.fillRect(c * tileSize, r * tileSize, tileSize, tileSize); - - // Highlight top for 3D feel - ctx.fillStyle = Palette.ROYAL_BLUE; - ctx.fillRect(c * tileSize, r * tileSize, tileSize, 2); - ctx.fillStyle = Palette.DARK_BLUE; - } - } - } - } - - 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(); - - // Pixel snapping - const drawX = Math.floor(position.x); - const drawY = Math.floor(position.y); - - // 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(drawX, drawY); - // REMOVED GLOBAL ROTATION: 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; - } - - // Map legacy colors to new Palette if necessary - let drawColor = sprite.color; - if (sprite.shape === 'slime') drawColor = Palette.CYAN; - // Map other colors? For now keep them if they match, but we should enforce palette eventually. - // The previous code had specific hardcoded colors. - - this.ctx.fillStyle = drawColor; - - // Select appropriate animation state based on velocity - const velocity = entity.getComponent('Velocity'); - if (velocity) { - const isMoving = Math.abs(velocity.vx) > 1 || Math.abs(velocity.vy) > 1; - sprite.animationState = isMoving ? 'walk' : 'idle'; - } - - // Lookup animation data - let spriteData = SpriteLibrary[sprite.shape]; - if (!spriteData) { - spriteData = SpriteLibrary.slime; // Hard fallback - } - - // Get animation frames for the current state - let frames = spriteData[sprite.animationState] || spriteData['idle']; - - // If frames is still not an array (fallback for simple grids or missing states) - if (!frames || !Array.isArray(frames)) { - // If it's a 2D array (legacy/simple), wrap it - if (Array.isArray(spriteData) || Array.isArray(spriteData[0])) { - frames = [spriteData]; - } else if (spriteData.idle) { - frames = spriteData.idle; - } else { - frames = SpriteLibrary.slime.idle; - } - } - - // Update animation timing - 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; - - // Calculate pixel size to fit the defined sprite dimensions - const pixelW = sprite.width / cols; - const pixelH = sprite.height / rows; - - // Draw from center - const offsetX = -sprite.width / 2; - const offsetY = -sprite.height / 2; - - // Horizontal Flipping based on rotation (facing left/right) - 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; - - // Determine color - 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; - } - - // Draw pixel (snapped to nearest integer for crisp look) - this.ctx.fillRect( - offsetX + c * pixelW, - offsetY + r * pixelH, - Math.ceil(pixelW), - Math.ceil(pixelH) - ); - } - } - this.ctx.restore(); - - // Draw health bar if entity has health (stays horizontal) - if (health && health.maxHp > 0 && !health.isProjectile) { - this.drawHealthBar(health, sprite); - } - - // Draw combat indicator if attacking (This DOES rotate) - const combat = entity.getComponent('Combat'); - if (combat && combat.isAttacking) { - this.ctx.save(); - this.ctx.rotate(position.rotation); - this.drawAttackIndicator(combat, position); - this.ctx.restore(); - } - - // Draw stealth indicator - const stealth = entity.getComponent('Stealth'); - if (stealth && stealth.isStealthed) { - this.drawStealthIndicator(stealth, sprite); - } - - // Mutation Visual Effects - Simplified for pixel art - const evolution = entity.getComponent('Evolution'); - if (evolution) { - if (evolution.mutationEffects.glowingBody) { - // Simple outline (square) - this.ctx.strokeStyle = Palette.WHITE; - this.ctx.lineWidth = 1; - this.ctx.strokeRect(-sprite.width / 2 - 2, -sprite.height / 2 - 2, sprite.width + 4, sprite.height + 4); - } - if (evolution.mutationEffects.electricSkin) { - // Sparks - 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(); - } - - - - drawHealthBar(health, sprite) { - // Pixel art health bar - const ctx = this.ctx; - // Width relative to sprite, snapped to even number - 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; - - // Background (Dark Blue) - ctx.fillStyle = Palette.DARK_BLUE; - ctx.fillRect(startX, startY, barWidth, barHeight); - - // Fill - const healthPercent = Math.max(0, health.hp / health.maxHp); - const fillWidth = Math.floor(barWidth * healthPercent); - - // Color based on Health (Palette only) - // High: CYAN, Mid: SKY_BLUE, Low: WHITE (flashing) - if (healthPercent > 0.5) { - ctx.fillStyle = Palette.CYAN; - } else if (healthPercent > 0.25) { - ctx.fillStyle = Palette.SKY_BLUE; - } else { - // Flash white for low health - ctx.fillStyle = (Math.floor(Date.now() / 200) % 2 === 0) ? Palette.WHITE : Palette.ROYAL_BLUE; - } - - ctx.fillRect(startX, startY, fillWidth, barHeight); - } - - - drawAttackIndicator(combat, _position) { - const ctx = this.ctx; - const length = 25; // Scaled down - 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 = 4; // Scaled down - 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) * 3 * attackProgress; - const perpY = Math.cos(angle) * 3 * attackProgress; - ctx.quadraticCurveTo(midX + perpX, midY + perpY, tentacleEndX, tentacleEndY); - ctx.stroke(); - - // Draw impact point - if (attackProgress > 0.5) { - ctx.beginPath(); - ctx.arc(tentacleEndX, tentacleEndY, 3 * 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..d9656cd --- /dev/null +++ b/src/systems/RenderSystem.ts @@ -0,0 +1,591 @@ +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 { 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; + + /** + * @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; + } + + /** + * 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 x = Math.floor((i * 70 + Math.sin(i) * 30) % width); + const y = Math.floor((i * 50 + Math.cos(i) * 40) % height); + const size = Math.floor(25 + (i % 4) * 15); + + ctx.fillRect(x, y, size, size); + } + } + + /** + * Draw the static tile-based map walls and highlights. + */ + drawMap(): void { + const tileMap = this.engine.tileMap; + if (!tileMap) return; + + const ctx = this.ctx; + const tileSize = tileMap.tileSize; + + ctx.fillStyle = Palette.DARK_BLUE; + + for (let r = 0; r < tileMap.rows; r++) { + for (let c = 0; c < tileMap.cols; c++) { + if (tileMap.getTile(c, r) === 1) { + ctx.fillRect(c * tileSize, r * tileSize, tileSize, tileSize); + + ctx.fillStyle = Palette.ROYAL_BLUE; + ctx.fillRect(c * tileSize, r * tileSize, 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 drawX = Math.floor(position.x); + const drawY = Math.floor(position.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); + + if (sprite.shape === EntityType.SLIME) { + sprite.animationTime += deltaTime; + sprite.morphAmount = Math.sin(sprite.animationTime * 3) * 0.2 + 0.8; + } + + let drawColor = sprite.color; + if (sprite.shape === EntityType.SLIME) 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[sprite.shape as string]; + if (!spriteData) { + spriteData = SpriteLibrary[EntityType.SLIME]; + } + + let frames = spriteData[sprite.animationState as string] || spriteData[AnimationState.IDLE]; + + if (!frames || !Array.isArray(frames)) { + // Fallback to default slime animation if data structure is unexpected + 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(); + } + + const stealth = entity.getComponent(ComponentType.STEALTH); + 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 x = Math.floor(p.x); + const y = Math.floor(p.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(); + + if (sprite.shape === 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 (sprite.shape === 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 (sprite.shape === 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; // Time in seconds for pulsing + const pulse = 0.5 + Math.sin(time * 3) * 0.3; // Pulsing between 0.2 and 0.8 + const baseRadius = Math.max(sprite.width, sprite.height) / 2; + const glowRadius = baseRadius + 4 + pulse * 2; + + // Create radial gradient for soft glow + 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)'); + + // Draw multiple layers for a softer glow effect + ctx.save(); + ctx.globalCompositeOperation = 'screen'; + + // Outer glow layer + ctx.fillStyle = gradient; + ctx.beginPath(); + ctx.arc(0, 0, glowRadius, 0, Math.PI * 2); + ctx.fill(); + + // Inner bright core + 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); + + ctx.translate(effect.x, effect.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; + } + + ctx.globalAlpha = Math.max(0, 0.3 * (1 - progress)); + ctx.fillStyle = Palette.VOID; + ctx.beginPath(); + ctx.ellipse(effect.startX, effect.startY, 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(effect.startX, effect.startY); + ctx.lineTo(currentX, currentY); + ctx.stroke(); + + const ringSize = progress * 40; + ctx.strokeStyle = `rgba(255, 255, 255, ${0.4 * (1 - progress)})`; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.arc(effect.startX, effect.startY, 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)); + + if (size > 0 && alpha > 0) { + 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(); + + 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/SkillEffectSystem.js b/src/systems/SkillEffectSystem.js deleted file mode 100644 index c2565a7..0000000 --- a/src/systems/SkillEffectSystem.js +++ /dev/null @@ -1,42 +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..6df2a26 --- /dev/null +++ b/src/systems/SkillSystem.ts @@ -0,0 +1,60 @@ +import { System } from '../core/System.ts'; +import { SkillRegistry } from '../skills/SkillRegistry.ts'; +import { SystemName, ComponentType } from '../core/Constants.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); + } + } + } +} 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..8d91067 --- /dev/null +++ b/src/systems/StealthSystem.ts @@ -0,0 +1,86 @@ +import { System } from '../core/System.ts'; +import { SystemName, ComponentType } from '../core/Constants.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 { 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; + } + + if (entity === player && inputSystem) { + const shiftPress = inputSystem.isKeyJustPressed('shift'); + if (shiftPress) { + if (stealth.isStealthed) { + stealth.exitStealth(); + } 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) { + switch (stealth.stealthType) { + case 'slime': + if (!isMoving) { + stealth.visibility = Math.max(0.05, stealth.visibility - deltaTime * 0.2); + } + 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); + } + } + break; + case 'human': + stealth.visibility = Math.max(0.2, stealth.visibility - deltaTime * 0.05); + break; + } + } + }); + } +} diff --git a/src/systems/UISystem.js b/src/systems/UISystem.js deleted file mode 100644 index 134e334..0000000 --- a/src/systems/UISystem.js +++ /dev/null @@ -1,258 +0,0 @@ -import { System } from '../core/System.js'; -import { SkillRegistry } from '../skills/SkillRegistry.js'; -import { Events } from '../core/EventBus.js'; -import { PixelFont } from '../core/PixelFont.js'; -import { Palette } from '../core/Palette.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, paused, or gameOver state - if (gameState === 'start' || gameState === 'paused' || gameState === 'gameOver') { - 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); - // REMOVED drawStats and drawSkillProgress from active gameplay - this.drawDamageNumbers(); - this.drawNotifications(); - this.drawAbsorptionEffects(); - } - - drawHUD(player) { - const health = player.getComponent('Health'); - const stats = player.getComponent('Stats'); - const evolution = player.getComponent('Evolution'); - - if (!health || !stats || !evolution) return; - - const ctx = this.ctx; - - // Health bar - const barWidth = 64; - const barHeight = 6; - const barX = 4; - const barY = 4; - - // Outer border - ctx.fillStyle = Palette.DARK_BLUE; - ctx.fillRect(barX - 1, barY - 1, barWidth + 2, barHeight + 2); - - // Background - 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); - - // HP Text - PixelFont.drawText(ctx, `${Math.ceil(health.hp)}/${health.maxHp}`, barX, barY + barHeight + 3, Palette.WHITE, 1); - - // Evolution display - const form = evolution.getDominantForm(); - const formY = barY + barHeight + 14; - PixelFont.drawText(ctx, form.toUpperCase(), barX, formY, Palette.SKY_BLUE, 1); - } - - drawSkills(player) { - const skills = player.getComponent('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); - }); - } - - drawStats(player, x, y) { - const stats = player.getComponent('Stats'); - const evolution = player.getComponent('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); - } - - drawSkillProgress(player, x, y) { - const skillProgress = player.getComponent('SkillProgress'); - 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++; - }); - } - - 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 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); - }); - } - - 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) => { - 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); - }); - } - - 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 6caccf7..0000000 --- a/src/world/World.js +++ /dev/null @@ -1,28 +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"] +}