Compare commits
52
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5386c7ac8a | ||
|
|
eb25625a45 | ||
|
|
05c445eaeb | ||
|
|
788928f2d9 | ||
|
|
5d3db9bb42 | ||
|
|
fd528abb9d | ||
|
|
40acccc818 | ||
|
|
62c8c87eb0 | ||
|
|
b5dab3b5f6 | ||
|
|
90790161db | ||
|
|
e8dee9b7f8 | ||
|
|
6fc93ca732 | ||
|
|
8787969347 | ||
|
|
01a82c3c93 | ||
|
|
f23f741a9d | ||
|
|
4714c69255 | ||
|
|
c03e5d8555 | ||
|
|
9abf6bbea7 | ||
|
|
9c09664778 | ||
|
|
87f11f6127 | ||
|
|
c04cd78ae9 | ||
|
|
7ae99a12a8 | ||
|
|
1b1f3a791d | ||
|
|
10b1cd9c5e | ||
|
|
ef18e0ae32 | ||
|
|
1c2e000da3 | ||
|
|
9e35c244ec | ||
|
|
e82cb33407 | ||
|
|
6e58d44c71 | ||
|
|
d45afc7767 | ||
|
|
e126ec9eea | ||
|
|
13a74ddbd2 | ||
|
|
b221ca1b52 | ||
|
|
05505279f8 | ||
|
|
61e66d3a11 | ||
|
|
3aa36ed129 | ||
|
|
90b9eee27e | ||
|
|
7d800d8932 | ||
|
|
913a754f20 | ||
|
|
73d32999e5 | ||
|
|
86034ca71f | ||
|
|
db91698c09 | ||
|
|
7f74114d31 | ||
|
|
53db634ec2 | ||
|
|
400f1f6289 | ||
|
|
596432f3f7 | ||
|
|
14980c993f | ||
|
|
e17433f28f | ||
|
|
0833d4e44f | ||
|
|
07b2eba5c5 | ||
|
|
91b4def490 | ||
|
|
122ff939aa |
+123
-1
@@ -8,7 +8,122 @@ Ce journal suit les **versions du modpack**. Lorsqu'un seul module change, sa ve
|
||||
|
||||
## Non distribué
|
||||
|
||||
Modules modifiés : **It's Alive `0.0.0-alpha.36`**, **Sanctuary `0.0.0-alpha.119`**, **Ambiance `0.0.0-alpha.15`** et **Only Fun `0.0.0-alpha.22`**. Le pack distribué reste en `26.2.0-alpha.210` jusqu’à la prochaine release.
|
||||
Module modifié : **Sanctuary `0.0.0-alpha.124`**. Le pack public reste temporairement en `26.2.0-alpha.221` jusqu'à la publication.
|
||||
|
||||
- ajoute un suivi d'avancement des quêtes, affichable ou masquable par une touche configurable (`J` par défaut) ;
|
||||
- affiche chaque quête active comme un simple texte ombré en haut à gauche, sans panneau, bordure, titre, message vide ni barre de progression ;
|
||||
- synchronise un snapshot borné des quêtes acceptées et des contrats réservés, sans les demandes que le joueur a lui-même créées, ni changer les données Sanctuary v11 ni le protocole réseau 22 ;
|
||||
- propose une saisie assistée des IDs d'item et de mob dans l'éditeur de demande, avec icône et noms proches.
|
||||
|
||||
## `26.2.0-alpha.221`
|
||||
|
||||
Module modifié : **Ambiance `0.0.0-alpha.22`**. Les neuf autres modules conservent leur version issue de l'alpha.220.
|
||||
|
||||
- corrige la cause réelle du disque blanc ralenti et trop grave : la piste AAC YouTube reste décodée en stéréo native à 48 kHz, puis chaque paire gauche/droite est explicitement moyennée en un échantillon mono positionnel ;
|
||||
- supprime la compensation OpenAL approximative à `4/3` et restitue la musique à sa hauteur et sa vitesse originales avec une hauteur `1.0` ;
|
||||
- conserve un flux PCM mono signé 16 bits little-endian pour l'audio positionnel du jukebox, sans silence ajouté ni changement de durée ;
|
||||
- ajoute un test déterministe du downmix stéréo et valide le lien signalé sur quatre secondes : 4 000 ms de source donnent 4 000 ms de PCM à 48 kHz ;
|
||||
- conserve les données Ambiance v2, le protocole réseau 6, les IDs et les sauvegardes existantes ; aucun nouveau monde ni réinitialisation n'est nécessaire.
|
||||
|
||||
## `26.2.0-alpha.220`
|
||||
|
||||
Module modifié : **Red-Stoner `0.0.0-alpha.12`**. Les neuf autres modules conservent leur version issue de l'alpha.219.
|
||||
|
||||
- remplace l’écran propriétaire du terminal par un véritable menu de conteneur inspiré des coffres vanilla et composé avec le panneau d’inventaire Sanctuary ;
|
||||
- supprime le bouton de fermeture dédié : Échap et la touche d’inventaire suivent désormais le comportement normal du jeu ;
|
||||
- affiche et synchronise toutes les rangées d’inventaire débloquées par Sanctuary, d’une à six, y compris les deux rangées persistantes supplémentaires ;
|
||||
- permet le retrait par clic gauche, le retrait unitaire par clic droit et le retrait vers l’inventaire par shift-clic ; un clic avec une pile portée ou un shift-clic depuis l’inventaire dépose dans le réseau ;
|
||||
- conserve les 54 cases comme une vue agrégée non réorganisable et affiche les quantités exactes sans créer de stockage virtuel ;
|
||||
- dépose dans le premier coffre ou baril disponible selon l’ordre déterministe de découverte, remplit d’abord ses piles compatibles puis ses cases vides et ne passe au stockage suivant qu’en cas de besoin ;
|
||||
- supprime les anciens payloads spécifiques au terminal au profit de la synchronisation native des menus, tout en gardant la distance, la topologie et les transferts sous autorité serveur ;
|
||||
- conserve les données Red-Stoner v3 et les sauvegardes existantes ; le protocole réseau Red-Stoner passe de 6 à 7, sans nouveau monde ni réinitialisation.
|
||||
|
||||
## `26.2.0-alpha.219`
|
||||
|
||||
Modules modifiés : **Sanctuary `0.0.0-alpha.122`**, **I Like To Move It `0.0.0-alpha.16`** et **Ambiance `0.0.0-alpha.21`**. Les sept autres modules conservent leur version issue de l'alpha.218.
|
||||
|
||||
- ajoute `sanctuary:alpha`, une île Minecraft Indev finie de 128 × 128 × 64 blocs, déterministe par seed, avec plages, cavernes, minerais, eau, lave, végétation et maison de départ historique ;
|
||||
- borne le monde par un fond en bedrock et un anneau de barrières, sans placement au-delà de X/Z −64…63, et expose les positions stables de la résidence et de l'arrivée sûre via l'API publique de Sanctuary ;
|
||||
- ajoute la dimension, son biome, son type et ses réglages aux profils Sanctuary et Overworld, tout en conservant la migration `portals:alpha` vers `sanctuary:alpha` ;
|
||||
- active la route de portail v2 en cobblestone moussue, cadre 4 × 5 et intérieur 2 × 3, avec validation serveur, refus si la dimension manque, paire persistante, déliaison à la casse et arrivée distante hors de la maison ;
|
||||
- compose localement les packs officiels Golden Days Base puis Alpha uniquement dans `sanctuary:alpha`, restaure exactement la pile précédente à la sortie ou déconnexion et laisse les textures vanilla fonctionner si les ZIP manquent ;
|
||||
- ne distribue aucun asset Golden Days dans les JAR, Packwiz ou `.mrpack`, documente les liens et la licence officiels, et n'impose aucun fichier au serveur dédié ;
|
||||
- ajoute les traductions FR/EN/RU et des tests déterministes pour le générateur, le portail et la composition des packs ;
|
||||
- conserve Sanctuary data v11/protocole 22, I Like To Move It data v3/protocole 5 et Ambiance data v2/protocole 6, sans réinitialisation de sauvegarde.
|
||||
|
||||
## `26.2.0-alpha.218`
|
||||
|
||||
Modules modifiés : **Red-Stoner `0.0.0-alpha.11`**, **Only Fun `0.0.0-alpha.25`** et **Sanctuary `0.0.0-alpha.121`**. Les sept autres modules conservent leur version issue de l'alpha.217.
|
||||
|
||||
- ajoute `redstoner:storage_terminal`, une demi-dalle verticale de huit pixels qui se pose uniquement sur la face horizontale d’un coffre, coffre piégé ou baril ;
|
||||
- découvre sans charger de chunk les conteneurs adjacents par face, verticalement compris, et refuse clairement tout réseau qui dépasse 128 blocs physiques ;
|
||||
- agrège uniquement les piles dont l’item et tous les composants sont strictement identiques, puis les affiche sur des pages défilantes de 54 entrées ;
|
||||
- valide côté serveur la distance, le terminal, la topologie, la page et l’identité de chaque pile avant un retrait par pile, un retrait unitaire ou un dépôt par shift-clic ;
|
||||
- fabrique le terminal avec quatre plaques d’argent, trois verres teintés, un Controller et un lingot de titane, sans entrée Shop, Black Market ni récompense de lootbox ;
|
||||
- ajoute les traductions FR/EN/RU, les ressources bloc/item/loot/recette et des tests déterministes de topologie, pagination, agrégation et transferts ;
|
||||
- conserve les données Red-Stoner v3, Only Fun v2 et Sanctuary v11 ; seul le protocole réseau Red-Stoner passe de 5 à 6, sans nouveau monde ni réinitialisation des sauvegardes.
|
||||
|
||||
## `26.2.0-alpha.217`
|
||||
|
||||
Module modifié : **Ambiance `0.0.0-alpha.20`**. Les neuf autres modules conservent leur version issue de l'alpha.216.
|
||||
|
||||
- corrige la hauteur trop grave et la vitesse trop lente du disque blanc en compensant la cadence du flux AAC YouTube par un facteur OpenAL exact de `4/3` ;
|
||||
- réapplique cette hauteur pendant toute la lecture afin qu’aucune mise à jour du canal audio ne la ramène à la valeur ralentie ;
|
||||
- valide la correction sur le lien signalé en comparant vingt secondes décodées par Lavaplayer au flux AAC direct, puis conserve les tests PCM 48 kHz et de niveau sonore ;
|
||||
- conserve les données Ambiance v2, le protocole réseau 6, les IDs et les sauvegardes existantes ;
|
||||
- distribue Ambiance `0.0.0-alpha.20` dans Packwiz et le `.mrpack` partagé client/serveur, sans exiger de nouveau monde ni réinitialiser les sauvegardes.
|
||||
|
||||
## `26.2.0-alpha.216`
|
||||
|
||||
Module modifié : **Ambiance `0.0.0-alpha.19`**. Les neuf autres modules conservent leur version issue de l'alpha.215.
|
||||
|
||||
- corrige le disque blanc ralenti et dégradé en remplissant continûment les tampons PCM d’une seconde demandés par Minecraft au lieu de les compléter prématurément avec du silence ;
|
||||
- conserve une fréquence réelle de 48 kHz, un flux mono 16 bits little-endian et force la vitesse OpenAL normale à `1.0` ;
|
||||
- borne chaque attente de trame à 250 ms après le démarrage afin de garder un flux continu sans bloquer indéfiniment le thread audio ;
|
||||
- ajoute un test déterministe des tampons Minecraft et renforce le test YouTube réel sur quatre secondes complètes ;
|
||||
- conserve les données Ambiance v2, le protocole réseau 6, les IDs et les sauvegardes existantes ;
|
||||
- distribue Ambiance `0.0.0-alpha.19` dans Packwiz et le `.mrpack` partagé client/serveur, sans exiger de nouveau monde ni réinitialiser les sauvegardes.
|
||||
|
||||
## `26.2.0-alpha.215`
|
||||
|
||||
Module modifié : **Ambiance `0.0.0-alpha.18`**. Les neuf autres modules conservent leur version issue de l'alpha.214.
|
||||
|
||||
- corrige le crash natif déclenché par l’insertion ou l’arrêt du disque blanc en laissant Minecraft libérer le canal OpenAL sur son thread audio ;
|
||||
- rétablit le décodage audible du lien YouTube signalé grâce au profil iOS corrigé et à une révision épinglée de `youtube-source` ;
|
||||
- borne uniquement l’attente initiale du flux puis poursuit la lecture sans blocage, avec silence de compensation lorsque les données arrivent en retard ;
|
||||
- transforme les erreurs et blocages de lecture en arrêt propre accompagné d’un message au joueur ;
|
||||
- conserve les données Ambiance v2, le protocole réseau 6, les IDs et les sauvegardes existantes ;
|
||||
- distribue Ambiance `0.0.0-alpha.18` dans Packwiz et le `.mrpack` partagé client/serveur, sans exiger de nouveau monde ni réinitialiser les sauvegardes.
|
||||
|
||||
## `26.2.0-alpha.214`
|
||||
|
||||
Module modifié : **Ambiance `0.0.0-alpha.17`**. Les neuf autres modules conservent leur version issue de l'alpha.213.
|
||||
|
||||
- attend de façon bornée la synchronisation client du jukebox avant de charger un disque blanc, ce qui rétablit sa lecture en solo ;
|
||||
- affiche immédiatement le chargement puis un échec explicite si le jukebox n'est toujours pas synchronisé après cinq secondes ;
|
||||
- accepte toujours les liens de partage YouTube avec paramètres, notamment `https://youtu.be/Ue5ZBe-GzSM?si=…` ;
|
||||
- conserve les données Ambiance v2 et le protocole réseau 6, sans migration de sauvegarde ;
|
||||
- distribue Ambiance `0.0.0-alpha.17` dans Packwiz et le `.mrpack` partagé client/serveur, sans exiger de nouveau monde ni réinitialiser les sauvegardes.
|
||||
|
||||
## `26.2.0-alpha.213`
|
||||
|
||||
Module modifié : **Ambiance `0.0.0-alpha.16`**. Les neuf autres modules conservent leur version issue de l'alpha.212.
|
||||
|
||||
- corrige le crash client au démarrage d'Ambiance sous Minecraft 26.2 en ciblant le nouveau gestionnaire vanilla `LevelEventHandler` pour l'arrêt du disque blanc ;
|
||||
- conserve les données Ambiance v2 et le protocole réseau 6, sans migration de sauvegarde ;
|
||||
- remplace le JAR Ambiance cassé dans Packwiz et le `.mrpack` partagé client/serveur, sans exiger de nouveau monde ni réinitialiser les sauvegardes.
|
||||
|
||||
## `26.2.0-alpha.212`
|
||||
|
||||
Module modifié : **Sanctuary `0.0.0-alpha.120`**. Les neuf autres modules conservent leur version issue de l'alpha.211.
|
||||
|
||||
- corrige le crash client au démarrage provoqué par le mixin du clic molette du Black Market sous Minecraft 26.2 ;
|
||||
- cible désormais la méthode vanilla `pickBlockOrEntity`, sans modifier les données Sanctuary v11 ni le protocole réseau 22.
|
||||
- remplace le JAR Sanctuary cassé dans Packwiz et le `.mrpack` partagé client/serveur, sans exiger de nouveau monde ni réinitialiser les sauvegardes.
|
||||
|
||||
## `26.2.0-alpha.211`
|
||||
|
||||
Modules modifiés : **Ambiance `0.0.0-alpha.15`**, **It's Alive `0.0.0-alpha.36`**, **Only Fun `0.0.0-alpha.24`**, **Ouch `0.0.0-alpha.10`** et **Sanctuary `0.0.0-alpha.119`**. Les cinq autres modules conservent leur version issue de l’alpha.210.
|
||||
|
||||
- remplace les pages noires du Livre interdit par l’affichage clair du livre vanilla ;
|
||||
- conserve les seize entrées, les découvertes personnelles, le partage sur pupitre, les IDs, les données v3 et le protocole réseau 4.
|
||||
@@ -20,6 +135,13 @@ Modules modifiés : **It's Alive `0.0.0-alpha.36`**, **Sanctuary `0.0.0-alpha.11
|
||||
- importe le cœur GPL historique d’Abjection dans Only Fun, sans onzième JAR : `/piss`, `/vomit`, `/shit` et `/smoke` retrouvent leurs durées, jets, impacts et messages ;
|
||||
- restaure `abjection:shit_ball` ainsi que le bloc boueux `abjection:shit_cube`, sa recette, son butin et ses ressources originales en FR/EN/RU ;
|
||||
- expose une API serveur unique pour les commandes et le futur raccourci, nettoie les actions temporaires à la mort, en spectateur ou à la déconnexion, et conserve les données v2 ainsi que le protocole réseau 2 d’Only Fun.
|
||||
- rend `abjection:shit_ball` lançable à la main et par dispenser grâce à une fabrique publique installée par Ouch, sans réenregistrer l’objet possédé par Only Fun ;
|
||||
- restaure le projectile historique de 0,25 bloc, sa consommation unique hors créatif, son rendu d’objet, son impact boueux et ses sept secondes de nausée ajoutées à la durée existante ;
|
||||
- déclare la dépendance sans cycle `ouch -> onlyfun` ; les données et protocoles réseau d’Ouch et d’Only Fun restent inchangés.
|
||||
- ajoute la touche configurable « Chier » dans les contrôles Only Fun, assignée à `J` par défaut et sans répétition lorsque la touche reste maintenue ;
|
||||
- transmet une demande C2S vide, déduplique le spam d’un même tick et revalide côté serveur la connexion, la vie et le mode spectateur avant de réutiliser exactement l’action `/shit` ;
|
||||
- conserve les données Only Fun v2 et porte son protocole réseau de 2 à 3, sans nouveau cooldown, besoin ni donnée persistante.
|
||||
- distribue les cinq JAR mis à jour dans Packwiz et le `.mrpack` partagé client/serveur, sans exiger de nouveau monde ni réinitialiser les sauvegardes.
|
||||
|
||||
## `26.2.0-alpha.210`
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Licence : **GNU GPL 3.0 or Later** (`GPL-3.0-or-later`)
|
||||
| `anotherworld` | Another World | choses matérielles | matière, photographie, instruments météo et 108 biomes natifs |
|
||||
| `itsalive` | It's Alive ! | vivant | Creepers, créatures, agriculture et gameplay serveur complet de Canaplia |
|
||||
| `ambiance` | Ambiance | perception | atmosphère, météo, lumières dynamiques, audio positionnel, calendrier et composition visuelle accessible |
|
||||
| `redstoner` | Red-Stoner | calcul et signaux | ordinateurs, identité serveur, Drawer et Particuleur |
|
||||
| `redstoner` | Red-Stoner | calcul et signaux | ordinateurs, identité serveur, terminal de stockage, Drawer et Particuleur |
|
||||
| `iliketomoveit` | I Like To Move It | déplacement | portails, véhicules, grappin, poulies et waystones locaux |
|
||||
| `ouch` | Ouch | combat et destruction | arsenal actif (6 armes) et TNT météo |
|
||||
| `onlyfun` | Only Fun | règles ludiques absurdes | lootboxes, Lucky Block, raretés et règles absurdes |
|
||||
@@ -22,11 +22,21 @@ Licence : **GNU GPL 3.0 or Later** (`GPL-3.0-or-later`)
|
||||
|
||||
Les descriptions FR/EN/RU sont déclarées dans chaque `fabric.mod.json` et dans les fichiers de langue. Les images fournies servent d'icônes Fabric de leur module.
|
||||
|
||||
Version courante du pack distribué : **`26.2.0-alpha.210`**. Cette release distribue le Livre interdit Canaplia avec It's Alive **`0.0.0-alpha.35`**, ainsi que Sanctuary **`0.0.0-alpha.118`**, Only Fun **`0.0.0-alpha.21`** et I Like To Move It **`0.0.0-alpha.15`**.
|
||||
Version courante du pack distribué : **`26.2.0-alpha.221`**. Cette release distribue Ambiance **`0.0.0-alpha.22`** ; les neuf autres modules conservent leur version issue de l'alpha.220.
|
||||
|
||||
La migration [CANA-01 à CANA-09](CANAPLIA.md) porte Canaplia vers Minecraft 26.2. It's Alive **`0.0.0-alpha.35`** enregistre les seize familles, les découvertes, hybridations, résines, effets serveur et le Livre interdit personnel, partageable temporairement sur un pupitre. Ambiance **`0.0.0-alpha.14`** compose les profils visuels bornés avec ses réglages d'accessibilité, et Only Fun **`0.0.0-alpha.21`** exclut explicitement Canaplia et les raccourcis de progression du Shop.
|
||||
|
||||
L’alpha.210 ajoute le Livre interdit, accélère le Happy Ghast piloté, regroupe les corrections du Shop, des requêtes, des capes et des noms de Zombies, puis actualise JEI en **`30.28.0.193`**. Elle met à jour les packs Modrinth, Prism et Packwiz sans exiger de nouveau monde ni réinitialiser les sauvegardes.
|
||||
L’alpha.221 corrige la cause réelle du disque blanc ralenti et trop grave : Ambiance décode désormais la source AAC en stéréo native à 48 kHz, puis moyenne explicitement chaque paire gauche/droite vers le flux mono positionnel de Minecraft. OpenAL reste à sa hauteur normale `1.0`, sans compensation artificielle ni changement de durée.
|
||||
|
||||
L’alpha.220 transforme le terminal de stockage en véritable menu de conteneur, sans bouton de fermeture propriétaire. Les clics gauche/droit et shift-clic permettent maintenant de retirer ou déposer des piles depuis toutes les rangées d’inventaire débloquées par Sanctuary, jusqu’à six ; la grille agrégée reste non réorganisable et remplit chaque coffre ou baril dans l’ordre de découverte avant de passer au suivant.
|
||||
|
||||
L’alpha.219 porte l'île finie Minecraft Indev dans `sanctuary:alpha` : 128 × 128 × 64 blocs déterministes, plages, cavernes, minerais, fluides, végétation et maison historique, le tout borné sans chunks parasites. Le cadre 4 × 5 en cobblestone moussue est maintenant actif et crée une paire persistante à une arrivée sûre hors de la maison. Ambiance peut enfin composer localement Golden Days Base puis Alpha uniquement dans cette dimension, sans redistribuer leurs textures et en restaurant exactement la pile précédente. Voir le [contrat Alpha](sanctuary/ALPHA.md), le [contrat des portails](iliketomoveit/PORTALS.md) et le [guide Golden Days](ambiance/GOLDEN_DAYS.md).
|
||||
|
||||
L’alpha.218 ajoute le terminal de stockage Red-Stoner : cette demi-dalle verticale se fixe sur un coffre, un coffre piégé ou un baril et donne accès, par pages défilantes de 54 types, à un réseau contigu borné à 128 conteneurs chargés. Les retraits et dépôts sont validés par le serveur, les composants d’items restent strictement séparés et le terminal endgame est exclu du Shop, du Black Market et des récompenses Only Fun.
|
||||
|
||||
L’alpha.217 tentait de compenser la hauteur trop grave du disque blanc par un facteur OpenAL de `4/3`. Cette compensation est retirée par l’alpha.221, qui corrige directement la conversion stéréo vers mono.
|
||||
|
||||
L’alpha.216 corrige la lecture lente et dégradée du disque blanc : les quatre tampons PCM initiaux de Minecraft sont maintenant remplis continûment à 48 kHz, avec une vitesse OpenAL normale, au lieu d’être majoritairement complétés par du silence. Les packs Modrinth, Prism et Packwiz sont mis à jour sans exiger de nouveau monde ni réinitialiser les sauvegardes.
|
||||
|
||||
L’alpha.207 rend le stockage du coffre-fort pratiquement illimité : rubis, saphirs et émeraudes utilisent chacun un solde 64 bits. Les 27 anciennes piles sont additionnées automatiquement lors du chargement, le Shop continue de consommer directement ces soldes et les 20 % pillés sont crédités dans le coffre-fort de l’attaquant sans créer des millions d’entités ou de piles d’objets.
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# Textures historiques Golden Days dans Alpha
|
||||
|
||||
Ambiance peut charger les packs officiels Golden Days uniquement pendant que
|
||||
le client se trouve dans `sanctuary:alpha`. Aucun fichier, texture ou dérivé de
|
||||
Golden Days n'est distribué dans les JAR ou dans le `.mrpack` Sanctuary.
|
||||
|
||||
## Installation cliente facultative
|
||||
|
||||
1. Télécharger [Golden Days Base](https://www.curseforge.com/minecraft/texture-packs/golden-days)
|
||||
depuis sa page officielle.
|
||||
2. Télécharger [Golden Days Alpha](https://www.curseforge.com/minecraft/texture-packs/golden-days-alpha)
|
||||
depuis sa page officielle.
|
||||
3. Placer les deux ZIP sans les extraire dans le dossier `resourcepacks/` du
|
||||
client. Les noms doivent conserver les marqueurs `golden-days-base` et
|
||||
`golden-days-alpha` ; les espaces et `_` sont aussi reconnus.
|
||||
4. Laisser les deux packs désactivés dans l'écran vanilla. Ambiance les compose
|
||||
temporairement dans l'ordre **Base**, puis **Alpha**.
|
||||
|
||||
À l'entrée dans Alpha, Ambiance capture la pile sélectionnée exacte, ajoute les
|
||||
deux packs en mémoire et recharge les ressources. À la sortie ou à la
|
||||
déconnexion, la pile capturée est restaurée exactement. Le fichier d'options
|
||||
vanilla n'est jamais modifié : après une interruption ou un crash pendant un
|
||||
rechargement, le prochain démarrage retrouve donc la pile enregistrée.
|
||||
|
||||
Si un des deux packs manque ou si un rechargement échoue, le client reste
|
||||
jouable avec les textures vanilla et affiche un message lisible. Un serveur
|
||||
dédié n'a besoin d'aucun ZIP.
|
||||
|
||||
## Licence et redistribution
|
||||
|
||||
Golden Days est publié sous **All Rights Reserved**. Sa
|
||||
[licence officielle](https://poeticrainbow.github.io/golden-days/license/)
|
||||
interdit notamment de redistribuer ou de reconditionner les ressources sans
|
||||
autorisation. Sanctuary ne fournit que l'intégration locale ci-dessus et les
|
||||
liens officiels. Les archives de développement restent hors du dépôt et de
|
||||
tous les artefacts distribués.
|
||||
@@ -0,0 +1,13 @@
|
||||
# Third-party notices
|
||||
|
||||
## Golden Days Base and Golden Days Alpha
|
||||
|
||||
Optional client resource packs by PoeticRainbow. No Golden Days asset is
|
||||
included in Ambiance or Sanctuary.
|
||||
|
||||
- Base project: https://www.curseforge.com/minecraft/texture-packs/golden-days
|
||||
- Alpha project: https://www.curseforge.com/minecraft/texture-packs/golden-days-alpha
|
||||
- License: https://poeticrainbow.github.io/golden-days/license/
|
||||
|
||||
Golden Days is All Rights Reserved. Players obtain the two archives separately
|
||||
from the official project pages for local use.
|
||||
+13
-7
@@ -16,16 +16,22 @@ jukebox vanilla.
|
||||
libre ; il est envoyé uniquement aux clients qui suivent le chunk et annoncent le canal ;
|
||||
- chaque client ouvre lui-même la source YouTube, refuse les directs et les vidéos de plus de
|
||||
trente minutes, et limite les lectures simultanées à quatre ;
|
||||
- la lecture utilise le canal `RECORDS`, un flux PCM mono positionnel et une atténuation à
|
||||
64 blocs. Elle s’arrête lorsque le disque est éjecté, le chunk disparaît ou la session se ferme.
|
||||
- la source AAC stéréo est décodée à 48 kHz (48 000 Hz) puis convertie explicitement en PCM mono 16 bits
|
||||
little-endian, échantillon gauche/droite par échantillon gauche/droite, afin de conserver la
|
||||
vitesse et la hauteur originales tout en restant positionnelle ;
|
||||
- OpenAL lit ce PCM à sa hauteur native `1.0` sur le canal `RECORDS`, avec une atténuation à
|
||||
64 blocs. La lecture s’arrête lorsque le disque est éjecté, le chunk disparaît ou la session se
|
||||
ferme.
|
||||
|
||||
Le disque ne crée aucune donnée persistante et ne demande aucune migration de sauvegarde.
|
||||
Ambiance conserve ses données v2 ; son protocole passe de 5 à 6.
|
||||
|
||||
## Dépendances clientes embarquées
|
||||
|
||||
Le décodage repose sur Lavaplayer `2.2.7` (Apache-2.0) et youtube-source `1.18.2` (MIT), ainsi que
|
||||
leurs dépendances transitives placées en JAR imbriqués par Loom. Leurs avis de licence restent dans
|
||||
les archives d’origine. L’accès YouTube est effectué uniquement par le client et peut cesser de
|
||||
fonctionner si YouTube modifie ses mécanismes de lecture ; aucune clé ou URL arbitraire n’est
|
||||
acceptée par le serveur.
|
||||
Le décodage repose sur Lavaplayer `2.2.7` (Apache-2.0) et sur le commit youtube-source
|
||||
`f45bbb7aebfcbc1c553769e04af6cd43afa8b7c3` (MIT), épinglé pour intégrer les correctifs de lecture
|
||||
YouTube publiés après `1.18.2`. Le profil iOS corrigé est utilisé sans compte, OAuth, jeton privé ou
|
||||
service de chiffrement tiers. Les dépendances sont placées en JAR imbriqués par Loom et leurs avis
|
||||
de licence restent dans les archives d’origine. L’accès YouTube est effectué uniquement par le
|
||||
client et peut cesser de fonctionner si YouTube modifie ses mécanismes de lecture ; aucune clé ou
|
||||
URL arbitraire n’est acceptée par le serveur.
|
||||
|
||||
+116
-8
@@ -1,5 +1,7 @@
|
||||
import groovy.json.JsonSlurper
|
||||
|
||||
def youtubeSourceVersion = "f45bbb7aebfcbc1c553769e04af6cd43afa8b7c3-SNAPSHOT"
|
||||
|
||||
configurations {
|
||||
whiteDiscLibraries
|
||||
implementation.extendsFrom whiteDiscLibraries
|
||||
@@ -13,14 +15,14 @@ dependencies {
|
||||
exclude group: "org.slf4j", module: "slf4j-api"
|
||||
exclude group: "org.jetbrains", module: "annotations"
|
||||
}
|
||||
whiteDiscLibraries("dev.lavalink.youtube:v2:1.18.2") {
|
||||
whiteDiscLibraries("dev.lavalink.youtube:v2:${youtubeSourceVersion}") {
|
||||
exclude group: "org.slf4j", module: "slf4j-api"
|
||||
exclude group: "org.jetbrains", module: "annotations"
|
||||
}
|
||||
[
|
||||
"dev.arbjerg:lavaplayer-natives:2.2.7",
|
||||
"dev.arbjerg:lava-common:2.2.7",
|
||||
"dev.lavalink.youtube:common:1.18.2",
|
||||
"dev.lavalink.youtube:common:${youtubeSourceVersion}",
|
||||
"org.mozilla:rhino-engine:1.7.15",
|
||||
"org.mozilla:rhino:1.7.15",
|
||||
"commons-io:commons-io:2.13.0",
|
||||
@@ -40,6 +42,12 @@ dependencies {
|
||||
}
|
||||
}
|
||||
|
||||
tasks.named("jar") {
|
||||
from("THIRD_PARTY_NOTICES.md") {
|
||||
rename { "THIRD_PARTY_NOTICES_ambiance.md" }
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register("ambianceVisualApiSmoke", JavaExec) {
|
||||
group = "verification"
|
||||
description = "Checks bounded visual-effect requests exposed to gameplay modules."
|
||||
@@ -64,15 +72,41 @@ tasks.register("whiteDiscUrlSmoke", JavaExec) {
|
||||
mainClass = "fr.koka99cab.sanctuary26.ambiance.white_disc.WhiteDiscUrlSmoke"
|
||||
}
|
||||
|
||||
tasks.register("whiteDiscPlaybackSmoke", JavaExec) {
|
||||
group = "verification"
|
||||
description = "Manually checks live YouTube loading and PCM decoding for the white disc."
|
||||
dependsOn tasks.named("testClasses")
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = "fr.koka99cab.sanctuary26.ambiance.client.audio.WhiteDiscPlaybackSmoke"
|
||||
}
|
||||
|
||||
tasks.register("whiteDiscStreamSmoke", JavaExec) {
|
||||
group = "verification"
|
||||
description = "Checks continuous 48 kHz PCM buffering for Minecraft's white-disc channel."
|
||||
dependsOn tasks.named("testClasses")
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = "fr.koka99cab.sanctuary26.ambiance.client.audio.LavaplayerAudioStreamSmoke"
|
||||
}
|
||||
|
||||
tasks.register("alphaTexturePackManagerSmoke", JavaExec) {
|
||||
group = "verification"
|
||||
description = "Checks local Golden Days detection, ordering and exact stack preservation."
|
||||
dependsOn tasks.named("testClasses")
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = "fr.koka99cab.sanctuary26.ambiance.client.AlphaTexturePackManagerSmoke"
|
||||
}
|
||||
|
||||
tasks.named("test") {
|
||||
failOnNoDiscoveredTests = false
|
||||
}
|
||||
|
||||
tasks.register("verifyAmbiance") {
|
||||
group = "verification"
|
||||
description = "Checks Ambiance alpha.15, including the bounded YouTube white-disc playback contract."
|
||||
description = "Checks Ambiance alpha.22, including native-rate white-disc audio and Alpha textures."
|
||||
inputs.files(fileTree("src/main/java"))
|
||||
inputs.files(fileTree("src/main/resources"))
|
||||
inputs.file(file("src/test/java/fr/koka99cab/sanctuary26/ambiance/client/audio/WhiteDiscPlaybackSmoke.java"))
|
||||
inputs.file(file("src/test/java/fr/koka99cab/sanctuary26/ambiance/client/audio/LavaplayerAudioStreamSmoke.java"))
|
||||
inputs.file(rootProject.file("pack/migrations/26.2.0-alpha.125-realtime-calendar-to-alpha.126.json"))
|
||||
inputs.file(rootProject.file("pack/migrations/26.2.0-alpha.127-daily-ambiance-sodium-iris-to-alpha.128.json"))
|
||||
inputs.file(rootProject.file("pack/migrations/26.2.0-alpha.128-ambiance-visuals-canon-iris-to-alpha.129.json"))
|
||||
@@ -120,8 +154,14 @@ tasks.register("verifyAmbiance") {
|
||||
file("src/main/resources/data/ambiance/recipe/white_disc.json"))
|
||||
def whiteDiscSong = new JsonSlurper().parse(
|
||||
file("src/main/resources/data/ambiance/jukebox_song/white_disc.json"))
|
||||
def levelEventMixin = file(
|
||||
"src/main/java/fr/koka99cab/sanctuary26/ambiance/mixin/client/LevelEventHandlerMixin.java").text
|
||||
def whiteDiscAudioPlayer = file(
|
||||
"src/main/java/fr/koka99cab/sanctuary26/ambiance/client/audio/WhiteDiscAudioPlayer.java").text
|
||||
def whiteDiscPlaybackSmoke = file(
|
||||
"src/test/java/fr/koka99cab/sanctuary26/ambiance/client/audio/WhiteDiscPlaybackSmoke.java").text
|
||||
|
||||
if (project.version.toString() != "0.0.0-alpha.15"
|
||||
if (project.version.toString() != "0.0.0-alpha.22"
|
||||
|| migration.source?.pack_version != "26.2.0-alpha.125"
|
||||
|| migration.target?.pack_version != "26.2.0-alpha.126"
|
||||
|| migration.target?.modules?.ambiance != "0.0.0-alpha.1"
|
||||
@@ -319,10 +359,29 @@ tasks.register("verifyAmbiance") {
|
||||
|| !javaText.contains('PlayerLookup.tracking(level, pos)')
|
||||
|| !javaText.contains('ServerPlayNetworking.canSend(receiver, WhiteDiscPlaybackPayload.ID)')
|
||||
|| !javaText.contains('MAXIMUM_SIMULTANEOUS_DISCS = 4')
|
||||
|| !javaText.contains('REQUESTS.size() + PLAYBACKS.size() >= MAXIMUM_SIMULTANEOUS_DISCS')
|
||||
|| !javaText.contains('PENDING_STARTS.size() + REQUESTS.size() + PLAYBACKS.size()')
|
||||
|| !javaText.contains('MAXIMUM_START_WAIT_TICKS = 100')
|
||||
|| !javaText.contains('tickPendingStarts(client)')
|
||||
|| !javaText.contains('client.level.dimension().equals(playback.dimension())')
|
||||
|| !javaText.contains('MAXIMUM_TRACK_MILLIS = 30L * 60L * 1_000L')
|
||||
|| !javaText.contains('new Pcm16AudioDataFormat(1, LavaplayerAudioStream.SAMPLE_RATE')
|
||||
|| !javaText.contains('new Pcm16AudioDataFormat(LavaplayerAudioStream.SOURCE_CHANNELS,')
|
||||
|| !javaText.contains('new YoutubeAudioSourceManager(false, new Ios())')
|
||||
|| !javaText.contains('player.addListener(new AudioEventAdapter()')
|
||||
|| !javaText.contains('failPlayback(client, key, failedPlayer)')
|
||||
|| !javaText.contains('initialWaitAttempted')
|
||||
|| !javaText.contains('CONTINUATION_FRAME_WAIT_MILLIS = 250L')
|
||||
|| !javaText.contains('player.provide(waitMillis, TimeUnit.MILLISECONDS)')
|
||||
|| !whiteDiscAudioPlayer.contains('PLAYBACK_PITCH = 1.0F')
|
||||
|| whiteDiscAudioPlayer.count('channel.setPitch(PLAYBACK_PITCH)') != 2
|
||||
|| !javaText.contains('SOURCE_CHANNELS = 2')
|
||||
|| !javaText.contains('downmixStereoFrame(frame.getData())')
|
||||
|| !javaText.contains('short mixed = (short) (((int) left + right) / SOURCE_CHANNELS)')
|
||||
|| !whiteDiscAudioPlayer.contains('handle.execute(channel -> channel.stop())')
|
||||
|| whiteDiscAudioPlayer.contains('.release()')
|
||||
|| !whiteDiscPlaybackSmoke.contains('https://www.youtube.com/watch?v=Ue5ZBe-GzSM')
|
||||
|| !whiteDiscPlaybackSmoke.contains('PCM cadence: 100%% speed')
|
||||
|| !whiteDiscPlaybackSmoke.contains('stream.decodedTimelineMillis() != 4_000L')
|
||||
|| !whiteDiscPlaybackSmoke.contains('stream.paddedSilenceBytes() != 0L')
|
||||
|| !javaText.contains('SoundSource.RECORDS')
|
||||
|| !javaText.contains('channel.linearAttenuation(ATTENUATION_DISTANCE)')
|
||||
|| !javaText.contains('LONG_HOSTS = Set.of(')
|
||||
@@ -334,6 +393,9 @@ tasks.register("verifyAmbiance") {
|
||||
|| whiteDiscRecipe.key?.W != "minecraft:white_dye"
|
||||
|| whiteDiscSong.sound_event != "minecraft:intentionally_empty"
|
||||
|| whiteDiscSong.length_in_seconds != 1800
|
||||
|| !levelEventMixin.contains('@Mixin(LevelEventHandler.class)')
|
||||
|| !levelEventMixin.contains('@Inject(method = "levelEvent"')
|
||||
|| !levelEventMixin.contains('ambiance$stopWhiteDisc(int type, BlockPos pos, int data,')
|
||||
|| ambianceLanguages.any { locale, translations ->
|
||||
!["item.ambiance.white_disc", "tooltip.ambiance.white_disc",
|
||||
"message.ambiance.white_disc.invalid_url", "message.ambiance.white_disc.failed"].every {
|
||||
@@ -341,8 +403,10 @@ tasks.register("verifyAmbiance") {
|
||||
}
|
||||
}
|
||||
|| !file("build.gradle").text.contains('dev.arbjerg:lavaplayer:2.2.7')
|
||||
|| !file("build.gradle").text.contains('dev.lavalink.youtube:v2:1.18.2')) {
|
||||
throw new GradleException("Ambiance alpha.15 white-disc playback contract is incomplete")
|
||||
|| !file("build.gradle").text.contains(
|
||||
'def youtubeSourceVersion = "f45bbb7aebfcbc1c553769e04af6cd43afa8b7c3-SNAPSHOT"')
|
||||
|| !file("build.gradle").text.contains('dev.lavalink.youtube:v2:${youtubeSourceVersion}')) {
|
||||
throw new GradleException("Ambiance white-disc playback contract is incomplete")
|
||||
}
|
||||
|
||||
[
|
||||
@@ -391,9 +455,53 @@ tasks.register("verifyAmbiance") {
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register("verifyAlphaTextures") {
|
||||
group = "verification"
|
||||
description = "Checks the alpha.21 local-only Golden Days overlay and exact restore contract."
|
||||
inputs.file(file("GOLDEN_DAYS.md"))
|
||||
inputs.file(file("THIRD_PARTY_NOTICES.md"))
|
||||
inputs.file(file("src/main/java/fr/koka99cab/sanctuary26/ambiance/client/AlphaTexturePackManager.java"))
|
||||
inputs.files(fileTree("src/main/resources/assets/ambiance/lang") { include "*.json" })
|
||||
inputs.file(rootProject.file("pack/migrations/26.2.0-alpha.218-alpha-indev-to-alpha.219.json"))
|
||||
doLast {
|
||||
def migration = new JsonSlurper().parse(rootProject.file(
|
||||
"pack/migrations/26.2.0-alpha.218-alpha-indev-to-alpha.219.json"))
|
||||
def manager = file(
|
||||
"src/main/java/fr/koka99cab/sanctuary26/ambiance/client/AlphaTexturePackManager.java").text
|
||||
def locales = ["fr_fr", "en_us", "ru_ru"].collect {
|
||||
new JsonSlurper().parse(file("src/main/resources/assets/ambiance/lang/${it}.json"))
|
||||
}
|
||||
def forbiddenArchives = rootProject.fileTree(rootProject.projectDir) {
|
||||
include "**/*golden*days*.zip", "**/*golden*days*.jar"
|
||||
exclude "**/build/**", "**/.gradle/**", "**/.git/**"
|
||||
}.files
|
||||
if (project.version.toString() != "0.0.0-alpha.22"
|
||||
|| migration.alpha_textures?.destination != "sanctuary:alpha"
|
||||
|| migration.alpha_textures?.client_only != true
|
||||
|| migration.alpha_textures?.bundled_assets != false
|
||||
|| migration.alpha_textures?.overlay_order != ["base", "alpha"]
|
||||
|| migration.alpha_textures?.previous_selection_restored_exactly != true
|
||||
|| migration.alpha_textures?.saved_options_modified != false
|
||||
|| migration.alpha_textures?.dedicated_server_files_required != false
|
||||
|| !manager.contains("SanctuaryDimensions.ALPHA")
|
||||
|| !manager.contains("previousSelection = List.copyOf")
|
||||
|| !manager.contains("selectionWithOverlay(previousSelection")
|
||||
|| manager.contains("updateResourcePacks")
|
||||
|| locales.any { !it.containsKey("message.ambiance.alpha_textures_missing")
|
||||
|| !it.containsKey("message.ambiance.alpha_textures_restore_failed") }
|
||||
|| !forbiddenArchives.isEmpty()
|
||||
|| !file("THIRD_PARTY_NOTICES.md").text.contains("No Golden Days asset")) {
|
||||
throw new GradleException("Ambiance alpha.21 Golden Days overlay contract is incomplete")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.named("check") {
|
||||
dependsOn tasks.named("verifyAmbiance")
|
||||
dependsOn tasks.named("ambianceScheduleSmoke")
|
||||
dependsOn tasks.named("ambianceVisualApiSmoke")
|
||||
dependsOn tasks.named("whiteDiscUrlSmoke")
|
||||
dependsOn tasks.named("whiteDiscStreamSmoke")
|
||||
dependsOn tasks.named("alphaTexturePackManagerSmoke")
|
||||
dependsOn tasks.named("verifyAlphaTextures")
|
||||
}
|
||||
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
package fr.koka99cab.sanctuary26.ambiance.client;
|
||||
|
||||
import fr.koka99cab.sanctuary26.ambiance.AmbianceMod;
|
||||
import fr.koka99cab.sanctuary26.sanctuary.world.SanctuaryDimensions;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
|
||||
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.server.packs.repository.PackRepository;
|
||||
|
||||
/**
|
||||
* Applies locally installed Golden Days packs only while the player is in
|
||||
* {@code sanctuary:alpha}. The selected stack is changed in memory only; the
|
||||
* player's saved resource-pack options remain untouched.
|
||||
*/
|
||||
public final class AlphaTexturePackManager {
|
||||
static final String BASE_MARKER = "golden-days-base";
|
||||
static final String ALPHA_MARKER = "golden-days-alpha";
|
||||
private static final int MAX_RESTORE_RETRIES = 1;
|
||||
|
||||
private static boolean requestKnown;
|
||||
private static boolean alphaRequested;
|
||||
private static boolean reloadInProgress;
|
||||
private static boolean failedForCurrentRequest;
|
||||
private static boolean missingMessageShown;
|
||||
private static boolean reconcilePending = true;
|
||||
private static int restoreFailures;
|
||||
private static List<String> previousSelection = List.of();
|
||||
private static boolean previousSelectionCaptured;
|
||||
|
||||
private AlphaTexturePackManager() {
|
||||
}
|
||||
|
||||
public static void register() {
|
||||
ClientTickEvents.END_CLIENT_TICK.register(AlphaTexturePackManager::tick);
|
||||
ClientPlayConnectionEvents.DISCONNECT.register((handler, client) -> request(client, false));
|
||||
}
|
||||
|
||||
private static void tick(Minecraft client) {
|
||||
boolean shouldUseAlphaTextures = client.level != null
|
||||
&& client.level.dimension().equals(SanctuaryDimensions.ALPHA);
|
||||
request(client, shouldUseAlphaTextures);
|
||||
if (!reloadInProgress && reconcilePending) {
|
||||
reconcilePending = false;
|
||||
reconcile(client);
|
||||
}
|
||||
}
|
||||
|
||||
private static void request(Minecraft client, boolean requested) {
|
||||
if (requestKnown && requested == alphaRequested) return;
|
||||
alphaRequested = requested;
|
||||
requestKnown = true;
|
||||
failedForCurrentRequest = false;
|
||||
missingMessageShown = false;
|
||||
restoreFailures = 0;
|
||||
if (requested && !previousSelectionCaptured) {
|
||||
PackRepository repository = client.getResourcePackRepository();
|
||||
repository.reload();
|
||||
previousSelection = List.copyOf(repository.getSelectedIds());
|
||||
previousSelectionCaptured = true;
|
||||
}
|
||||
reconcilePending = true;
|
||||
}
|
||||
|
||||
private static void reconcile(Minecraft client) {
|
||||
PackRepository repository = client.getResourcePackRepository();
|
||||
repository.reload();
|
||||
List<String> selected = List.copyOf(repository.getSelectedIds());
|
||||
|
||||
if (alphaRequested && !failedForCurrentRequest) {
|
||||
Optional<PackPair> packs = findPacks(repository.getAvailableIds());
|
||||
if (packs.isEmpty()) {
|
||||
failedForCurrentRequest = true;
|
||||
showMissingPackMessage(client);
|
||||
applyIfDifferent(client, selected, previousSelection, false);
|
||||
return;
|
||||
}
|
||||
List<String> desired = selectionWithOverlay(previousSelection, packs.orElseThrow());
|
||||
applyIfDifferent(client, selected, desired, true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!previousSelectionCaptured) return;
|
||||
applyIfDifferent(client, selected, previousSelection, false);
|
||||
}
|
||||
|
||||
private static void applyIfDifferent(Minecraft client, List<String> selected,
|
||||
List<String> desired, boolean enablingAlpha) {
|
||||
if (selected.equals(desired)) {
|
||||
if (!enablingAlpha && !alphaRequested) clearSnapshot();
|
||||
return;
|
||||
}
|
||||
client.getResourcePackRepository().setSelected(desired);
|
||||
reloadInProgress = true;
|
||||
AmbianceMod.LOGGER.info("[{}] Reloading client resources for Alpha texture mode: {}.",
|
||||
AmbianceMod.DISPLAY_NAME, enablingAlpha ? "enabled" : "disabled");
|
||||
client.reloadResourcePacks().whenComplete((ignored, error) -> client.execute(() -> {
|
||||
reloadInProgress = false;
|
||||
if (error != null) {
|
||||
AmbianceMod.LOGGER.error("[{}] Could not {} the Golden Days Alpha overlay.",
|
||||
AmbianceMod.DISPLAY_NAME, enablingAlpha ? "enable" : "restore", error);
|
||||
if (enablingAlpha) {
|
||||
failedForCurrentRequest = true;
|
||||
reconcilePending = true;
|
||||
} else if (++restoreFailures <= MAX_RESTORE_RETRIES) {
|
||||
reconcilePending = true;
|
||||
} else {
|
||||
client.getResourcePackRepository().setSelected(previousSelection);
|
||||
showRestoreFailureMessage(client);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!enablingAlpha && !alphaRequested) clearSnapshot();
|
||||
if (enablingAlpha != alphaRequested) reconcilePending = true;
|
||||
}));
|
||||
}
|
||||
|
||||
static Optional<PackPair> findPacks(Collection<String> availableIds) {
|
||||
Optional<String> base = findPack(availableIds, BASE_MARKER);
|
||||
Optional<String> alpha = findPack(availableIds, ALPHA_MARKER);
|
||||
return base.isPresent() && alpha.isPresent()
|
||||
? Optional.of(new PackPair(base.orElseThrow(), alpha.orElseThrow()))
|
||||
: Optional.empty();
|
||||
}
|
||||
|
||||
static List<String> selectionWithOverlay(Collection<String> previous, PackPair packs) {
|
||||
List<String> desired = selectedWithoutGoldenDays(previous);
|
||||
// Pack order is bottom-to-top: Base first, then its Alpha overlay.
|
||||
desired.add(packs.base());
|
||||
desired.add(packs.alpha());
|
||||
return List.copyOf(desired);
|
||||
}
|
||||
|
||||
private static List<String> selectedWithoutGoldenDays(Collection<String> selectedIds) {
|
||||
List<String> result = new ArrayList<>(selectedIds.size());
|
||||
for (String id : selectedIds) {
|
||||
if (!isGoldenDaysPack(id)) result.add(id);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Optional<String> findPack(Collection<String> availableIds, String marker) {
|
||||
return availableIds.stream()
|
||||
.filter(id -> normalized(id).contains(marker))
|
||||
.sorted(Comparator.naturalOrder())
|
||||
.findFirst();
|
||||
}
|
||||
|
||||
static boolean isGoldenDaysPack(String id) {
|
||||
String normalized = normalized(id);
|
||||
return normalized.contains(BASE_MARKER) || normalized.contains(ALPHA_MARKER);
|
||||
}
|
||||
|
||||
private static String normalized(String id) {
|
||||
return id.toLowerCase(Locale.ROOT).replace('_', '-').replace(' ', '-');
|
||||
}
|
||||
|
||||
private static void clearSnapshot() {
|
||||
previousSelection = List.of();
|
||||
previousSelectionCaptured = false;
|
||||
restoreFailures = 0;
|
||||
}
|
||||
|
||||
private static void showMissingPackMessage(Minecraft client) {
|
||||
if (!missingMessageShown && client.player != null) {
|
||||
client.gui.hud.getChat().addClientSystemMessage(
|
||||
Component.translatable("message.ambiance.alpha_textures_missing"));
|
||||
missingMessageShown = true;
|
||||
}
|
||||
}
|
||||
|
||||
private static void showRestoreFailureMessage(Minecraft client) {
|
||||
if (client.player != null) {
|
||||
client.gui.hud.getChat().addClientSystemMessage(
|
||||
Component.translatable("message.ambiance.alpha_textures_restore_failed"));
|
||||
}
|
||||
}
|
||||
|
||||
record PackPair(String base, String alpha) {
|
||||
PackPair {
|
||||
if (base == null || alpha == null || base.equals(alpha)) {
|
||||
throw new IllegalArgumentException("Golden Days Base and Alpha packs must be distinct");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ public final class AmbianceClient implements ClientModInitializer {
|
||||
@Override
|
||||
public void onInitializeClient() {
|
||||
RealtimeClient.initialize();
|
||||
AlphaTexturePackManager.register();
|
||||
CelestialSkyRenderer.register();
|
||||
ClientTickEvents.END_CLIENT_TICK.register(DynamicTorchLights::tick);
|
||||
ClientPlayNetworking.registerGlobalReceiver(AmbianceEnvironmentPayload.TYPE, (payload, context) ->
|
||||
|
||||
+66
-8
@@ -9,16 +9,26 @@ import java.util.concurrent.TimeoutException;
|
||||
import javax.sound.sampled.AudioFormat;
|
||||
import net.minecraft.client.sounds.AudioStream;
|
||||
|
||||
/** Adapts bounded mono PCM frames from Lavaplayer to Minecraft's streaming OpenAL channel. */
|
||||
/** Downmixes bounded stereo PCM frames from Lavaplayer for Minecraft's mono OpenAL stream. */
|
||||
final class LavaplayerAudioStream implements AudioStream {
|
||||
static final int SAMPLE_RATE = 48_000;
|
||||
private static final int FRAME_SAMPLES = 960;
|
||||
private static final int BYTES_PER_SAMPLE = 2;
|
||||
static final int SOURCE_CHANNELS = 2;
|
||||
static final int FRAME_SAMPLES = 960;
|
||||
private static final int BYTES_PER_SAMPLE = Short.BYTES;
|
||||
private static final int SOURCE_FRAME_SIZE = SOURCE_CHANNELS * BYTES_PER_SAMPLE;
|
||||
private static final long INITIAL_FRAME_WAIT_MILLIS = 2_000L;
|
||||
private static final long CONTINUATION_FRAME_WAIT_MILLIS = 250L;
|
||||
private static final AudioFormat FORMAT =
|
||||
new AudioFormat(SAMPLE_RATE, 16, 1, true, false);
|
||||
private final AudioPlayer player;
|
||||
private byte[] pending = new byte[0];
|
||||
private int pendingOffset;
|
||||
private long decodedBytes;
|
||||
private long paddedSilenceBytes;
|
||||
private long firstFrameTimecode = -1L;
|
||||
private long lastFrameTimecode = -1L;
|
||||
private int decodedFrames;
|
||||
private boolean initialWaitAttempted;
|
||||
private volatile boolean closed;
|
||||
private volatile boolean ended;
|
||||
|
||||
@@ -40,20 +50,26 @@ final class LavaplayerAudioStream implements AudioStream {
|
||||
int length = Math.min(output.remaining(), pending.length - pendingOffset);
|
||||
output.put(pending, pendingOffset, length);
|
||||
pendingOffset += length;
|
||||
decodedBytes += length;
|
||||
continue;
|
||||
}
|
||||
AudioFrame frame = nextFrame();
|
||||
if (frame == null) {
|
||||
if (ended) break;
|
||||
int silence = Math.min(output.remaining(), FRAME_SAMPLES * BYTES_PER_SAMPLE);
|
||||
for (int index = 0; index < silence; index++) output.put((byte) 0);
|
||||
continue;
|
||||
while (output.hasRemaining()) {
|
||||
output.put((byte) 0);
|
||||
paddedSilenceBytes++;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (frame.isTerminator()) {
|
||||
ended = true;
|
||||
break;
|
||||
}
|
||||
pending = frame.getData();
|
||||
if (firstFrameTimecode < 0L) firstFrameTimecode = frame.getTimecode();
|
||||
lastFrameTimecode = frame.getTimecode();
|
||||
decodedFrames++;
|
||||
pending = downmixStereoFrame(frame.getData());
|
||||
pendingOffset = 0;
|
||||
}
|
||||
if (output.position() == 0 && ended) return null;
|
||||
@@ -61,9 +77,34 @@ final class LavaplayerAudioStream implements AudioStream {
|
||||
return output;
|
||||
}
|
||||
|
||||
private static byte[] downmixStereoFrame(byte[] stereo) throws IOException {
|
||||
if (stereo.length == 0 || stereo.length % SOURCE_FRAME_SIZE != 0) {
|
||||
throw new IOException("Invalid stereo PCM frame length: " + stereo.length);
|
||||
}
|
||||
byte[] mono = new byte[stereo.length / SOURCE_CHANNELS];
|
||||
for (int sourceOffset = 0, targetOffset = 0;
|
||||
sourceOffset < stereo.length;
|
||||
sourceOffset += SOURCE_FRAME_SIZE, targetOffset += BYTES_PER_SAMPLE) {
|
||||
short left = littleEndianSample(stereo, sourceOffset);
|
||||
short right = littleEndianSample(stereo, sourceOffset + BYTES_PER_SAMPLE);
|
||||
short mixed = (short) (((int) left + right) / SOURCE_CHANNELS);
|
||||
mono[targetOffset] = (byte) mixed;
|
||||
mono[targetOffset + 1] = (byte) (mixed >>> 8);
|
||||
}
|
||||
return mono;
|
||||
}
|
||||
|
||||
private static short littleEndianSample(byte[] pcm, int offset) {
|
||||
return (short) (Byte.toUnsignedInt(pcm[offset]) | pcm[offset + 1] << 8);
|
||||
}
|
||||
|
||||
private AudioFrame nextFrame() throws IOException {
|
||||
try {
|
||||
AudioFrame frame = player.provide(2, TimeUnit.SECONDS);
|
||||
long waitMillis = initialWaitAttempted
|
||||
? CONTINUATION_FRAME_WAIT_MILLIS
|
||||
: INITIAL_FRAME_WAIT_MILLIS;
|
||||
initialWaitAttempted = true;
|
||||
AudioFrame frame = player.provide(waitMillis, TimeUnit.MILLISECONDS);
|
||||
if (frame == null && player.getPlayingTrack() == null) ended = true;
|
||||
return frame;
|
||||
} catch (TimeoutException ignored) {
|
||||
@@ -78,6 +119,23 @@ final class LavaplayerAudioStream implements AudioStream {
|
||||
return ended;
|
||||
}
|
||||
|
||||
boolean uses(AudioPlayer expectedPlayer) {
|
||||
return player == expectedPlayer;
|
||||
}
|
||||
|
||||
long decodedBytes() {
|
||||
return decodedBytes;
|
||||
}
|
||||
|
||||
long paddedSilenceBytes() {
|
||||
return paddedSilenceBytes;
|
||||
}
|
||||
|
||||
long decodedTimelineMillis() {
|
||||
if (decodedFrames == 0) return 0L;
|
||||
return lastFrameTimecode - firstFrameTimecode + 20L;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (closed) return;
|
||||
|
||||
+111
-19
@@ -5,10 +5,12 @@ import com.sedmelluq.discord.lavaplayer.format.Pcm16AudioDataFormat;
|
||||
import com.sedmelluq.discord.lavaplayer.player.AudioLoadResultHandler;
|
||||
import com.sedmelluq.discord.lavaplayer.player.AudioPlayer;
|
||||
import com.sedmelluq.discord.lavaplayer.player.DefaultAudioPlayerManager;
|
||||
import com.sedmelluq.discord.lavaplayer.player.event.AudioEventAdapter;
|
||||
import com.sedmelluq.discord.lavaplayer.tools.FriendlyException;
|
||||
import com.sedmelluq.discord.lavaplayer.track.AudioPlaylist;
|
||||
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
|
||||
import dev.lavalink.youtube.YoutubeAudioSourceManager;
|
||||
import dev.lavalink.youtube.clients.Ios;
|
||||
import fr.koka99cab.sanctuary26.ambiance.AmbianceMod;
|
||||
import fr.koka99cab.sanctuary26.ambiance.mixin.client.SoundEngineAccessor;
|
||||
import fr.koka99cab.sanctuary26.ambiance.mixin.client.SoundManagerAccessor;
|
||||
@@ -30,10 +32,14 @@ import net.minecraft.world.phys.Vec3;
|
||||
/** Client-only, positional and resource-bounded YouTube playback for white discs. */
|
||||
public final class WhiteDiscAudioPlayer {
|
||||
private static final int MAXIMUM_SIMULTANEOUS_DISCS = 4;
|
||||
private static final int MAXIMUM_START_WAIT_TICKS = 100;
|
||||
private static final long MAXIMUM_TRACK_MILLIS = 30L * 60L * 1_000L;
|
||||
private static final long FINISHED_GRACE_NANOS = 5_000_000_000L;
|
||||
private static final float ATTENUATION_DISTANCE = 64.0F;
|
||||
/** OpenAL must preserve the decoded music's native speed and pitch. */
|
||||
static final float PLAYBACK_PITCH = 1.0F;
|
||||
private static final DefaultAudioPlayerManager MANAGER = createManager();
|
||||
private static final Map<Long, PendingStart> PENDING_STARTS = new HashMap<>();
|
||||
private static final Map<Long, Long> REQUESTS = new HashMap<>();
|
||||
private static final LinkedHashMap<Long, Playback> PLAYBACKS = new LinkedHashMap<>();
|
||||
private static long requestSequence;
|
||||
@@ -43,16 +49,27 @@ public final class WhiteDiscAudioPlayer {
|
||||
}
|
||||
|
||||
public static void play(Minecraft client, BlockPos pos, String videoId) {
|
||||
if (shutdown || !WhiteDiscUrl.validVideoId(videoId) || !validTarget(client, pos)) return;
|
||||
if (shutdown || !WhiteDiscUrl.validVideoId(videoId) || !validJukeboxTarget(client, pos)) return;
|
||||
long key = pos.asLong();
|
||||
if (REQUESTS.containsKey(key)) return;
|
||||
if (PENDING_STARTS.containsKey(key) || REQUESTS.containsKey(key)) return;
|
||||
boolean replacingPlayback = PLAYBACKS.containsKey(key);
|
||||
if (!replacingPlayback
|
||||
&& REQUESTS.size() + PLAYBACKS.size() >= MAXIMUM_SIMULTANEOUS_DISCS) return;
|
||||
&& PENDING_STARTS.size() + REQUESTS.size() + PLAYBACKS.size()
|
||||
>= MAXIMUM_SIMULTANEOUS_DISCS) return;
|
||||
stop(key);
|
||||
client.player.sendOverlayMessage(Component.translatable("message.ambiance.white_disc.loading"));
|
||||
if (!validTarget(client, pos)) {
|
||||
PENDING_STARTS.put(key, new PendingStart(pos.immutable(), client.level.dimension(), videoId,
|
||||
MAXIMUM_START_WAIT_TICKS));
|
||||
return;
|
||||
}
|
||||
startLoading(client, pos, videoId);
|
||||
}
|
||||
|
||||
private static void startLoading(Minecraft client, BlockPos pos, String videoId) {
|
||||
long key = pos.asLong();
|
||||
long request = ++requestSequence;
|
||||
REQUESTS.put(key, request);
|
||||
client.player.sendOverlayMessage(Component.translatable("message.ambiance.white_disc.loading"));
|
||||
MANAGER.loadItemOrdered(key, WhiteDiscUrl.canonicalUrl(videoId), new AudioLoadResultHandler() {
|
||||
@Override
|
||||
public void trackLoaded(AudioTrack track) {
|
||||
@@ -101,24 +118,50 @@ public final class WhiteDiscAudioPlayer {
|
||||
ChannelAccess channels = ((SoundEngineAccessor) ((SoundManagerAccessor) client.getSoundManager())
|
||||
.ambiance$getSoundEngine()).ambiance$getChannelAccess();
|
||||
channels.createHandle(Library.Pool.STREAMING).thenAccept(handle -> client.execute(() -> {
|
||||
if (handle == null || REQUESTS.getOrDefault(key, -1L) != request) {
|
||||
if (handle != null) handle.release();
|
||||
if (handle == null || REQUESTS.getOrDefault(key, -1L) != request
|
||||
|| !validTarget(client, pos)) {
|
||||
if (handle != null) handle.execute(channel -> channel.stop());
|
||||
if (REQUESTS.getOrDefault(key, -1L) == request) REQUESTS.remove(key);
|
||||
return;
|
||||
}
|
||||
AudioPlayer player = MANAGER.createPlayer();
|
||||
player.playTrack(track);
|
||||
LavaplayerAudioStream stream = new LavaplayerAudioStream(player);
|
||||
Playback playback = new Playback(pos.immutable(), client.level.dimension(), handle, stream);
|
||||
PLAYBACKS.put(key, playback);
|
||||
REQUESTS.remove(key);
|
||||
player.addListener(new AudioEventAdapter() {
|
||||
@Override
|
||||
public void onTrackException(AudioPlayer failedPlayer, AudioTrack failedTrack,
|
||||
FriendlyException exception) {
|
||||
AmbianceMod.LOGGER.warn("White-disc playback failed for YouTube video {}: {}",
|
||||
failedTrack.getIdentifier(), exception.getMessage());
|
||||
client.execute(() -> failPlayback(client, key, failedPlayer));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTrackStuck(AudioPlayer stuckPlayer, AudioTrack stuckTrack,
|
||||
long thresholdMs) {
|
||||
AmbianceMod.LOGGER.warn("White-disc playback stalled for YouTube video {} after {} ms",
|
||||
stuckTrack.getIdentifier(), thresholdMs);
|
||||
client.execute(() -> failPlayback(client, key, stuckPlayer));
|
||||
}
|
||||
});
|
||||
player.playTrack(track);
|
||||
float volume = client.options.getFinalSoundSourceVolume(SoundSource.RECORDS);
|
||||
handle.execute(channel -> {
|
||||
channel.setSelfPosition(Vec3.atCenterOf(pos));
|
||||
channel.setRelative(false);
|
||||
channel.linearAttenuation(ATTENUATION_DISTANCE);
|
||||
channel.setVolume(volume);
|
||||
channel.attachBufferStream(stream);
|
||||
channel.play();
|
||||
try {
|
||||
channel.setSelfPosition(Vec3.atCenterOf(pos));
|
||||
channel.setRelative(false);
|
||||
channel.setPitch(PLAYBACK_PITCH);
|
||||
channel.linearAttenuation(ATTENUATION_DISTANCE);
|
||||
channel.setVolume(volume);
|
||||
channel.attachBufferStream(stream);
|
||||
channel.play();
|
||||
} catch (RuntimeException | LinkageError exception) {
|
||||
AmbianceMod.LOGGER.error("Unable to attach white-disc audio to Minecraft's sound engine",
|
||||
exception);
|
||||
client.execute(() -> failPlayback(client, key, player));
|
||||
}
|
||||
});
|
||||
if (client.player != null) client.player.sendOverlayMessage(
|
||||
Component.translatable("message.ambiance.white_disc.playing", track.getInfo().title));
|
||||
@@ -132,17 +175,25 @@ public final class WhiteDiscAudioPlayer {
|
||||
Component.translatable("message.ambiance.white_disc.failed"));
|
||||
}
|
||||
|
||||
private static void failPlayback(Minecraft client, long key, AudioPlayer player) {
|
||||
Playback playback = PLAYBACKS.get(key);
|
||||
if (playback == null || !playback.stream().uses(player)) return;
|
||||
stop(key);
|
||||
if (client.player != null) client.player.sendOverlayMessage(
|
||||
Component.translatable("message.ambiance.white_disc.failed"));
|
||||
}
|
||||
|
||||
public static void stop(BlockPos pos) {
|
||||
stop(pos.asLong());
|
||||
}
|
||||
|
||||
private static void stop(long key) {
|
||||
PENDING_STARTS.remove(key);
|
||||
REQUESTS.remove(key);
|
||||
Playback playback = PLAYBACKS.remove(key);
|
||||
if (playback == null) return;
|
||||
playback.stream().close();
|
||||
playback.handle().execute(channel -> channel.stop());
|
||||
playback.handle().release();
|
||||
}
|
||||
|
||||
public static void tick(Minecraft client) {
|
||||
@@ -150,6 +201,7 @@ public final class WhiteDiscAudioPlayer {
|
||||
clear();
|
||||
return;
|
||||
}
|
||||
tickPendingStarts(client);
|
||||
long now = System.nanoTime();
|
||||
for (Map.Entry<Long, Playback> entry : Map.copyOf(PLAYBACKS).entrySet()) {
|
||||
Playback playback = entry.getValue();
|
||||
@@ -162,11 +214,15 @@ public final class WhiteDiscAudioPlayer {
|
||||
continue;
|
||||
}
|
||||
float volume = client.options.getFinalSoundSourceVolume(SoundSource.RECORDS);
|
||||
playback.handle().execute(channel -> channel.setVolume(volume));
|
||||
playback.handle().execute(channel -> {
|
||||
channel.setPitch(PLAYBACK_PITCH);
|
||||
channel.setVolume(volume);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public static void clear() {
|
||||
PENDING_STARTS.clear();
|
||||
REQUESTS.clear();
|
||||
for (long key : PLAYBACKS.keySet().stream().mapToLong(Long::longValue).toArray()) stop(key);
|
||||
}
|
||||
@@ -181,19 +237,55 @@ public final class WhiteDiscAudioPlayer {
|
||||
private static DefaultAudioPlayerManager createManager() {
|
||||
DefaultAudioPlayerManager manager = new DefaultAudioPlayerManager();
|
||||
manager.getConfiguration().setOutputFormat(
|
||||
new Pcm16AudioDataFormat(1, LavaplayerAudioStream.SAMPLE_RATE, 960, false));
|
||||
new Pcm16AudioDataFormat(LavaplayerAudioStream.SOURCE_CHANNELS,
|
||||
LavaplayerAudioStream.SAMPLE_RATE, LavaplayerAudioStream.FRAME_SAMPLES, false));
|
||||
manager.setFrameBufferDuration(5_000);
|
||||
manager.setItemLoaderThreadPoolSize(2);
|
||||
manager.registerSourceManager(new YoutubeAudioSourceManager(false));
|
||||
manager.registerSourceManager(new YoutubeAudioSourceManager(false, new Ios()));
|
||||
return manager;
|
||||
}
|
||||
|
||||
private static boolean validTarget(Minecraft client, BlockPos pos) {
|
||||
return validJukeboxTarget(client, pos)
|
||||
&& client.level.getBlockState(pos).getValue(JukeboxBlock.HAS_RECORD);
|
||||
}
|
||||
|
||||
private static boolean validJukeboxTarget(Minecraft client, BlockPos pos) {
|
||||
return client.player != null && client.level != null
|
||||
&& client.player.distanceToSqr(Vec3.atCenterOf(pos))
|
||||
<= ATTENUATION_DISTANCE * ATTENUATION_DISTANCE * 4.0D
|
||||
&& client.level.getBlockState(pos).is(Blocks.JUKEBOX)
|
||||
&& client.level.getBlockState(pos).getValue(JukeboxBlock.HAS_RECORD);
|
||||
&& client.level.getBlockState(pos).is(Blocks.JUKEBOX);
|
||||
}
|
||||
|
||||
private static void tickPendingStarts(Minecraft client) {
|
||||
for (Map.Entry<Long, PendingStart> entry : Map.copyOf(PENDING_STARTS).entrySet()) {
|
||||
long key = entry.getKey();
|
||||
PendingStart pending = entry.getValue();
|
||||
if (!client.level.dimension().equals(pending.dimension())
|
||||
|| !validJukeboxTarget(client, pending.pos())) {
|
||||
PENDING_STARTS.remove(key);
|
||||
continue;
|
||||
}
|
||||
if (validTarget(client, pending.pos())) {
|
||||
PENDING_STARTS.remove(key);
|
||||
startLoading(client, pending.pos(), pending.videoId());
|
||||
continue;
|
||||
}
|
||||
if (pending.ticksRemaining() <= 1) {
|
||||
PENDING_STARTS.remove(key);
|
||||
if (client.player != null) client.player.sendOverlayMessage(
|
||||
Component.translatable("message.ambiance.white_disc.failed"));
|
||||
continue;
|
||||
}
|
||||
PENDING_STARTS.put(key, pending.waitOneTick());
|
||||
}
|
||||
}
|
||||
|
||||
private record PendingStart(BlockPos pos, ResourceKey<Level> dimension, String videoId,
|
||||
int ticksRemaining) {
|
||||
private PendingStart waitOneTick() {
|
||||
return new PendingStart(pos, dimension, videoId, ticksRemaining - 1);
|
||||
}
|
||||
}
|
||||
|
||||
private record Playback(BlockPos pos, ResourceKey<Level> dimension,
|
||||
|
||||
+4
-6
@@ -1,19 +1,17 @@
|
||||
package fr.koka99cab.sanctuary26.ambiance.mixin.client;
|
||||
|
||||
import fr.koka99cab.sanctuary26.ambiance.client.audio.WhiteDiscAudioPlayer;
|
||||
import net.minecraft.client.renderer.LevelRenderer;
|
||||
import net.minecraft.client.renderer.LevelEventHandler;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Inject;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
|
||||
@Mixin(LevelRenderer.class)
|
||||
public abstract class LevelRendererMixin {
|
||||
@Mixin(LevelEventHandler.class)
|
||||
public abstract class LevelEventHandlerMixin {
|
||||
@Inject(method = "levelEvent", at = @At("HEAD"))
|
||||
private void ambiance$stopWhiteDisc(Entity source, int type, BlockPos pos, int data,
|
||||
CallbackInfo callback) {
|
||||
private void ambiance$stopWhiteDisc(int type, BlockPos pos, int data, CallbackInfo callback) {
|
||||
if (type == 1011) WhiteDiscAudioPlayer.stop(pos);
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
"GameRendererMixin",
|
||||
"LightCoordsUtilMixin",
|
||||
"LightmapRenderStateExtractorMixin",
|
||||
"LevelRendererMixin",
|
||||
"LevelEventHandlerMixin",
|
||||
"SoundEngineAccessor",
|
||||
"SoundManagerAccessor",
|
||||
"SkyRendererMixin"
|
||||
|
||||
@@ -32,5 +32,7 @@
|
||||
"message.ambiance.white_disc.loading": "Loading YouTube video…",
|
||||
"message.ambiance.white_disc.playing": "Now playing: %s",
|
||||
"message.ambiance.white_disc.failed": "This YouTube video could not be played.",
|
||||
"message.ambiance.white_disc.too_long": "This video is live or longer than 30 minutes."
|
||||
"message.ambiance.white_disc.too_long": "This video is live or longer than 30 minutes.",
|
||||
"message.ambiance.alpha_textures_missing": "Alpha textures are unavailable: install Golden Days Base then Golden Days Alpha in resourcepacks and leave them disabled.",
|
||||
"message.ambiance.alpha_textures_restore_failed": "The previous resource-pack stack could not be reloaded. It will be restored on the next start."
|
||||
}
|
||||
|
||||
@@ -32,5 +32,7 @@
|
||||
"message.ambiance.white_disc.loading": "Chargement de la vidéo YouTube…",
|
||||
"message.ambiance.white_disc.playing": "Lecture : %s",
|
||||
"message.ambiance.white_disc.failed": "Impossible de lire cette vidéo YouTube.",
|
||||
"message.ambiance.white_disc.too_long": "Cette vidéo est en direct ou dépasse 30 minutes."
|
||||
"message.ambiance.white_disc.too_long": "Cette vidéo est en direct ou dépasse 30 minutes.",
|
||||
"message.ambiance.alpha_textures_missing": "Textures Alpha indisponibles : installez Golden Days Base puis Golden Days Alpha dans resourcepacks en les laissant désactivés.",
|
||||
"message.ambiance.alpha_textures_restore_failed": "La pile de packs précédente n’a pas pu être rechargée. Elle sera restaurée au prochain démarrage."
|
||||
}
|
||||
|
||||
@@ -32,5 +32,7 @@
|
||||
"message.ambiance.white_disc.loading": "Загрузка видео YouTube…",
|
||||
"message.ambiance.white_disc.playing": "Сейчас играет: %s",
|
||||
"message.ambiance.white_disc.failed": "Не удалось воспроизвести это видео YouTube.",
|
||||
"message.ambiance.white_disc.too_long": "Это прямая трансляция или видео длиннее 30 минут."
|
||||
"message.ambiance.white_disc.too_long": "Это прямая трансляция или видео длиннее 30 минут.",
|
||||
"message.ambiance.alpha_textures_missing": "Текстуры Alpha недоступны: установите Golden Days Base, затем Golden Days Alpha в resourcepacks и оставьте их выключенными.",
|
||||
"message.ambiance.alpha_textures_restore_failed": "Не удалось перезагрузить прежний набор ресурсов. Он будет восстановлен при следующем запуске."
|
||||
}
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package fr.koka99cab.sanctuary26.ambiance.client;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** Executable tests for local Golden Days discovery and exact stack composition. */
|
||||
public final class AlphaTexturePackManagerSmoke {
|
||||
private AlphaTexturePackManagerSmoke() {
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
var packs = AlphaTexturePackManager.findPacks(List.of(
|
||||
"vanilla",
|
||||
"file/Golden Days Base 16.2 [1.20 to 26.2].zip",
|
||||
"file/golden_days_alpha_16.2.zip"
|
||||
)).orElseThrow(() -> new AssertionError("Golden Days packs were not detected"));
|
||||
|
||||
List<String> previous = List.of(
|
||||
"vanilla", "file/custom.zip", "file/golden-days-alpha-old.zip");
|
||||
List<String> desired = AlphaTexturePackManager.selectionWithOverlay(previous, packs);
|
||||
require(desired.equals(List.of("vanilla", "file/custom.zip", packs.base(), packs.alpha())),
|
||||
"Base and Alpha must be appended in bottom-to-top order without disturbing custom packs");
|
||||
require(previous.equals(List.of(
|
||||
"vanilla", "file/custom.zip", "file/golden-days-alpha-old.zip")),
|
||||
"Composing the overlay must not mutate the captured stack");
|
||||
require(AlphaTexturePackManager.findPacks(List.of("file/golden-days-base.zip")).isEmpty(),
|
||||
"A partial installation must keep the vanilla stack");
|
||||
require(AlphaTexturePackManager.isGoldenDaysPack("FILE/GOLDEN_DAYS_ALPHA.ZIP"),
|
||||
"Pack matching must be case- and separator-insensitive");
|
||||
require(!AlphaTexturePackManager.isGoldenDaysPack("file/custom-alpha.zip"),
|
||||
"Unrelated packs must never be removed from the captured stack");
|
||||
}
|
||||
|
||||
private static void require(boolean condition, String message) {
|
||||
if (!condition) throw new AssertionError(message);
|
||||
}
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
package fr.koka99cab.sanctuary26.ambiance.client.audio;
|
||||
|
||||
import com.sedmelluq.discord.lavaplayer.format.Pcm16AudioDataFormat;
|
||||
import com.sedmelluq.discord.lavaplayer.player.AudioPlayer;
|
||||
import com.sedmelluq.discord.lavaplayer.track.playback.AudioFrame;
|
||||
import com.sedmelluq.discord.lavaplayer.track.playback.ImmutableAudioFrame;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Queue;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/** Deterministic check for Minecraft's four one-second streaming buffers. */
|
||||
public final class LavaplayerAudioStreamSmoke {
|
||||
private static final int FRAME_SAMPLES = 960;
|
||||
private static final int SOURCE_FRAME_BYTES = FRAME_SAMPLES
|
||||
* LavaplayerAudioStream.SOURCE_CHANNELS * Short.BYTES;
|
||||
private static final int FRAMES_PER_SECOND = LavaplayerAudioStream.SAMPLE_RATE / FRAME_SAMPLES;
|
||||
|
||||
private LavaplayerAudioStreamSmoke() {
|
||||
}
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
if (Math.abs(WhiteDiscAudioPlayer.PLAYBACK_PITCH - 1.0F) > 0.0001F) {
|
||||
throw new IllegalStateException("The white-disc playback pitch is not native");
|
||||
}
|
||||
Queue<AudioFrame> frames = new ArrayDeque<>();
|
||||
Pcm16AudioDataFormat format = new Pcm16AudioDataFormat(
|
||||
LavaplayerAudioStream.SOURCE_CHANNELS, LavaplayerAudioStream.SAMPLE_RATE,
|
||||
FRAME_SAMPLES, false);
|
||||
for (int index = 0; index < FRAMES_PER_SECOND; index++) {
|
||||
byte[] data = new byte[SOURCE_FRAME_BYTES];
|
||||
for (int offset = 0; offset < data.length; offset += 4) {
|
||||
putLittleEndian(data, offset, (short) (1_000 + index));
|
||||
putLittleEndian(data, offset + 2, (short) (3_000 + index));
|
||||
}
|
||||
frames.add(new ImmutableAudioFrame(index * 20L, data, 100, format));
|
||||
}
|
||||
|
||||
AtomicInteger timedProvides = new AtomicInteger();
|
||||
AtomicInteger nonBlockingProvides = new AtomicInteger();
|
||||
AtomicBoolean destroyed = new AtomicBoolean();
|
||||
AudioPlayer player = (AudioPlayer) Proxy.newProxyInstance(
|
||||
AudioPlayer.class.getClassLoader(), new Class<?>[] {AudioPlayer.class},
|
||||
(proxy, method, arguments) -> switch (method.getName()) {
|
||||
case "provide" -> {
|
||||
if (method.getParameterCount() == 2) {
|
||||
timedProvides.incrementAndGet();
|
||||
yield frames.poll();
|
||||
}
|
||||
nonBlockingProvides.incrementAndGet();
|
||||
yield null;
|
||||
}
|
||||
case "destroy" -> {
|
||||
destroyed.set(true);
|
||||
yield null;
|
||||
}
|
||||
case "getPlayingTrack" -> null;
|
||||
case "getVolume" -> 100;
|
||||
case "isPaused", "startTrack" -> false;
|
||||
default -> null;
|
||||
});
|
||||
|
||||
LavaplayerAudioStream stream = new LavaplayerAudioStream(player);
|
||||
ByteBuffer pcm = stream.read(LavaplayerAudioStream.SAMPLE_RATE * 2);
|
||||
if (pcm == null || pcm.remaining() != LavaplayerAudioStream.SAMPLE_RATE * 2) {
|
||||
throw new IllegalStateException("The stream did not fill Minecraft's one-second buffer");
|
||||
}
|
||||
for (int frame = 0; frame < FRAMES_PER_SECOND; frame++) {
|
||||
short expected = (short) (2_000 + frame);
|
||||
for (int sample = 0; sample < FRAME_SAMPLES; sample++) {
|
||||
int low = Byte.toUnsignedInt(pcm.get());
|
||||
short actual = (short) (low | pcm.get() << 8);
|
||||
if (actual != expected) {
|
||||
throw new IllegalStateException("Stereo PCM was not downmixed in sample order");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (timedProvides.get() != FRAMES_PER_SECOND || nonBlockingProvides.get() != 0) {
|
||||
throw new IllegalStateException("PCM frames were not read with bounded waits");
|
||||
}
|
||||
if (stream.getFormat().getSampleRate() != LavaplayerAudioStream.SAMPLE_RATE
|
||||
|| stream.getFormat().isBigEndian()) {
|
||||
throw new IllegalStateException("The OpenAL PCM format is not 48 kHz little-endian");
|
||||
}
|
||||
stream.close();
|
||||
if (!destroyed.get()) {
|
||||
throw new IllegalStateException("Closing the stream did not destroy its player");
|
||||
}
|
||||
}
|
||||
|
||||
private static void putLittleEndian(byte[] data, int offset, short sample) {
|
||||
data[offset] = (byte) sample;
|
||||
data[offset + 1] = (byte) (sample >>> 8);
|
||||
}
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
package fr.koka99cab.sanctuary26.ambiance.client.audio;
|
||||
|
||||
import com.sedmelluq.discord.lavaplayer.format.Pcm16AudioDataFormat;
|
||||
import com.sedmelluq.discord.lavaplayer.player.AudioLoadResultHandler;
|
||||
import com.sedmelluq.discord.lavaplayer.player.AudioPlayer;
|
||||
import com.sedmelluq.discord.lavaplayer.player.DefaultAudioPlayerManager;
|
||||
import com.sedmelluq.discord.lavaplayer.tools.FriendlyException;
|
||||
import com.sedmelluq.discord.lavaplayer.track.AudioPlaylist;
|
||||
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
|
||||
import dev.lavalink.youtube.YoutubeAudioSourceManager;
|
||||
import dev.lavalink.youtube.clients.Ios;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/** Manual network smoke test for the exact YouTube playback path used by white discs. */
|
||||
public final class WhiteDiscPlaybackSmoke {
|
||||
private static final String VIDEO_URL = "https://www.youtube.com/watch?v=Ue5ZBe-GzSM";
|
||||
|
||||
private WhiteDiscPlaybackSmoke() {
|
||||
}
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
DefaultAudioPlayerManager manager = new DefaultAudioPlayerManager();
|
||||
manager.getConfiguration().setOutputFormat(
|
||||
new Pcm16AudioDataFormat(LavaplayerAudioStream.SOURCE_CHANNELS,
|
||||
LavaplayerAudioStream.SAMPLE_RATE, LavaplayerAudioStream.FRAME_SAMPLES, false));
|
||||
manager.registerSourceManager(new YoutubeAudioSourceManager(false, new Ios()));
|
||||
try {
|
||||
AudioTrack track = load(manager).get(30, TimeUnit.SECONDS);
|
||||
AudioPlayer player = manager.createPlayer();
|
||||
player.playTrack(track);
|
||||
LavaplayerAudioStream stream = new LavaplayerAudioStream(player);
|
||||
int totalBytes = 0;
|
||||
long squaredSampleSum = 0L;
|
||||
int sampleCount = 0;
|
||||
int peakSample = 0;
|
||||
for (int index = 0; index < 4; index++) {
|
||||
ByteBuffer pcm = stream.read(LavaplayerAudioStream.SAMPLE_RATE * 2);
|
||||
if (pcm == null) break;
|
||||
totalBytes += pcm.remaining();
|
||||
while (pcm.remaining() >= 2) {
|
||||
int low = Byte.toUnsignedInt(pcm.get());
|
||||
int high = pcm.get();
|
||||
short sample = (short) ((high << 8) | low);
|
||||
int magnitude = Math.abs((int) sample);
|
||||
peakSample = Math.max(peakSample, magnitude);
|
||||
squaredSampleSum += (long) sample * sample;
|
||||
sampleCount++;
|
||||
}
|
||||
}
|
||||
int expectedBytes = LavaplayerAudioStream.SAMPLE_RATE * 2 * 4;
|
||||
if (totalBytes != expectedBytes) {
|
||||
throw new IllegalStateException("YouTube playback did not fill four PCM seconds");
|
||||
}
|
||||
if (stream.decodedBytes() != expectedBytes || stream.paddedSilenceBytes() != 0L) {
|
||||
throw new IllegalStateException("YouTube playback was not 100% decoded PCM: "
|
||||
+ stream.decodedBytes() + " decoded bytes and "
|
||||
+ stream.paddedSilenceBytes() + " padded bytes");
|
||||
}
|
||||
if (stream.decodedTimelineMillis() != 4_000L) {
|
||||
throw new IllegalStateException("PCM cadence mismatch: four seconds of samples cover "
|
||||
+ stream.decodedTimelineMillis() + " ms of the YouTube timeline");
|
||||
}
|
||||
double rmsSample = Math.sqrt((double) squaredSampleSum / sampleCount);
|
||||
if (rmsSample < 256.0 || peakSample < 2_048) {
|
||||
throw new IllegalStateException("YouTube PCM signal is too quiet: RMS "
|
||||
+ Math.round(rmsSample) + ", peak " + peakSample);
|
||||
}
|
||||
System.out.printf(
|
||||
"PCM cadence: 100%% speed, 4000 ms decoded into 4000 ms at %.0f Hz; RMS %.0f, peak %d from %s%n",
|
||||
stream.getFormat().getSampleRate(), rmsSample, peakSample, track.getInfo().title);
|
||||
stream.close();
|
||||
} finally {
|
||||
manager.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
private static CompletableFuture<AudioTrack> load(DefaultAudioPlayerManager manager) {
|
||||
CompletableFuture<AudioTrack> result = new CompletableFuture<>();
|
||||
manager.loadItem(VIDEO_URL, new AudioLoadResultHandler() {
|
||||
@Override
|
||||
public void trackLoaded(AudioTrack track) {
|
||||
result.complete(track);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void playlistLoaded(AudioPlaylist playlist) {
|
||||
AudioTrack selected = playlist.getSelectedTrack();
|
||||
if (selected == null && !playlist.getTracks().isEmpty()) {
|
||||
selected = playlist.getTracks().getFirst();
|
||||
}
|
||||
if (selected == null) result.completeExceptionally(
|
||||
new IllegalStateException("YouTube playlist contained no track"));
|
||||
else result.complete(selected);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void noMatches() {
|
||||
result.completeExceptionally(new IllegalStateException("YouTube returned no match"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void loadFailed(FriendlyException exception) {
|
||||
result.completeExceptionally(exception);
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
}
|
||||
+2
@@ -10,6 +10,8 @@ public final class WhiteDiscUrlSmoke {
|
||||
public static void main(String[] args) {
|
||||
require(VIDEO_ID.equals(id("https://www.youtube.com/watch?v=" + VIDEO_ID)), "watch URL rejected");
|
||||
require(VIDEO_ID.equals(id("https://youtu.be/" + VIDEO_ID + "?si=abc")), "short URL rejected");
|
||||
require("Ue5ZBe-GzSM".equals(id("https://youtu.be/Ue5ZBe-GzSM?si=78LSbtBQzUYzhA2Y")),
|
||||
"reported white-disc URL rejected");
|
||||
require(VIDEO_ID.equals(id("https://music.youtube.com/watch?v=" + VIDEO_ID)), "music URL rejected");
|
||||
require(VIDEO_ID.equals(id("https://www.youtube.com/shorts/" + VIDEO_ID)), "shorts URL rejected");
|
||||
require(VIDEO_ID.equals(id("https://www.youtube.com/embed/" + VIDEO_ID)), "embed URL rejected");
|
||||
|
||||
+1268
-113
File diff suppressed because it is too large
Load Diff
+7
-7
@@ -12,23 +12,23 @@ fabric_api_version=0.158.0+26.2
|
||||
# Standby modules stay at zero until their migration really begins.
|
||||
mod_version=0.0.0-alpha.0
|
||||
module_lifecycle=standby
|
||||
ambiance_version=0.0.0-alpha.15
|
||||
ambiance_version=0.0.0-alpha.22
|
||||
ambiance_lifecycle=active
|
||||
redstoner_version=0.0.0-alpha.10
|
||||
redstoner_version=0.0.0-alpha.13
|
||||
redstoner_lifecycle=active
|
||||
anotherworld_version=0.0.0-alpha.55
|
||||
anotherworld_lifecycle=active
|
||||
itsalive_version=0.0.0-alpha.36
|
||||
itsalive_lifecycle=active
|
||||
ouch_version=0.0.0-alpha.9
|
||||
ouch_version=0.0.0-alpha.10
|
||||
ouch_lifecycle=active
|
||||
masterkey_version=0.0.0-alpha.8
|
||||
masterkey_lifecycle=active
|
||||
iliketomoveit_version=0.0.0-alpha.15
|
||||
iliketomoveit_version=0.0.0-alpha.16
|
||||
iliketomoveit_lifecycle=active
|
||||
onlyfun_version=0.0.0-alpha.22
|
||||
onlyfun_version=0.0.0-alpha.25
|
||||
onlyfun_lifecycle=active
|
||||
sanctuary_version=0.0.0-alpha.119
|
||||
sanctuary_version=0.0.0-alpha.124
|
||||
sanctuary_lifecycle=active
|
||||
pack_version=26.2.0-alpha.210
|
||||
pack_version=26.2.0-alpha.221
|
||||
maven_group=fr.koka99cab.sanctuary26
|
||||
|
||||
@@ -29,7 +29,7 @@ Une route est un JSON sous `data/<namespace>/sanctuary_portal/<id>.json` :
|
||||
- `arrival.type` vaut `surface`, `matched_y` ou `fixed_y`; ce dernier exige `arrival.y`.
|
||||
- `priority` départage deux routes acceptant le même cadre.
|
||||
|
||||
Les neuf routes historiques restent déclarées pour la compatibilité. Pour l'alpha.150, seules deux routes sont actives : terre vers la paire Sanctuary/Overworld et pierre vers `sanctuary:caverns`. Alpha, Sheep Land, Skeleton Waste, Lost City, Painted Canyons, Breaker Sea et Laputa restent réservées mais ne sont pas chargeables.
|
||||
Les neuf routes historiques restent déclarées pour la compatibilité. Depuis l'alpha.219, trois routes sont actives : terre vers la paire Sanctuary/Overworld, pierre vers `sanctuary:caverns` et cobblestone moussue vers `sanctuary:alpha`. Sheep Land, Skeleton Waste, Lost City, Painted Canyons, Breaker Sea et Laputa restent réservées mais ne sont pas chargeables.
|
||||
|
||||
La route en terre est la seule route v2 dynamique. Dans le profil Sanctuary, elle relie `minecraft:overworld` à `sanctuary:overworld`. Dans le profil Overworld, elle relie `minecraft:overworld` à `sanctuary:main`. Le choix appartient à la sauvegarde, jamais à une préférence cliente.
|
||||
|
||||
@@ -43,8 +43,16 @@ La route en terre est la seule route v2 dynamique. Dans le profil Sanctuary, ell
|
||||
4. Reconstruire le même cadre réactive son endpoint; la traversée suivante régénère un lien.
|
||||
5. Une destination absente refuse la traversée proprement. Elle ne crée aucune dimension et n'endommage pas la sauvegarde.
|
||||
|
||||
La route Alpha exige un cadre extérieur de 4 × 5 avec un intérieur vide de
|
||||
2 × 3, entièrement en cobblestone moussue, puis un briquet. Le serveur valide
|
||||
le cadre, l'activateur et la présence de `sanctuary:alpha` avant de l'allumer.
|
||||
Sa première moitié distante utilise l'arrivée sûre calculée par l'API publique
|
||||
de Sanctuary, hors de la maison Indev ; le client ne choisit ni la destination
|
||||
ni les coordonnées. L'Atlas découvre le bloc de portail actif comme les autres
|
||||
points d'intérêt locaux.
|
||||
|
||||
Le bloc intérieur actif est `iliketomoveit:portal`. Les neuf anciens blocs `portals:*_portal` restent enregistrés comme compatibilité de lecture; une première utilisation les rattache au nouvel index.
|
||||
|
||||
## État des générateurs
|
||||
|
||||
Le générateur principal Sanctuary et sa paire Overworld sont actifs depuis l'alpha.71. Caverns, Alpha, Sheep Land, Skeleton Waste, Lost City, Painted Canyons, Breaker Sea et Laputa restent réservés mais non générés. Leurs cadres peuvent être allumés et persistés ; une traversée vers une destination encore absente est refusée proprement.
|
||||
Le générateur principal Sanctuary et sa paire Overworld sont actifs depuis l'alpha.71. Caverns et Alpha sont également générés ; Alpha est le monde Indev fini 128 × 128 × 64 possédé par Sanctuary. Sheep Land, Skeleton Waste, Lost City, Painted Canyons, Breaker Sea et Laputa restent réservés mais non générés. Une traversée vers une destination absente est refusée proprement dès l'allumage.
|
||||
|
||||
@@ -24,6 +24,14 @@ tasks.register("happyGhastCruiseSpeedSmoke", JavaExec) {
|
||||
dependsOn tasks.named("testClasses")
|
||||
}
|
||||
|
||||
tasks.register("alphaPortalContractSmoke", JavaExec) {
|
||||
group = "verification"
|
||||
description = "Checks the active Alpha route and its finite-world safe-arrival contract."
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = "fr.koka99cab.sanctuary26.iliketomoveit.portal.AlphaPortalContractSmoke"
|
||||
dependsOn tasks.named("testClasses")
|
||||
}
|
||||
|
||||
tasks.named("test") {
|
||||
// Runtime contracts are executable smoke tasks, not JUnit engine tests.
|
||||
failOnNoDiscoveredTests = false
|
||||
@@ -268,7 +276,7 @@ tasks.register("verifyPortals") {
|
||||
def routeFile = file("src/main/resources/data/iliketomoveit/sanctuary_portal/${route}.json")
|
||||
if (!routeFile.isFile()) throw new GradleException("Missing portal route ${route}")
|
||||
def json = new JsonSlurper().parse(routeFile)
|
||||
def expectedSchema = route == 'overworld' ? 2 : 1
|
||||
def expectedSchema = route in ['overworld', 'alpha'] ? 2 : 1
|
||||
if (json.schema_version != expectedSchema || json.destination_dimension != destination
|
||||
|| json.source_dimension != "minecraft:overworld"
|
||||
|| (route == 'overworld' && json.destination_resolver != "overworld_pair")
|
||||
@@ -305,7 +313,7 @@ tasks.register("verifyPortals") {
|
||||
}
|
||||
def atlasCavernsMigration = new JsonSlurper().parse(rootProject.file(
|
||||
"pack/migrations/26.2.0-alpha.149-atlas-caverns-to-alpha.150.json"))
|
||||
if (project.version.toString() != "0.0.0-alpha.15"
|
||||
if (project.version.toString() != "0.0.0-alpha.16"
|
||||
|| atlasCavernsMigration.target?.pack_version != "26.2.0-alpha.150"
|
||||
|| atlasCavernsMigration.target?.modules?.iliketomoveit != "0.0.0-alpha.9"
|
||||
|| atlasCavernsMigration.portals?.active_routes != [
|
||||
@@ -313,7 +321,7 @@ tasks.register("verifyPortals") {
|
||||
|| atlasCavernsMigration.portals?.inactive_routes?.size() != 7
|
||||
|| atlasCavernsMigration.portals?.reserved_ids_retained != true
|
||||
|| atlasCavernsMigration.portals?.inactive_routes_loadable != false
|
||||
|| !javaText.contains("ACTIVE_ROUTES = List.of(OVERWORLD, CAVERNS)")
|
||||
|| !javaText.contains("ACTIVE_ROUTES = List.of(OVERWORLD, CAVERNS, ALPHA)")
|
||||
|| !javaText.contains("REQUIRED_ROUTES = ACTIVE_ROUTES")
|
||||
|| atlasCavernsMigration.compatibility?.iliketomoveit_data_version != 3
|
||||
|| atlasCavernsMigration.compatibility?.iliketomoveit_network_protocol != 3) {
|
||||
@@ -341,7 +349,7 @@ tasks.register("verifySilverAircraft") {
|
||||
def manifest = new JsonSlurper().parse(file("src/main/resources/fabric.mod.json"))
|
||||
def migration = new JsonSlurper().parse(rootProject.file(
|
||||
"pack/migrations/26.2.0-alpha.184-silver-engineering-to-alpha.185.json"))
|
||||
if (project.version.toString() != "0.0.0-alpha.15"
|
||||
if (project.version.toString() != "0.0.0-alpha.16"
|
||||
|| recipe.pattern != ["PGP", "GCG", "PBP"]
|
||||
|| recipe.key != [B: "anotherworld:silver_block", C: "anotherworld:aircraft_cockpit",
|
||||
G: "anotherworld:silver_gear", P: "anotherworld:silver_plate"]
|
||||
@@ -370,7 +378,7 @@ tasks.register("verifyGrapplePrediction") {
|
||||
def payload = file("src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/network/GrappleStatePayload.java").text
|
||||
def controller = file("src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/grapple/GrappleController.java").text
|
||||
def prediction = file("src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/client/grapple/ClientGrapplePrediction.java").text
|
||||
if (project.version.toString() != "0.0.0-alpha.15"
|
||||
if (project.version.toString() != "0.0.0-alpha.16"
|
||||
|| migration.target?.modules?.iliketomoveit != "0.0.0-alpha.13"
|
||||
|| migration.grappling?.owner_prediction_payload != "iliketomoveit:s2c/grapple_state"
|
||||
|| migration.compatibility?.iliketomoveit_source_network_protocol != 4
|
||||
@@ -402,7 +410,7 @@ tasks.register("verifyWaystoneDescendingRelease") {
|
||||
"src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/waystone/WaystoneElevatorRules.java").text
|
||||
def teleportation = file(
|
||||
"src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/network/WaystoneTeleportation.java").text
|
||||
if (project.version.toString() != "0.0.0-alpha.15"
|
||||
if (project.version.toString() != "0.0.0-alpha.16"
|
||||
|| migration.target?.modules?.iliketomoveit != "0.0.0-alpha.14"
|
||||
|| migration.waystone_elevator?.undiscovered_downward_destination_allowed != true
|
||||
|| migration.waystone_elevator?.destination_discovered_after_arrival != true
|
||||
@@ -418,6 +426,44 @@ tasks.register("verifyWaystoneDescendingRelease") {
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register("verifyAlphaPortal") {
|
||||
group = "verification"
|
||||
description = "Checks the alpha.16 mossy Alpha portal lifecycle and server authority."
|
||||
inputs.file(file("PORTALS.md"))
|
||||
inputs.file(file("src/main/resources/data/iliketomoveit/sanctuary_portal/alpha.json"))
|
||||
inputs.files(fileTree("src/main/resources/assets/iliketomoveit/lang") { include "*.json" })
|
||||
inputs.files(fileTree("src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/portal") { include "*.java" })
|
||||
inputs.file(rootProject.file("pack/migrations/26.2.0-alpha.218-alpha-indev-to-alpha.219.json"))
|
||||
doLast {
|
||||
def route = new JsonSlurper().parse(file(
|
||||
"src/main/resources/data/iliketomoveit/sanctuary_portal/alpha.json"))
|
||||
def migration = new JsonSlurper().parse(rootProject.file(
|
||||
"pack/migrations/26.2.0-alpha.218-alpha-indev-to-alpha.219.json"))
|
||||
def javaText = fileTree("src/main/java/fr/koka99cab/sanctuary26/iliketomoveit/portal")
|
||||
.matching { include "*.java" }.files*.text.join("\n")
|
||||
def locales = ["fr_fr", "en_us", "ru_ru"].collect {
|
||||
new JsonSlurper().parse(file("src/main/resources/assets/iliketomoveit/lang/${it}.json"))
|
||||
}
|
||||
if (project.version.toString() != "0.0.0-alpha.16"
|
||||
|| route.schema_version != 2 || route.source_dimension != "minecraft:overworld"
|
||||
|| route.destination_dimension != "sanctuary:alpha"
|
||||
|| route.destination_resolver != "fixed"
|
||||
|| route.frame_blocks != ["minecraft:mossy_cobblestone"]
|
||||
|| route.activation_items != ["minecraft:flint_and_steel"]
|
||||
|| migration.alpha_portal?.outer_width != 4 || migration.alpha_portal?.outer_height != 5
|
||||
|| migration.alpha_portal?.inner_width != 2 || migration.alpha_portal?.inner_height != 3
|
||||
|| migration.alpha_portal?.inactive_when_destination_absent != true
|
||||
|| migration.alpha_portal?.persistent_pair_reused != true
|
||||
|| migration.alpha_portal?.break_unlinks_pair != true
|
||||
|| !javaText.contains("ACTIVE_ROUTES = List.of(OVERWORLD, CAVERNS, ALPHA)")
|
||||
|| !javaText.contains("SanctuaryAlphaWorld.portalArrival(targetLevel.getSeed())")
|
||||
|| !javaText.contains("getServer().getLevel(")
|
||||
|| locales.any { !it.containsKey("message.iliketomoveit.portal.destination_unavailable") }) {
|
||||
throw new GradleException("I Like To Move It alpha.16 Alpha portal contract is incomplete")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.named("check") {
|
||||
dependsOn tasks.named("grapplePhysicsSmoke")
|
||||
dependsOn tasks.named("waystoneElevatorRulesSmoke")
|
||||
@@ -428,4 +474,6 @@ tasks.named("check") {
|
||||
dependsOn tasks.named("verifyWaystoneDescendingRelease")
|
||||
dependsOn tasks.named("verifyPortals")
|
||||
dependsOn tasks.named("verifySilverAircraft")
|
||||
dependsOn tasks.named("alphaPortalContractSmoke")
|
||||
dependsOn tasks.named("verifyAlphaPortal")
|
||||
}
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ public final class PortalDefinitions {
|
||||
public static final Identifier LAPUTA = ILikeToMoveItMod.id("laputa");
|
||||
|
||||
/** Routes enabled for the current gameplay milestone. Other stable IDs stay reserved. */
|
||||
public static final List<Identifier> ACTIVE_ROUTES = List.of(OVERWORLD, CAVERNS);
|
||||
public static final List<Identifier> ACTIVE_ROUTES = List.of(OVERWORLD, CAVERNS, ALPHA);
|
||||
public static final List<Identifier> REQUIRED_ROUTES = ACTIVE_ROUTES;
|
||||
|
||||
private static volatile Map<Identifier, PortalDefinition> definitions = Map.of();
|
||||
|
||||
+7
@@ -4,6 +4,7 @@ import fr.koka99cab.sanctuary26.iliketomoveit.registry.ILikeToMoveItBlocks;
|
||||
import java.util.Optional;
|
||||
import net.fabricmc.fabric.api.event.player.UseBlockCallback;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.sounds.SoundEvents;
|
||||
import net.minecraft.sounds.SoundSource;
|
||||
@@ -37,6 +38,12 @@ public final class PortalIgnitionHandler {
|
||||
}
|
||||
|
||||
ServerLevel serverLevel = (ServerLevel) level;
|
||||
if (serverLevel.getServer().getLevel(
|
||||
selected.targetDimension(serverLevel.getServer(), serverLevel.dimension())) == null) {
|
||||
player.sendOverlayMessage(Component.translatable(
|
||||
"message.iliketomoveit.portal.destination_unavailable"));
|
||||
return InteractionResult.FAIL;
|
||||
}
|
||||
PortalEndpoint endpoint = PortalSavedData.get(serverLevel.getServer())
|
||||
.register(serverLevel, selectedFrame, selected.id());
|
||||
selectedFrame.fill(serverLevel, selected, endpoint, ILikeToMoveItBlocks.PORTAL);
|
||||
|
||||
+5
@@ -3,6 +3,8 @@ package fr.koka99cab.sanctuary26.iliketomoveit.portal;
|
||||
import fr.koka99cab.sanctuary26.iliketomoveit.ILikeToMoveItMod;
|
||||
import fr.koka99cab.sanctuary26.iliketomoveit.block.PortalBlock;
|
||||
import fr.koka99cab.sanctuary26.iliketomoveit.registry.ILikeToMoveItBlocks;
|
||||
import fr.koka99cab.sanctuary26.sanctuary.api.world.SanctuaryAlphaWorld;
|
||||
import fr.koka99cab.sanctuary26.sanctuary.world.SanctuaryDimensions;
|
||||
import java.util.Optional;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
@@ -92,6 +94,9 @@ public final class PortalTeleporter {
|
||||
|
||||
private static BlockPos targetCenter(ServerLevel sourceLevel, ServerLevel targetLevel,
|
||||
PortalFrame sourceFrame, PortalDefinition definition) {
|
||||
if (targetLevel.dimension().equals(SanctuaryDimensions.ALPHA)) {
|
||||
return SanctuaryAlphaWorld.portalArrival(targetLevel.getSeed());
|
||||
}
|
||||
double scale = definition.scaleFrom(sourceLevel.dimension());
|
||||
int x = Mth.floor(sourceFrame.center().getX() * scale);
|
||||
int z = Mth.floor(sourceFrame.center().getZ() * scale);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"schema_version": 2,
|
||||
"source_dimension": "minecraft:overworld",
|
||||
"destination_dimension": "sanctuary:alpha",
|
||||
"destination_resolver": "fixed",
|
||||
"frame_blocks": ["minecraft:mossy_cobblestone"],
|
||||
"preferred_frame_block": "minecraft:mossy_cobblestone",
|
||||
"activation_items": ["minecraft:flint_and_steel"],
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package fr.koka99cab.sanctuary26.iliketomoveit.portal;
|
||||
|
||||
import fr.koka99cab.sanctuary26.sanctuary.api.world.SanctuaryAlphaWorld;
|
||||
import java.util.Set;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
|
||||
/** Executable cross-module contract for the active mossy Alpha portal. */
|
||||
public final class AlphaPortalContractSmoke {
|
||||
private AlphaPortalContractSmoke() {
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
SharedConstants.tryDetectVersion();
|
||||
Bootstrap.bootStrap();
|
||||
require(PortalDefinitions.ACTIVE_ROUTES.equals(PortalDefinitions.REQUIRED_ROUTES),
|
||||
"Every active portal definition must be required at reload");
|
||||
require(Set.copyOf(PortalDefinitions.ACTIVE_ROUTES).equals(Set.of(
|
||||
PortalDefinitions.OVERWORLD, PortalDefinitions.CAVERNS, PortalDefinitions.ALPHA)),
|
||||
"Exactly the Overworld, Caverns and Alpha routes must be active");
|
||||
|
||||
for (long seed : new long[] { 0L, 1L, 46L }) {
|
||||
BlockPos arrival = SanctuaryAlphaWorld.portalArrival(seed);
|
||||
BlockPos residence = SanctuaryAlphaWorld.residence(seed);
|
||||
require(SanctuaryAlphaWorld.containsHorizontal(arrival),
|
||||
"Server-selected Alpha arrival must stay inside the finite island");
|
||||
require(Math.max(Math.abs(arrival.getX() - residence.getX()),
|
||||
Math.abs(arrival.getZ() - residence.getZ())) >= 10,
|
||||
"The remote portal must not damage the Indev residence");
|
||||
}
|
||||
}
|
||||
|
||||
private static void require(boolean condition, String message) {
|
||||
if (!condition) throw new AssertionError(message);
|
||||
}
|
||||
}
|
||||
@@ -20,3 +20,20 @@ ou à la déconnexion.
|
||||
Le bloc `abjection:shit_cube` reprend les propriétés de la boue, l’apparence 16×16, la recette
|
||||
neuf boules, le butin et l’outil pelle historiques. Les textes sont disponibles en français,
|
||||
anglais et russe. Only Fun reste en données v2 et protocole réseau 2 pour ABJ-01.
|
||||
|
||||
## ABJ-02 — projectile Ouch
|
||||
|
||||
Only Fun expose l’objet et le point d’installation `AbjectionProjectileApi`. Ouch, qui dépend
|
||||
explicitement d’Only Fun sans cycle inverse, enregistre le type d’entité sous le même ID historique
|
||||
`abjection:shit_ball` et installe la fabrique commune au lancer manuel et au dispenser. Le
|
||||
projectile mesure 0,25 bloc, consomme une unité hors créatif, joue le son de boue à l’impact et
|
||||
ajoute sept secondes à la nausée déjà présente sans dupliquer d’objet.
|
||||
|
||||
## ABJ-03 — raccourci configurable
|
||||
|
||||
La catégorie de contrôles Only Fun expose « Chier », assigné à `J` par défaut. Le client utilise
|
||||
`consumeClick`, puis envoie un payload vide `onlyfun:c2s/shit_action`. Le serveur accepte au plus
|
||||
une demande par joueur et par tick, revalide que le joueur est toujours connecté, vivant et non
|
||||
spectateur, puis appelle le même `AbjectionActions.start(SHIT)` que `/shit`. Maintenir la touche
|
||||
ne produit donc pas de répétition par tick et la commande reste disponible. Only Fun conserve ses
|
||||
données v2 et passe son protocole réseau de 2 à 3.
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ public final class AbjectionRegistries {
|
||||
public static final Block SHIT_CUBE = registerBlock("shit_cube", Block::new,
|
||||
BlockBehaviour.Properties.ofFullCopy(Blocks.MUD));
|
||||
public static final Item SHIT_BALL = registerItem("shit_ball",
|
||||
properties -> new Item(properties.stacksTo(16).useItemDescriptionPrefix()));
|
||||
properties -> new ShitBallItem(properties.stacksTo(16).useItemDescriptionPrefix()));
|
||||
public static final Item SHIT_CUBE_ITEM = registerBlockItem("shit_cube", SHIT_CUBE);
|
||||
|
||||
private AbjectionRegistries() {
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package fr.koka99cab.sanctuary26.onlyfun.abjection;
|
||||
|
||||
import fr.koka99cab.sanctuary26.onlyfun.api.abjection.AbjectionActions;
|
||||
import fr.koka99cab.sanctuary26.onlyfun.network.ShitActionRequestPayload;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
|
||||
import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
|
||||
/** Validates connection/player state and deduplicates malicious same-tick key requests. */
|
||||
public final class ShitActionRequestHandler {
|
||||
private static final Map<UUID, Integer> LAST_REQUEST_TICK = new HashMap<>();
|
||||
|
||||
private ShitActionRequestHandler() {
|
||||
}
|
||||
|
||||
public static void register() {
|
||||
ServerPlayNetworking.registerGlobalReceiver(ShitActionRequestPayload.ID,
|
||||
(payload, context) -> handle(context.player()));
|
||||
ServerPlayConnectionEvents.DISCONNECT.register((handler, server) ->
|
||||
clear(handler.player.getUUID()));
|
||||
}
|
||||
|
||||
private static void handle(ServerPlayer player) {
|
||||
MinecraftServer server = player.level().getServer();
|
||||
if (server == null || server.getPlayerList().getPlayer(player.getUUID()) != player
|
||||
|| !player.isAlive() || player.isSpectator()
|
||||
|| !acceptTick(player.getUUID(), server.getTickCount())) return;
|
||||
AbjectionActions.start(player, AbjectionActions.Action.SHIT);
|
||||
}
|
||||
|
||||
static boolean acceptTick(UUID playerId, int tick) {
|
||||
Integer previous = LAST_REQUEST_TICK.put(playerId, tick);
|
||||
return previous == null || previous != tick;
|
||||
}
|
||||
|
||||
static void clear(UUID playerId) {
|
||||
LAST_REQUEST_TICK.remove(playerId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package fr.koka99cab.sanctuary26.onlyfun.abjection;
|
||||
|
||||
import fr.koka99cab.sanctuary26.onlyfun.api.abjection.AbjectionProjectileApi;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.core.Position;
|
||||
import net.minecraft.world.entity.projectile.Projectile;
|
||||
import net.minecraft.world.item.Item;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.item.ProjectileItem;
|
||||
import net.minecraft.world.level.Level;
|
||||
|
||||
/** Historical item identity with a projectile contract implemented by the owning Ouch module. */
|
||||
public final class ShitBallItem extends Item implements ProjectileItem {
|
||||
public ShitBallItem(Properties properties) {
|
||||
super(properties);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Projectile asProjectile(Level level, Position position, ItemStack stack, Direction direction) {
|
||||
return AbjectionProjectileApi.create(level, position, stack, direction);
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package fr.koka99cab.sanctuary26.onlyfun.api.abjection;
|
||||
|
||||
import fr.koka99cab.sanctuary26.onlyfun.abjection.AbjectionRegistries;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.world.item.Item;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
|
||||
/** Minimal public object contract consumed by Ouch's Abjection projectile implementation. */
|
||||
public final class AbjectionItems {
|
||||
private AbjectionItems() {
|
||||
}
|
||||
|
||||
public static Item shitBall() {
|
||||
return AbjectionRegistries.SHIT_BALL;
|
||||
}
|
||||
|
||||
public static Identifier shitBallId() {
|
||||
return Identifier.fromNamespaceAndPath("abjection", "shit_ball");
|
||||
}
|
||||
|
||||
public static boolean isShitBall(ItemStack stack) {
|
||||
return stack.is(AbjectionRegistries.SHIT_BALL);
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package fr.koka99cab.sanctuary26.onlyfun.api.abjection;
|
||||
|
||||
import java.util.Objects;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.core.Position;
|
||||
import net.minecraft.world.entity.projectile.Projectile;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.level.Level;
|
||||
|
||||
/**
|
||||
* Owner-neutral projectile factory contract: Only Fun owns the item, while Ouch installs its
|
||||
* combat implementation without creating a reverse dependency.
|
||||
*/
|
||||
public final class AbjectionProjectileApi {
|
||||
@FunctionalInterface
|
||||
public interface Factory {
|
||||
Projectile create(Level level, Position position, ItemStack stack, Direction direction);
|
||||
}
|
||||
|
||||
private static Factory factory;
|
||||
|
||||
private AbjectionProjectileApi() {
|
||||
}
|
||||
|
||||
public static void registerFactory(Factory implementation) {
|
||||
Objects.requireNonNull(implementation, "implementation");
|
||||
if (factory != null) throw new IllegalStateException("Abjection projectile factory already registered");
|
||||
factory = implementation;
|
||||
}
|
||||
|
||||
public static Projectile create(Level level, Position position, ItemStack stack, Direction direction) {
|
||||
if (factory == null) throw new IllegalStateException("Ouch has not registered the Abjection projectile factory");
|
||||
return factory.create(level, position, stack, direction);
|
||||
}
|
||||
|
||||
public static boolean isReady() {
|
||||
return factory != null;
|
||||
}
|
||||
}
|
||||
@@ -3,17 +3,34 @@ package fr.koka99cab.sanctuary26.onlyfun.client;
|
||||
import fr.koka99cab.sanctuary26.iliketomoveit.physics.VehicleFlightPhysics;
|
||||
import fr.koka99cab.sanctuary26.onlyfun.chickenboat.ChickenBoatControlPayload;
|
||||
import fr.koka99cab.sanctuary26.onlyfun.chickenboat.ChickenBoatUtil;
|
||||
import fr.koka99cab.sanctuary26.onlyfun.OnlyFunMod;
|
||||
import fr.koka99cab.sanctuary26.onlyfun.network.ShitActionRequestPayload;
|
||||
import com.mojang.blaze3d.platform.InputConstants;
|
||||
import net.fabricmc.api.ClientModInitializer;
|
||||
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
|
||||
import net.fabricmc.fabric.api.client.keymapping.v1.KeyMappingHelper;
|
||||
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
|
||||
import net.minecraft.client.KeyMapping;
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import net.minecraft.world.entity.vehicle.boat.AbstractBoat;
|
||||
import org.lwjgl.glfw.GLFW;
|
||||
|
||||
public final class OnlyFunClient implements ClientModInitializer {
|
||||
private static final KeyMapping.Category ONLY_FUN_CATEGORY =
|
||||
KeyMapping.Category.register(OnlyFunMod.id("onlyfun"));
|
||||
private static KeyMapping shitKey;
|
||||
|
||||
@Override
|
||||
public void onInitializeClient() {
|
||||
shitKey = KeyMappingHelper.registerKeyMapping(new KeyMapping(
|
||||
"key.onlyfun.shit", InputConstants.Type.KEYSYM, GLFW.GLFW_KEY_J, ONLY_FUN_CATEGORY));
|
||||
ClientTickEvents.END_CLIENT_TICK.register(client -> {
|
||||
if (client.player == null || client.level == null) return;
|
||||
while (shitKey.consumeClick()) {
|
||||
if (ClientPlayNetworking.canSend(ShitActionRequestPayload.ID)) {
|
||||
ClientPlayNetworking.send(new ShitActionRequestPayload());
|
||||
}
|
||||
}
|
||||
Entity vehicle = client.player.getVehicle();
|
||||
if (!(vehicle instanceof AbstractBoat boat) || !ChickenBoatUtil.hasChickenPassenger(boat)) return;
|
||||
if (!ClientPlayNetworking.canSend(ChickenBoatControlPayload.ID)) return;
|
||||
|
||||
+3
-1
@@ -9,7 +9,7 @@ import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry;
|
||||
* Networking contract for Only Fun. Payload IDs must use the module namespace.
|
||||
*/
|
||||
public final class OnlyFunNetworking {
|
||||
public static final int PROTOCOL_VERSION = 2;
|
||||
public static final int PROTOCOL_VERSION = 3;
|
||||
private static boolean initialized;
|
||||
|
||||
private OnlyFunNetworking() {
|
||||
@@ -21,7 +21,9 @@ public final class OnlyFunNetworking {
|
||||
}
|
||||
initialized = true;
|
||||
PayloadTypeRegistry.serverboundPlay().register(ChickenBoatControlPayload.ID, ChickenBoatControlPayload.CODEC);
|
||||
PayloadTypeRegistry.serverboundPlay().register(ShitActionRequestPayload.ID, ShitActionRequestPayload.CODEC);
|
||||
ChickenBoatServerController.register();
|
||||
fr.koka99cab.sanctuary26.onlyfun.abjection.ShitActionRequestHandler.register();
|
||||
OnlyFunMod.LOGGER.debug("[{}] Networking contract v{} ready; chicken boat controls registered.",
|
||||
OnlyFunMod.DISPLAY_NAME, PROTOCOL_VERSION);
|
||||
}
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package fr.koka99cab.sanctuary26.onlyfun.network;
|
||||
|
||||
import fr.koka99cab.sanctuary26.onlyfun.OnlyFunMod;
|
||||
import net.minecraft.network.RegistryFriendlyByteBuf;
|
||||
import net.minecraft.network.codec.StreamCodec;
|
||||
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
|
||||
|
||||
/** Empty and therefore strictly bounded request to start the existing server-owned action. */
|
||||
public record ShitActionRequestPayload() implements CustomPacketPayload {
|
||||
public static final Type<ShitActionRequestPayload> ID =
|
||||
new Type<>(OnlyFunMod.id("c2s/shit_action"));
|
||||
public static final StreamCodec<RegistryFriendlyByteBuf, ShitActionRequestPayload> CODEC =
|
||||
CustomPacketPayload.codec((payload, buffer) -> {}, buffer -> new ShitActionRequestPayload());
|
||||
|
||||
@Override
|
||||
public Type<? extends CustomPacketPayload> type() {
|
||||
return ID;
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,7 @@ public final class OnlyFunItemPolicy {
|
||||
if (namespace.equals("canaplia") || namespace.equals("itsalive")) return true;
|
||||
if (namespace.equals("minecraft") && VANILLA_SHOP_GAMEPLAY_BYPASSES.contains(path)) return true;
|
||||
if (namespace.equals("anotherworld") && isProtectedAnotherWorldItem(path)) return true;
|
||||
if (isStorageTerminal(namespace, path)) return true;
|
||||
if (TECHNICAL_OR_REWARD_BLOCKS.contains(path) || path.startsWith("infested_")
|
||||
|| path.contains("command_block")) return true;
|
||||
if (path.contains("lootbox") || path.contains("loot_box")) return true;
|
||||
@@ -61,6 +62,7 @@ public final class OnlyFunItemPolicy {
|
||||
String namespace = id.getNamespace().toLowerCase(Locale.ROOT);
|
||||
String path = normalizedPath(id);
|
||||
return namespace.equals("canaplia") || path.contains("lootbox") || path.contains("loot_box")
|
||||
|| isStorageTerminal(namespace, path)
|
||||
|| (namespace.equals("onlyfun") && path.equals("lucky_block"))
|
||||
|| (namespace.equals("sanctuary") && path.equals("delivery_box"))
|
||||
|| TECHNICAL_OR_REWARD_BLOCKS.contains(path) || path.startsWith("infested_")
|
||||
@@ -77,6 +79,10 @@ public final class OnlyFunItemPolicy {
|
||||
|| path.contains("titanium") || path.contains("rainbow_block");
|
||||
}
|
||||
|
||||
private static boolean isStorageTerminal(String namespace, String path) {
|
||||
return namespace.equals("redstoner") && path.equals("storage_terminal");
|
||||
}
|
||||
|
||||
private static String normalizedPath(Identifier id) {
|
||||
return id.getPath().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
@@ -41,5 +41,7 @@
|
||||
"command.onlyfun.history": "Rolls: %s | dry streak: %s | anomalies: %s | last: %s",
|
||||
"command.onlyfun.give": "Granted Only Fun source: %s",
|
||||
"command.onlyfun.roll": "Outcome: %s (%s)",
|
||||
"command.onlyfun.simulate": "Simulation %s × %s: %s"
|
||||
"command.onlyfun.simulate": "Simulation %s × %s: %s",
|
||||
"key.categories.onlyfun.onlyfun": "Only Fun",
|
||||
"key.onlyfun.shit": "Shit"
|
||||
}
|
||||
|
||||
@@ -41,5 +41,7 @@
|
||||
"command.onlyfun.history": "Tirages : %s | série sèche : %s | anomalies : %s | dernier : %s",
|
||||
"command.onlyfun.give": "Source Only Fun donnée : %s",
|
||||
"command.onlyfun.roll": "Résultat : %s (%s)",
|
||||
"command.onlyfun.simulate": "Simulation %s × %s : %s"
|
||||
"command.onlyfun.simulate": "Simulation %s × %s : %s",
|
||||
"key.categories.onlyfun.onlyfun": "Only Fun",
|
||||
"key.onlyfun.shit": "Chier"
|
||||
}
|
||||
|
||||
@@ -41,5 +41,7 @@
|
||||
"command.onlyfun.history": "Розыгрыши: %s | серия: %s | аномалии: %s | последнее: %s",
|
||||
"command.onlyfun.give": "Выдан источник Only Fun: %s",
|
||||
"command.onlyfun.roll": "Результат: %s (%s)",
|
||||
"command.onlyfun.simulate": "Симуляция %s × %s: %s"
|
||||
"command.onlyfun.simulate": "Симуляция %s × %s: %s",
|
||||
"key.categories.onlyfun.onlyfun": "Only Fun",
|
||||
"key.onlyfun.shit": "Испражниться"
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"system_type": "gameplay-system",
|
||||
"namespace": "onlyfun",
|
||||
"data_version": 2,
|
||||
"network_protocol": 2,
|
||||
"network_protocol": 3,
|
||||
"lifecycle": "${lifecycle}",
|
||||
"pack_version": "${pack_version}",
|
||||
"descriptions": {
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package fr.koka99cab.sanctuary26.onlyfun.abjection;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/** Executable check for one physical press and same-tick spam deduplication. */
|
||||
public final class ShitActionRequestSmoke {
|
||||
private ShitActionRequestSmoke() {
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
UUID playerId = UUID.fromString("00000000-0000-0000-0000-000000000059");
|
||||
require(ShitActionRequestHandler.acceptTick(playerId, 100), "first press was rejected");
|
||||
require(!ShitActionRequestHandler.acceptTick(playerId, 100), "same-tick spam was accepted");
|
||||
require(ShitActionRequestHandler.acceptTick(playerId, 101), "next physical press was rejected");
|
||||
ShitActionRequestHandler.clear(playerId);
|
||||
require(ShitActionRequestHandler.acceptTick(playerId, 101), "disconnect cleanup retained input state");
|
||||
ShitActionRequestHandler.clear(playerId);
|
||||
}
|
||||
|
||||
private static void require(boolean condition, String message) {
|
||||
if (!condition) throw new AssertionError(message);
|
||||
}
|
||||
}
|
||||
+7
@@ -81,6 +81,13 @@ public final class OnlyFunShopCatalogSmoke {
|
||||
require(!OnlyFunItemPolicy.excludedFromShop(Identifier.parse(rawFoodId)),
|
||||
"Raw ingredient was removed with finished food: " + rawFoodId);
|
||||
}
|
||||
Identifier storageTerminal = Identifier.parse("redstoner:storage_terminal");
|
||||
require(OnlyFunItemPolicy.excludedFromShop(storageTerminal),
|
||||
"Storage Terminal reached the Shop policy");
|
||||
require(OnlyFunItemPolicy.excludedFromRewards(storageTerminal),
|
||||
"Storage Terminal reached lootbox rewards");
|
||||
require(find(all, storageTerminal.toString()) == null,
|
||||
"Storage Terminal reached the generated Shop catalogue");
|
||||
require(OnlyFunBossShopGate.requiresBoss("iliketomoveit:magic_carpet"),
|
||||
"Magic Carpet must remain boss-gated");
|
||||
require(OnlyFunBossShopGate.requiresBoss("iliketomoveit:magic_cloud"),
|
||||
|
||||
+45
-3
@@ -12,9 +12,10 @@ def miningRifleTextureHash = "4e4295c5c8a8f71d03307809448ff64363eec2d2aefdc538c4
|
||||
|
||||
tasks.register("verifyOuch") {
|
||||
group = "verification"
|
||||
description = "Checks Ouch alpha.9, including the Ambiance-backed Weather TNT and migrated Gunz arsenal."
|
||||
description = "Checks Ouch alpha.10, including the shared Abjection projectile implementation."
|
||||
inputs.files(fileTree("src/main/java"))
|
||||
inputs.files(fileTree("src/main/resources"))
|
||||
inputs.files(rootProject.fileTree("onlyfun/src/main/java"))
|
||||
inputs.file(rootProject.file("pack/migrations/26.1.2-gunz-to-26.2.0-alpha.41.json"))
|
||||
inputs.file(rootProject.file("pack/migrations/26.2.0-alpha.99-canon-weather-ownership-to-alpha.100.json"))
|
||||
inputs.file(rootProject.file("pack/migrations/26.2.0-alpha.184-silver-engineering-to-alpha.185.json"))
|
||||
@@ -25,6 +26,34 @@ tasks.register("verifyOuch") {
|
||||
def weatherRegistry = file("src/main/java/fr/koka99cab/sanctuary26/ouch/registry/OuchWeatherTnt.java").text
|
||||
def weatherEntity = file("src/main/java/fr/koka99cab/sanctuary26/ouch/entity/WeatherTntEntity.java").text
|
||||
def manifest = new JsonSlurper().parse(file("src/main/resources/fabric.mod.json"))
|
||||
def abjectionRegistry = file(
|
||||
"src/main/java/fr/koka99cab/sanctuary26/ouch/registry/OuchAbjectionProjectiles.java").text
|
||||
def abjectionEntity = file(
|
||||
"src/main/java/fr/koka99cab/sanctuary26/ouch/entity/ShitBallEntity.java").text
|
||||
def abjectionClient = file(
|
||||
"src/main/java/fr/koka99cab/sanctuary26/ouch/client/OuchClient.java").text
|
||||
def onlyFunProjectileApi = rootProject.file(
|
||||
"onlyfun/src/main/java/fr/koka99cab/sanctuary26/onlyfun/api/abjection/AbjectionProjectileApi.java").text
|
||||
if (manifest.depends?.onlyfun != ">=0.0.0-alpha.23"
|
||||
|| manifest.custom?.sanctuary26?.data_version != 1
|
||||
|| manifest.custom?.sanctuary26?.network_protocol != 1
|
||||
|| !rootProject.file("build.gradle").text.contains('implementation project(":onlyfun")')
|
||||
|| !onlyFunProjectileApi.contains("public interface Factory")
|
||||
|| !abjectionRegistry.contains('AbjectionProjectileApi.registerFactory(')
|
||||
|| !abjectionRegistry.contains('AbjectionItems.shitBallId()')
|
||||
|| abjectionRegistry.contains('AbjectionRegistries')
|
||||
|| !abjectionRegistry.contains('DispenserBlock.registerProjectileBehavior(AbjectionItems.shitBall())')
|
||||
|| !abjectionRegistry.contains('UseItemCallback.EVENT.register')
|
||||
|| !abjectionRegistry.contains('stack.consume(1, player)')
|
||||
|| !abjectionRegistry.contains('Projectile.spawnProjectileFromRotation(')
|
||||
|| !abjectionRegistry.contains('.sized(0.25F, 0.25F)')
|
||||
|| !abjectionEntity.contains('NAUSEA_DURATION_TICKS = 7 * 20')
|
||||
|| !abjectionEntity.contains('current == null ? 0 : current.getDuration()')
|
||||
|| !abjectionEntity.contains('SoundEvents.MUD_PLACE')
|
||||
|| !abjectionEntity.contains('discard()')
|
||||
|| !abjectionClient.contains('new ThrownItemRenderer<>(context)')) {
|
||||
throw new GradleException("Ouch alpha.10 Abjection projectile contract is incomplete")
|
||||
}
|
||||
if (!itemRegistry.contains("MIGRATED_WEAPONS = List.of(CREEPERLOCK, MITRAILLETTE, EXPULSEUR, ASPIRATEUR)")
|
||||
|| !itemRegistry.contains("WEAPONS = List.of(CREEPERLOCK, MITRAILLETTE, EXPULSEUR, ASPIRATEUR, MINING_RIFLE, BETA_BOW)")
|
||||
|| !creativeTab.contains('OuchMod.id("arsenal")')
|
||||
@@ -89,7 +118,7 @@ tasks.register("verifyOuch") {
|
||||
def creeperlockRecipe = new JsonSlurper().parse(file("src/main/resources/data/ouch/recipe/creeperlock.json"))
|
||||
def silverEngineeringMigration = new JsonSlurper().parse(rootProject.file(
|
||||
"pack/migrations/26.2.0-alpha.184-silver-engineering-to-alpha.185.json"))
|
||||
if (project.version.toString() != "0.0.0-alpha.9"
|
||||
if (project.version.toString() != "0.0.0-alpha.10"
|
||||
|| mitrailletteRecipe.pattern != ["RRP", "IGI", " B "]
|
||||
|| mitrailletteRecipe.key != [B: "minecraft:stone_button", G: "minecraft:redstone",
|
||||
I: "anotherworld:silver_ingot", P: "anotherworld:silver_plate", R: "anotherworld:silver_rod"]
|
||||
@@ -317,10 +346,23 @@ tasks.register("verifyOuch") {
|
||||
throw new GradleException("Runtime Ouch resources must not re-register the legacy gunz namespace: ${forbiddenNamespaceReferences}")
|
||||
}
|
||||
|
||||
logger.lifecycle("Ouch verified: 6 enchantable items, 4 custom enchantments, debuffed bases, shared headshots, 6 recipes and FR/EN/RU translations.")
|
||||
logger.lifecycle("Ouch verified: arsenal, Weather TNT and shared Abjection projectile contract.")
|
||||
}
|
||||
}
|
||||
|
||||
tasks.named("test") {
|
||||
failOnNoDiscoveredTests = false
|
||||
}
|
||||
|
||||
tasks.register("shitBallContractSmoke", JavaExec) {
|
||||
group = "verification"
|
||||
description = "Checks additive and overflow-safe Shit Ball nausea."
|
||||
dependsOn tasks.named("testClasses")
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = "fr.koka99cab.sanctuary26.ouch.entity.ShitBallContractSmoke"
|
||||
}
|
||||
|
||||
tasks.named("check") {
|
||||
dependsOn tasks.named("shitBallContractSmoke")
|
||||
dependsOn tasks.named("verifyOuch")
|
||||
}
|
||||
|
||||
@@ -2,16 +2,20 @@ package fr.koka99cab.sanctuary26.ouch.client;
|
||||
|
||||
import fr.koka99cab.sanctuary26.ouch.OuchMod;
|
||||
import fr.koka99cab.sanctuary26.ouch.entity.WeatherTntEntity;
|
||||
import fr.koka99cab.sanctuary26.ouch.registry.OuchAbjectionProjectiles;
|
||||
import fr.koka99cab.sanctuary26.ouch.registry.OuchWeatherTnt;
|
||||
import net.fabricmc.api.ClientModInitializer;
|
||||
import net.fabricmc.fabric.api.client.rendering.v1.EntityRendererRegistry;
|
||||
import net.minecraft.client.renderer.entity.EntityRenderer;
|
||||
import net.minecraft.client.renderer.entity.TntRenderer;
|
||||
import net.minecraft.client.renderer.entity.ThrownItemRenderer;
|
||||
import net.minecraft.world.entity.EntityType;
|
||||
|
||||
public final class OuchClient implements ClientModInitializer {
|
||||
@Override
|
||||
public void onInitializeClient() {
|
||||
EntityRendererRegistry.register(OuchAbjectionProjectiles.ENTITY,
|
||||
context -> new ThrownItemRenderer<>(context));
|
||||
registerWeatherRenderer(OuchWeatherTnt.ENTITY);
|
||||
registerWeatherRenderer(OuchWeatherTnt.REDSTONER_COMPAT_ENTITY);
|
||||
registerWeatherRenderer(OuchWeatherTnt.LEGACY_ENTITY);
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package fr.koka99cab.sanctuary26.ouch.entity;
|
||||
|
||||
import fr.koka99cab.sanctuary26.onlyfun.api.abjection.AbjectionItems;
|
||||
import fr.koka99cab.sanctuary26.ouch.registry.OuchAbjectionProjectiles;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.sounds.SoundEvents;
|
||||
import net.minecraft.sounds.SoundSource;
|
||||
import net.minecraft.world.effect.MobEffectInstance;
|
||||
import net.minecraft.world.effect.MobEffects;
|
||||
import net.minecraft.world.entity.EntityType;
|
||||
import net.minecraft.world.entity.LivingEntity;
|
||||
import net.minecraft.world.entity.projectile.throwableitemprojectile.ThrowableItemProjectile;
|
||||
import net.minecraft.world.item.Item;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.minecraft.world.phys.EntityHitResult;
|
||||
import net.minecraft.world.phys.HitResult;
|
||||
|
||||
/** Server-authoritative historical Abjection projectile, owned by Ouch. */
|
||||
public final class ShitBallEntity extends ThrowableItemProjectile {
|
||||
public static final int NAUSEA_DURATION_TICKS = 7 * 20;
|
||||
|
||||
public ShitBallEntity(EntityType<? extends ShitBallEntity> type, Level level) {
|
||||
super(type, level);
|
||||
}
|
||||
|
||||
public ShitBallEntity(Level level, LivingEntity owner, ItemStack stack) {
|
||||
super(OuchAbjectionProjectiles.ENTITY, owner, level, stack.copyWithCount(1));
|
||||
}
|
||||
|
||||
public ShitBallEntity(Level level, double x, double y, double z, ItemStack stack) {
|
||||
super(OuchAbjectionProjectiles.ENTITY, x, y, z, level, stack.copyWithCount(1));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Item getDefaultItem() {
|
||||
return AbjectionItems.shitBall();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onHitEntity(EntityHitResult hit) {
|
||||
super.onHitEntity(hit);
|
||||
if (!(level() instanceof ServerLevel) || !(hit.getEntity() instanceof LivingEntity target)) return;
|
||||
MobEffectInstance current = target.getEffect(MobEffects.NAUSEA);
|
||||
int duration = extendedNauseaDuration(current == null ? 0 : current.getDuration());
|
||||
target.addEffect(new MobEffectInstance(MobEffects.NAUSEA, duration), getOwner());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onHit(HitResult hit) {
|
||||
super.onHit(hit);
|
||||
if (level() instanceof ServerLevel level) {
|
||||
level.playSound(null, getX(), getY(), getZ(), SoundEvents.MUD_PLACE,
|
||||
SoundSource.PLAYERS, 1.0F, 0.75F);
|
||||
discard();
|
||||
}
|
||||
}
|
||||
|
||||
static int extendedNauseaDuration(int currentDuration) {
|
||||
return (int) Math.min(Integer.MAX_VALUE, Math.max(0L, currentDuration) + NAUSEA_DURATION_TICKS);
|
||||
}
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
package fr.koka99cab.sanctuary26.ouch.registry;
|
||||
|
||||
import fr.koka99cab.sanctuary26.onlyfun.api.abjection.AbjectionItems;
|
||||
import fr.koka99cab.sanctuary26.onlyfun.api.abjection.AbjectionProjectileApi;
|
||||
import fr.koka99cab.sanctuary26.ouch.entity.ShitBallEntity;
|
||||
import net.fabricmc.fabric.api.event.player.UseItemCallback;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.core.Position;
|
||||
import net.minecraft.core.Registry;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.sounds.SoundEvents;
|
||||
import net.minecraft.sounds.SoundSource;
|
||||
import net.minecraft.stats.Stats;
|
||||
import net.minecraft.world.InteractionResult;
|
||||
import net.minecraft.world.entity.EntityType;
|
||||
import net.minecraft.world.entity.LivingEntity;
|
||||
import net.minecraft.world.entity.MobCategory;
|
||||
import net.minecraft.world.entity.projectile.Projectile;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.minecraft.world.level.block.DispenserBlock;
|
||||
|
||||
/** Shared manual/dispenser creation path for {@code abjection:shit_ball}. */
|
||||
public final class OuchAbjectionProjectiles {
|
||||
private static final float SHOOT_POWER = 1.5F;
|
||||
private static final ResourceKey<EntityType<?>> KEY = ResourceKey.create(
|
||||
Registries.ENTITY_TYPE, AbjectionItems.shitBallId());
|
||||
public static final EntityType<ShitBallEntity> ENTITY = Registry.register(
|
||||
BuiltInRegistries.ENTITY_TYPE, KEY,
|
||||
EntityType.Builder.<ShitBallEntity>of(ShitBallEntity::new, MobCategory.MISC)
|
||||
.sized(0.25F, 0.25F).clientTrackingRange(4).updateInterval(10).build(KEY));
|
||||
private static boolean initialized;
|
||||
|
||||
private OuchAbjectionProjectiles() {
|
||||
}
|
||||
|
||||
public static void initialize() {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
AbjectionProjectileApi.registerFactory(OuchAbjectionProjectiles::createFromDispenser);
|
||||
DispenserBlock.registerProjectileBehavior(AbjectionItems.shitBall());
|
||||
UseItemCallback.EVENT.register((player, level, hand) -> {
|
||||
ItemStack stack = player.getItemInHand(hand);
|
||||
if (!AbjectionItems.isShitBall(stack) || player.isSpectator()) return InteractionResult.PASS;
|
||||
if (level.isClientSide()) return InteractionResult.SUCCESS;
|
||||
if (!(level instanceof ServerLevel serverLevel)) return InteractionResult.FAIL;
|
||||
serverLevel.playSound(null, player.getX(), player.getY(), player.getZ(),
|
||||
SoundEvents.SNOWBALL_THROW, SoundSource.NEUTRAL, 0.5F,
|
||||
0.4F / (serverLevel.getRandom().nextFloat() * 0.4F + 0.8F));
|
||||
Projectile.spawnProjectileFromRotation(OuchAbjectionProjectiles::createFromPlayer,
|
||||
serverLevel, stack, player, 0.0F, SHOOT_POWER, 1.0F);
|
||||
player.awardStat(Stats.ITEM_USED.get(AbjectionItems.shitBall()));
|
||||
stack.consume(1, player);
|
||||
return InteractionResult.SUCCESS_SERVER;
|
||||
});
|
||||
}
|
||||
|
||||
public static ShitBallEntity createFromPlayer(ServerLevel level, LivingEntity owner, ItemStack stack) {
|
||||
return new ShitBallEntity(level, owner, stack);
|
||||
}
|
||||
|
||||
public static Projectile createFromDispenser(Level level, Position position, ItemStack stack,
|
||||
Direction direction) {
|
||||
return new ShitBallEntity(level, position.x(), position.y(), position.z(), stack);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ public final class OuchRegistries {
|
||||
initialized = true;
|
||||
OuchItems.initialize();
|
||||
OuchWeatherTnt.initialize();
|
||||
OuchAbjectionProjectiles.initialize();
|
||||
OuchCreativeTab.initialize();
|
||||
OuchMod.LOGGER.debug("[{}] Weapon registry hub initialized.", OuchMod.DISPLAY_NAME);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"fabric-api": ">=0.154.2+26.2",
|
||||
"anotherworld": ">=0.0.0-alpha.46",
|
||||
"ambiance": ">=0.0.0-alpha.12",
|
||||
"onlyfun": ">=0.0.0-alpha.23",
|
||||
"sanctuary": ">=0.0.0-alpha.110"
|
||||
},
|
||||
"custom": {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package fr.koka99cab.sanctuary26.ouch.entity;
|
||||
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
|
||||
/** Executable regression test for additive and overflow-safe nausea duration. */
|
||||
public final class ShitBallContractSmoke {
|
||||
private ShitBallContractSmoke() {
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
SharedConstants.tryDetectVersion();
|
||||
Bootstrap.bootStrap();
|
||||
require(ShitBallEntity.NAUSEA_DURATION_TICKS == 140, "historical duration changed");
|
||||
require(ShitBallEntity.extendedNauseaDuration(0) == 140, "first hit duration is wrong");
|
||||
require(ShitBallEntity.extendedNauseaDuration(60) == 200,
|
||||
"a new impact did not add seven seconds");
|
||||
require(ShitBallEntity.extendedNauseaDuration(-10) == 140,
|
||||
"negative input was not normalized");
|
||||
require(ShitBallEntity.extendedNauseaDuration(Integer.MAX_VALUE) == Integer.MAX_VALUE,
|
||||
"duration overflow was not saturated");
|
||||
}
|
||||
|
||||
private static void require(boolean condition, String message) {
|
||||
if (!condition) throw new AssertionError(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"source": {
|
||||
"pack_version": "26.2.0-alpha.210",
|
||||
"modules": {
|
||||
"ambiance": "0.0.0-alpha.14",
|
||||
"itsalive": "0.0.0-alpha.35",
|
||||
"onlyfun": "0.0.0-alpha.21",
|
||||
"ouch": "0.0.0-alpha.9",
|
||||
"sanctuary": "0.0.0-alpha.118"
|
||||
}
|
||||
},
|
||||
"target": {
|
||||
"pack_version": "26.2.0-alpha.211",
|
||||
"modules": {
|
||||
"ambiance": "0.0.0-alpha.15",
|
||||
"itsalive": "0.0.0-alpha.36",
|
||||
"onlyfun": "0.0.0-alpha.24",
|
||||
"ouch": "0.0.0-alpha.10",
|
||||
"sanctuary": "0.0.0-alpha.119"
|
||||
}
|
||||
},
|
||||
"features": {
|
||||
"canaplia_codex_uses_vanilla_book_pages": true,
|
||||
"black_market_middle_click_restored": true,
|
||||
"white_disc_youtube_audio": true,
|
||||
"white_disc_max_concurrent_streams": 4,
|
||||
"abjection_server_actions": ["piss", "vomit", "shit", "smoke"],
|
||||
"abjection_shit_ball_projectile": true,
|
||||
"abjection_shit_cube": true,
|
||||
"abjection_default_key": "J",
|
||||
"abjection_projectile_nausea_seconds": 7
|
||||
},
|
||||
"compatibility": {
|
||||
"new_world_required": false,
|
||||
"save_reset_required": false,
|
||||
"existing_saves_supported": true,
|
||||
"registry_ids_added": [
|
||||
"ambiance:white_disc",
|
||||
"abjection:shit_ball",
|
||||
"abjection:shit_cube"
|
||||
],
|
||||
"data_versions": {
|
||||
"ambiance": 2,
|
||||
"itsalive": 3,
|
||||
"onlyfun": 2,
|
||||
"ouch": 1,
|
||||
"sanctuary": 11
|
||||
},
|
||||
"network_protocols": {
|
||||
"ambiance": 6,
|
||||
"itsalive": 4,
|
||||
"onlyfun": 3,
|
||||
"ouch": 1,
|
||||
"sanctuary": 22
|
||||
}
|
||||
},
|
||||
"distribution": {
|
||||
"pack_release_updated": true,
|
||||
"packwiz_index_refreshed": true,
|
||||
"prism_modrinth_index_updated": true,
|
||||
"shared_client_server_mrpack": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"source": {
|
||||
"pack_version": "26.2.0-alpha.211",
|
||||
"modules": {
|
||||
"sanctuary": "0.0.0-alpha.119"
|
||||
}
|
||||
},
|
||||
"target": {
|
||||
"pack_version": "26.2.0-alpha.212",
|
||||
"modules": {
|
||||
"sanctuary": "0.0.0-alpha.120"
|
||||
}
|
||||
},
|
||||
"features": {
|
||||
"black_market_middle_click_preserved": true,
|
||||
"minecraft_mixin_target": "pickBlockOrEntity",
|
||||
"client_startup_crash_fixed": true
|
||||
},
|
||||
"compatibility": {
|
||||
"new_world_required": false,
|
||||
"save_reset_required": false,
|
||||
"existing_saves_supported": true,
|
||||
"registry_ids_changed": false,
|
||||
"sanctuary_data_version": 11,
|
||||
"sanctuary_network_protocol": 22
|
||||
},
|
||||
"distribution": {
|
||||
"pack_release_updated": true,
|
||||
"packwiz_index_refreshed": true,
|
||||
"prism_modrinth_index_updated": true,
|
||||
"shared_client_server_mrpack": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"source": {
|
||||
"pack_version": "26.2.0-alpha.212",
|
||||
"modules": {
|
||||
"ambiance": "0.0.0-alpha.15"
|
||||
}
|
||||
},
|
||||
"target": {
|
||||
"pack_version": "26.2.0-alpha.213",
|
||||
"modules": {
|
||||
"ambiance": "0.0.0-alpha.16"
|
||||
}
|
||||
},
|
||||
"features": {
|
||||
"white_disc_stop_event_preserved": true,
|
||||
"minecraft_mixin_target_class": "LevelEventHandler",
|
||||
"minecraft_mixin_target_method": "levelEvent",
|
||||
"client_startup_crash_fixed": true
|
||||
},
|
||||
"compatibility": {
|
||||
"new_world_required": false,
|
||||
"save_reset_required": false,
|
||||
"existing_saves_supported": true,
|
||||
"registry_ids_changed": false,
|
||||
"ambiance_data_version": 2,
|
||||
"ambiance_network_protocol": 6
|
||||
},
|
||||
"distribution": {
|
||||
"pack_release_updated": true,
|
||||
"packwiz_index_refreshed": true,
|
||||
"prism_modrinth_index_updated": true,
|
||||
"shared_client_server_mrpack": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"source": {
|
||||
"pack_version": "26.2.0-alpha.213",
|
||||
"modules": {
|
||||
"ambiance": "0.0.0-alpha.16"
|
||||
}
|
||||
},
|
||||
"target": {
|
||||
"pack_version": "26.2.0-alpha.214",
|
||||
"modules": {
|
||||
"ambiance": "0.0.0-alpha.17"
|
||||
}
|
||||
},
|
||||
"features": {
|
||||
"white_disc_solo_playback_restored": true,
|
||||
"client_jukebox_sync_wait_ticks": 100,
|
||||
"loading_feedback_immediate": true,
|
||||
"sync_timeout_feedback": true,
|
||||
"youtube_share_parameters_supported": true
|
||||
},
|
||||
"compatibility": {
|
||||
"new_world_required": false,
|
||||
"save_reset_required": false,
|
||||
"existing_saves_supported": true,
|
||||
"registry_ids_changed": false,
|
||||
"ambiance_data_version": 2,
|
||||
"ambiance_network_protocol": 6
|
||||
},
|
||||
"distribution": {
|
||||
"pack_release_updated": true,
|
||||
"packwiz_index_refreshed": true,
|
||||
"prism_modrinth_index_updated": true,
|
||||
"shared_client_server_mrpack": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"source": {
|
||||
"pack_version": "26.2.0-alpha.214",
|
||||
"modules": {
|
||||
"ambiance": "0.0.0-alpha.17"
|
||||
}
|
||||
},
|
||||
"target": {
|
||||
"pack_version": "26.2.0-alpha.215",
|
||||
"modules": {
|
||||
"ambiance": "0.0.0-alpha.18"
|
||||
}
|
||||
},
|
||||
"features": {
|
||||
"white_disc_native_crash_fixed": true,
|
||||
"openal_channel_release_owned_by_audio_thread": true,
|
||||
"youtube_client_profile": "Ios",
|
||||
"youtube_source_commit": "f45bbb7aebfcbc1c553769e04af6cd43afa8b7c3",
|
||||
"initial_stream_wait_bounded": true,
|
||||
"subsequent_stream_polls_non_blocking": true,
|
||||
"playback_errors_stop_safely": true,
|
||||
"live_pcm_smoke_video_id": "Ue5ZBe-GzSM"
|
||||
},
|
||||
"compatibility": {
|
||||
"new_world_required": false,
|
||||
"save_reset_required": false,
|
||||
"existing_saves_supported": true,
|
||||
"registry_ids_changed": false,
|
||||
"ambiance_data_version": 2,
|
||||
"ambiance_network_protocol": 6
|
||||
},
|
||||
"distribution": {
|
||||
"pack_release_updated": true,
|
||||
"packwiz_index_refreshed": true,
|
||||
"prism_modrinth_index_updated": true,
|
||||
"shared_client_server_mrpack": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"source": {
|
||||
"pack_version": "26.2.0-alpha.215",
|
||||
"modules": {
|
||||
"ambiance": "0.0.0-alpha.18"
|
||||
}
|
||||
},
|
||||
"target": {
|
||||
"pack_version": "26.2.0-alpha.216",
|
||||
"modules": {
|
||||
"ambiance": "0.0.0-alpha.19"
|
||||
}
|
||||
},
|
||||
"features": {
|
||||
"white_disc_continuous_pcm_buffering": true,
|
||||
"sample_rate_hz": 48000,
|
||||
"channel_count": 1,
|
||||
"sample_size_bits": 16,
|
||||
"minecraft_stream_buffer_seconds": 1,
|
||||
"minecraft_initial_buffer_count": 4,
|
||||
"initial_frame_wait_millis": 2000,
|
||||
"continuation_frame_wait_millis": 250,
|
||||
"openal_pitch": 1.0,
|
||||
"live_pcm_smoke_video_id": "Ue5ZBe-GzSM",
|
||||
"minimum_live_audible_byte_ratio": 0.75,
|
||||
"deterministic_stream_smoke": true
|
||||
},
|
||||
"compatibility": {
|
||||
"new_world_required": false,
|
||||
"save_reset_required": false,
|
||||
"existing_saves_supported": true,
|
||||
"registry_ids_changed": false,
|
||||
"ambiance_data_version": 2,
|
||||
"ambiance_network_protocol": 6
|
||||
},
|
||||
"distribution": {
|
||||
"pack_release_updated": true,
|
||||
"packwiz_index_refreshed": true,
|
||||
"prism_modrinth_index_updated": true,
|
||||
"shared_client_server_mrpack": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"source": {
|
||||
"pack_version": "26.2.0-alpha.216",
|
||||
"modules": {
|
||||
"ambiance": "0.0.0-alpha.19"
|
||||
}
|
||||
},
|
||||
"target": {
|
||||
"pack_version": "26.2.0-alpha.217",
|
||||
"modules": {
|
||||
"ambiance": "0.0.0-alpha.20"
|
||||
}
|
||||
},
|
||||
"features": {
|
||||
"white_disc_pitch_corrected": true,
|
||||
"youtube_source_format": "MP4_AAC_LC",
|
||||
"openal_pitch_numerator": 4,
|
||||
"openal_pitch_denominator": 3,
|
||||
"openal_pitch_reapplied_during_playback": true,
|
||||
"reference_video_id": "Ue5ZBe-GzSM",
|
||||
"direct_source_comparison": true,
|
||||
"decoded_sample_rate_hz": 48000,
|
||||
"decoded_channel_count": 1,
|
||||
"decoded_sample_size_bits": 16
|
||||
},
|
||||
"compatibility": {
|
||||
"new_world_required": false,
|
||||
"save_reset_required": false,
|
||||
"existing_saves_supported": true,
|
||||
"registry_ids_changed": false,
|
||||
"ambiance_data_version": 2,
|
||||
"ambiance_network_protocol": 6
|
||||
},
|
||||
"distribution": {
|
||||
"pack_release_updated": true,
|
||||
"packwiz_index_refreshed": true,
|
||||
"prism_modrinth_index_updated": true,
|
||||
"shared_client_server_mrpack": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"source": {
|
||||
"pack_version": "26.2.0-alpha.217",
|
||||
"modules": {
|
||||
"redstoner": "0.0.0-alpha.10",
|
||||
"onlyfun": "0.0.0-alpha.24",
|
||||
"sanctuary": "0.0.0-alpha.120"
|
||||
}
|
||||
},
|
||||
"target": {
|
||||
"pack_version": "26.2.0-alpha.218",
|
||||
"modules": {
|
||||
"redstoner": "0.0.0-alpha.11",
|
||||
"onlyfun": "0.0.0-alpha.25",
|
||||
"sanctuary": "0.0.0-alpha.121"
|
||||
}
|
||||
},
|
||||
"storage_terminal": {
|
||||
"owner": "redstoner",
|
||||
"id": "redstoner:storage_terminal",
|
||||
"shape_depth_pixels": 8,
|
||||
"horizontal_wall_mount_only": true,
|
||||
"supported_containers": [
|
||||
"minecraft:chest",
|
||||
"minecraft:trapped_chest",
|
||||
"minecraft:barrel"
|
||||
],
|
||||
"ender_chest_supported": false,
|
||||
"face_adjacent_network_includes_vertical": true,
|
||||
"loaded_chunks_only": true,
|
||||
"maximum_physical_containers": 128,
|
||||
"oversized_network_rejected": true,
|
||||
"page_size": 54,
|
||||
"strict_item_component_aggregation": true,
|
||||
"server_authoritative_transfers": true,
|
||||
"left_click_extracts_stack": true,
|
||||
"right_click_extracts_one": true,
|
||||
"shift_click_deposits_player_stack": true,
|
||||
"crafting_pattern": ["PGP", "GCG", "PTP"],
|
||||
"crafting_ingredients": {
|
||||
"P": "anotherworld:silver_plate",
|
||||
"G": "minecraft:tinted_glass",
|
||||
"C": "redstoner:controller",
|
||||
"T": "anotherworld:titanium_ingot"
|
||||
},
|
||||
"available_in_shop": false,
|
||||
"available_in_black_market": false,
|
||||
"available_in_lootbox_rewards": false
|
||||
},
|
||||
"compatibility": {
|
||||
"new_world_required": false,
|
||||
"save_reset_required": false,
|
||||
"existing_saves_supported": true,
|
||||
"registry_ids_added": ["redstoner:storage_terminal"],
|
||||
"redstoner_data_version": 3,
|
||||
"redstoner_source_network_protocol": 5,
|
||||
"redstoner_target_network_protocol": 6,
|
||||
"onlyfun_data_version": 2,
|
||||
"onlyfun_network_protocol": 3,
|
||||
"sanctuary_data_version": 11,
|
||||
"sanctuary_network_protocol": 22
|
||||
},
|
||||
"distribution": {
|
||||
"pack_release_updated": true,
|
||||
"packwiz_index_refreshed": true,
|
||||
"prism_modrinth_index_updated": true,
|
||||
"shared_client_server_mrpack": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"source": {
|
||||
"pack_version": "26.2.0-alpha.218",
|
||||
"modules": {
|
||||
"sanctuary": "0.0.0-alpha.121",
|
||||
"iliketomoveit": "0.0.0-alpha.15",
|
||||
"ambiance": "0.0.0-alpha.20"
|
||||
}
|
||||
},
|
||||
"target": {
|
||||
"pack_version": "26.2.0-alpha.219",
|
||||
"modules": {
|
||||
"sanctuary": "0.0.0-alpha.122",
|
||||
"iliketomoveit": "0.0.0-alpha.16",
|
||||
"ambiance": "0.0.0-alpha.21"
|
||||
}
|
||||
},
|
||||
"alpha_world": {
|
||||
"owner": "sanctuary",
|
||||
"dimension": "sanctuary:alpha",
|
||||
"legacy_dimension": "portals:alpha",
|
||||
"legacy_identifier_migrated": true,
|
||||
"width": 128,
|
||||
"depth": 128,
|
||||
"height": 64,
|
||||
"minimum_xz": -64,
|
||||
"maximum_xz": 63,
|
||||
"deterministic_per_seed": true,
|
||||
"finite_barrier_boundary": true,
|
||||
"bedrock_floor": true,
|
||||
"historical_house": true,
|
||||
"historical_residence_exposed_by_api": true,
|
||||
"safe_portal_arrival_exposed_by_api": true,
|
||||
"registered_profiles": ["sanctuary", "overworld"],
|
||||
"dedicated_server_supported": true
|
||||
},
|
||||
"alpha_portal": {
|
||||
"owner": "iliketomoveit",
|
||||
"route": "iliketomoveit:alpha",
|
||||
"schema_version": 2,
|
||||
"frame_block": "minecraft:mossy_cobblestone",
|
||||
"outer_width": 4,
|
||||
"outer_height": 5,
|
||||
"inner_width": 2,
|
||||
"inner_height": 3,
|
||||
"activation_item": "minecraft:flint_and_steel",
|
||||
"inactive_when_destination_absent": true,
|
||||
"server_authoritative_destination": true,
|
||||
"server_authoritative_coordinates": true,
|
||||
"persistent_pair_reused": true,
|
||||
"break_unlinks_pair": true,
|
||||
"remote_half_avoids_historical_house": true,
|
||||
"atlas_discovery_supported": true
|
||||
},
|
||||
"alpha_textures": {
|
||||
"owner": "ambiance",
|
||||
"client_only": true,
|
||||
"destination": "sanctuary:alpha",
|
||||
"bundled_assets": false,
|
||||
"bundled_archives": false,
|
||||
"external_packs": ["Golden Days Base", "Golden Days Alpha"],
|
||||
"overlay_order": ["base", "alpha"],
|
||||
"previous_selection_restored_exactly": true,
|
||||
"saved_options_modified": false,
|
||||
"missing_packs_fallback": "vanilla",
|
||||
"dedicated_server_files_required": false,
|
||||
"official_base_url": "https://www.curseforge.com/minecraft/texture-packs/golden-days",
|
||||
"official_alpha_url": "https://www.curseforge.com/minecraft/texture-packs/golden-days-alpha",
|
||||
"official_license_url": "https://poeticrainbow.github.io/golden-days/license/"
|
||||
},
|
||||
"compatibility": {
|
||||
"new_world_required": false,
|
||||
"save_reset_required": false,
|
||||
"existing_saves_supported": true,
|
||||
"sanctuary_data_version": 11,
|
||||
"sanctuary_network_protocol": 22,
|
||||
"iliketomoveit_data_version": 3,
|
||||
"iliketomoveit_network_protocol": 5,
|
||||
"ambiance_data_version": 2,
|
||||
"ambiance_network_protocol": 6
|
||||
},
|
||||
"distribution": {
|
||||
"pack_release_updated": true,
|
||||
"packwiz_index_refreshed": true,
|
||||
"prism_modrinth_index_updated": true,
|
||||
"golden_days_assets_in_mrpack": false,
|
||||
"shared_client_server_mrpack": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"source": {
|
||||
"pack_version": "26.2.0-alpha.219",
|
||||
"modules": {
|
||||
"redstoner": "0.0.0-alpha.11"
|
||||
}
|
||||
},
|
||||
"target": {
|
||||
"pack_version": "26.2.0-alpha.220",
|
||||
"modules": {
|
||||
"redstoner": "0.0.0-alpha.12"
|
||||
}
|
||||
},
|
||||
"storage_terminal_menu": {
|
||||
"owner": "redstoner",
|
||||
"id": "redstoner:storage_terminal",
|
||||
"menu_type": "redstoner:storage_terminal",
|
||||
"native_container_menu": true,
|
||||
"dedicated_close_button": false,
|
||||
"terminal_slots": 54,
|
||||
"terminal_slots_reorderable": false,
|
||||
"quantity_data_words_per_slot": 2,
|
||||
"minimum_player_inventory_rows": 1,
|
||||
"maximum_player_inventory_rows": 6,
|
||||
"inventory_rows_authority": "sanctuary_progression",
|
||||
"left_click_with_empty_cursor": "take_stack",
|
||||
"right_click_with_empty_cursor": "take_one",
|
||||
"click_with_carried_stack": "deposit",
|
||||
"shift_click_from_terminal": "take_to_unlocked_inventory",
|
||||
"shift_click_from_player": "deposit",
|
||||
"deposit_order": "first_discovered_container",
|
||||
"per_container_order": ["compatible_stacks", "empty_slots"],
|
||||
"spill_to_next_container_when_required": true,
|
||||
"strict_item_component_aggregation": true,
|
||||
"server_authoritative_transfers": true,
|
||||
"vanilla_menu_synchronization": true,
|
||||
"legacy_terminal_payloads_removed": true
|
||||
},
|
||||
"compatibility": {
|
||||
"new_world_required": false,
|
||||
"save_reset_required": false,
|
||||
"existing_saves_supported": true,
|
||||
"redstoner_data_version": 3,
|
||||
"redstoner_source_network_protocol": 6,
|
||||
"redstoner_target_network_protocol": 7
|
||||
},
|
||||
"distribution": {
|
||||
"pack_release_updated": true,
|
||||
"packwiz_index_refreshed": true,
|
||||
"prism_modrinth_index_updated": true,
|
||||
"shared_client_server_mrpack": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"source": {
|
||||
"pack_version": "26.2.0-alpha.220",
|
||||
"modules": {
|
||||
"ambiance": "0.0.0-alpha.21"
|
||||
}
|
||||
},
|
||||
"target": {
|
||||
"pack_version": "26.2.0-alpha.221",
|
||||
"modules": {
|
||||
"ambiance": "0.0.0-alpha.22"
|
||||
}
|
||||
},
|
||||
"white_disc_native_rate": {
|
||||
"owner": "ambiance",
|
||||
"reference_video_id": "Ue5ZBe-GzSM",
|
||||
"source_format": "MP4_AAC_LC",
|
||||
"decoded_sample_rate_hz": 48000,
|
||||
"decoded_source_channels": 2,
|
||||
"openal_output_channels": 1,
|
||||
"sample_size_bits": 16,
|
||||
"byte_order": "little_endian",
|
||||
"stereo_to_mono": "average_left_right_per_sample",
|
||||
"openal_pitch": 1.0,
|
||||
"decoded_timeline_millis": 4000,
|
||||
"openal_timeline_millis": 4000,
|
||||
"padded_silence_bytes": 0,
|
||||
"positional_audio_preserved": true,
|
||||
"previous_pitch_compensation_removed": true
|
||||
},
|
||||
"compatibility": {
|
||||
"new_world_required": false,
|
||||
"save_reset_required": false,
|
||||
"existing_saves_supported": true,
|
||||
"registry_ids_changed": false,
|
||||
"ambiance_data_version": 2,
|
||||
"ambiance_network_protocol": 6
|
||||
},
|
||||
"distribution": {
|
||||
"pack_release_updated": true,
|
||||
"packwiz_index_refreshed": true,
|
||||
"prism_modrinth_index_updated": true,
|
||||
"shared_client_server_mrpack": true
|
||||
}
|
||||
}
|
||||
+17
-17
@@ -13,16 +13,16 @@ file = "instance-icon.png"
|
||||
hash = "41f4f4c88c71d0a23446d5014f7407d55ad477441847099cad24492b032335a0"
|
||||
|
||||
[[files]]
|
||||
file = "mods/ambiance-0.0.0-alpha.14.jar"
|
||||
hash = "a4b2a36e046a3095ada58e3dbf5fd30ab7c8ba42eaf2abcf513b38b8cc2f3e4a"
|
||||
file = "mods/ambiance-0.0.0-alpha.22.jar"
|
||||
hash = "4d5c31315c436c716a48e7d563fd179be2f39951c24bf4641f83a05a24323a3e"
|
||||
|
||||
[[files]]
|
||||
file = "mods/anotherworld-0.0.0-alpha.55.jar"
|
||||
hash = "da7163a34ca71e1c2385a84221cddccc09f0f83d9a00bce0ed6263b19ec519cb"
|
||||
hash = "7fbe2ac0d2ece62285aa8277ff92284760f2ccfd6d08159015661978bd0cde9a"
|
||||
|
||||
[[files]]
|
||||
file = "mods/events-0.0.0-alpha.0.jar"
|
||||
hash = "7400d63c905d1c87a5290adaa2fe91305fa1f067827ca8f0610a6ae4e17624da"
|
||||
hash = "cbf8d47fb82ca06c438b75fd72f805acc9813dffc6168d6786d54644ef8bb387"
|
||||
|
||||
[[files]]
|
||||
file = "mods/fabric-api.pw.toml"
|
||||
@@ -30,8 +30,8 @@ hash = "df032a6b051d0262cf24945c43e0e6b915e8cc1d62af62fe933932e7f35960c4"
|
||||
metafile = true
|
||||
|
||||
[[files]]
|
||||
file = "mods/iliketomoveit-0.0.0-alpha.15.jar"
|
||||
hash = "a084cf25a4afa50086ce220f40b6b3946a06389de105f23c0246df1577deb3c8"
|
||||
file = "mods/iliketomoveit-0.0.0-alpha.16.jar"
|
||||
hash = "26156014813bfa4384194ac0f20395b6df590a5bc29cd91a32114ced988bbaf3"
|
||||
|
||||
[[files]]
|
||||
file = "mods/iris.pw.toml"
|
||||
@@ -39,8 +39,8 @@ hash = "84a3cef87679c0d21dceb4641167dc0368b9a3593698f6a3632485e9b3a8d5da"
|
||||
metafile = true
|
||||
|
||||
[[files]]
|
||||
file = "mods/itsalive-0.0.0-alpha.35.jar"
|
||||
hash = "4a6433abdfc03a0872f7c810fb17016a0f20acda5e223e0fef479dd786cc73ac"
|
||||
file = "mods/itsalive-0.0.0-alpha.36.jar"
|
||||
hash = "5673ab7d605f2f1d18d807f0939f90ecedc13c892d03991fd60f7d02d74f9404"
|
||||
|
||||
[[files]]
|
||||
file = "mods/jei.pw.toml"
|
||||
@@ -49,23 +49,23 @@ metafile = true
|
||||
|
||||
[[files]]
|
||||
file = "mods/masterkey-0.0.0-alpha.8.jar"
|
||||
hash = "eaf70f6c5a78ee7ab2d3be498d4f7d67fcba433ed61b65b305e958f03b387d94"
|
||||
hash = "202b159fd04b7447cda7fa9f7360693106927904819e834956ab46f6e07f658e"
|
||||
|
||||
[[files]]
|
||||
file = "mods/onlyfun-0.0.0-alpha.21.jar"
|
||||
hash = "7e319a0cc6149d5819ef2359fe3ad4035066a9cddd5ae0a16c12c9db64bc3f6a"
|
||||
file = "mods/onlyfun-0.0.0-alpha.25.jar"
|
||||
hash = "6551d75cb3cb7be220b2aa6033d4eb533fa28a39ee88d66bbb0c40244c08c259"
|
||||
|
||||
[[files]]
|
||||
file = "mods/ouch-0.0.0-alpha.9.jar"
|
||||
hash = "f3cf2d8c83afa32b1c70e8239dd514be3c74834397f9d09efe177bc8c5ef9ac4"
|
||||
file = "mods/ouch-0.0.0-alpha.10.jar"
|
||||
hash = "0c2e0e2ef7f6c944d0b6d5609c42431a0a31f1d92087c3b5c28b67f76795d8cf"
|
||||
|
||||
[[files]]
|
||||
file = "mods/redstoner-0.0.0-alpha.10.jar"
|
||||
hash = "a9049b9d3a323015545194c92ce9da7a5dca4ff9a28ea26b02bead96748328ab"
|
||||
file = "mods/redstoner-0.0.0-alpha.12.jar"
|
||||
hash = "fee65e8d865f918ec8174b7a808762658ed91b566af590d99a3448f9d237175a"
|
||||
|
||||
[[files]]
|
||||
file = "mods/sanctuary-0.0.0-alpha.118.jar"
|
||||
hash = "1a1319e5f09de941ad8a7561aff9d2fb8c7460a8cd8d2374817d8f39c5682030"
|
||||
file = "mods/sanctuary-0.0.0-alpha.122.jar"
|
||||
hash = "9183652d13fd3c15663ca645f96f2a70026cb4d3a82f62e92fc3c72aea72d589"
|
||||
|
||||
[[files]]
|
||||
file = "mods/simple-voice-chat.pw.toml"
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
name = "Sanctuary 26.2"
|
||||
author = "KOKA99CAB"
|
||||
version = "26.2.0-alpha.210"
|
||||
version = "26.2.0-alpha.221"
|
||||
pack-format = "packwiz:1.1.0"
|
||||
|
||||
[index]
|
||||
file = "index.toml"
|
||||
hash-format = "sha256"
|
||||
hash = "e20c051f8895ce3164ec72b7764b8904f411e36f63bf1e046b1bcc9108d00a31"
|
||||
hash = "60e8342eef216dd225488fe4225a28c9b9940858642cbfc8025541affd150ab0"
|
||||
|
||||
[versions]
|
||||
fabric = "0.19.3"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"game": "minecraft",
|
||||
"name": "Sanctuary 26.2",
|
||||
"summary": "Sanctuary 26.2 — progression, exploration, worldgen and multiplayer systems by KOKA99CAB.",
|
||||
"versionId": "26.2.0-alpha.210",
|
||||
"versionId": "26.2.0-alpha.221",
|
||||
"files": [
|
||||
{
|
||||
"path": "mods/fabric-api-0.158.0+26.2.jar",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"pack_version": "26.2.0-alpha.210",
|
||||
"pack_version": "26.2.0-alpha.221",
|
||||
"release_channel": "alpha",
|
||||
"minecraft_version": "26.2",
|
||||
"fabric_loader_version": "0.19.3",
|
||||
@@ -9,7 +9,7 @@
|
||||
"project_id": "FgOqyAXd",
|
||||
"project_slug": "sanctuary-koka99",
|
||||
"project_url": "https://modrinth.com/modpack/sanctuary-koka99",
|
||||
"primary_file": "sanctuary-26.2.0-alpha.210.mrpack",
|
||||
"primary_file": "sanctuary-26.2.0-alpha.221.mrpack",
|
||||
"shared_client_server_pack": true,
|
||||
"update_owner": "packwiz-installer",
|
||||
"check_on_launch": true,
|
||||
|
||||
@@ -3,6 +3,7 @@ package fr.koka99cab.sanctuary26.redstoner;
|
||||
import fr.koka99cab.sanctuary26.redstoner.api.RedStonerApi;
|
||||
import fr.koka99cab.sanctuary26.redstoner.network.RedStonerNetworking;
|
||||
import fr.koka99cab.sanctuary26.redstoner.registry.RedStonerRegistries;
|
||||
import fr.koka99cab.sanctuary26.redstoner.registry.RedStonerMenus;
|
||||
import fr.koka99cab.sanctuary26.sanctuary.api.server.SanctuaryServerIdentity;
|
||||
import net.fabricmc.api.ModInitializer;
|
||||
import net.minecraft.resources.Identifier;
|
||||
@@ -26,8 +27,9 @@ public final class RedStonerMod implements ModInitializer {
|
||||
public void onInitialize() {
|
||||
RedStonerApi.registerServerIdentityProvider(SanctuaryServerIdentity::get);
|
||||
RedStonerRegistries.initialize();
|
||||
RedStonerMenus.initialize();
|
||||
RedStonerNetworking.initialize();
|
||||
LOGGER.info("[{}] Computers, Drawer and Particuleur ready (data v{}, network v{}).",
|
||||
LOGGER.info("[{}] Computers, Drawer, Particuleur and Storage Terminal ready (data v{}, network v{}).",
|
||||
DISPLAY_NAME, CURRENT_DATA_VERSION, RedStonerNetworking.PROTOCOL_VERSION);
|
||||
}
|
||||
}
|
||||
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
package fr.koka99cab.sanctuary26.redstoner.block;
|
||||
|
||||
import com.mojang.serialization.MapCodec;
|
||||
import fr.koka99cab.sanctuary26.redstoner.storage.StorageTerminalMenu;
|
||||
import fr.koka99cab.sanctuary26.redstoner.storage.StorageTerminalNetwork;
|
||||
import java.util.Map;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.util.RandomSource;
|
||||
import net.minecraft.world.InteractionHand;
|
||||
import net.minecraft.world.InteractionResult;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.item.context.BlockPlaceContext;
|
||||
import net.minecraft.world.level.BlockGetter;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.minecraft.world.level.LevelReader;
|
||||
import net.minecraft.world.level.ScheduledTickAccess;
|
||||
import net.minecraft.world.level.block.Block;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.Mirror;
|
||||
import net.minecraft.world.level.block.Rotation;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.block.state.StateDefinition;
|
||||
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
|
||||
import net.minecraft.world.level.block.state.properties.EnumProperty;
|
||||
import net.minecraft.world.phys.BlockHitResult;
|
||||
import net.minecraft.world.phys.shapes.CollisionContext;
|
||||
import net.minecraft.world.phys.shapes.VoxelShape;
|
||||
|
||||
public final class StorageTerminalBlock extends Block {
|
||||
public static final MapCodec<StorageTerminalBlock> CODEC = simpleCodec(StorageTerminalBlock::new);
|
||||
public static final EnumProperty<Direction> FACING = BlockStateProperties.HORIZONTAL_FACING;
|
||||
private static final Map<Direction, VoxelShape> SHAPES = Map.of(
|
||||
Direction.NORTH, Block.box(0.0D, 0.0D, 8.0D, 16.0D, 16.0D, 16.0D),
|
||||
Direction.SOUTH, Block.box(0.0D, 0.0D, 0.0D, 16.0D, 16.0D, 8.0D),
|
||||
Direction.WEST, Block.box(8.0D, 0.0D, 0.0D, 16.0D, 16.0D, 16.0D),
|
||||
Direction.EAST, Block.box(0.0D, 0.0D, 0.0D, 8.0D, 16.0D, 16.0D)
|
||||
);
|
||||
|
||||
public StorageTerminalBlock(Properties properties) {
|
||||
super(properties);
|
||||
registerDefaultState(stateDefinition.any().setValue(FACING, Direction.NORTH));
|
||||
}
|
||||
|
||||
@Override
|
||||
public MapCodec<StorageTerminalBlock> codec() {
|
||||
return CODEC;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BlockState getStateForPlacement(BlockPlaceContext context) {
|
||||
Direction facing = context.getClickedFace();
|
||||
if (!facing.getAxis().isHorizontal()) return null;
|
||||
BlockState state = defaultBlockState().setValue(FACING, facing);
|
||||
return state.canSurvive(context.getLevel(), context.getClickedPos()) ? state : null;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean canSurvive(BlockState state, LevelReader level, BlockPos pos) {
|
||||
BlockPos support = pos.relative(state.getValue(FACING).getOpposite());
|
||||
return StorageTerminalNetwork.isCompatibleStorage(level.getBlockState(support));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected BlockState updateShape(BlockState state, LevelReader level, ScheduledTickAccess ticks,
|
||||
BlockPos pos, Direction direction, BlockPos neighborPos, BlockState neighborState, RandomSource random) {
|
||||
return direction == state.getValue(FACING).getOpposite() && !state.canSurvive(level, pos)
|
||||
? Blocks.AIR.defaultBlockState()
|
||||
: super.updateShape(state, level, ticks, pos, direction, neighborPos, neighborState, random);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected VoxelShape getShape(BlockState state, BlockGetter level, BlockPos pos, CollisionContext context) {
|
||||
return SHAPES.get(state.getValue(FACING));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected InteractionResult useItemOn(ItemStack stack, BlockState state, Level level, BlockPos pos,
|
||||
Player player, InteractionHand hand, BlockHitResult hit) {
|
||||
return open(level, pos, player);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos,
|
||||
Player player, BlockHitResult hit) {
|
||||
return open(level, pos, player);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void createBlockStateDefinition(StateDefinition.Builder<Block, BlockState> builder) {
|
||||
builder.add(FACING);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected BlockState rotate(BlockState state, Rotation rotation) {
|
||||
return state.setValue(FACING, rotation.rotate(state.getValue(FACING)));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected BlockState mirror(BlockState state, Mirror mirror) {
|
||||
return state.rotate(mirror.getRotation(state.getValue(FACING)));
|
||||
}
|
||||
|
||||
private static InteractionResult open(Level level, BlockPos pos, Player player) {
|
||||
if (level.isClientSide()) return InteractionResult.SUCCESS;
|
||||
if (player instanceof ServerPlayer serverPlayer) {
|
||||
StorageTerminalMenu.open(serverPlayer, pos);
|
||||
return InteractionResult.SUCCESS_SERVER;
|
||||
}
|
||||
return InteractionResult.CONSUME;
|
||||
}
|
||||
}
|
||||
@@ -3,19 +3,23 @@ package fr.koka99cab.sanctuary26.redstoner.client;
|
||||
import fr.koka99cab.sanctuary26.redstoner.RedStonerMod;
|
||||
import fr.koka99cab.sanctuary26.redstoner.client.gui.ComputerScreen;
|
||||
import fr.koka99cab.sanctuary26.redstoner.client.gui.PersonalComputerScreen;
|
||||
import fr.koka99cab.sanctuary26.redstoner.client.gui.StorageTerminalScreen;
|
||||
import fr.koka99cab.sanctuary26.redstoner.client.renderer.DrawerBlockEntityRenderer;
|
||||
import fr.koka99cab.sanctuary26.redstoner.network.OpenComputerPayload;
|
||||
import fr.koka99cab.sanctuary26.redstoner.network.OpenPersonalComputerPayload;
|
||||
import fr.koka99cab.sanctuary26.redstoner.registry.RedStonerMenus;
|
||||
import fr.koka99cab.sanctuary26.redstoner.registry.RedStonerRegistries;
|
||||
import net.fabricmc.api.ClientModInitializer;
|
||||
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
|
||||
import net.fabricmc.fabric.api.client.rendering.v1.BlockEntityRendererRegistry;
|
||||
import net.minecraft.client.gui.screens.MenuScreens;
|
||||
|
||||
public final class RedStonerClient implements ClientModInitializer {
|
||||
@Override
|
||||
public void onInitializeClient() {
|
||||
BlockEntityRendererRegistry.register(RedStonerRegistries.DRAWER_BLOCK_ENTITY, DrawerBlockEntityRenderer::new);
|
||||
BlockEntityRendererRegistry.register(RedStonerRegistries.LEGACY_DRAWER_BLOCK_ENTITY, DrawerBlockEntityRenderer::new);
|
||||
MenuScreens.register(RedStonerMenus.STORAGE_TERMINAL, StorageTerminalScreen::new);
|
||||
ClientPlayNetworking.registerGlobalReceiver(OpenComputerPayload.TYPE, (payload, context) ->
|
||||
context.client().execute(() -> context.client().gui.setScreen(new ComputerScreen(payload))));
|
||||
ClientPlayNetworking.registerGlobalReceiver(OpenPersonalComputerPayload.TYPE, (payload, context) ->
|
||||
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
package fr.koka99cab.sanctuary26.redstoner.client.gui;
|
||||
|
||||
import fr.koka99cab.sanctuary26.redstoner.storage.StorageTerminalMenu;
|
||||
import fr.koka99cab.sanctuary26.redstoner.storage.StorageTerminalRules;
|
||||
import fr.koka99cab.sanctuary26.redstoner.storage.StorageTerminalStatus;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import net.minecraft.ChatFormatting;
|
||||
import net.minecraft.client.gui.GuiGraphicsExtractor;
|
||||
import net.minecraft.client.gui.components.EditBox;
|
||||
import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen;
|
||||
import net.minecraft.client.input.KeyEvent;
|
||||
import net.minecraft.client.input.MouseButtonEvent;
|
||||
import net.minecraft.client.renderer.RenderPipelines;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.world.entity.player.Inventory;
|
||||
import net.minecraft.world.inventory.Slot;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
|
||||
/** Vanilla-style terminal surface composed with Sanctuary's dynamic player-inventory panel. */
|
||||
public final class StorageTerminalScreen extends AbstractContainerScreen<StorageTerminalMenu> {
|
||||
private static final Identifier TEXTURE = Identifier.withDefaultNamespace(
|
||||
"textures/gui/container/generic_54.png");
|
||||
private static final int TERMINAL_TOP = 18;
|
||||
private static final int TERMINAL_HEIGHT = 108;
|
||||
private static final int SCROLL_X = 171;
|
||||
private static final int SCROLL_WIDTH = 4;
|
||||
private int requestedPage;
|
||||
private EditBox searchBox;
|
||||
private String searchValue = "";
|
||||
private String sentSearchValue = "";
|
||||
|
||||
public StorageTerminalScreen(StorageTerminalMenu menu, Inventory inventory, Component title) {
|
||||
super(menu, inventory, title, 176, 222);
|
||||
requestedPage = menu.page();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void init() {
|
||||
super.init();
|
||||
titleLabelY = 6;
|
||||
searchBox = addRenderableWidget(new EditBox(font, leftPos + 8, topPos + 2, 126, 14,
|
||||
Component.translatable("screen.redstoner.storage_terminal.search")));
|
||||
searchBox.setMaxLength(StorageTerminalRules.MAX_SEARCH_LENGTH);
|
||||
searchBox.setHint(Component.translatable("screen.redstoner.storage_terminal.search"));
|
||||
searchBox.setValue(searchValue);
|
||||
searchBox.setResponder(this::searchChanged);
|
||||
setInitialFocus(searchBox);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void extractBackground(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float tickDelta) {
|
||||
super.extractBackground(graphics, mouseX, mouseY, tickDelta);
|
||||
graphics.blit(RenderPipelines.GUI_TEXTURED, TEXTURE, leftPos, topPos, 0.0F, 0.0F,
|
||||
imageWidth, imageHeight, 256, 256);
|
||||
extractScrollBar(graphics);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void extractLabels(GuiGraphicsExtractor graphics, int mouseX, int mouseY) {
|
||||
Component pageLabel = Component.literal((menu.page() + 1) + "/" + menu.pageCount());
|
||||
graphics.text(font, pageLabel, 168 - font.width(pageLabel), titleLabelY, 0xFF404040, false);
|
||||
if (menu.status() != StorageTerminalStatus.READY) {
|
||||
graphics.centeredText(font, menu.statusMessage(), imageWidth / 2, 68, 0xFFAA3333);
|
||||
} else if (!searchValue.isBlank() && menu.totalEntries() == 0) {
|
||||
graphics.centeredText(font, Component.translatable(
|
||||
"screen.redstoner.storage_terminal.no_results"), imageWidth / 2, 68, 0xFF777777);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void extractSlot(GuiGraphicsExtractor graphics, Slot slot, int mouseX, int mouseY) {
|
||||
super.extractSlot(graphics, slot, mouseX, mouseY);
|
||||
if (slot.index < StorageTerminalMenu.TERMINAL_SLOT_COUNT && slot.hasItem()) {
|
||||
int total = menu.totalAt(slot.index);
|
||||
if (total > 1) {
|
||||
String label = StorageTerminalRules.compactCount(total);
|
||||
graphics.text(font, label, slot.x + 17 - font.width(label), slot.y + 9,
|
||||
0xFFFFFFFF, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected List<Component> getTooltipFromContainerItem(ItemStack stack) {
|
||||
List<Component> tooltip = new ArrayList<>(super.getTooltipFromContainerItem(stack));
|
||||
if (hoveredSlot != null && hoveredSlot.index < StorageTerminalMenu.TERMINAL_SLOT_COUNT) {
|
||||
int total = menu.totalAt(hoveredSlot.index);
|
||||
if (total > 0) tooltip.add(Component.literal("×" + total).withStyle(ChatFormatting.GRAY));
|
||||
}
|
||||
return tooltip;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void extractTooltip(GuiGraphicsExtractor graphics, int mouseX, int mouseY) {
|
||||
super.extractTooltip(graphics, mouseX, mouseY);
|
||||
if (isHovering(SCROLL_X - 2, TERMINAL_TOP, SCROLL_WIDTH + 4, TERMINAL_HEIGHT, mouseX, mouseY)) {
|
||||
graphics.setTooltipForNextFrame(font, Component.translatable(
|
||||
"screen.redstoner.storage_terminal.summary",
|
||||
menu.page() + 1, menu.pageCount(), menu.containerCount(), menu.totalEntries()),
|
||||
mouseX, mouseY);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) {
|
||||
if (scrollY != 0.0D && isHovering(7, TERMINAL_TOP, 169, TERMINAL_HEIGHT, mouseX, mouseY)) {
|
||||
requestPage(requestedPage + (scrollY < 0.0D ? 1 : -1));
|
||||
return true;
|
||||
}
|
||||
return super.mouseScrolled(mouseX, mouseY, scrollX, scrollY);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) {
|
||||
if (event.button() == 0
|
||||
&& isHovering(SCROLL_X - 2, TERMINAL_TOP, SCROLL_WIDTH + 4, TERMINAL_HEIGHT,
|
||||
event.x(), event.y())) {
|
||||
double ratio = Math.clamp((event.y() - topPos - TERMINAL_TOP) / TERMINAL_HEIGHT,
|
||||
0.0D, 0.999999D);
|
||||
requestPage((int) Math.floor(ratio * menu.pageCount()));
|
||||
return true;
|
||||
}
|
||||
return super.mouseClicked(event, doubleClick);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean keyPressed(KeyEvent event) {
|
||||
if (searchBox != null && searchBox.isFocused() && event.key() != 256) {
|
||||
searchBox.keyPressed(event);
|
||||
return true;
|
||||
}
|
||||
return super.keyPressed(event);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void containerTick() {
|
||||
requestedPage = menu.page();
|
||||
}
|
||||
|
||||
private void requestPage(int requested) {
|
||||
int page = Math.clamp(requested, 0, menu.pageCount() - 1);
|
||||
requestedPage = page;
|
||||
if (minecraft != null && minecraft.gameMode != null) {
|
||||
minecraft.gameMode.handleInventoryButtonClick(menu.containerId,
|
||||
StorageTerminalMenu.PAGE_BUTTON_BASE + page);
|
||||
}
|
||||
}
|
||||
|
||||
private void searchChanged(String value) {
|
||||
String previous = sentSearchValue;
|
||||
searchValue = value;
|
||||
requestedPage = 0;
|
||||
if (value.startsWith(previous)
|
||||
&& value.codePointCount(previous.length(), value.length()) == 1) {
|
||||
sendMenuButton(StorageTerminalMenu.SEARCH_CODEPOINT_BUTTON_BASE
|
||||
+ value.codePointAt(previous.length()));
|
||||
} else if (previous.startsWith(value)
|
||||
&& previous.codePointCount(value.length(), previous.length()) == 1) {
|
||||
sendMenuButton(StorageTerminalMenu.SEARCH_BACKSPACE_BUTTON);
|
||||
} else {
|
||||
sendMenuButton(StorageTerminalMenu.SEARCH_CLEAR_BUTTON);
|
||||
value.codePoints().forEach(codePoint -> sendMenuButton(
|
||||
StorageTerminalMenu.SEARCH_CODEPOINT_BUTTON_BASE + codePoint));
|
||||
}
|
||||
sentSearchValue = value;
|
||||
}
|
||||
|
||||
private void sendMenuButton(int buttonId) {
|
||||
if (minecraft != null && minecraft.gameMode != null) {
|
||||
minecraft.gameMode.handleInventoryButtonClick(menu.containerId, buttonId);
|
||||
}
|
||||
}
|
||||
|
||||
private void extractScrollBar(GuiGraphicsExtractor graphics) {
|
||||
int x = leftPos + SCROLL_X;
|
||||
int y = topPos + TERMINAL_TOP;
|
||||
graphics.fill(x, y, x + SCROLL_WIDTH, y + TERMINAL_HEIGHT, 0xFF8B8B8B);
|
||||
int pageCount = menu.pageCount();
|
||||
int handleHeight = Math.max(8, TERMINAL_HEIGHT / pageCount);
|
||||
int travel = TERMINAL_HEIGHT - handleHeight;
|
||||
int handleY = y + (pageCount <= 1 ? 0 : menu.page() * travel / (pageCount - 1));
|
||||
graphics.fill(x, handleY, x + SCROLL_WIDTH, handleY + handleHeight, 0xFFE0E0E0);
|
||||
graphics.outline(x, y, SCROLL_WIDTH, TERMINAL_HEIGHT, 0xFF373737);
|
||||
}
|
||||
|
||||
}
|
||||
+1
-1
@@ -12,7 +12,7 @@ import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
|
||||
public final class RedStonerNetworking {
|
||||
public static final int PROTOCOL_VERSION = 5;
|
||||
public static final int PROTOCOL_VERSION = 7;
|
||||
private static boolean initialized;
|
||||
|
||||
private RedStonerNetworking() {
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package fr.koka99cab.sanctuary26.redstoner.registry;
|
||||
|
||||
import fr.koka99cab.sanctuary26.redstoner.RedStonerMod;
|
||||
import fr.koka99cab.sanctuary26.redstoner.storage.StorageTerminalMenu;
|
||||
import net.minecraft.core.Registry;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.world.flag.FeatureFlags;
|
||||
import net.minecraft.world.inventory.MenuType;
|
||||
|
||||
public final class RedStonerMenus {
|
||||
public static final MenuType<StorageTerminalMenu> STORAGE_TERMINAL = Registry.register(
|
||||
BuiltInRegistries.MENU,
|
||||
RedStonerMod.id("storage_terminal"),
|
||||
new MenuType<>(StorageTerminalMenu::new, FeatureFlags.VANILLA_SET)
|
||||
);
|
||||
|
||||
private RedStonerMenus() {
|
||||
}
|
||||
|
||||
public static void initialize() {
|
||||
RedStonerMod.LOGGER.debug("[{}] Storage-terminal menu registered.", RedStonerMod.DISPLAY_NAME);
|
||||
}
|
||||
}
|
||||
+11
-1
@@ -5,6 +5,7 @@ import fr.koka99cab.sanctuary26.redstoner.block.ComputerBlock;
|
||||
import fr.koka99cab.sanctuary26.redstoner.block.DrawerBlock;
|
||||
import fr.koka99cab.sanctuary26.redstoner.block.PersonalComputerBlock;
|
||||
import fr.koka99cab.sanctuary26.redstoner.block.ParticuleurBlock;
|
||||
import fr.koka99cab.sanctuary26.redstoner.block.StorageTerminalBlock;
|
||||
import fr.koka99cab.sanctuary26.redstoner.block.entity.ComputerBlockEntity;
|
||||
import fr.koka99cab.sanctuary26.redstoner.block.entity.DrawerBlockEntity;
|
||||
import fr.koka99cab.sanctuary26.redstoner.block.entity.PersonalComputerBlockEntity;
|
||||
@@ -64,6 +65,12 @@ public final class RedStonerRegistries {
|
||||
ParticuleurBlock::new,
|
||||
particuleurProperties(RedStonerMod.id("particuleur"))
|
||||
);
|
||||
public static final StorageTerminalBlock STORAGE_TERMINAL = registerBlock(
|
||||
RedStonerMod.id("storage_terminal"),
|
||||
StorageTerminalBlock::new,
|
||||
BlockBehaviour.Properties.of().mapColor(MapColor.COLOR_GRAY).strength(4.0F, 6.0F).sound(SoundType.METAL)
|
||||
.requiresCorrectToolForDrops().noOcclusion().setId(blockKey(RedStonerMod.id("storage_terminal")))
|
||||
);
|
||||
|
||||
public static final ComputerBlock LEGACY_EIGHT_BIT_COMPUTER = registerBlock(
|
||||
LEGACY_COMPUTER_ID, ComputerBlock::new, computerProperties(LEGACY_COMPUTER_ID, MapColor.COLOR_RED));
|
||||
@@ -83,6 +90,8 @@ public final class RedStonerRegistries {
|
||||
public static final Item PERSONAL_COMPUTER_ITEM = registerBlockItem(RedStonerMod.id("personal_computer"), PERSONAL_COMPUTER);
|
||||
public static final Item DRAWER_ITEM = registerBlockItem(RedStonerMod.id("drawer"), DRAWER);
|
||||
public static final Item PARTICULEUR_ITEM = registerBlockItem(RedStonerMod.id("particuleur"), PARTICULEUR);
|
||||
public static final Item STORAGE_TERMINAL_ITEM = registerBlockItem(
|
||||
RedStonerMod.id("storage_terminal"), STORAGE_TERMINAL);
|
||||
|
||||
public static final Item LEGACY_EIGHT_BIT_COMPUTER_ITEM = registerBlockItem(LEGACY_COMPUTER_ID, LEGACY_EIGHT_BIT_COMPUTER);
|
||||
public static final Item LEGACY_CONTROLLER_ITEM = registerBlockItem(LEGACY_CONTROLLER_ID, LEGACY_CONTROLLER);
|
||||
@@ -117,6 +126,7 @@ public final class RedStonerRegistries {
|
||||
output.accept(EIGHT_BIT_COMPUTER_ITEM);
|
||||
output.accept(CONTROLLER_ITEM);
|
||||
output.accept(PARTICULEUR_ITEM);
|
||||
output.accept(STORAGE_TERMINAL_ITEM);
|
||||
})
|
||||
.build()
|
||||
);
|
||||
@@ -132,7 +142,7 @@ public final class RedStonerRegistries {
|
||||
}
|
||||
initialized = true;
|
||||
registerDrawerAttack();
|
||||
RedStonerMod.LOGGER.info("[{}] Registered five active blocks and five legacy block aliases.", RedStonerMod.DISPLAY_NAME);
|
||||
RedStonerMod.LOGGER.info("[{}] Registered six active blocks and five legacy block aliases.", RedStonerMod.DISPLAY_NAME);
|
||||
}
|
||||
|
||||
public static boolean isController(BlockState state) {
|
||||
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
package fr.koka99cab.sanctuary26.redstoner.storage;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import net.minecraft.world.Container;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
|
||||
public final class StorageTerminalInventory {
|
||||
private StorageTerminalInventory() {
|
||||
}
|
||||
|
||||
public static List<Entry> aggregate(List<? extends Container> containers) {
|
||||
Map<StackIdentity, MutableEntry> grouped = new LinkedHashMap<>();
|
||||
for (Container container : containers) {
|
||||
for (int slot = 0; slot < container.getContainerSize(); slot++) {
|
||||
ItemStack stack = container.getItem(slot);
|
||||
if (stack.isEmpty()) continue;
|
||||
StackIdentity key = identity(stack);
|
||||
MutableEntry entry = grouped.computeIfAbsent(key, ignored -> new MutableEntry(key.stack));
|
||||
entry.total = saturatedAdd(entry.total, stack.getCount());
|
||||
}
|
||||
}
|
||||
List<Entry> result = new ArrayList<>(grouped.size());
|
||||
for (MutableEntry entry : grouped.values()) result.add(new Entry(entry.stack, entry.total));
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills each container in discovery order, preferring its compatible stacks before its empty slots.
|
||||
* Mutates {@code source} and only spills into the next container when the current one cannot accept more.
|
||||
*/
|
||||
public static int insert(List<? extends Container> containers, ItemStack source) {
|
||||
if (source == null || source.isEmpty()) return 0;
|
||||
int before = source.getCount();
|
||||
for (Container container : containers) {
|
||||
for (int slot = 0; slot < container.getContainerSize() && !source.isEmpty(); slot++) {
|
||||
ItemStack target = container.getItem(slot);
|
||||
if (target.isEmpty() || !ItemStack.isSameItemSameComponents(target, source)
|
||||
|| !container.canPlaceItem(slot, source)) continue;
|
||||
int limit = Math.min(container.getMaxStackSize(source), source.getMaxStackSize());
|
||||
int moved = Math.min(source.getCount(), Math.max(0, limit - target.getCount()));
|
||||
if (moved <= 0) continue;
|
||||
target.grow(moved);
|
||||
source.shrink(moved);
|
||||
container.setChanged();
|
||||
}
|
||||
for (int slot = 0; slot < container.getContainerSize() && !source.isEmpty(); slot++) {
|
||||
if (!container.getItem(slot).isEmpty() || !container.canPlaceItem(slot, source)) continue;
|
||||
int limit = Math.min(container.getMaxStackSize(source), source.getMaxStackSize());
|
||||
int moved = Math.min(source.getCount(), Math.max(0, limit));
|
||||
if (moved <= 0) continue;
|
||||
container.setItem(slot, source.copyWithCount(moved));
|
||||
source.shrink(moved);
|
||||
container.setChanged();
|
||||
}
|
||||
}
|
||||
return before - source.getCount();
|
||||
}
|
||||
|
||||
public static ItemStack take(List<? extends Container> containers, ItemStack expected, int requested) {
|
||||
if (expected == null || expected.isEmpty() || requested <= 0) return ItemStack.EMPTY;
|
||||
int remaining = requested;
|
||||
int total = 0;
|
||||
for (Container container : containers) {
|
||||
for (int slot = 0; slot < container.getContainerSize() && remaining > 0; slot++) {
|
||||
ItemStack stored = container.getItem(slot);
|
||||
if (stored.isEmpty() || !ItemStack.isSameItemSameComponents(stored, expected)) continue;
|
||||
ItemStack removed = container.removeItem(slot, Math.min(remaining, stored.getCount()));
|
||||
if (removed.isEmpty()) continue;
|
||||
total += removed.getCount();
|
||||
remaining -= removed.getCount();
|
||||
container.setChanged();
|
||||
}
|
||||
}
|
||||
return total <= 0 ? ItemStack.EMPTY : expected.copyWithCount(total);
|
||||
}
|
||||
|
||||
private static int saturatedAdd(int left, int right) {
|
||||
return (int) Math.min(Integer.MAX_VALUE, (long) left + Math.max(0, right));
|
||||
}
|
||||
|
||||
public record Entry(ItemStack stack, int total) {
|
||||
public Entry {
|
||||
stack = stack == null || stack.isEmpty() ? ItemStack.EMPTY : stack.copyWithCount(1);
|
||||
total = stack.isEmpty() ? 0 : Math.max(0, total);
|
||||
}
|
||||
}
|
||||
|
||||
static StackIdentity identity(ItemStack stack) {
|
||||
return new StackIdentity(stack);
|
||||
}
|
||||
|
||||
private static final class MutableEntry {
|
||||
private final ItemStack stack;
|
||||
private int total;
|
||||
|
||||
private MutableEntry(ItemStack stack) {
|
||||
this.stack = stack.copyWithCount(1);
|
||||
}
|
||||
}
|
||||
|
||||
static final class StackIdentity {
|
||||
private final ItemStack stack;
|
||||
private final int hash;
|
||||
|
||||
private StackIdentity(ItemStack stack) {
|
||||
this.stack = stack.copyWithCount(1);
|
||||
this.hash = ItemStack.hashItemAndComponents(this.stack);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return hash;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object other) {
|
||||
return other instanceof StackIdentity identity
|
||||
&& ItemStack.isSameItemSameComponents(stack, identity.stack);
|
||||
}
|
||||
|
||||
ItemStack representative() {
|
||||
return stack.copyWithCount(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package fr.koka99cab.sanctuary26.redstoner.storage;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
|
||||
/** Keeps terminal slot identities stable for the lifetime of one search layout. */
|
||||
final class StorageTerminalLayout {
|
||||
private static final StorageTerminalInventory.Entry EMPTY_ENTRY =
|
||||
new StorageTerminalInventory.Entry(ItemStack.EMPTY, 0);
|
||||
private final List<StorageTerminalInventory.StackIdentity> order = new ArrayList<>();
|
||||
private Map<StorageTerminalInventory.StackIdentity, StorageTerminalInventory.Entry> liveByIdentity = Map.of();
|
||||
|
||||
void update(List<StorageTerminalInventory.Entry> liveEntries, boolean resetOrder) {
|
||||
Map<StorageTerminalInventory.StackIdentity, StorageTerminalInventory.Entry> live =
|
||||
new LinkedHashMap<>();
|
||||
for (StorageTerminalInventory.Entry entry : liveEntries) {
|
||||
live.put(StorageTerminalInventory.identity(entry.stack()), entry);
|
||||
}
|
||||
if (resetOrder) order.clear();
|
||||
Set<StorageTerminalInventory.StackIdentity> known = new HashSet<>(order);
|
||||
for (StorageTerminalInventory.StackIdentity identity : live.keySet()) {
|
||||
if (known.add(identity)) order.add(identity);
|
||||
}
|
||||
liveByIdentity = Map.copyOf(live);
|
||||
}
|
||||
|
||||
int size() {
|
||||
return order.size();
|
||||
}
|
||||
|
||||
List<StorageTerminalInventory.Entry> page(int requestedPage) {
|
||||
int start = StorageTerminalRules.pageStart(requestedPage, order.size());
|
||||
List<StorageTerminalInventory.Entry> entries = new ArrayList<>(StorageTerminalRules.PAGE_SIZE);
|
||||
for (int slot = 0; slot < StorageTerminalRules.PAGE_SIZE; slot++) {
|
||||
int orderedIndex = start + slot;
|
||||
if (orderedIndex >= order.size()) {
|
||||
entries.add(EMPTY_ENTRY);
|
||||
continue;
|
||||
}
|
||||
StorageTerminalInventory.StackIdentity identity = order.get(orderedIndex);
|
||||
StorageTerminalInventory.Entry live = liveByIdentity.get(identity);
|
||||
entries.add(live == null
|
||||
? new StorageTerminalInventory.Entry(identity.representative(), 0) : live);
|
||||
}
|
||||
return List.copyOf(entries);
|
||||
}
|
||||
}
|
||||
+336
@@ -0,0 +1,336 @@
|
||||
package fr.koka99cab.sanctuary26.redstoner.storage;
|
||||
|
||||
import fr.koka99cab.sanctuary26.redstoner.registry.RedStonerMenus;
|
||||
import fr.koka99cab.sanctuary26.redstoner.registry.RedStonerRegistries;
|
||||
import fr.koka99cab.sanctuary26.sanctuary.api.inventory.SanctuaryPlayerInventoryView;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.Container;
|
||||
import net.minecraft.world.SimpleContainer;
|
||||
import net.minecraft.world.SimpleMenuProvider;
|
||||
import net.minecraft.world.entity.player.Inventory;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.inventory.AbstractContainerMenu;
|
||||
import net.minecraft.world.inventory.ContainerData;
|
||||
import net.minecraft.world.inventory.ContainerInput;
|
||||
import net.minecraft.world.inventory.SimpleContainerData;
|
||||
import net.minecraft.world.inventory.Slot;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
|
||||
/** A server-authoritative aggregate view over the connected physical storage. */
|
||||
public final class StorageTerminalMenu extends AbstractContainerMenu {
|
||||
public static final int TERMINAL_SLOT_COUNT = StorageTerminalRules.PAGE_SIZE;
|
||||
public static final int PAGE_BUTTON_BASE = 10;
|
||||
public static final int SEARCH_CLEAR_BUTTON = 100;
|
||||
public static final int SEARCH_BACKSPACE_BUTTON = 101;
|
||||
public static final int SEARCH_CODEPOINT_BUTTON_BASE = 1_000;
|
||||
private static final int PLAYER_INVENTORY_Y = 140;
|
||||
private static final int REFRESH_INTERVAL = 20;
|
||||
private static final double MAX_DISTANCE_SQUARED = 64.0D;
|
||||
private static final int STATUS_DATA = 0;
|
||||
private static final int PAGE_DATA = 1;
|
||||
private static final int PAGE_COUNT_DATA = 2;
|
||||
private static final int CONTAINER_COUNT_DATA = 3;
|
||||
private static final int TOTAL_ENTRIES_DATA = 4;
|
||||
private static final int TOTALS_DATA = 5;
|
||||
private static final int WORDS_PER_TOTAL = 2;
|
||||
private static final int DATA_COUNT = TOTALS_DATA + TERMINAL_SLOT_COUNT * WORDS_PER_TOTAL;
|
||||
|
||||
private final SimpleContainer display = new SimpleContainer(TERMINAL_SLOT_COUNT);
|
||||
private final ContainerData data;
|
||||
private final ServerPlayer serverPlayer;
|
||||
private final BlockPos terminalPos;
|
||||
private StorageTerminalNetwork.Discovery discovery =
|
||||
StorageTerminalNetwork.Discovery.error(StorageTerminalStatus.INVALID_TERMINAL);
|
||||
private final StorageTerminalLayout layout = new StorageTerminalLayout();
|
||||
private List<StorageTerminalInventory.Entry> entries = List.of();
|
||||
private String searchQuery = "";
|
||||
private int refreshCooldown = REFRESH_INTERVAL;
|
||||
|
||||
public StorageTerminalMenu(int containerId, Inventory inventory) {
|
||||
this(containerId, inventory, new SimpleContainerData(DATA_COUNT), null, null);
|
||||
}
|
||||
|
||||
public StorageTerminalMenu(int containerId, Inventory inventory, ServerPlayer player, BlockPos pos) {
|
||||
this(containerId, inventory, new SimpleContainerData(DATA_COUNT), player, pos.immutable());
|
||||
refresh(0, true);
|
||||
}
|
||||
|
||||
private StorageTerminalMenu(int containerId, Inventory inventory, ContainerData data,
|
||||
ServerPlayer serverPlayer, BlockPos terminalPos) {
|
||||
super(RedStonerMenus.STORAGE_TERMINAL, containerId);
|
||||
checkContainerDataCount(data, DATA_COUNT);
|
||||
this.data = data;
|
||||
this.serverPlayer = serverPlayer;
|
||||
this.terminalPos = terminalPos;
|
||||
for (int row = 0; row < 6; row++) {
|
||||
for (int column = 0; column < 9; column++) {
|
||||
int slot = column + row * 9;
|
||||
addSlot(new TerminalDisplaySlot(display, slot, 8 + column * 18, 18 + row * 18));
|
||||
}
|
||||
}
|
||||
addStandardInventorySlots(inventory, 8, PLAYER_INVENTORY_Y);
|
||||
addDataSlots(data);
|
||||
}
|
||||
|
||||
public static void open(ServerPlayer player, BlockPos pos) {
|
||||
if (player.distanceToSqr(Vec3.atCenterOf(pos)) > MAX_DISTANCE_SQUARED) return;
|
||||
player.openMenu(new SimpleMenuProvider(
|
||||
(containerId, inventory, ignored) -> new StorageTerminalMenu(containerId, inventory, player, pos),
|
||||
Component.translatable("screen.redstoner.storage_terminal.title")));
|
||||
}
|
||||
|
||||
public StorageTerminalStatus status() {
|
||||
StorageTerminalStatus[] values = StorageTerminalStatus.values();
|
||||
return values[Math.clamp(data.get(STATUS_DATA), 0, values.length - 1)];
|
||||
}
|
||||
|
||||
public int page() {
|
||||
return Math.max(0, data.get(PAGE_DATA));
|
||||
}
|
||||
|
||||
public int pageCount() {
|
||||
return Math.max(1, data.get(PAGE_COUNT_DATA));
|
||||
}
|
||||
|
||||
public int containerCount() {
|
||||
return Math.max(0, data.get(CONTAINER_COUNT_DATA));
|
||||
}
|
||||
|
||||
public int totalEntries() {
|
||||
return Math.max(0, data.get(TOTAL_ENTRIES_DATA));
|
||||
}
|
||||
|
||||
public int totalAt(int slot) {
|
||||
if (slot < 0 || slot >= TERMINAL_SLOT_COUNT) return 0;
|
||||
int base = TOTALS_DATA + slot * WORDS_PER_TOTAL;
|
||||
return StorageTerminalRules.totalFromWords(data.get(base), data.get(base + 1));
|
||||
}
|
||||
|
||||
public Component statusMessage() {
|
||||
return Component.translatable("screen.redstoner.storage_terminal.error."
|
||||
+ status().name().toLowerCase(Locale.ROOT));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean clickMenuButton(Player player, int buttonId) {
|
||||
if (serverPlayer == null || player != serverPlayer || !stillValid(player)) return false;
|
||||
int requestedPage = buttonId - PAGE_BUTTON_BASE;
|
||||
if (requestedPage >= 0 && requestedPage < StorageTerminalRules.MAX_PAGE_COUNT) {
|
||||
refresh(requestedPage, false);
|
||||
broadcastChanges();
|
||||
return true;
|
||||
}
|
||||
if (buttonId == SEARCH_CLEAR_BUTTON) {
|
||||
searchQuery = "";
|
||||
} else if (buttonId == SEARCH_BACKSPACE_BUTTON) {
|
||||
if (searchQuery.isEmpty()) return true;
|
||||
searchQuery = searchQuery.substring(0,
|
||||
searchQuery.offsetByCodePoints(searchQuery.length(), -1));
|
||||
} else {
|
||||
int codePoint = buttonId - SEARCH_CODEPOINT_BUTTON_BASE;
|
||||
if (!isSearchCodePoint(codePoint)) return false;
|
||||
String appended = new String(Character.toChars(codePoint));
|
||||
if (searchQuery.length() + appended.length() > StorageTerminalRules.MAX_SEARCH_LENGTH) return false;
|
||||
searchQuery += appended;
|
||||
}
|
||||
refresh(0, true);
|
||||
broadcastChanges();
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clicked(int slotId, int button, ContainerInput input, Player player) {
|
||||
if (slotId >= 0 && slotId < TERMINAL_SLOT_COUNT) {
|
||||
if (input == ContainerInput.QUICK_MOVE) {
|
||||
if (serverPlayer != null) quickMoveStack(player, slotId);
|
||||
return;
|
||||
}
|
||||
if (input == ContainerInput.PICKUP && (button == 0 || button == 1)) {
|
||||
if (serverPlayer != null && player == serverPlayer && stillValid(player)) {
|
||||
if (getCarried().isEmpty()) takeToCarried(slotId, button == 1);
|
||||
else depositCarried(button == 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
super.clicked(slotId, button, input, player);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ItemStack quickMoveStack(Player player, int index) {
|
||||
if (serverPlayer == null || player != serverPlayer || !stillValid(player)
|
||||
|| index < 0 || index >= slots.size()) return ItemStack.EMPTY;
|
||||
if (index < TERMINAL_SLOT_COUNT) return takeIntoPlayer(index);
|
||||
|
||||
Slot sourceSlot = slots.get(index);
|
||||
if (!sourceSlot.hasItem() || !sourceSlot.mayPickup(player)) return ItemStack.EMPTY;
|
||||
if (!ready()) return ItemStack.EMPTY;
|
||||
ItemStack source = sourceSlot.getItem();
|
||||
ItemStack original = source.copy();
|
||||
int moved = StorageTerminalInventory.insert(discovery.inventoryContainers(), source);
|
||||
if (moved <= 0) return ItemStack.EMPTY;
|
||||
if (source.isEmpty()) sourceSlot.setByPlayer(ItemStack.EMPTY);
|
||||
else sourceSlot.setChanged();
|
||||
sourceSlot.onTake(player, source);
|
||||
refresh(page(), false);
|
||||
broadcastChanges();
|
||||
return original;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean stillValid(Player player) {
|
||||
if (serverPlayer == null || terminalPos == null) return true;
|
||||
return player == serverPlayer
|
||||
&& player.distanceToSqr(Vec3.atCenterOf(terminalPos)) <= MAX_DISTANCE_SQUARED
|
||||
&& player.level().getBlockState(terminalPos).is(RedStonerRegistries.STORAGE_TERMINAL);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void broadcastChanges() {
|
||||
if (serverPlayer != null && --refreshCooldown <= 0) refresh(page(), false);
|
||||
super.broadcastChanges();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean canDragTo(Slot slot) {
|
||||
return !(slot instanceof TerminalDisplaySlot) && super.canDragTo(slot);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean canTakeItemForPickAll(ItemStack stack, Slot slot) {
|
||||
return !(slot instanceof TerminalDisplaySlot) && super.canTakeItemForPickAll(stack, slot);
|
||||
}
|
||||
|
||||
private void refresh(int requestedPage, boolean resetOrder) {
|
||||
if (serverPlayer == null || terminalPos == null) return;
|
||||
discovery = StorageTerminalNetwork.discover(serverPlayer, terminalPos);
|
||||
List<StorageTerminalInventory.Entry> liveEntries =
|
||||
StorageTerminalNetwork.filteredEntries(discovery, searchQuery);
|
||||
layout.update(liveEntries, resetOrder);
|
||||
|
||||
int page = discovery.status() == StorageTerminalStatus.READY
|
||||
? StorageTerminalRules.clampPage(requestedPage, layout.size()) : 0;
|
||||
int pageCount = discovery.status() == StorageTerminalStatus.READY
|
||||
? StorageTerminalRules.pageCount(layout.size()) : 1;
|
||||
entries = discovery.status() == StorageTerminalStatus.READY ? layout.page(page) : List.of();
|
||||
data.set(STATUS_DATA, discovery.status().ordinal());
|
||||
data.set(PAGE_DATA, page);
|
||||
data.set(PAGE_COUNT_DATA, pageCount);
|
||||
data.set(CONTAINER_COUNT_DATA, discovery.containers().size());
|
||||
data.set(TOTAL_ENTRIES_DATA, liveEntries.size());
|
||||
for (int slot = 0; slot < TERMINAL_SLOT_COUNT; slot++) {
|
||||
StorageTerminalInventory.Entry entry = slot < entries.size() ? entries.get(slot) : null;
|
||||
display.setItem(slot, entry == null || entry.total() <= 0
|
||||
? ItemStack.EMPTY : entry.stack().copyWithCount(1));
|
||||
int total = entry == null ? 0 : entry.total();
|
||||
int base = TOTALS_DATA + slot * WORDS_PER_TOTAL;
|
||||
data.set(base, StorageTerminalRules.totalLowWord(total));
|
||||
data.set(base + 1, StorageTerminalRules.totalHighWord(total));
|
||||
}
|
||||
refreshCooldown = REFRESH_INTERVAL;
|
||||
}
|
||||
|
||||
private static boolean isSearchCodePoint(int codePoint) {
|
||||
return Character.isValidCodePoint(codePoint)
|
||||
&& !(codePoint >= Character.MIN_SURROGATE && codePoint <= Character.MAX_SURROGATE)
|
||||
&& !Character.isISOControl(codePoint);
|
||||
}
|
||||
|
||||
private boolean ready() {
|
||||
if (serverPlayer == null || terminalPos == null) return false;
|
||||
discovery = StorageTerminalNetwork.discover(serverPlayer, terminalPos);
|
||||
return discovery.status() == StorageTerminalStatus.READY;
|
||||
}
|
||||
|
||||
private void takeToCarried(int slot, boolean single) {
|
||||
if (!ready() || slot >= entries.size()) return;
|
||||
StorageTerminalInventory.Entry entry = entries.get(slot);
|
||||
if (entry.stack().isEmpty() || entry.total() <= 0) return;
|
||||
int requested = single ? 1 : entry.stack().getMaxStackSize();
|
||||
ItemStack extracted = StorageTerminalInventory.take(discovery.inventoryContainers(), entry.stack(),
|
||||
Math.min(entry.total(), requested));
|
||||
if (extracted.isEmpty()) return;
|
||||
setCarried(extracted);
|
||||
refresh(page(), false);
|
||||
broadcastChanges();
|
||||
}
|
||||
|
||||
private void depositCarried(boolean single) {
|
||||
if (!ready()) return;
|
||||
ItemStack carried = getCarried();
|
||||
int offeredCount = single ? 1 : carried.getCount();
|
||||
ItemStack offered = carried.copyWithCount(offeredCount);
|
||||
int moved = StorageTerminalInventory.insert(discovery.inventoryContainers(), offered);
|
||||
if (moved <= 0) return;
|
||||
carried.shrink(moved);
|
||||
if (carried.isEmpty()) setCarried(ItemStack.EMPTY);
|
||||
refresh(page(), false);
|
||||
broadcastChanges();
|
||||
}
|
||||
|
||||
private ItemStack takeIntoPlayer(int slot) {
|
||||
if (!ready() || slot >= entries.size()) return ItemStack.EMPTY;
|
||||
StorageTerminalInventory.Entry entry = entries.get(slot);
|
||||
if (entry.stack().isEmpty() || entry.total() <= 0) return ItemStack.EMPTY;
|
||||
int requested = Math.min(entry.total(), entry.stack().getMaxStackSize());
|
||||
ItemStack extracted = StorageTerminalInventory.take(discovery.inventoryContainers(), entry.stack(), requested);
|
||||
if (extracted.isEmpty()) return ItemStack.EMPTY;
|
||||
ItemStack original = extracted.copy();
|
||||
int inserted = insertPlayer(serverPlayer, extracted);
|
||||
if (!extracted.isEmpty()) StorageTerminalInventory.insert(discovery.inventoryContainers(), extracted);
|
||||
if (inserted <= 0) return ItemStack.EMPTY;
|
||||
refresh(page(), false);
|
||||
broadcastChanges();
|
||||
return original.copyWithCount(inserted);
|
||||
}
|
||||
|
||||
private static int insertPlayer(ServerPlayer player, ItemStack source) {
|
||||
int before = source.getCount();
|
||||
var slots = SanctuaryPlayerInventoryView.unlockedRows(player);
|
||||
for (var slot : slots) {
|
||||
if (source.isEmpty()) break;
|
||||
ItemStack target = slot.stack();
|
||||
if (target.isEmpty() || !ItemStack.isSameItemSameComponents(target, source)
|
||||
|| !slot.container().canPlaceItem(slot.slot(), source)) continue;
|
||||
int limit = Math.min(target.getMaxStackSize(), slot.container().getMaxStackSize(source));
|
||||
int moved = Math.min(source.getCount(), Math.max(0, limit - target.getCount()));
|
||||
if (moved <= 0) continue;
|
||||
target.grow(moved);
|
||||
source.shrink(moved);
|
||||
slot.markChanged();
|
||||
}
|
||||
for (var slot : slots) {
|
||||
if (source.isEmpty()) break;
|
||||
if (!slot.stack().isEmpty() || !slot.container().canPlaceItem(slot.slot(), source)) continue;
|
||||
int limit = Math.min(source.getMaxStackSize(), slot.container().getMaxStackSize(source));
|
||||
int moved = Math.min(source.getCount(), Math.max(0, limit));
|
||||
if (moved <= 0) continue;
|
||||
slot.container().setItem(slot.slot(), source.copyWithCount(moved));
|
||||
source.shrink(moved);
|
||||
slot.markChanged();
|
||||
}
|
||||
return before - source.getCount();
|
||||
}
|
||||
|
||||
private static final class TerminalDisplaySlot extends Slot {
|
||||
private TerminalDisplaySlot(Container container, int slot, int x, int y) {
|
||||
super(container, slot, x, y);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mayPlace(ItemStack stack) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mayPickup(Player player) {
|
||||
return hasItem();
|
||||
}
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
package fr.koka99cab.sanctuary26.redstoner.storage;
|
||||
|
||||
import fr.koka99cab.sanctuary26.redstoner.block.StorageTerminalBlock;
|
||||
import fr.koka99cab.sanctuary26.redstoner.registry.RedStonerRegistries;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.Container;
|
||||
import net.minecraft.world.RandomizableContainer;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.entity.BarrelBlockEntity;
|
||||
import net.minecraft.world.level.block.entity.BaseContainerBlockEntity;
|
||||
import net.minecraft.world.level.block.entity.BlockEntity;
|
||||
import net.minecraft.world.level.block.entity.ChestBlockEntity;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
|
||||
public final class StorageTerminalNetwork {
|
||||
private StorageTerminalNetwork() {
|
||||
}
|
||||
|
||||
public static boolean isCompatibleStorage(BlockState state) {
|
||||
return state.is(Blocks.CHEST) || state.is(Blocks.TRAPPED_CHEST) || state.is(Blocks.BARREL);
|
||||
}
|
||||
|
||||
public static Discovery discover(ServerPlayer player, BlockPos terminalPos) {
|
||||
if (!(player.level() instanceof ServerLevel level)) return Discovery.error(StorageTerminalStatus.INVALID_TERMINAL);
|
||||
BlockState terminalState = level.getBlockState(terminalPos);
|
||||
if (!terminalState.is(RedStonerRegistries.STORAGE_TERMINAL)
|
||||
|| !terminalState.hasProperty(StorageTerminalBlock.FACING)) {
|
||||
return Discovery.error(StorageTerminalStatus.INVALID_TERMINAL);
|
||||
}
|
||||
BlockPos start = terminalPos.relative(terminalState.getValue(StorageTerminalBlock.FACING).getOpposite());
|
||||
StorageTerminalTopology.Result topology = StorageTerminalTopology.discover(start, pos ->
|
||||
level.hasChunkAt(pos) && isCompatibleStorage(level.getBlockState(pos)) && storageEntity(level, pos) != null);
|
||||
if (topology.tooLarge()) return Discovery.error(StorageTerminalStatus.TOO_LARGE);
|
||||
if (topology.positions().isEmpty()) return Discovery.error(StorageTerminalStatus.DISCONNECTED);
|
||||
|
||||
List<BaseContainerBlockEntity> containers = new ArrayList<>(topology.positions().size());
|
||||
for (BlockPos pos : topology.positions()) {
|
||||
BaseContainerBlockEntity container = storageEntity(level, pos);
|
||||
if (container == null) return Discovery.error(StorageTerminalStatus.DISCONNECTED);
|
||||
if (container.isLocked()) return Discovery.error(StorageTerminalStatus.LOCKED);
|
||||
if (container instanceof RandomizableContainer randomizable) randomizable.unpackLootTable(player);
|
||||
containers.add(container);
|
||||
}
|
||||
return new Discovery(StorageTerminalStatus.READY, containers);
|
||||
}
|
||||
|
||||
public static List<StorageTerminalInventory.Entry> filteredEntries(
|
||||
Discovery discovery, String searchQuery) {
|
||||
if (discovery.status() != StorageTerminalStatus.READY) return List.of();
|
||||
return StorageTerminalSearch.filter(
|
||||
StorageTerminalInventory.aggregate(discovery.containers()), searchQuery);
|
||||
}
|
||||
|
||||
private static BaseContainerBlockEntity storageEntity(ServerLevel level, BlockPos pos) {
|
||||
BlockEntity blockEntity = level.getBlockEntity(pos);
|
||||
if (blockEntity instanceof ChestBlockEntity chest) return chest;
|
||||
if (blockEntity instanceof BarrelBlockEntity barrel) return barrel;
|
||||
return null;
|
||||
}
|
||||
|
||||
public record Discovery(StorageTerminalStatus status, List<BaseContainerBlockEntity> containers) {
|
||||
public Discovery {
|
||||
status = status == null ? StorageTerminalStatus.INVALID_TERMINAL : status;
|
||||
containers = containers == null ? List.of() : List.copyOf(containers);
|
||||
}
|
||||
|
||||
public static Discovery error(StorageTerminalStatus status) {
|
||||
return new Discovery(status, List.of());
|
||||
}
|
||||
|
||||
public List<? extends Container> inventoryContainers() {
|
||||
return containers;
|
||||
}
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package fr.koka99cab.sanctuary26.redstoner.storage;
|
||||
|
||||
public final class StorageTerminalRules {
|
||||
public static final int PAGE_SIZE = 54;
|
||||
public static final int MAX_CONTAINERS = 128;
|
||||
public static final int SLOTS_PER_CONTAINER = 27;
|
||||
public static final int MAX_ENTRIES = MAX_CONTAINERS * SLOTS_PER_CONTAINER;
|
||||
public static final int MAX_PAGE_COUNT = MAX_ENTRIES / PAGE_SIZE;
|
||||
public static final int MAX_SEARCH_LENGTH = 32;
|
||||
|
||||
private StorageTerminalRules() {
|
||||
}
|
||||
|
||||
public static int pageCount(int entryCount) {
|
||||
int bounded = Math.max(0, Math.min(MAX_ENTRIES, entryCount));
|
||||
return Math.max(1, (bounded + PAGE_SIZE - 1) / PAGE_SIZE);
|
||||
}
|
||||
|
||||
public static int clampPage(int requestedPage, int entryCount) {
|
||||
return Math.max(0, Math.min(pageCount(entryCount) - 1, requestedPage));
|
||||
}
|
||||
|
||||
public static int pageStart(int requestedPage, int entryCount) {
|
||||
return clampPage(requestedPage, entryCount) * PAGE_SIZE;
|
||||
}
|
||||
|
||||
public static int totalLowWord(int total) {
|
||||
return Math.max(0, total) & 0xFFFF;
|
||||
}
|
||||
|
||||
public static int totalHighWord(int total) {
|
||||
return Math.max(0, total) >>> 16;
|
||||
}
|
||||
|
||||
public static int totalFromWords(int low, int high) {
|
||||
return (low & 0xFFFF) | (high & 0xFFFF) << 16;
|
||||
}
|
||||
|
||||
public static String compactCount(int count) {
|
||||
int bounded = Math.max(0, count);
|
||||
if (bounded < 1_000) return Integer.toString(bounded);
|
||||
if (bounded < 10_000) return decimalUnit(bounded, 1_000, "K");
|
||||
if (bounded < 1_000_000) return (bounded / 1_000) + "K";
|
||||
if (bounded < 10_000_000) return decimalUnit(bounded, 1_000_000, "M");
|
||||
return (bounded / 1_000_000) + "M";
|
||||
}
|
||||
|
||||
private static String decimalUnit(int count, int unit, String suffix) {
|
||||
int tenths = count / (unit / 10);
|
||||
int decimal = tenths % 10;
|
||||
return decimal == 0 ? (tenths / 10) + suffix
|
||||
: (tenths / 10) + "." + decimal + suffix;
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package fr.koka99cab.sanctuary26.redstoner.storage;
|
||||
|
||||
import java.text.Normalizer;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.regex.Pattern;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
|
||||
/** Server-side matching for the bounded terminal search field. */
|
||||
public final class StorageTerminalSearch {
|
||||
private static final Pattern COMBINING_MARKS = Pattern.compile("\\p{M}+");
|
||||
private static final Pattern NON_ALPHANUMERIC = Pattern.compile("[^\\p{L}\\p{N}]+");
|
||||
|
||||
private StorageTerminalSearch() {
|
||||
}
|
||||
|
||||
public static List<StorageTerminalInventory.Entry> filter(
|
||||
List<StorageTerminalInventory.Entry> entries, String query) {
|
||||
String normalizedQuery = normalize(query);
|
||||
if (normalizedQuery.isEmpty()) return List.copyOf(entries);
|
||||
String[] terms = normalizedQuery.split(" ");
|
||||
List<StorageTerminalInventory.Entry> matches = new ArrayList<>();
|
||||
for (StorageTerminalInventory.Entry entry : entries) {
|
||||
if (matches(entry.stack(), terms)) matches.add(entry);
|
||||
}
|
||||
return List.copyOf(matches);
|
||||
}
|
||||
|
||||
static String normalize(String value) {
|
||||
if (value == null || value.isBlank()) return "";
|
||||
String withoutMarks = COMBINING_MARKS.matcher(
|
||||
Normalizer.normalize(value, Normalizer.Form.NFD)).replaceAll("");
|
||||
return NON_ALPHANUMERIC.matcher(withoutMarks.toLowerCase(Locale.ROOT))
|
||||
.replaceAll(" ").strip();
|
||||
}
|
||||
|
||||
private static boolean matches(ItemStack stack, String[] terms) {
|
||||
StringBuilder searchable = new StringBuilder(stack.getHoverName().getString());
|
||||
Identifier id = BuiltInRegistries.ITEM.getKey(stack.getItem());
|
||||
if (id != null) searchable.append(' ').append(id);
|
||||
searchable.append(' ').append(stack.getItem().getDescriptionId());
|
||||
String normalized = normalize(searchable.toString());
|
||||
for (String term : terms) {
|
||||
if (!normalized.contains(term)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package fr.koka99cab.sanctuary26.redstoner.storage;
|
||||
|
||||
public enum StorageTerminalStatus {
|
||||
READY,
|
||||
INVALID_TERMINAL,
|
||||
DISCONNECTED,
|
||||
TOO_LARGE,
|
||||
LOCKED
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package fr.koka99cab.sanctuary26.redstoner.storage;
|
||||
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.function.Predicate;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
|
||||
public final class StorageTerminalTopology {
|
||||
private StorageTerminalTopology() {
|
||||
}
|
||||
|
||||
public static Result discover(BlockPos start, Predicate<BlockPos> storageAt) {
|
||||
if (start == null || storageAt == null) return new Result(List.of(), false);
|
||||
ArrayDeque<BlockPos> pending = new ArrayDeque<>();
|
||||
Set<BlockPos> visited = new HashSet<>();
|
||||
List<BlockPos> storage = new ArrayList<>();
|
||||
pending.add(start.immutable());
|
||||
|
||||
while (!pending.isEmpty()) {
|
||||
BlockPos pos = pending.removeFirst();
|
||||
if (!visited.add(pos) || !storageAt.test(pos)) continue;
|
||||
if (storage.size() >= StorageTerminalRules.MAX_CONTAINERS) {
|
||||
return new Result(storage, true);
|
||||
}
|
||||
storage.add(pos);
|
||||
for (Direction direction : Direction.values()) {
|
||||
BlockPos neighbor = pos.relative(direction);
|
||||
if (!visited.contains(neighbor)) pending.addLast(neighbor.immutable());
|
||||
}
|
||||
}
|
||||
|
||||
return new Result(storage, false);
|
||||
}
|
||||
|
||||
public record Result(List<BlockPos> positions, boolean tooLarge) {
|
||||
public Result {
|
||||
positions = positions == null ? List.of() : List.copyOf(positions);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"variants": {
|
||||
"facing=north": { "model": "redstoner:block/storage_terminal" },
|
||||
"facing=east": { "model": "redstoner:block/storage_terminal", "y": 90 },
|
||||
"facing=south": { "model": "redstoner:block/storage_terminal", "y": 180 },
|
||||
"facing=west": { "model": "redstoner:block/storage_terminal", "y": 270 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"model": {
|
||||
"type": "minecraft:model",
|
||||
"model": "redstoner:block/storage_terminal"
|
||||
}
|
||||
}
|
||||
@@ -49,5 +49,14 @@
|
||||
"message.redstoner.computer.invalid": "Program rejected: %s",
|
||||
"block.redstoner.particuleur": "Particle Emitter",
|
||||
"block.particuleur.particuleur": "Legacy Particle Emitter",
|
||||
"tooltip.redstoner.particuleur": "Insert a catalyst, then power the block with redstone."
|
||||
"tooltip.redstoner.particuleur": "Insert a catalyst, then power the block with redstone.",
|
||||
"block.redstoner.storage_terminal": "Storage Terminal",
|
||||
"screen.redstoner.storage_terminal.title": "Storage Terminal",
|
||||
"screen.redstoner.storage_terminal.search": "Search…",
|
||||
"screen.redstoner.storage_terminal.no_results": "No items found.",
|
||||
"screen.redstoner.storage_terminal.summary": "%s/%s · %s blocks · %s types",
|
||||
"screen.redstoner.storage_terminal.error.invalid_terminal": "The terminal is no longer accessible.",
|
||||
"screen.redstoner.storage_terminal.error.disconnected": "No connected chest or barrel.",
|
||||
"screen.redstoner.storage_terminal.error.too_large": "Network too large (maximum 128 storage blocks).",
|
||||
"screen.redstoner.storage_terminal.error.locked": "The network contains a locked storage block."
|
||||
}
|
||||
|
||||
@@ -49,5 +49,14 @@
|
||||
"message.redstoner.computer.invalid": "Programme refusé : %s",
|
||||
"block.redstoner.particuleur": "Particuleur",
|
||||
"block.particuleur.particuleur": "Particuleur historique",
|
||||
"tooltip.redstoner.particuleur": "Insérez un catalyseur puis alimentez le bloc en redstone."
|
||||
"tooltip.redstoner.particuleur": "Insérez un catalyseur puis alimentez le bloc en redstone.",
|
||||
"block.redstoner.storage_terminal": "Terminal de stockage",
|
||||
"screen.redstoner.storage_terminal.title": "Terminal de stockage",
|
||||
"screen.redstoner.storage_terminal.search": "Rechercher…",
|
||||
"screen.redstoner.storage_terminal.no_results": "Aucun objet trouvé.",
|
||||
"screen.redstoner.storage_terminal.summary": "%s/%s · %s blocs · %s types",
|
||||
"screen.redstoner.storage_terminal.error.invalid_terminal": "Le terminal n'est plus accessible.",
|
||||
"screen.redstoner.storage_terminal.error.disconnected": "Aucun coffre ou baril connecté.",
|
||||
"screen.redstoner.storage_terminal.error.too_large": "Réseau trop grand (128 stockages maximum).",
|
||||
"screen.redstoner.storage_terminal.error.locked": "Le réseau contient un stockage verrouillé."
|
||||
}
|
||||
|
||||
@@ -49,5 +49,14 @@
|
||||
"message.redstoner.computer.invalid": "Программа отклонена: %s",
|
||||
"block.redstoner.particuleur": "Генератор частиц",
|
||||
"block.particuleur.particuleur": "Старый генератор частиц",
|
||||
"tooltip.redstoner.particuleur": "Вставьте катализатор и подайте сигнал редстоуна."
|
||||
"tooltip.redstoner.particuleur": "Вставьте катализатор и подайте сигнал редстоуна.",
|
||||
"block.redstoner.storage_terminal": "Терминал хранилища",
|
||||
"screen.redstoner.storage_terminal.title": "Терминал хранилища",
|
||||
"screen.redstoner.storage_terminal.search": "Поиск…",
|
||||
"screen.redstoner.storage_terminal.no_results": "Предметы не найдены.",
|
||||
"screen.redstoner.storage_terminal.summary": "%s/%s · блоков: %s · типов: %s",
|
||||
"screen.redstoner.storage_terminal.error.invalid_terminal": "Терминал больше недоступен.",
|
||||
"screen.redstoner.storage_terminal.error.disconnected": "Нет подключённых сундуков или бочек.",
|
||||
"screen.redstoner.storage_terminal.error.too_large": "Сеть слишком велика (не более 128 хранилищ).",
|
||||
"screen.redstoner.storage_terminal.error.locked": "В сети есть запертое хранилище."
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"parent": "minecraft:block/block",
|
||||
"textures": {
|
||||
"particle": "redstoner:block/redstone_controller_front",
|
||||
"front": "redstoner:block/redstone_controller_front",
|
||||
"back": "redstoner:block/redstone_controller_back",
|
||||
"side": "redstoner:block/redstone_controller_right",
|
||||
"top": "redstoner:block/redstone_controller_top",
|
||||
"bottom": "redstoner:block/redstone_controller_bottom"
|
||||
},
|
||||
"elements": [
|
||||
{
|
||||
"from": [0, 0, 8],
|
||||
"to": [16, 16, 16],
|
||||
"faces": {
|
||||
"north": { "uv": [0, 0, 16, 16], "texture": "#front" },
|
||||
"south": { "uv": [0, 0, 16, 16], "texture": "#back" },
|
||||
"west": { "uv": [8, 0, 16, 16], "texture": "#side" },
|
||||
"east": { "uv": [0, 0, 8, 16], "texture": "#side" },
|
||||
"up": { "uv": [0, 8, 16, 16], "texture": "#top" },
|
||||
"down": { "uv": [0, 0, 16, 8], "texture": "#bottom" }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
{"replace":false,"values":["redstoner:eight_bit_computer","redstoner:controller","redstoner:personal_computer","redstoner:particuleur","redstone_computer:redstone_computer","redstone_computer:redstone_controller","redstone_computer:personal_computer","particuleur:particuleur"]}
|
||||
{"replace":false,"values":["redstoner:eight_bit_computer","redstoner:controller","redstoner:personal_computer","redstoner:particuleur","redstoner:storage_terminal","redstone_computer:redstone_computer","redstone_computer:redstone_controller","redstone_computer:personal_computer","particuleur:particuleur"]}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"replace":false,"values":["redstoner:eight_bit_computer","redstoner:controller","redstoner:personal_computer","redstoner:particuleur","redstone_computer:redstone_computer","redstone_computer:redstone_controller","redstone_computer:personal_computer","particuleur:particuleur"]}
|
||||
{"replace":false,"values":["redstoner:eight_bit_computer","redstoner:controller","redstoner:personal_computer","redstoner:particuleur","redstoner:storage_terminal","redstone_computer:redstone_computer","redstone_computer:redstone_controller","redstone_computer:personal_computer","particuleur:particuleur"]}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"type": "minecraft:block",
|
||||
"pools": [
|
||||
{
|
||||
"rolls": 1,
|
||||
"entries": [
|
||||
{
|
||||
"type": "minecraft:item",
|
||||
"name": "redstoner:storage_terminal"
|
||||
}
|
||||
],
|
||||
"conditions": [
|
||||
{
|
||||
"condition": "minecraft:survives_explosion"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"random_sequence": "redstoner:blocks/storage_terminal"
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"type": "minecraft:crafting_shaped",
|
||||
"category": "redstone",
|
||||
"pattern": [
|
||||
"PGP",
|
||||
"GCG",
|
||||
"PTP"
|
||||
],
|
||||
"key": {
|
||||
"P": "anotherworld:silver_plate",
|
||||
"G": "minecraft:tinted_glass",
|
||||
"C": "redstoner:controller",
|
||||
"T": "anotherworld:titanium_ingot"
|
||||
},
|
||||
"result": {
|
||||
"id": "redstoner:storage_terminal",
|
||||
"count": 1
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@
|
||||
"system_type": "gameplay-system",
|
||||
"namespace": "redstoner",
|
||||
"data_version": 3,
|
||||
"network_protocol": 5,
|
||||
"network_protocol": 7,
|
||||
"lifecycle": "${lifecycle}",
|
||||
"pack_version": "${pack_version}",
|
||||
"descriptions": {
|
||||
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
package fr.koka99cab.sanctuary26.redstoner.storage;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Holder;
|
||||
import net.minecraft.core.component.DataComponentMap;
|
||||
import net.minecraft.core.component.DataComponents;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import net.minecraft.world.SimpleContainer;
|
||||
import net.minecraft.world.item.Item;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.item.Items;
|
||||
|
||||
public final class StorageTerminalSmoke {
|
||||
private StorageTerminalSmoke() {
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
SharedConstants.tryDetectVersion();
|
||||
Bootstrap.bootStrap();
|
||||
verifyTopology();
|
||||
verifyPagination();
|
||||
verifyAggregationAndTransfers();
|
||||
verifySearch();
|
||||
verifyStableLayout();
|
||||
}
|
||||
|
||||
private static void verifyTopology() {
|
||||
Set<BlockPos> line = new HashSet<>();
|
||||
for (int x = 0; x < StorageTerminalRules.MAX_CONTAINERS; x++) line.add(new BlockPos(x, 0, 0));
|
||||
StorageTerminalTopology.Result exact = StorageTerminalTopology.discover(BlockPos.ZERO, line::contains);
|
||||
require(!exact.tooLarge() && exact.positions().size() == StorageTerminalRules.MAX_CONTAINERS,
|
||||
"An exact 128-container network was rejected");
|
||||
|
||||
line.add(new BlockPos(StorageTerminalRules.MAX_CONTAINERS, 0, 0));
|
||||
StorageTerminalTopology.Result oversized = StorageTerminalTopology.discover(BlockPos.ZERO, line::contains);
|
||||
require(oversized.tooLarge() && oversized.positions().size() == StorageTerminalRules.MAX_CONTAINERS,
|
||||
"A 129-container network was not bounded");
|
||||
|
||||
Set<BlockPos> loaded = Set.of(BlockPos.ZERO, new BlockPos(0, 1, 0), new BlockPos(0, 2, 0),
|
||||
new BlockPos(20, 0, 0));
|
||||
StorageTerminalTopology.Result bounded = StorageTerminalTopology.discover(BlockPos.ZERO, loaded::contains);
|
||||
require(!bounded.tooLarge() && bounded.positions().size() == 3
|
||||
&& !bounded.positions().contains(new BlockPos(20, 0, 0)),
|
||||
"Topology crossed a disconnected or unloaded gap");
|
||||
}
|
||||
|
||||
private static void verifyPagination() {
|
||||
require(StorageTerminalRules.pageCount(0) == 1, "An empty terminal must keep one page");
|
||||
require(StorageTerminalRules.pageCount(54) == 1, "A full visible page overflowed");
|
||||
require(StorageTerminalRules.pageCount(55) == 2, "The second page did not open");
|
||||
require(StorageTerminalRules.clampPage(999, 55) == 1, "A hostile page index was not clamped");
|
||||
int networkMaximum = StorageTerminalRules.MAX_CONTAINERS
|
||||
* StorageTerminalRules.SLOTS_PER_CONTAINER * 64;
|
||||
require(StorageTerminalRules.totalFromWords(
|
||||
(short) StorageTerminalRules.totalLowWord(networkMaximum),
|
||||
(short) StorageTerminalRules.totalHighWord(networkMaximum)) == networkMaximum,
|
||||
"A large aggregated quantity was truncated by menu data synchronization");
|
||||
require(StorageTerminalRules.compactCount(999).equals("999")
|
||||
&& StorageTerminalRules.compactCount(1_000).equals("1K")
|
||||
&& StorageTerminalRules.compactCount(1_250).equals("1.2K")
|
||||
&& StorageTerminalRules.compactCount(12_345).equals("12K")
|
||||
&& StorageTerminalRules.compactCount(1_250_000).equals("1.2M"),
|
||||
"Terminal quantities were not compacted without overstating their value");
|
||||
}
|
||||
|
||||
private static void verifyAggregationAndTransfers() {
|
||||
SimpleContainer first = new SimpleContainer(27);
|
||||
SimpleContainer second = new SimpleContainer(27);
|
||||
Holder<Item> cobblestone = new Holder.Direct<>(Items.COBBLESTONE, DataComponentMap.builder()
|
||||
.set(DataComponents.MAX_STACK_SIZE, 64).build());
|
||||
first.setItem(0, new ItemStack(cobblestone, 60));
|
||||
ItemStack named = new ItemStack(cobblestone, 5);
|
||||
named.set(DataComponents.CUSTOM_NAME, Component.literal("Distinct"));
|
||||
second.setItem(3, named);
|
||||
|
||||
ItemStack deposit = new ItemStack(cobblestone, 10);
|
||||
int inserted = StorageTerminalInventory.insert(List.of(first, second), deposit);
|
||||
require(inserted == 10 && deposit.isEmpty(), "Terminal deposit lost or retained items");
|
||||
require(first.getItem(0).getCount() == 64 && first.getItem(1).getCount() == 6,
|
||||
"Terminal deposit did not fill matching stacks before empty slots");
|
||||
|
||||
SimpleContainer earliest = new SimpleContainer(1);
|
||||
SimpleContainer laterMatch = new SimpleContainer(1);
|
||||
laterMatch.setItem(0, new ItemStack(cobblestone, 60));
|
||||
ItemStack orderedDeposit = new ItemStack(cobblestone, 10);
|
||||
StorageTerminalInventory.insert(List.of(earliest, laterMatch), orderedDeposit);
|
||||
require(earliest.getItem(0).getCount() == 10 && laterMatch.getItem(0).getCount() == 60,
|
||||
"Terminal deposit skipped the first available physical container");
|
||||
|
||||
List<StorageTerminalInventory.Entry> entries = StorageTerminalInventory.aggregate(List.of(first, second));
|
||||
require(entries.size() == 2, "Items with distinct components were merged");
|
||||
StorageTerminalInventory.Entry plain = entries.stream()
|
||||
.filter(entry -> !entry.stack().has(DataComponents.CUSTOM_NAME)).findFirst().orElseThrow();
|
||||
require(plain.total() == 70, "Aggregated terminal quantity is incorrect");
|
||||
|
||||
ItemStack taken = StorageTerminalInventory.take(List.of(first, second), plain.stack(), 64);
|
||||
require(taken.getCount() == 64 && ItemStack.isSameItemSameComponents(taken, plain.stack()),
|
||||
"Terminal extraction changed item identity or quantity");
|
||||
List<StorageTerminalInventory.Entry> remaining = StorageTerminalInventory.aggregate(List.of(first, second));
|
||||
require(remaining.stream().filter(entry -> !entry.stack().has(DataComponents.CUSTOM_NAME))
|
||||
.findFirst().orElseThrow().total() == 6, "Terminal extraction did not update physical slots");
|
||||
}
|
||||
|
||||
private static void verifySearch() {
|
||||
SimpleContainer container = new SimpleContainer(4);
|
||||
container.setItem(0, new ItemStack(stackable(Items.COBBLESTONE), 12));
|
||||
ItemStack accented = new ItemStack(stackable(Items.DIAMOND), 2);
|
||||
accented.set(DataComponents.CUSTOM_NAME, Component.literal("Élément spécial"));
|
||||
container.setItem(1, accented);
|
||||
ItemStack russian = new ItemStack(stackable(Items.REDSTONE), 3);
|
||||
russian.set(DataComponents.CUSTOM_NAME, Component.literal("Красный камень"));
|
||||
container.setItem(2, russian);
|
||||
|
||||
List<StorageTerminalInventory.Entry> entries = StorageTerminalInventory.aggregate(List.of(container));
|
||||
require(StorageTerminalSearch.filter(entries, "minecraft:cobblestone").size() == 1,
|
||||
"Terminal search did not match a namespaced item id");
|
||||
require(StorageTerminalSearch.filter(entries, "cobble stone").size() == 1,
|
||||
"Terminal search did not combine multiple terms");
|
||||
require(StorageTerminalSearch.filter(entries, "element special").size() == 1,
|
||||
"Terminal search did not ignore accents in a custom name");
|
||||
require(StorageTerminalSearch.filter(entries, "красный камень").size() == 1,
|
||||
"Terminal search did not preserve non-Latin keyboard input");
|
||||
require(StorageTerminalSearch.filter(entries, "missing").isEmpty(),
|
||||
"Terminal search retained an unrelated item");
|
||||
}
|
||||
|
||||
private static Holder<Item> stackable(Item item) {
|
||||
return new Holder.Direct<>(item, DataComponentMap.builder()
|
||||
.set(DataComponents.MAX_STACK_SIZE, 64).build());
|
||||
}
|
||||
|
||||
private static void verifyStableLayout() {
|
||||
StorageTerminalInventory.Entry stone = new StorageTerminalInventory.Entry(
|
||||
new ItemStack(stackable(Items.STONE)), 64);
|
||||
StorageTerminalInventory.Entry dirt = new StorageTerminalInventory.Entry(
|
||||
new ItemStack(stackable(Items.DIRT)), 64);
|
||||
StorageTerminalInventory.Entry diamond = new StorageTerminalInventory.Entry(
|
||||
new ItemStack(stackable(Items.DIAMOND)), 8);
|
||||
StorageTerminalLayout layout = new StorageTerminalLayout();
|
||||
layout.update(List.of(stone, dirt), true);
|
||||
layout.update(List.of(dirt, diamond), false);
|
||||
List<StorageTerminalInventory.Entry> page = layout.page(0);
|
||||
require(page.get(0).total() == 0 && ItemStack.isSameItemSameComponents(page.get(0).stack(), stone.stack()),
|
||||
"An exhausted terminal resource moved another item under the cursor");
|
||||
require(ItemStack.isSameItemSameComponents(page.get(1).stack(), dirt.stack())
|
||||
&& ItemStack.isSameItemSameComponents(page.get(2).stack(), diamond.stack()),
|
||||
"A terminal refresh did not preserve existing slots or append a new resource");
|
||||
layout.update(List.of(dirt, diamond), true);
|
||||
require(ItemStack.isSameItemSameComponents(layout.page(0).get(0).stack(), dirt.stack()),
|
||||
"A deliberate terminal layout reset did not compact the visible resources");
|
||||
}
|
||||
|
||||
private static void require(boolean condition, String message) {
|
||||
if (!condition) throw new IllegalStateException(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
# Dimension Alpha / Indev
|
||||
|
||||
`sanctuary:alpha` est la dimension historique finie de Sanctuary. Sanctuary
|
||||
possède son identifiant, son type de dimension et son générateur ; I Like To
|
||||
Move It possède exclusivement son portail et sa persistance.
|
||||
|
||||
## Contrat de génération
|
||||
|
||||
- volume historique exact : **128 × 128 × 64**, de X/Z `-64` à `63` et de
|
||||
Y `0` à `63` ;
|
||||
- terrain déterministe par seed : relief insulaire, plages, cavernes, minerais,
|
||||
eau, lave, végétation et maison de départ Indev ;
|
||||
- anneau extérieur en barrières, fond en bedrock et aucun placement au-delà de
|
||||
l'enveloppe finie ;
|
||||
- résidence et arrivée de portail calculées par l'API publique en lecture seule
|
||||
`SanctuaryAlphaWorld` ; l'arrivée est sèche, dégagée et séparée de la maison ;
|
||||
- la dimension est déclarée dans les profils Sanctuary et Overworld et reste
|
||||
disponible en solo comme sur serveur dédié.
|
||||
|
||||
Une même seed reconstruit le même volume, la même maison et les mêmes positions.
|
||||
L'ancien identifiant `portals:alpha` est migré vers `sanctuary:alpha` ; aucun
|
||||
nouvel identifiant persistant de Portals n'est repris.
|
||||
|
||||
## Accès
|
||||
|
||||
En jeu normal, construire un cadre extérieur de 4 × 5 en cobblestone moussue,
|
||||
laisser un intérieur vide de 2 × 3 et l'allumer au briquet. La première
|
||||
traversée crée une moitié distante à l'arrivée sûre ; les traversées suivantes
|
||||
réutilisent la paire persistée.
|
||||
|
||||
Au laboratoire, un opérateur peut vérifier le chargement de la dimension avec
|
||||
`/masterkey teleport @s sanctuary:alpha 0 48 0`. Cette commande téléporte aux
|
||||
coordonnées demandées sans recherche de surface : utiliser le portail pour
|
||||
l'arrivée sûre du gameplay normal.
|
||||
|
||||
Les textures historiques facultatives sont décrites dans
|
||||
[`ambiance/GOLDEN_DAYS.md`](../ambiance/GOLDEN_DAYS.md). Elles ne font pas
|
||||
partie de Sanctuary.
|
||||
@@ -0,0 +1,36 @@
|
||||
# Third-party notices
|
||||
|
||||
## minecraft-python — Indev 20100223 level generator
|
||||
|
||||
The `AlphaIndevChunkFeature` terrain algorithm is adapted from the
|
||||
`minecraft-python` Indev 20100223 implementation by pythonengineer.
|
||||
|
||||
Source: https://github.com/pythonengineer/minecraft-python/tree/indev-20100223
|
||||
|
||||
BSD 3-Clause License
|
||||
|
||||
Copyright (c) 2023 pythonengineer
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice,
|
||||
this list of conditions and the following disclaimer.
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
3. Neither the name of the copyright holder nor the names of its contributors
|
||||
may be used to endorse or promote products derived from this software
|
||||
without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
+99
-14
@@ -6,6 +6,12 @@ dependencies {
|
||||
implementation "maven.modrinth:u6dRKJwZ:VI3quY4l"
|
||||
}
|
||||
|
||||
tasks.named("jar") {
|
||||
from("THIRD_PARTY_NOTICES.md") {
|
||||
rename { "THIRD_PARTY_NOTICES_sanctuary.md" }
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register("progressionModelSmoke", JavaExec) {
|
||||
group = "verification"
|
||||
description = "Round-trips the Sanctuary v3 statistic and unlock codec."
|
||||
@@ -62,6 +68,22 @@ tasks.register("questRequestModelSmoke", JavaExec) {
|
||||
mainClass = "fr.koka99cab.sanctuary26.sanctuary.shop.QuestRequestModelSmoke"
|
||||
}
|
||||
|
||||
tasks.register("questTrackerModelSmoke", JavaExec) {
|
||||
group = "verification"
|
||||
description = "Checks the bounded quest-tracker snapshot used by the HUD concept."
|
||||
dependsOn tasks.named("testClasses")
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = "fr.koka99cab.sanctuary26.sanctuary.shop.QuestTrackerModelSmoke"
|
||||
}
|
||||
|
||||
tasks.register("questIdSuggestionsSmoke", JavaExec) {
|
||||
group = "verification"
|
||||
description = "Checks accent-folded quest-panel item name ranking and inventory pins."
|
||||
dependsOn tasks.named("testClasses")
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = "fr.koka99cab.sanctuary26.sanctuary.shop.QuestIdSuggestionsSmoke"
|
||||
}
|
||||
|
||||
tasks.register("shopHeaderLayoutSmoke", JavaExec) {
|
||||
group = "verification"
|
||||
description = "Checks that the daily Shop timer and unlocked-offer status cannot overlap."
|
||||
@@ -86,6 +108,14 @@ tasks.register("capeJoinSyncSmoke", JavaExec) {
|
||||
mainClass = "fr.koka99cab.sanctuary26.sanctuary.gameplay.CapeJoinSyncQueueSmoke"
|
||||
}
|
||||
|
||||
tasks.register("alphaIndevGeneratorSmoke", JavaExec) {
|
||||
group = "verification"
|
||||
description = "Checks deterministic finite Indev islands, residences and safe portal arrivals."
|
||||
dependsOn tasks.named("testClasses")
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = "fr.koka99cab.sanctuary26.sanctuary.worldgen.feature.AlphaIndevGeneratorSmoke"
|
||||
}
|
||||
|
||||
tasks.register("verifyBlackMarket") {
|
||||
group = "verification"
|
||||
description = "Checks the physical sign market, flash Shop, escrow ledger and mailbox delivery."
|
||||
@@ -121,7 +151,7 @@ tasks.register("verifyBlackMarket") {
|
||||
def questData = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/QuestBoardData.java").text
|
||||
def quests = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/QuestBoardService.java").text
|
||||
def inventoryView = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/api/inventory/SanctuaryPlayerInventoryView.java").text
|
||||
if (project.version.toString() != "0.0.0-alpha.119"
|
||||
if (project.version.toString() != "0.0.0-alpha.124"
|
||||
|| !data.contains('SAVE_KEY = "black_market"') || !data.contains("DATA_VERSION = 2")
|
||||
|| !data.contains("ItemStack.CODEC.fieldOf(\"item\")") || !data.contains("replaceListing")
|
||||
|| !service.contains("LISTING_FEE_RUBIES = 1") || !service.contains("COMMISSION_PERCENT = 5")
|
||||
@@ -132,7 +162,7 @@ tasks.register("verifyBlackMarket") {
|
||||
|| !interactionRules.contains("middleClick || secondaryUse")
|
||||
|| !client.contains("handleBlackMarketMiddleClick")
|
||||
|| !client.contains("ClientPlayNetworking.send(new BlackMarketInteractPayload())")
|
||||
|| !minecraftMixin.contains('method = "pickBlock"')
|
||||
|| !minecraftMixin.contains('method = "pickBlockOrEntity"')
|
||||
|| !middleClickPayload.contains('SanctuaryMod.id("c2s/black_market_interact")')
|
||||
|| !middleClickPayload.contains("No client position, listing ID, item or price is trusted")
|
||||
|| !network.contains("PROTOCOL_VERSION = 22")
|
||||
@@ -264,7 +294,9 @@ tasks.register("verifySanctuary") {
|
||||
inputs.files(fileTree("tools"))
|
||||
dependsOn tasks.named("progressionModelSmoke"), tasks.named("serverIdentityModelSmoke"),
|
||||
tasks.named("shopDeliveryTimingSmoke"), tasks.named("blackMarketModelSmoke"),
|
||||
tasks.named("questBoardModelSmoke"), tasks.named("shopHeaderLayoutSmoke"), tasks.named("zombieNamesSmoke"),
|
||||
tasks.named("questBoardModelSmoke"), tasks.named("questTrackerModelSmoke"),
|
||||
tasks.named("questIdSuggestionsSmoke"),
|
||||
tasks.named("shopHeaderLayoutSmoke"), tasks.named("zombieNamesSmoke"),
|
||||
tasks.named("capeJoinSyncSmoke"),
|
||||
tasks.named("verifyBlackMarket")
|
||||
|
||||
@@ -1602,8 +1634,8 @@ tasks.register("verifySanctuary") {
|
||||
|
||||
def worldgenRoot = file("src/main/resources/data/sanctuary/worldgen")
|
||||
def worldgenCounts = [
|
||||
density_function: 12, biome: 5, configured_feature: 12, configured_carver: 1,
|
||||
placed_feature: 18, noise_settings: 2, world_preset: 2
|
||||
density_function: 12, biome: 6, configured_feature: 13, configured_carver: 1,
|
||||
placed_feature: 19, noise_settings: 3, world_preset: 2
|
||||
]
|
||||
worldgenCounts.each { type, expected ->
|
||||
def actual = fileTree(new File(worldgenRoot, type.toString())).matching { include "**/*.json" }.files.size()
|
||||
@@ -2818,7 +2850,7 @@ tasks.register("verifyShopCurrencyRowsRelease") {
|
||||
"src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/BlackMarketService.java").text
|
||||
def shop = file(
|
||||
"src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/ShopService.java").text
|
||||
if (project.version.toString() != "0.0.0-alpha.119"
|
||||
if (project.version.toString() != "0.0.0-alpha.124"
|
||||
|| migration.target?.modules?.sanctuary != "0.0.0-alpha.117"
|
||||
|| migration.shop_currency_inventory?.all_unlocked_rows_counted != true
|
||||
|| migration.shop_currency_inventory?.overflow_rows_counted != true
|
||||
@@ -2984,7 +3016,7 @@ tasks.register("verifyProgressivePauseMenu") {
|
||||
"pack/migrations/26.2.0-alpha.190-progressive-pause-menu-to-alpha.191.json"))
|
||||
def pauseMenu = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/mixin/client/PauseScreenMixin.java").text
|
||||
def costs = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/progression/SanctuaryProgressionCosts.java").text
|
||||
if (project.version.toString() != "0.0.0-alpha.119"
|
||||
if (project.version.toString() != "0.0.0-alpha.124"
|
||||
|| migration.target?.pack_version != "26.2.0-alpha.191"
|
||||
|| migration.target?.modules?.sanctuary != "0.0.0-alpha.109"
|
||||
|| migration.pause_menu?.progression_full_width != true
|
||||
@@ -3022,7 +3054,7 @@ tasks.register("verifyShopPricingLayout") {
|
||||
"pack/migrations/26.2.0-alpha.192-shop-pricing-layout-to-alpha.193.json"))
|
||||
def screen = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/client/SanctuaryShopScreen.java").text
|
||||
def progression = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/ShopProgression.java").text
|
||||
if (project.version.toString() != "0.0.0-alpha.119"
|
||||
if (project.version.toString() != "0.0.0-alpha.124"
|
||||
|| migration.target?.modules?.sanctuary != "0.0.0-alpha.110"
|
||||
|| migration.shop_layout?.locked_offer_buttons != 6
|
||||
|| migration.shop_layout?.button_slots != [4, 5, 6, 7, 8, 9]
|
||||
@@ -3053,7 +3085,7 @@ tasks.register("verifyShopCsvBossGates") {
|
||||
"pack/migrations/26.2.0-alpha.194-shop-csv-boss-gates-to-alpha.195.json"))
|
||||
def provider = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/ShopCatalogProvider.java").text
|
||||
def catalog = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/ShopOfferCatalog.java").text
|
||||
if (project.version.toString() != "0.0.0-alpha.119"
|
||||
if (project.version.toString() != "0.0.0-alpha.124"
|
||||
|| migration.target?.pack_version != "26.2.0-alpha.195"
|
||||
|| migration.target?.modules?.sanctuary != "0.0.0-alpha.111"
|
||||
|| migration.compatibility?.sanctuary_data_version != 11
|
||||
@@ -3084,7 +3116,7 @@ tasks.register("verifyDeliveryBoxOrientation") {
|
||||
def blockstate = new JsonSlurper().parse(
|
||||
file("src/main/resources/assets/sanctuary/blockstates/delivery_box.json"))
|
||||
def expectedVariants = ["facing=north", "facing=east", "facing=south", "facing=west"].toSet()
|
||||
if (project.version.toString() != "0.0.0-alpha.119"
|
||||
if (project.version.toString() != "0.0.0-alpha.124"
|
||||
|| migration.target?.modules?.sanctuary != "0.0.0-alpha.112"
|
||||
|| migration.mailbox?.id != "sanctuary:delivery_box"
|
||||
|| migration.mailbox?.horizontal_facing != true
|
||||
@@ -3112,7 +3144,7 @@ tasks.register("verifyInventoryShiftClick") {
|
||||
def mixin = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/mixin/AbstractContainerMenuMixin.java").text
|
||||
def screen = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/mixin/client/AbstractContainerScreenMixin.java").text
|
||||
def gui = file("INVENTORY_GUI.md").text
|
||||
if (project.version.toString() != "0.0.0-alpha.119"
|
||||
if (project.version.toString() != "0.0.0-alpha.124"
|
||||
|| !transfer.contains("shouldAppendOverflow")
|
||||
|| !transfer.contains("isVanillaStorageRange")
|
||||
|| !transfer.contains("hotbarStart")
|
||||
@@ -3158,7 +3190,7 @@ tasks.register("verifyQuestRequests") {
|
||||
new JsonSlurper().parse(file("src/main/resources/data/sanctuary/loot_table/blocks/${path}.json"))
|
||||
.pools[0].entries[0].name
|
||||
}
|
||||
if (project.version.toString() != "0.0.0-alpha.119"
|
||||
if (project.version.toString() != "0.0.0-alpha.124"
|
||||
|| migration.target?.modules?.sanctuary != "0.0.0-alpha.113"
|
||||
|| migration.quest_requests?.save_key != "quest_requests"
|
||||
|| migration.quest_requests?.data_version != 1
|
||||
@@ -3216,7 +3248,7 @@ tasks.register("verifyQuestRequestContracts") {
|
||||
def locales = ["fr_fr", "en_us", "ru_ru"].collectEntries { locale ->
|
||||
[(locale): new JsonSlurper().parse(file("src/main/resources/assets/sanctuary/lang/${locale}.json"))]
|
||||
}
|
||||
if (project.version.toString() != "0.0.0-alpha.119"
|
||||
if (project.version.toString() != "0.0.0-alpha.124"
|
||||
|| migration.source?.pack_version != "26.2.0-alpha.198"
|
||||
|| migration.source?.modules?.sanctuary != "0.0.0-alpha.114"
|
||||
|| migration.target?.pack_version != "26.2.0-alpha.199"
|
||||
@@ -3285,7 +3317,7 @@ tasks.register("verifyQuestRequestEscrow") {
|
||||
def locales = ["fr_fr", "en_us", "ru_ru"].collectEntries { locale ->
|
||||
[(locale): new JsonSlurper().parse(file("src/main/resources/assets/sanctuary/lang/${locale}.json"))]
|
||||
}
|
||||
if (project.version.toString() != "0.0.0-alpha.119"
|
||||
if (project.version.toString() != "0.0.0-alpha.124"
|
||||
|| migration.source?.pack_version != "26.2.0-alpha.200"
|
||||
|| migration.source?.modules?.sanctuary != "0.0.0-alpha.115"
|
||||
|| migration.target?.pack_version != "26.2.0-alpha.201"
|
||||
@@ -3330,6 +3362,55 @@ tasks.named("verifySanctuary") {
|
||||
dependsOn tasks.named("verifyInventoryShiftClick")
|
||||
}
|
||||
|
||||
tasks.register("verifyAlphaIndev") {
|
||||
group = "verification"
|
||||
description = "Checks the alpha.122 finite Indev world, migration and attribution contract."
|
||||
inputs.file(file("ALPHA.md"))
|
||||
inputs.file(file("THIRD_PARTY_NOTICES.md"))
|
||||
inputs.file(file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/api/world/SanctuaryAlphaWorld.java"))
|
||||
inputs.file(file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/worldgen/feature/AlphaIndevChunkFeature.java"))
|
||||
inputs.files(fileTree("src/main/resources/data/sanctuary") { include "**/alpha*.json" })
|
||||
inputs.files(fileTree("src/main/resources/assets/sanctuary/lang") { include "*.json" })
|
||||
inputs.file(rootProject.file("pack/migrations/26.2.0-alpha.218-alpha-indev-to-alpha.219.json"))
|
||||
doLast {
|
||||
def migration = new JsonSlurper().parse(rootProject.file(
|
||||
"pack/migrations/26.2.0-alpha.218-alpha-indev-to-alpha.219.json"))
|
||||
def presetFiles = ["sanctuary", "overworld"].collect {
|
||||
new JsonSlurper().parse(file("src/main/resources/data/sanctuary/worldgen/world_preset/${it}.json"))
|
||||
}
|
||||
def dimensionType = new JsonSlurper().parse(file(
|
||||
"src/main/resources/data/sanctuary/dimension_type/alpha.json"))
|
||||
def configured = new JsonSlurper().parse(file(
|
||||
"src/main/resources/data/sanctuary/worldgen/configured_feature/alpha_indev_chunk.json"))
|
||||
def javaText = [
|
||||
file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/api/world/SanctuaryAlphaWorld.java"),
|
||||
file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/worldgen/feature/AlphaIndevChunkFeature.java")
|
||||
]*.text.join("\n")
|
||||
def locales = ["fr_fr", "en_us", "ru_ru"].collect {
|
||||
new JsonSlurper().parse(file("src/main/resources/assets/sanctuary/lang/${it}.json"))
|
||||
}
|
||||
if (project.version.toString() != "0.0.0-alpha.124"
|
||||
|| migration.target?.modules?.sanctuary != "0.0.0-alpha.122"
|
||||
|| migration.alpha_world?.dimension != "sanctuary:alpha"
|
||||
|| migration.alpha_world?.legacy_dimension != "portals:alpha"
|
||||
|| migration.alpha_world?.width != 128 || migration.alpha_world?.depth != 128
|
||||
|| migration.alpha_world?.height != 64 || migration.alpha_world?.deterministic_per_seed != true
|
||||
|| migration.alpha_world?.finite_barrier_boundary != true
|
||||
|| presetFiles.any { it.dimensions?.get("sanctuary:alpha") == null }
|
||||
|| dimensionType.min_y != 0 || dimensionType.height != 64 || dimensionType.logical_height != 64
|
||||
|| configured.type != "sanctuary:alpha_indev_chunk"
|
||||
|| !javaText.contains("public static final int WIDTH = 128")
|
||||
|| !javaText.contains("public static final int HEIGHT = 64")
|
||||
|| !javaText.contains("Blocks.BARRIER.defaultBlockState()")
|
||||
|| !javaText.contains("Blocks.BEDROCK.defaultBlockState()")
|
||||
|| !javaText.contains("public static BlockPos portalArrival(long seed)")
|
||||
|| locales.any { !it.containsKey("dimension.sanctuary.alpha") }
|
||||
|| !file("THIRD_PARTY_NOTICES.md").text.contains("BSD 3-Clause License")) {
|
||||
throw new GradleException("Sanctuary alpha.122 finite Indev contract is incomplete")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.named("check") {
|
||||
dependsOn tasks.named("verifySanctuary")
|
||||
dependsOn tasks.named("verifyDeliveryBoxOrientation")
|
||||
@@ -3339,4 +3420,8 @@ tasks.named("check") {
|
||||
dependsOn tasks.named("verifyShopCurrencyRowsRelease")
|
||||
dependsOn tasks.named("celestialGraphSmoke")
|
||||
dependsOn tasks.named("questRequestModelSmoke")
|
||||
dependsOn tasks.named("questTrackerModelSmoke")
|
||||
dependsOn tasks.named("questIdSuggestionsSmoke")
|
||||
dependsOn tasks.named("alphaIndevGeneratorSmoke")
|
||||
dependsOn tasks.named("verifyAlphaIndev")
|
||||
}
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package fr.koka99cab.sanctuary26.sanctuary.api.world;
|
||||
|
||||
import fr.koka99cab.sanctuary26.sanctuary.worldgen.feature.AlphaIndevChunkFeature;
|
||||
import net.minecraft.core.BlockPos;
|
||||
|
||||
/** Public, read-only geometry contract for the finite {@code sanctuary:alpha} world. */
|
||||
public final class SanctuaryAlphaWorld {
|
||||
public static final int WIDTH = AlphaIndevChunkFeature.WIDTH;
|
||||
public static final int DEPTH = AlphaIndevChunkFeature.DEPTH;
|
||||
public static final int HEIGHT = AlphaIndevChunkFeature.HEIGHT;
|
||||
|
||||
private SanctuaryAlphaWorld() {
|
||||
}
|
||||
|
||||
/** Historical residence position inside the deterministic Indev starting house. */
|
||||
public static BlockPos residence(long seed) {
|
||||
return AlphaIndevChunkFeature.notchHome(seed);
|
||||
}
|
||||
|
||||
/** Safe portal feet position outside the house for this world's seed. */
|
||||
public static BlockPos portalArrival(long seed) {
|
||||
return AlphaIndevChunkFeature.portalArrival(seed);
|
||||
}
|
||||
|
||||
public static boolean containsHorizontal(BlockPos pos) {
|
||||
return pos.getX() >= AlphaIndevChunkFeature.MIN_X && pos.getX() <= AlphaIndevChunkFeature.MAX_X
|
||||
&& pos.getZ() >= AlphaIndevChunkFeature.MIN_Z && pos.getZ() <= AlphaIndevChunkFeature.MAX_Z;
|
||||
}
|
||||
}
|
||||
+310
-10
@@ -1,14 +1,29 @@
|
||||
package fr.koka99cab.sanctuary26.sanctuary.client;
|
||||
|
||||
import fr.koka99cab.sanctuary26.sanctuary.api.inventory.SanctuaryPlayerInventoryView;
|
||||
import fr.koka99cab.sanctuary26.sanctuary.network.QuestRequestEditorPayload;
|
||||
import fr.koka99cab.sanctuary26.sanctuary.network.QuestRequestSubmitPayload;
|
||||
import fr.koka99cab.sanctuary26.sanctuary.shop.QuestIdSuggestions;
|
||||
import fr.koka99cab.sanctuary26.sanctuary.shop.QuestRequestService;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
|
||||
import net.minecraft.client.gui.GuiGraphicsExtractor;
|
||||
import net.minecraft.client.gui.components.Button;
|
||||
import net.minecraft.client.gui.components.EditBox;
|
||||
import net.minecraft.client.gui.components.events.GuiEventListener;
|
||||
import net.minecraft.client.gui.screens.Screen;
|
||||
import net.minecraft.client.input.KeyEvent;
|
||||
import net.minecraft.client.input.MouseButtonEvent;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.world.item.Item;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.item.Items;
|
||||
import org.lwjgl.glfw.GLFW;
|
||||
|
||||
/** Complete bounded editor for mission target and promised reward contracts. */
|
||||
public final class QuestRequestEditorScreen extends Screen {
|
||||
@@ -19,8 +34,12 @@ public final class QuestRequestEditorScreen extends Screen {
|
||||
QuestRequestService.REWARD_GIFT, QuestRequestService.REWARD_PAYMENT,
|
||||
QuestRequestService.REWARD_LOAN
|
||||
};
|
||||
private static final int SUGGESTION_ROW = 16;
|
||||
private static final int SUGGESTION_MARGIN = 4;
|
||||
private final Screen parent;
|
||||
private final QuestRequestEditorPayload payload;
|
||||
private final List<QuestIdSuggestions.Entry> itemCatalog = new ArrayList<>();
|
||||
private final List<QuestIdSuggestions.Entry> mobCatalog = new ArrayList<>();
|
||||
private EditBox alias;
|
||||
private EditBox objective;
|
||||
private EditBox target;
|
||||
@@ -34,6 +53,10 @@ public final class QuestRequestEditorScreen extends Screen {
|
||||
private int actionIndex;
|
||||
private int rewardIndex;
|
||||
private int killTargetIndex;
|
||||
private int selectedSuggestion;
|
||||
private boolean applyingCompletion;
|
||||
private boolean suggestionsOpen;
|
||||
private List<QuestIdSuggestions.Match> suggestions = List.of();
|
||||
|
||||
public QuestRequestEditorScreen(Screen parent, QuestRequestEditorPayload payload) {
|
||||
super(Component.translatable("screen.sanctuary.quest_request.title"));
|
||||
@@ -51,6 +74,7 @@ public final class QuestRequestEditorScreen extends Screen {
|
||||
@Override
|
||||
protected void init() {
|
||||
int center = width / 2;
|
||||
fillCatalogs();
|
||||
alias = addRenderableWidget(new EditBox(font, center - 152, 48, 304, 20,
|
||||
Component.translatable("screen.sanctuary.quest_request.alias")));
|
||||
alias.setMaxLength(QuestRequestService.MAX_ALIAS_LENGTH);
|
||||
@@ -80,15 +104,17 @@ public final class QuestRequestEditorScreen extends Screen {
|
||||
quantity.setMaxLength(4);
|
||||
quantity.setValue(Integer.toString(payload.quantity()));
|
||||
|
||||
target = addRenderableWidget(new EditBox(font, center + 52, 118, 100, 20,
|
||||
target = addRenderableWidget(new EditBox(font, center + 70, 118, 82, 20,
|
||||
Component.translatable("screen.sanctuary.quest_request.target")));
|
||||
target.setMaxLength(QuestRequestService.MAX_TARGET_LENGTH);
|
||||
target.setValue(payload.target());
|
||||
target.setResponder(value -> onIdTyped());
|
||||
|
||||
reward = addRenderableWidget(Button.builder(rewardLabel(), button -> {
|
||||
rewardIndex = (rewardIndex + 1) % REWARDS.length;
|
||||
button.setMessage(rewardLabel());
|
||||
rewardTarget.setHint(rewardHint());
|
||||
closeSuggestions();
|
||||
}).bounds(center - 152, 157, 84, 20).build());
|
||||
|
||||
rewardQuantity = addRenderableWidget(new EditBox(font, center - 64, 157, 46, 20,
|
||||
@@ -96,11 +122,12 @@ public final class QuestRequestEditorScreen extends Screen {
|
||||
rewardQuantity.setMaxLength(4);
|
||||
rewardQuantity.setValue(Integer.toString(payload.rewardQuantity()));
|
||||
|
||||
rewardTarget = addRenderableWidget(new EditBox(font, center - 14, 157, 166, 20,
|
||||
rewardTarget = addRenderableWidget(new EditBox(font, center + 8, 157, 144, 20,
|
||||
Component.translatable("screen.sanctuary.quest_request.reward_target")));
|
||||
rewardTarget.setMaxLength(QuestRequestService.MAX_TARGET_LENGTH);
|
||||
rewardTarget.setValue(payload.rewardTarget());
|
||||
rewardTarget.setHint(rewardHint());
|
||||
rewardTarget.setResponder(value -> onIdTyped());
|
||||
|
||||
submit = addRenderableWidget(Button.builder(Component.translatable("screen.sanctuary.quest_request.publish"),
|
||||
button -> submit()).bounds(center - 152, 199, 149, 20).build());
|
||||
@@ -112,9 +139,16 @@ public final class QuestRequestEditorScreen extends Screen {
|
||||
|
||||
@Override
|
||||
public void tick() {
|
||||
if (suggestionsOpen) refreshSuggestions();
|
||||
submit.active = values() != null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setFocused(GuiEventListener listener) {
|
||||
super.setFocused(listener);
|
||||
if (listener != target && listener != rewardTarget) closeSuggestions();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void extractRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float tickDelta) {
|
||||
int center = width / 2;
|
||||
@@ -129,9 +163,53 @@ public final class QuestRequestEditorScreen extends Screen {
|
||||
center - 152, 107, 0xFFDBB7CA);
|
||||
graphics.text(font, Component.translatable("screen.sanctuary.quest_request.reward"),
|
||||
center - 152, 146, 0xFFF2C4D7);
|
||||
graphics.centeredText(font, Component.translatable("screen.sanctuary.quest_request.help"),
|
||||
center, 183, 0xFF9F8B95);
|
||||
graphics.centeredText(font, Component.translatable(idFieldFocused()
|
||||
? "screen.sanctuary.quest_request.id_help"
|
||||
: "screen.sanctuary.quest_request.help"), center, 183, 0xFF9F8B95);
|
||||
drawPreview(graphics, target, targetPreview(mouseX, mouseY), center + 52, 120);
|
||||
drawPreview(graphics, rewardTarget, rewardPreview(mouseX, mouseY), center - 14, 159);
|
||||
super.extractRenderState(graphics, mouseX, mouseY, tickDelta);
|
||||
renderSuggestions(graphics, mouseX, mouseY);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean keyPressed(KeyEvent event) {
|
||||
if (!suggestions.isEmpty()) {
|
||||
if (event.key() == GLFW.GLFW_KEY_DOWN) {
|
||||
selectedSuggestion = (selectedSuggestion + 1) % suggestions.size();
|
||||
return true;
|
||||
}
|
||||
if (event.key() == GLFW.GLFW_KEY_UP) {
|
||||
selectedSuggestion = (selectedSuggestion + suggestions.size() - 1) % suggestions.size();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return super.keyPressed(event);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) {
|
||||
int index = suggestionIndexAt(event.x(), event.y());
|
||||
if (index >= 0) {
|
||||
applySuggestion(index);
|
||||
return true;
|
||||
}
|
||||
boolean handled = super.mouseClicked(event, doubleClick);
|
||||
if (clickedSearchField(event.x(), event.y())) {
|
||||
suggestionsOpen = true;
|
||||
refreshSuggestions();
|
||||
} else closeSuggestions();
|
||||
return handled;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) {
|
||||
if (!suggestions.isEmpty() && suggestionPanel().contains(mouseX, mouseY)) {
|
||||
int delta = scrollY > 0 ? -1 : 1;
|
||||
selectedSuggestion = Math.floorMod(selectedSuggestion + delta, suggestions.size());
|
||||
return true;
|
||||
}
|
||||
return super.mouseScrolled(mouseX, mouseY, scrollX, scrollY);
|
||||
}
|
||||
|
||||
private void refreshMissionControls() {
|
||||
@@ -139,6 +217,7 @@ public final class QuestRequestEditorScreen extends Screen {
|
||||
targetKind.setMessage(targetKindLabel());
|
||||
targetKind.active = QuestRequestService.KILL.equals(currentAction());
|
||||
target.setHint(targetHint());
|
||||
closeSuggestions();
|
||||
}
|
||||
|
||||
private String currentAction() { return ACTIONS[actionIndex]; }
|
||||
@@ -172,14 +251,16 @@ public final class QuestRequestEditorScreen extends Screen {
|
||||
}
|
||||
|
||||
private Values values() {
|
||||
if (objective.getValue().isBlank() || target.getValue().isBlank()
|
||||
|| rewardTarget.getValue().isBlank()) return null;
|
||||
if (objective.getValue().isBlank()) return null;
|
||||
String resolvedTarget = resolveTarget();
|
||||
String resolvedReward = QuestIdSuggestions.resolve(itemCatalog, rewardTarget.getValue(), inventoryIds());
|
||||
if (resolvedTarget == null || resolvedReward == null) return null;
|
||||
try {
|
||||
int amount = Integer.parseInt(quantity.getValue());
|
||||
int rewardAmount = Integer.parseInt(rewardQuantity.getValue());
|
||||
if (amount < 1 || amount > QuestRequestService.MAX_QUANTITY
|
||||
|| rewardAmount < 1 || rewardAmount > QuestRequestService.MAX_QUANTITY) return null;
|
||||
return new Values(amount, rewardAmount);
|
||||
return new Values(resolvedTarget, amount, resolvedReward, rewardAmount);
|
||||
} catch (NumberFormatException exception) {
|
||||
return null;
|
||||
}
|
||||
@@ -189,13 +270,232 @@ public final class QuestRequestEditorScreen extends Screen {
|
||||
Values values = values();
|
||||
if (values == null || !ClientPlayNetworking.canSend(QuestRequestSubmitPayload.ID)) return;
|
||||
ClientPlayNetworking.send(new QuestRequestSubmitPayload(payload.pos(), alias.getValue(),
|
||||
objective.getValue(), currentAction(), currentTargetKind(), target.getValue(), values.quantity(),
|
||||
currentReward(), rewardTarget.getValue(), values.rewardQuantity()));
|
||||
objective.getValue(), currentAction(), currentTargetKind(), values.target(), values.quantity(),
|
||||
currentReward(), values.rewardTarget(), values.rewardQuantity()));
|
||||
onClose();
|
||||
}
|
||||
|
||||
private void fillCatalogs() {
|
||||
itemCatalog.clear();
|
||||
for (Item item : BuiltInRegistries.ITEM) {
|
||||
if (item == Items.AIR) continue;
|
||||
Identifier id = BuiltInRegistries.ITEM.getKey(item);
|
||||
itemCatalog.add(new QuestIdSuggestions.Entry(id.toString(), id.getPath(),
|
||||
new ItemStack(item).getHoverName().getString()));
|
||||
}
|
||||
mobCatalog.clear();
|
||||
for (var type : BuiltInRegistries.ENTITY_TYPE) {
|
||||
Identifier id = BuiltInRegistries.ENTITY_TYPE.getKey(type);
|
||||
if ("minecraft:player".equals(id.toString())) continue;
|
||||
mobCatalog.add(new QuestIdSuggestions.Entry(id.toString(), id.getPath(),
|
||||
type.getDescription().getString()));
|
||||
}
|
||||
}
|
||||
|
||||
private void onIdTyped() {
|
||||
if (applyingCompletion) return;
|
||||
suggestionsOpen = !focusedSearchValue().isBlank();
|
||||
refreshSuggestions();
|
||||
}
|
||||
|
||||
private void closeSuggestions() {
|
||||
suggestionsOpen = false;
|
||||
suggestions = List.of();
|
||||
selectedSuggestion = 0;
|
||||
}
|
||||
|
||||
private void refreshSuggestions() {
|
||||
if (applyingCompletion || !suggestionsOpen) {
|
||||
if (!suggestionsOpen) suggestions = List.of();
|
||||
return;
|
||||
}
|
||||
EditBox field = focusedIdField();
|
||||
if (field == null || field.getValue().isBlank()) {
|
||||
closeSuggestions();
|
||||
return;
|
||||
}
|
||||
boolean itemField = field == rewardTarget || QuestRequestService.ITEM.equals(currentTargetKind());
|
||||
List<QuestIdSuggestions.Match> next = QuestIdSuggestions.suggest(
|
||||
itemField ? itemCatalog : mobCatalog, field.getValue(),
|
||||
itemField ? inventoryIds() : Set.of(), QuestIdSuggestions.LIMIT);
|
||||
if (!next.equals(suggestions)) {
|
||||
suggestions = next;
|
||||
selectedSuggestion = 0;
|
||||
} else if (selectedSuggestion >= suggestions.size()) {
|
||||
selectedSuggestion = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private void applySuggestion(int index) {
|
||||
EditBox field = focusedIdField();
|
||||
if (field == null || index < 0 || index >= suggestions.size()) return;
|
||||
writeId(field, suggestions.get(index).id());
|
||||
closeSuggestions();
|
||||
}
|
||||
|
||||
private boolean clickedSearchField(double mouseX, double mouseY) {
|
||||
if (contains(rewardTarget, mouseX, mouseY)) return true;
|
||||
return contains(target, mouseX, mouseY)
|
||||
&& !QuestRequestService.PLAYER.equals(currentTargetKind());
|
||||
}
|
||||
|
||||
private boolean contains(EditBox field, double mouseX, double mouseY) {
|
||||
return field != null && mouseX >= field.getX() && mouseX < field.getX() + field.getWidth()
|
||||
&& mouseY >= field.getY() && mouseY < field.getY() + field.getHeight();
|
||||
}
|
||||
|
||||
private String focusedSearchValue() {
|
||||
EditBox field = focusedIdField();
|
||||
return field == null ? "" : field.getValue();
|
||||
}
|
||||
|
||||
private void writeId(EditBox field, String id) {
|
||||
applyingCompletion = true;
|
||||
field.setValue(id);
|
||||
field.moveCursorToEnd(false);
|
||||
applyingCompletion = false;
|
||||
}
|
||||
|
||||
private String resolveTarget() {
|
||||
if (QuestRequestService.PLAYER.equals(currentTargetKind())) {
|
||||
String name = target.getValue().strip();
|
||||
return name.matches("[A-Za-z0-9_]{1,16}") ? name : null;
|
||||
}
|
||||
boolean item = QuestRequestService.ITEM.equals(currentTargetKind());
|
||||
return QuestIdSuggestions.resolve(item ? itemCatalog : mobCatalog, target.getValue(),
|
||||
item ? inventoryIds() : Set.of());
|
||||
}
|
||||
|
||||
private QuestIdSuggestions.Match targetPreview(int mouseX, int mouseY) {
|
||||
if (QuestRequestService.PLAYER.equals(currentTargetKind())) return null;
|
||||
return previewFor(target, mouseX, mouseY);
|
||||
}
|
||||
|
||||
private QuestIdSuggestions.Match rewardPreview(int mouseX, int mouseY) {
|
||||
return previewFor(rewardTarget, mouseX, mouseY);
|
||||
}
|
||||
|
||||
private QuestIdSuggestions.Match previewFor(EditBox field, int mouseX, int mouseY) {
|
||||
if (field == null) return null;
|
||||
if (field.isFocused() && !suggestions.isEmpty()) {
|
||||
int hover = suggestionIndexAt(mouseX, mouseY);
|
||||
return suggestions.get(hover >= 0 ? hover : selectedSuggestion);
|
||||
}
|
||||
boolean item = field == rewardTarget || QuestRequestService.ITEM.equals(currentTargetKind());
|
||||
return QuestIdSuggestions.exact(item ? itemCatalog : mobCatalog, field.getValue(),
|
||||
item ? inventoryIds() : Set.of());
|
||||
}
|
||||
|
||||
private void drawPreview(GuiGraphicsExtractor graphics, EditBox field,
|
||||
QuestIdSuggestions.Match match, int x, int y) {
|
||||
if (field == null || match == null) return;
|
||||
graphics.fill(x, y, x + 16, y + 16, 0x66101010);
|
||||
graphics.outline(x, y, 16, 16, 0x88E8A8C3);
|
||||
ItemStack stack = iconFor(match.id(), field == rewardTarget
|
||||
|| QuestRequestService.ITEM.equals(currentTargetKind()));
|
||||
if (!stack.isEmpty()) graphics.item(stack, x, y);
|
||||
}
|
||||
|
||||
private void renderSuggestions(GuiGraphicsExtractor graphics, int mouseX, int mouseY) {
|
||||
if (suggestions.isEmpty()) return;
|
||||
Panel panel = suggestionPanel();
|
||||
graphics.fill(panel.x(), panel.y(), panel.x() + panel.width(), panel.y() + panel.height(), 0xF2181417);
|
||||
graphics.outline(panel.x(), panel.y(), panel.width(), panel.height(), 0xFFE8A8C3);
|
||||
boolean itemField = focusedIdField() == rewardTarget
|
||||
|| QuestRequestService.ITEM.equals(currentTargetKind());
|
||||
int hover = suggestionIndexAt(mouseX, mouseY);
|
||||
for (int index = 0; index < suggestions.size(); index++) {
|
||||
QuestIdSuggestions.Match match = suggestions.get(index);
|
||||
int top = panel.y() + 1 + index * SUGGESTION_ROW;
|
||||
if (index == selectedSuggestion || index == hover) {
|
||||
graphics.fill(panel.x() + 1, top, panel.x() + panel.width() - 1, top + SUGGESTION_ROW, 0x55E8A8C3);
|
||||
}
|
||||
ItemStack stack = iconFor(match.id(), itemField);
|
||||
if (!stack.isEmpty()) graphics.item(stack, panel.x() + 2, top);
|
||||
graphics.text(font, Component.literal(ellipsize(match.displayName(), panel.width() - 24)),
|
||||
panel.x() + 20, top + 4, 0xFFF2C4D7);
|
||||
}
|
||||
}
|
||||
|
||||
private ItemStack iconFor(String id, boolean item) {
|
||||
Identifier identifier = Identifier.tryParse(id);
|
||||
if (identifier == null) return ItemStack.EMPTY;
|
||||
if (item) {
|
||||
return BuiltInRegistries.ITEM.getOptional(identifier).map(ItemStack::new).orElse(ItemStack.EMPTY);
|
||||
}
|
||||
Identifier egg = Identifier.fromNamespaceAndPath(identifier.getNamespace(),
|
||||
identifier.getPath() + "_spawn_egg");
|
||||
return BuiltInRegistries.ITEM.getOptional(egg).map(ItemStack::new).orElse(ItemStack.EMPTY);
|
||||
}
|
||||
|
||||
private Set<String> inventoryIds() {
|
||||
Set<String> ids = new LinkedHashSet<>();
|
||||
if (minecraft == null || minecraft.player == null) return ids;
|
||||
ItemStack held = minecraft.player.getMainHandItem();
|
||||
if (!held.isEmpty()) ids.add(BuiltInRegistries.ITEM.getKey(held.getItem()).toString());
|
||||
for (var slot : SanctuaryPlayerInventoryView.unlockedRows(minecraft.player)) {
|
||||
if (!slot.stack().isEmpty()) {
|
||||
ids.add(BuiltInRegistries.ITEM.getKey(slot.stack().getItem()).toString());
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
private EditBox focusedIdField() {
|
||||
if (target != null && target.isFocused() && !QuestRequestService.PLAYER.equals(currentTargetKind())) {
|
||||
return target;
|
||||
}
|
||||
if (rewardTarget != null && rewardTarget.isFocused()) return rewardTarget;
|
||||
return null;
|
||||
}
|
||||
|
||||
private boolean idFieldFocused() {
|
||||
return focusedIdField() != null;
|
||||
}
|
||||
|
||||
private Panel suggestionPanel() {
|
||||
EditBox field = focusedIdField();
|
||||
if (field == null) return new Panel(0, 0, 0, 0);
|
||||
int rows = Math.max(1, suggestions.size());
|
||||
int height = rows * SUGGESTION_ROW + 2;
|
||||
int width = 200;
|
||||
int x = Math.max(SUGGESTION_MARGIN, Math.min(this.width / 2 - width / 2,
|
||||
this.width - width - SUGGESTION_MARGIN));
|
||||
int below = field.getY() + field.getHeight() + 2;
|
||||
int above = field.getY() - 2 - height;
|
||||
boolean preferAbove = field == rewardTarget;
|
||||
int y = preferAbove && above >= SUGGESTION_MARGIN ? above : below;
|
||||
if (y + height > this.height - SUGGESTION_MARGIN) y = above;
|
||||
y = Math.max(SUGGESTION_MARGIN, Math.min(y, this.height - height - SUGGESTION_MARGIN));
|
||||
return new Panel(x, y, width, height);
|
||||
}
|
||||
|
||||
private int suggestionIndexAt(double mouseX, double mouseY) {
|
||||
if (suggestions.isEmpty()) return -1;
|
||||
Panel panel = suggestionPanel();
|
||||
if (!panel.contains(mouseX, mouseY)) return -1;
|
||||
int index = (int) ((mouseY - panel.y() - 1) / SUGGESTION_ROW);
|
||||
return index >= 0 && index < suggestions.size() ? index : -1;
|
||||
}
|
||||
|
||||
private String ellipsize(String value, int maxWidth) {
|
||||
if (font.width(value) <= maxWidth) return value;
|
||||
String suffix = "…";
|
||||
int suffixWidth = font.width(suffix);
|
||||
StringBuilder cut = new StringBuilder(value);
|
||||
while (cut.length() > 0 && font.width(cut.toString()) + suffixWidth > maxWidth) {
|
||||
cut.deleteCharAt(cut.length() - 1);
|
||||
}
|
||||
return cut + suffix;
|
||||
}
|
||||
|
||||
@Override public void onClose() { if (minecraft != null) minecraft.gui.setScreen(parent); }
|
||||
@Override public boolean isPauseScreen() { return false; }
|
||||
|
||||
private record Values(int quantity, int rewardQuantity) {}
|
||||
private record Values(String target, int quantity, String rewardTarget, int rewardQuantity) {}
|
||||
private record Panel(int x, int y, int width, int height) {
|
||||
boolean contains(double mouseX, double mouseY) {
|
||||
return mouseX >= x && mouseX < x + width && mouseY >= y && mouseY < y + height;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
package fr.koka99cab.sanctuary26.sanctuary.client;
|
||||
|
||||
import fr.koka99cab.sanctuary26.sanctuary.SanctuaryMod;
|
||||
import fr.koka99cab.sanctuary26.sanctuary.network.QuestTrackerSnapshotPayload;
|
||||
import fr.koka99cab.sanctuary26.sanctuary.shop.QuestTrackerSync;
|
||||
import java.util.List;
|
||||
import net.fabricmc.fabric.api.client.rendering.v1.hud.HudElementRegistry;
|
||||
import net.minecraft.client.DeltaTracker;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.gui.Font;
|
||||
import net.minecraft.client.gui.GuiGraphicsExtractor;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
|
||||
/** Transparent, toggleable text overlay for accepted quests. */
|
||||
final class QuestTrackerHud {
|
||||
private static final int MAX_WIDTH = 240;
|
||||
private static final int MARGIN = 2;
|
||||
private static final int ENTRY_SPACING = 2;
|
||||
private static final int BODY = 0xFFE9DDE2;
|
||||
|
||||
private QuestTrackerHud() {}
|
||||
|
||||
static void register() {
|
||||
HudElementRegistry.addLast(SanctuaryMod.id("quest_tracker"), QuestTrackerHud::render);
|
||||
}
|
||||
|
||||
private static void render(GuiGraphicsExtractor graphics, DeltaTracker tickCounter) {
|
||||
Minecraft client = Minecraft.getInstance();
|
||||
if (client.player == null || client.level == null || client.gui.screen() != null) return;
|
||||
if (!SanctuaryClientQuests.visible()) return;
|
||||
List<QuestTrackerSnapshotPayload.Entry> entries = SanctuaryClientQuests.entries();
|
||||
if (entries.isEmpty()) return;
|
||||
int width = Math.max(1, Math.min(MAX_WIDTH, graphics.guiWidth() - MARGIN * 2));
|
||||
int y = MARGIN;
|
||||
for (QuestTrackerSnapshotPayload.Entry entry : entries) {
|
||||
drawEntry(graphics, client.font, entry, MARGIN, y, width);
|
||||
y += client.font.lineHeight * 2 + ENTRY_SPACING;
|
||||
}
|
||||
}
|
||||
|
||||
private static void drawEntry(GuiGraphicsExtractor graphics, Font font,
|
||||
QuestTrackerSnapshotPayload.Entry entry, int x, int y, int width) {
|
||||
int accent = 0xFF000000 | (entry.accent() & 0xFFFFFF);
|
||||
graphics.text(font, ellipsize(font, title(entry), width), x, y, accent, true);
|
||||
Component progress = Component.translatable("hud.sanctuary.quest_tracker.progress",
|
||||
action(entry), describeTarget(entry), entry.progress(), entry.required());
|
||||
graphics.text(font, ellipsize(font, progress, width), x, y + font.lineHeight, BODY, true);
|
||||
}
|
||||
|
||||
private static Component title(QuestTrackerSnapshotPayload.Entry entry) {
|
||||
if (!entry.title().isBlank()) return Component.literal(entry.title());
|
||||
return Component.translatable("hud.sanctuary.quest_tracker.source." + entry.source());
|
||||
}
|
||||
|
||||
private static Component action(QuestTrackerSnapshotPayload.Entry entry) {
|
||||
if (QuestTrackerSync.SOURCE_BOARD.equals(entry.source())) {
|
||||
return Component.translatable("sign.sanctuary.quest_board.action." + entry.action());
|
||||
}
|
||||
return Component.translatable("message.sanctuary.quest_request.action." + entry.action());
|
||||
}
|
||||
|
||||
private static Component describeTarget(QuestTrackerSnapshotPayload.Entry entry) {
|
||||
Identifier id = Identifier.tryParse(entry.target());
|
||||
if (id == null) return Component.literal(entry.target());
|
||||
return BuiltInRegistries.ENTITY_TYPE.getOptional(id).map(type -> type.getDescription())
|
||||
.or(() -> BuiltInRegistries.ITEM.getOptional(id).map(item -> new ItemStack(item).getHoverName()))
|
||||
.orElse(Component.literal(id.getPath()));
|
||||
}
|
||||
|
||||
private static Component ellipsize(Font font, Component text, int maxWidth) {
|
||||
if (font.width(text) <= maxWidth) return text;
|
||||
String value = text.getString();
|
||||
while (!value.isEmpty() && font.width(value + "…") > maxWidth) {
|
||||
value = value.substring(0, value.length() - 1);
|
||||
}
|
||||
return Component.literal(value + "…");
|
||||
}
|
||||
}
|
||||
+25
@@ -14,6 +14,7 @@ import fr.koka99cab.sanctuary26.sanctuary.network.BlackMarketEditorPayload;
|
||||
import fr.koka99cab.sanctuary26.sanctuary.network.BlackMarketInteractPayload;
|
||||
import fr.koka99cab.sanctuary26.sanctuary.network.QuestRequestEditorPayload;
|
||||
import fr.koka99cab.sanctuary26.sanctuary.network.QuestRequestConfirmPayload;
|
||||
import fr.koka99cab.sanctuary26.sanctuary.network.QuestTrackerSnapshotPayload;
|
||||
import fr.koka99cab.sanctuary26.sanctuary.progression.SanctuaryProgressionCosts;
|
||||
import fr.koka99cab.sanctuary26.sanctuary.progression.SanctuaryUnlock;
|
||||
import net.fabricmc.api.ClientModInitializer;
|
||||
@@ -48,6 +49,7 @@ public final class SanctuaryClient implements ClientModInitializer {
|
||||
private static KeyMapping zoomKey;
|
||||
private static KeyMapping celestialKey;
|
||||
private static KeyMapping switchHotbarRowKey;
|
||||
private static KeyMapping questTrackerKey;
|
||||
private static boolean zoomWasHeld;
|
||||
private static boolean smokeScreenOpened;
|
||||
private static boolean inventorySmokeScreenOpened;
|
||||
@@ -91,7 +93,14 @@ public final class SanctuaryClient implements ClientModInitializer {
|
||||
GLFW.GLFW_KEY_TAB,
|
||||
SANCTUARY_CATEGORY
|
||||
));
|
||||
questTrackerKey = KeyMappingHelper.registerKeyMapping(new KeyMapping(
|
||||
"key.sanctuary.quest_tracker",
|
||||
InputConstants.Type.KEYSYM,
|
||||
GLFW.GLFW_KEY_J,
|
||||
SANCTUARY_CATEGORY
|
||||
));
|
||||
SanctuaryAtlasOptions.load();
|
||||
SanctuaryClientQuests.load();
|
||||
SanctuaryBulkActionsClient.initialize();
|
||||
SanctuaryHealthPreview.register();
|
||||
CelestialSpyglassSelection.register();
|
||||
@@ -124,6 +133,8 @@ public final class SanctuaryClient implements ClientModInitializer {
|
||||
ClientPlayNetworking.registerGlobalReceiver(QuestRequestConfirmPayload.ID, (payload, context) ->
|
||||
context.client().execute(() -> context.client().gui.setScreen(
|
||||
new QuestRequestConfirmScreen(context.client().gui.screen(), payload))));
|
||||
ClientPlayNetworking.registerGlobalReceiver(QuestTrackerSnapshotPayload.ID, (payload, context) ->
|
||||
context.client().execute(() -> SanctuaryClientQuests.update(payload)));
|
||||
ClientPlayConnectionEvents.DISCONNECT.register((handler, client) -> {
|
||||
SanctuaryAtlasVisitedChunks.saveIfDirty(client);
|
||||
SanctuaryAtlasMapCache.clear();
|
||||
@@ -135,6 +146,7 @@ public final class SanctuaryClient implements ClientModInitializer {
|
||||
CelestialSpyglassSelection.reset();
|
||||
SanctuaryClientShop.clear();
|
||||
SanctuaryClientCapes.clear();
|
||||
SanctuaryClientQuests.clear();
|
||||
zoomWasHeld = false;
|
||||
});
|
||||
ClientHotbarScrollEvents.ALLOW.register((inventory, selectedSlot, scrollDirection, horizontalAmount, verticalAmount) -> {
|
||||
@@ -142,6 +154,7 @@ public final class SanctuaryClient implements ClientModInitializer {
|
||||
return !SanctuaryAtlasHud.handleScroll(amount);
|
||||
});
|
||||
SanctuaryAtlasHud.register();
|
||||
QuestTrackerHud.register();
|
||||
SanctuaryMod.LOGGER.info("[{}] Atlas ready on M; Mouse 5 controls the HUD preview.", SanctuaryMod.DISPLAY_NAME);
|
||||
}
|
||||
|
||||
@@ -181,6 +194,10 @@ public final class SanctuaryClient implements ClientModInitializer {
|
||||
}
|
||||
}
|
||||
while (openAtlasKey.consumeClick()) toggleAtlas(client);
|
||||
while (questTrackerKey.consumeClick()) {
|
||||
if (client.player != null && client.level != null && client.gui.screen() == null)
|
||||
toggleQuestTracker(client);
|
||||
}
|
||||
while (celestialKey.consumeClick()) {
|
||||
if (client.player != null && client.level != null && client.gui.screen() == null)
|
||||
client.gui.setScreen(new CelestialSkyScreen(null));
|
||||
@@ -275,6 +292,14 @@ public final class SanctuaryClient implements ClientModInitializer {
|
||||
&& client.level != null && client.gui.screen() == null;
|
||||
}
|
||||
|
||||
private static void toggleQuestTracker(Minecraft client) {
|
||||
boolean visible = SanctuaryClientQuests.toggle();
|
||||
if (client.player == null) return;
|
||||
client.player.sendOverlayMessage(Component.translatable(visible
|
||||
? "hud.sanctuary.quest_tracker.toggle_on"
|
||||
: "hud.sanctuary.quest_tracker.toggle_off"));
|
||||
}
|
||||
|
||||
private static void toggleAtlas(Minecraft client) {
|
||||
if (client.gui.screen() instanceof SanctuaryAtlasScreen) {
|
||||
client.gui.setScreen(null);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user