@@ -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
|
||||
+29
@@ -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
|
||||
@@ -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/<nom>` — nouvelles fonctionnalités
|
||||
- `fix/<nom>` — 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.
|
||||
@@ -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.
|
||||
@@ -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 <choisir\|quitter\|info> [classe]` | Gérer votre classe |
|
||||
| `/mmorpg power <recevoir\|retirer\|info> [pouvoir]` | Gérer vos pouvoirs |
|
||||
| `/mmorpg job <rejoindre\|quitter\|info> [metier]` | Gérer vos métiers |
|
||||
| `/mmorpg group <inviter\|accepter\|quitter\|kick\|info> [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 <classe>` 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).
|
||||
@@ -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/<ItemId>.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.
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.*`
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,3 @@
|
||||
org.gradle.daemon=true
|
||||
org.gradle.parallel=true
|
||||
org.gradle.java.home=/usr/lib/jvm/java-25-openjdk
|
||||
@@ -0,0 +1,3 @@
|
||||
org.gradle.daemon=true
|
||||
org.gradle.parallel=true
|
||||
org.gradle.java.home=/usr/lib/jvm/java-25-openjdk
|
||||
Vendored
BIN
Binary file not shown.
+7
@@ -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
|
||||
@@ -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" "$@"
|
||||
Vendored
+93
@@ -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
|
||||
@@ -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=
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* Public API interfaces for cross-module integration (future extraction to separate plugins).
|
||||
*/
|
||||
package com.disklexar.mmorpg.api;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<Class<?>, Object> services = new HashMap<>();
|
||||
private final List<Service> lifecycleServices = new ArrayList<>();
|
||||
|
||||
public <T> void register(@Nonnull Class<T> type, @Nonnull T instance) {
|
||||
services.put(type, instance);
|
||||
if (instance instanceof Service service) {
|
||||
lifecycleServices.add(service);
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public <T> T get(@Nonnull Class<T> type) {
|
||||
return type.cast(services.get(type));
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
public <T> T require(@Nonnull Class<T> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<UUID, Map<String, Long>> 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<String, Long> 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<EntityStore> aimedTarget) {
|
||||
UUID caster = playerRef.getUuid();
|
||||
Store<EntityStore> store = world.getEntityStore().getStore();
|
||||
Ref<EntityStore> 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<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> 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<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> 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<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> 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<EntityStore> 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<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> 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<EntityStore> 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<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> 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<EntityStore> 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<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> 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<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> 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<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> 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<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> 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<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> 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<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> 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<EntityStore> 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<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> 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<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> casterRef,
|
||||
@Nullable Ref<EntityStore> aimedTarget) {
|
||||
world.execute(() -> {
|
||||
Ref<EntityStore> 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<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> 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<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> 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<EntityStore> 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<EntityStore> store, @Nonnull Ref<EntityStore> ref) {
|
||||
return new Vector3d(position(store, ref)).add(0, 1.5, 0);
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
private Vector3d position(@Nonnull Store<EntityStore> store, @Nonnull Ref<EntityStore> 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<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> 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<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> casterRef,
|
||||
@Nonnull Ref<EntityStore> 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<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> 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<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> target,
|
||||
@Nonnull Vector3d fallback) {
|
||||
TransformComponent transform = store.getComponent(target, TransformComponent.getComponentType());
|
||||
return transform != null ? transform.getPosition() : fallback;
|
||||
}
|
||||
|
||||
private UUID entityUuid(@Nonnull Store<EntityStore> store, @Nonnull Ref<EntityStore> ref) {
|
||||
UUIDComponent component = store.getComponent(ref, UUIDComponent.getComponentType());
|
||||
return component == null ? null : component.getUuid();
|
||||
}
|
||||
|
||||
public enum CastResult {
|
||||
OK, ON_COOLDOWN, WRONG_WEAPON, UNKNOWN
|
||||
}
|
||||
}
|
||||
@@ -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<EntityStore> 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) {
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
* <ul>
|
||||
* <li>Archer "Tir rapide": a flat outgoing-damage bonus for a window (server-side proxy for
|
||||
* attack speed, which is otherwise client-authoritative),</li>
|
||||
* <li>Archer "Tir puissant": a one-shot +200% multiplier on the next basic attack,</li>
|
||||
* <li>Assassin "Lame Empoisonnée": a window during which every hit applies a stacking poison,</li>
|
||||
* <li>Assassin "Camouflage": bookkeeping of the current invisibility window.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public final class CombatBuffService {
|
||||
|
||||
private final Map<UUID, Long> rapidFireUntil = new ConcurrentHashMap<>();
|
||||
private final Set<UUID> powerShotCharged = ConcurrentHashMap.newKeySet();
|
||||
private final Map<UUID, Long> poisonBladeUntil = new ConcurrentHashMap<>();
|
||||
private final Map<UUID, Long> 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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
* <p>
|
||||
* 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<EntityStore> 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<EntityStore> 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 <em>player</em> 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<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> 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<EntityStore> store,
|
||||
@Nonnull String soundEventId,
|
||||
@Nonnull Vector3d position) {
|
||||
sound(store, soundEventId, position, 1.0f, 1.0f);
|
||||
}
|
||||
|
||||
public static void sound(
|
||||
@Nonnull Store<EntityStore> 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<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> 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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
* <ul>
|
||||
* <li>last time the player took damage (drives the "Résilient" power and out-of-combat detection),</li>
|
||||
* <li>temporary resistance debuffs applied by abilities (e.g. the Knight war cry).</li>
|
||||
* </ul>
|
||||
*/
|
||||
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<UUID, Long> lastDamageTakenAt = new ConcurrentHashMap<>();
|
||||
private final Map<UUID, Long> lastCombatAt = new ConcurrentHashMap<>();
|
||||
private final Map<UUID, Long> 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);
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
* <p>
|
||||
* 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());
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
* <p>
|
||||
* 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<EntityStore> store = player.world().getEntityStore().getStore();
|
||||
Ref<EntityStore> 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> caster,
|
||||
@Nonnull Ref<EntityStore> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<UUID, Long> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<EntityStore, BreakBlockEvent> {
|
||||
|
||||
private static final Query<EntityStore> 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<EntityStore> getQuery() {
|
||||
return QUERY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handle(
|
||||
int index,
|
||||
@Nonnull ArchetypeChunk<EntityStore> chunk,
|
||||
@Nonnull Store<EntityStore> store,
|
||||
@Nonnull CommandBuffer<EntityStore> commandBuffer,
|
||||
@Nonnull BreakBlockEvent event) {
|
||||
Ref<EntityStore> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
* <ul>
|
||||
* <li>victims with an active resistance debuff (Knight war cry) take +20% damage,</li>
|
||||
* <li>attackers with the "Résilient" power deal +10% damage for 5s after being hit.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public final class MmorpgDamageSystem extends DamageEventSystem {
|
||||
|
||||
private static final Query<EntityStore> 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<EntityStore> getQuery() {
|
||||
return QUERY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SystemGroup<EntityStore> getGroup() {
|
||||
return DamageModule.get().getInspectDamageGroup();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handle(
|
||||
int index,
|
||||
@Nonnull ArchetypeChunk<EntityStore> chunk,
|
||||
@Nonnull Store<EntityStore> store,
|
||||
@Nonnull CommandBuffer<EntityStore> commandBuffer,
|
||||
@Nonnull Damage damage) {
|
||||
Ref<EntityStore> 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<EntityStore> 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<EntityStore> store, @Nonnull Ref<EntityStore> ref) {
|
||||
UUIDComponent component = store.getComponent(ref, UUIDComponent.getComponentType());
|
||||
return component == null ? null : component.getUuid();
|
||||
}
|
||||
}
|
||||
@@ -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<EntityStore> 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<EntityStore> getQuery() {
|
||||
return QUERY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onComponentAdded(
|
||||
@Nonnull Ref<EntityStore> deadRef,
|
||||
@Nonnull DeathComponent death,
|
||||
@Nonnull Store<EntityStore> store,
|
||||
@Nonnull CommandBuffer<EntityStore> 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<EntityStore> 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()));
|
||||
}
|
||||
}
|
||||
@@ -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<Integer> 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<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> 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 <classe>."));
|
||||
return;
|
||||
}
|
||||
controller.tryCast(playerRef, world, slot, null);
|
||||
}
|
||||
}
|
||||
@@ -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 <choisir|quitter|info>}
|
||||
*/
|
||||
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<String> 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<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> 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<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> 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<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> 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();
|
||||
}
|
||||
}
|
||||
@@ -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> T service(@Nonnull MmorpgPlugin plugin, @Nonnull Class<T> type) {
|
||||
Bootstrap bootstrap = plugin.getBootstrap();
|
||||
if (bootstrap == null) {
|
||||
return null;
|
||||
}
|
||||
return bootstrap.getRegistry().get(type);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
static UUID uuid(@Nonnull Store<EntityStore> store, @Nonnull Ref<EntityStore> ref) {
|
||||
UUIDComponent component = store.getComponent(ref, UUIDComponent.getComponentType());
|
||||
return component == null ? null : component.getUuid();
|
||||
}
|
||||
|
||||
@Nullable
|
||||
static PlayerProfile profile(
|
||||
@Nonnull MmorpgPlugin plugin,
|
||||
@Nonnull Store<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> ref) {
|
||||
UUID uuid = uuid(store, ref);
|
||||
if (uuid == null) {
|
||||
return null;
|
||||
}
|
||||
PlayerSessionService sessions = service(plugin, PlayerSessionService.class);
|
||||
return sessions == null ? null : sessions.getSession(uuid);
|
||||
}
|
||||
}
|
||||
@@ -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 <inviter|accepter|quitter|kick|info>}
|
||||
*/
|
||||
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<PlayerRef> 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<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> 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<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> 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<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> 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<PlayerRef> 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<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> 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<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> 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 + "%"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> 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 <id>"));
|
||||
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)."));
|
||||
}
|
||||
}
|
||||
@@ -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<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> 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)));
|
||||
}
|
||||
}
|
||||
@@ -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 <rejoindre|quitter|info>}
|
||||
*/
|
||||
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<String> 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<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> 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<String> 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<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> 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<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> 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)));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> 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)));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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<Void> 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 <choisir|quitter|info> [classe]
|
||||
/mmorpg power <recevoir|retirer|info> [pouvoir]
|
||||
/mmorpg job <rejoindre|quitter|info> [metier]
|
||||
/mmorpg group <inviter|accepter|quitter|kick|info> [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);
|
||||
}
|
||||
}
|
||||
@@ -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<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> 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())));
|
||||
}
|
||||
}
|
||||
@@ -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 <recevoir|retirer|info>}
|
||||
*/
|
||||
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<String> 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<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> 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<String> 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<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> 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<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> 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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.disklexar.mmorpg.core.service;
|
||||
|
||||
/**
|
||||
* Lifecycle contract for MMORPG services.
|
||||
*/
|
||||
public interface Service {
|
||||
|
||||
void start();
|
||||
|
||||
void shutdown();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Economy system (currency, shops, auctions).
|
||||
* <p>
|
||||
* Disabled by default via {@code Features.Economy} in config.json.
|
||||
* Planned: wallets, transactions, NPC vendors.
|
||||
*/
|
||||
package com.disklexar.mmorpg.economy;
|
||||
@@ -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<SyncInteractionChain> 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<EntityStore> entityRef = playerRef.getReference();
|
||||
if (entityRef == null || !entityRef.isValid()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Store<EntityStore> 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));
|
||||
}
|
||||
}
|
||||
@@ -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<EntityStore> entityRef = event.getPlayerRef();
|
||||
|
||||
Bootstrap bootstrap = plugin.getBootstrap();
|
||||
if (bootstrap == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Store<EntityStore> 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<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> 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<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> ref,
|
||||
@Nonnull Player player) {
|
||||
PlayerRef playerRef = store.getComponent(ref, PlayerRef.getComponentType());
|
||||
if (playerRef != null) {
|
||||
return playerRef.getUsername();
|
||||
}
|
||||
return "Joueur";
|
||||
}
|
||||
}
|
||||
@@ -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<MmorpgCastAbilityInteraction> 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<EntityStore> entityRef = interactionContext.getEntity();
|
||||
if (entityRef == null || !entityRef.isValid()) {
|
||||
interactionContext.getState().state = InteractionState.Failed;
|
||||
return;
|
||||
}
|
||||
|
||||
Store<EntityStore> 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<EntityStore> target = interactionContext.getTargetEntity();
|
||||
ClassAbilityController controller = plugin.getClassAbilityController();
|
||||
if (!controller.tryCast(playerRef, world, abilitySlot, target)) {
|
||||
interactionContext.getState().state = InteractionState.Failed;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<Group> loadAll() throws SQLException {
|
||||
List<Group> 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();
|
||||
}
|
||||
}
|
||||
+148
@@ -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<List<String>>() {
|
||||
}.getType();
|
||||
|
||||
private final DatabaseManager databaseManager;
|
||||
|
||||
public PlayerProfileRepository(@Nonnull DatabaseManager databaseManager) {
|
||||
this.databaseManager = databaseManager;
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
public Optional<PlayerProfile> 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<String> parseList(String json) {
|
||||
if (json == null || json.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
List<String> parsed = GSON.fromJson(json, STRING_LIST);
|
||||
return parsed != null ? parsed : List.of();
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
private Connection connection() {
|
||||
return databaseManager.getConnection();
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
* <p>
|
||||
* 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<String> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<EntityStore> entityRef,
|
||||
@Nonnull World world) {
|
||||
}
|
||||
@@ -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<UUID, OnlinePlayer> 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<OnlinePlayer> all() {
|
||||
return online.values();
|
||||
}
|
||||
}
|
||||
@@ -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<Entry> entries(@Nonnull PlayerProfile profile) {
|
||||
List<Entry> 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));
|
||||
}
|
||||
}
|
||||
@@ -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<UUID, PlayerProfile> sessions = new ConcurrentHashMap<>();
|
||||
private final Map<UUID, Long> 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<PlayerProfile> 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
* <p>
|
||||
* 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<String> powers = new LinkedHashSet<>();
|
||||
private final Set<String> 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<String> getPowers() {
|
||||
return powers;
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
public Set<String> 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();
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Quest system (objectives, rewards, quest chains).
|
||||
* <p>
|
||||
* Disabled by default via {@code Features.Quests} in config.json.
|
||||
* Planned: quest definitions, progress tracking, daily quests.
|
||||
*/
|
||||
package com.disklexar.mmorpg.quest;
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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<String, PlayerClass> 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<PlayerClass> all() {
|
||||
return List.copyOf(BY_ID.values());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<String> weapons,
|
||||
@Nonnull List<AbilityDefinition> abilities) {
|
||||
|
||||
public boolean usesWeapon(@Nonnull String weaponId) {
|
||||
for (String weapon : weapons) {
|
||||
if (weapon.equalsIgnoreCase(weaponId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -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<UUID> members = ConcurrentHashMap.newKeySet();
|
||||
private final Set<UUID> 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<UUID> getMembers() {
|
||||
return members;
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
public Set<UUID> getPendingInvites() {
|
||||
return pendingInvites;
|
||||
}
|
||||
|
||||
public boolean isOwner(@Nonnull UUID uuid) {
|
||||
return owner.equals(uuid);
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
public Set<UUID> membersExcept(@Nonnull UUID uuid) {
|
||||
Set<UUID> others = new LinkedHashSet<>(members);
|
||||
others.remove(uuid);
|
||||
return others;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
* <p>
|
||||
* 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<UUID, Group> groupsById = new ConcurrentHashMap<>();
|
||||
private final Map<UUID, UUID> 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
|
||||
}
|
||||
}
|
||||
@@ -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<String, JobDefinition> 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<JobDefinition> all() {
|
||||
return List.copyOf(BY_ID.values());
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<String, PowerDefinition> 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<PowerDefinition> all() {
|
||||
return List.copyOf(BY_ID.values());
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<String, RaceDefinition> 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<RaceDefinition> all() {
|
||||
return List.copyOf(BY_ID.values());
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Social systems (parties, guilds, friends).
|
||||
* <p>
|
||||
* Disabled by default via {@code Features.Guilds} in config.json.
|
||||
* Planned: guild creation, ranks, shared storage, party instances.
|
||||
*/
|
||||
package com.disklexar.mmorpg.social;
|
||||
@@ -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("\"", "\\\"");
|
||||
}
|
||||
}
|
||||
@@ -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<UUID, AbilityBarHud> 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<EntityStore> store = world.getEntityStore().getStore();
|
||||
Ref<EntityStore> 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<EntityStore> resolveEntityRef(@Nonnull World world, @Nonnull UUID playerUuid) {
|
||||
for (PlayerRef playerRef : world.getPlayerRefs()) {
|
||||
if (!playerRef.getUuid().equals(playerUuid) || !playerRef.isValid()) {
|
||||
continue;
|
||||
}
|
||||
Ref<EntityStore> 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<EntityStore> store = world.getEntityStore().getStore();
|
||||
for (PlayerRef playerRef : world.getPlayerRefs()) {
|
||||
if (!playerRef.getUuid().equals(playerUuid) || !playerRef.isValid()) {
|
||||
continue;
|
||||
}
|
||||
Ref<EntityStore> entityRef = playerRef.getReference();
|
||||
if (entityRef == null || !entityRef.isValid()) {
|
||||
continue;
|
||||
}
|
||||
Player player = store.getComponent(entityRef, Player.getComponentType());
|
||||
if (player != null) {
|
||||
return player;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
* <p>
|
||||
* 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<EntityStore> ref,
|
||||
@Nonnull UICommandBuilder ui,
|
||||
@Nonnull UIEventBuilder events,
|
||||
@Nonnull Store<EntityStore> 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<PlayerInfoView.Entry> 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("\"", "\\\"");
|
||||
}
|
||||
}
|
||||
@@ -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<EntityStore> store,
|
||||
@Nonnull Ref<EntityStore> 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()));
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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 }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
@@ -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);
|
||||
@@ -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"
|
||||
}
|
||||
Executable
+124
@@ -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
|
||||
Reference in New Issue
Block a user