@@ -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