commit 3710a28ae0442ac61ef7cbafa526bd9a83126012 Author: gpatruno Date: Fri Jun 5 15:00:58 2026 +0200 first diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..8c846fd --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,25 @@ +name: Build + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 25 + uses: actions/setup-java@v4 + with: + java-version: "25" + distribution: "temurin" + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Build with Gradle + run: ./gradlew build --no-daemon diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..729d4ea --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +### Build ### +build/ +.gradle/ +.kotlin/ +devserver/ + +### Hytale local config (chemin d'installation personnel) ### +hytale.properties + +### Database ### +*.db +*.db-journal + +### IDE ### +.idea/ +*.iml +*.ipr +*.iws +.vscode/ +.classpath +.project +.settings/ + +### OS ### +.DS_Store +Thumbs.db + +### Logs ### +*.log diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..8d3e2c6 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,41 @@ +# Contribution + +Merci de contribuer au serveur MMORPG Hytale. + +## Avant de commencer + +1. Lire [ARCHITECTURE.md](docs/ARCHITECTURE.md) et [ROADMAP.md](docs/ROADMAP.md) +2. Vérifier qu'aucune issue existante ne couvre déjà votre sujet + +## Branches + +- `main` — stable +- `feature/` — nouvelles fonctionnalités +- `fix/` — corrections + +## Pull requests + +- Une PR = un sujet cohérent +- `./gradlew build` doit passer +- Description en français ou anglais : **quoi** + **pourquoi** +- Référencer la phase roadmap (M0, M1, …) si applicable + +## Code + +- Respecter les frontières de packages (pas d'import `economy` → `quest` circulaire) +- Nouveaux services : implémenter `Service` et enregistrer dans `Bootstrap` +- Pas de secrets dans le dépôt (tokens, clés API) + +## Commits + +Messages clairs, de préférence en impératif : + +``` +feat(player): add experience gain on mob kill +fix(db): close connection on plugin reload +docs: update ROADMAP for M2 economy +``` + +## Questions + +Ouvrir une discussion GitHub ou contacter les mainteneurs du dépôt. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d965a99 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Disklexar + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..bac6026 --- /dev/null +++ b/README.md @@ -0,0 +1,153 @@ +# Hytale MMORPG + +Plugin serveur Hytale pour un MMORPG persistant : profils joueurs, progression et fondations modulaires pour l'économie, les quêtes et les guildes. + +## Prérequis + +- **JDK 25+** (Java 26 recommandé si JDK 25 indisponible) +- **Gradle** via le wrapper inclus (`./gradlew`) +- Serveur Hytale (binaire ou `./gradlew runServer` via [ScaffoldIt](https://scaffoldit.dev)) + +## Démarrage rapide + +```bash +# 1. Indiquer où Hytale est installé (launcher officiel) +cp hytale.properties.example hytale.properties +# Éditer hytale.home_path (voir exemples dans le fichier) + +# 2. Compiler puis lancer le serveur de dev (Java 25 forcé) +./start-server.sh +``` + +Si le serveur a déjà planté sans assets, réinitialisez le monde de dev : + +```bash +HYTALE_RESET_DEVSERVER=1 ./start-server.sh +``` + +Ou manuellement : + +```bash +# Compiler le plugin (JAR avec SQLite + Gson embarqués) +JAVA_HOME=/usr/lib/jvm/java-25-openjdk ./gradlew build + +# Lancer le serveur de développement +JAVA_HOME=/usr/lib/jvm/java-25-openjdk ./gradlew runServer +``` + +Le JAR produit se trouve dans `build/libs/hytale-mmorpg-0.1.0.jar` (nom selon version Gradle). + +### Déploiement manuel + +1. Copier le JAR dans le dossier `mods/` du serveur Hytale +2. Redémarrer le serveur +3. La configuration est générée dans le répertoire de données du plugin : `mods/com.disklexar_MMORPG/config.json` + +## Systèmes MMORPG + +| Système | Contenu | Effet | +|---------|---------|-------| +| **Classes** | Chevalier (`knight`) | Armes : Épée longue, Hache de bataille. 3 capacités (Charge/dash+étourdissement 3s, Toupie 3s, Cri de guerre : repousse + −20% résistance 20s, bonus si étourdi). | +| **Pouvoirs** (passifs, aucun par défaut) | Sprinteur (`sprinter`), Résilient (`resilient`) | +10% vitesse hors combat / +10% dégâts 5s après avoir été touché. | +| **Métiers** | Tueur de monstre (`monster_slayer`), Mineur (`miner`) | +1 money par monstre tué / +1 money par bloc cassé. | +| **Groupes** | Partage d'XP | Les membres reçoivent 40% de l'XP gagnée sur un kill. | +| **Races** | Humain (`human`, par défaut) | XP ×2. | + +> **Effets de combat** : dash, étourdissement, toupie, knockback et le buff de vitesse sont +> implémentés via les APIs serveur (`Velocity`, `TransformComponent`, `MovementManager`, +> `TargetUtil`, `DamageSystems`). Les valeurs (distances, forces, dégâts) sont des constantes à +> ajuster en jeu. + +## Commandes + +| Commande | Description | +|----------|-------------| +| `/mmorpg help` | Affiche l'aide | +| `/mmorpg profile` | Résumé court (niveau, XP) | +| `/mmorpg info` | Toutes vos informations (chat) | +| `/mmorpg menu` | Ouvre l'interface d'informations du joueur (page UI custom) | +| `/mmorpg class [classe]` | Gérer votre classe | +| `/mmorpg power [pouvoir]` | Gérer vos pouvoirs | +| `/mmorpg job [metier]` | Gérer vos métiers | +| `/mmorpg group [joueur]` | Gérer votre groupe | +| `/mmorpg ability <1\|2\|3>` | Utiliser une capacité de classe | +| Touches **Use Ability 1/2/3** | Même effet que la commande ci-dessus (si vous avez une classe) | + +### Capacités de classe (touches Ability 1/2/3) + +Les armes vanilla possèdent leurs propres compétences sur les slots Ability. Le plugin les **remplace** +dès qu'un joueur a choisi une classe MMORPG : + +1. **Filtre de paquets** (`SyncInteractionChains`) — intercepte Ability 1/2/3 avant le moteur vanilla, + envoie `CancelInteractionChain` au client et lance la capacité de classe. +2. **Interaction native** (`mmorpg_cast_ability`) — enregistrée côté serveur ; les patches d'items dans + `src/main/resources/Server/Item/Items/` remplacent Ability1/2/3 sur les armes de test (sync client/serveur). + +Prérequis en jeu : `/mmorpg class choisir ` puis équiper une arme autorisée pour cette classe. +Sans classe, les compétences vanilla de l'arme s'appliquent normalement. + +Pour ajouter d'autres armes, copiez un patch JSON existant (ex. `Weapon_Longsword_Mithril.json`) en +ne gardant que `"Id"` et `"Interactions"`. + + +`/mmorpg menu` ouvre une `CustomUIPage` listant toutes les infos du joueur (classe, race, pouvoirs, +métiers, groupe, money, temps de jeu, dates…). Un plugin **serveur seul** ne peut pas ajouter un +bouton dans l'écran d'inventaire natif ; les équivalents fournis sont la commande `/mmorpg menu` et +l'interaction `mmorpg_player_info` (voir `assetpack/README.md` pour brancher un asset `.ui`). + +## Configuration + +Fichier `config.json` (valeurs par défaut dans `src/main/resources/config.json`) : + +| Clé | Description | +|-----|-------------| +| `Debug` | Logs détaillés | +| `DefaultLevel` | Niveau initial des nouveaux joueurs | +| `MaxPlayers` | Limite prévue (référence future) | +| `BaseXpPerLevel` | XP de base requise par niveau | +| `KillExperience` | XP de base accordée par monstre tué | +| `Database.FileName` | Fichier SQLite (`mmorpg.db`) | +| `Features.Economy` | Active le module économie | +| `Features.Quests` | Active le module quêtes (M3) | +| `Features.Guilds` | Active le module guildes (M4) | + +## Base de données + +`player_profiles` ajoute : `class_id`, `powers` (JSON), `jobs` (JSON), `guild_id`, `group_id`, +`is_connected`, `total_time_play`, `last_date_connected`, `date_creation`, `money`, `race_id`. +Tables de dépendance : `classes`, `powers`, `jobs`, `races`, `groups`, `group_members`. +Les migrations sont versionnées via `schema_migrations` (voir `db/migrations/`). + +## Structure du projet + +``` +src/main/java/com/disklexar/mmorpg/ +├── MmorpgPlugin.java # Point d'entrée (commandes + systèmes ECS) +├── bootstrap/ # Initialisation et registre de services +├── core/ # Config et contrats +├── player/ # Sessions, profils, registre des joueurs en ligne, vue d'infos +├── persistence/ # SQLite (migrations versionnées, repositories) +├── command/ # Commandes /mmorpg (class, power, job, group, ability, info, menu) +├── events/ # Connexion / déconnexion, filtre paquets Ability 1/2/3 +├── interaction/ # mmorpg_cast_ability (interaction native asset pack) +├── progression/ # XP et niveaux (multiplicateurs de race) +├── economy/ # Monnaie (money) +├── rpg/ # clazz (classes) · power · job · race · group +├── combat/ # Capacités, état de combat, effets passifs, planificateur +│ └── system/ # Systèmes ECS : dégâts, morts/kills, casse de bloc +├── ui/ # AbilityBarHud, AbilityBarService, PlayerInfoPage +├── quest/ # Stub (M3) +└── social/ # Stub (M4) +``` + +## Documentation + +- [Interface utilisateur MMORPG (UI/UX)](docs/README-UI.md) +- [Architecture](docs/ARCHITECTURE.md) +- [Feuille de route](docs/ROADMAP.md) +- [Guide de développement](docs/DEVELOPMENT.md) +- [Contribution](CONTRIBUTING.md) + +## Licence + +MIT — voir [LICENSE](LICENSE). diff --git a/assetpack/README.md b/assetpack/README.md new file mode 100644 index 0000000..8362053 --- /dev/null +++ b/assetpack/README.md @@ -0,0 +1,54 @@ +# Asset pack (scaffold) — MMORPG + +Server-only plugins **cannot inject a button into the native inventory screen** and cannot render +a custom-designed page without a client-side asset (`.ui`). This folder is the scaffold to add one. + +## What already works without this asset pack + +- `/mmorpg menu` opens a real `CustomUIPage` (`PlayerInfoPage`) built with **inline UI markup**, so + it renders without any bundled asset. +- `/mmorpg info` prints the same data in chat (reliable fallback). +- The `mmorpg_player_info` interaction is registered (`OpenCustomUIInteraction`) so the page can be + opened from an item/block interaction once you wire one. + +## Class ability keys (Ability 1/2/3) + +Weapon ability slots are overridden in **`src/main/resources/Server/Item/Items/`** (bundled with the +plugin JAR, not this scaffold folder). Each patch sets: + +```json +"Ability1": { + "Interactions": [{ "Type": "mmorpg_cast_ability", "Slot": 1 }] +} +``` + +The Java interaction `mmorpg_cast_ability` is registered in `MmorpgPlugin`. Even without a patch +for a given weapon, the **packet filter** still routes Ability 1/2/3 to class abilities when the +player has a class. + +To patch another weapon, add `Server/Item/Items/.json` with only `"Id"` and `"Interactions"`. + +Shipped examples: longsword, battleaxe, staff, daggers, shortbow, crossbow (mithril/cobalt variants). + +## Enabling the designed `.ui` template + +1. Package this `assetpack/` content according to your Hytale/ScaffoldIt asset-pack layout and set + `"IncludesAssetPack": true` in `src/main/resources/manifest.json`. +2. Edit `com.disklexar.mmorpg.ui.PlayerInfoPage#build` to append the template and fill fields: + + ```java + ui.append("Pages/MmorpgPlayerInfo.ui"); + ui.set("#Title.Text", "Profil MMORPG"); + for (PlayerInfoView.Entry e : PlayerInfoView.entries(profile)) { + ui.append("#Content", "Label { Text: \"" + e.label() + " : " + e.value() + "\"; }"); + } + events.addEventBinding(CustomUIEventBindingType.Click, "#CloseButton"); + ``` + +3. Validate the `.ui` grammar in-game and adjust `MmorpgPlayerInfo.ui`. + +## Inventory button + +A literal button inside the native inventory UI requires a client UI mod/asset overriding the +inventory screen, which is out of scope for a server-only plugin. The supported equivalents are the +`/mmorpg menu` command and the `mmorpg_player_info` interaction above. diff --git a/assetpack/ui/Pages/MmorpgPlayerInfo.ui b/assetpack/ui/Pages/MmorpgPlayerInfo.ui new file mode 100644 index 0000000..4ce8c6c --- /dev/null +++ b/assetpack/ui/Pages/MmorpgPlayerInfo.ui @@ -0,0 +1,29 @@ +// Sample custom UI page template for the MMORPG player-info screen. +// +// This is a SCAFFOLD: the exact .ui markup grammar is defined by the Hytale client and must be +// validated in-game. The plugin works today without this file because PlayerInfoPage builds its +// layout with inline markup. Use this template when you want a richer designed layout, then have +// PlayerInfoPage.build() append "Pages/MmorpgPlayerInfo.ui" and set the named fields below. +// +// Named fields the server can target via UICommandBuilder.set(...): +// #Title.Text - page title +// #Content - vertical container the server appends one Label per attribute into +// #CloseButton - close/dismiss button (bind via UIEventBuilder) + +Panel { + Style: (Width: 420; Anchor: Center; Padding: 16; Background: (Color: #1b1b1fEE)); + + Label #Title { + Text: "Profil MMORPG"; + Style: (Alignment: Center; FontSize: 20; MarginBottom: 12); + } + + VerticalList #Content { + Style: (Spacing: 4); + } + + Button #CloseButton { + Text: "Fermer"; + Style: (Alignment: Center; MarginTop: 12); + } +} diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..a165b2a --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,37 @@ +import org.gradle.jvm.toolchain.JavaLanguageVersion + +plugins { + id("com.gradleup.shadow") version "9.0.0-rc1" +} + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(25)) + } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation("org.xerial:sqlite-jdbc:3.47.2.0") + implementation("com.google.code.gson:gson:2.11.0") +} + +tasks.shadowJar { + archiveClassifier.set("") + mergeServiceFiles() +} + +tasks.jar { + enabled = false +} + +tasks.assemble { + dependsOn(tasks.shadowJar) +} + +tasks.build { + dependsOn(tasks.shadowJar) +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..20a9944 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,72 @@ +# Architecture + +## Vue d'ensemble + +Le projet est un **plugin monolithique** Java déployé sur le serveur Hytale officiel. Le serveur gère le monde, le réseau et l'ECS ; le plugin ajoute la couche MMORPG (persistance, progression, systèmes futurs). + +```mermaid +flowchart TB + subgraph server [Serveur Hytale] + ECS[ECS / Mondes] + Events[EventBus] + Cmds[CommandRegistry] + end + + subgraph mmorpg [Plugin com.disklexar:MMORPG] + Plugin[MmorpgPlugin] + Boot[Bootstrap] + Registry[ServiceRegistry] + PlayerSvc[PlayerSessionService] + DB[(SQLite)] + end + + Plugin --> Boot --> Registry + Registry --> PlayerSvc + PlayerSvc --> DB + Plugin --> Events + Plugin --> Cmds +``` + +## Cycle de vie du plugin + +| Phase | Responsabilité | +|-------|----------------| +| `setup()` | Charge la config, initialise `Bootstrap`, enregistre commandes et événements | +| `start()` | Démarre les services (`DatabaseManager`, `PlayerSessionService`, …) | +| `shutdown()` | Sauvegarde les sessions, ferme SQLite | + +Le singleton `MmorpgPlugin.get()` est rafraîchi à chaque chargement pour rester compatible avec le hot-reload Hytale. + +## Modules et dépendances + +| Package | Dépend de | Rôle | +|---------|-----------|------| +| `bootstrap` | `core`, `persistence`, `player` | Câblage des services | +| `core` | — | Config, interface `Service` | +| `persistence` | `core` | SQLite, repositories | +| `player` | `persistence`, `core` | Profils et sessions en ligne | +| `command` | `player`, `bootstrap` | Interface joueur | +| `events` | `player`, `bootstrap` | Connexion / déconnexion | +| `economy`, `quest`, `social` | — (stubs) | Extensions futures | + +**Règle** : les packages `economy`, `quest` et `social` ne doivent pas être importés par `core` ou `persistence` tant qu'ils ne sont pas implémentés. L'activation se fait via `Features.*` dans `config.json`. + +## Persistance + +- Fichier : `{dataDirectory}/mmorpg.db` +- Migrations : `src/main/resources/db/migrations/*.sql` +- Table initiale : `player_profiles` (uuid, display_name, level, experience, timestamps) + +Les dépendances JDBC (SQLite) et JSON (Gson) sont **embarquées dans le JAR** via Shadow, car absentes du classpath serveur. + +## Événements joueur + +- **Connexion prête** : `PlayerReadyEvent` → chargement ou création du profil +- **Déconnexion** : `PlayerDisconnectEvent` → sauvegarde et retrait de la session mémoire + +## Conventions + +- Identifiants joueur : `java.util.UUID` +- Noms affichés : `PlayerRef.getUsername()` en priorité +- Logs : `HytaleLogger` du plugin +- Permissions futures : préfixe `com.disklexar.mmorpg.*` diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md new file mode 100644 index 0000000..73bd1cd --- /dev/null +++ b/docs/DEVELOPMENT.md @@ -0,0 +1,66 @@ +# Guide de développement + +## Environnement + +1. Installer **JDK 25** (`java-25-openjdk`). Le script `start-server.sh` force `/usr/lib/jvm/java-25-openjdk`. +2. Installer **Hytale** via le launcher et configurer `hytale.home_path` (copier `hytale.properties.example` → `hytale.properties`). +3. Cloner le dépôt et ouvrir le projet dans IntelliJ IDEA : `./gradlew openIdea` (ScaffoldIt). + +## Commandes Gradle + +| Tâche | Description | +|-------|-------------| +| `./gradlew build` | Compile et produit le JAR (Shadow) | +| `./gradlew runServer` | Serveur de dev local | +| `./gradlew generateManifest` | Régénère `manifest.json` depuis `settings.gradle.kts` | +| `./gradlew setupServer` | Prépare le répertoire `devserver/` | + +## Workflow recommandé + +1. Créer une branche depuis `main` +2. Implémenter dans le package approprié (voir [ARCHITECTURE.md](ARCHITECTURE.md)) +3. Vérifier `./gradlew build` +4. Tester en jeu avec `./gradlew runServer` +5. Ouvrir une pull request + +## Base de données locale + +- Fichier : `devserver/.../com.disklexar_MMORPG/mmorpg.db` (chemin exact selon ScaffoldIt) +- Inspecter avec `sqlite3 mmorpg.db` +- Requête utile : `SELECT * FROM player_profiles;` + +Les fichiers `*.db` sont ignorés par Git. + +## Logs + +Le plugin utilise `getLogger()` (HytaleLogger). Activer `Debug: true` dans `config.json` pour plus de détails sur les sessions. + +Logs serveur de dev : `devserver/logs/`. + +## Dépannage runServer + +| Symptôme | Cause | Solution | +|----------|-------|----------| +| `--assets=null` dans les logs Gradle | `hytale.home_path` non configuré | `hytale.properties` ou `HYTALE_HOME` | +| `Missing default DamageCause assets` | Assets jeu absents | Installer Hytale + configurer le chemin | +| `Env_Zone1_Plains doesn't exist` | `devserver` créé sans assets | `HYTALE_RESET_DEVSERVER=1 ./start-server.sh` | +| `Task :runServer FAILED` exit 1 | Voir `devserver/logs/*.log` | Corriger la cause ci-dessus | + +## API Hytale + +La dépendance `com.hypixel.hytale:Server:+` est en **compileOnly** : ne pas l'inclure dans le JAR final. En cas d'erreur de compilation après mise à jour serveur : + +- Vérifier les noms d'événements (`PlayerReadyEvent`, `PlayerDisconnectEvent`) +- Consulter [doctale.dev](https://doctale.dev) et le template [HytaleModding/plugin-template](https://github.com/HytaleModding/plugin-template) + +## Ajouter une migration SQL + +1. Créer `src/main/resources/db/migrations/00X_description.sql` +2. Étendre `SchemaInitializer` pour exécuter les nouveaux fichiers dans l'ordre +3. Documenter le changement dans le changelog / PR + +## Style de code + +- Packages en minuscules, classes en PascalCase +- Pas de logique métier dans `MmorpgPlugin` — déléguer aux services +- Fermer les ressources JDBC dans try-with-resources diff --git a/docs/README-UI.md b/docs/README-UI.md new file mode 100644 index 0000000..96780ca --- /dev/null +++ b/docs/README-UI.md @@ -0,0 +1,408 @@ +# Interface utilisateur MMORPG — conception UI/UX + +Document de référence pour l'interface joueur du plugin **com.disklexar:MMORPG** sur Hytale. +Objectif : une UI type MMORPG **moderne, ergonomique, immersive et intuitive**, compatible avec les +contraintes d'un plugin **serveur** (assets `.ui` embarqués, `CustomUIHud`, `CustomUIPage`). + +--- + +## Principes directeurs + +| Principe | Application | +|----------|-------------| +| **Lisibilité en combat** | Contrastes élevés, tailles minimales 12 px, icônes + texte court | +| **Hiérarchie visuelle** | HUD persistant discret ; menus modaux pour la gestion profonde | +| **Feedback immédiat** | Cooldowns, buffs et dégâts reflétés en < 1 s côté client | +| **Cohérence** | Palette sombre `#1a1c20` / accents `#c8a45c` (or MMORPG) sur tous les écrans | +| **Responsive** | Ancres relatives (`Left`, `Right`, `Bottom`) + grilles flex, pas de pixels fixes centraux | +| **Dégradation gracieuse** | Commandes chat (`/mmorpg info`, `/mmorpg profile`) si un asset `.ui` est invalide | + +### Palette + +``` +Fond HUD #1a1c20 @ 85 % Barre pleine vie #3d8f5a +Fond slot #24262a Barre vide #2a2d32 +Texte primaire #e8e8ec Mana / énergie #4a8fd4 +Texte secondaire #878e9c XP / niveau #c8a45c +Buff positif #5cb85c Debuff négatif #c9302c +Bordure focus #c8a45c Overlay modal #1b1b1f @ 93 % +``` + +--- + +## Architecture globale + +```mermaid +flowchart TB + subgraph hud [HUD persistant — en jeu] + Vitals[Barres vie / mana / XP] + Buffs[Buffs & debuffs] + Hotbar[Barre d'action] + AbilityBar[Barre capacités classe] + end + + subgraph menus [Menus joueur — modaux] + Shell[Coquille MmorpgPlayerShell.ui] + Char[Onglet Personnage] + Inv[Onglet Inventaire] + Skills[Onglet Arbre de compétences] + end + + subgraph server [Serveur Java] + ABS[AbilityBarService] + PIP[PlayerInfoPage] + Future[HudVitalsService — M1] + FutureMenu[PlayerMenuPage — M1] + end + + ABS --> AbilityBar + Future --> Vitals + Future --> Buffs + Future --> Hotbar + FutureMenu --> Shell + Shell --> Char + Shell --> Inv + Shell --> Skills + PIP -.->|fallback actuel| Char +``` + +### Arborescence des assets (cible) + +``` +src/main/resources/Common/UI/Custom/ +├── Huds/ +│ ├── MmorpgVitalsHud.ui # Vie, mana, XP (bas-gauche) +│ ├── MmorpgBuffTrayHud.ui # Buffs / debuffs (sous les barres) +│ ├── MmorpgHotbarHud.ui # Slots objets + raccourcis +│ └── MmorpgAbilityBar.ui # ✅ Implémenté — 3 slots classe +├── Pages/ +│ ├── MmorpgPlayerShell.ui # Fenêtre à onglets (coquille) +│ ├── MmorpgCharacterTab.ui # Onglet Personnage +│ ├── MmorpgInventoryTab.ui # Onglet Inventaire +│ └── MmorpgSkillTreeTab.ui # Onglet Arbre de compétences +└── Widgets/ + ├── MmorpgProgressBar.ui # Barre réutilisable (vie, mana, XP) + ├── MmorpgBuffIcon.ui # Icône buff avec timer + └── MmorpgSkillNode.ui # Nœud d'arbre de compétences +``` + +### Mapping code serveur + +| Composant UI | Classe Java | Statut | +|--------------|-------------|--------| +| Barre capacités | `AbilityBarHud` + `AbilityBarService` | ✅ Actif | +| Profil joueur (liste) | `PlayerInfoPage` | ✅ Actif (layout base-game) | +| Coquille à onglets | `PlayerMenuPage` | 🔲 M1 | +| Barres vitales HUD | `VitalsHud` + `VitalsHudService` | 🔲 M1 | +| Buff tray | `BuffTrayHud` | 🔲 M1 | +| Hotbar étendue | `HotbarHud` | 🔲 M2 | + +--- + +## 1. Affichage principal (HUD) + +Le HUD reste **toujours visible** en exploration et en combat. Les éléments natifs Hytale +(inventaire rapide, minimap) sont conservés ; le plugin ajoute des calques `CustomUIHud` +(`z-order` croissant : vitals → buffs → hotbar → capacités). + +### 1.1 Disposition écran (desktop 16:9) + +``` +┌──────────────────────────────────────────────────────────────────────────┐ +│ [Minimap native] [Quêtes]* │ +│ │ +│ MONDE / COMBAT │ +│ │ +│ │ +│ ┌─ Vitals ─────────────────┐ │ +│ │ ♥ ████████░░ 840/1000 │ ← Vie │ +│ │ ◆ ██████░░░░ 120/200 │ ← Mana / énergie │ +│ │ ★ Nv.42 ████░ 67 % │ ← Niveau + barre XP │ +│ └──────────────────────────┘ │ +│ [Buff][Buff][Debuff]... ← Buff tray (max 8 icônes visibles) │ +│ │ +│ [1][2][3][4][5][6][7][8][9][0] ← Hotbar objets │ +│ [Cap1][Cap2][Cap3] ← Barre capacités classe │ +└──────────────────────────────────────────────────────────────────────────┘ +* futur module quêtes +``` + +### 1.2 Barres de vie, mana et XP + +**Fichier cible :** `Huds/MmorpgVitalsHud.ui` + +| Élément | ID serveur | Donnée source | Comportement | +|---------|------------|---------------|--------------| +| Barre de vie | `#HealthBar`, `#HealthText` | Composants entité joueur (vanilla) + bonus MMORPG futurs | Remplissage proportionnel ; flash rouge si < 25 % | +| Barre de mana | `#ManaBar`, `#ManaText` | Attribut MMORPG (M1) | Masquée si classe sans mana (ex. Chevalier) | +| Niveau | `#LevelLabel` | `PlayerProfile.level` | `Nv. {n}` | +| Barre XP | `#XpBar`, `#XpText` | `ProgressionService` | `{current}/{required}` + pourcentage | + +**Rafraîchissement :** toutes les 500 ms via `VitalsHudService` ; push immédiat sur gain d'XP ou dégâts. + +**Widget réutilisable `MmorpgProgressBar.ui` :** + +``` +Group #ProgressBar + Group #Fill — largeur dynamique (ui.set Width ou FlexWeight) + Label #ValueText — "840 / 1000" +``` + +### 1.3 Buffs et debuffs actifs + +**Fichier cible :** `Huds/MmorpgBuffTrayHud.ui` + +| Propriété | Valeur | +|-----------|--------| +| Position | Au-dessus des barres vitales, aligné à gauche | +| Capacité | 8 icônes visibles ; défilement horizontal si > 8 | +| Par icône | Image, nom court, timer circulaire ou compte à rebours | +| Sources serveur | `CombatBuffService`, `StunService`, effets de classe | + +**IDs dynamiques :** `#BuffSlot0` … `#BuffSlot7` — le serveur injecte des instances de +`Widgets/MmorpgBuffIcon.ui` via `appendInline`. + +**Code couleur :** bordure verte = buff allié ; rouge = debuff ; or = effet neutre / passif. + +### 1.4 Barre d'action (hotbar) + +**Fichier cible :** `Huds/MmorpgHotbarHud.ui` + +La hotbar MMORPG **complète** la barre native sans la remplacer : + +| Zone | Slots | Contenu | +|------|-------|---------| +| Objets consommables | 1–0 (10) | Potions, nourriture, gadgets MMORPG | +| Capacités classe | Ability 1–3 | Géré par `MmorpgAbilityBar.ui` (déjà séparé) | +| Slot utilitaire | `U` (optionnel) | Monture, outil de métier | + +**Par slot :** + +- Icône de l'objet / compétence +- Raccourci clavier (coin supérieur droit) +- Overlay cooldown (assombrissement + texte secondes) +- Bordure dorée si sélectionné + +**Interaction :** les slots 1–3 déclenchent `mmorpg_cast_ability` ; les autres passent par +l'inventaire natif ou des interactions custom futures. + +### 1.5 Barre de capacités de classe (implémentée) + +**Fichier :** `Common/UI/Custom/MmorpgAbilityBar.ui` +**Service :** `AbilityBarService` — affichage automatique à la connexion si le joueur a une classe. + +| Slot | ID | Touche | Mise à jour | +|------|-----|--------|-------------| +| 1 | `#Slot1Name`, `#Slot1Key` | Ability 1 | Nom + cooldown (`Charge (3s)`) | +| 2 | `#Slot2Name`, `#Slot2Key` | Ability 2 | idem | +| 3 | `#Slot3Name`, `#Slot3Key` | Ability 3 | idem | + +**Commande debug :** `/mmorpg hud` — force la synchronisation. + +--- + +## 2. Menus joueur + +Ouverture cible : **`C`** ou `/mmorpg menu` → coquille à onglets. +État actuel : `/mmorpg menu` ouvre `PlayerInfoPage` (liste verticale, layout `WarpListPage.ui`). + +### 2.1 Coquille — `MmorpgPlayerShell.ui` + +``` +Panel (centré, max 900×600, fond #1b1b1fEE) +├── Header +│ ├── Portrait / icône classe #ClassPortrait +│ ├── Nom joueur #PlayerName +│ └── Monnaie #MoneyLabel +├── TabBar (horizontal) +│ ├── Button #TabCharacter "Personnage" +│ ├── Button #TabInventory "Inventaire" +│ └── Button #TabSkills "Compétences" +├── ContentHost #TabContent (swap le document de l'onglet actif) +└── Footer + └── Button #CloseButton "Fermer" +``` + +**Navigation :** `UIEventBuilder` lie chaque onglet ; le serveur charge le `.ui` de l'onglet +dans `#TabContent` via `ui.clear` + `ui.append`. + +### 2.2 Onglet Personnage + +**Fichier cible :** `Pages/MmorpgCharacterTab.ui` + +| Section | Champs | Source `PlayerProfile` | +|---------|--------|------------------------| +| Identité | Nom, race, classe | `displayName`, `raceId`, `classId` | +| Progression | Niveau, XP, barre XP | `level`, `experience` | +| Attributs* | Force, Agilité, Intelligence… | M1 — stats dérivées | +| Pouvoirs passifs | Liste à puces | `powers` → `PowerCatalog` | +| Métiers | Liste à puces | `jobs` → `JobCatalog` | +| Social | Groupe, guilde | `groupId`, `guildId` | +| Statistiques | Temps de jeu, argent, dates | `PlayerInfoView` (existant) | + +\* Les attributs numériques sont un placeholder visuel jusqu'à l'implémentation M1. + +**Layout :** deux colonnes sur grand écran — gauche : portrait + barres ; droite : listes +pouvoirs / métiers / social. + +### 2.3 Onglet Inventaire + +**Fichier cible :** `Pages/MmorpgInventoryTab.ui` + +> Un plugin serveur **ne peut pas modifier l'inventaire natif**. Cet onglet est une **vue MMORPG** +> complémentaire (items tagués, équipement RPG, filtres). + +| Zone | Description | +|------|-------------| +| `#EquipmentPaperDoll` | Silhouette : tête, torse, mains, jambes, arme principale / secondaire | +| `#InventoryGrid` | Grille 8×5 (40 slots) — items avec rareté (bordure couleur) | +| `#ItemDetails` | Panneau droit : nom, stats, description, boutons Utiliser / Jeter | +| `#CategoryTabs` | Filtres : Tout, Équipement, Consommables, Matériaux, Quête | +| `#WeightBar` | Encumbrance (futur) | + +**Ergonomie :** + +- Clic gauche : sélection + détails +- Double-clic : utiliser (si consommable) +- Shift-clic : déplacer vers équipement (futur) +- Recherche texte `#SearchField` en haut de grille + +### 2.4 Onglet Arbre de compétences + +**Fichier cible :** `Pages/MmorpgSkillTreeTab.ui` + +| Élément | Description | +|---------|-------------| +| `#TreeCanvas` | Zone scrollable / zoomable (pincer sur mobile) | +| `#SkillNode_*` | Nœuds : icône, rang actuel / max, prérequis | +| `#PointsAvailable` | Points de compétence non dépensés | +| `#NodeTooltip` | Survol : nom, effet, coût, prérequis | +| `#ResetButton` | Réinitialisation (coût en monnaie, futur) | + +**États visuels d'un nœud (`Widgets/MmorpgSkillNode.ui`) :** + +| État | Apparence | +|------|-----------| +| Verrouillé | Grisé, cadenas | +| Disponible | Bordure or clignotante légère | +| Appris (rang 1–n) | Rempli, rang affiché | +| Max | Bordure brillante, étoile | + +**Données :** JSON ou table SQLite `skill_nodes` (M1+) ; rendu initial basé sur la classe active +(`ClassCatalog` → 3 branches par classe en V1). + +--- + +## 3. Responsive et multi-résolution + +### Breakpoints (ratio largeur / hauteur viewport client) + +| Profil | Condition | Adaptations | +|--------|-----------|-------------| +| **Desktop** | largeur ≥ 1280 px | Menus 900 px ; HUD complet ; 2 colonnes Personnage | +| **Compact** | 1024–1279 px | Menus 720 px ; buff tray 6 icônes ; texte réduit 1 px | +| **Small** | < 1024 px | Menus plein écran ; vitals empilés ; hotbar 6 slots visibles + scroll | +| **Ultrawide** | ratio > 2.1 | HUD ancré aux tiers gauche/droite, pas aux bords extrêmes | + +### Règles d'ancrage (fichiers `.ui`) + +``` +# HUD bas +Anchor: (Bottom: 24, Left: 24) — vitals +Anchor: (Bottom: 28, Left: 0, Right: 0) — ability bar (centré) + +# Menu modal +Anchor: Center +Style: (MaxWidth: 900, Width: 90%) +``` + +- Préférer `FlexWeight`, `Padding: (Full: n)` et `LayoutMode: Top/Right` aux positions absolues. +- Les groupes vides utilisent `Anchor: (Width: 6)` comme séparateurs (pattern `MmorpgAbilityBar.ui`). +- Tester en 1920×1080, 1366×768 et 1280×720. + +### Accessibilité + +- Contraste texte / fond ≥ 4.5:1 (WCAG AA) +- Taille police minimale 12 px (HUD) / 14 px (menus) +- Les cooldowns affichent **chiffres + assombrissement** (pas la couleur seule) +- Raccourcis clavier listés dans chaque infobulle de slot + +--- + +## 4. Flux utilisateur + +```mermaid +sequenceDiagram + participant J as Joueur + participant C as Client Hytale + participant S as Serveur MMORPG + + J->>C: Connexion + C->>S: PlayerReadyEvent + S->>S: loadOrCreate(profile) + alt a une classe + S->>C: addCustomHud(MmorpgAbilityBar) + end + S->>C: Message bienvenue (niveau, race) + + J->>C: /mmorpg menu ou touche C + C->>S: openCustomPage + S->>C: PlayerInfoPage (puis PlayerMenuPage) + + J->>C: Ability 1 + C->>S: SyncInteractionChains (filtré) + S->>C: CancelInteractionChain + cast + S->>C: update HUD cooldowns +``` + +--- + +## 5. Commandes et raccourcis + +| Action | Commande / touche | Écran | +|--------|-------------------|-------| +| Ouvrir profil | `/mmorpg menu` | Menu (liste → coquille) | +| Profil chat | `/mmorpg info` | Fallback texte | +| Résumé court | `/mmorpg profile` | Chat | +| Forcer HUD capacités | `/mmorpg hud` | HUD | +| Capacité 1–3 | Touches Ability 1/2/3 | HUD + exécution | +| Fermer menu | Échap | — | + +**Raccourcis cibles (M1) :** `C` → menu ; `K` → arbre de compétences ; `I` déjà natif inventaire. + +--- + +## 6. Phases d'implémentation + +| Phase | Livrables UI | Dépendances gameplay | +|-------|--------------|----------------------| +| **M0** ✅ | `MmorpgAbilityBar.ui`, `PlayerInfoPage`, `/mmorpg hud` | Classes, capacités | +| **M1** | `MmorpgVitalsHud`, `MmorpgBuffTray`, `MmorpgPlayerShell` + onglet Personnage | Stats vie/mana, buffs | +| **M2** | Onglet Inventaire MMORPG, `MmorpgHotbarHud` | Items tagués, équipement | +| **M3** | Onglet Arbre de compétences | Points de compétence, déblocages | +| **M4** | Quêtes dans HUD, tooltips riches | Module quêtes | + +--- + +## 7. Validation et tests + +1. **Asset pack** : `IncludesAssetPack: true` dans `manifest.json` — chaque `.ui` doit être + validé en jeu (un fichier invalide **crash le client** à la connexion). +2. **Checklist par écran :** + - [ ] Connexion sans crash client + - [ ] HUD visible après choix de classe + - [ ] Cooldowns décrémentent chaque seconde + - [ ] Déconnexion retire le HUD (`AbilityBarService.dismiss`) + - [ ] Menu s'ouvre et se ferme (Échap) + - [ ] Responsive : pas de chevauchement à 1366×768 +3. **Logs serveur :** rechercher `Ability bar HUD` dans `devserver/logs/`. + +--- + +## 8. Références projet + +- Code HUD actif : `src/main/java/com/disklexar/mmorpg/ui/` +- Asset barre capacités : `src/main/resources/Common/UI/Custom/MmorpgAbilityBar.ui` +- Scaffold pages : `assetpack/ui/Pages/MmorpgPlayerInfo.ui` +- Guide asset pack : [assetpack/README.md](../assetpack/README.md) +- Architecture : [ARCHITECTURE.md](ARCHITECTURE.md) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 0000000..886ece6 --- /dev/null +++ b/docs/ROADMAP.md @@ -0,0 +1,53 @@ +# Feuille de route + +## M0 — Fondation (actuel) + +- [x] Projet Gradle + ScaffoldIt +- [x] Plugin `com.disklexar:MMORPG` +- [x] SQLite + table `player_profiles` +- [x] Sessions joueur (connexion / déconnexion) +- [x] Commandes `/mmorpg help` et `/mmorpg profile` +- [x] Packages stub : `economy`, `quest`, `social` +- [x] Documentation et CI + +## M1 — Personnage et progression + +- [ ] Système d'expérience et montée de niveau +- [ ] Statistiques de base (vie, mana, attributs) +- [ ] Sauvegarde position / monde (spawn MMORPG) +- [ ] Messages et UI côté client (si API disponible) + +## M2 — Économie + +- [ ] Monnaie serveur (wallets SQLite) +- [ ] Transactions atomiques +- [ ] Vendeurs NPC et tables de loot +- [ ] Activer `Features.Economy` dans la config + +## M3 — Quêtes + +- [ ] Définitions de quêtes (JSON ou DB) +- [ ] Objectifs : collecte, dialogue, zone +- [ ] Récompenses XP / items / monnaie +- [ ] Quêtes journalières +- [ ] Activer `Features.Quests` + +## M4 — Social et instances + +- [ ] Groupes / parties +- [ ] Guildes (création, rangs, banque) +- [ ] Instances / donjons dédiés +- [ ] Activer `Features.Guilds` + +## M5 — Production + +- [ ] Migration PostgreSQL optionnelle +- [ ] Cache Redis pour sessions multi-serveur +- [ ] Métriques et monitoring +- [ ] Anti-triche basique (rate limits, validation serveur) + +## Hors périmètre connu + +- Asset pack custom (modèles, textures) +- Interface web d'administration +- Client modifié (hors API serveur) diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..96d799e --- /dev/null +++ b/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.daemon=true +org.gradle.parallel=true +org.gradle.java.home=/usr/lib/jvm/java-25-openjdk diff --git a/gradle.properties.bak b/gradle.properties.bak new file mode 100644 index 0000000..96d799e --- /dev/null +++ b/gradle.properties.bak @@ -0,0 +1,3 @@ +org.gradle.daemon=true +org.gradle.parallel=true +org.gradle.java.home=/usr/lib/jvm/java-25-openjdk diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..f8e1ee3 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..bad7c24 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.0-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..61b77d7 --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..bd8a8c0 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/hytale.properties.example b/hytale.properties.example new file mode 100644 index 0000000..8b66b65 --- /dev/null +++ b/hytale.properties.example @@ -0,0 +1,10 @@ +# Copier ce fichier vers hytale.properties et adapter le chemin. +# Dossier d'installation Hytale (doit contenir install/.../Assets.zip) +# +# Linux (Flatpak) : +# hytale.home_path=/home/VOTRE_USER/.var/app/com.hypixel.HytaleLauncher/data/Hytale +# +# Windows : +# hytale.home_path=C:/Users/VOTRE_USER/AppData/Roaming/Hytale + +hytale.home_path= diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..2192b10 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,16 @@ +rootProject.name = "hytale-mmorpg" + +plugins { + id("dev.scaffoldit") version "0.2.+" +} + +hytale { + usePatchline("release") + useVersion("latest") + + manifest { + Group = "com.disklexar" + Name = "MMORPG" + Main = "com.disklexar.mmorpg.MmorpgPlugin" + } +} diff --git a/src/main/java/com/disklexar/mmorpg/MmorpgPlugin.java b/src/main/java/com/disklexar/mmorpg/MmorpgPlugin.java new file mode 100644 index 0000000..1f528fc --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/MmorpgPlugin.java @@ -0,0 +1,148 @@ +package com.disklexar.mmorpg; + +import com.disklexar.mmorpg.bootstrap.Bootstrap; +import com.disklexar.mmorpg.bootstrap.ServiceRegistry; +import com.disklexar.mmorpg.combat.ClassAbilityController; +import com.disklexar.mmorpg.combat.CombatBuffService; +import com.disklexar.mmorpg.combat.CombatStateService; +import com.disklexar.mmorpg.combat.PoisonService; +import com.disklexar.mmorpg.combat.system.MinerBlockBreakSystem; +import com.disklexar.mmorpg.combat.system.MmorpgDamageSystem; +import com.disklexar.mmorpg.combat.system.MmorpgDeathSystem; +import com.disklexar.mmorpg.command.MmorpgCommand; +import com.disklexar.mmorpg.economy.EconomyService; +import com.disklexar.mmorpg.events.AbilityPacketFilter; +import com.disklexar.mmorpg.events.PlayerConnectionHandler; +import com.disklexar.mmorpg.interaction.MmorpgCastAbilityInteraction; +import com.disklexar.mmorpg.player.OnlinePlayerRegistry; +import com.disklexar.mmorpg.player.PlayerSessionService; +import com.disklexar.mmorpg.player.model.PlayerProfile; +import com.disklexar.mmorpg.progression.ProgressionService; +import com.disklexar.mmorpg.rpg.group.GroupService; +import com.disklexar.mmorpg.rpg.job.JobService; +import com.disklexar.mmorpg.ui.PlayerInfoPage; +import com.hypixel.hytale.server.core.event.events.player.PlayerDisconnectEvent; +import com.hypixel.hytale.server.core.event.events.player.PlayerReadyEvent; +import com.hypixel.hytale.server.core.io.adapter.PacketAdapters; +import com.hypixel.hytale.server.core.io.adapter.PacketFilter; +import com.hypixel.hytale.server.core.modules.interaction.interaction.config.Interaction; +import com.hypixel.hytale.server.core.modules.interaction.interaction.config.server.OpenCustomUIInteraction; +import com.hypixel.hytale.server.core.plugin.JavaPlugin; +import com.hypixel.hytale.server.core.plugin.JavaPluginInit; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.logging.Level; + +/** + * Main entry point for the Disklexar Hytale MMORPG plugin. + */ +public final class MmorpgPlugin extends JavaPlugin { + + private static MmorpgPlugin instance; + + private Bootstrap bootstrap; + private ClassAbilityController classAbilityController; + private PlayerConnectionHandler connectionHandler; + private AbilityPacketFilter abilityPacketFilter; + private PacketFilter inboundPacketFilter; + + public MmorpgPlugin(@Nonnull JavaPluginInit init) { + super(init); + } + + @Nullable + public static MmorpgPlugin get() { + return instance; + } + + @Nullable + public Bootstrap getBootstrap() { + return bootstrap; + } + + @Nonnull + public ClassAbilityController getClassAbilityController() { + return classAbilityController; + } + + @Override + protected void setup() { + instance = this; + classAbilityController = new ClassAbilityController(this); + connectionHandler = new PlayerConnectionHandler(this); + abilityPacketFilter = new AbilityPacketFilter(classAbilityController); + + try { + bootstrap = new Bootstrap(this); + bootstrap.initialize(); + } catch (Exception e) { + getLogger().at(Level.SEVERE).log("MMORPG bootstrap failed: %s", e.getMessage()); + throw new IllegalStateException("Failed to initialize MMORPG plugin", e); + } + + getCodecRegistry(Interaction.CODEC).register( + MmorpgCastAbilityInteraction.ID, + MmorpgCastAbilityInteraction.class, + MmorpgCastAbilityInteraction.CODEC); + + getCommandRegistry().registerCommand(new MmorpgCommand(this)); + getEventRegistry().registerGlobal(PlayerReadyEvent.class, connectionHandler::onPlayerReady); + getEventRegistry().registerGlobal(PlayerDisconnectEvent.class, connectionHandler::onPlayerDisconnect); + inboundPacketFilter = PacketAdapters.registerInbound(abilityPacketFilter); + + registerGameplaySystems(); + + getLogger().at(Level.INFO).log("MMORPG plugin setup complete"); + } + + private void registerGameplaySystems() { + ServiceRegistry registry = bootstrap.getRegistry(); + PlayerSessionService sessions = registry.require(PlayerSessionService.class); + CombatStateService combatState = registry.require(CombatStateService.class); + CombatBuffService combatBuffs = registry.require(CombatBuffService.class); + PoisonService poisonService = registry.require(PoisonService.class); + OnlinePlayerRegistry onlinePlayers = registry.require(OnlinePlayerRegistry.class); + ProgressionService progression = registry.require(ProgressionService.class); + GroupService groups = registry.require(GroupService.class); + JobService jobs = registry.require(JobService.class); + EconomyService economy = registry.require(EconomyService.class); + int killExperience = bootstrap.getConfig().getKillExperience(); + + getEntityStoreRegistry().registerSystem( + new MmorpgDamageSystem(sessions, combatState, combatBuffs, poisonService, onlinePlayers)); + getEntityStoreRegistry().registerSystem( + new MmorpgDeathSystem(sessions, progression, groups, jobs, economy, killExperience)); + getEntityStoreRegistry().registerSystem(new MinerBlockBreakSystem(sessions, jobs, economy)); + + OpenCustomUIInteraction.registerSimple(this, PlayerInfoPage.class, "mmorpg_player_info", playerRef -> { + PlayerProfile profile = sessions.getSession(playerRef.getUuid()); + if (profile == null) { + profile = new PlayerProfile(playerRef.getUuid(), playerRef.getUsername(), System.currentTimeMillis()); + } + return new PlayerInfoPage(playerRef, profile); + }); + } + + @Override + protected void start() { + if (bootstrap != null) { + bootstrap.startServices(); + } + getLogger().at(Level.INFO).log("MMORPG plugin started"); + } + + @Override + protected void shutdown() { + if (inboundPacketFilter != null) { + PacketAdapters.deregisterInbound(inboundPacketFilter); + inboundPacketFilter = null; + } + if (bootstrap != null) { + bootstrap.shutdownServices(); + bootstrap = null; + } + instance = null; + getLogger().at(Level.INFO).log("MMORPG plugin shut down"); + } +} diff --git a/src/main/java/com/disklexar/mmorpg/api/package-info.java b/src/main/java/com/disklexar/mmorpg/api/package-info.java new file mode 100644 index 0000000..75ce7f1 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/api/package-info.java @@ -0,0 +1,4 @@ +/** + * Public API interfaces for cross-module integration (future extraction to separate plugins). + */ +package com.disklexar.mmorpg.api; diff --git a/src/main/java/com/disklexar/mmorpg/bootstrap/Bootstrap.java b/src/main/java/com/disklexar/mmorpg/bootstrap/Bootstrap.java new file mode 100644 index 0000000..693b54e --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/bootstrap/Bootstrap.java @@ -0,0 +1,122 @@ +package com.disklexar.mmorpg.bootstrap; + +import com.disklexar.mmorpg.MmorpgPlugin; +import com.disklexar.mmorpg.combat.AbilityService; +import com.disklexar.mmorpg.combat.CombatBuffService; +import com.disklexar.mmorpg.combat.CombatStateService; +import com.disklexar.mmorpg.combat.MmorpgScheduler; +import com.disklexar.mmorpg.combat.PassiveEffectService; +import com.disklexar.mmorpg.combat.PoisonService; +import com.disklexar.mmorpg.combat.StunService; +import com.disklexar.mmorpg.core.config.MmorpgConfig; +import com.disklexar.mmorpg.economy.EconomyService; +import com.disklexar.mmorpg.persistence.DatabaseManager; +import com.disklexar.mmorpg.persistence.repository.GroupRepository; +import com.disklexar.mmorpg.persistence.repository.PlayerProfileRepository; +import com.disklexar.mmorpg.player.OnlinePlayerRegistry; +import com.disklexar.mmorpg.player.PlayerSessionService; +import com.disklexar.mmorpg.progression.ProgressionService; +import com.disklexar.mmorpg.rpg.clazz.ClassService; +import com.disklexar.mmorpg.rpg.group.GroupService; +import com.disklexar.mmorpg.rpg.job.JobService; +import com.disklexar.mmorpg.rpg.power.PowerService; +import com.disklexar.mmorpg.rpg.race.RaceService; +import com.disklexar.mmorpg.ui.AbilityBarService; +import com.hypixel.hytale.logger.HytaleLogger; + +import javax.annotation.Nonnull; +import java.io.IOException; +import java.util.logging.Level; + +/** + * Wires and starts all MMORPG services. + */ +public final class Bootstrap { + + private final MmorpgPlugin plugin; + private final HytaleLogger logger; + private final ServiceRegistry registry = new ServiceRegistry(); + private MmorpgConfig config; + + public Bootstrap(@Nonnull MmorpgPlugin plugin) { + this.plugin = plugin; + this.logger = plugin.getLogger(); + } + + public void initialize() throws IOException { + config = MmorpgConfig.load(plugin.getDataDirectory()); + + // Persistence (DatabaseManager must start first so repositories can query). + DatabaseManager databaseManager = new DatabaseManager(plugin, config); + PlayerProfileRepository profileRepository = new PlayerProfileRepository(databaseManager); + GroupRepository groupRepository = new GroupRepository(databaseManager); + + // Stateless domain services. + ProgressionService progression = new ProgressionService(config.getBaseXpPerLevel()); + EconomyService economy = new EconomyService(); + ClassService classService = new ClassService(); + PowerService powerService = new PowerService(); + JobService jobService = new JobService(); + RaceService raceService = new RaceService(); + CombatStateService combatState = new CombatStateService(); + CombatBuffService combatBuffs = new CombatBuffService(); + StunService stunService = new StunService(); + OnlinePlayerRegistry onlinePlayers = new OnlinePlayerRegistry(); + + // Session and lifecycle services. + PlayerSessionService sessionService = + new PlayerSessionService(plugin, config, profileRepository); + MmorpgScheduler scheduler = new MmorpgScheduler(plugin); + PoisonService poisonService = new PoisonService(scheduler); + GroupService groupService = + new GroupService(plugin, groupRepository, sessionService, progression); + AbilityService abilityService = + new AbilityService(scheduler, stunService, combatState, combatBuffs); + PassiveEffectService passiveEffects = + new PassiveEffectService(plugin, scheduler, onlinePlayers, sessionService, combatState); + AbilityBarService abilityBarService = new AbilityBarService( + plugin, classService, abilityService, onlinePlayers, sessionService, progression, scheduler); + + registry.register(MmorpgConfig.class, config); + registry.register(DatabaseManager.class, databaseManager); + registry.register(PlayerProfileRepository.class, profileRepository); + registry.register(GroupRepository.class, groupRepository); + registry.register(ProgressionService.class, progression); + registry.register(EconomyService.class, economy); + registry.register(ClassService.class, classService); + registry.register(PowerService.class, powerService); + registry.register(JobService.class, jobService); + registry.register(RaceService.class, raceService); + registry.register(CombatStateService.class, combatState); + registry.register(CombatBuffService.class, combatBuffs); + registry.register(PoisonService.class, poisonService); + registry.register(StunService.class, stunService); + registry.register(OnlinePlayerRegistry.class, onlinePlayers); + registry.register(PlayerSessionService.class, sessionService); + registry.register(MmorpgScheduler.class, scheduler); + registry.register(GroupService.class, groupService); + registry.register(AbilityService.class, abilityService); + registry.register(AbilityBarService.class, abilityBarService); + registry.register(PassiveEffectService.class, passiveEffects); + + logger.at(Level.INFO).log("MMORPG bootstrap initialized"); + } + + public void startServices() { + registry.startAll(); + } + + public void shutdownServices() { + registry.shutdownAll(); + } + + @Nonnull + public ServiceRegistry getRegistry() { + return registry; + } + + @Nonnull + public MmorpgConfig getConfig() { + return config; + } +} diff --git a/src/main/java/com/disklexar/mmorpg/bootstrap/ServiceRegistry.java b/src/main/java/com/disklexar/mmorpg/bootstrap/ServiceRegistry.java new file mode 100644 index 0000000..0a6d775 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/bootstrap/ServiceRegistry.java @@ -0,0 +1,52 @@ +package com.disklexar.mmorpg.bootstrap; + +import com.disklexar.mmorpg.core.service.Service; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Simple service locator for MMORPG components. + */ +public final class ServiceRegistry { + + private final Map, Object> services = new HashMap<>(); + private final List lifecycleServices = new ArrayList<>(); + + public void register(@Nonnull Class type, @Nonnull T instance) { + services.put(type, instance); + if (instance instanceof Service service) { + lifecycleServices.add(service); + } + } + + @Nullable + public T get(@Nonnull Class type) { + return type.cast(services.get(type)); + } + + @Nonnull + public T require(@Nonnull Class type) { + T service = get(type); + if (service == null) { + throw new IllegalStateException("Service not registered: " + type.getName()); + } + return service; + } + + public void startAll() { + for (Service service : lifecycleServices) { + service.start(); + } + } + + public void shutdownAll() { + for (int i = lifecycleServices.size() - 1; i >= 0; i--) { + lifecycleServices.get(i).shutdown(); + } + } +} diff --git a/src/main/java/com/disklexar/mmorpg/combat/AbilityService.java b/src/main/java/com/disklexar/mmorpg/combat/AbilityService.java new file mode 100644 index 0000000..f9948cf --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/combat/AbilityService.java @@ -0,0 +1,747 @@ +package com.disklexar.mmorpg.combat; + +import com.disklexar.mmorpg.rpg.clazz.AbilityDefinition; +import com.disklexar.mmorpg.rpg.clazz.ClassCatalog; +import com.disklexar.mmorpg.rpg.clazz.PlayerClass; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.server.core.entity.UUIDComponent; +import com.hypixel.hytale.server.core.modules.entity.component.HeadRotation; +import com.hypixel.hytale.server.core.inventory.InventoryComponent; +import com.hypixel.hytale.server.core.inventory.ItemStack; +import com.hypixel.hytale.server.core.modules.entity.component.TransformComponent; +import com.hypixel.hytale.server.core.modules.entity.damage.Damage; +import com.hypixel.hytale.server.core.modules.entity.damage.DamageCause; +import com.hypixel.hytale.server.core.modules.entity.damage.DamageSystems; +import com.hypixel.hytale.server.core.modules.physics.component.Velocity; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; +import com.hypixel.hytale.server.core.util.TargetUtil; +import org.joml.Vector3d; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Executes the active class abilities. Effects are applied with the verified server APIs + * ({@link TargetUtil}, {@link DamageSystems#executeDamage}, {@link Velocity}); exact numbers + * (dash speed, radii, damage) are tuned via {@code config.json}-style constants and may need + * in-game adjustment. + */ +public final class AbilityService { + + // Knockback impulse for the forward dash. The vertical component matters a lot: too little and + // ground friction kills the lunge instantly (the previous ~0.5 block issue). Tunable. + private static final double DASH_KNOCKBACK = 16.0; + private static final double DASH_KNOCKBACK_Y = 4.0; + private static final double DASH_RADIUS = 2.5; + private static final long DASH_SCAN_PERIOD = 100L; + private static final int DASH_SCANS = 5; + private static final long STUN_DURATION = 3_000L; + + private static final double SPIN_RADIUS = 3.0; + private static final float SPIN_DAMAGE = 3.0f; + private static final long SPIN_DURATION = 3_000L; + private static final long SPIN_PERIOD = 300L; + + private static final double SHOUT_RADIUS = 5.0; + private static final double SHOUT_KNOCKBACK = 18.0; + private static final double SHOUT_KNOCKBACK_Y = 4.0; + private static final float SHOUT_STUNNED_BONUS_DAMAGE = 6.0f; + private static final long RESISTANCE_DEBUFF_DURATION = 20_000L; + + // --- Archer --- + private static final long RAPID_FIRE_DURATION = 10_000L; + private static final int VOLLEY_ARROWS = 6; + private static final double VOLLEY_SPREAD_DEG = 9.0; + private static final float VOLLEY_ARROW_DAMAGE = 4.0f; + private static final double VOLLEY_RANGE = 18.0; + private static final double VOLLEY_BACK_KNOCKBACK = 12.0; + private static final double VOLLEY_BACK_KNOCKBACK_Y = 3.0; + + // --- Mage --- + private static final int FIREBALL_COUNT = 3; + private static final double FIREBALL_SPREAD_DEG = 12.0; + private static final float FIREBALL_DAMAGE = 7.0f; + private static final double FIREBALL_RANGE = 20.0; + private static final double FIREBALL_HIT_RADIUS = 1.6; + private static final double TELEPORT_DISTANCE = 8.0; + private static final double TORNADO_DISTANCE = 15.0; + private static final double TORNADO_RADIUS = 3.5; + private static final float TORNADO_DAMAGE = 4.0f; + private static final double TORNADO_PULL = 6.0; + private static final long TORNADO_DURATION = 4_000L; + private static final long TORNADO_PERIOD = 350L; + + // --- Assassin --- + private static final long CAMOUFLAGE_DURATION = 6_000L; + private static final double SHADOWSTEP_RANGE = 12.0; + private static final double SHADOWSTEP_BEHIND = 1.5; + private static final long POISON_BLADE_DURATION = 10_000L; + + // Projectile / spell visuals (shipped game particle systems; unknown ids fail silently). + private static final String VFX_ARROW_TRAIL = "Bow_Signature_Projectile_Sparks"; + private static final String VFX_ARROW_HIT = "Example_Projectile_Impact"; + private static final String VFX_FIRE_TRAIL = "Fire_Charged1"; + private static final String VFX_FIRE_HIT = "Impact_Explosion"; + private static final String VFX_TELEPORT = "Teleport"; + private static final String VFX_TORNADO = "Example_Tornado"; + private static final String VFX_TORNADO_HIT = "Impact_Battleaxe_Bash"; + private static final String VFX_SMOKE = "Smoke_Black"; + private static final String VFX_BUFF = "Test_Cast_Buff"; + private static final String SFX_BOW = "SFX_Bow_T1_Shoot"; + private static final String SFX_FIRE = "SFX_Staff_Flame_Fireball_Launch"; + private static final String SFX_TELEPORT = "SFX_Portal_Neutral"; + + // Visual / audio asset ids (shipped game assets). See CombatFx. + private static final String VFX_DASH = "Daggers_Signature_Dash"; + private static final String VFX_HIT = "Impact_Blade_01"; + private static final String VFX_SPIN = "Battleaxe_Signature_Whirlwind"; + private static final String VFX_SPIN_HIT = "Impact_Battleaxe_Bash"; + private static final String VFX_SHOUT = "Impact_Explosion"; + + private static final String SFX_ACTIVATE = "SFX_Avatar_Powers_Enable"; + private static final String SFX_SWING = "SFX_Golem_Earth_Swing"; + private static final String SFX_HIT = "SFX_Golem_Earth_Swing_Impact"; + private static final String SFX_SHOUT = "SFX_Deer_Stag_Roar"; + private static final String SFX_SHOUT_IMPACT = "SFX_Golem_Earth_Slam_Impact"; + + // Weapon animation moves (see Server/Item/Animations/{Battleaxe,Longsword}.json). + private static final String ANIM_SET_BATTLEAXE = "Battleaxe"; + private static final String ANIM_SET_LONGSWORD = "Longsword"; + private static final String ANIM_DASH = "SwingDown"; + private static final String ANIM_SHOUT = "GuardBash"; + private static final String ANIM_SPIN_BATTLEAXE = "WhirlwindChargedSpin"; + private static final String ANIM_SPIN_LONGSWORD_A = "SwingLeft"; + private static final String ANIM_SPIN_LONGSWORD_B = "SwingRight"; + + private final MmorpgScheduler scheduler; + private final StunService stunService; + private final CombatStateService combatState; + private final CombatBuffService combatBuffs; + + private final Map> cooldowns = new ConcurrentHashMap<>(); + + public AbilityService( + @Nonnull MmorpgScheduler scheduler, + @Nonnull StunService stunService, + @Nonnull CombatStateService combatState, + @Nonnull CombatBuffService combatBuffs) { + this.scheduler = scheduler; + this.stunService = stunService; + this.combatState = combatState; + this.combatBuffs = combatBuffs; + } + + public long cooldownRemaining(@Nonnull UUID player, @Nonnull String abilityId) { + Map map = cooldowns.get(player); + if (map == null) { + return 0L; + } + Long readyAt = map.get(abilityId); + if (readyAt == null) { + return 0L; + } + return Math.max(0L, readyAt - System.currentTimeMillis()); + } + + /** + * Attempts to cast {@code ability} for the player. The caller is responsible for verifying the + * player owns the class; this method enforces the weapon requirement and the cooldown. + */ + @Nonnull + public CastResult cast( + @Nonnull PlayerRef playerRef, + @Nonnull World world, + @Nonnull PlayerClass playerClass, + @Nonnull AbilityDefinition ability) { + return cast(playerRef, world, playerClass, ability, null); + } + + /** + * Casts {@code ability}. {@code aimedTarget} is the entity the player is currently looking at + * (supplied by the input-action handler, used by abilities such as the assassin's Shadow Step); + * it may be {@code null} when triggered from the command, in which case the target is resolved + * from the caster's aim. + */ + @Nonnull + public CastResult cast( + @Nonnull PlayerRef playerRef, + @Nonnull World world, + @Nonnull PlayerClass playerClass, + @Nonnull AbilityDefinition ability, + @Nullable Ref aimedTarget) { + UUID caster = playerRef.getUuid(); + Store store = world.getEntityStore().getStore(); + Ref casterRef = playerRef.getReference(); + + if (!hasRequiredWeapon(store, casterRef, playerClass)) { + return CastResult.WRONG_WEAPON; + } + if (cooldownRemaining(caster, ability.id()) > 0) { + return CastResult.ON_COOLDOWN; + } + + String weaponSet = resolveWeaponAnimSet(store, casterRef); + switch (ability.id()) { + case ClassCatalog.ABILITY_DASH -> castDash(world, store, casterRef, weaponSet); + case ClassCatalog.ABILITY_SPIN -> castSpin(world, store, casterRef, weaponSet); + case ClassCatalog.ABILITY_SHOUT -> castShout(world, store, casterRef, weaponSet); + + case ClassCatalog.ABILITY_ARCHER_RAPIDFIRE -> castRapidFire(world, store, casterRef, caster); + case ClassCatalog.ABILITY_ARCHER_VOLLEY -> castVolley(world, store, casterRef); + case ClassCatalog.ABILITY_ARCHER_POWERSHOT -> castPowerShot(world, store, casterRef, caster); + + case ClassCatalog.ABILITY_MAGE_FIREBALLS -> castFireballs(world, store, casterRef); + case ClassCatalog.ABILITY_MAGE_TELEPORT -> castTeleport(world, store, casterRef); + case ClassCatalog.ABILITY_MAGE_TORNADO -> castTornado(world, store, casterRef); + + case ClassCatalog.ABILITY_ASSASSIN_CAMOUFLAGE -> castCamouflage(world, store, casterRef, playerRef); + case ClassCatalog.ABILITY_ASSASSIN_SHADOWSTEP -> castShadowStep(world, store, casterRef, aimedTarget); + case ClassCatalog.ABILITY_ASSASSIN_POISONBLADE -> castPoisonBlade(world, store, casterRef, caster); + default -> { + return CastResult.UNKNOWN; + } + } + + combatState.markCombat(caster); + cooldowns.computeIfAbsent(caster, k -> new ConcurrentHashMap<>()) + .put(ability.id(), System.currentTimeMillis() + ability.cooldownMillis()); + return CastResult.OK; + } + + /** + * Maps the currently held weapon to its animation set id ({@code Battleaxe} / {@code Longsword}), + * so we can trigger the matching swing/whirlwind move. Returns {@code null} if it cannot be + * resolved (the animation step is then skipped, effects still play). + */ + private String resolveWeaponAnimSet( + @Nonnull Store store, + @Nonnull Ref casterRef) { + try { + ItemStack held = InventoryComponent.getItemInHand(store, casterRef); + if (ItemStack.isEmpty(held)) { + return null; + } + String itemId = held.getItemId(); + if (itemId == null) { + return null; + } + String lower = itemId.toLowerCase(); + if (lower.contains("battleaxe") || (lower.contains("axe") && !lower.contains("pickaxe"))) { + return ANIM_SET_BATTLEAXE; + } + return ANIM_SET_LONGSWORD; + } catch (Throwable t) { + return null; + } + } + + private boolean hasRequiredWeapon( + @Nonnull Store store, + @Nonnull Ref casterRef, + @Nonnull PlayerClass playerClass) { + try { + ItemStack held = InventoryComponent.getItemInHand(store, casterRef); + if (ItemStack.isEmpty(held)) { + return false; + } + String itemId = held.getItemId(); + if (itemId == null) { + return false; + } + for (String weapon : playerClass.weapons()) { + if (itemId.toLowerCase().contains(weapon.toLowerCase())) { + return true; + } + } + return false; + } catch (Throwable t) { + // If the inventory API shape differs at runtime, fail open so the ability is still usable. + return true; + } + } + + private void castDash( + @Nonnull World world, + @Nonnull Store store, + @Nonnull Ref casterRef, + String weaponSet) { + world.execute(() -> { + Vector3d direction = horizontalLookDirection(store, casterRef); + // Player movement is client-authoritative, so push the caster via the engine knockback + // system (syncs to the client) instead of writing Velocity directly. + CombatFx.knockback(store, casterRef, new Vector3d( + direction.x * DASH_KNOCKBACK, DASH_KNOCKBACK_Y, direction.z * DASH_KNOCKBACK)); + TransformComponent transform = store.getComponent(casterRef, TransformComponent.getComponentType()); + Vector3d pos = transform != null ? transform.getPosition() : new Vector3d(); + CombatFx.animation(store, casterRef, weaponSet, ANIM_DASH); + CombatFx.vfx(store, VFX_DASH, pos); + CombatFx.sound(store, SFX_ACTIVATE, pos, 1.0f, 1.2f); + CombatFx.sound(store, SFX_SWING, pos); + }); + + // Stun every enemy the dash passes through over its travel window. + for (int scan = 1; scan <= DASH_SCANS; scan++) { + scheduler.runLater(() -> world.execute(() -> { + TransformComponent transform = store.getComponent(casterRef, TransformComponent.getComponentType()); + if (transform == null) { + return; + } + Vector3d pos = transform.getPosition(); + for (Ref target : TargetUtil.getAllEntitiesInSphere( + pos, DASH_RADIUS, store)) { + if (target.equals(casterRef)) { + continue; + } + applyStun(world, store, target); + dealDamage(store, casterRef, target, 2.0f); + CombatFx.vfx(store, VFX_HIT, targetPosition(store, target, pos)); + CombatFx.sound(store, SFX_HIT, pos); + } + }), (long) scan * DASH_SCAN_PERIOD); + } + } + + private void castSpin( + @Nonnull World world, + @Nonnull Store store, + @Nonnull Ref casterRef, + String weaponSet) { + String spinAnimA = ANIM_SET_BATTLEAXE.equals(weaponSet) ? ANIM_SPIN_BATTLEAXE : ANIM_SPIN_LONGSWORD_A; + String spinAnimB = ANIM_SET_BATTLEAXE.equals(weaponSet) ? ANIM_SPIN_BATTLEAXE : ANIM_SPIN_LONGSWORD_B; + java.util.concurrent.atomic.AtomicInteger tick = new java.util.concurrent.atomic.AtomicInteger(); + + var future = scheduler.runRepeating(() -> world.execute(() -> { + TransformComponent transform = store.getComponent(casterRef, TransformComponent.getComponentType()); + if (transform == null) { + return; + } + Vector3d pos = transform.getPosition(); + int n = tick.getAndIncrement(); + // Keep the whirlwind spinning visually and audibly for its whole duration. Each puff is + // spawned with a short, explicit lifetime so it follows the player and fully disappears + // when the spin ends (otherwise the looping aura would stay on screen forever). + CombatFx.animation(store, casterRef, weaponSet, (n % 2 == 0) ? spinAnimA : spinAnimB); + CombatFx.vfxTimed(store, VFX_SPIN, pos, 1.0f, (SPIN_PERIOD / 1000.0f) * 1.2f); + CombatFx.sound(store, SFX_SWING, pos, 0.8f, 1.1f); + for (Ref target : TargetUtil.getAllEntitiesInSphere(pos, SPIN_RADIUS, store)) { + if (target.equals(casterRef)) { + continue; + } + dealDamage(store, casterRef, target, SPIN_DAMAGE); + CombatFx.vfx(store, VFX_SPIN_HIT, targetPosition(store, target, pos)); + } + }), 0L, SPIN_PERIOD); + scheduler.runLater(() -> future.cancel(false), SPIN_DURATION); + } + + private void castShout( + @Nonnull World world, + @Nonnull Store store, + @Nonnull Ref casterRef, + String weaponSet) { + world.execute(() -> { + TransformComponent casterTransform = store.getComponent(casterRef, TransformComponent.getComponentType()); + if (casterTransform == null) { + return; + } + Vector3d origin = casterTransform.getPosition(); + // Fallback push direction (caster's facing) for targets standing right on top of the + // player, where origin-to-target has no usable direction. + Vector3d facing = horizontalLookDirection(store, casterRef); + CombatFx.animation(store, casterRef, weaponSet, ANIM_SHOUT); + CombatFx.vfx(store, VFX_SHOUT, origin); + CombatFx.sound(store, SFX_SHOUT, origin, 1.2f, 0.9f); + CombatFx.sound(store, SFX_SHOUT_IMPACT, origin); + for (Ref target : TargetUtil.getAllEntitiesInSphere(origin, SHOUT_RADIUS, store)) { + if (target.equals(casterRef)) { + continue; + } + TransformComponent targetTransform = + store.getComponent(target, TransformComponent.getComponentType()); + if (targetTransform != null) { + Vector3d push = new Vector3d(targetTransform.getPosition()).sub(origin); + push.y = 0; + if (push.lengthSquared() < 1.0e-4) { + // Mob glued to the player: shove it away along the caster's look direction. + push.set(facing.x, 0, facing.z); + } + if (push.lengthSquared() < 1.0e-4) { + push.set(0, 0, 1); + } + push.normalize(); + // Client-synced knockback so both players and mobs are actually pushed back. + CombatFx.knockback(store, target, new Vector3d( + push.x * SHOUT_KNOCKBACK, SHOUT_KNOCKBACK_Y, push.z * SHOUT_KNOCKBACK)); + } + + UUID targetUuid = entityUuid(store, target); + if (targetUuid != null) { + if (stunService.isStunned(targetUuid)) { + dealDamage(store, casterRef, target, SHOUT_STUNNED_BONUS_DAMAGE); + } + // -20% resistance == takes +20% damage for 20s (applied in the damage system). + combatState.applyResistanceDebuff(targetUuid, RESISTANCE_DEBUFF_DURATION); + } + } + }); + } + + // ------------------------------------------------------------------------------------------ + // Archer + // ------------------------------------------------------------------------------------------ + + private void castRapidFire( + @Nonnull World world, + @Nonnull Store store, + @Nonnull Ref casterRef, + @Nonnull UUID caster) { + combatBuffs.grantRapidFire(caster, RAPID_FIRE_DURATION); + world.execute(() -> { + Vector3d pos = position(store, casterRef); + CombatFx.vfx(store, VFX_BUFF, pos); + CombatFx.sound(store, SFX_ACTIVATE, pos, 1.0f, 1.4f); + }); + } + + private void castVolley( + @Nonnull World world, + @Nonnull Store store, + @Nonnull Ref casterRef) { + world.execute(() -> { + Vector3d facing = horizontalLookDirection(store, casterRef); + // Bond en arrière. + CombatFx.knockback(store, casterRef, new Vector3d( + -facing.x * VOLLEY_BACK_KNOCKBACK, VOLLEY_BACK_KNOCKBACK_Y, -facing.z * VOLLEY_BACK_KNOCKBACK)); + Vector3d eye = eyePosition(store, casterRef); + CombatFx.sound(store, SFX_BOW, eye, 1.0f, 1.1f); + double start = -(VOLLEY_ARROWS - 1) / 2.0; + for (int i = 0; i < VOLLEY_ARROWS; i++) { + Vector3d dir = rotatedHorizontal(facing, (start + i) * VOLLEY_SPREAD_DEG); + fireProjectile(store, casterRef, eye, dir, VOLLEY_RANGE, 1.2, + VOLLEY_ARROW_DAMAGE, VFX_ARROW_TRAIL, VFX_ARROW_HIT); + } + }); + } + + private void castPowerShot( + @Nonnull World world, + @Nonnull Store store, + @Nonnull Ref casterRef, + @Nonnull UUID caster) { + combatBuffs.chargePowerShot(caster); + world.execute(() -> { + Vector3d pos = position(store, casterRef); + CombatFx.vfx(store, VFX_BUFF, pos); + CombatFx.sound(store, SFX_ACTIVATE, pos, 1.0f, 0.8f); + }); + } + + // ------------------------------------------------------------------------------------------ + // Mage + // ------------------------------------------------------------------------------------------ + + private void castFireballs( + @Nonnull World world, + @Nonnull Store store, + @Nonnull Ref casterRef) { + world.execute(() -> { + Vector3d facing = horizontalLookDirection(store, casterRef); + Vector3d eye = eyePosition(store, casterRef); + CombatFx.sound(store, SFX_FIRE, eye, 1.1f, 1.0f); + double start = -(FIREBALL_COUNT - 1) / 2.0; + for (int i = 0; i < FIREBALL_COUNT; i++) { + Vector3d dir = rotatedHorizontal(facing, (start + i) * FIREBALL_SPREAD_DEG); + fireProjectile(store, casterRef, eye, dir, FIREBALL_RANGE, FIREBALL_HIT_RADIUS, + FIREBALL_DAMAGE, VFX_FIRE_TRAIL, VFX_FIRE_HIT); + } + }); + } + + private void castTeleport( + @Nonnull World world, + @Nonnull Store store, + @Nonnull Ref casterRef) { + world.execute(() -> { + TransformComponent transform = store.getComponent(casterRef, TransformComponent.getComponentType()); + if (transform == null) { + return; + } + Vector3d from = new Vector3d(transform.getPosition()); + Vector3d dir = horizontalLookDirection(store, casterRef); + Vector3d to = new Vector3d(from).add(dir.x * TELEPORT_DISTANCE, 0, dir.z * TELEPORT_DISTANCE); + CombatFx.vfx(store, VFX_TELEPORT, from); + CombatFx.sound(store, SFX_TELEPORT, from, 1.0f, 1.0f); + transform.teleportPosition(to); + CombatFx.vfx(store, VFX_TELEPORT, to); + CombatFx.sound(store, SFX_TELEPORT, to, 1.0f, 1.2f); + }); + } + + private void castTornado( + @Nonnull World world, + @Nonnull Store store, + @Nonnull Ref casterRef) { + Vector3d[] center = new Vector3d[1]; + world.execute(() -> { + Vector3d eye = eyePosition(store, casterRef); + Vector3d dir = horizontalLookDirection(store, casterRef); + center[0] = new Vector3d(eye).add(dir.x * TORNADO_DISTANCE, -1.0, dir.z * TORNADO_DISTANCE); + CombatFx.sound(store, SFX_FIRE, center[0], 1.3f, 0.7f); + }); + var future = scheduler.runRepeating(() -> world.execute(() -> { + if (center[0] == null) { + return; + } + Vector3d pos = center[0]; + CombatFx.vfxTimed(store, VFX_TORNADO, pos, 1.5f, (TORNADO_PERIOD / 1000.0f) * 1.3f); + for (Ref target : TargetUtil.getAllEntitiesInSphere(pos, TORNADO_RADIUS, store)) { + if (target.equals(casterRef)) { + continue; + } + Vector3d tpos = targetPosition(store, target, pos); + Vector3d pull = new Vector3d(pos).sub(tpos); + pull.y = 0; + if (pull.lengthSquared() > 1.0e-4) { + pull.normalize(); + } + CombatFx.knockback(store, target, new Vector3d( + pull.x * TORNADO_PULL, 2.5, pull.z * TORNADO_PULL)); + dealDamage(store, casterRef, target, TORNADO_DAMAGE); + CombatFx.vfx(store, VFX_TORNADO_HIT, tpos); + } + }), 0L, TORNADO_PERIOD); + scheduler.runLater(() -> future.cancel(false), TORNADO_DURATION); + } + + // ------------------------------------------------------------------------------------------ + // Assassin + // ------------------------------------------------------------------------------------------ + + private void castCamouflage( + @Nonnull World world, + @Nonnull Store store, + @Nonnull Ref casterRef, + @Nonnull PlayerRef playerRef) { + UUID caster = playerRef.getUuid(); + combatBuffs.markInvisible(caster, CAMOUFLAGE_DURATION); + world.execute(() -> { + Vector3d pos = position(store, casterRef); + CombatFx.vfx(store, VFX_SMOKE, pos); + CombatFx.sound(store, SFX_ACTIVATE, pos, 1.0f, 0.7f); + for (PlayerRef viewer : world.getPlayerRefs()) { + try { + viewer.getHiddenPlayersManager().hidePlayer(caster); + } catch (Throwable ignored) { + // viewer not ready: skip. + } + } + }); + scheduler.runLater(() -> world.execute(() -> { + for (PlayerRef viewer : world.getPlayerRefs()) { + try { + viewer.getHiddenPlayersManager().showPlayer(caster); + } catch (Throwable ignored) { + // viewer gone: skip. + } + } + CombatFx.vfx(store, VFX_SMOKE, position(store, casterRef)); + }), CAMOUFLAGE_DURATION); + } + + private void castShadowStep( + @Nonnull World world, + @Nonnull Store store, + @Nonnull Ref casterRef, + @Nullable Ref aimedTarget) { + world.execute(() -> { + Ref target = aimedTarget; + if (target == null || !target.isValid() || target.equals(casterRef)) { + target = TargetUtil.getTargetEntity(casterRef, (float) SHADOWSTEP_RANGE, store); + } + TransformComponent casterTransform = + store.getComponent(casterRef, TransformComponent.getComponentType()); + if (casterTransform == null) { + return; + } + if (target == null || !target.isValid() || target.equals(casterRef)) { + // No target in sight: short blink forward instead of failing outright. + Vector3d dir = horizontalLookDirection(store, casterRef); + Vector3d from = new Vector3d(casterTransform.getPosition()); + Vector3d to = new Vector3d(from).add(dir.x * 3.0, 0, dir.z * 3.0); + CombatFx.vfx(store, VFX_TELEPORT, from); + casterTransform.teleportPosition(to); + CombatFx.vfx(store, VFX_TELEPORT, to); + CombatFx.sound(store, SFX_TELEPORT, to, 1.0f, 1.0f); + return; + } + TransformComponent targetTransform = + store.getComponent(target, TransformComponent.getComponentType()); + if (targetTransform == null) { + return; + } + Vector3d targetPos = new Vector3d(targetTransform.getPosition()); + Vector3d targetFacing = horizontalLookDirection(store, target); + Vector3d behind = new Vector3d(targetPos) + .sub(targetFacing.x * SHADOWSTEP_BEHIND, 0, targetFacing.z * SHADOWSTEP_BEHIND); + Vector3d from = new Vector3d(casterTransform.getPosition()); + CombatFx.vfx(store, VFX_SMOKE, from); + casterTransform.teleportPosition(behind); + CombatFx.vfx(store, VFX_TELEPORT, behind); + CombatFx.sound(store, SFX_TELEPORT, behind, 1.0f, 0.9f); + }); + } + + private void castPoisonBlade( + @Nonnull World world, + @Nonnull Store store, + @Nonnull Ref casterRef, + @Nonnull UUID caster) { + combatBuffs.grantPoisonBlade(caster, POISON_BLADE_DURATION); + world.execute(() -> { + Vector3d pos = position(store, casterRef); + CombatFx.vfx(store, "Impact_Poison", pos); + CombatFx.sound(store, SFX_ACTIVATE, pos, 1.0f, 0.9f); + }); + } + + // ------------------------------------------------------------------------------------------ + // Shared helpers for the new abilities + // ------------------------------------------------------------------------------------------ + + /** + * Casts a straight projectile by stepping along {@code dir} from {@code origin}, leaving a + * particle streak and damaging the first entity within {@code hitRadius} of the path. A + * server-side approximation of a real arrow/fireball (no engine projectile entity is spawned), + * but it reads as a projectile and deals damage. + */ + private void fireProjectile( + @Nonnull Store store, + @Nonnull Ref casterRef, + @Nonnull Vector3d origin, + @Nonnull Vector3d dir, + double range, + double hitRadius, + float damage, + @Nonnull String trailVfx, + @Nonnull String hitVfx) { + Vector3d point = new Vector3d(origin); + Vector3d step = new Vector3d(dir).mul(1.0); + int steps = (int) Math.ceil(range); + for (int i = 0; i < steps; i++) { + point.add(step); + CombatFx.vfxTimed(store, trailVfx, new Vector3d(point), 0.5f, 0.35f); + for (Ref target : TargetUtil.getAllEntitiesInSphere(point, hitRadius, store)) { + if (target.equals(casterRef)) { + continue; + } + dealDamage(store, casterRef, target, damage); + CombatFx.vfx(store, hitVfx, targetPosition(store, target, point)); + return; + } + } + } + + @Nonnull + private Vector3d rotatedHorizontal(@Nonnull Vector3d base, double degrees) { + Vector3d v = new Vector3d(base); + v.y = 0; + v.rotateY((float) Math.toRadians(degrees)); + if (v.lengthSquared() < 1.0e-4) { + return new Vector3d(0, 0, 1); + } + return v.normalize(); + } + + @Nonnull + private Vector3d eyePosition(@Nonnull Store store, @Nonnull Ref ref) { + return new Vector3d(position(store, ref)).add(0, 1.5, 0); + } + + @Nonnull + private Vector3d position(@Nonnull Store store, @Nonnull Ref ref) { + TransformComponent transform = store.getComponent(ref, TransformComponent.getComponentType()); + return transform != null ? new Vector3d(transform.getPosition()) : new Vector3d(); + } + + private void applyStun( + @Nonnull World world, + @Nonnull Store store, + @Nonnull Ref target) { + UUID uuid = entityUuid(store, target); + if (uuid != null) { + if (stunService.isStunned(uuid)) { + return; + } + stunService.stun(uuid, STUN_DURATION); + } + var future = scheduler.runRepeating(() -> world.execute(() -> { + Velocity velocity = store.getComponent(target, Velocity.getComponentType()); + if (velocity != null) { + velocity.setZero(); + } + }), 0L, 100L); + scheduler.runLater(() -> future.cancel(false), STUN_DURATION); + } + + private void dealDamage( + @Nonnull Store store, + @Nonnull Ref casterRef, + @Nonnull Ref target, + float amount) { + Damage damage = new Damage(new Damage.EntitySource(casterRef), DamageCause.PHYSICAL, amount); + DamageSystems.executeDamage(target, store, damage); + } + + @Nonnull + private Vector3d horizontalLookDirection( + @Nonnull Store store, + @Nonnull Ref casterRef) { + // Use the HEAD rotation (precise camera yaw), not the body TransformComponent rotation which + // is coarse/axis-snapped — that snapping made the dash align to X/Z instead of the true aim. + // This mirrors how the engine itself derives a knockback direction (see KnockbackApplyCommand). + Float yaw = null; + HeadRotation head = store.getComponent(casterRef, HeadRotation.getComponentType()); + if (head != null && head.getRotation() != null) { + yaw = head.getRotation().yaw(); + } else { + TransformComponent transform = store.getComponent(casterRef, TransformComponent.getComponentType()); + if (transform != null && transform.getRotation() != null) { + yaw = transform.getRotation().yaw(); + } + } + if (yaw == null) { + return new Vector3d(0, 0, 1); + } + Vector3d direction = new Vector3d(0, 0, -1).rotateY(yaw); + direction.y = 0; + if (direction.lengthSquared() < 1.0e-4) { + return new Vector3d(0, 0, 1); + } + return direction.normalize(); + } + + @Nonnull + private Vector3d targetPosition( + @Nonnull Store store, + @Nonnull Ref target, + @Nonnull Vector3d fallback) { + TransformComponent transform = store.getComponent(target, TransformComponent.getComponentType()); + return transform != null ? transform.getPosition() : fallback; + } + + private UUID entityUuid(@Nonnull Store store, @Nonnull Ref ref) { + UUIDComponent component = store.getComponent(ref, UUIDComponent.getComponentType()); + return component == null ? null : component.getUuid(); + } + + public enum CastResult { + OK, ON_COOLDOWN, WRONG_WEAPON, UNKNOWN + } +} diff --git a/src/main/java/com/disklexar/mmorpg/combat/ClassAbilityController.java b/src/main/java/com/disklexar/mmorpg/combat/ClassAbilityController.java new file mode 100644 index 0000000..b5ab98e --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/combat/ClassAbilityController.java @@ -0,0 +1,119 @@ +package com.disklexar.mmorpg.combat; + +import com.disklexar.mmorpg.MmorpgPlugin; +import com.disklexar.mmorpg.bootstrap.Bootstrap; +import com.disklexar.mmorpg.player.PlayerSessionService; +import com.disklexar.mmorpg.player.model.PlayerProfile; +import com.disklexar.mmorpg.rpg.clazz.AbilityDefinition; +import com.disklexar.mmorpg.rpg.clazz.ClassService; +import com.disklexar.mmorpg.rpg.clazz.PlayerClass; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.protocol.InteractionType; +import com.hypixel.hytale.server.core.Message; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * Central entry point for routing Ability 1/2/3 inputs to MMORPG class abilities. + * Used by the packet filter, native {@code mmorpg_cast_ability} interaction, and commands. + */ +public final class ClassAbilityController { + + private final MmorpgPlugin plugin; + + public ClassAbilityController(@Nonnull MmorpgPlugin plugin) { + this.plugin = plugin; + } + + /** + * @return {@code true} when the player has an MMORPG class and vanilla ability interactions + * should be suppressed for this slot (even if the cast fails, e.g. wrong weapon). + */ + public boolean shouldSuppressVanilla(@Nonnull PlayerRef playerRef, int slot) { + return resolveAbility(playerRef, slot) != null; + } + + /** + * Attempts to cast the class ability bound to {@code slot}. + * + * @return {@code true} if vanilla should be blocked (player has a class with this slot). + */ + public boolean tryCast( + @Nonnull PlayerRef playerRef, + @Nonnull World world, + int slot, + @Nullable Ref aimedTarget) { + ResolvedAbility resolved = resolveAbility(playerRef, slot); + if (resolved == null) { + return false; + } + + AbilityService.CastResult result = resolved.abilities().cast( + playerRef, world, resolved.playerClass(), resolved.ability(), aimedTarget); + + switch (result) { + case OK -> playerRef.sendMessage(Message.raw("Capacité : " + resolved.ability().displayName())); + case ON_COOLDOWN -> { + long remaining = resolved.abilities().cooldownRemaining( + playerRef.getUuid(), resolved.ability().id()); + playerRef.sendMessage(Message.raw("En recharge (" + (remaining / 1000 + 1) + "s).")); + } + case WRONG_WEAPON -> playerRef.sendMessage(Message.raw( + "Mauvaise arme. Équipez : " + String.join(", ", resolved.playerClass().weapons()))); + case UNKNOWN -> playerRef.sendMessage(Message.raw("Capacité inconnue.")); + } + return true; + } + + public static int slotFor(@Nonnull InteractionType type) { + return switch (type) { + case Ability1 -> 1; + case Ability2 -> 2; + case Ability3 -> 3; + default -> 0; + }; + } + + public static boolean isClassAbilityType(@Nonnull InteractionType type) { + return slotFor(type) != 0; + } + + @Nullable + private ResolvedAbility resolveAbility(@Nonnull PlayerRef playerRef, int slot) { + Bootstrap bootstrap = plugin.getBootstrap(); + if (bootstrap == null) { + return null; + } + + PlayerSessionService sessions = bootstrap.getRegistry().require(PlayerSessionService.class); + ClassService classService = bootstrap.getRegistry().require(ClassService.class); + AbilityService abilities = bootstrap.getRegistry().require(AbilityService.class); + + PlayerProfile profile = sessions.getSession(playerRef.getUuid()); + if (profile == null) { + return null; + } + PlayerClass playerClass = classService.getClass(profile); + if (playerClass == null) { + return null; + } + AbilityDefinition ability = playerClass.abilities().stream() + .filter(a -> a.slot() == slot) + .findFirst() + .orElse(null); + if (ability == null) { + return null; + } + return new ResolvedAbility(playerClass, ability, abilities); + } + + private record ResolvedAbility( + PlayerClass playerClass, + AbilityDefinition ability, + AbilityService abilities) { + } +} diff --git a/src/main/java/com/disklexar/mmorpg/combat/CombatBuffService.java b/src/main/java/com/disklexar/mmorpg/combat/CombatBuffService.java new file mode 100644 index 0000000..39ca1fa --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/combat/CombatBuffService.java @@ -0,0 +1,72 @@ +package com.disklexar.mmorpg.combat; + +import javax.annotation.Nonnull; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Tracks the temporary offensive buffs granted by class abilities and read back by the damage + * system when the buffed player lands a basic attack: + *
    + *
  • Archer "Tir rapide": a flat outgoing-damage bonus for a window (server-side proxy for + * attack speed, which is otherwise client-authoritative),
  • + *
  • Archer "Tir puissant": a one-shot +200% multiplier on the next basic attack,
  • + *
  • Assassin "Lame Empoisonnée": a window during which every hit applies a stacking poison,
  • + *
  • Assassin "Camouflage": bookkeeping of the current invisibility window.
  • + *
+ */ +public final class CombatBuffService { + + private final Map rapidFireUntil = new ConcurrentHashMap<>(); + private final Set powerShotCharged = ConcurrentHashMap.newKeySet(); + private final Map poisonBladeUntil = new ConcurrentHashMap<>(); + private final Map invisibleUntil = new ConcurrentHashMap<>(); + + public void grantRapidFire(@Nonnull UUID uuid, long durationMillis) { + rapidFireUntil.put(uuid, System.currentTimeMillis() + durationMillis); + } + + public boolean hasRapidFire(@Nonnull UUID uuid) { + Long until = rapidFireUntil.get(uuid); + return until != null && System.currentTimeMillis() < until; + } + + public void chargePowerShot(@Nonnull UUID uuid) { + powerShotCharged.add(uuid); + } + + public boolean hasPowerShot(@Nonnull UUID uuid) { + return powerShotCharged.contains(uuid); + } + + public boolean consumePowerShot(@Nonnull UUID uuid) { + return powerShotCharged.remove(uuid); + } + + public void grantPoisonBlade(@Nonnull UUID uuid, long durationMillis) { + poisonBladeUntil.put(uuid, System.currentTimeMillis() + durationMillis); + } + + public boolean hasPoisonBlade(@Nonnull UUID uuid) { + Long until = poisonBladeUntil.get(uuid); + return until != null && System.currentTimeMillis() < until; + } + + public void markInvisible(@Nonnull UUID uuid, long durationMillis) { + invisibleUntil.put(uuid, System.currentTimeMillis() + durationMillis); + } + + public boolean isInvisible(@Nonnull UUID uuid) { + Long until = invisibleUntil.get(uuid); + return until != null && System.currentTimeMillis() < until; + } + + public void clear(@Nonnull UUID uuid) { + rapidFireUntil.remove(uuid); + powerShotCharged.remove(uuid); + poisonBladeUntil.remove(uuid); + invisibleUntil.remove(uuid); + } +} diff --git a/src/main/java/com/disklexar/mmorpg/combat/CombatFx.java b/src/main/java/com/disklexar/mmorpg/combat/CombatFx.java new file mode 100644 index 0000000..8471948 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/combat/CombatFx.java @@ -0,0 +1,148 @@ +package com.disklexar.mmorpg.combat; + +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.protocol.AnimationSlot; +import com.hypixel.hytale.protocol.ChangeVelocityType; +import com.hypixel.hytale.protocol.SoundCategory; +import com.hypixel.hytale.protocol.VelocityThresholdStyle; +import com.hypixel.hytale.server.core.asset.type.soundevent.config.SoundEvent; +import com.hypixel.hytale.server.core.entity.AnimationUtils; +import com.hypixel.hytale.server.core.entity.knockback.KnockbackComponent; +import com.hypixel.hytale.server.core.modules.splitvelocity.VelocityConfig; +import com.hypixel.hytale.server.core.universe.world.ParticleUtil; +import com.hypixel.hytale.server.core.universe.world.SoundUtil; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; +import org.joml.Vector3d; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * Thin, failure-safe wrappers around the verified server effect helpers + * ({@link ParticleUtil}, {@link SoundUtil}, {@link AnimationUtils}). Each call is guarded so a + * missing asset id or a runtime API mismatch can never break the ability or disconnect the player — + * the worst case is simply that one effect is skipped. + *

+ * Asset ids (particle systems / sound events / weapon animation moves) are taken from the shipped + * game assets, e.g. {@code Battleaxe_Signature_Whirlwind}, {@code Impact_Explosion}, + * {@code SFX_Deer_Stag_Roar}, animation move {@code WhirlwindChargedSpin}. + */ +public final class CombatFx { + + private CombatFx() { + } + + /** Spawns a world particle system (broadcast to nearby players) at a position. */ + public static void vfx( + @Nonnull Store store, + @Nonnull String particleSystemId, + @Nonnull Vector3d position) { + try { + ParticleUtil.spawnParticleEffect(particleSystemId, new Vector3d(position), store); + } catch (Throwable ignored) { + // Unknown particle id or API shape mismatch: skip the effect silently. + } + } + + /** + * Spawns a particle system with an explicit lifetime ({@code durationSeconds}) so it is + * guaranteed to disappear — used for looping/aura effects (e.g. the whirlwind) that would + * otherwise stay on screen forever. + */ + public static void vfxTimed( + @Nonnull Store store, + @Nonnull String particleSystemId, + @Nonnull Vector3d position, + float scale, + float durationSeconds) { + try { + ParticleUtil.spawnParticleEffect( + particleSystemId, new Vector3d(position), 0f, 0f, 0f, scale, durationSeconds, store); + } catch (Throwable ignored) { + // Unknown particle id or API shape mismatch: skip the effect silently. + } + } + + /** + * Applies a client-synced knockback/impulse to an entity (player or mob) via the engine's + * {@link KnockbackComponent}. This is the only reliable way to move a player from the + * server: directly writing {@code Velocity} is ignored because player movement is + * client-authoritative. Must be called on the world thread. + */ + public static void knockback( + @Nonnull Store store, + @Nonnull Ref entity, + @Nonnull Vector3d velocity) { + try { + KnockbackComponent kb = store.ensureAndGetComponent(entity, KnockbackComponent.getComponentType()); + kb.setVelocity(new Vector3d(velocity)); + kb.setVelocityType(ChangeVelocityType.Set); + kb.setVelocityConfig(defaultVelocityConfig()); + kb.setDuration(0f); + kb.setTimer(0f); + } catch (Throwable ignored) { + // Knockback API mismatch: skip silently (ability still deals damage / plays effects). + } + } + + @Nonnull + private static VelocityConfig defaultVelocityConfig() { + // Mirrors the engine's default knockback decay preset (see KnockbackApplyCommand). + VelocityConfig config = new VelocityConfig(); + config.setAirResistance(0.99f); + config.setAirResistanceMax(0.98f); + config.setGroundResistance(0.94f); + config.setGroundResistanceMax(0.3f); + config.setThreshold(3.0f); + config.setStyle(VelocityThresholdStyle.Linear); + return config; + } + + /** Plays a 3D sound event (broadcast to nearby players) at a position. */ + public static void sound( + @Nonnull Store store, + @Nonnull String soundEventId, + @Nonnull Vector3d position) { + sound(store, soundEventId, position, 1.0f, 1.0f); + } + + public static void sound( + @Nonnull Store store, + @Nonnull String soundEventId, + @Nonnull Vector3d position, + float volume, + float pitch) { + try { + int index = SoundEvent.getAssetMap().getIndexOrDefault(soundEventId, -1); + if (index < 0) { + return; + } + SoundUtil.playSoundEvent3d( + index, SoundCategory.SFX, position.x, position.y, position.z, volume, pitch, store); + } catch (Throwable ignored) { + // Unknown sound id or API shape mismatch: skip the sound silently. + } + } + + /** + * Plays a weapon animation move on the entity (e.g. {@code WhirlwindChargedSpin}). The move must + * belong to the supplied weapon animation set ({@code Battleaxe} / {@code Longsword}); a null set + * skips the animation. + */ + public static void animation( + @Nonnull Store store, + @Nonnull Ref entity, + @Nullable String weaponAnimationSet, + @Nonnull String animationMove) { + if (weaponAnimationSet == null) { + return; + } + try { + AnimationUtils.playAnimation( + entity, AnimationSlot.Action, weaponAnimationSet, animationMove, store); + } catch (Throwable ignored) { + // Animation set/move not loaded for this entity: skip silently. + } + } +} diff --git a/src/main/java/com/disklexar/mmorpg/combat/CombatStateService.java b/src/main/java/com/disklexar/mmorpg/combat/CombatStateService.java new file mode 100644 index 0000000..93d3ab6 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/combat/CombatStateService.java @@ -0,0 +1,58 @@ +package com.disklexar.mmorpg.combat; + +import javax.annotation.Nonnull; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Tracks lightweight combat state per player: + *

    + *
  • last time the player took damage (drives the "Résilient" power and out-of-combat detection),
  • + *
  • temporary resistance debuffs applied by abilities (e.g. the Knight war cry).
  • + *
+ */ +public final class CombatStateService { + + /** A player is considered "in combat" for this long after taking or dealing damage. */ + public static final long IN_COMBAT_WINDOW_MILLIS = 6_000L; + + private final Map lastDamageTakenAt = new ConcurrentHashMap<>(); + private final Map lastCombatAt = new ConcurrentHashMap<>(); + private final Map resistanceDebuffUntil = new ConcurrentHashMap<>(); + + public void markDamageTaken(@Nonnull UUID uuid) { + long now = System.currentTimeMillis(); + lastDamageTakenAt.put(uuid, now); + lastCombatAt.put(uuid, now); + } + + public void markCombat(@Nonnull UUID uuid) { + lastCombatAt.put(uuid, System.currentTimeMillis()); + } + + public boolean wasRecentlyHit(@Nonnull UUID uuid, long windowMillis) { + Long at = lastDamageTakenAt.get(uuid); + return at != null && System.currentTimeMillis() - at <= windowMillis; + } + + public boolean isInCombat(@Nonnull UUID uuid) { + Long at = lastCombatAt.get(uuid); + return at != null && System.currentTimeMillis() - at <= IN_COMBAT_WINDOW_MILLIS; + } + + public void applyResistanceDebuff(@Nonnull UUID uuid, long durationMillis) { + resistanceDebuffUntil.put(uuid, System.currentTimeMillis() + durationMillis); + } + + public boolean hasResistanceDebuff(@Nonnull UUID uuid) { + Long until = resistanceDebuffUntil.get(uuid); + return until != null && System.currentTimeMillis() < until; + } + + public void clear(@Nonnull UUID uuid) { + lastDamageTakenAt.remove(uuid); + lastCombatAt.remove(uuid); + resistanceDebuffUntil.remove(uuid); + } +} diff --git a/src/main/java/com/disklexar/mmorpg/combat/MmorpgScheduler.java b/src/main/java/com/disklexar/mmorpg/combat/MmorpgScheduler.java new file mode 100644 index 0000000..2ffba43 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/combat/MmorpgScheduler.java @@ -0,0 +1,69 @@ +package com.disklexar.mmorpg.combat; + +import com.disklexar.mmorpg.MmorpgPlugin; +import com.disklexar.mmorpg.core.service.Service; +import com.hypixel.hytale.logger.HytaleLogger; + +import javax.annotation.Nonnull; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.logging.Level; + +/** + * A small dedicated scheduler for timed gameplay effects (ability ticks, buff reconciliation). + *

+ * The scheduler runs on its own thread; callbacks that touch ECS state must hop onto the world + * thread via {@link com.hypixel.hytale.server.core.universe.world.World#execute(Runnable)}. + */ +public final class MmorpgScheduler implements Service { + + private final HytaleLogger logger; + private ScheduledExecutorService executor; + + public MmorpgScheduler(@Nonnull MmorpgPlugin plugin) { + this.logger = plugin.getLogger(); + } + + @Override + public void start() { + executor = Executors.newScheduledThreadPool(2, runnable -> { + Thread thread = new Thread(runnable, "mmorpg-scheduler"); + thread.setDaemon(true); + return thread; + }); + } + + @Override + public void shutdown() { + if (executor != null) { + executor.shutdownNow(); + executor = null; + } + } + + public void runLater(@Nonnull Runnable task, long delayMillis) { + if (executor == null) { + return; + } + executor.schedule(wrap(task), Math.max(0L, delayMillis), TimeUnit.MILLISECONDS); + } + + @Nonnull + public ScheduledFuture runRepeating(@Nonnull Runnable task, long initialDelayMillis, long periodMillis) { + return executor.scheduleAtFixedRate( + wrap(task), Math.max(0L, initialDelayMillis), Math.max(1L, periodMillis), TimeUnit.MILLISECONDS); + } + + @Nonnull + private Runnable wrap(@Nonnull Runnable task) { + return () -> { + try { + task.run(); + } catch (Throwable t) { + logger.at(Level.WARNING).log("MMORPG scheduled task failed: %s", t.getMessage()); + } + }; + } +} diff --git a/src/main/java/com/disklexar/mmorpg/combat/PassiveEffectService.java b/src/main/java/com/disklexar/mmorpg/combat/PassiveEffectService.java new file mode 100644 index 0000000..0b05871 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/combat/PassiveEffectService.java @@ -0,0 +1,92 @@ +package com.disklexar.mmorpg.combat; + +import com.disklexar.mmorpg.MmorpgPlugin; +import com.disklexar.mmorpg.core.service.Service; +import com.disklexar.mmorpg.player.OnlinePlayer; +import com.disklexar.mmorpg.player.OnlinePlayerRegistry; +import com.disklexar.mmorpg.player.PlayerSessionService; +import com.disklexar.mmorpg.player.model.PlayerProfile; +import com.disklexar.mmorpg.rpg.power.PowerCatalog; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.protocol.MovementSettings; +import com.hypixel.hytale.server.core.entity.entities.player.movement.MovementManager; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; + +import javax.annotation.Nonnull; +import java.util.concurrent.ScheduledFuture; + +/** + * Reconciles passive movement effects every couple of seconds. + *

+ * The "Sprinter" power grants {@code +10%} base movement speed while the player is out of combat; + * the bonus is removed while in combat and restored afterwards. + */ +public final class PassiveEffectService implements Service { + + private static final long RECONCILE_PERIOD = 2_000L; + + private final MmorpgScheduler scheduler; + private final OnlinePlayerRegistry online; + private final PlayerSessionService sessions; + private final CombatStateService combatState; + + private ScheduledFuture task; + + public PassiveEffectService( + @Nonnull MmorpgPlugin plugin, + @Nonnull MmorpgScheduler scheduler, + @Nonnull OnlinePlayerRegistry online, + @Nonnull PlayerSessionService sessions, + @Nonnull CombatStateService combatState) { + this.scheduler = scheduler; + this.online = online; + this.sessions = sessions; + this.combatState = combatState; + } + + @Override + public void start() { + task = scheduler.runRepeating(this::reconcileAll, RECONCILE_PERIOD, RECONCILE_PERIOD); + } + + @Override + public void shutdown() { + if (task != null) { + task.cancel(false); + task = null; + } + } + + private void reconcileAll() { + for (OnlinePlayer player : online.all()) { + PlayerProfile profile = sessions.getSession(player.uuid()); + if (profile == null) { + continue; + } + boolean sprinter = profile.getPowers().contains(PowerCatalog.SPRINTER.id()); + boolean active = sprinter && !combatState.isInCombat(player.uuid()); + player.world().execute(() -> applySpeed(player, active)); + } + } + + private void applySpeed(@Nonnull OnlinePlayer player, boolean buffed) { + Store store = player.world().getEntityStore().getStore(); + Ref ref = player.entityRef(); + MovementManager movement = store.getComponent(ref, MovementManager.getComponentType()); + if (movement == null) { + return; + } + MovementSettings defaults = movement.getDefaultSettings(); + MovementSettings current = movement.getSettings(); + if (defaults == null || current == null) { + return; + } + float baseline = defaults.baseSpeed; + float target = buffed ? baseline * (float) (1.0 + PowerCatalog.SPRINTER_SPEED_BONUS) : baseline; + if (Math.abs(current.baseSpeed - target) > 0.0001f) { + current.baseSpeed = target; + movement.update(player.playerRef().getPacketHandler()); + } + } +} diff --git a/src/main/java/com/disklexar/mmorpg/combat/PoisonService.java b/src/main/java/com/disklexar/mmorpg/combat/PoisonService.java new file mode 100644 index 0000000..2274184 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/combat/PoisonService.java @@ -0,0 +1,64 @@ +package com.disklexar.mmorpg.combat; + +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.server.core.modules.entity.component.TransformComponent; +import com.hypixel.hytale.server.core.modules.entity.damage.Damage; +import com.hypixel.hytale.server.core.modules.entity.damage.DamageCause; +import com.hypixel.hytale.server.core.modules.entity.damage.DamageSystems; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; +import org.joml.Vector3d; + +import javax.annotation.Nonnull; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Applies a damage-over-time poison to a target. Each application is independent (so the assassin's + * "Lame Empoisonnée" stacks without limit): calling {@link #applyPoison} again simply schedules + * another series of ticks on top of the existing ones. + */ +public final class PoisonService { + + private static final String VFX_POISON = "Impact_Poison"; + + private final MmorpgScheduler scheduler; + + public PoisonService(@Nonnull MmorpgScheduler scheduler) { + this.scheduler = scheduler; + } + + /** + * Deals {@code damagePerTick} magic damage to {@code victim} once per {@code periodMillis}, + * {@code ticks} times. The caster keeps the kill credit via the damage source. + */ + public void applyPoison( + @Nonnull World world, + @Nonnull Store store, + @Nonnull Ref caster, + @Nonnull Ref victim, + int ticks, + float damagePerTick, + long periodMillis) { + AtomicInteger remaining = new AtomicInteger(ticks); + for (int i = 1; i <= ticks; i++) { + scheduler.runLater(() -> world.execute(() -> { + if (remaining.decrementAndGet() < 0) { + return; + } + if (!victim.isValid()) { + return; + } + // No dedicated "magic" cause exists; ENVIRONMENT keeps the tick from re-triggering + // the on-hit (PHYSICAL) buff logic, so the poison cannot recursively re-poison. + Damage damage = new Damage(new Damage.EntitySource(caster), DamageCause.ENVIRONMENT, damagePerTick); + DamageSystems.executeDamage(victim, store, damage); + TransformComponent transform = + store.getComponent(victim, TransformComponent.getComponentType()); + if (transform != null) { + CombatFx.vfx(store, VFX_POISON, new Vector3d(transform.getPosition())); + } + }), (long) i * periodMillis); + } + } +} diff --git a/src/main/java/com/disklexar/mmorpg/combat/StunService.java b/src/main/java/com/disklexar/mmorpg/combat/StunService.java new file mode 100644 index 0000000..0f48cb5 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/combat/StunService.java @@ -0,0 +1,32 @@ +package com.disklexar.mmorpg.combat; + +import javax.annotation.Nonnull; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Tracks which entities are currently stunned (used so the Knight war cry can deal bonus damage + * to already-stunned targets). The actual movement lock is enforced by repeatedly zeroing the + * target's velocity for the stun duration. + */ +public final class StunService { + + private final Map stunnedUntil = new ConcurrentHashMap<>(); + + public void stun(@Nonnull UUID entityUuid, long durationMillis) { + stunnedUntil.put(entityUuid, System.currentTimeMillis() + durationMillis); + } + + public boolean isStunned(@Nonnull UUID entityUuid) { + Long until = stunnedUntil.get(entityUuid); + if (until == null) { + return false; + } + if (System.currentTimeMillis() >= until) { + stunnedUntil.remove(entityUuid); + return false; + } + return true; + } +} diff --git a/src/main/java/com/disklexar/mmorpg/combat/system/MinerBlockBreakSystem.java b/src/main/java/com/disklexar/mmorpg/combat/system/MinerBlockBreakSystem.java new file mode 100644 index 0000000..f1a6af8 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/combat/system/MinerBlockBreakSystem.java @@ -0,0 +1,66 @@ +package com.disklexar.mmorpg.combat.system; + +import com.disklexar.mmorpg.economy.EconomyService; +import com.disklexar.mmorpg.player.PlayerSessionService; +import com.disklexar.mmorpg.player.model.PlayerProfile; +import com.disklexar.mmorpg.rpg.job.JobCatalog; +import com.disklexar.mmorpg.rpg.job.JobService; +import com.hypixel.hytale.component.ArchetypeChunk; +import com.hypixel.hytale.component.CommandBuffer; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.component.query.Query; +import com.hypixel.hytale.component.system.EntityEventSystem; +import com.hypixel.hytale.server.core.event.events.ecs.BreakBlockEvent; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; + +import javax.annotation.Nonnull; + +/** + * Grants money to players with the "Mineur" job each time they break a block. + */ +public final class MinerBlockBreakSystem extends EntityEventSystem { + + private static final Query QUERY = Query.any(); + + private final PlayerSessionService sessions; + private final JobService jobs; + private final EconomyService economy; + + public MinerBlockBreakSystem( + @Nonnull PlayerSessionService sessions, + @Nonnull JobService jobs, + @Nonnull EconomyService economy) { + super(BreakBlockEvent.class); + this.sessions = sessions; + this.jobs = jobs; + this.economy = economy; + } + + @Override + public Query getQuery() { + return QUERY; + } + + @Override + public void handle( + int index, + @Nonnull ArchetypeChunk chunk, + @Nonnull Store store, + @Nonnull CommandBuffer commandBuffer, + @Nonnull BreakBlockEvent event) { + Ref breaker = chunk.getReferenceTo(index); + PlayerRef player = store.getComponent(breaker, PlayerRef.getComponentType()); + if (player == null) { + return; + } + PlayerProfile profile = sessions.getSession(player.getUuid()); + if (profile == null) { + return; + } + if (jobs.has(profile, JobCatalog.MINER.id())) { + economy.deposit(profile, JobCatalog.MINER_REWARD); + } + } +} diff --git a/src/main/java/com/disklexar/mmorpg/combat/system/MmorpgDamageSystem.java b/src/main/java/com/disklexar/mmorpg/combat/system/MmorpgDamageSystem.java new file mode 100644 index 0000000..88c18cb --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/combat/system/MmorpgDamageSystem.java @@ -0,0 +1,143 @@ +package com.disklexar.mmorpg.combat.system; + +import com.disklexar.mmorpg.combat.CombatBuffService; +import com.disklexar.mmorpg.combat.CombatStateService; +import com.disklexar.mmorpg.combat.PoisonService; +import com.disklexar.mmorpg.player.OnlinePlayer; +import com.disklexar.mmorpg.player.OnlinePlayerRegistry; +import com.disklexar.mmorpg.player.PlayerSessionService; +import com.disklexar.mmorpg.player.model.PlayerProfile; +import com.disklexar.mmorpg.rpg.power.PowerCatalog; +import com.hypixel.hytale.component.ArchetypeChunk; +import com.hypixel.hytale.component.CommandBuffer; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.component.SystemGroup; +import com.hypixel.hytale.component.query.Query; +import com.hypixel.hytale.server.core.entity.UUIDComponent; +import com.hypixel.hytale.server.core.modules.entity.damage.Damage; +import com.hypixel.hytale.server.core.modules.entity.damage.DamageCause; +import com.hypixel.hytale.server.core.modules.entity.damage.DamageEventSystem; +import com.hypixel.hytale.server.core.modules.entity.damage.DamageModule; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; + +import javax.annotation.Nonnull; +import java.util.UUID; + +/** + * Adjusts incoming damage according to MMORPG state and tracks combat for passive powers: + *

    + *
  • victims with an active resistance debuff (Knight war cry) take +20% damage,
  • + *
  • attackers with the "Résilient" power deal +10% damage for 5s after being hit.
  • + *
+ */ +public final class MmorpgDamageSystem extends DamageEventSystem { + + private static final Query QUERY = Query.any(); + private static final double RESISTANCE_DEBUFF_FACTOR = 1.20; + + // Archer "Tir rapide": flat outgoing-damage bonus (server proxy for attack speed). + private static final double RAPID_FIRE_FACTOR = 1.20; + // Archer "Tir puissant": +200% on the next basic attack. + private static final double POWER_SHOT_FACTOR = 3.0; + // Assassin "Lame Empoisonnée": stacking poison applied on each basic hit. + private static final int POISON_TICKS = 5; + private static final float POISON_DAMAGE_PER_TICK = 2.0f; + private static final long POISON_PERIOD_MILLIS = 1_000L; + + private final PlayerSessionService sessions; + private final CombatStateService combatState; + private final CombatBuffService combatBuffs; + private final PoisonService poisonService; + private final OnlinePlayerRegistry onlinePlayers; + + public MmorpgDamageSystem( + @Nonnull PlayerSessionService sessions, + @Nonnull CombatStateService combatState, + @Nonnull CombatBuffService combatBuffs, + @Nonnull PoisonService poisonService, + @Nonnull OnlinePlayerRegistry onlinePlayers) { + this.sessions = sessions; + this.combatState = combatState; + this.combatBuffs = combatBuffs; + this.poisonService = poisonService; + this.onlinePlayers = onlinePlayers; + } + + @Override + public Query getQuery() { + return QUERY; + } + + @Override + public SystemGroup getGroup() { + return DamageModule.get().getInspectDamageGroup(); + } + + @Override + public void handle( + int index, + @Nonnull ArchetypeChunk chunk, + @Nonnull Store store, + @Nonnull CommandBuffer commandBuffer, + @Nonnull Damage damage) { + Ref victim = chunk.getReferenceTo(index); + double amount = damage.getAmount(); + + UUID victimUuid = uuid(store, victim); + if (victimUuid != null && combatState.hasResistanceDebuff(victimUuid)) { + amount *= RESISTANCE_DEBUFF_FACTOR; + } + + if (damage.getSource() instanceof Damage.EntitySource source) { + Ref attackerRef = source.getRef(); + if (attackerRef != null) { + PlayerRef attacker = store.getComponent(attackerRef, PlayerRef.getComponentType()); + if (attacker != null) { + UUID attackerUuid = attacker.getUuid(); + combatState.markCombat(attackerUuid); + PlayerProfile profile = sessions.getSession(attackerUuid); + if (profile != null + && profile.getPowers().contains(PowerCatalog.RESILIENT.id()) + && combatState.wasRecentlyHit(attackerUuid, PowerCatalog.RESILIENT_WINDOW_MILLIS)) { + amount *= (1.0 + PowerCatalog.RESILIENT_DAMAGE_BONUS); + } + + // Offensive class buffs only apply to direct physical hits, never to the + // damage-over-time / spell ticks our own abilities deal (those are MAGIC), so a + // single sword swing doesn't waste the archer's charged shot, etc. + if (damage.getCause() == DamageCause.PHYSICAL) { + if (combatBuffs.hasRapidFire(attackerUuid)) { + amount *= RAPID_FIRE_FACTOR; + } + if (combatBuffs.consumePowerShot(attackerUuid)) { + amount *= POWER_SHOT_FACTOR; + } + if (combatBuffs.hasPoisonBlade(attackerUuid)) { + OnlinePlayer online = onlinePlayers.get(attackerUuid); + if (online != null) { + poisonService.applyPoison(online.world(), store, attackerRef, victim, + POISON_TICKS, POISON_DAMAGE_PER_TICK, POISON_PERIOD_MILLIS); + } + } + } + } + } + } + + if (Math.abs(amount - damage.getAmount()) > 0.0001) { + damage.setAmount((float) amount); + } + + PlayerRef victimPlayer = store.getComponent(victim, PlayerRef.getComponentType()); + if (victimPlayer != null) { + combatState.markDamageTaken(victimPlayer.getUuid()); + } + } + + private UUID uuid(@Nonnull Store store, @Nonnull Ref ref) { + UUIDComponent component = store.getComponent(ref, UUIDComponent.getComponentType()); + return component == null ? null : component.getUuid(); + } +} diff --git a/src/main/java/com/disklexar/mmorpg/combat/system/MmorpgDeathSystem.java b/src/main/java/com/disklexar/mmorpg/combat/system/MmorpgDeathSystem.java new file mode 100644 index 0000000..50f157a --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/combat/system/MmorpgDeathSystem.java @@ -0,0 +1,105 @@ +package com.disklexar.mmorpg.combat.system; + +import com.disklexar.mmorpg.economy.EconomyService; +import com.disklexar.mmorpg.player.PlayerSessionService; +import com.disklexar.mmorpg.player.model.PlayerProfile; +import com.disklexar.mmorpg.progression.ProgressionService; +import com.disklexar.mmorpg.rpg.group.GroupService; +import com.disklexar.mmorpg.rpg.job.JobCatalog; +import com.disklexar.mmorpg.rpg.job.JobService; +import com.hypixel.hytale.component.CommandBuffer; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.component.query.Query; +import com.hypixel.hytale.server.core.Message; +import com.hypixel.hytale.server.core.modules.entity.damage.Damage; +import com.hypixel.hytale.server.core.modules.entity.damage.DeathComponent; +import com.hypixel.hytale.server.core.modules.entity.damage.DeathSystems; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; + +import javax.annotation.Nonnull; +import java.util.UUID; + +/** + * Rewards a player for killing a mob: kill XP (scaled by race), shared XP for group mates, and + * money when the killer holds the "Tueur de monstre" job. + */ +public final class MmorpgDeathSystem extends DeathSystems.OnDeathSystem { + + private static final Query QUERY = Query.any(); + + private final PlayerSessionService sessions; + private final ProgressionService progression; + private final GroupService groups; + private final JobService jobs; + private final EconomyService economy; + private final int killExperience; + + public MmorpgDeathSystem( + @Nonnull PlayerSessionService sessions, + @Nonnull ProgressionService progression, + @Nonnull GroupService groups, + @Nonnull JobService jobs, + @Nonnull EconomyService economy, + int killExperience) { + this.sessions = sessions; + this.progression = progression; + this.groups = groups; + this.jobs = jobs; + this.economy = economy; + this.killExperience = Math.max(0, killExperience); + } + + @Override + public Query getQuery() { + return QUERY; + } + + @Override + public void onComponentAdded( + @Nonnull Ref deadRef, + @Nonnull DeathComponent death, + @Nonnull Store store, + @Nonnull CommandBuffer commandBuffer) { + // Only mobs award rewards; ignore player deaths. + if (store.getComponent(deadRef, PlayerRef.getComponentType()) != null) { + return; + } + Damage info = death.getDeathInfo(); + if (info == null || !(info.getSource() instanceof Damage.EntitySource source)) { + return; + } + Ref killerRef = source.getRef(); + if (killerRef == null) { + return; + } + PlayerRef killer = store.getComponent(killerRef, PlayerRef.getComponentType()); + if (killer == null) { + return; + } + UUID killerUuid = killer.getUuid(); + PlayerProfile profile = sessions.getSession(killerUuid); + if (profile == null) { + return; + } + + long gained = progression.grantExperience(profile, killExperience, true); + int shared = groups.shareKillExperience(killerUuid, gained); + + long reward = 0L; + if (jobs.has(profile, JobCatalog.MONSTER_SLAYER.id())) { + reward = JobCatalog.MONSTER_SLAYER_REWARD; + economy.deposit(profile, reward); + } + + StringBuilder message = new StringBuilder("Monstre vaincu — +" + gained + " XP"); + if (reward > 0) { + message.append(" | +").append(reward).append(" money"); + } + if (shared > 0) { + message.append(" | XP partagée avec ").append(shared).append(" allié(s)"); + } + killer.sendMessage(Message.raw(message.toString())); + } +} diff --git a/src/main/java/com/disklexar/mmorpg/command/AbilityCommand.java b/src/main/java/com/disklexar/mmorpg/command/AbilityCommand.java new file mode 100644 index 0000000..e6d6d27 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/command/AbilityCommand.java @@ -0,0 +1,43 @@ +package com.disklexar.mmorpg.command; + +import com.disklexar.mmorpg.MmorpgPlugin; +import com.disklexar.mmorpg.combat.ClassAbilityController; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.server.core.Message; +import com.hypixel.hytale.server.core.command.system.CommandContext; +import com.hypixel.hytale.server.core.command.system.arguments.system.RequiredArg; +import com.hypixel.hytale.server.core.command.system.arguments.types.ArgTypes; +import com.hypixel.hytale.server.core.command.system.basecommands.AbstractPlayerCommand; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; + +import javax.annotation.Nonnull; + +/** + * {@code /mmorpg ability <1|2|3>} — uses one of your class abilities. + */ +public final class AbilityCommand extends AbstractPlayerCommand { + + private final MmorpgPlugin plugin; + private final RequiredArg slotArg; + + public AbilityCommand(@Nonnull MmorpgPlugin plugin) { + super("ability", "Utiliser une capacité de classe (slot 1, 2 ou 3)"); + this.plugin = plugin; + this.slotArg = withRequiredArg("slot", "Numéro de la capacité (1-3)", ArgTypes.INTEGER); + } + + @Override + protected void execute(@Nonnull CommandContext context, @Nonnull Store store, + @Nonnull Ref ref, @Nonnull PlayerRef playerRef, @Nonnull World world) { + ClassAbilityController controller = plugin.getClassAbilityController(); + int slot = context.get(slotArg); + if (!controller.shouldSuppressVanilla(playerRef, slot)) { + context.sendMessage(Message.raw("Vous n'avez pas de classe. Utilisez /mmorpg class choisir .")); + return; + } + controller.tryCast(playerRef, world, slot, null); + } +} diff --git a/src/main/java/com/disklexar/mmorpg/command/ClassCommand.java b/src/main/java/com/disklexar/mmorpg/command/ClassCommand.java new file mode 100644 index 0000000..c68325c --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/command/ClassCommand.java @@ -0,0 +1,159 @@ +package com.disklexar.mmorpg.command; + +import com.disklexar.mmorpg.MmorpgPlugin; +import com.disklexar.mmorpg.player.OnlinePlayer; +import com.disklexar.mmorpg.player.OnlinePlayerRegistry; +import com.disklexar.mmorpg.player.model.PlayerProfile; +import com.disklexar.mmorpg.rpg.clazz.AbilityDefinition; +import com.disklexar.mmorpg.ui.AbilityBarService; +import com.disklexar.mmorpg.rpg.clazz.ClassCatalog; +import com.disklexar.mmorpg.rpg.clazz.ClassService; +import com.disklexar.mmorpg.rpg.clazz.PlayerClass; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.server.core.Message; +import com.hypixel.hytale.server.core.command.system.CommandContext; +import com.hypixel.hytale.server.core.command.system.arguments.system.RequiredArg; +import com.hypixel.hytale.server.core.command.system.arguments.types.ArgTypes; +import com.hypixel.hytale.server.core.command.system.basecommands.AbstractCommandCollection; +import com.hypixel.hytale.server.core.command.system.basecommands.AbstractPlayerCommand; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; + +import javax.annotation.Nonnull; + +/** + * {@code /mmorpg class } + */ +public final class ClassCommand extends AbstractCommandCollection { + + public ClassCommand(@Nonnull MmorpgPlugin plugin) { + super("class", "Gérer votre classe"); + addSubCommand(new Choose(plugin)); + addSubCommand(new Leave(plugin)); + addSubCommand(new Info(plugin)); + } + + private static final class Choose extends AbstractPlayerCommand { + private final MmorpgPlugin plugin; + private final RequiredArg classArg; + + Choose(@Nonnull MmorpgPlugin plugin) { + super("choisir", "Choisir une classe"); + this.plugin = plugin; + this.classArg = withRequiredArg("classe", "Identifiant de la classe", ArgTypes.STRING); + } + + @Override + protected void execute(@Nonnull CommandContext context, @Nonnull Store store, + @Nonnull Ref ref, @Nonnull PlayerRef playerRef, @Nonnull World world) { + PlayerProfile profile = CommandSupport.profile(plugin, store, ref); + ClassService classService = CommandSupport.service(plugin, ClassService.class); + if (profile == null || classService == null) { + context.sendMessage(Message.raw("Profil indisponible.")); + return; + } + String id = context.get(classArg); + if (classService.choose(profile, id)) { + PlayerClass chosen = classService.getClass(profile); + context.sendMessage(Message.raw("Classe choisie : " + (chosen != null ? chosen.displayName() : id))); + syncAbilityBar(plugin, playerRef, profile); + } else { + context.sendMessage(Message.raw("Classe inconnue. Disponibles : " + classList())); + } + } + } + + private static final class Leave extends AbstractPlayerCommand { + private final MmorpgPlugin plugin; + + Leave(@Nonnull MmorpgPlugin plugin) { + super("quitter", "Quitter votre classe"); + this.plugin = plugin; + } + + @Override + protected void execute(@Nonnull CommandContext context, @Nonnull Store store, + @Nonnull Ref ref, @Nonnull PlayerRef playerRef, @Nonnull World world) { + PlayerProfile profile = CommandSupport.profile(plugin, store, ref); + ClassService classService = CommandSupport.service(plugin, ClassService.class); + if (profile == null || classService == null) { + context.sendMessage(Message.raw("Profil indisponible.")); + return; + } + if (classService.leave(profile)) { + context.sendMessage(Message.raw("Vous avez quitté votre classe.")); + dismissAbilityBar(plugin, playerRef); + } else { + context.sendMessage(Message.raw("Vous n'avez aucune classe.")); + } + } + } + + private static final class Info extends AbstractPlayerCommand { + private final MmorpgPlugin plugin; + + Info(@Nonnull MmorpgPlugin plugin) { + super("info", "Informations sur les classes"); + this.plugin = plugin; + } + + @Override + protected void execute(@Nonnull CommandContext context, @Nonnull Store store, + @Nonnull Ref ref, @Nonnull PlayerRef playerRef, @Nonnull World world) { + PlayerProfile profile = CommandSupport.profile(plugin, store, ref); + ClassService classService = CommandSupport.service(plugin, ClassService.class); + StringBuilder builder = new StringBuilder("=== Classes ==="); + if (profile != null && classService != null) { + PlayerClass current = classService.getClass(profile); + builder.append("\nClasse actuelle : ").append(current != null ? current.displayName() : "aucune"); + } + for (PlayerClass playerClass : ClassCatalog.all()) { + builder.append("\n- ").append(playerClass.displayName()) + .append(" (").append(playerClass.id()).append(") — ") + .append(playerClass.description()) + .append("\n Armes : ").append(String.join(", ", playerClass.weapons())); + for (AbilityDefinition ability : playerClass.abilities()) { + builder.append("\n • Capacité ").append(ability.slot()).append(" — ") + .append(ability.displayName()).append(" : ").append(ability.description()); + } + } + context.sendMessage(Message.raw(builder.toString())); + } + } + + private static void syncAbilityBar( + @Nonnull MmorpgPlugin plugin, + @Nonnull PlayerRef playerRef, + @Nonnull PlayerProfile profile) { + AbilityBarService abilityBar = CommandSupport.service(plugin, AbilityBarService.class); + OnlinePlayerRegistry onlinePlayers = CommandSupport.service(plugin, OnlinePlayerRegistry.class); + if (abilityBar == null || onlinePlayers == null) { + return; + } + OnlinePlayer online = onlinePlayers.get(playerRef.getUuid()); + if (online != null) { + abilityBar.sync(online, profile); + } + } + + private static void dismissAbilityBar(@Nonnull MmorpgPlugin plugin, @Nonnull PlayerRef playerRef) { + AbilityBarService abilityBar = CommandSupport.service(plugin, AbilityBarService.class); + if (abilityBar != null) { + abilityBar.dismiss(playerRef.getUuid()); + } + } + + @Nonnull + private static String classList() { + StringBuilder builder = new StringBuilder(); + for (PlayerClass playerClass : ClassCatalog.all()) { + if (builder.length() > 0) { + builder.append(", "); + } + builder.append(playerClass.id()); + } + return builder.toString(); + } +} diff --git a/src/main/java/com/disklexar/mmorpg/command/CommandSupport.java b/src/main/java/com/disklexar/mmorpg/command/CommandSupport.java new file mode 100644 index 0000000..0fea12a --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/command/CommandSupport.java @@ -0,0 +1,51 @@ +package com.disklexar.mmorpg.command; + +import com.disklexar.mmorpg.MmorpgPlugin; +import com.disklexar.mmorpg.bootstrap.Bootstrap; +import com.disklexar.mmorpg.player.PlayerSessionService; +import com.disklexar.mmorpg.player.model.PlayerProfile; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.server.core.entity.UUIDComponent; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.UUID; + +/** + * Shared helpers for MMORPG commands: service lookup and current-player profile resolution. + */ +final class CommandSupport { + + private CommandSupport() { + } + + @Nullable + static T service(@Nonnull MmorpgPlugin plugin, @Nonnull Class type) { + Bootstrap bootstrap = plugin.getBootstrap(); + if (bootstrap == null) { + return null; + } + return bootstrap.getRegistry().get(type); + } + + @Nullable + static UUID uuid(@Nonnull Store store, @Nonnull Ref ref) { + UUIDComponent component = store.getComponent(ref, UUIDComponent.getComponentType()); + return component == null ? null : component.getUuid(); + } + + @Nullable + static PlayerProfile profile( + @Nonnull MmorpgPlugin plugin, + @Nonnull Store store, + @Nonnull Ref ref) { + UUID uuid = uuid(store, ref); + if (uuid == null) { + return null; + } + PlayerSessionService sessions = service(plugin, PlayerSessionService.class); + return sessions == null ? null : sessions.getSession(uuid); + } +} diff --git a/src/main/java/com/disklexar/mmorpg/command/GroupCommand.java b/src/main/java/com/disklexar/mmorpg/command/GroupCommand.java new file mode 100644 index 0000000..cf202ed --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/command/GroupCommand.java @@ -0,0 +1,180 @@ +package com.disklexar.mmorpg.command; + +import com.disklexar.mmorpg.MmorpgPlugin; +import com.disklexar.mmorpg.rpg.group.Group; +import com.disklexar.mmorpg.rpg.group.GroupService; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.server.core.Message; +import com.hypixel.hytale.server.core.command.system.CommandContext; +import com.hypixel.hytale.server.core.command.system.arguments.system.RequiredArg; +import com.hypixel.hytale.server.core.command.system.arguments.types.ArgTypes; +import com.hypixel.hytale.server.core.command.system.basecommands.AbstractCommandCollection; +import com.hypixel.hytale.server.core.command.system.basecommands.AbstractPlayerCommand; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; + +import javax.annotation.Nonnull; +import java.util.UUID; + +/** + * {@code /mmorpg group } + */ +public final class GroupCommand extends AbstractCommandCollection { + + public GroupCommand(@Nonnull MmorpgPlugin plugin) { + super("group", "Gérer votre groupe (partage d'XP)"); + addSubCommand(new Invite(plugin)); + addSubCommand(new Accept(plugin)); + addSubCommand(new Leave(plugin)); + addSubCommand(new Kick(plugin)); + addSubCommand(new Info(plugin)); + } + + private static final class Invite extends AbstractPlayerCommand { + private final MmorpgPlugin plugin; + private final RequiredArg targetArg; + + Invite(@Nonnull MmorpgPlugin plugin) { + super("inviter", "Inviter un joueur dans votre groupe"); + this.plugin = plugin; + this.targetArg = withRequiredArg("joueur", "Joueur à inviter", ArgTypes.PLAYER_REF); + } + + @Override + protected void execute(@Nonnull CommandContext context, @Nonnull Store store, + @Nonnull Ref ref, @Nonnull PlayerRef playerRef, @Nonnull World world) { + GroupService groups = CommandSupport.service(plugin, GroupService.class); + UUID inviter = CommandSupport.uuid(store, ref); + if (groups == null || inviter == null) { + context.sendMessage(Message.raw("Service de groupe indisponible.")); + return; + } + PlayerRef target = context.get(targetArg); + switch (groups.invite(inviter, target.getUuid())) { + case INVITED -> { + context.sendMessage(Message.raw("Invitation envoyée à " + target.getUsername() + ".")); + target.sendMessage(Message.raw(playerRef.getUsername() + + " vous invite dans son groupe. Tapez /mmorpg group accepter")); + } + case SELF -> context.sendMessage(Message.raw("Vous ne pouvez pas vous inviter vous-même.")); + case NOT_OWNER -> context.sendMessage(Message.raw("Seul le créateur peut inviter.")); + case TARGET_ALREADY_GROUPED -> + context.sendMessage(Message.raw("Ce joueur est déjà dans un groupe.")); + } + } + } + + private static final class Accept extends AbstractPlayerCommand { + private final MmorpgPlugin plugin; + + Accept(@Nonnull MmorpgPlugin plugin) { + super("accepter", "Accepter une invitation de groupe"); + this.plugin = plugin; + } + + @Override + protected void execute(@Nonnull CommandContext context, @Nonnull Store store, + @Nonnull Ref ref, @Nonnull PlayerRef playerRef, @Nonnull World world) { + GroupService groups = CommandSupport.service(plugin, GroupService.class); + UUID uuid = CommandSupport.uuid(store, ref); + if (groups == null || uuid == null) { + context.sendMessage(Message.raw("Service de groupe indisponible.")); + return; + } + switch (groups.accept(uuid)) { + case JOINED -> context.sendMessage(Message.raw("Vous avez rejoint le groupe.")); + case NO_INVITE -> context.sendMessage(Message.raw("Aucune invitation en attente.")); + case ALREADY_GROUPED -> context.sendMessage(Message.raw("Vous êtes déjà dans un groupe.")); + } + } + } + + private static final class Leave extends AbstractPlayerCommand { + private final MmorpgPlugin plugin; + + Leave(@Nonnull MmorpgPlugin plugin) { + super("quitter", "Quitter votre groupe"); + this.plugin = plugin; + } + + @Override + protected void execute(@Nonnull CommandContext context, @Nonnull Store store, + @Nonnull Ref ref, @Nonnull PlayerRef playerRef, @Nonnull World world) { + GroupService groups = CommandSupport.service(plugin, GroupService.class); + UUID uuid = CommandSupport.uuid(store, ref); + if (groups == null || uuid == null) { + context.sendMessage(Message.raw("Service de groupe indisponible.")); + return; + } + context.sendMessage(Message.raw(groups.leave(uuid) + ? "Vous avez quitté votre groupe." + : "Vous n'êtes dans aucun groupe.")); + } + } + + private static final class Kick extends AbstractPlayerCommand { + private final MmorpgPlugin plugin; + private final RequiredArg targetArg; + + Kick(@Nonnull MmorpgPlugin plugin) { + super("kick", "Exclure un joueur (créateur uniquement)"); + this.plugin = plugin; + this.targetArg = withRequiredArg("joueur", "Joueur à exclure", ArgTypes.PLAYER_REF); + } + + @Override + protected void execute(@Nonnull CommandContext context, @Nonnull Store store, + @Nonnull Ref ref, @Nonnull PlayerRef playerRef, @Nonnull World world) { + GroupService groups = CommandSupport.service(plugin, GroupService.class); + UUID owner = CommandSupport.uuid(store, ref); + if (groups == null || owner == null) { + context.sendMessage(Message.raw("Service de groupe indisponible.")); + return; + } + PlayerRef target = context.get(targetArg); + switch (groups.kick(owner, target.getUuid())) { + case KICKED -> { + context.sendMessage(Message.raw(target.getUsername() + " a été exclu du groupe.")); + target.sendMessage(Message.raw("Vous avez été exclu du groupe.")); + } + case NOT_OWNER -> context.sendMessage(Message.raw("Seul le créateur peut exclure.")); + case NOT_MEMBER -> context.sendMessage(Message.raw("Ce joueur n'est pas dans votre groupe.")); + case CANNOT_KICK_SELF -> + context.sendMessage(Message.raw("Utilisez /mmorpg group quitter pour partir.")); + } + } + } + + private static final class Info extends AbstractPlayerCommand { + private final MmorpgPlugin plugin; + + Info(@Nonnull MmorpgPlugin plugin) { + super("info", "Informations sur votre groupe"); + this.plugin = plugin; + } + + @Override + protected void execute(@Nonnull CommandContext context, @Nonnull Store store, + @Nonnull Ref ref, @Nonnull PlayerRef playerRef, @Nonnull World world) { + GroupService groups = CommandSupport.service(plugin, GroupService.class); + UUID uuid = CommandSupport.uuid(store, ref); + if (groups == null || uuid == null) { + context.sendMessage(Message.raw("Service de groupe indisponible.")); + return; + } + Group group = groups.getGroupOf(uuid); + if (group == null) { + context.sendMessage(Message.raw("Vous n'êtes dans aucun groupe. " + + "Le partage d'XP donne " + GroupService.SHARED_XP_FRACTION_PERCENT + + "% de l'XP d'un kill aux autres membres.")); + return; + } + context.sendMessage(Message.raw("=== Groupe ===\nCréateur : " + + (group.isOwner(uuid) ? "vous" : group.getOwner()) + + "\nMembres : " + group.getMembers().size() + + "\nPartage d'XP : " + GroupService.SHARED_XP_FRACTION_PERCENT + "%")); + } + } +} diff --git a/src/main/java/com/disklexar/mmorpg/command/HudCommand.java b/src/main/java/com/disklexar/mmorpg/command/HudCommand.java new file mode 100644 index 0000000..e7e573c --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/command/HudCommand.java @@ -0,0 +1,64 @@ +package com.disklexar.mmorpg.command; + +import com.disklexar.mmorpg.MmorpgPlugin; +import com.disklexar.mmorpg.player.OnlinePlayer; +import com.disklexar.mmorpg.player.OnlinePlayerRegistry; +import com.disklexar.mmorpg.player.model.PlayerProfile; +import com.disklexar.mmorpg.rpg.clazz.ClassService; +import com.disklexar.mmorpg.ui.AbilityBarService; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.server.core.Message; +import com.hypixel.hytale.server.core.command.system.CommandContext; +import com.hypixel.hytale.server.core.command.system.basecommands.AbstractPlayerCommand; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; + +import javax.annotation.Nonnull; + +/** + * {@code /mmorpg hud} — force-affiche la barre de capacités (debug / test manuel). + */ +public final class HudCommand extends AbstractPlayerCommand { + + private final MmorpgPlugin plugin; + + public HudCommand(@Nonnull MmorpgPlugin plugin) { + super("hud", "Afficher la barre de capacités MMORPG"); + this.plugin = plugin; + } + + @Override + protected void execute(@Nonnull CommandContext context, @Nonnull Store store, + @Nonnull Ref ref, @Nonnull PlayerRef playerRef, @Nonnull World world) { + PlayerProfile profile = CommandSupport.profile(plugin, store, ref); + if (profile == null) { + context.sendMessage(Message.raw("Aucun profil chargé. Reconnectez-vous.")); + return; + } + + ClassService classService = CommandSupport.service(plugin, ClassService.class); + AbilityBarService abilityBar = CommandSupport.service(plugin, AbilityBarService.class); + OnlinePlayerRegistry onlinePlayers = CommandSupport.service(plugin, OnlinePlayerRegistry.class); + if (classService == null || abilityBar == null || onlinePlayers == null) { + context.sendMessage(Message.raw("Service HUD indisponible.")); + return; + } + + if (!classService.hasClass(profile)) { + context.sendMessage(Message.raw("Choisissez d'abord une classe : /mmorpg class choisir ")); + return; + } + + OnlinePlayer online = onlinePlayers.get(playerRef.getUuid()); + if (online == null) { + context.sendMessage(Message.raw("Joueur non enregistré. Reconnectez-vous.")); + return; + } + + abilityBar.dismiss(online); + abilityBar.sync(online, profile); + context.sendMessage(Message.raw("Barre de capacités renvoyée (colonne gauche).")); + } +} diff --git a/src/main/java/com/disklexar/mmorpg/command/InfoCommand.java b/src/main/java/com/disklexar/mmorpg/command/InfoCommand.java new file mode 100644 index 0000000..8ee4e02 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/command/InfoCommand.java @@ -0,0 +1,39 @@ +package com.disklexar.mmorpg.command; + +import com.disklexar.mmorpg.MmorpgPlugin; +import com.disklexar.mmorpg.player.PlayerInfoView; +import com.disklexar.mmorpg.player.model.PlayerProfile; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.server.core.Message; +import com.hypixel.hytale.server.core.command.system.CommandContext; +import com.hypixel.hytale.server.core.command.system.basecommands.AbstractPlayerCommand; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; + +import javax.annotation.Nonnull; + +/** + * {@code /mmorpg info} — prints the full player profile in chat (reliable fallback for the UI). + */ +public final class InfoCommand extends AbstractPlayerCommand { + + private final MmorpgPlugin plugin; + + public InfoCommand(@Nonnull MmorpgPlugin plugin) { + super("info", "Afficher toutes vos informations de joueur"); + this.plugin = plugin; + } + + @Override + protected void execute(@Nonnull CommandContext context, @Nonnull Store store, + @Nonnull Ref ref, @Nonnull PlayerRef playerRef, @Nonnull World world) { + PlayerProfile profile = CommandSupport.profile(plugin, store, ref); + if (profile == null) { + context.sendMessage(Message.raw("Aucun profil chargé. Reconnectez-vous.")); + return; + } + context.sendMessage(Message.raw(PlayerInfoView.asText(profile))); + } +} diff --git a/src/main/java/com/disklexar/mmorpg/command/JobCommand.java b/src/main/java/com/disklexar/mmorpg/command/JobCommand.java new file mode 100644 index 0000000..e57641e --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/command/JobCommand.java @@ -0,0 +1,131 @@ +package com.disklexar.mmorpg.command; + +import com.disklexar.mmorpg.MmorpgPlugin; +import com.disklexar.mmorpg.player.model.PlayerProfile; +import com.disklexar.mmorpg.rpg.job.JobCatalog; +import com.disklexar.mmorpg.rpg.job.JobDefinition; +import com.disklexar.mmorpg.rpg.job.JobService; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.server.core.Message; +import com.hypixel.hytale.server.core.command.system.CommandContext; +import com.hypixel.hytale.server.core.command.system.arguments.system.RequiredArg; +import com.hypixel.hytale.server.core.command.system.arguments.types.ArgTypes; +import com.hypixel.hytale.server.core.command.system.basecommands.AbstractCommandCollection; +import com.hypixel.hytale.server.core.command.system.basecommands.AbstractPlayerCommand; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; + +import javax.annotation.Nonnull; + +/** + * {@code /mmorpg job } + */ +public final class JobCommand extends AbstractCommandCollection { + + public JobCommand(@Nonnull MmorpgPlugin plugin) { + super("job", "Gérer vos métiers"); + addSubCommand(new Join(plugin)); + addSubCommand(new Leave(plugin)); + addSubCommand(new Info(plugin)); + } + + private static final class Join extends AbstractPlayerCommand { + private final MmorpgPlugin plugin; + private final RequiredArg jobArg; + + Join(@Nonnull MmorpgPlugin plugin) { + super("rejoindre", "Rejoindre un métier"); + this.plugin = plugin; + this.jobArg = withRequiredArg("metier", "Identifiant du métier", ArgTypes.STRING); + } + + @Override + protected void execute(@Nonnull CommandContext context, @Nonnull Store store, + @Nonnull Ref ref, @Nonnull PlayerRef playerRef, @Nonnull World world) { + PlayerProfile profile = CommandSupport.profile(plugin, store, ref); + JobService jobService = CommandSupport.service(plugin, JobService.class); + if (profile == null || jobService == null) { + context.sendMessage(Message.raw("Profil indisponible.")); + return; + } + String id = context.get(jobArg); + JobDefinition job = JobCatalog.byId(id); + if (job == null) { + context.sendMessage(Message.raw("Métier inconnu. Disponibles : " + jobList())); + return; + } + context.sendMessage(Message.raw(jobService.join(profile, id) + ? "Métier rejoint : " + job.displayName() + : "Vous exercez déjà " + job.displayName() + ".")); + } + } + + private static final class Leave extends AbstractPlayerCommand { + private final MmorpgPlugin plugin; + private final RequiredArg jobArg; + + Leave(@Nonnull MmorpgPlugin plugin) { + super("quitter", "Quitter un métier"); + this.plugin = plugin; + this.jobArg = withRequiredArg("metier", "Identifiant du métier", ArgTypes.STRING); + } + + @Override + protected void execute(@Nonnull CommandContext context, @Nonnull Store store, + @Nonnull Ref ref, @Nonnull PlayerRef playerRef, @Nonnull World world) { + PlayerProfile profile = CommandSupport.profile(plugin, store, ref); + JobService jobService = CommandSupport.service(plugin, JobService.class); + if (profile == null || jobService == null) { + context.sendMessage(Message.raw("Profil indisponible.")); + return; + } + String id = context.get(jobArg); + JobDefinition job = JobCatalog.byId(id); + if (job == null) { + context.sendMessage(Message.raw("Métier inconnu. Disponibles : " + jobList())); + return; + } + context.sendMessage(Message.raw(jobService.leave(profile, id) + ? "Métier quitté : " + job.displayName() + : "Vous n'exercez pas " + job.displayName() + ".")); + } + } + + private static final class Info extends AbstractPlayerCommand { + private final MmorpgPlugin plugin; + + Info(@Nonnull MmorpgPlugin plugin) { + super("info", "Informations sur les métiers"); + this.plugin = plugin; + } + + @Override + protected void execute(@Nonnull CommandContext context, @Nonnull Store store, + @Nonnull Ref ref, @Nonnull PlayerRef playerRef, @Nonnull World world) { + PlayerProfile profile = CommandSupport.profile(plugin, store, ref); + StringBuilder builder = new StringBuilder("=== Métiers ==="); + for (JobDefinition job : JobCatalog.all()) { + boolean active = profile != null && profile.getJobs().contains(job.id()); + builder.append("\n- ").append(job.displayName()) + .append(" (").append(job.id()).append(") ") + .append(active ? "[actif]" : "") + .append("\n ").append(job.description()); + } + context.sendMessage(Message.raw(builder.toString())); + } + } + + @Nonnull + private static String jobList() { + StringBuilder builder = new StringBuilder(); + for (JobDefinition job : JobCatalog.all()) { + if (builder.length() > 0) { + builder.append(", "); + } + builder.append(job.id()); + } + return builder.toString(); + } +} diff --git a/src/main/java/com/disklexar/mmorpg/command/MenuCommand.java b/src/main/java/com/disklexar/mmorpg/command/MenuCommand.java new file mode 100644 index 0000000..4f0a130 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/command/MenuCommand.java @@ -0,0 +1,55 @@ +package com.disklexar.mmorpg.command; + +import com.disklexar.mmorpg.MmorpgPlugin; +import com.disklexar.mmorpg.player.PlayerInfoView; +import com.disklexar.mmorpg.player.model.PlayerProfile; +import com.disklexar.mmorpg.ui.PlayerInfoPage; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.server.core.Message; +import com.hypixel.hytale.server.core.command.system.CommandContext; +import com.hypixel.hytale.server.core.command.system.basecommands.AbstractPlayerCommand; +import com.hypixel.hytale.server.core.entity.entities.Player; +import com.hypixel.hytale.server.core.entity.entities.player.pages.PageManager; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; + +import javax.annotation.Nonnull; + +/** + * {@code /mmorpg menu} — opens the custom UI page listing all player info. + * Falls back to the chat panel if the UI page cannot be opened. + */ +public final class MenuCommand extends AbstractPlayerCommand { + + private final MmorpgPlugin plugin; + + public MenuCommand(@Nonnull MmorpgPlugin plugin) { + super("menu", "Ouvrir l'interface d'informations du joueur"); + this.plugin = plugin; + } + + @Override + protected void execute(@Nonnull CommandContext context, @Nonnull Store store, + @Nonnull Ref ref, @Nonnull PlayerRef playerRef, @Nonnull World world) { + PlayerProfile profile = CommandSupport.profile(plugin, store, ref); + if (profile == null) { + context.sendMessage(Message.raw("Aucun profil chargé. Reconnectez-vous.")); + return; + } + Player player = store.getComponent(ref, Player.getComponentType()); + if (player == null) { + context.sendMessage(Message.raw(PlayerInfoView.asText(profile))); + return; + } + PageManager pageManager = player.getPageManager(); + world.execute(() -> { + try { + pageManager.openCustomPage(ref, store, new PlayerInfoPage(playerRef, profile, false)); + } catch (Throwable t) { + playerRef.sendMessage(Message.raw(PlayerInfoView.asText(profile))); + } + }); + } +} diff --git a/src/main/java/com/disklexar/mmorpg/command/MenuCustomCommand.java b/src/main/java/com/disklexar/mmorpg/command/MenuCustomCommand.java new file mode 100644 index 0000000..0975e30 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/command/MenuCustomCommand.java @@ -0,0 +1,55 @@ +package com.disklexar.mmorpg.command; + +import com.disklexar.mmorpg.MmorpgPlugin; +import com.disklexar.mmorpg.player.PlayerInfoView; +import com.disklexar.mmorpg.player.model.PlayerProfile; +import com.disklexar.mmorpg.ui.PlayerInfoPage; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.server.core.Message; +import com.hypixel.hytale.server.core.command.system.CommandContext; +import com.hypixel.hytale.server.core.command.system.basecommands.AbstractPlayerCommand; +import com.hypixel.hytale.server.core.entity.entities.Player; +import com.hypixel.hytale.server.core.entity.entities.player.pages.PageManager; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; + +import javax.annotation.Nonnull; + +/** + * {@code /mmorpg menucustom} — opens the fully custom player-info window backed by this plugin's + * Opens the designed asset-pack page ({@code Pages/MmorpgPlayerInfo.ui}). + */ +public final class MenuCustomCommand extends AbstractPlayerCommand { + + private final MmorpgPlugin plugin; + + public MenuCustomCommand(@Nonnull MmorpgPlugin plugin) { + super("menucustom", "Ouvrir l'interface joueur personnalisée (asset pack)"); + this.plugin = plugin; + } + + @Override + protected void execute(@Nonnull CommandContext context, @Nonnull Store store, + @Nonnull Ref ref, @Nonnull PlayerRef playerRef, @Nonnull World world) { + PlayerProfile profile = CommandSupport.profile(plugin, store, ref); + if (profile == null) { + context.sendMessage(Message.raw("Aucun profil chargé. Reconnectez-vous.")); + return; + } + Player player = store.getComponent(ref, Player.getComponentType()); + if (player == null) { + context.sendMessage(Message.raw(PlayerInfoView.asText(profile))); + return; + } + PageManager pageManager = player.getPageManager(); + world.execute(() -> { + try { + pageManager.openCustomPage(ref, store, new PlayerInfoPage(playerRef, profile, true)); + } catch (Throwable t) { + playerRef.sendMessage(Message.raw(PlayerInfoView.asText(profile))); + } + }); + } +} diff --git a/src/main/java/com/disklexar/mmorpg/command/MmorpgCommand.java b/src/main/java/com/disklexar/mmorpg/command/MmorpgCommand.java new file mode 100644 index 0000000..0df31e2 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/command/MmorpgCommand.java @@ -0,0 +1,26 @@ +package com.disklexar.mmorpg.command; + +import com.disklexar.mmorpg.MmorpgPlugin; +import com.hypixel.hytale.server.core.command.system.basecommands.AbstractCommandCollection; + +import javax.annotation.Nonnull; + +/** + * Root command: /mmorpg + */ +public final class MmorpgCommand extends AbstractCommandCollection { + + public MmorpgCommand(@Nonnull MmorpgPlugin plugin) { + super("mmorpg", "Commandes du serveur MMORPG"); + addSubCommand(new MmorpgHelpCommand()); + addSubCommand(new MmorpgProfileCommand(plugin)); + addSubCommand(new InfoCommand(plugin)); + addSubCommand(new MenuCommand(plugin)); + addSubCommand(new HudCommand(plugin)); + addSubCommand(new ClassCommand(plugin)); + addSubCommand(new PowerCommand(plugin)); + addSubCommand(new JobCommand(plugin)); + addSubCommand(new GroupCommand(plugin)); + addSubCommand(new AbilityCommand(plugin)); + } +} diff --git a/src/main/java/com/disklexar/mmorpg/command/MmorpgHelpCommand.java b/src/main/java/com/disklexar/mmorpg/command/MmorpgHelpCommand.java new file mode 100644 index 0000000..d06fd4b --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/command/MmorpgHelpCommand.java @@ -0,0 +1,40 @@ +package com.disklexar.mmorpg.command; + +import com.hypixel.hytale.server.core.Message; +import com.hypixel.hytale.server.core.command.system.CommandContext; +import com.hypixel.hytale.server.core.command.system.basecommands.AbstractAsyncCommand; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.concurrent.CompletableFuture; + +/** + * /mmorpg help + */ +public final class MmorpgHelpCommand extends AbstractAsyncCommand { + + public MmorpgHelpCommand() { + super("help", "Affiche l'aide MMORPG"); + } + + @Nullable + @Override + protected CompletableFuture executeAsync(@Nonnull CommandContext context) { + context.sendMessage(Message.raw(""" + === MMORPG === + /mmorpg help — cette aide + /mmorpg profile — résumé court de votre profil + /mmorpg info — toutes vos informations (chat) + /mmorpg menu — interface des informations du joueur + /mmorpg menucustom — interface personnalisée (asset pack, test) + /mmorpg class [classe] + /mmorpg power [pouvoir] + /mmorpg job [metier] + /mmorpg group [joueur] + /mmorpg ability <1|2|3> — utiliser une capacité de classe + (ou utilisez directement les touches « Use ability 1/2/3 » en jeu) + Classes : Chevalier, Archer, Mage, Assassin (voir /mmorpg class info) + """)); + return CompletableFuture.completedFuture(null); + } +} diff --git a/src/main/java/com/disklexar/mmorpg/command/MmorpgProfileCommand.java b/src/main/java/com/disklexar/mmorpg/command/MmorpgProfileCommand.java new file mode 100644 index 0000000..5aab27e --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/command/MmorpgProfileCommand.java @@ -0,0 +1,65 @@ +package com.disklexar.mmorpg.command; + +import com.disklexar.mmorpg.MmorpgPlugin; +import com.disklexar.mmorpg.bootstrap.Bootstrap; +import com.disklexar.mmorpg.player.PlayerSessionService; +import com.disklexar.mmorpg.player.model.PlayerProfile; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.server.core.Message; +import com.hypixel.hytale.server.core.command.system.CommandContext; +import com.hypixel.hytale.server.core.command.system.basecommands.AbstractPlayerCommand; +import com.hypixel.hytale.server.core.entity.UUIDComponent; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; + +import javax.annotation.Nonnull; + +/** + * /mmorpg profile + */ +public final class MmorpgProfileCommand extends AbstractPlayerCommand { + + private final MmorpgPlugin plugin; + + public MmorpgProfileCommand(@Nonnull MmorpgPlugin plugin) { + super("profile", "Affiche votre profil MMORPG"); + this.plugin = plugin; + } + + @Override + protected void execute( + @Nonnull CommandContext context, + @Nonnull Store store, + @Nonnull Ref ref, + @Nonnull PlayerRef playerRef, + @Nonnull World world) { + UUIDComponent uuidComponent = store.getComponent(ref, UUIDComponent.getComponentType()); + + if (uuidComponent == null) { + context.sendMessage(Message.raw("Impossible de lire votre profil.")); + return; + } + + Bootstrap bootstrap = plugin.getBootstrap(); + if (bootstrap == null) { + context.sendMessage(Message.raw("Le plugin MMORPG n'est pas encore prêt.")); + return; + } + + PlayerSessionService sessions = bootstrap.getRegistry().require(PlayerSessionService.class); + PlayerProfile profile = sessions.getSession(uuidComponent.getUuid()); + + if (profile == null) { + context.sendMessage(Message.raw("Aucun profil chargé. Reconnectez-vous au serveur.")); + return; + } + + context.sendMessage(Message.raw(String.format( + "Profil MMORPG — %s | Niveau %d | XP %d", + profile.getDisplayName(), + profile.getLevel(), + profile.getExperience()))); + } +} diff --git a/src/main/java/com/disklexar/mmorpg/command/PowerCommand.java b/src/main/java/com/disklexar/mmorpg/command/PowerCommand.java new file mode 100644 index 0000000..01dde1b --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/command/PowerCommand.java @@ -0,0 +1,131 @@ +package com.disklexar.mmorpg.command; + +import com.disklexar.mmorpg.MmorpgPlugin; +import com.disklexar.mmorpg.player.model.PlayerProfile; +import com.disklexar.mmorpg.rpg.power.PowerCatalog; +import com.disklexar.mmorpg.rpg.power.PowerDefinition; +import com.disklexar.mmorpg.rpg.power.PowerService; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.server.core.Message; +import com.hypixel.hytale.server.core.command.system.CommandContext; +import com.hypixel.hytale.server.core.command.system.arguments.system.RequiredArg; +import com.hypixel.hytale.server.core.command.system.arguments.types.ArgTypes; +import com.hypixel.hytale.server.core.command.system.basecommands.AbstractCommandCollection; +import com.hypixel.hytale.server.core.command.system.basecommands.AbstractPlayerCommand; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; + +import javax.annotation.Nonnull; + +/** + * {@code /mmorpg power } + */ +public final class PowerCommand extends AbstractCommandCollection { + + public PowerCommand(@Nonnull MmorpgPlugin plugin) { + super("power", "Gérer vos pouvoirs passifs"); + addSubCommand(new Grant(plugin)); + addSubCommand(new Revoke(plugin)); + addSubCommand(new Info(plugin)); + } + + private static final class Grant extends AbstractPlayerCommand { + private final MmorpgPlugin plugin; + private final RequiredArg powerArg; + + Grant(@Nonnull MmorpgPlugin plugin) { + super("recevoir", "Recevoir un pouvoir"); + this.plugin = plugin; + this.powerArg = withRequiredArg("pouvoir", "Identifiant du pouvoir", ArgTypes.STRING); + } + + @Override + protected void execute(@Nonnull CommandContext context, @Nonnull Store store, + @Nonnull Ref ref, @Nonnull PlayerRef playerRef, @Nonnull World world) { + PlayerProfile profile = CommandSupport.profile(plugin, store, ref); + PowerService powerService = CommandSupport.service(plugin, PowerService.class); + if (profile == null || powerService == null) { + context.sendMessage(Message.raw("Profil indisponible.")); + return; + } + String id = context.get(powerArg); + PowerDefinition power = PowerCatalog.byId(id); + if (power == null) { + context.sendMessage(Message.raw("Pouvoir inconnu. Disponibles : " + powerList())); + return; + } + context.sendMessage(Message.raw(powerService.grant(profile, id) + ? "Pouvoir reçu : " + power.displayName() + : "Vous possédez déjà " + power.displayName() + ".")); + } + } + + private static final class Revoke extends AbstractPlayerCommand { + private final MmorpgPlugin plugin; + private final RequiredArg powerArg; + + Revoke(@Nonnull MmorpgPlugin plugin) { + super("retirer", "Retirer un pouvoir"); + this.plugin = plugin; + this.powerArg = withRequiredArg("pouvoir", "Identifiant du pouvoir", ArgTypes.STRING); + } + + @Override + protected void execute(@Nonnull CommandContext context, @Nonnull Store store, + @Nonnull Ref ref, @Nonnull PlayerRef playerRef, @Nonnull World world) { + PlayerProfile profile = CommandSupport.profile(plugin, store, ref); + PowerService powerService = CommandSupport.service(plugin, PowerService.class); + if (profile == null || powerService == null) { + context.sendMessage(Message.raw("Profil indisponible.")); + return; + } + String id = context.get(powerArg); + PowerDefinition power = PowerCatalog.byId(id); + if (power == null) { + context.sendMessage(Message.raw("Pouvoir inconnu. Disponibles : " + powerList())); + return; + } + context.sendMessage(Message.raw(powerService.remove(profile, id) + ? "Pouvoir retiré : " + power.displayName() + : "Vous ne possédez pas " + power.displayName() + ".")); + } + } + + private static final class Info extends AbstractPlayerCommand { + private final MmorpgPlugin plugin; + + Info(@Nonnull MmorpgPlugin plugin) { + super("info", "Informations sur les pouvoirs"); + this.plugin = plugin; + } + + @Override + protected void execute(@Nonnull CommandContext context, @Nonnull Store store, + @Nonnull Ref ref, @Nonnull PlayerRef playerRef, @Nonnull World world) { + PlayerProfile profile = CommandSupport.profile(plugin, store, ref); + StringBuilder builder = new StringBuilder("=== Pouvoirs (passifs, aucun par défaut) ==="); + for (PowerDefinition power : PowerCatalog.all()) { + boolean owned = profile != null && profile.getPowers().contains(power.id()); + builder.append("\n- ").append(power.displayName()) + .append(" (").append(power.id()).append(") ") + .append(owned ? "[possédé]" : "") + .append("\n ").append(power.description()); + } + context.sendMessage(Message.raw(builder.toString())); + } + } + + @Nonnull + private static String powerList() { + StringBuilder builder = new StringBuilder(); + for (PowerDefinition power : PowerCatalog.all()) { + if (builder.length() > 0) { + builder.append(", "); + } + builder.append(power.id()); + } + return builder.toString(); + } +} diff --git a/src/main/java/com/disklexar/mmorpg/core/config/MmorpgConfig.java b/src/main/java/com/disklexar/mmorpg/core/config/MmorpgConfig.java new file mode 100644 index 0000000..9f2cc5e --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/core/config/MmorpgConfig.java @@ -0,0 +1,112 @@ +package com.disklexar.mmorpg.core.config; + +import com.google.gson.Gson; +import com.google.gson.annotations.SerializedName; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * Plugin configuration loaded from {@code config.json} in the plugin data directory. + */ +public final class MmorpgConfig { + + private static final Gson GSON = new Gson(); + + private boolean debug; + @SerializedName("DefaultLevel") + private int defaultLevel = 1; + @SerializedName("MaxPlayers") + private int maxPlayers = 500; + @SerializedName("BaseXpPerLevel") + private int baseXpPerLevel = 100; + @SerializedName("KillExperience") + private int killExperience = 10; + private DatabaseConfig database = new DatabaseConfig(); + private FeaturesConfig features = new FeaturesConfig(); + + public static MmorpgConfig load(Path dataDirectory) throws IOException { + Files.createDirectories(dataDirectory); + Path configFile = dataDirectory.resolve("config.json"); + + if (!Files.exists(configFile)) { + try (InputStream defaults = MmorpgConfig.class.getResourceAsStream("/config.json")) { + if (defaults == null) { + throw new IOException("Default config.json not found in plugin resources"); + } + Files.copy(defaults, configFile); + } + } + + try (InputStreamReader reader = new InputStreamReader( + Files.newInputStream(configFile), StandardCharsets.UTF_8)) { + MmorpgConfig config = GSON.fromJson(reader, MmorpgConfig.class); + if (config.database == null) { + config.database = new DatabaseConfig(); + } + if (config.features == null) { + config.features = new FeaturesConfig(); + } + return config; + } + } + + public boolean isDebug() { + return debug; + } + + public int getDefaultLevel() { + return defaultLevel; + } + + public int getMaxPlayers() { + return maxPlayers; + } + + public int getBaseXpPerLevel() { + return baseXpPerLevel; + } + + public int getKillExperience() { + return killExperience; + } + + public DatabaseConfig getDatabase() { + return database; + } + + public FeaturesConfig getFeatures() { + return features; + } + + public static final class DatabaseConfig { + @SerializedName("FileName") + private String fileName = "mmorpg.db"; + + public String getFileName() { + return fileName; + } + } + + public static final class FeaturesConfig { + private boolean economy; + private boolean quests; + private boolean guilds; + + public boolean isEconomyEnabled() { + return economy; + } + + public boolean isQuestsEnabled() { + return quests; + } + + public boolean isGuildsEnabled() { + return guilds; + } + } +} diff --git a/src/main/java/com/disklexar/mmorpg/core/service/Service.java b/src/main/java/com/disklexar/mmorpg/core/service/Service.java new file mode 100644 index 0000000..cc7cbc6 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/core/service/Service.java @@ -0,0 +1,11 @@ +package com.disklexar.mmorpg.core.service; + +/** + * Lifecycle contract for MMORPG services. + */ +public interface Service { + + void start(); + + void shutdown(); +} diff --git a/src/main/java/com/disklexar/mmorpg/economy/EconomyService.java b/src/main/java/com/disklexar/mmorpg/economy/EconomyService.java new file mode 100644 index 0000000..904330a --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/economy/EconomyService.java @@ -0,0 +1,35 @@ +package com.disklexar.mmorpg.economy; + +import com.disklexar.mmorpg.player.model.PlayerProfile; + +import javax.annotation.Nonnull; + +/** + * Minimal currency operations on a {@link PlayerProfile}. Money is persisted with the profile. + */ +public final class EconomyService { + + public long getBalance(@Nonnull PlayerProfile profile) { + return profile.getMoney(); + } + + public void deposit(@Nonnull PlayerProfile profile, long amount) { + if (amount <= 0) { + return; + } + profile.setMoney(profile.getMoney() + amount); + profile.touch(); + } + + public boolean withdraw(@Nonnull PlayerProfile profile, long amount) { + if (amount <= 0) { + return true; + } + if (profile.getMoney() < amount) { + return false; + } + profile.setMoney(profile.getMoney() - amount); + profile.touch(); + return true; + } +} diff --git a/src/main/java/com/disklexar/mmorpg/economy/package-info.java b/src/main/java/com/disklexar/mmorpg/economy/package-info.java new file mode 100644 index 0000000..ffb2b4b --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/economy/package-info.java @@ -0,0 +1,7 @@ +/** + * Economy system (currency, shops, auctions). + *

+ * Disabled by default via {@code Features.Economy} in config.json. + * Planned: wallets, transactions, NPC vendors. + */ +package com.disklexar.mmorpg.economy; diff --git a/src/main/java/com/disklexar/mmorpg/events/AbilityPacketFilter.java b/src/main/java/com/disklexar/mmorpg/events/AbilityPacketFilter.java new file mode 100644 index 0000000..c8f4857 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/events/AbilityPacketFilter.java @@ -0,0 +1,88 @@ +package com.disklexar.mmorpg.events; + +import com.disklexar.mmorpg.combat.ClassAbilityController; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.protocol.ForkedChainId; +import com.hypixel.hytale.protocol.InteractionType; +import com.hypixel.hytale.protocol.Packet; +import com.hypixel.hytale.protocol.packets.interaction.CancelInteractionChain; +import com.hypixel.hytale.protocol.packets.interaction.SyncInteractionChain; +import com.hypixel.hytale.protocol.packets.interaction.SyncInteractionChains; +import com.hypixel.hytale.server.core.entity.entities.Player; +import com.hypixel.hytale.server.core.io.adapter.PlayerPacketFilter; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; + +import javax.annotation.Nonnull; +import java.util.ArrayList; +import java.util.List; + +/** + * Intercepts {@link SyncInteractionChains} before the vanilla interaction pipeline runs. + * When a player with an MMORPG class presses Ability 1/2/3, the vanilla weapon ability is + * cancelled client-side and the plugin casts the class ability instead. + */ +public final class AbilityPacketFilter implements PlayerPacketFilter { + + private final ClassAbilityController controller; + + public AbilityPacketFilter(@Nonnull ClassAbilityController controller) { + this.controller = controller; + } + + @Override + public boolean test(@Nonnull PlayerRef playerRef, @Nonnull Packet packet) { + if (!(packet instanceof SyncInteractionChains syncPacket) + || syncPacket.updates == null + || syncPacket.updates.length == 0) { + return false; + } + + List keep = new ArrayList<>(syncPacket.updates.length); + boolean blockedAny = false; + + for (SyncInteractionChain chain : syncPacket.updates) { + int slot = ClassAbilityController.slotFor(chain.interactionType); + if (slot == 0 || !chain.initial || !controller.shouldSuppressVanilla(playerRef, slot)) { + keep.add(chain); + continue; + } + + blockedAny = true; + cancelAndCast(playerRef, chain, slot); + } + + if (!blockedAny) { + return false; + } + if (keep.isEmpty()) { + return true; + } + syncPacket.updates = keep.toArray(new SyncInteractionChain[0]); + return false; + } + + private void cancelAndCast( + @Nonnull PlayerRef playerRef, + @Nonnull SyncInteractionChain chain, + int slot) { + ForkedChainId forkedId = chain.forkedId; + playerRef.getPacketHandler().writeNoCache(new CancelInteractionChain(chain.chainId, forkedId)); + + Ref entityRef = playerRef.getReference(); + if (entityRef == null || !entityRef.isValid()) { + return; + } + + Store store = entityRef.getStore(); + Player player = store.getComponent(entityRef, Player.getComponentType()); + if (player == null) { + return; + } + + World world = player.getWorld(); + world.execute(() -> controller.tryCast(playerRef, world, slot, null)); + } +} diff --git a/src/main/java/com/disklexar/mmorpg/events/PlayerConnectionHandler.java b/src/main/java/com/disklexar/mmorpg/events/PlayerConnectionHandler.java new file mode 100644 index 0000000..bb82741 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/events/PlayerConnectionHandler.java @@ -0,0 +1,133 @@ +package com.disklexar.mmorpg.events; + +import com.disklexar.mmorpg.MmorpgPlugin; +import com.disklexar.mmorpg.bootstrap.Bootstrap; +import com.disklexar.mmorpg.player.OnlinePlayer; +import com.disklexar.mmorpg.combat.MmorpgScheduler; +import com.disklexar.mmorpg.player.OnlinePlayerRegistry; +import com.disklexar.mmorpg.player.PlayerSessionService; +import com.disklexar.mmorpg.player.model.PlayerProfile; +import com.disklexar.mmorpg.rpg.clazz.ClassService; +import com.disklexar.mmorpg.ui.AbilityBarService; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.logger.HytaleLogger; +import com.hypixel.hytale.server.core.Message; +import com.hypixel.hytale.server.core.entity.UUIDComponent; +import com.hypixel.hytale.server.core.entity.entities.Player; +import com.hypixel.hytale.server.core.event.events.player.PlayerDisconnectEvent; +import com.hypixel.hytale.server.core.event.events.player.PlayerReadyEvent; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; + +import javax.annotation.Nonnull; +import java.sql.SQLException; +import java.util.UUID; +import java.util.logging.Level; + +/** + * Handles player join and leave for MMORPG session management. + */ +public final class PlayerConnectionHandler { + + private final MmorpgPlugin plugin; + private final HytaleLogger logger; + + public PlayerConnectionHandler(@Nonnull MmorpgPlugin plugin) { + this.plugin = plugin; + this.logger = plugin.getLogger(); + } + + public void onPlayerReady(@Nonnull PlayerReadyEvent event) { + Player player = event.getPlayer(); + Ref entityRef = event.getPlayerRef(); + + Bootstrap bootstrap = plugin.getBootstrap(); + if (bootstrap == null) { + return; + } + + Store store = player.getWorld().getEntityStore().getStore(); + PlayerRef universePlayerRef = store.getComponent(entityRef, PlayerRef.getComponentType()); + PlayerSessionService sessions = bootstrap.getRegistry().require(PlayerSessionService.class); + + try { + UUID uuid = resolveUuid(store, entityRef, player); + String displayName = resolveDisplayName(store, entityRef, player); + PlayerProfile profile = sessions.loadOrCreate(uuid, displayName); + + if (universePlayerRef != null) { + OnlinePlayerRegistry onlinePlayers = + bootstrap.getRegistry().require(OnlinePlayerRegistry.class); + OnlinePlayer online = new OnlinePlayer(uuid, universePlayerRef, entityRef, player.getWorld()); + onlinePlayers.add(online); + + ClassService classService = bootstrap.getRegistry().require(ClassService.class); + AbilityBarService abilityBar = bootstrap.getRegistry().require(AbilityBarService.class); + MmorpgScheduler scheduler = bootstrap.getRegistry().require(MmorpgScheduler.class); + + universePlayerRef.sendMessage(Message.raw(String.format( + "Bienvenue sur le serveur MMORPG — Niveau %d | Race: %s", + profile.getLevel(), + com.disklexar.mmorpg.rpg.race.RaceCatalog.byId(profile.getRaceId()).displayName()))); + universePlayerRef.sendMessage(Message.raw( + "Commandes : /mmorpg menu (profil) | /mmorpg class info")); + + if (classService.hasClass(profile)) { + scheduler.runLater(() -> abilityBar.sync(uuid, profile), 5_000L); + } + } + } catch (SQLException e) { + logger.at(Level.SEVERE).log( + "Failed to load profile for %s: %s", + resolveDisplayName(store, entityRef, player), + e.getMessage()); + if (universePlayerRef != null) { + universePlayerRef.sendMessage(Message.raw( + "Erreur lors du chargement de votre profil MMORPG.")); + } + } + } + + public void onPlayerDisconnect(@Nonnull PlayerDisconnectEvent event) { + Bootstrap bootstrap = plugin.getBootstrap(); + if (bootstrap == null) { + return; + } + + PlayerRef playerRef = event.getPlayerRef(); + OnlinePlayerRegistry onlinePlayers = bootstrap.getRegistry().require(OnlinePlayerRegistry.class); + onlinePlayers.remove(playerRef.getUuid()); + bootstrap.getRegistry().require(AbilityBarService.class).dismiss(playerRef.getUuid()); + PlayerSessionService sessions = bootstrap.getRegistry().require(PlayerSessionService.class); + sessions.unload(playerRef.getUuid()); + } + + @Nonnull + private UUID resolveUuid( + @Nonnull Store store, + @Nonnull Ref ref, + @Nonnull Player player) { + UUIDComponent component = store.getComponent(ref, UUIDComponent.getComponentType()); + if (component != null) { + return component.getUuid(); + } + PlayerRef playerRef = store.getComponent(ref, PlayerRef.getComponentType()); + if (playerRef != null) { + return playerRef.getUuid(); + } + throw new IllegalStateException("UUID not found for player: " + resolveDisplayName(store, ref, player)); + } + + @Nonnull + private String resolveDisplayName( + @Nonnull Store store, + @Nonnull Ref ref, + @Nonnull Player player) { + PlayerRef playerRef = store.getComponent(ref, PlayerRef.getComponentType()); + if (playerRef != null) { + return playerRef.getUsername(); + } + return "Joueur"; + } +} diff --git a/src/main/java/com/disklexar/mmorpg/interaction/MmorpgCastAbilityInteraction.java b/src/main/java/com/disklexar/mmorpg/interaction/MmorpgCastAbilityInteraction.java new file mode 100644 index 0000000..2adb930 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/interaction/MmorpgCastAbilityInteraction.java @@ -0,0 +1,90 @@ +package com.disklexar.mmorpg.interaction; + +import com.disklexar.mmorpg.MmorpgPlugin; +import com.disklexar.mmorpg.combat.ClassAbilityController; +import com.hypixel.hytale.codec.Codec; +import com.hypixel.hytale.codec.KeyedCodec; +import com.hypixel.hytale.codec.builder.BuilderCodec; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.protocol.InteractionState; +import com.hypixel.hytale.protocol.InteractionType; +import com.hypixel.hytale.server.core.entity.InteractionContext; +import com.hypixel.hytale.server.core.entity.entities.Player; +import com.hypixel.hytale.server.core.modules.interaction.interaction.CooldownHandler; +import com.hypixel.hytale.server.core.modules.interaction.interaction.config.SimpleInstantInteraction; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; + +import javax.annotation.Nonnull; + +/** + * Native interaction ({@code mmorpg_cast_ability}) wired from item asset overrides. + * Client and server stay in sync when weapon JSON maps Ability1/2/3 to this type. + */ +public final class MmorpgCastAbilityInteraction extends SimpleInstantInteraction { + + public static final String ID = "mmorpg_cast_ability"; + + public static final BuilderCodec CODEC = BuilderCodec.builder( + MmorpgCastAbilityInteraction.class, + MmorpgCastAbilityInteraction::new, + SimpleInstantInteraction.CODEC) + .append(new KeyedCodec<>("Slot", Codec.INTEGER), + (interaction, slot) -> interaction.slot = slot, + interaction -> interaction.slot) + .add() + .build(); + + private int slot = 1; + + public MmorpgCastAbilityInteraction() { + } + + public MmorpgCastAbilityInteraction(@Nonnull String id) { + super(id); + } + + @Override + protected void firstRun( + @Nonnull InteractionType interactionType, + @Nonnull InteractionContext interactionContext, + @Nonnull CooldownHandler cooldownHandler) { + int abilitySlot = slot; + if (abilitySlot == 0) { + abilitySlot = ClassAbilityController.slotFor(interactionType); + } + if (abilitySlot == 0) { + interactionContext.getState().state = InteractionState.Failed; + return; + } + + Ref entityRef = interactionContext.getEntity(); + if (entityRef == null || !entityRef.isValid()) { + interactionContext.getState().state = InteractionState.Failed; + return; + } + + Store store = entityRef.getStore(); + PlayerRef playerRef = store.getComponent(entityRef, PlayerRef.getComponentType()); + Player player = store.getComponent(entityRef, Player.getComponentType()); + if (playerRef == null || player == null) { + interactionContext.getState().state = InteractionState.Failed; + return; + } + + MmorpgPlugin plugin = MmorpgPlugin.get(); + if (plugin == null) { + interactionContext.getState().state = InteractionState.Failed; + return; + } + + World world = player.getWorld(); + Ref target = interactionContext.getTargetEntity(); + ClassAbilityController controller = plugin.getClassAbilityController(); + if (!controller.tryCast(playerRef, world, abilitySlot, target)) { + interactionContext.getState().state = InteractionState.Failed; + } + } +} diff --git a/src/main/java/com/disklexar/mmorpg/persistence/DatabaseManager.java b/src/main/java/com/disklexar/mmorpg/persistence/DatabaseManager.java new file mode 100644 index 0000000..a34ee06 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/persistence/DatabaseManager.java @@ -0,0 +1,72 @@ +package com.disklexar.mmorpg.persistence; + +import com.disklexar.mmorpg.MmorpgPlugin; +import com.disklexar.mmorpg.core.config.MmorpgConfig; +import com.disklexar.mmorpg.core.service.Service; +import com.disklexar.mmorpg.persistence.schema.SchemaInitializer; +import com.hypixel.hytale.logger.HytaleLogger; + +import javax.annotation.Nonnull; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.util.logging.Level; + +/** + * Manages the SQLite database connection for the MMORPG plugin. + */ +public final class DatabaseManager implements Service { + + private final MmorpgPlugin plugin; + private final MmorpgConfig config; + private final HytaleLogger logger; + private Connection connection; + + public DatabaseManager( + @Nonnull MmorpgPlugin plugin, + @Nonnull MmorpgConfig config) { + this.plugin = plugin; + this.config = config; + this.logger = plugin.getLogger(); + } + + @Override + public void start() { + try { + Path dbPath = plugin.getDataDirectory().resolve(config.getDatabase().getFileName()); + String jdbcUrl = "jdbc:sqlite:" + dbPath.toAbsolutePath(); + + connection = DriverManager.getConnection(jdbcUrl); + connection.setAutoCommit(true); + + SchemaInitializer.apply(connection); + + logger.at(Level.INFO).log("SQLite database ready at %s", dbPath); + } catch (SQLException e) { + throw new IllegalStateException("Failed to initialize SQLite database", e); + } + } + + @Override + public void shutdown() { + if (connection != null) { + try { + connection.close(); + logger.at(Level.INFO).log("SQLite connection closed"); + } catch (SQLException e) { + logger.at(Level.WARNING).log("Error closing SQLite connection: %s", e.getMessage()); + } finally { + connection = null; + } + } + } + + @Nonnull + public Connection getConnection() { + if (connection == null) { + throw new IllegalStateException("Database is not initialized"); + } + return connection; + } +} diff --git a/src/main/java/com/disklexar/mmorpg/persistence/repository/GroupRepository.java b/src/main/java/com/disklexar/mmorpg/persistence/repository/GroupRepository.java new file mode 100644 index 0000000..a6685d4 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/persistence/repository/GroupRepository.java @@ -0,0 +1,100 @@ +package com.disklexar.mmorpg.persistence.repository; + +import com.disklexar.mmorpg.persistence.DatabaseManager; +import com.disklexar.mmorpg.rpg.group.Group; + +import javax.annotation.Nonnull; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +/** + * SQLite persistence for {@link Group} parties and their members. + */ +public final class GroupRepository { + + private final DatabaseManager databaseManager; + + public GroupRepository(@Nonnull DatabaseManager databaseManager) { + this.databaseManager = databaseManager; + } + + @Nonnull + public List loadAll() throws SQLException { + List groups = new ArrayList<>(); + try (PreparedStatement statement = connection().prepareStatement( + "SELECT id, owner_uuid, created_at FROM groups"); + ResultSet resultSet = statement.executeQuery()) { + while (resultSet.next()) { + Group group = new Group( + UUID.fromString(resultSet.getString("id")), + UUID.fromString(resultSet.getString("owner_uuid")), + resultSet.getLong("created_at")); + groups.add(group); + } + } + for (Group group : groups) { + try (PreparedStatement statement = connection().prepareStatement( + "SELECT player_uuid FROM group_members WHERE group_id = ? ORDER BY joined_at")) { + statement.setString(1, group.getId().toString()); + try (ResultSet resultSet = statement.executeQuery()) { + while (resultSet.next()) { + group.getMembers().add(UUID.fromString(resultSet.getString("player_uuid"))); + } + } + } + } + return groups; + } + + public void insertGroup(@Nonnull Group group) throws SQLException { + try (PreparedStatement statement = connection().prepareStatement( + "INSERT OR REPLACE INTO groups (id, owner_uuid, created_at) VALUES (?, ?, ?)")) { + statement.setString(1, group.getId().toString()); + statement.setString(2, group.getOwner().toString()); + statement.setLong(3, group.getCreatedAt()); + statement.executeUpdate(); + } + } + + public void deleteGroup(@Nonnull UUID groupId) throws SQLException { + try (PreparedStatement members = connection().prepareStatement( + "DELETE FROM group_members WHERE group_id = ?")) { + members.setString(1, groupId.toString()); + members.executeUpdate(); + } + try (PreparedStatement statement = connection().prepareStatement( + "DELETE FROM groups WHERE id = ?")) { + statement.setString(1, groupId.toString()); + statement.executeUpdate(); + } + } + + public void addMember(@Nonnull UUID groupId, @Nonnull UUID player) throws SQLException { + try (PreparedStatement statement = connection().prepareStatement( + "INSERT OR REPLACE INTO group_members (group_id, player_uuid, joined_at) VALUES (?, ?, ?)")) { + statement.setString(1, groupId.toString()); + statement.setString(2, player.toString()); + statement.setLong(3, System.currentTimeMillis()); + statement.executeUpdate(); + } + } + + public void removeMember(@Nonnull UUID groupId, @Nonnull UUID player) throws SQLException { + try (PreparedStatement statement = connection().prepareStatement( + "DELETE FROM group_members WHERE group_id = ? AND player_uuid = ?")) { + statement.setString(1, groupId.toString()); + statement.setString(2, player.toString()); + statement.executeUpdate(); + } + } + + @Nonnull + private Connection connection() { + return databaseManager.getConnection(); + } +} diff --git a/src/main/java/com/disklexar/mmorpg/persistence/repository/PlayerProfileRepository.java b/src/main/java/com/disklexar/mmorpg/persistence/repository/PlayerProfileRepository.java new file mode 100644 index 0000000..95736d8 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/persistence/repository/PlayerProfileRepository.java @@ -0,0 +1,148 @@ +package com.disklexar.mmorpg.persistence.repository; + +import com.disklexar.mmorpg.persistence.DatabaseManager; +import com.disklexar.mmorpg.player.model.PlayerProfile; +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; + +import javax.annotation.Nonnull; +import java.lang.reflect.Type; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +/** + * SQLite persistence for {@link PlayerProfile}. + */ +public final class PlayerProfileRepository { + + private static final Gson GSON = new Gson(); + private static final Type STRING_LIST = new TypeToken>() { + }.getType(); + + private final DatabaseManager databaseManager; + + public PlayerProfileRepository(@Nonnull DatabaseManager databaseManager) { + this.databaseManager = databaseManager; + } + + @Nonnull + public Optional findByUuid(@Nonnull UUID uuid) throws SQLException { + String sql = """ + SELECT uuid, display_name, level, experience, class_id, powers, jobs, + guild_id, group_id, is_connected, total_time_play, last_date_connected, + date_creation, money, race_id, updated_at + FROM player_profiles + WHERE uuid = ? + """; + + try (PreparedStatement statement = connection().prepareStatement(sql)) { + statement.setString(1, uuid.toString()); + try (ResultSet resultSet = statement.executeQuery()) { + if (!resultSet.next()) { + return Optional.empty(); + } + return Optional.of(mapRow(resultSet)); + } + } + } + + @Nonnull + public PlayerProfile save(@Nonnull PlayerProfile profile) throws SQLException { + String sql = """ + INSERT INTO player_profiles ( + uuid, display_name, level, experience, class_id, powers, jobs, + guild_id, group_id, is_connected, total_time_play, last_date_connected, + date_creation, money, race_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(uuid) DO UPDATE SET + display_name = excluded.display_name, + level = excluded.level, + experience = excluded.experience, + class_id = excluded.class_id, + powers = excluded.powers, + jobs = excluded.jobs, + guild_id = excluded.guild_id, + group_id = excluded.group_id, + is_connected = excluded.is_connected, + total_time_play = excluded.total_time_play, + last_date_connected = excluded.last_date_connected, + money = excluded.money, + race_id = excluded.race_id, + updated_at = excluded.updated_at + """; + + try (PreparedStatement statement = connection().prepareStatement(sql)) { + statement.setString(1, profile.getUuid().toString()); + statement.setString(2, profile.getDisplayName()); + statement.setInt(3, profile.getLevel()); + statement.setLong(4, profile.getExperience()); + statement.setString(5, profile.getClassId()); + statement.setString(6, GSON.toJson(profile.getPowers())); + statement.setString(7, GSON.toJson(profile.getJobs())); + statement.setString(8, profile.getGuildId()); + statement.setString(9, profile.getGroupId()); + statement.setInt(10, profile.isConnected() ? 1 : 0); + statement.setLong(11, profile.getTotalTimePlay()); + statement.setLong(12, profile.getLastDateConnected()); + statement.setLong(13, profile.getDateCreation()); + statement.setLong(14, profile.getMoney()); + statement.setString(15, profile.getRaceId()); + // created_at mirrors date_creation to keep the original 001 column populated. + statement.setLong(16, profile.getDateCreation()); + statement.setLong(17, profile.getUpdatedAt()); + statement.executeUpdate(); + } + + return profile; + } + + /** Resets {@code is_connected} for every profile (called on boot to recover from crashes). */ + public void clearConnectedFlags() throws SQLException { + try (PreparedStatement statement = connection().prepareStatement( + "UPDATE player_profiles SET is_connected = 0 WHERE is_connected <> 0")) { + statement.executeUpdate(); + } + } + + @Nonnull + private PlayerProfile mapRow(@Nonnull ResultSet resultSet) throws SQLException { + PlayerProfile profile = new PlayerProfile( + UUID.fromString(resultSet.getString("uuid")), + resultSet.getString("display_name"), + resultSet.getLong("date_creation")); + profile.setLevel(resultSet.getInt("level")); + profile.setExperience(resultSet.getLong("experience")); + profile.setClassId(resultSet.getString("class_id")); + profile.getPowers().addAll(parseList(resultSet.getString("powers"))); + profile.getJobs().addAll(parseList(resultSet.getString("jobs"))); + profile.setGuildId(resultSet.getString("guild_id")); + profile.setGroupId(resultSet.getString("group_id")); + profile.setConnected(resultSet.getInt("is_connected") != 0); + profile.setTotalTimePlay(resultSet.getLong("total_time_play")); + profile.setLastDateConnected(resultSet.getLong("last_date_connected")); + profile.setMoney(resultSet.getLong("money")); + String raceId = resultSet.getString("race_id"); + profile.setRaceId(raceId != null ? raceId : "human"); + profile.setUpdatedAt(resultSet.getLong("updated_at")); + return profile; + } + + @Nonnull + private List parseList(String json) { + if (json == null || json.isBlank()) { + return List.of(); + } + List parsed = GSON.fromJson(json, STRING_LIST); + return parsed != null ? parsed : List.of(); + } + + @Nonnull + private Connection connection() { + return databaseManager.getConnection(); + } +} diff --git a/src/main/java/com/disklexar/mmorpg/persistence/schema/SchemaInitializer.java b/src/main/java/com/disklexar/mmorpg/persistence/schema/SchemaInitializer.java new file mode 100644 index 0000000..c52beb5 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/persistence/schema/SchemaInitializer.java @@ -0,0 +1,113 @@ +package com.disklexar.mmorpg.persistence.schema; + +import javax.annotation.Nonnull; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.List; +import java.util.stream.Collectors; + +/** + * Applies ordered SQL migrations from classpath resources. + *

+ * Each migration is applied at most once. A {@code schema_migrations} table records which + * migrations already ran, so new files can be added without re-running previous ones (which + * matters for non-idempotent statements such as {@code ALTER TABLE ... ADD COLUMN}). + */ +public final class SchemaInitializer { + + private static final List MIGRATIONS = List.of( + "001_init.sql", + "002_rpg.sql"); + + private SchemaInitializer() { + } + + public static void apply(@Nonnull Connection connection) throws SQLException { + ensureMigrationsTable(connection); + for (String migration : MIGRATIONS) { + if (isApplied(connection, migration)) { + continue; + } + runMigration(connection, migration); + markApplied(connection, migration); + } + } + + private static void ensureMigrationsTable(@Nonnull Connection connection) throws SQLException { + try (Statement statement = connection.createStatement()) { + statement.execute(""" + CREATE TABLE IF NOT EXISTS schema_migrations ( + id TEXT PRIMARY KEY, + applied_at INTEGER NOT NULL + ) + """); + } + } + + private static boolean isApplied(@Nonnull Connection connection, @Nonnull String migration) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement( + "SELECT 1 FROM schema_migrations WHERE id = ?")) { + statement.setString(1, migration); + try (ResultSet resultSet = statement.executeQuery()) { + return resultSet.next(); + } + } + } + + private static void markApplied(@Nonnull Connection connection, @Nonnull String migration) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement( + "INSERT INTO schema_migrations (id, applied_at) VALUES (?, ?)")) { + statement.setString(1, migration); + statement.setLong(2, System.currentTimeMillis()); + statement.executeUpdate(); + } + } + + private static void runMigration(@Nonnull Connection connection, @Nonnull String migration) throws SQLException { + String sql = loadMigration("/db/migrations/" + migration); + try (Statement statement = connection.createStatement()) { + for (String chunk : sql.split(";")) { + String trimmed = stripComments(chunk).trim(); + if (!trimmed.isEmpty()) { + statement.execute(trimmed); + } + } + } + } + + @Nonnull + private static String stripComments(@Nonnull String sql) { + StringBuilder builder = new StringBuilder(); + for (String line : sql.split("\n")) { + String trimmed = line.trim(); + if (trimmed.startsWith("--")) { + continue; + } + builder.append(line).append('\n'); + } + return builder.toString(); + } + + @Nonnull + private static String loadMigration(@Nonnull String resourcePath) throws SQLException { + try (InputStream input = SchemaInitializer.class.getResourceAsStream(resourcePath)) { + if (input == null) { + throw new SQLException("Migration resource not found: " + resourcePath); + } + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(input, StandardCharsets.UTF_8))) { + return reader.lines().collect(Collectors.joining("\n")); + } + } catch (IOException e) { + throw new SQLException("Failed to read migration: " + resourcePath, e); + } + } +} diff --git a/src/main/java/com/disklexar/mmorpg/player/OnlinePlayer.java b/src/main/java/com/disklexar/mmorpg/player/OnlinePlayer.java new file mode 100644 index 0000000..d03dc05 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/player/OnlinePlayer.java @@ -0,0 +1,19 @@ +package com.disklexar.mmorpg.player; + +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; + +import javax.annotation.Nonnull; +import java.util.UUID; + +/** + * A snapshot of the runtime handles for an online player, used to apply live effects. + */ +public record OnlinePlayer( + @Nonnull UUID uuid, + @Nonnull PlayerRef playerRef, + @Nonnull Ref entityRef, + @Nonnull World world) { +} diff --git a/src/main/java/com/disklexar/mmorpg/player/OnlinePlayerRegistry.java b/src/main/java/com/disklexar/mmorpg/player/OnlinePlayerRegistry.java new file mode 100644 index 0000000..287fd8d --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/player/OnlinePlayerRegistry.java @@ -0,0 +1,35 @@ +package com.disklexar.mmorpg.player; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.Collection; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Tracks runtime handles ({@link OnlinePlayer}) for connected players so services can apply + * live effects (movement buffs, abilities) by UUID. + */ +public final class OnlinePlayerRegistry { + + private final Map online = new ConcurrentHashMap<>(); + + public void add(@Nonnull OnlinePlayer player) { + online.put(player.uuid(), player); + } + + public void remove(@Nonnull UUID uuid) { + online.remove(uuid); + } + + @Nullable + public OnlinePlayer get(@Nonnull UUID uuid) { + return online.get(uuid); + } + + @Nonnull + public Collection all() { + return online.values(); + } +} diff --git a/src/main/java/com/disklexar/mmorpg/player/PlayerInfoView.java b/src/main/java/com/disklexar/mmorpg/player/PlayerInfoView.java new file mode 100644 index 0000000..2a8d731 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/player/PlayerInfoView.java @@ -0,0 +1,115 @@ +package com.disklexar.mmorpg.player; + +import com.disklexar.mmorpg.player.model.PlayerProfile; +import com.disklexar.mmorpg.rpg.clazz.ClassCatalog; +import com.disklexar.mmorpg.rpg.clazz.PlayerClass; +import com.disklexar.mmorpg.rpg.job.JobCatalog; +import com.disklexar.mmorpg.rpg.job.JobDefinition; +import com.disklexar.mmorpg.rpg.power.PowerCatalog; +import com.disklexar.mmorpg.rpg.power.PowerDefinition; +import com.disklexar.mmorpg.rpg.race.RaceCatalog; + +import javax.annotation.Nonnull; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.StringJoiner; + +/** + * Builds a human-readable summary of every MMORPG attribute of a player. Shared by the chat + * {@code /mmorpg info} command and the custom UI page so both stay consistent. + */ +public final class PlayerInfoView { + + private static final DateTimeFormatter DATE_FORMAT = + DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm").withZone(ZoneId.systemDefault()); + + public record Entry(@Nonnull String label, @Nonnull String value) { + } + + private PlayerInfoView() { + } + + @Nonnull + public static List entries(@Nonnull PlayerProfile profile) { + List entries = new ArrayList<>(); + entries.add(new Entry("Nom", profile.getDisplayName())); + entries.add(new Entry("Niveau", String.valueOf(profile.getLevel()))); + entries.add(new Entry("Expérience", String.valueOf(profile.getExperience()))); + entries.add(new Entry("Argent", profile.getMoney() + " money")); + entries.add(new Entry("Race", RaceCatalog.byId(profile.getRaceId()).displayName())); + entries.add(new Entry("Classe", className(profile))); + entries.add(new Entry("Pouvoirs", powerNames(profile))); + entries.add(new Entry("Métiers", jobNames(profile))); + entries.add(new Entry("Groupe", profile.getGroupId() != null ? "Oui" : "Aucun")); + entries.add(new Entry("Guilde", profile.getGuildId() != null ? profile.getGuildId() : "Aucune")); + entries.add(new Entry("Connecté", profile.isConnected() ? "Oui" : "Non")); + entries.add(new Entry("Temps de jeu", formatDuration(profile.getTotalTimePlay()))); + entries.add(new Entry("Dernière connexion", formatDate(profile.getLastDateConnected()))); + entries.add(new Entry("Création", formatDate(profile.getDateCreation()))); + return entries; + } + + @Nonnull + public static String asText(@Nonnull PlayerProfile profile) { + StringBuilder builder = new StringBuilder("=== Profil MMORPG ==="); + for (Entry entry : entries(profile)) { + builder.append('\n').append(entry.label()).append(" : ").append(entry.value()); + } + return builder.toString(); + } + + @Nonnull + private static String className(@Nonnull PlayerProfile profile) { + PlayerClass playerClass = ClassCatalog.byId(profile.getClassId()); + return playerClass != null ? playerClass.displayName() : "Aucune"; + } + + @Nonnull + private static String powerNames(@Nonnull PlayerProfile profile) { + if (profile.getPowers().isEmpty()) { + return "Aucun"; + } + StringJoiner joiner = new StringJoiner(", "); + for (String id : profile.getPowers()) { + PowerDefinition power = PowerCatalog.byId(id); + joiner.add(power != null ? power.displayName() : id); + } + return joiner.toString(); + } + + @Nonnull + private static String jobNames(@Nonnull PlayerProfile profile) { + if (profile.getJobs().isEmpty()) { + return "Aucun"; + } + StringJoiner joiner = new StringJoiner(", "); + for (String id : profile.getJobs()) { + JobDefinition job = JobCatalog.byId(id); + joiner.add(job != null ? job.displayName() : id); + } + return joiner.toString(); + } + + @Nonnull + private static String formatDuration(long millis) { + if (millis <= 0) { + return "0 min"; + } + Duration duration = Duration.ofMillis(millis); + long hours = duration.toHours(); + long minutes = duration.toMinutesPart(); + return hours > 0 ? hours + "h " + minutes + "min" : minutes + "min"; + } + + @Nonnull + private static String formatDate(long epochMillis) { + if (epochMillis <= 0) { + return "—"; + } + return DATE_FORMAT.format(Instant.ofEpochMilli(epochMillis)); + } +} diff --git a/src/main/java/com/disklexar/mmorpg/player/PlayerSessionService.java b/src/main/java/com/disklexar/mmorpg/player/PlayerSessionService.java new file mode 100644 index 0000000..fb74dc8 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/player/PlayerSessionService.java @@ -0,0 +1,122 @@ +package com.disklexar.mmorpg.player; + +import com.disklexar.mmorpg.MmorpgPlugin; +import com.disklexar.mmorpg.core.config.MmorpgConfig; +import com.disklexar.mmorpg.core.service.Service; +import com.disklexar.mmorpg.persistence.repository.PlayerProfileRepository; +import com.disklexar.mmorpg.player.model.PlayerProfile; +import com.disklexar.mmorpg.rpg.race.RaceCatalog; +import com.hypixel.hytale.logger.HytaleLogger; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.sql.SQLException; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.logging.Level; + +/** + * Tracks online player sessions and synchronizes profiles with SQLite. + */ +public final class PlayerSessionService implements Service { + + private final MmorpgPlugin plugin; + private final MmorpgConfig config; + private final PlayerProfileRepository repository; + private final HytaleLogger logger; + private final Map sessions = new ConcurrentHashMap<>(); + private final Map connectedAt = new ConcurrentHashMap<>(); + + public PlayerSessionService( + @Nonnull MmorpgPlugin plugin, + @Nonnull MmorpgConfig config, + @Nonnull PlayerProfileRepository repository) { + this.plugin = plugin; + this.config = config; + this.repository = repository; + this.logger = plugin.getLogger(); + } + + @Override + public void start() { + try { + repository.clearConnectedFlags(); + } catch (SQLException e) { + logger.at(Level.WARNING).log("Failed to reset connection flags: %s", e.getMessage()); + } + logger.at(Level.INFO).log("Player session service started"); + } + + @Override + public void shutdown() { + for (PlayerProfile profile : sessions.values()) { + accumulatePlaytime(profile.getUuid(), profile); + profile.setConnected(false); + saveQuietly(profile); + } + sessions.clear(); + connectedAt.clear(); + logger.at(Level.INFO).log("Player session service stopped"); + } + + /** + * Loads (or creates) a profile and marks the player connected. + */ + @Nonnull + public PlayerProfile loadOrCreate(@Nonnull UUID uuid, @Nonnull String displayName) throws SQLException { + long now = System.currentTimeMillis(); + Optional existing = repository.findByUuid(uuid); + PlayerProfile profile; + if (existing.isPresent()) { + profile = existing.get(); + profile.setDisplayName(displayName); + } else { + profile = new PlayerProfile(uuid, displayName, now); + profile.setLevel(config.getDefaultLevel()); + profile.setRaceId(RaceCatalog.DEFAULT_RACE_ID); + logger.at(Level.INFO).log("Created new MMORPG profile for %s", displayName); + } + + profile.setConnected(true); + profile.setLastDateConnected(now); + profile.touch(); + sessions.put(uuid, profile); + connectedAt.put(uuid, now); + saveQuietly(profile); + return profile; + } + + @Nullable + public PlayerProfile getSession(@Nonnull UUID uuid) { + return sessions.get(uuid); + } + + public void unload(@Nonnull UUID uuid) { + PlayerProfile profile = sessions.remove(uuid); + if (profile == null) { + return; + } + accumulatePlaytime(uuid, profile); + profile.setConnected(false); + profile.touch(); + saveQuietly(profile); + } + + private void accumulatePlaytime(@Nonnull UUID uuid, @Nonnull PlayerProfile profile) { + Long start = connectedAt.remove(uuid); + if (start != null) { + profile.addTimePlay(System.currentTimeMillis() - start); + } + } + + private void saveQuietly(@Nonnull PlayerProfile profile) { + try { + repository.save(profile); + } catch (SQLException e) { + logger.at(Level.WARNING).log( + "Failed to save profile for %s: %s", profile.getUuid(), e.getMessage()); + } + } +} diff --git a/src/main/java/com/disklexar/mmorpg/player/model/PlayerProfile.java b/src/main/java/com/disklexar/mmorpg/player/model/PlayerProfile.java new file mode 100644 index 0000000..4193556 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/player/model/PlayerProfile.java @@ -0,0 +1,175 @@ +package com.disklexar.mmorpg.player.model; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.UUID; + +/** + * Persistent MMORPG player profile. + *

+ * Mutable so gameplay services can update progression in-session; the + * {@code updatedAt} timestamp is refreshed by {@link #touch()} on every change and the whole + * profile is flushed to SQLite on unload / shutdown. + */ +public final class PlayerProfile { + + private final UUID uuid; + private String displayName; + private int level; + private long experience; + + @Nullable + private String classId; + private final Set powers = new LinkedHashSet<>(); + private final Set jobs = new LinkedHashSet<>(); + @Nullable + private String guildId; + @Nullable + private String groupId; + private boolean connected; + private long totalTimePlay; + private long lastDateConnected; + private long money; + private String raceId = "human"; + + private final long dateCreation; + private long updatedAt; + + public PlayerProfile(@Nonnull UUID uuid, @Nonnull String displayName, long dateCreation) { + this.uuid = uuid; + this.displayName = displayName; + this.dateCreation = dateCreation; + this.updatedAt = dateCreation; + } + + @Nonnull + public UUID getUuid() { + return uuid; + } + + @Nonnull + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(@Nonnull String displayName) { + this.displayName = displayName; + } + + public int getLevel() { + return level; + } + + public void setLevel(int level) { + this.level = level; + } + + public long getExperience() { + return experience; + } + + public void setExperience(long experience) { + this.experience = experience; + } + + @Nullable + public String getClassId() { + return classId; + } + + public void setClassId(@Nullable String classId) { + this.classId = classId; + } + + @Nonnull + public Set getPowers() { + return powers; + } + + @Nonnull + public Set getJobs() { + return jobs; + } + + @Nullable + public String getGuildId() { + return guildId; + } + + public void setGuildId(@Nullable String guildId) { + this.guildId = guildId; + } + + @Nullable + public String getGroupId() { + return groupId; + } + + public void setGroupId(@Nullable String groupId) { + this.groupId = groupId; + } + + public boolean isConnected() { + return connected; + } + + public void setConnected(boolean connected) { + this.connected = connected; + } + + public long getTotalTimePlay() { + return totalTimePlay; + } + + public void setTotalTimePlay(long totalTimePlay) { + this.totalTimePlay = totalTimePlay; + } + + public void addTimePlay(long deltaMillis) { + this.totalTimePlay += Math.max(0L, deltaMillis); + } + + public long getLastDateConnected() { + return lastDateConnected; + } + + public void setLastDateConnected(long lastDateConnected) { + this.lastDateConnected = lastDateConnected; + } + + public long getMoney() { + return money; + } + + public void setMoney(long money) { + this.money = Math.max(0L, money); + } + + @Nonnull + public String getRaceId() { + return raceId; + } + + public void setRaceId(@Nonnull String raceId) { + this.raceId = raceId; + } + + public long getDateCreation() { + return dateCreation; + } + + public long getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(long updatedAt) { + this.updatedAt = updatedAt; + } + + /** Refreshes {@link #updatedAt} to now; call after mutating any field. */ + public void touch() { + this.updatedAt = System.currentTimeMillis(); + } +} diff --git a/src/main/java/com/disklexar/mmorpg/progression/ProgressionService.java b/src/main/java/com/disklexar/mmorpg/progression/ProgressionService.java new file mode 100644 index 0000000..f60d385 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/progression/ProgressionService.java @@ -0,0 +1,53 @@ +package com.disklexar.mmorpg.progression; + +import com.disklexar.mmorpg.player.model.PlayerProfile; +import com.disklexar.mmorpg.rpg.race.RaceCatalog; +import com.disklexar.mmorpg.rpg.race.RaceDefinition; + +import javax.annotation.Nonnull; + +/** + * Experience and leveling. Applies the player's race XP multiplier (Humain x2). + */ +public final class ProgressionService { + + private final int baseXpPerLevel; + + public ProgressionService(int baseXpPerLevel) { + this.baseXpPerLevel = Math.max(1, baseXpPerLevel); + } + + /** XP required to advance from {@code level} to {@code level + 1}. */ + public long xpForLevel(int level) { + return (long) baseXpPerLevel * Math.max(1, level); + } + + /** + * Grants {@code baseAmount} XP, scaled by the player's race multiplier when requested. + * + * @return the amount of XP actually added (after multiplier). + */ + public long grantExperience(@Nonnull PlayerProfile profile, long baseAmount, boolean applyRaceMultiplier) { + if (baseAmount <= 0) { + return 0L; + } + long granted = baseAmount; + if (applyRaceMultiplier) { + RaceDefinition race = RaceCatalog.byId(profile.getRaceId()); + granted = Math.round(baseAmount * race.xpMultiplier()); + } + profile.setExperience(profile.getExperience() + granted); + applyLevelUps(profile); + profile.touch(); + return granted; + } + + private void applyLevelUps(@Nonnull PlayerProfile profile) { + long needed = xpForLevel(profile.getLevel()); + while (profile.getExperience() >= needed) { + profile.setExperience(profile.getExperience() - needed); + profile.setLevel(profile.getLevel() + 1); + needed = xpForLevel(profile.getLevel()); + } + } +} diff --git a/src/main/java/com/disklexar/mmorpg/quest/package-info.java b/src/main/java/com/disklexar/mmorpg/quest/package-info.java new file mode 100644 index 0000000..4c8227d --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/quest/package-info.java @@ -0,0 +1,7 @@ +/** + * Quest system (objectives, rewards, quest chains). + *

+ * Disabled by default via {@code Features.Quests} in config.json. + * Planned: quest definitions, progress tracking, daily quests. + */ +package com.disklexar.mmorpg.quest; diff --git a/src/main/java/com/disklexar/mmorpg/rpg/clazz/AbilityDefinition.java b/src/main/java/com/disklexar/mmorpg/rpg/clazz/AbilityDefinition.java new file mode 100644 index 0000000..919a2bf --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/rpg/clazz/AbilityDefinition.java @@ -0,0 +1,14 @@ +package com.disklexar.mmorpg.rpg.clazz; + +import javax.annotation.Nonnull; + +/** + * A single class ability (active skill) with its cooldown. + */ +public record AbilityDefinition( + @Nonnull String id, + int slot, + @Nonnull String displayName, + @Nonnull String description, + long cooldownMillis) { +} diff --git a/src/main/java/com/disklexar/mmorpg/rpg/clazz/ClassCatalog.java b/src/main/java/com/disklexar/mmorpg/rpg/clazz/ClassCatalog.java new file mode 100644 index 0000000..44bd9ae --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/rpg/clazz/ClassCatalog.java @@ -0,0 +1,119 @@ +package com.disklexar.mmorpg.rpg.clazz; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Registry of all playable classes. + */ +public final class ClassCatalog { + + public static final String WEAPON_LONGSWORD = "LongSword"; + public static final String WEAPON_BATTLEAXE = "BattleAxe"; + public static final String WEAPON_BOW = "Bow"; + public static final String WEAPON_CROSSBOW = "Crossbow"; + public static final String WEAPON_STAFF = "Staff"; + public static final String WEAPON_DAGGERS = "Daggers"; + + public static final String ABILITY_DASH = "knight_dash"; + public static final String ABILITY_SPIN = "knight_spin"; + public static final String ABILITY_SHOUT = "knight_shout"; + + public static final String ABILITY_ARCHER_RAPIDFIRE = "archer_rapidfire"; + public static final String ABILITY_ARCHER_VOLLEY = "archer_volley"; + public static final String ABILITY_ARCHER_POWERSHOT = "archer_powershot"; + + public static final String ABILITY_MAGE_FIREBALLS = "mage_fireballs"; + public static final String ABILITY_MAGE_TELEPORT = "mage_teleport"; + public static final String ABILITY_MAGE_TORNADO = "mage_tornado"; + + public static final String ABILITY_ASSASSIN_CAMOUFLAGE = "assassin_camouflage"; + public static final String ABILITY_ASSASSIN_SHADOWSTEP = "assassin_shadowstep"; + public static final String ABILITY_ASSASSIN_POISONBLADE = "assassin_poisonblade"; + + public static final PlayerClass KNIGHT = new PlayerClass( + "knight", + "Chevalier", + "Combattant lourd au corps-à-corps. Utilise l'Épée longue et la Hache de bataille.", + List.of(WEAPON_LONGSWORD, WEAPON_BATTLEAXE), + List.of( + new AbilityDefinition(ABILITY_DASH, 1, "Charge", + "Petit dash en avant qui étourdit 3s tous les ennemis touchés.", 8_000L), + new AbilityDefinition(ABILITY_SPIN, 2, "Toupie", + "Tournoiement infligeant des dégâts autour de vous pendant 3s.", 12_000L), + new AbilityDefinition(ABILITY_SHOUT, 3, "Cri de guerre", + "Repousse les entités proches (dégâts bonus si étourdies) et réduit leur résistance de 20% pendant 20s.", + 15_000L))); + + public static final PlayerClass ARCHER = new PlayerClass( + "archer", + "Archer", + "Tireur à distance. Utilise l'Arc et l'Arbalète.", + List.of(WEAPON_BOW, WEAPON_CROSSBOW), + List.of( + new AbilityDefinition(ABILITY_ARCHER_RAPIDFIRE, 1, "Tir rapide", + "Augmente votre cadence de tir (+20% de dégâts) pendant 10s.", 12_000L), + new AbilityDefinition(ABILITY_ARCHER_VOLLEY, 2, "Salve", + "Bond en arrière puis décoche instantanément 6 flèches devant vous.", 10_000L), + new AbilityDefinition(ABILITY_ARCHER_POWERSHOT, 3, "Tir puissant", + "Votre prochaine attaque de base inflige +200% de dégâts.", 9_000L))); + + public static final PlayerClass MAGE = new PlayerClass( + "mage", + "Mage", + "Lanceur de sorts à distance. Utilise le Bâton.", + List.of(WEAPON_STAFF), + List.of( + new AbilityDefinition(ABILITY_MAGE_FIREBALLS, 1, "Nova de Givre", + "Envoie instantanément 3 boules de feu vers l'avant.", 8_000L), + new AbilityDefinition(ABILITY_MAGE_TELEPORT, 2, "Téléportation", + "Vous téléporte instantanément de 8 blocs vers l'avant.", 10_000L), + new AbilityDefinition(ABILITY_MAGE_TORNADO, 3, "Surcharge Arcane", + "Crée une tornade dévastatrice 15 blocs devant vous.", 16_000L))); + + public static final PlayerClass ASSASSIN = new PlayerClass( + "assassin", + "Assassin", + "Tueur furtif au corps-à-corps. Utilise les Dagues.", + List.of(WEAPON_DAGGERS), + List.of( + new AbilityDefinition(ABILITY_ASSASSIN_CAMOUFLAGE, 1, "Camouflage", + "Devient totalement invisible pendant 6s.", 14_000L), + new AbilityDefinition(ABILITY_ASSASSIN_SHADOWSTEP, 2, "Pas de l'Ombre", + "Se téléporte instantanément dans le dos de la cible visée.", 9_000L), + new AbilityDefinition(ABILITY_ASSASSIN_POISONBLADE, 3, "Lame Empoisonnée", + "Pendant 10s, chaque coup applique un poison (dégâts magiques/seconde pendant 5s, cumulable).", + 13_000L))); + + private static final Map BY_ID = new LinkedHashMap<>(); + + static { + register(KNIGHT); + register(ARCHER); + register(MAGE); + register(ASSASSIN); + } + + private ClassCatalog() { + } + + private static void register(@Nonnull PlayerClass playerClass) { + BY_ID.put(playerClass.id().toLowerCase(), playerClass); + } + + @Nullable + public static PlayerClass byId(@Nullable String id) { + if (id == null) { + return null; + } + return BY_ID.get(id.toLowerCase()); + } + + @Nonnull + public static List all() { + return List.copyOf(BY_ID.values()); + } +} diff --git a/src/main/java/com/disklexar/mmorpg/rpg/clazz/ClassService.java b/src/main/java/com/disklexar/mmorpg/rpg/clazz/ClassService.java new file mode 100644 index 0000000..f37d078 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/rpg/clazz/ClassService.java @@ -0,0 +1,45 @@ +package com.disklexar.mmorpg.rpg.clazz; + +import com.disklexar.mmorpg.player.model.PlayerProfile; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * Selecting / leaving a player class. + */ +public final class ClassService { + + @Nullable + public PlayerClass getClass(@Nonnull PlayerProfile profile) { + return ClassCatalog.byId(profile.getClassId()); + } + + public boolean hasClass(@Nonnull PlayerProfile profile) { + return getClass(profile) != null; + } + + /** + * Assigns a class to the player. + * + * @return {@code true} if the class exists and was assigned. + */ + public boolean choose(@Nonnull PlayerProfile profile, @Nonnull String classId) { + PlayerClass playerClass = ClassCatalog.byId(classId); + if (playerClass == null) { + return false; + } + profile.setClassId(playerClass.id()); + profile.touch(); + return true; + } + + public boolean leave(@Nonnull PlayerProfile profile) { + if (profile.getClassId() == null) { + return false; + } + profile.setClassId(null); + profile.touch(); + return true; + } +} diff --git a/src/main/java/com/disklexar/mmorpg/rpg/clazz/PlayerClass.java b/src/main/java/com/disklexar/mmorpg/rpg/clazz/PlayerClass.java new file mode 100644 index 0000000..1a818af --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/rpg/clazz/PlayerClass.java @@ -0,0 +1,24 @@ +package com.disklexar.mmorpg.rpg.clazz; + +import javax.annotation.Nonnull; +import java.util.List; + +/** + * Definition of a playable class: its usable weapons and its abilities. + */ +public record PlayerClass( + @Nonnull String id, + @Nonnull String displayName, + @Nonnull String description, + @Nonnull List weapons, + @Nonnull List abilities) { + + public boolean usesWeapon(@Nonnull String weaponId) { + for (String weapon : weapons) { + if (weapon.equalsIgnoreCase(weaponId)) { + return true; + } + } + return false; + } +} diff --git a/src/main/java/com/disklexar/mmorpg/rpg/group/Group.java b/src/main/java/com/disklexar/mmorpg/rpg/group/Group.java new file mode 100644 index 0000000..e9cb8e4 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/rpg/group/Group.java @@ -0,0 +1,61 @@ +package com.disklexar.mmorpg.rpg.group; + +import javax.annotation.Nonnull; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * A party of players. When a member kills a mob, the other members receive a share of the XP. + */ +public final class Group { + + private final UUID id; + private final UUID owner; + private final long createdAt; + private final Set members = ConcurrentHashMap.newKeySet(); + private final Set pendingInvites = ConcurrentHashMap.newKeySet(); + + public Group(@Nonnull UUID id, @Nonnull UUID owner, long createdAt) { + this.id = id; + this.owner = owner; + this.createdAt = createdAt; + this.members.add(owner); + } + + @Nonnull + public UUID getId() { + return id; + } + + @Nonnull + public UUID getOwner() { + return owner; + } + + public long getCreatedAt() { + return createdAt; + } + + @Nonnull + public Set getMembers() { + return members; + } + + @Nonnull + public Set getPendingInvites() { + return pendingInvites; + } + + public boolean isOwner(@Nonnull UUID uuid) { + return owner.equals(uuid); + } + + @Nonnull + public Set membersExcept(@Nonnull UUID uuid) { + Set others = new LinkedHashSet<>(members); + others.remove(uuid); + return others; + } +} diff --git a/src/main/java/com/disklexar/mmorpg/rpg/group/GroupService.java b/src/main/java/com/disklexar/mmorpg/rpg/group/GroupService.java new file mode 100644 index 0000000..b7cce67 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/rpg/group/GroupService.java @@ -0,0 +1,257 @@ +package com.disklexar.mmorpg.rpg.group; + +import com.disklexar.mmorpg.MmorpgPlugin; +import com.disklexar.mmorpg.core.service.Service; +import com.disklexar.mmorpg.persistence.repository.GroupRepository; +import com.disklexar.mmorpg.player.PlayerSessionService; +import com.disklexar.mmorpg.player.model.PlayerProfile; +import com.disklexar.mmorpg.progression.ProgressionService; +import com.hypixel.hytale.logger.HytaleLogger; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.sql.SQLException; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.logging.Level; + +/** + * Manages parties (groups) and shared kill experience. + *

+ * When a member kills a mob, the other online members receive + * {@value #SHARED_XP_FRACTION_PERCENT}% of the XP the killer gained. + */ +public final class GroupService implements Service { + + public static final int SHARED_XP_FRACTION_PERCENT = 40; + private static final double SHARED_XP_FRACTION = SHARED_XP_FRACTION_PERCENT / 100.0; + + private final HytaleLogger logger; + private final GroupRepository repository; + private final PlayerSessionService sessions; + private final ProgressionService progression; + + private final Map groupsById = new ConcurrentHashMap<>(); + private final Map groupByPlayer = new ConcurrentHashMap<>(); + + public GroupService( + @Nonnull MmorpgPlugin plugin, + @Nonnull GroupRepository repository, + @Nonnull PlayerSessionService sessions, + @Nonnull ProgressionService progression) { + this.logger = plugin.getLogger(); + this.repository = repository; + this.sessions = sessions; + this.progression = progression; + } + + @Override + public void start() { + try { + for (Group group : repository.loadAll()) { + groupsById.put(group.getId(), group); + for (UUID member : group.getMembers()) { + groupByPlayer.put(member, group.getId()); + } + } + logger.at(Level.INFO).log("Loaded %d MMORPG groups", groupsById.size()); + } catch (SQLException e) { + logger.at(Level.WARNING).log("Failed to load groups: %s", e.getMessage()); + } + } + + @Override + public void shutdown() { + groupsById.clear(); + groupByPlayer.clear(); + } + + @Nullable + public Group getGroupOf(@Nonnull UUID player) { + UUID groupId = groupByPlayer.get(player); + return groupId == null ? null : groupsById.get(groupId); + } + + @Nullable + public Group getById(@Nonnull UUID groupId) { + return groupsById.get(groupId); + } + + /** Invites {@code target} to {@code inviter}'s group, creating the group if needed. */ + @Nonnull + public InviteResult invite(@Nonnull UUID inviter, @Nonnull UUID target) { + if (inviter.equals(target)) { + return InviteResult.SELF; + } + if (getGroupOf(target) != null) { + return InviteResult.TARGET_ALREADY_GROUPED; + } + Group group = getGroupOf(inviter); + if (group == null) { + group = createGroup(inviter); + } else if (!group.isOwner(inviter)) { + return InviteResult.NOT_OWNER; + } + group.getPendingInvites().add(target); + return InviteResult.INVITED; + } + + /** Accepts a pending invitation. */ + @Nonnull + public AcceptResult accept(@Nonnull UUID player) { + if (getGroupOf(player) != null) { + return AcceptResult.ALREADY_GROUPED; + } + for (Group group : groupsById.values()) { + if (group.getPendingInvites().remove(player)) { + addMember(group, player); + return AcceptResult.JOINED; + } + } + return AcceptResult.NO_INVITE; + } + + /** Removes the player from their group; disbands it if the owner leaves or it becomes empty. */ + public boolean leave(@Nonnull UUID player) { + Group group = getGroupOf(player); + if (group == null) { + return false; + } + if (group.isOwner(player)) { + disband(group); + return true; + } + removeMember(group, player); + if (group.getMembers().size() <= 1) { + disband(group); + } + return true; + } + + @Nonnull + public KickResult kick(@Nonnull UUID owner, @Nonnull UUID target) { + Group group = getGroupOf(owner); + if (group == null || !group.isOwner(owner)) { + return KickResult.NOT_OWNER; + } + if (owner.equals(target)) { + return KickResult.CANNOT_KICK_SELF; + } + if (!group.getMembers().contains(target)) { + return KickResult.NOT_MEMBER; + } + removeMember(group, target); + if (group.getMembers().size() <= 1) { + disband(group); + } + return KickResult.KICKED; + } + + /** + * Distributes shared XP to the killer's online group mates. + * + * @return number of members who received XP. + */ + public int shareKillExperience(@Nonnull UUID killer, long killerGainedXp) { + if (killerGainedXp <= 0) { + return 0; + } + Group group = getGroupOf(killer); + if (group == null) { + return 0; + } + long shared = Math.round(killerGainedXp * SHARED_XP_FRACTION); + if (shared <= 0) { + return 0; + } + int rewarded = 0; + for (UUID member : group.membersExcept(killer)) { + PlayerProfile profile = sessions.getSession(member); + if (profile == null) { + continue; + } + progression.grantExperience(profile, shared, false); + rewarded++; + } + return rewarded; + } + + @Nonnull + private Group createGroup(@Nonnull UUID owner) { + Group group = new Group(UUID.randomUUID(), owner, System.currentTimeMillis()); + groupsById.put(group.getId(), group); + groupByPlayer.put(owner, group.getId()); + persistGroup(group); + addMemberPersistence(group.getId(), owner); + setProfileGroup(owner, group.getId()); + return group; + } + + private void addMember(@Nonnull Group group, @Nonnull UUID player) { + group.getMembers().add(player); + groupByPlayer.put(player, group.getId()); + addMemberPersistence(group.getId(), player); + setProfileGroup(player, group.getId()); + } + + private void removeMember(@Nonnull Group group, @Nonnull UUID player) { + group.getMembers().remove(player); + groupByPlayer.remove(player); + try { + repository.removeMember(group.getId(), player); + } catch (SQLException e) { + logger.at(Level.WARNING).log("Failed to remove group member: %s", e.getMessage()); + } + setProfileGroup(player, null); + } + + private void disband(@Nonnull Group group) { + for (UUID member : group.getMembers()) { + groupByPlayer.remove(member); + setProfileGroup(member, null); + } + groupsById.remove(group.getId()); + try { + repository.deleteGroup(group.getId()); + } catch (SQLException e) { + logger.at(Level.WARNING).log("Failed to delete group: %s", e.getMessage()); + } + } + + private void persistGroup(@Nonnull Group group) { + try { + repository.insertGroup(group); + } catch (SQLException e) { + logger.at(Level.WARNING).log("Failed to persist group: %s", e.getMessage()); + } + } + + private void addMemberPersistence(@Nonnull UUID groupId, @Nonnull UUID player) { + try { + repository.addMember(groupId, player); + } catch (SQLException e) { + logger.at(Level.WARNING).log("Failed to persist group member: %s", e.getMessage()); + } + } + + private void setProfileGroup(@Nonnull UUID player, @Nullable UUID groupId) { + PlayerProfile profile = sessions.getSession(player); + if (profile != null) { + profile.setGroupId(groupId == null ? null : groupId.toString()); + profile.touch(); + } + } + + public enum InviteResult { + INVITED, SELF, NOT_OWNER, TARGET_ALREADY_GROUPED + } + + public enum AcceptResult { + JOINED, NO_INVITE, ALREADY_GROUPED + } + + public enum KickResult { + KICKED, NOT_OWNER, NOT_MEMBER, CANNOT_KICK_SELF + } +} diff --git a/src/main/java/com/disklexar/mmorpg/rpg/job/JobCatalog.java b/src/main/java/com/disklexar/mmorpg/rpg/job/JobCatalog.java new file mode 100644 index 0000000..f449954 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/rpg/job/JobCatalog.java @@ -0,0 +1,55 @@ +package com.disklexar.mmorpg.rpg.job; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Registry of all jobs. + */ +public final class JobCatalog { + + /** Money granted per mob killed while the Monster Slayer job is active. */ + public static final long MONSTER_SLAYER_REWARD = 1L; + /** Money granted per block broken while the Miner job is active. */ + public static final long MINER_REWARD = 1L; + + public static final JobDefinition MONSTER_SLAYER = new JobDefinition( + "monster_slayer", + "Tueur de monstre", + "Gagne 1 money à chaque monstre tué."); + + public static final JobDefinition MINER = new JobDefinition( + "miner", + "Mineur", + "Gagne 1 money à chaque bloc cassé."); + + private static final Map BY_ID = new LinkedHashMap<>(); + + static { + register(MONSTER_SLAYER); + register(MINER); + } + + private JobCatalog() { + } + + private static void register(@Nonnull JobDefinition job) { + BY_ID.put(job.id().toLowerCase(), job); + } + + @Nullable + public static JobDefinition byId(@Nullable String id) { + if (id == null) { + return null; + } + return BY_ID.get(id.toLowerCase()); + } + + @Nonnull + public static List all() { + return List.copyOf(BY_ID.values()); + } +} diff --git a/src/main/java/com/disklexar/mmorpg/rpg/job/JobDefinition.java b/src/main/java/com/disklexar/mmorpg/rpg/job/JobDefinition.java new file mode 100644 index 0000000..a19a8ff --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/rpg/job/JobDefinition.java @@ -0,0 +1,12 @@ +package com.disklexar.mmorpg.rpg.job; + +import javax.annotation.Nonnull; + +/** + * A job a player can join to earn money from a specific activity. + */ +public record JobDefinition( + @Nonnull String id, + @Nonnull String displayName, + @Nonnull String description) { +} diff --git a/src/main/java/com/disklexar/mmorpg/rpg/job/JobService.java b/src/main/java/com/disklexar/mmorpg/rpg/job/JobService.java new file mode 100644 index 0000000..880a1a6 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/rpg/job/JobService.java @@ -0,0 +1,49 @@ +package com.disklexar.mmorpg.rpg.job; + +import com.disklexar.mmorpg.player.model.PlayerProfile; + +import javax.annotation.Nonnull; + +/** + * Joining / leaving jobs. + */ +public final class JobService { + + public boolean has(@Nonnull PlayerProfile profile, @Nonnull String jobId) { + return profile.getJobs().contains(jobId.toLowerCase()); + } + + /** + * Joins a job. + * + * @return {@code true} if the job exists and was newly joined. + */ + public boolean join(@Nonnull PlayerProfile profile, @Nonnull String jobId) { + JobDefinition job = JobCatalog.byId(jobId); + if (job == null) { + return false; + } + boolean added = profile.getJobs().add(job.id()); + if (added) { + profile.touch(); + } + return added; + } + + /** + * Leaves a job. + * + * @return {@code true} if the job was active and was left. + */ + public boolean leave(@Nonnull PlayerProfile profile, @Nonnull String jobId) { + JobDefinition job = JobCatalog.byId(jobId); + if (job == null) { + return false; + } + boolean removed = profile.getJobs().remove(job.id()); + if (removed) { + profile.touch(); + } + return removed; + } +} diff --git a/src/main/java/com/disklexar/mmorpg/rpg/power/PowerCatalog.java b/src/main/java/com/disklexar/mmorpg/rpg/power/PowerCatalog.java new file mode 100644 index 0000000..15e0ffc --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/rpg/power/PowerCatalog.java @@ -0,0 +1,57 @@ +package com.disklexar.mmorpg.rpg.power; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Registry of all passive powers. + */ +public final class PowerCatalog { + + /** Constant out-of-combat movement bonus. */ + public static final double SPRINTER_SPEED_BONUS = 0.10; + /** Damage bonus applied for a short window after being hit. */ + public static final double RESILIENT_DAMAGE_BONUS = 0.10; + /** Window during which {@link #RESILIENT} grants its bonus, in milliseconds. */ + public static final long RESILIENT_WINDOW_MILLIS = 5_000L; + + public static final PowerDefinition SPRINTER = new PowerDefinition( + "sprinter", + "Sprinteur", + "+10% de vitesse en permanence hors combat."); + + public static final PowerDefinition RESILIENT = new PowerDefinition( + "resilient", + "Résilient", + "+10% de dégâts pendant 5s après avoir été touché."); + + private static final Map BY_ID = new LinkedHashMap<>(); + + static { + register(SPRINTER); + register(RESILIENT); + } + + private PowerCatalog() { + } + + private static void register(@Nonnull PowerDefinition power) { + BY_ID.put(power.id().toLowerCase(), power); + } + + @Nullable + public static PowerDefinition byId(@Nullable String id) { + if (id == null) { + return null; + } + return BY_ID.get(id.toLowerCase()); + } + + @Nonnull + public static List all() { + return List.copyOf(BY_ID.values()); + } +} diff --git a/src/main/java/com/disklexar/mmorpg/rpg/power/PowerDefinition.java b/src/main/java/com/disklexar/mmorpg/rpg/power/PowerDefinition.java new file mode 100644 index 0000000..535c1c9 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/rpg/power/PowerDefinition.java @@ -0,0 +1,12 @@ +package com.disklexar.mmorpg.rpg.power; + +import javax.annotation.Nonnull; + +/** + * A passive power a player can own. Players start with none. + */ +public record PowerDefinition( + @Nonnull String id, + @Nonnull String displayName, + @Nonnull String description) { +} diff --git a/src/main/java/com/disklexar/mmorpg/rpg/power/PowerService.java b/src/main/java/com/disklexar/mmorpg/rpg/power/PowerService.java new file mode 100644 index 0000000..409edb6 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/rpg/power/PowerService.java @@ -0,0 +1,49 @@ +package com.disklexar.mmorpg.rpg.power; + +import com.disklexar.mmorpg.player.model.PlayerProfile; + +import javax.annotation.Nonnull; + +/** + * Granting / removing passive powers. Players start with none. + */ +public final class PowerService { + + public boolean has(@Nonnull PlayerProfile profile, @Nonnull String powerId) { + return profile.getPowers().contains(powerId.toLowerCase()); + } + + /** + * Grants a power. + * + * @return {@code true} if the power exists and was newly added. + */ + public boolean grant(@Nonnull PlayerProfile profile, @Nonnull String powerId) { + PowerDefinition power = PowerCatalog.byId(powerId); + if (power == null) { + return false; + } + boolean added = profile.getPowers().add(power.id()); + if (added) { + profile.touch(); + } + return added; + } + + /** + * Removes a power. + * + * @return {@code true} if the power was present and removed. + */ + public boolean remove(@Nonnull PlayerProfile profile, @Nonnull String powerId) { + PowerDefinition power = PowerCatalog.byId(powerId); + if (power == null) { + return false; + } + boolean removed = profile.getPowers().remove(power.id()); + if (removed) { + profile.touch(); + } + return removed; + } +} diff --git a/src/main/java/com/disklexar/mmorpg/rpg/race/RaceCatalog.java b/src/main/java/com/disklexar/mmorpg/rpg/race/RaceCatalog.java new file mode 100644 index 0000000..9d4b171 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/rpg/race/RaceCatalog.java @@ -0,0 +1,46 @@ +package com.disklexar.mmorpg.rpg.race; + +import javax.annotation.Nonnull; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Registry of all races. Every player defaults to {@link #HUMAN}. + */ +public final class RaceCatalog { + + public static final String DEFAULT_RACE_ID = "human"; + + public static final RaceDefinition HUMAN = new RaceDefinition( + DEFAULT_RACE_ID, + "Humain", + "Race par défaut. Gagne le double d'expérience.", + 2.0); + + private static final Map BY_ID = new LinkedHashMap<>(); + + static { + register(HUMAN); + } + + private RaceCatalog() { + } + + private static void register(@Nonnull RaceDefinition race) { + BY_ID.put(race.id().toLowerCase(), race); + } + + @Nonnull + public static RaceDefinition byId(String id) { + if (id == null) { + return HUMAN; + } + return BY_ID.getOrDefault(id.toLowerCase(), HUMAN); + } + + @Nonnull + public static List all() { + return List.copyOf(BY_ID.values()); + } +} diff --git a/src/main/java/com/disklexar/mmorpg/rpg/race/RaceDefinition.java b/src/main/java/com/disklexar/mmorpg/rpg/race/RaceDefinition.java new file mode 100644 index 0000000..73849d4 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/rpg/race/RaceDefinition.java @@ -0,0 +1,13 @@ +package com.disklexar.mmorpg.rpg.race; + +import javax.annotation.Nonnull; + +/** + * A playable race with its global XP multiplier. + */ +public record RaceDefinition( + @Nonnull String id, + @Nonnull String displayName, + @Nonnull String description, + double xpMultiplier) { +} diff --git a/src/main/java/com/disklexar/mmorpg/rpg/race/RaceService.java b/src/main/java/com/disklexar/mmorpg/rpg/race/RaceService.java new file mode 100644 index 0000000..d1a19ca --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/rpg/race/RaceService.java @@ -0,0 +1,35 @@ +package com.disklexar.mmorpg.rpg.race; + +import com.disklexar.mmorpg.player.model.PlayerProfile; + +import javax.annotation.Nonnull; + +/** + * Race access. Every player defaults to Humain (set at profile creation). + */ +public final class RaceService { + + @Nonnull + public RaceDefinition getRace(@Nonnull PlayerProfile profile) { + return RaceCatalog.byId(profile.getRaceId()); + } + + /** Ensures the profile has a valid race, defaulting to Humain. */ + public void ensureDefault(@Nonnull PlayerProfile profile) { + if (RaceCatalog.byId(profile.getRaceId()) == RaceCatalog.HUMAN + && !RaceCatalog.DEFAULT_RACE_ID.equals(profile.getRaceId())) { + profile.setRaceId(RaceCatalog.DEFAULT_RACE_ID); + profile.touch(); + } + } + + public boolean setRace(@Nonnull PlayerProfile profile, @Nonnull String raceId) { + RaceDefinition race = RaceCatalog.byId(raceId); + if (!race.id().equalsIgnoreCase(raceId)) { + return false; + } + profile.setRaceId(race.id()); + profile.touch(); + return true; + } +} diff --git a/src/main/java/com/disklexar/mmorpg/social/package-info.java b/src/main/java/com/disklexar/mmorpg/social/package-info.java new file mode 100644 index 0000000..6b4ada7 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/social/package-info.java @@ -0,0 +1,7 @@ +/** + * Social systems (parties, guilds, friends). + *

+ * Disabled by default via {@code Features.Guilds} in config.json. + * Planned: guild creation, ranks, shared storage, party instances. + */ +package com.disklexar.mmorpg.social; diff --git a/src/main/java/com/disklexar/mmorpg/ui/AbilityBarHud.java b/src/main/java/com/disklexar/mmorpg/ui/AbilityBarHud.java new file mode 100644 index 0000000..6c26eb6 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/ui/AbilityBarHud.java @@ -0,0 +1,83 @@ +package com.disklexar.mmorpg.ui; + +import com.disklexar.mmorpg.combat.AbilityService; +import com.disklexar.mmorpg.player.model.PlayerProfile; +import com.disklexar.mmorpg.progression.ProgressionService; +import com.disklexar.mmorpg.rpg.clazz.AbilityDefinition; +import com.disklexar.mmorpg.rpg.clazz.PlayerClass; +import com.hypixel.hytale.server.core.entity.entities.player.hud.CustomUIHud; +import com.hypixel.hytale.server.core.ui.builder.UICommandBuilder; +import com.hypixel.hytale.server.core.universe.PlayerRef; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.UUID; + +/** + * Persistent MMORPG HUD: vitals (health, stamina, XP) and class ability bar (top-left). + */ +public final class AbilityBarHud extends CustomUIHud { + + public static final String KEY = "mmorpg_ability_bar"; + private static final String DOCUMENT = "MmorpgAbilityBar.ui"; + public AbilityBarHud(@Nonnull PlayerRef playerRef) { + super(playerRef, KEY); + } + + @Override + protected void build(@Nonnull UICommandBuilder ui) { + ui.append(DOCUMENT); + } + + public void sync( + @Nonnull PlayerProfile profile, + @Nonnull PlayerClass playerClass, + @Nonnull AbilityService abilities, + @Nonnull ProgressionService progression, + @Nullable PlayerVitalsReader.Vitals vitals, + @Nonnull UUID playerId) { + UICommandBuilder ui = new UICommandBuilder(); + + ui.set("#HeaderLabel.Text", escape( + "Nv." + profile.getLevel() + " — " + playerClass.displayName())); + + if (vitals != null) { + applyBar(ui, "#HealthBar", vitals.current(), vitals.max()); + ui.set("#HealthValue.Text", escape(vitals.current() + " / " + vitals.max())); + + applyBar(ui, "#StaminaBar", vitals.staminaCurrent(), vitals.staminaMax()); + ui.set("#StaminaValue.Text", escape( + vitals.staminaCurrent() + " / " + vitals.staminaMax())); + } + + long xpCurrent = profile.getExperience(); + long xpMax = Math.max(1L, progression.xpForLevel(profile.getLevel())); + applyBar(ui, "#XpBar", xpCurrent, xpMax); + ui.set("#XpValue.Text", escape(xpCurrent + " / " + xpMax)); + + for (AbilityDefinition ability : playerClass.abilities()) { + int slot = ability.slot(); + long remaining = abilities.cooldownRemaining(playerId, ability.id()); + String label = remaining > 0 + ? ability.displayName() + " (" + (remaining / 1000 + 1) + "s)" + : ability.displayName(); + ui.set("#Slot" + slot + "Name.Text", escape(label)); + } + + update(false, ui); + } + + private static void applyBar( + @Nonnull UICommandBuilder ui, + @Nonnull String barSelector, + long current, + long max) { + float value = max <= 0 ? 0f : Math.min(1f, (float) current / max); + ui.set(barSelector + ".Value", value); + } + + @Nonnull + private static String escape(@Nonnull String text) { + return text.replace("\\", "\\\\").replace("\"", "\\\""); + } +} diff --git a/src/main/java/com/disklexar/mmorpg/ui/AbilityBarService.java b/src/main/java/com/disklexar/mmorpg/ui/AbilityBarService.java new file mode 100644 index 0000000..f76f094 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/ui/AbilityBarService.java @@ -0,0 +1,237 @@ +package com.disklexar.mmorpg.ui; + +import com.disklexar.mmorpg.MmorpgPlugin; +import com.disklexar.mmorpg.combat.AbilityService; +import com.disklexar.mmorpg.combat.MmorpgScheduler; +import com.disklexar.mmorpg.core.service.Service; +import com.disklexar.mmorpg.player.OnlinePlayer; +import com.disklexar.mmorpg.player.OnlinePlayerRegistry; +import com.disklexar.mmorpg.player.PlayerSessionService; +import com.disklexar.mmorpg.player.model.PlayerProfile; +import com.disklexar.mmorpg.progression.ProgressionService; +import com.disklexar.mmorpg.rpg.clazz.ClassService; +import com.disklexar.mmorpg.rpg.clazz.PlayerClass; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.logger.HytaleLogger; +import com.hypixel.hytale.server.core.entity.entities.Player; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ScheduledFuture; +import java.util.logging.Level; + +/** + * Shows and refreshes the native ability-bar HUD for players with an MMORPG class. + */ +public final class AbilityBarService implements Service { + + private final HytaleLogger logger; + private final ClassService classService; + private final AbilityService abilityService; + private final OnlinePlayerRegistry onlinePlayers; + private final PlayerSessionService sessions; + private final ProgressionService progression; + private final MmorpgScheduler scheduler; + + private final Map activeHuds = new ConcurrentHashMap<>(); + private ScheduledFuture refreshTask; + + public AbilityBarService( + @Nonnull MmorpgPlugin plugin, + @Nonnull ClassService classService, + @Nonnull AbilityService abilityService, + @Nonnull OnlinePlayerRegistry onlinePlayers, + @Nonnull PlayerSessionService sessions, + @Nonnull ProgressionService progression, + @Nonnull MmorpgScheduler scheduler) { + this.logger = plugin.getLogger(); + this.classService = classService; + this.abilityService = abilityService; + this.onlinePlayers = onlinePlayers; + this.sessions = sessions; + this.progression = progression; + this.scheduler = scheduler; + } + + @Override + public void start() { + refreshTask = scheduler.runRepeating(this::refreshAll, 5_000L, 1_000L); + } + + @Override + public void shutdown() { + if (refreshTask != null) { + refreshTask.cancel(false); + refreshTask = null; + } + activeHuds.clear(); + } + + public void sync(@Nonnull UUID playerUuid, @Nonnull PlayerProfile profile) { + OnlinePlayer online = onlinePlayers.get(playerUuid); + if (online == null) { + return; + } + sync(online, profile); + } + + public void sync(@Nonnull OnlinePlayer online, @Nonnull PlayerProfile profile) { + PlayerClass playerClass = classService.getClass(profile); + if (playerClass == null) { + dismiss(online.uuid()); + return; + } + + online.world().execute(() -> applyHud(online, profile, playerClass)); + } + + public void dismiss(@Nonnull UUID playerUuid) { + OnlinePlayer online = onlinePlayers.get(playerUuid); + if (online == null) { + activeHuds.remove(playerUuid); + return; + } + dismiss(online); + } + + public void dismiss(@Nonnull OnlinePlayer online) { + AbilityBarHud hud = activeHuds.remove(online.uuid()); + if (hud == null) { + return; + } + online.world().execute(() -> { + Player player = resolvePlayer(online.world(), online.uuid()); + if (player != null) { + player.getHudManager().removeCustomHud(online.playerRef(), AbilityBarHud.KEY); + } + }); + } + + private void refreshAll() { + for (OnlinePlayer online : onlinePlayers.all()) { + PlayerProfile profile = sessions.getSession(online.uuid()); + if (profile == null || !classService.hasClass(profile)) { + if (activeHuds.containsKey(online.uuid())) { + dismiss(online.uuid()); + } + continue; + } + AbilityBarHud hud = activeHuds.get(online.uuid()); + if (hud == null) { + sync(online, profile); + continue; + } + PlayerClass playerClass = classService.getClass(profile); + if (playerClass == null) { + continue; + } + online.world().execute(() -> { + if (onlinePlayers.get(online.uuid()) != online) { + return; + } + if (resolvePlayer(online.world(), online.uuid()) == null) { + activeHuds.remove(online.uuid()); + return; + } + pushUpdate(online, profile, playerClass, hud); + }); + } + } + + private void applyHud( + @Nonnull OnlinePlayer online, + @Nonnull PlayerProfile profile, + @Nonnull PlayerClass playerClass) { + try { + if (onlinePlayers.get(online.uuid()) == null) { + return; + } + + Player player = resolvePlayer(online.world(), online.uuid()); + if (player == null) { + logger.at(Level.FINE).log( + "Ability bar HUD deferred for %s: player not in world store yet", + online.playerRef().getUsername()); + return; + } + + AbilityBarHud hud = activeHuds.get(online.uuid()); + if (hud == null) { + hud = new AbilityBarHud(online.playerRef()); + player.getHudManager().addCustomHud(online.playerRef(), hud); + activeHuds.put(online.uuid(), hud); + logger.at(Level.INFO).log( + "MMORPG HUD shown for %s", + online.playerRef().getUsername()); + } + pushUpdate(online, profile, playerClass, hud); + } catch (Throwable t) { + activeHuds.remove(online.uuid()); + logger.at(Level.WARNING).log( + "Ability bar HUD failed for %s: %s", + online.playerRef().getUsername(), + t.getMessage()); + } + } + + private void pushUpdate( + @Nonnull OnlinePlayer online, + @Nonnull PlayerProfile profile, + @Nonnull PlayerClass playerClass, + @Nonnull AbilityBarHud hud) { + if (!activeHuds.containsKey(online.uuid()) || onlinePlayers.get(online.uuid()) == null) { + return; + } + World world = online.world(); + Store store = world.getEntityStore().getStore(); + Ref entityRef = resolveEntityRef(world, online.uuid()); + PlayerVitalsReader.Vitals vitals = entityRef == null + ? null + : PlayerVitalsReader.read(store, entityRef); + hud.sync(profile, playerClass, abilityService, progression, vitals, profile.getUuid()); + } + + @Nullable + private static Ref resolveEntityRef(@Nonnull World world, @Nonnull UUID playerUuid) { + for (PlayerRef playerRef : world.getPlayerRefs()) { + if (!playerRef.getUuid().equals(playerUuid) || !playerRef.isValid()) { + continue; + } + Ref entityRef = playerRef.getReference(); + if (entityRef != null && entityRef.isValid()) { + return entityRef; + } + } + return null; + } + + /** + * Looks up the live {@link Player} from the world's player list instead of a cached + * {@link Ref} that may be unset or invalidated after {@code ClientReady}. + */ + @Nullable + private static Player resolvePlayer(@Nonnull World world, @Nonnull UUID playerUuid) { + Store store = world.getEntityStore().getStore(); + for (PlayerRef playerRef : world.getPlayerRefs()) { + if (!playerRef.getUuid().equals(playerUuid) || !playerRef.isValid()) { + continue; + } + Ref entityRef = playerRef.getReference(); + if (entityRef == null || !entityRef.isValid()) { + continue; + } + Player player = store.getComponent(entityRef, Player.getComponentType()); + if (player != null) { + return player; + } + } + return null; + } +} diff --git a/src/main/java/com/disklexar/mmorpg/ui/PlayerInfoPage.java b/src/main/java/com/disklexar/mmorpg/ui/PlayerInfoPage.java new file mode 100644 index 0000000..5885d9c --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/ui/PlayerInfoPage.java @@ -0,0 +1,94 @@ +package com.disklexar.mmorpg.ui; + +import com.disklexar.mmorpg.player.PlayerInfoView; +import com.disklexar.mmorpg.player.model.PlayerProfile; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.protocol.packets.interface_.CustomPageLifetime; +import com.hypixel.hytale.server.core.entity.entities.player.pages.CustomUIPage; +import com.hypixel.hytale.server.core.ui.builder.UICommandBuilder; +import com.hypixel.hytale.server.core.ui.builder.UIEventBuilder; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; + +import javax.annotation.Nonnull; +import java.util.List; + +/** + * Custom UI page listing every MMORPG attribute of the player. + *

+ * Custom UI documents must already exist on the client (they ship in the game's {@code Assets.zip}), + * so we reuse the always-present {@code Pages/WarpListPage.ui} layout — a titled window with a + * scrolling list container ({@code #WarpList}) and a back button — and fill that container with one + * label per attribute via {@link UICommandBuilder#appendInline(String, String)}. This is the same + * mechanism the builtin pages use to inject rows, and it avoids the previous crash that came from + * passing inline markup to {@link UICommandBuilder#append(String)} (which expects a document path). + */ +public final class PlayerInfoPage extends CustomUIPage { + + /** Reliable layout: reuses the always-present base-game document. */ + private static final String RELIABLE_DOCUMENT = "Pages/WarpListPage.ui"; + private static final String RELIABLE_LIST = "#WarpList"; + + /** Designed layout shipped in the plugin asset pack. */ + private static final String CUSTOM_DOCUMENT = "Pages/MmorpgPlayerInfo.ui"; + private static final String CUSTOM_LIST = "#ProfileList"; + + private final PlayerProfile profile; + private final boolean custom; + + public PlayerInfoPage(@Nonnull PlayerRef playerRef, @Nonnull PlayerProfile profile) { + this(playerRef, profile, false); + } + + public PlayerInfoPage(@Nonnull PlayerRef playerRef, @Nonnull PlayerProfile profile, boolean custom) { + super(playerRef, CustomPageLifetime.CanDismiss); + this.profile = profile; + this.custom = custom; + } + + @Override + public void build( + @Nonnull Ref ref, + @Nonnull UICommandBuilder ui, + @Nonnull UIEventBuilder events, + @Nonnull Store store) { + String document = custom ? CUSTOM_DOCUMENT : RELIABLE_DOCUMENT; + String list = custom ? CUSTOM_LIST : RELIABLE_LIST; + + ui.append(document); + ui.clear(list); + + ui.appendInline(list, title("=== Profil MMORPG ===")); + List entries = PlayerInfoView.entries(profile); + for (PlayerInfoView.Entry entry : entries) { + ui.appendInline(list, row(entry.label(), entry.value())); + } + ui.appendInline(list, hint("(Échap pour fermer)")); + } + + @Nonnull + private static String title(@Nonnull String text) { + return "Label { Text: \"" + escape(text) + + "\"; Style: (HorizontalAlignment: Center, RenderBold: true, FontSize: 18);" + + " Anchor: (Bottom: 8); }"; + } + + @Nonnull + private static String row(@Nonnull String label, @Nonnull String value) { + return "Label { Text: \"" + escape(label + " : " + value) + + "\"; Style: (FontSize: 14, Wrap: true); Anchor: (Bottom: 3); }"; + } + + @Nonnull + private static String hint(@Nonnull String text) { + return "Label { Text: \"" + escape(text) + + "\"; Style: (HorizontalAlignment: Center, FontSize: 12, TextColor: #878e9c);" + + " Anchor: (Top: 8); }"; + } + + @Nonnull + private static String escape(@Nonnull String text) { + return text.replace("\\", "\\\\").replace("\"", "\\\""); + } +} diff --git a/src/main/java/com/disklexar/mmorpg/ui/PlayerVitalsReader.java b/src/main/java/com/disklexar/mmorpg/ui/PlayerVitalsReader.java new file mode 100644 index 0000000..e522f86 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/ui/PlayerVitalsReader.java @@ -0,0 +1,45 @@ +package com.disklexar.mmorpg.ui; + +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.server.core.modules.entitystats.EntityStatMap; +import com.hypixel.hytale.server.core.modules.entitystats.EntityStatValue; +import com.hypixel.hytale.server.core.modules.entitystats.asset.DefaultEntityStatTypes; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * Reads live health and stamina from the vanilla entity stat map. + */ +public final class PlayerVitalsReader { + + public record Vitals(int current, int max, int staminaCurrent, int staminaMax) { + } + + private PlayerVitalsReader() { + } + + @Nullable + public static Vitals read( + @Nonnull Store store, + @Nonnull Ref entityRef) { + EntityStatMap stats = store.getComponent(entityRef, EntityStatMap.getComponentType()); + if (stats == null) { + return null; + } + + EntityStatValue health = stats.get(DefaultEntityStatTypes.getHealth()); + EntityStatValue stamina = stats.get(DefaultEntityStatTypes.getStamina()); + if (health == null || stamina == null) { + return null; + } + + return new Vitals( + Math.round(health.get()), + Math.round(health.getMax()), + Math.round(stamina.get()), + Math.round(stamina.getMax())); + } +} diff --git a/src/main/resources/Common/UI/Custom/MmorpgAbilityBar.ui b/src/main/resources/Common/UI/Custom/MmorpgAbilityBar.ui new file mode 100644 index 0000000..523ba3a --- /dev/null +++ b/src/main/resources/Common/UI/Custom/MmorpgAbilityBar.ui @@ -0,0 +1,170 @@ +@HealthBarFill = PatchStyle(Color: #e74c3c); +@HealthBarBg = PatchStyle(Color: #2a1515); +@StaminaBarFill = PatchStyle(Color: #2ecc71); +@StaminaBarBg = PatchStyle(Color: #152a1c); +@XpBarFill = PatchStyle(Color: #4a9eff); +@XpBarBg = PatchStyle(Color: #152030); +@VitalsPanelWidth = 260; +@VitalsBarWidth = 244; +@AbilitySlotSize = 68; +@AbilityPanelPad = 3; +@AbilityPanelWidth = @AbilitySlotSize + (@AbilityPanelPad * 2); + +Group { + LayoutMode: Top; + Anchor: (Left: 16, Top: 16, Width: @VitalsPanelWidth); + + Label #HeaderLabel { + Style: (FontSize: 14, RenderBold: true, TextColor: #f5c842); + Text: "Nv.1 — Classe"; + Anchor: (Left: 0, Bottom: 8); + } + + Group #VitalsPanel { + LayoutMode: Top; + Padding: (Full: 8); + Background: #0d0f14(0.92); + Anchor: (Left: 0, Width: @VitalsPanelWidth, Bottom: 8); + + Group #HealthRow { + LayoutMode: Top; + Anchor: (Bottom: 6); + + Label #HealthTitle { + Style: (FontSize: 10, RenderBold: true, TextColor: #ff7b7b); + Text: "Vie"; + Anchor: (Bottom: 2); + } + + ProgressBar #HealthBar { + Anchor: (Width: @VitalsBarWidth, Height: 12); + Bar: @HealthBarFill; + Background: @HealthBarBg; + Value: 1.0; + Direction: End; + } + + Label #HealthValue { + Style: (FontSize: 9, TextColor: #f0f0f0); + Text: "0 / 0"; + Anchor: (Top: 2); + } + } + + Group #StaminaRow { + LayoutMode: Top; + Anchor: (Bottom: 6); + + Label #StaminaTitle { + Style: (FontSize: 10, RenderBold: true, TextColor: #7bdc9a); + Text: "Endurance"; + Anchor: (Bottom: 2); + } + + ProgressBar #StaminaBar { + Anchor: (Width: @VitalsBarWidth, Height: 12); + Bar: @StaminaBarFill; + Background: @StaminaBarBg; + Value: 1.0; + Direction: End; + } + + Label #StaminaValue { + Style: (FontSize: 9, TextColor: #f0f0f0); + Text: "0 / 0"; + Anchor: (Top: 2); + } + } + + Group #XpRow { + LayoutMode: Top; + + Label #XpTitle { + Style: (FontSize: 10, RenderBold: true, TextColor: #8eb6ff); + Text: "Expérience"; + Anchor: (Bottom: 2); + } + + ProgressBar #XpBar { + Anchor: (Width: @VitalsBarWidth, Height: 12); + Bar: @XpBarFill; + Background: @XpBarBg; + Value: 0.0; + Direction: End; + } + + Label #XpValue { + Style: (FontSize: 9, TextColor: #f0f0f0); + Text: "0 / 0"; + Anchor: (Top: 2); + } + } + } + + Label #BarTitle { + Style: (FontSize: 12, RenderBold: true, TextColor: #f5c842); + Text: "CAPACITÉS"; + Anchor: (Left: 0, Bottom: 4); + } + + Group #AbilityBarPanel { + LayoutMode: Top; + Padding: (Full: @AbilityPanelPad); + Background: #0d0f14(0.92); + Anchor: (Left: 0, Width: @AbilityPanelWidth); + + Group #AbilitySlot1 { + Anchor: (Width: @AbilitySlotSize, Height: @AbilitySlotSize, Bottom: 5); + Background: #2a3140; + LayoutMode: Top; + Padding: (Full: 4); + + Label #Slot1Key { + Style: (FontSize: 12, RenderBold: true, TextColor: #f5c842); + Text: "[1]"; + Anchor: (Bottom: 2); + } + + Label #Slot1Name { + Style: (FontSize: 10, TextColor: #ffffff, Wrap: true); + Text: "Capacité 1"; + } + } + + Group #AbilitySlot2 { + Anchor: (Width: @AbilitySlotSize, Height: @AbilitySlotSize, Bottom: 5); + Background: #2a3140; + LayoutMode: Top; + Padding: (Full: 4); + + Label #Slot2Key { + Style: (FontSize: 12, RenderBold: true, TextColor: #f5c842); + Text: "[2]"; + Anchor: (Bottom: 2); + } + + Label #Slot2Name { + Style: (FontSize: 10, TextColor: #ffffff, Wrap: true); + Text: "Capacité 2"; + } + } + + Group #AbilitySlot3 { + Anchor: (Width: @AbilitySlotSize, Height: @AbilitySlotSize); + Background: #2a3140; + LayoutMode: Top; + Padding: (Full: 4); + + Label #Slot3Key { + Style: (FontSize: 12, RenderBold: true, TextColor: #f5c842); + Text: "[3]"; + Anchor: (Bottom: 2); + } + + Label #Slot3Name { + Style: (FontSize: 10, TextColor: #ffffff, Wrap: true); + Text: "Capacité 3"; + } + } + } +} diff --git a/src/main/resources/Common/UI/Custom/Pages/MmorpgPlayerInfo.ui b/src/main/resources/Common/UI/Custom/Pages/MmorpgPlayerInfo.ui new file mode 100644 index 0000000..8e60447 --- /dev/null +++ b/src/main/resources/Common/UI/Custom/Pages/MmorpgPlayerInfo.ui @@ -0,0 +1,29 @@ +$C = "../Common.ui"; + +$C.@PageOverlay {} + +$C.@Container { + Anchor: (Width: 600, Height: 700); + + #Title { + Group { + $C.@Title { + @Text = %server.customUI.warpListPage.warps; + } + + $C.@HeaderSearch {} + } + } + + #Content { + LayoutMode: Left; + + Group #ProfileList { + FlexWeight: 1; + LayoutMode: TopScrolling; + ScrollbarStyle: $C.@DefaultScrollbarStyle; + } + } +} + +$C.@BackButton {} diff --git a/src/main/resources/Server/Item/Items/Weapon_Battleaxe_Mithril.json b/src/main/resources/Server/Item/Items/Weapon_Battleaxe_Mithril.json new file mode 100644 index 0000000..9cb88e5 --- /dev/null +++ b/src/main/resources/Server/Item/Items/Weapon_Battleaxe_Mithril.json @@ -0,0 +1,20 @@ +{ + "Id": "Weapon_Battleaxe_Mithril", + "Interactions": { + "Ability1": { + "Interactions": [ + { "Type": "mmorpg_cast_ability", "Slot": 1 } + ] + }, + "Ability2": { + "Interactions": [ + { "Type": "mmorpg_cast_ability", "Slot": 2 } + ] + }, + "Ability3": { + "Interactions": [ + { "Type": "mmorpg_cast_ability", "Slot": 3 } + ] + } + } +} diff --git a/src/main/resources/Server/Item/Items/Weapon_Crossbow_Ancient_Steel.json b/src/main/resources/Server/Item/Items/Weapon_Crossbow_Ancient_Steel.json new file mode 100644 index 0000000..846cad5 --- /dev/null +++ b/src/main/resources/Server/Item/Items/Weapon_Crossbow_Ancient_Steel.json @@ -0,0 +1,20 @@ +{ + "Id": "Weapon_Crossbow_Ancient_Steel", + "Interactions": { + "Ability1": { + "Interactions": [ + { "Type": "mmorpg_cast_ability", "Slot": 1 } + ] + }, + "Ability2": { + "Interactions": [ + { "Type": "mmorpg_cast_ability", "Slot": 2 } + ] + }, + "Ability3": { + "Interactions": [ + { "Type": "mmorpg_cast_ability", "Slot": 3 } + ] + } + } +} diff --git a/src/main/resources/Server/Item/Items/Weapon_Daggers_Mithril.json b/src/main/resources/Server/Item/Items/Weapon_Daggers_Mithril.json new file mode 100644 index 0000000..a053fed --- /dev/null +++ b/src/main/resources/Server/Item/Items/Weapon_Daggers_Mithril.json @@ -0,0 +1,20 @@ +{ + "Id": "Weapon_Daggers_Mithril", + "Interactions": { + "Ability1": { + "Interactions": [ + { "Type": "mmorpg_cast_ability", "Slot": 1 } + ] + }, + "Ability2": { + "Interactions": [ + { "Type": "mmorpg_cast_ability", "Slot": 2 } + ] + }, + "Ability3": { + "Interactions": [ + { "Type": "mmorpg_cast_ability", "Slot": 3 } + ] + } + } +} diff --git a/src/main/resources/Server/Item/Items/Weapon_Longsword_Mithril.json b/src/main/resources/Server/Item/Items/Weapon_Longsword_Mithril.json new file mode 100644 index 0000000..2fe9e6a --- /dev/null +++ b/src/main/resources/Server/Item/Items/Weapon_Longsword_Mithril.json @@ -0,0 +1,20 @@ +{ + "Id": "Weapon_Longsword_Mithril", + "Interactions": { + "Ability1": { + "Interactions": [ + { "Type": "mmorpg_cast_ability", "Slot": 1 } + ] + }, + "Ability2": { + "Interactions": [ + { "Type": "mmorpg_cast_ability", "Slot": 2 } + ] + }, + "Ability3": { + "Interactions": [ + { "Type": "mmorpg_cast_ability", "Slot": 3 } + ] + } + } +} diff --git a/src/main/resources/Server/Item/Items/Weapon_Shortbow_Cobalt.json b/src/main/resources/Server/Item/Items/Weapon_Shortbow_Cobalt.json new file mode 100644 index 0000000..1021f6c --- /dev/null +++ b/src/main/resources/Server/Item/Items/Weapon_Shortbow_Cobalt.json @@ -0,0 +1,20 @@ +{ + "Id": "Weapon_Shortbow_Cobalt", + "Interactions": { + "Ability1": { + "Interactions": [ + { "Type": "mmorpg_cast_ability", "Slot": 1 } + ] + }, + "Ability2": { + "Interactions": [ + { "Type": "mmorpg_cast_ability", "Slot": 2 } + ] + }, + "Ability3": { + "Interactions": [ + { "Type": "mmorpg_cast_ability", "Slot": 3 } + ] + } + } +} diff --git a/src/main/resources/Server/Item/Items/Weapon_Staff_Mithril.json b/src/main/resources/Server/Item/Items/Weapon_Staff_Mithril.json new file mode 100644 index 0000000..42f0b42 --- /dev/null +++ b/src/main/resources/Server/Item/Items/Weapon_Staff_Mithril.json @@ -0,0 +1,20 @@ +{ + "Id": "Weapon_Staff_Mithril", + "Interactions": { + "Ability1": { + "Interactions": [ + { "Type": "mmorpg_cast_ability", "Slot": 1 } + ] + }, + "Ability2": { + "Interactions": [ + { "Type": "mmorpg_cast_ability", "Slot": 2 } + ] + }, + "Ability3": { + "Interactions": [ + { "Type": "mmorpg_cast_ability", "Slot": 3 } + ] + } + } +} diff --git a/src/main/resources/config.json b/src/main/resources/config.json new file mode 100644 index 0000000..784fb84 --- /dev/null +++ b/src/main/resources/config.json @@ -0,0 +1,15 @@ +{ + "Debug": false, + "DefaultLevel": 1, + "MaxPlayers": 500, + "BaseXpPerLevel": 100, + "KillExperience": 10, + "Database": { + "FileName": "mmorpg.db" + }, + "Features": { + "Economy": true, + "Quests": false, + "Guilds": false + } +} diff --git a/src/main/resources/db/migrations/001_init.sql b/src/main/resources/db/migrations/001_init.sql new file mode 100644 index 0000000..39b6d8d --- /dev/null +++ b/src/main/resources/db/migrations/001_init.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS player_profiles ( + uuid TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + level INTEGER NOT NULL DEFAULT 1, + experience INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); diff --git a/src/main/resources/db/migrations/002_rpg.sql b/src/main/resources/db/migrations/002_rpg.sql new file mode 100644 index 0000000..fe00274 --- /dev/null +++ b/src/main/resources/db/migrations/002_rpg.sql @@ -0,0 +1,63 @@ +-- MMORPG progression schema: classes, powers, jobs, races, groups. +-- New columns on player_profiles. SQLite ignores duplicate ADD COLUMN guarded by +-- the migration runner (each migration is applied at most once via schema_migrations). + +ALTER TABLE player_profiles ADD COLUMN class_id TEXT; +ALTER TABLE player_profiles ADD COLUMN powers TEXT NOT NULL DEFAULT '[]'; +ALTER TABLE player_profiles ADD COLUMN jobs TEXT NOT NULL DEFAULT '[]'; +ALTER TABLE player_profiles ADD COLUMN guild_id TEXT; +ALTER TABLE player_profiles ADD COLUMN group_id TEXT; +ALTER TABLE player_profiles ADD COLUMN is_connected INTEGER NOT NULL DEFAULT 0; +ALTER TABLE player_profiles ADD COLUMN total_time_play INTEGER NOT NULL DEFAULT 0; +ALTER TABLE player_profiles ADD COLUMN last_date_connected INTEGER NOT NULL DEFAULT 0; +ALTER TABLE player_profiles ADD COLUMN date_creation INTEGER NOT NULL DEFAULT 0; +ALTER TABLE player_profiles ADD COLUMN money INTEGER NOT NULL DEFAULT 0; +ALTER TABLE player_profiles ADD COLUMN race_id TEXT NOT NULL DEFAULT 'human'; + +-- Backfill creation date for profiles that predate this migration. +UPDATE player_profiles SET date_creation = created_at WHERE date_creation = 0; + +-- Reference tables (definitions persisted for inspection / future editing). +CREATE TABLE IF NOT EXISTS classes ( + id TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + weapons TEXT NOT NULL DEFAULT '[]' +); + +CREATE TABLE IF NOT EXISTS powers ( + id TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + passive INTEGER NOT NULL DEFAULT 1 +); + +CREATE TABLE IF NOT EXISTS jobs ( + id TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '' +); + +CREATE TABLE IF NOT EXISTS races ( + id TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + xp_multiplier REAL NOT NULL DEFAULT 1.0 +); + +-- Groups (parties): shared XP between members. +CREATE TABLE IF NOT EXISTS groups ( + id TEXT PRIMARY KEY, + owner_uuid TEXT NOT NULL, + created_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS group_members ( + group_id TEXT NOT NULL, + player_uuid TEXT NOT NULL, + joined_at INTEGER NOT NULL, + PRIMARY KEY (group_id, player_uuid), + FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_group_members_player ON group_members(player_uuid); diff --git a/src/main/resources/manifest.json b/src/main/resources/manifest.json new file mode 100644 index 0000000..cbe919d --- /dev/null +++ b/src/main/resources/manifest.json @@ -0,0 +1,18 @@ +{ + "Group": "com.disklexar", + "Name": "MMORPG", + "Version": "0.2.6", + "Description": "Serveur MMORPG Hytale — progression, persistance et systèmes sociaux", + "Authors": [ + { + "Name": "Disklexar" + } + ], + "Website": "https://github.com/disklexar/hytale-mmorpg", + "DisabledByDefault": false, + "IncludesAssetPack": true, + "Dependencies": {}, + "OptionalDependencies": {}, + "ServerVersion": "*", + "Main": "com.disklexar.mmorpg.MmorpgPlugin" +} \ No newline at end of file diff --git a/start-server.sh b/start-server.sh new file mode 100755 index 0000000..35d2e77 --- /dev/null +++ b/start-server.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +# Compile le plugin, configure le serveur de dev, puis lance Hytale. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$ROOT" + +JAVA_HOME="/usr/lib/jvm/java-25-openjdk" +export JAVA_HOME +export PATH="$JAVA_HOME/bin:$PATH" + +GRADLE_PROPS="$ROOT/gradle.properties" +HYTALE_PROPS="$ROOT/hytale.properties" +GRADLE_ARGS=() + +read_property() { + local file="$1" + local key="$2" + if [[ -f "$file" ]]; then + grep -E "^${key}=" "$file" 2>/dev/null | tail -1 | cut -d= -f2- | tr -d '\r' || true + fi +} + +find_assets_zip() { + local home="$1" + [[ -z "$home" || ! -d "$home" ]] && return 1 + + local candidates=( + "$home/install/release/package/game/latest/Assets.zip" + "$home/Assets.zip" + ) + local path + for path in "${candidates[@]}"; do + if [[ -f "$path" && -s "$path" ]]; then + echo "$path" + return 0 + fi + done + + find "$home" -name "Assets.zip" -size +1M 2>/dev/null | head -1 +} + +detect_hytale_home() { + local candidates=( + "${HYTALE_HOME:-}" + "$(read_property "$HYTALE_PROPS" "hytale.home_path")" + "$(read_property "$GRADLE_PROPS" "hytale.home_path")" + "$HOME/.var/app/com.hypixel.HytaleLauncher/data/Hytale" + "$HOME/.local/share/Hytale" + "$HOME/AppData/Roaming/Hytale" + ) + local candidate assets + for candidate in "${candidates[@]}"; do + [[ -z "$candidate" ]] && continue + assets="$(find_assets_zip "$candidate" || true)" + if [[ -n "$assets" ]]; then + echo "$candidate" + return 0 + fi + done + return 1 +} + +ensure_hytale_home_path() { + local home + home="$(detect_hytale_home || true)" + + if [[ -z "$home" ]]; then + echo "Erreur: Assets.zip introuvable (fichier vide ou installation absente)." >&2 + echo "" >&2 + echo "Le serveur a besoin des assets du jeu Hytale. Configurez le chemin :" >&2 + echo "" >&2 + echo " cp hytale.properties.example hytale.properties" >&2 + echo " # puis éditez hytale.home_path=/chemin/vers/Hytale" >&2 + echo "" >&2 + echo " Ou : export HYTALE_HOME=/chemin/vers/Hytale" >&2 + echo " Ou : ajoutez hytale.home_path=... dans gradle.properties" >&2 + echo "" >&2 + echo "Le dossier doit contenir install/.../Assets.zip (via le launcher Hytale)." >&2 + exit 1 + fi + + local assets + assets="$(find_assets_zip "$home")" + echo "Hytale: $home" + echo "Assets: $assets" + + GRADLE_ARGS+=("-Phytale.home_path=$home") + + if [[ ! -f "$HYTALE_PROPS" ]] || [[ "$(read_property "$HYTALE_PROPS" "hytale.home_path")" != "$home" ]]; then + echo "hytale.home_path=$home" > "$HYTALE_PROPS" + echo "Enregistré dans hytale.properties" + fi +} + +reset_devserver_if_needed() { + if [[ "${HYTALE_RESET_DEVSERVER:-}" == "1" && -d "$ROOT/devserver" ]]; then + echo "HYTALE_RESET_DEVSERVER=1 : suppression de devserver/" + rm -rf "$ROOT/devserver" + fi +} + +run_gradle() { + "$ROOT/gradlew" --stop 2>/dev/null || true + "$ROOT/gradlew" "${GRADLE_ARGS[@]}" "$@" +} + +if [[ ! -d "$JAVA_HOME" ]]; then + echo "Erreur: JDK introuvable: $JAVA_HOME" >&2 + exit 1 +fi + +echo "JAVA_HOME=$JAVA_HOME ($("$JAVA_HOME/bin/java" -version 2>&1 | head -1))" +ensure_hytale_home_path +reset_devserver_if_needed + +echo "Configuration du serveur de dev..." +run_gradle setupServer + +echo "Compilation..." +run_gradle build + +echo "Démarrage du serveur (logs: devserver/logs/)..." +exec "$ROOT/gradlew" "${GRADLE_ARGS[@]}" runServer