Compare commits
74
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8531aac4b | ||
|
|
3ffa9b889f | ||
|
|
a16156b005 | ||
|
|
2518c20071 | ||
|
|
b7497c81d1 | ||
|
|
97638e0fa2 | ||
|
|
a071a0b746 | ||
|
|
6fcc56552c | ||
|
|
66944ec492 | ||
|
|
b513ab1262 | ||
|
|
b2222c5d33 | ||
|
|
2d0be21c5b | ||
|
|
a0b0788869 | ||
|
|
106d730dd8 | ||
|
|
21dd089486 | ||
|
|
e3b6c6ed01 | ||
|
|
7387096d98 | ||
|
|
457f91b92e | ||
|
|
f283c1705b | ||
|
|
6ee5cdc2fb | ||
|
|
87428c5f0d | ||
|
|
0599bf6885 | ||
|
|
624f441578 | ||
|
|
d6ca1140b5 | ||
|
|
53fa32bbc5 | ||
|
|
7a2f697024 | ||
|
|
4d0750e993 | ||
|
|
c49673801f | ||
|
|
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 |
+172
-2
@@ -6,9 +6,178 @@ Ce journal suit les **versions du modpack**. Lorsqu'un seul module change, sa ve
|
||||
- Depuis `26.2.0-alpha.6`, le pack et les modules ont des versions indépendantes. I Like To Move It et Only Fun deviennent les cinquième et sixième modules actifs dans l'alpha 46.
|
||||
- Les versions ci-dessous ont été reconstituées à partir des JAR archivés, des packs locaux et du code source actuel. Les dates n'étant pas enregistrées de manière fiable, elles ne sont pas inventées.
|
||||
|
||||
## Non distribué
|
||||
## `26.2.0-alpha.226`
|
||||
|
||||
Modules modifiés : **It's Alive `0.0.0-alpha.36`**, **Sanctuary `0.0.0-alpha.119`**, **Ambiance `0.0.0-alpha.15`**, **Only Fun `0.0.0-alpha.24`** et **Ouch `0.0.0-alpha.10`**. Le pack distribué reste en `26.2.0-alpha.210` jusqu’à la prochaine release.
|
||||
Module modifié : **Sanctuary `0.0.0-alpha.130`**. Les neuf autres modules conservent leur version issue de l'alpha.225.
|
||||
|
||||
- ajoute `sanctuary:white_cape`, une cape blanche craftable avec six laines blanches disposées comme une porte, en deux colonnes de trois ;
|
||||
- permet d'associer simplement la cape et n'importe quelle bannière dans l'établi afin d'en conserver la couleur de fond et jusqu'à six motifs ;
|
||||
- affiche les motifs dans l'inventaire et sur la cape équipée, avec la physique native des capes, y compris en multijoueur ;
|
||||
- conserve les sauvegardes Sanctuary en data v13 ; le payload d'apparence transporte désormais la couleur et les motifs bornés, donc le protocole Sanctuary passe de 23 à 24 et client comme serveur doivent être mis à jour, sans nouveau monde ni réinitialisation.
|
||||
|
||||
## `26.2.0-alpha.225`
|
||||
|
||||
Modules modifiés : **Another World `0.0.0-alpha.56`**, **It's Alive `0.0.0-alpha.38`**, **I Like To Move It `0.0.0-alpha.18`**, **Only Fun `0.0.0-alpha.27`** et **Sanctuary `0.0.0-alpha.129`**. Les cinq autres modules conservent leur version issue de l'alpha.224.
|
||||
|
||||
- exige que l’attaquant possède son propre coffre-fort actif avant de commencer un braquage, revalide cette condition pendant le forage et refuse défensivement tout transfert si le coffre disparaît entre-temps ;
|
||||
- empêche les familiers temporaires Sanctuary de produire du butin vanilla, de l’expérience ou une lootbox Only Fun, et les exclut des contrats de panneau comme des demandes communautaires ;
|
||||
- conserve la variante Canaplia des seize graines historiques pendant le placement, la croissance naturelle, la poudre d’os, la création de la partie haute, le rechargement et la récolte, ce qui réactive notamment l’hybridation Tendre + Olive vers Dorée ;
|
||||
- remplace la laine violette du Magic Carpet par la texture historique 16 × 16 du Totem de Notch, utilisée par l’objet canonique et son alias hérité sans modifier la plateforme 3 × 3 ;
|
||||
- conserve tous les IDs, sauvegardes et formats de données ; les protocoles réseau restent Another World 2, It's Alive 4, I Like To Move It 5, Only Fun 3 et Sanctuary 23, sans nouveau monde ni réinitialisation.
|
||||
|
||||
## `26.2.0-alpha.224`
|
||||
|
||||
Modules modifiés : **It's Alive `0.0.0-alpha.37`**, **Ouch `0.0.0-alpha.11`**, **I Like To Move It `0.0.0-alpha.17`**, **Only Fun `0.0.0-alpha.26`**, **Sanctuary `0.0.0-alpha.128`** et **MasterKey `0.0.0-alpha.9`**.
|
||||
|
||||
- étend le clic molette aux coffres, barils, boîtes de Shulker, entonnoirs et distributeurs, tout en excluant les fours, le Shop et le terminal Red-Stoner ;
|
||||
- trie l'inventaire du joueur lorsque le curseur survole l'une de ses cases dans un conteneur, sans toucher à la hotbar, et replace la hotbar en bas après connexion ou achat d'une rangée ;
|
||||
- ajoute Notch comme résident unique et persistant de la maison Indev, avec restauration automatique, déduplication et lecture des anciennes entités `portals:notch` ;
|
||||
- transforme Notch en Titan au premier coup volontaire, avec santé bornée selon les participants, poursuite, attaques télégraphiées, boules de feu renvoyables et réinitialisation si le combat est abandonné ;
|
||||
- limite la destruction de blocs à l'explosion initiale de transformation ; les attaques suivantes utilisent uniquement dégâts, poussée, particules et explosions non destructrices ;
|
||||
- enregistre la première victoire comme jalon mondial monotone, crédite les participants, inscrit un marqueur de boss dans leur Atlas déjà découvert et réserve une récompense physique unique ;
|
||||
- place le Magic Carpet de Notch dans un coffre déterministe signalé par feu d'artifice, avec réservation et livraison persistées séparément pour récupérer une interruption sans duplication ;
|
||||
- conserve `portals:notch_totem` comme alias caché et convertit inventaires, conteneurs et items au sol vers `iliketomoveit:magic_carpet` en gardant quantité, composants et nom personnalisé ;
|
||||
- maintient la plateforme temporaire 3 × 3 sous autorité serveur, sans écraser le terrain et avec un suivi par propriétaire qui protège les tapis superposés pendant les nettoyages ;
|
||||
- rend l'offre Magic Carpet visible et achetable uniquement après `sanctuary:notch_defeated`, au prix exceptionnel existant, avec paiement normal, livraison Mailbox, provenance et journal de remplacement ;
|
||||
- ajoute les diagnostics `/masterkey notch inspect` et `/masterkey notch restore`, les traductions FR/EN/RU et les contrats de test déterministes associés ;
|
||||
- migre les données It's Alive de v3 à v4, I Like To Move It de v3 à v4 et Sanctuary de v11 à v13 ; le payload de tri ayant gagné un booléen borné, le protocole Sanctuary passe de 22 à 23 et client comme serveur doivent être mis à jour, sans nouveau monde ni réinitialisation.
|
||||
|
||||
## `26.2.0-alpha.223`
|
||||
|
||||
Module modifié : **Sanctuary `0.0.0-alpha.125`**. Les neuf autres modules conservent leur version issue de l'alpha.222.
|
||||
|
||||
- ajoute un tri natif de l’inventaire par clic molette configurable, uniquement sur les rangées de stockage débloquées ;
|
||||
- fusionne et range les piles depuis le haut sans déplacer la hotbar, l’armure, la main secondaire, le craft ni les emplacements spéciaux ;
|
||||
- garde le tri sous autorité serveur, avec un délai de huit ticks, et l’ignore en créatif ou pendant la saisie dans un champ texte ;
|
||||
- ajoute un style configurable au suivi des quêtes (`N` par défaut), entre le texte transparent et le panneau original de Chris ;
|
||||
- place automatiquement le style texte sous la hauteur réelle de la colonne gauche du F3 permanent afin que les deux affichages ne se recouvrent pas ;
|
||||
- nomme et ordonne les contrats suivis par niveau de récompense, rubis puis saphir puis émeraude/autres, au lieu du libellé générique « Contrat du jour » ;
|
||||
- actualise JEI vers la bêta Fabric 26.2 `30.29.0.198` et synchronise ses empreintes entre Packwiz et Prism ;
|
||||
- conserve les données Sanctuary v11, le protocole réseau 22, les IDs et les sauvegardes existantes ; aucun nouveau monde ni réinitialisation n’est nécessaire.
|
||||
|
||||
## `26.2.0-alpha.222`
|
||||
|
||||
Modules modifiés : **Red-Stoner `0.0.0-alpha.13`** et **Sanctuary `0.0.0-alpha.124`**. Les huit autres modules conservent leur version issue de l'alpha.221.
|
||||
|
||||
- ajoute au terminal de stockage une recherche clavier focalisée à l'ouverture, compatible avec les noms affichés, les IDs, les traductions, les accents et Unicode ;
|
||||
- affiche les grandes quantités du terminal sous forme compacte (`1K`, `1.2K`, `12K`, `1.2M`) tout en conservant la valeur exacte dans l'infobulle ;
|
||||
- stabilise les emplacements du terminal lorsqu'une pile est vidée afin que les ressources suivantes ne glissent plus sous le curseur, puis recompacte volontairement lors d'une nouvelle recherche ou à la réouverture ;
|
||||
- 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.
|
||||
- actualise JEI vers la bêta Fabric 26.2 `30.29.0.196` et synchronise Packwiz avec l'index Prism/Modrinth ;
|
||||
- conserve Red-Stoner data v3/protocole 7, Sanctuary data v11/protocole 22, les IDs et les sauvegardes existantes ; aucun nouveau monde ni réinitialisation n'est nécessaire.
|
||||
|
||||
## `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.
|
||||
@@ -26,6 +195,7 @@ Modules modifiés : **It's Alive `0.0.0-alpha.36`**, **Sanctuary `0.0.0-alpha.11
|
||||
- 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,29 @@ 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.226`**. Cette release distribue Sanctuary **`0.0.0-alpha.130`** ; les neuf autres modules conservent leur version issue de l'alpha.225.
|
||||
|
||||
L’alpha.225 corrige quatre tickets : posséder un coffre-fort devient obligatoire pour en braquer un autre, les familiers temporaires ne donnent plus de butin, d’expérience, de lootbox ni de progression de quête, les seize variantes Canaplia survivent à tout leur cycle de culture, et le Magic Carpet retrouve la texture historique du Totem de Notch à la place de la laine violette. Les IDs, sauvegardes, formats de données et protocoles restent compatibles.
|
||||
|
||||
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.224 ajoute Notch comme résident unique de la maison Indev et son combat de Titan. It's Alive possède l'entité persistante, Ouch les attaques et dégâts, Sanctuary le jalon mondial de victoire et l'Atlas, tandis que MasterKey fournit les commandes d'inspection et de restauration. Les anciennes entités `portals:notch` restent chargeables. La première victoire réserve maintenant un unique `iliketomoveit:magic_carpet` dans un coffre signalé par feu d'artifice. L'ancien `portals:notch_totem` est migré sans perte ; après Notch, Only Fun ouvre l'offre de remplacement au Shop avec paiement serveur et livraison par Mailbox. Le clic molette trie aussi les coffres, barils, boîtes de Shulker, entonnoirs et distributeurs, ou l'inventaire du joueur lorsqu'il est survolé, sans déplacer la hotbar. Le protocole Sanctuary passe à 23 : client et serveur doivent utiliser l'alpha.224.
|
||||
|
||||
L’alpha.223 ajoute le tri natif des rangées de stockage par clic molette sans déplacer la hotbar ni les emplacements spéciaux. Le suivi des quêtes propose désormais le texte transparent aligné sous la colonne gauche du F3 ou le panneau original de Chris, avec bascule configurable sur `N` et contrats ordonnés par récompense. JEI passe à la bêta Fabric 26.2 `30.29.0.198`.
|
||||
|
||||
L’alpha.222 améliore le terminal de stockage avec une recherche Unicode, des quantités compactes et des emplacements qui ne glissent plus lorsqu'une pile est vidée. Elle ajoute aussi le suivi textuel des quêtes actives en haut à gauche, masquable avec une touche configurable, ainsi que des suggestions d’IDs d’items et de mobs dans l’éditeur de demandes. JEI passe à la bêta Fabric 26.2 `30.29.0.196`.
|
||||
|
||||
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.
|
||||
|
||||
@@ -98,6 +116,8 @@ L’alpha.173 centralise l’audio du Miner : une seule boucle de frappe sur le
|
||||
|
||||
L’alpha.171 rassemble les **46 capes** dans un onglet créatif dédié « Capes ». Elles ne sont plus mélangées à l’onglet vanilla Tools & Utilities et ne sont donc affichées qu’une seule fois.
|
||||
|
||||
L’alpha.226 ajoute une 47e cape, `sanctuary:white_cape`, distincte de la collection Only Fun. Elle se fabrique avec six laines blanches en deux colonnes de trois, puis accepte une bannière dans une recette simple pour en conserver la couleur et jusqu’à six motifs ; les motifs s’affichent dans l’inventaire comme une fois la cape équipée.
|
||||
|
||||
L’alpha.170 ajoute les pictogrammes fournis aux emplacements vides de la cape et du familier. Ils disparaissent dès qu’une cape ou un œuf est équipé, sans modifier les objets, la persistance ni le protocole.
|
||||
|
||||
L’alpha.169 agrandit la collection de 13 à **46 capes équipables**. Les 33 nouvelles textures 64×32 fournies sont conservées telles quelles, avec une icône d’inventaire, un modèle et des traductions FR/EN/RU pour chaque cape. Elles utilisent le slot, la persistance et le rendu multijoueur déjà existants, restent sans recette et rejoignent toutes la lootbox de capes Only Fun. Les caméras Canon restent accessibles dans la lootbox normale et peuvent désormais aussi apparaître directement, très rarement, dans les coffres d’exploration et du donjon de cobblestone.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -1647,6 +1647,7 @@ tasks.register("generateAnotherWorldResources") {
|
||||
"message.anotherworld.safe_vault.empty_before_break": "Videz le coffre-fort et attendez la fin du forage avant de le démonter.",
|
||||
"message.anotherworld.safe_vault.drill_own": "Vous ne pouvez pas forer votre propre coffre-fort.",
|
||||
"message.anotherworld.safe_vault.inactive": "Ce coffre-fort n’est pas actif.",
|
||||
"message.anotherworld.safe_vault.raid_requires_vault": "Vous devez posséder un coffre-fort actif avant d’en braquer un autre.",
|
||||
"message.anotherworld.safe_vault.empty": "Ce coffre-fort est vide.",
|
||||
"message.anotherworld.safe_vault.owner_offline": "Le propriétaire doit être connecté pour que le coffre-fort soit attaquable.",
|
||||
"message.anotherworld.safe_vault.protected": "Ce coffre-fort reste protégé pendant environ %s minute(s).",
|
||||
@@ -1671,6 +1672,7 @@ tasks.register("generateAnotherWorldResources") {
|
||||
"message.anotherworld.safe_vault.empty_before_break": "Опустошите сейф и дождитесь окончания сверления перед разборкой.",
|
||||
"message.anotherworld.safe_vault.drill_own": "Нельзя сверлить собственный сейф.",
|
||||
"message.anotherworld.safe_vault.inactive": "Этот сейф не активен.",
|
||||
"message.anotherworld.safe_vault.raid_requires_vault": "Для ограбления чужого сейфа нужен собственный активный сейф.",
|
||||
"message.anotherworld.safe_vault.empty": "Этот сейф пуст.",
|
||||
"message.anotherworld.safe_vault.owner_offline": "Владелец должен быть в сети, чтобы атаковать сейф.",
|
||||
"message.anotherworld.safe_vault.protected": "Сейф защищён ещё примерно %s минут(ы).",
|
||||
@@ -1695,6 +1697,7 @@ tasks.register("generateAnotherWorldResources") {
|
||||
"message.anotherworld.safe_vault.empty_before_break": "Empty the safe and wait for drilling to end before dismantling it.",
|
||||
"message.anotherworld.safe_vault.drill_own": "You cannot drill your own safe.",
|
||||
"message.anotherworld.safe_vault.inactive": "This safe is not active.",
|
||||
"message.anotherworld.safe_vault.raid_requires_vault": "You need an active safe of your own before raiding another one.",
|
||||
"message.anotherworld.safe_vault.empty": "This safe is empty.",
|
||||
"message.anotherworld.safe_vault.owner_offline": "The owner must be online before the safe can be raided.",
|
||||
"message.anotherworld.safe_vault.protected": "This safe remains protected for about %s minute(s).",
|
||||
@@ -1953,7 +1956,7 @@ tasks.register("verifyAnotherWorldCatalog") {
|
||||
"src/main/resources/assets/redstoner/textures/item/weather_gauge.png",
|
||||
"src/main/resources/data/redstoner/recipe/weather_gauge.json"
|
||||
]
|
||||
if (project.version.toString() != "0.0.0-alpha.55"
|
||||
if (project.version.toString() != "0.0.0-alpha.56"
|
||||
|| sulfurMigration.target?.modules?.anotherworld != "0.0.0-alpha.44"
|
||||
|| sulfurMigration.sulfur_caves?.sanctuary?.dripstone_cell_period != 2
|
||||
|| !javaText.contains('Identifier.parse("sanctuary:sulfur_caves")')
|
||||
@@ -3270,7 +3273,7 @@ tasks.register("verifyClimateBiomeMixer") {
|
||||
"pack/migrations/26.2.0-alpha.188-beta-climate-provinces-to-alpha.189.json"))
|
||||
def mixer = file(
|
||||
"src/main/java/fr/koka99cab/sanctuary26/anotherworld/mixin/MultiNoiseBiomeSourceParameterListPresetMixin.java").text
|
||||
if (project.version.toString() != "0.0.0-alpha.55"
|
||||
if (project.version.toString() != "0.0.0-alpha.56"
|
||||
|| migration.source?.pack_version != "26.2.0-alpha.188"
|
||||
|| migration.target?.pack_version != "26.2.0-alpha.189"
|
||||
|| migration.target?.modules?.anotherworld != "0.0.0-alpha.48"
|
||||
|
||||
+11
-1
@@ -113,6 +113,12 @@ public final class VaultData extends SavedData {
|
||||
&& address.pos() == center.asLong() && address.owner().equals(ownerId));
|
||||
}
|
||||
|
||||
public boolean hasRegisteredVault(UUID owner) {
|
||||
if (owner == null) return false;
|
||||
String ownerId = owner.toString();
|
||||
return vaults.stream().anyMatch(address -> address.owner().equals(ownerId));
|
||||
}
|
||||
|
||||
public void unregisterNear(ServerLevel level, BlockPos member) {
|
||||
String dimension = dimension(level);
|
||||
boolean changed = vaults.removeIf(address -> {
|
||||
@@ -163,7 +169,7 @@ public final class VaultData extends SavedData {
|
||||
|
||||
/** Transfers the exact 20% raid share into the attacker's unlimited balances without item spam. */
|
||||
public long transferRaid(UUID owner, UUID attacker) {
|
||||
if (owner == null || attacker == null || owner.equals(attacker)) return 0L;
|
||||
if (!canReceiveRaidLoot(owner, attacker, hasRegisteredVault(attacker))) return 0L;
|
||||
Account source = accounts.get(owner);
|
||||
if (source == null) return 0L;
|
||||
Account target = accounts.computeIfAbsent(attacker, ignored -> new Account());
|
||||
@@ -180,6 +186,10 @@ public final class VaultData extends SavedData {
|
||||
return total;
|
||||
}
|
||||
|
||||
static boolean canReceiveRaidLoot(UUID owner, UUID attacker, boolean attackerHasRegisteredVault) {
|
||||
return owner != null && attacker != null && !owner.equals(attacker) && attackerHasRegisteredVault;
|
||||
}
|
||||
|
||||
public long protectionRemaining(UUID owner, long gameTime) {
|
||||
Account account = accounts.get(owner);
|
||||
return account == null ? 0L : Math.max(0L, account.protectedUntil - gameTime);
|
||||
|
||||
+11
@@ -48,6 +48,10 @@ public final class VaultDrillingService {
|
||||
attacker.sendSystemMessage(Component.translatable("message.anotherworld.safe_vault.inactive"));
|
||||
return false;
|
||||
}
|
||||
if (!data.hasRegisteredVault(attacker.getUUID())) {
|
||||
attacker.sendSystemMessage(Component.translatable("message.anotherworld.safe_vault.raid_requires_vault"));
|
||||
return false;
|
||||
}
|
||||
if (!data.hasContents(formed.owner())) {
|
||||
attacker.sendSystemMessage(Component.translatable("message.anotherworld.safe_vault.empty"));
|
||||
return false;
|
||||
@@ -104,6 +108,13 @@ public final class VaultDrillingService {
|
||||
attacker.stopUsingItem();
|
||||
return;
|
||||
}
|
||||
if (!VaultData.get(level.getServer()).hasRegisteredVault(attacker.getUUID())) {
|
||||
cancel(attacker, false);
|
||||
attacker.sendOverlayMessage(Component.translatable(
|
||||
"message.anotherworld.safe_vault.raid_requires_vault"));
|
||||
attacker.stopUsingItem();
|
||||
return;
|
||||
}
|
||||
|
||||
int elapsed = session.elapsedTicks() + 1;
|
||||
Session progressed = session.withElapsed(elapsed);
|
||||
|
||||
+10
@@ -1,5 +1,7 @@
|
||||
package fr.koka99cab.sanctuary26.anotherworld.vault;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public final class VaultRulesSmoke {
|
||||
private VaultRulesSmoke() {
|
||||
}
|
||||
@@ -55,6 +57,14 @@ public final class VaultRulesSmoke {
|
||||
require(VaultRules.raidLoot(Integer.MAX_VALUE) == 429_496_730L, "32-bit balance raid");
|
||||
require(VaultRules.raidLoot(Long.MAX_VALUE) == 1_844_674_407_370_955_162L,
|
||||
"64-bit balance raid without overflow");
|
||||
UUID owner = UUID.fromString("00000000-0000-0000-0000-000000000001");
|
||||
UUID attacker = UUID.fromString("00000000-0000-0000-0000-000000000002");
|
||||
require(!VaultData.canReceiveRaidLoot(owner, attacker, false),
|
||||
"an attacker without a registered vault cannot receive raid loot");
|
||||
require(VaultData.canReceiveRaidLoot(owner, attacker, true),
|
||||
"an attacker with a registered vault can receive raid loot");
|
||||
require(!VaultData.canReceiveRaidLoot(owner, owner, true),
|
||||
"a vault owner cannot receive loot from their own vault");
|
||||
require(VaultRules.saturatingAdd(Long.MAX_VALUE - 2L, 10L) == Long.MAX_VALUE,
|
||||
"balance saturation without overflow");
|
||||
for (long balance : new long[] {0L, Integer.MAX_VALUE, (long) Integer.MAX_VALUE + 1L, Long.MAX_VALUE}) {
|
||||
|
||||
+2152
-174
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,404 @@
|
||||
# Sanctuary TikTok — organisation de réalisation
|
||||
|
||||
Ce document transforme les [100 premiers scripts](VIDEOS_001_100.md) en chaîne de production solo, reproductible et extensible. L'objectif est de produire vite sans perdre la sobriété, la précision technique ni la cohérence de Sanctuary.
|
||||
|
||||
## Objectif de la saison 001
|
||||
|
||||
- Produire les 100 vidéos en **10 à 12 semaines**.
|
||||
- Publier **5 vidéos par semaine**, soit 20 semaines de diffusion.
|
||||
- Maintenir en permanence **au moins 15 vidéos terminées d'avance**.
|
||||
- Valider le langage visuel sur 12 pilotes avant d'industrialiser les 88 suivantes.
|
||||
- Utiliser les performances pour choisir les prochains sujets, sans réécrire l'identité de la série à chaque publication.
|
||||
|
||||
Charge solo réaliste : **14 à 18 heures par semaine**, soit environ **140 à 180 heures** pour la saison complète. Les vidéos d'interface peuvent demander moins d'une heure ; les scènes multijoueur, worldgen ou avant/après authentiques peuvent en demander deux à quatre.
|
||||
|
||||
## Principe directeur
|
||||
|
||||
La publication suit les numéros. La réalisation suit les décors.
|
||||
|
||||
Tourner dix vidéos partageant la même ferme, le même laboratoire ou la même lumière est plus efficace que de réaliser les épisodes 001 à 010 dans l'ordre. Le numéro reste l'identité publique de la vidéo ; le lot reste son identité de production.
|
||||
|
||||
## Les neuf plateaux de tournage
|
||||
|
||||
| Code | Plateau | Épisodes principaux | Préparation indispensable |
|
||||
|---|---|---|---|
|
||||
| `UI` | Laboratoire interface | 003–019, 083–086, 097 | profils de progression, inventaires remplis, terminal et Shop |
|
||||
| `WRLD` | Archipel et worldgen | 002, 020–027, 053, 072–079 | seed verrouillée, points de vue, météo et heures reproductibles |
|
||||
| `ALPH` | Alpha / maison Indev | 028–031 | portail, maison intacte, état de Notch et récompense réinitialisable |
|
||||
| `MOVE` | Terrain de déplacement | 032–039 | tours, deux niveaux de Waystones, ligne de poulies et trajectoire de grappin |
|
||||
| `PASS` | Bestiaire calme | 040–049, 053 | enclos invisibles ou hors champ, variantes préparées, fleurs et reproduction |
|
||||
| `HOST` | Bestiaire hostile | 050–059 | arène sombre, protections opérateur, cibles mobiles et second joueur si nécessaire |
|
||||
| `FARM` | Ferme et cuisine | 060–071 | parcelles par stade, six postes, pupitre, stocks d'ingrédients et deux joueurs |
|
||||
| `TECH` | Atelier technologique | 080–090 | Canon, terminal, ordinateurs, réseau de coffres et deux coffres-forts appartenant à des joueurs distincts |
|
||||
| `OUCH` | Stand de tir / place serveur | 091–100 | cibles, munitions, protections, Shop, panneaux, Mailboxes et second joueur |
|
||||
|
||||
Les commandes MasterKey servent à préparer les acteurs et les états, jamais à apparaître dans l'image finale. Les scènes doivent ensuite montrer le comportement de jeu normal.
|
||||
|
||||
## Les six vagues de production
|
||||
|
||||
### P — Pilote, 12 vidéos
|
||||
|
||||
`001, 003, 006, 014, 020, 031, 041, 053, 062, 077, 086, 099`
|
||||
|
||||
But : éprouver le manifeste, l'interface, le monde, une mécanique spectaculaire, une créature, l'agriculture, l'ambiance et le multijoueur.
|
||||
|
||||
Livrable : 12 exports finalisés, mais seulement les quatre premiers sont programmés immédiatement. Les huit autres restent disponibles pour corriger le système avant une diffusion complète.
|
||||
|
||||
### A — Progression et interfaces, 20 vidéos
|
||||
|
||||
`002, 004, 005, 007–013, 015–019, 082–085, 097`
|
||||
|
||||
But : rentabiliser le laboratoire UI et verrouiller la manière de filmer les menus verticaux.
|
||||
|
||||
### B — Monde et Ambiance, 19 vidéos
|
||||
|
||||
`021–030, 072–076, 078–081`
|
||||
|
||||
But : tourner toutes les variations de jour, nuit, météo, shader et dimension pendant que la seed et les cadrages sont verrouillés.
|
||||
|
||||
### C — Déplacement et bestiaire, 20 vidéos
|
||||
|
||||
`032–040, 042–052`
|
||||
|
||||
But : mutualiser les acteurs, les enclos, les caméras latérales et les scènes nécessitant du mouvement.
|
||||
|
||||
### D — Infectés, agriculture et Canaplia, 17 vidéos
|
||||
|
||||
`054–061, 063–071`
|
||||
|
||||
But : partager l'arène hostile, la ferme à stades contrôlés, les ingrédients et le deuxième joueur.
|
||||
|
||||
### E — Technologie, combat et systèmes sociaux, 12 vidéos
|
||||
|
||||
`087–096, 098, 100`
|
||||
|
||||
But : terminer avec les scènes demandant le plus de préparation matérielle et de règles serveur.
|
||||
|
||||
Ces six listes couvrent les 100 épisodes une seule fois. Une vidéo pilote ne doit pas être retournée pendant sa vague thématique sauf si le test révèle un défaut concret.
|
||||
|
||||
## Calendrier conseillé
|
||||
|
||||
### Semaine 0 — Installer l'usine
|
||||
|
||||
- Verrouiller la seed, la version du pack et les réglages vidéo.
|
||||
- Créer les neuf plateaux ou sauvegardes de référence.
|
||||
- Choisir la typographie, la taille du texte et l'unique animation de carton final.
|
||||
- Créer un projet de montage modèle avec trois pistes vidéo, voix, sons du jeu, ambiance et sous-titres.
|
||||
- Exporter un test de dix secondes et le regarder sur un téléphone réel.
|
||||
- Préparer les douze fiches du pilote et leurs commandes de laboratoire.
|
||||
|
||||
### Semaine 1 — Produire le pilote
|
||||
|
||||
- Jour 1 : préparer les scènes `003, 006, 014, 086`.
|
||||
- Jour 2 : capturer `001, 020, 053, 077` dans le monde.
|
||||
- Jour 3 : capturer `031, 041, 062, 099` et les scènes multijoueur.
|
||||
- Jour 4 : monter les douze versions brutes.
|
||||
- Jour 5 : voix, texte, son, exports et contrôle qualité.
|
||||
- Jour 6 : corrections et programmation des quatre premières publications.
|
||||
|
||||
### Semaine 2 — Publier et corriger le système
|
||||
|
||||
- Publier quatre vidéos espacées.
|
||||
- Relever les performances à J+1 sans changer les vidéos déjà programmées au premier commentaire négatif.
|
||||
- Identifier les problèmes répétés de lisibilité, de rythme ou de compréhension.
|
||||
- Corriger uniquement le modèle de production concerné : accroche, taille de texte, cadrage, son ou durée.
|
||||
- Commencer la vague A pendant la diffusion.
|
||||
|
||||
### Semaines 3 à 11 — Régime de croisière
|
||||
|
||||
- Produire environ dix nouvelles vidéos par semaine.
|
||||
- Publier cinq vidéos par semaine.
|
||||
- Consacrer une semaine complète aux grandes vagues A, B et C lorsqu'elles exigent davantage de scènes.
|
||||
- Garder la semaine 11 pour les reprises, les scènes multijoueur manquantes et les exports finaux.
|
||||
|
||||
### Semaines 12 à 20 — Diffuser avec une saison d'avance
|
||||
|
||||
- Continuer la publication des 100 vidéos.
|
||||
- Concevoir la saison 101–200 à partir des formats qui fonctionnent, pas seulement des sujets qui ont fait le plus de vues.
|
||||
- Maintenir un tampon minimum de quinze exports prêts.
|
||||
|
||||
## Semaine type en régime de croisière
|
||||
|
||||
| Jour | Travail | Durée cible | Livrable |
|
||||
|---|---|---:|---|
|
||||
| Lundi | verrouillage de dix scripts, vérification des sources, liste des accessoires | 2 h | dix fiches `LOCKED` |
|
||||
| Mardi | préparation des scènes, acteurs, inventaires, heure et météo | 3 h | dix scènes `READY` |
|
||||
| Mercredi | capture groupée du lot | 4 h | rushes nommés et vérifiés |
|
||||
| Jeudi | montage image des dix vidéos | 4 h | dix montages `V1` |
|
||||
| Vendredi | voix, texte, mixage, export et QC | 3 à 4 h | dix masters `APPROVED` |
|
||||
| Samedi | programmation et revue des publications précédentes | 1 h | calendrier et décisions J+7 |
|
||||
|
||||
Ne pas imposer cette répartition lorsque la scène dicte autre chose. Une nuit de sang, un combat multijoueur ou une météo rare doivent être capturés en série pendant que leur état est disponible.
|
||||
|
||||
## Pipeline d'une vidéo
|
||||
|
||||
Chaque vidéo passe par un seul état à la fois :
|
||||
|
||||
```text
|
||||
BACKLOG
|
||||
→ LOCKED
|
||||
→ READY
|
||||
→ CAPTURED
|
||||
→ V1
|
||||
→ TEXT_AUDIO
|
||||
→ QC
|
||||
→ APPROVED
|
||||
→ SCHEDULED
|
||||
→ PUBLISHED
|
||||
→ REVIEWED
|
||||
```
|
||||
|
||||
- `BACKLOG` : sujet présent dans le catalogue.
|
||||
- `LOCKED` : accroche, plans, texte et source validés pour la version distribuée du pack.
|
||||
- `READY` : décor, acteurs, objets et état du monde préparés.
|
||||
- `CAPTURED` : tous les rushes existent, sont lisibles et sauvegardés.
|
||||
- `V1` : montage image complet sans finition.
|
||||
- `TEXT_AUDIO` : texte exact, voix et sons finalisés.
|
||||
- `QC` : contrôle sur ordinateur puis téléphone.
|
||||
- `APPROVED` : master immuable prêt à publier.
|
||||
- `SCHEDULED` : date, légende et couverture enregistrées.
|
||||
- `PUBLISHED` : URL et date réelles ajoutées au suivi.
|
||||
- `REVIEWED` : performances J+1 et J+7 enregistrées, décision de suite prise.
|
||||
|
||||
Une vidéo qui échoue au contrôle revient à l'état concerné. Elle ne saute jamais directement de `CAPTURED` à `SCHEDULED`.
|
||||
|
||||
## Arborescence locale des médias
|
||||
|
||||
Les rushes et exports sont trop lourds pour Git. Les conserver hors du dépôt, par exemple sous `/Users/koka/Movies/Sanctuary-TikTok/Season-001/` :
|
||||
|
||||
```text
|
||||
Season-001/
|
||||
├── 00-shared/
|
||||
│ ├── brand/
|
||||
│ ├── music-and-sfx/
|
||||
│ ├── project-template/
|
||||
│ └── export-presets/
|
||||
├── 01-pilot/
|
||||
├── 02-wave-a-ui/
|
||||
├── 03-wave-b-world/
|
||||
├── 04-wave-c-movement-living/
|
||||
├── 05-wave-d-food-canaplia/
|
||||
├── 06-wave-e-tech-combat-social/
|
||||
└── episodes/
|
||||
└── SNC-TT-001/
|
||||
├── brief/
|
||||
├── capture/
|
||||
├── audio/
|
||||
├── project/
|
||||
├── exports/
|
||||
└── cover/
|
||||
```
|
||||
|
||||
Le dépôt Git conserve uniquement les scripts, le plan, les éventuels manifests texte et les décisions éditoriales. Il ne doit recevoir ni rushes, ni projets de montage lourds, ni exports vidéo.
|
||||
|
||||
## Convention de fichiers
|
||||
|
||||
Identifiant canonique : `SNC-TT-<numéro sur trois chiffres>`.
|
||||
|
||||
Exemples :
|
||||
|
||||
```text
|
||||
SNC-TT-041__capture-01__wide.mov
|
||||
SNC-TT-041__capture-02__flower.mov
|
||||
SNC-TT-041__voice-fr__v02.wav
|
||||
SNC-TT-041__master-fr__v03.mp4
|
||||
SNC-TT-041__cover-fr__v01.png
|
||||
SNC-TT-041__metadata.yml
|
||||
```
|
||||
|
||||
Règles :
|
||||
|
||||
- ne jamais employer `final-final`, `nouveau` ou `ok2` ;
|
||||
- incrémenter `v01`, `v02`, `v03` ;
|
||||
- ne jamais remplacer un master `APPROVED` ; créer une nouvelle version ;
|
||||
- conserver la langue dans le nom du master pour préparer EN et RU ;
|
||||
- inscrire la version exacte du pack capturé dans `metadata.yml` ;
|
||||
- conserver les musiques, sons et images avec leur provenance et leurs droits.
|
||||
|
||||
## Fiche de préparation obligatoire
|
||||
|
||||
Avant la capture, chaque épisode reçoit ces champs :
|
||||
|
||||
```yaml
|
||||
id: SNC-TT-041
|
||||
status: LOCKED
|
||||
pack_version: 26.2.0-alpha.226
|
||||
capture_batch: PASS
|
||||
format: interaction
|
||||
target_duration_seconds: 11
|
||||
source_verified: true
|
||||
actors: 1
|
||||
multiplayer_required: false
|
||||
world_snapshot: passive-mobs-v1
|
||||
shots:
|
||||
- dangerous-approach
|
||||
- flower-given
|
||||
- happy-follow
|
||||
rights_checked: true
|
||||
```
|
||||
|
||||
Le numéro de version sert à empêcher la publication tardive d'une règle devenue fausse après une mise à jour. Avant programmation, comparer le comportement capturé à la version actuellement distribuée.
|
||||
|
||||
## Préparation des scènes
|
||||
|
||||
### Décors
|
||||
|
||||
- Utiliser des volumes simples, de grandes surfaces lisibles et un fond sans détails concurrents.
|
||||
- Réserver la pièce blanche ou gris très clair aux vidéos de laboratoire.
|
||||
- Réserver le décor naturel aux découvertes et chroniques.
|
||||
- Conserver les coordonnées et l'orientation de chaque caméra dans la fiche de scène.
|
||||
- Pour un avant/après, réutiliser exactement la même caméra, la même heure et la même action.
|
||||
|
||||
### Caméras
|
||||
|
||||
N'utiliser que quatre familles de plans :
|
||||
|
||||
1. **preuve FPS** : interaction telle que le joueur la vit ;
|
||||
2. **caméra latérale fixe** : mouvement, créature ou mécanisme ;
|
||||
3. **plan large lent** : monde, météo ou révélation ;
|
||||
4. **gros plan d'interface** : valeur, état ou changement de rangée.
|
||||
|
||||
Un cinquième type de plan doit justifier son existence. Aucun mouvement de caméra ne doit concurrencer la mécanique montrée.
|
||||
|
||||
### Portrait
|
||||
|
||||
- Capturer directement en portrait lorsque l'interface est la feature.
|
||||
- Si la source est horizontale, cadrer toute l'action dans la zone verticale centrale dès le tournage.
|
||||
- Tester la taille du GUI et les sous-titres sur téléphone avant de tourner tout un lot.
|
||||
- Garder le texte loin des zones extrêmes supérieure et inférieure de l'écran.
|
||||
|
||||
### Multijoueur
|
||||
|
||||
Réserver une session acteur de deux à trois heures toutes les deux semaines. Y regrouper au minimum : `031, 037, 039, 048, 057, 065, 071, 090, 098 et 099`.
|
||||
|
||||
Préparer avant l'arrivée du second joueur : inventaires, permissions, Mailboxes, objectifs, position de caméra et état de sauvegarde. Le temps acteur doit servir à jouer la scène, pas à construire son décor.
|
||||
|
||||
## Montage minimaliste
|
||||
|
||||
### Structure par défaut
|
||||
|
||||
```text
|
||||
0,0–1,5 s preuve ou anomalie visible
|
||||
1,5–5,0 s action principale
|
||||
5,0–10,0 s conséquence
|
||||
10,0–14,0 s règle ou précision
|
||||
dernier 0,5–1,0 s carton Sanctuary
|
||||
```
|
||||
|
||||
Ce rythme est un point de départ. Une chronique contemplative peut respirer davantage ; une interaction évidente peut s'arrêter après huit secondes.
|
||||
|
||||
### Texte
|
||||
|
||||
- Une phrase d'accroche.
|
||||
- Une phrase de compréhension ou de conséquence.
|
||||
- Deux lignes maximum à la fois.
|
||||
- Aucun paragraphe explicatif.
|
||||
- Les nombres et noms techniques doivent être composés dans le logiciel de montage, jamais générés dans une image.
|
||||
- Les sous-titres reprennent la voix sans ajouter une deuxième narration concurrente.
|
||||
|
||||
### Son
|
||||
|
||||
- Conserver le son de l'action : clic, déplacement, impact, ouverture, souffle ou ambiance.
|
||||
- Utiliser le silence comme contraste avant une révélation.
|
||||
- Éviter une musique différente à chaque vidéo ; une petite famille sonore suffit à créer la série.
|
||||
- Vérifier les droits de tout son extérieur au jeu.
|
||||
- Pour le disque blanc, n'utiliser qu'un extrait dont la publication est autorisée.
|
||||
|
||||
## Contrôle qualité avant publication
|
||||
|
||||
Une vidéo est `APPROVED` seulement si toutes les réponses sont oui :
|
||||
|
||||
- La feature est-elle compréhensible sans lire la légende TikTok ?
|
||||
- La première preuve apparaît-elle dans les deux premières secondes ?
|
||||
- Une seule idée principale est-elle défendue ?
|
||||
- Les valeurs et comportements correspondent-ils encore à la version distribuée ?
|
||||
- Le HUD est-il présent uniquement lorsqu'il sert de preuve ?
|
||||
- Les textes restent-ils lisibles sur téléphone et limités à deux lignes ?
|
||||
- L'action reste-t-elle visible sous les textes et les surfaces de l'application ?
|
||||
- Le montage contient-il trois plans ou moins, sauf justification écrite ?
|
||||
- Les sons, musiques et images peuvent-ils être publiés légalement ?
|
||||
- Les commandes de laboratoire, messages opérateur et éléments privés sont-ils absents ?
|
||||
- Le fichier porte-t-il le bon ID, la langue et la bonne version ?
|
||||
- Le master a-t-il été regardé intégralement après export ?
|
||||
|
||||
## Publication
|
||||
|
||||
Cadence initiale : cinq vidéos par semaine.
|
||||
|
||||
| Jour | Type conseillé |
|
||||
|---|---|
|
||||
| Lundi | règle simple ou interface |
|
||||
| Mardi | créature ou interaction |
|
||||
| Mercredi | problème / correction |
|
||||
| Vendredi | feature spectaculaire |
|
||||
| Dimanche | chronique calme, monde ou détail absurde |
|
||||
|
||||
Ne pas publier dix épisodes du même module à la suite même s'ils ont été capturés ensemble. Alterner les plateaux donne l'impression d'un monde cohérent plutôt que de neuf mini-campagnes séparées.
|
||||
|
||||
La légende doit compléter la vidéo, pas répéter son texte. Structure recommandée :
|
||||
|
||||
```text
|
||||
Une phrase de contexte.
|
||||
La règle exacte en une phrase.
|
||||
Sanctuary — épisode 041.
|
||||
```
|
||||
|
||||
Limiter les appels à l'action. Une question réelle peut être utilisée lorsqu'elle ouvre le sujet suivant ; éviter les demandes génériques de like ou d'abonnement sur chaque publication.
|
||||
|
||||
## Revue des performances
|
||||
|
||||
Enregistrer à J+1 puis J+7 :
|
||||
|
||||
- vues ;
|
||||
- rétention dans les deux premières secondes ;
|
||||
- durée moyenne regardée ;
|
||||
- taux de complétion ;
|
||||
- relectures ;
|
||||
- partages et enregistrements ;
|
||||
- visites de profil ;
|
||||
- questions ou incompréhensions récurrentes.
|
||||
|
||||
Comparer chaque vidéo à la médiane de son propre format : une chronique de 25 secondes ne doit pas être jugée comme une interaction de huit secondes.
|
||||
|
||||
Décisions :
|
||||
|
||||
- accroche forte et bonne complétion : produire une suite dans les sept jours ;
|
||||
- accroche faible mais bonne complétion chez les spectateurs restants : recouper les deux premières secondes pour un futur sujet proche ;
|
||||
- chute au moment d'une explication : raccourcir ou déplacer la phrase en légende ;
|
||||
- question répétée : créer une vidéo-réponse, sans alourdir la vidéo originale ;
|
||||
- bon sujet mais preuve peu visible : retourner la scène, pas seulement changer le texte ;
|
||||
- résultat faible isolé : ne pas abandonner immédiatement tout le format.
|
||||
|
||||
## Tableau de suivi minimal
|
||||
|
||||
Le suivi peut rester dans un tableur ou un outil Kanban, avec une ligne par ID et ces colonnes :
|
||||
|
||||
| Champ | Usage |
|
||||
|---|---|
|
||||
| `id` | `SNC-TT-001` à `SNC-TT-100` |
|
||||
| `title` | titre canonique du script |
|
||||
| `batch` | `UI`, `WRLD`, `ALPH`, `MOVE`, `PASS`, `HOST`, `FARM`, `TECH` ou `OUCH` |
|
||||
| `status` | état unique du pipeline |
|
||||
| `pack_version` | version réellement capturée |
|
||||
| `source_verified` | oui/non et date |
|
||||
| `scene_ready` | oui/non |
|
||||
| `multiplayer` | oui/non |
|
||||
| `capture_date` | date réelle |
|
||||
| `master_path` | chemin du master approuvé |
|
||||
| `rights_checked` | oui/non |
|
||||
| `scheduled_at` | date de programmation |
|
||||
| `published_url` | URL réelle |
|
||||
| `review_j1` | métriques et note courte |
|
||||
| `review_j7` | métriques et décision |
|
||||
| `follow_up` | nouvel ID ou action décidée |
|
||||
|
||||
## Prochaine action concrète
|
||||
|
||||
Commencer par la semaine 0, puis ne préparer que les douze pilotes. Le système n'est considéré comme verrouillé qu'après :
|
||||
|
||||
1. un export regardé sur téléphone ;
|
||||
2. quatre publications réelles ;
|
||||
3. une revue J+1 de ces quatre vidéos ;
|
||||
4. une décision explicite sur la taille du texte, le rythme, le son et le carton final.
|
||||
|
||||
Après cette validation, les 88 vidéos restantes peuvent entrer en production industrielle sans devoir être remontées à cause d'un défaut de format découvert trop tard.
|
||||
@@ -0,0 +1,950 @@
|
||||
# Sanctuary TikTok — vidéos 001 à 100
|
||||
|
||||
Première saison éditoriale de TikTok sobres et minimalistes présentant Sanctuary comme une réinterprétation personnelle de Minecraft, une règle à la fois. La chaîne de production complète est décrite dans le [plan de réalisation](PRODUCTION_PLAN.md).
|
||||
|
||||
## Contrat de réalisation
|
||||
|
||||
- Format vertical 9:16, 1080 × 1920, 30 images par seconde.
|
||||
- Une seule idée par vidéo, généralement en 10 à 20 secondes.
|
||||
- Trois plans maximum, coupes franches, caméra fixe ou mouvement très lent.
|
||||
- HUD masqué sauf lorsque l'interface constitue la preuve.
|
||||
- Sons du jeu prioritaires ; musique et voix off facultatives.
|
||||
- Deux lignes de texte maximum simultanément.
|
||||
- Carton final de 0,5 à 1 seconde : `SANCTUARY / <SÉRIE> <NUMÉRO>`.
|
||||
- Les valeurs, limites et comportements annoncés doivent rester identiques aux sources liées.
|
||||
- Pour les correctifs, montrer le symptôme puis la correction ; ne jamais fabriquer un faux « avant ».
|
||||
|
||||
Les textes proposés sont une base de tournage. Ils peuvent être lus en voix off ou affichés à l'écran, mais ne doivent pas être doublés intégralement sur les deux canaux.
|
||||
|
||||
## Vue d'ensemble
|
||||
|
||||
| Épisodes | Axe principal |
|
||||
|---|---|
|
||||
| 001 | Manifeste de la série |
|
||||
| 002–019 | Progression, inventaire et Atlas |
|
||||
| 020–031 | Monde flottant, biomes et Alpha |
|
||||
| 032–039 | Waystones, grappin et poulies |
|
||||
| 040–059 | Creepers, animaux, monstres et familiers |
|
||||
| 060–071 | Agriculture, cuisine et Canaplia |
|
||||
| 072–081 | Temps, météo, lumière, image et audio |
|
||||
| 082–090 | Red-Stoner, stockage et coffre-fort |
|
||||
| 091–096 | Arsenal Ouch |
|
||||
| 097–100 | Shop, marché, quêtes et Only Fun |
|
||||
|
||||
## 001 — Je réinterprète Minecraft, un détail à la fois
|
||||
|
||||
- **Format / durée :** manifeste, 15 s.
|
||||
- **Accroche :** « Je ne voulais pas seulement ajouter des mods à Minecraft. »
|
||||
- **Plans :** îles flottantes au loin ; Creeper portant une fleur ; inventaire qui gagne une nouvelle rangée ; coupe noire.
|
||||
- **Texte / voix :** « Je voulais réinterpréter ses règles, son monde et ses créatures. Un détail à la fois. »
|
||||
- **Sortie :** `SANCTUARY / MANIFESTE 001`.
|
||||
- **Source :** [présentation des dix modules](../../README.md#modules).
|
||||
|
||||
## 002 — Ici, tu commences réellement faible
|
||||
|
||||
- **Format / durée :** une règle, 12 s.
|
||||
- **Accroche :** « Trois cœurs. Trois morceaux de faim. »
|
||||
- **Plans :** apparition sur l'île ; gros plan propre sur le HUD ; tentative de minage volontairement lente.
|
||||
- **Texte / voix :** « Dans Sanctuary, le personnage vanilla complet est quelque chose que tu reconstruis. »
|
||||
- **Sortie :** `SANCTUARY / PROGRESSION 002`.
|
||||
- **Source :** [départ de partie et statistiques initiales](../../sanctuary/PROGRESSION.md#2-départ-de-partie).
|
||||
|
||||
## 003 — Tu ne commences qu'avec ta hotbar
|
||||
|
||||
- **Format / durée :** une règle, 10 s.
|
||||
- **Accroche :** « Au début, ceci est tout ton inventaire. »
|
||||
- **Plans :** ramasser plusieurs objets ; ouvrir l'inventaire ; révéler uniquement les neuf cases disponibles.
|
||||
- **Texte / voix :** « Les autres rangées ne sont pas cachées. Elles ne sont pas encore à toi. »
|
||||
- **Sortie :** `SANCTUARY / INVENTAIRE 003`.
|
||||
- **Source :** [inventaire extensible](../../README.md#progression-sanctuary-alpha).
|
||||
|
||||
## 004 — La place se gagne
|
||||
|
||||
- **Format / durée :** interaction, 14 s.
|
||||
- **Accroche :** « Ton XP peut agrandir ton inventaire. »
|
||||
- **Plans :** inventaire à une rangée ; achat du rang suivant dans Progression ; réouverture avec deux rangées.
|
||||
- **Texte / voix :** « Cinq achats permettent de passer d'une seule rangée à six. Les déblocages restent acquis après la mort. »
|
||||
- **Sortie :** `SANCTUARY / INVENTAIRE 004`.
|
||||
- **Source :** [inventaire extensible et coûts](../../sanctuary/PROGRESSION.md#44-inventaire-extensible-alpha74).
|
||||
|
||||
## 005 — Tab change réellement de hotbar
|
||||
|
||||
- **Format / durée :** interaction cachée, 10 s.
|
||||
- **Accroche :** « Ta deuxième rangée peut devenir ta hotbar. »
|
||||
- **Plans :** objet distinct dans deux rangées ; pression sur `Tab` ; échange visible de la rangée active sans ouvrir de menu.
|
||||
- **Texte / voix :** « `Tab` avance. `Maj + Tab` revient. Le serveur échange la vraie hotbar vanilla. »
|
||||
- **Sortie :** `SANCTUARY / INVENTAIRE 005`.
|
||||
- **Source :** [rangées de hotbar](../../README.md#progression-sanctuary-alpha).
|
||||
|
||||
## 006 — Miner 256 blocs sans tricher
|
||||
|
||||
- **Format / durée :** démonstration, 15 s.
|
||||
- **Accroche :** « Le minage groupé grandit avec ton personnage. »
|
||||
- **Plans :** cible composée de blocs identiques ; maintien de `Mouse 4` et sélection à la molette ; destruction bornée de la veine.
|
||||
- **Texte / voix :** « Les rangs passent de 4 à 256 blocs. Portée, permissions, outils, usure et drops restent vérifiés par le serveur. »
|
||||
- **Sortie :** `SANCTUARY / MINAGE 006`.
|
||||
- **Source :** [rangs de minage groupé](../../sanctuary/PROGRESSION.md#minage--sept-améliorations).
|
||||
|
||||
## 007 — Construire jusqu'à 15 × 15
|
||||
|
||||
- **Format / durée :** démonstration, 15 s.
|
||||
- **Accroche :** « Un clic peut poser un plan entier. »
|
||||
- **Plans :** prévisualisation 3 × 3 ; sélection d'un rang supérieur à la molette ; placement final d'un plan 15 × 15.
|
||||
- **Texte / voix :** « `B` et clic droit construisent de 3 × 3 à 15 × 15. Chaque bloc reste placé par une action Minecraft normale. »
|
||||
- **Sortie :** `SANCTUARY / CONSTRUCTION 007`.
|
||||
- **Source :** [construction groupée](../../sanctuary/PROGRESSION.md#construction--sept-améliorations).
|
||||
|
||||
## 008 — Ta portée devient une statistique
|
||||
|
||||
- **Format / durée :** avant / après, 12 s.
|
||||
- **Accroche :** « Au départ, ce bloc est trop loin. »
|
||||
- **Plans :** interaction refusée à trois blocs dépassés ; achat de Construction ; même caméra, interaction réussie jusqu'au plafond de 5,5 blocs.
|
||||
- **Texte / voix :** « La portée de construction progresse. La portée d'attaque, elle, ne change pas. »
|
||||
- **Sortie :** `SANCTUARY / PROGRESSION 008`.
|
||||
- **Source :** [rangs de Construction](../../sanctuary/PROGRESSION.md#construction--sept-améliorations).
|
||||
|
||||
## 009 — Les bons repas donnent de vrais cœurs temporaires
|
||||
|
||||
- **Format / durée :** une règle, 13 s.
|
||||
- **Accroche :** « Manger peut te protéger sans augmenter ta vie permanente. »
|
||||
- **Plans :** HUD avant le repas ; consommation ; apparition des cœurs orange ; disparition d'une portion après sa durée.
|
||||
- **Texte / voix :** « Nutrition, saturation et effets déterminent jusqu'à trois cœurs de repas pendant 15 à 60 secondes. »
|
||||
- **Sortie :** `SANCTUARY / REPAS 009`.
|
||||
- **Source :** [cœurs de repas](../../README.md#progression-sanctuary-alpha).
|
||||
|
||||
## 010 — Une barre pleine ne suffit pas pour sprinter
|
||||
|
||||
- **Format / durée :** problème / règle, 12 s.
|
||||
- **Accroche :** « Six points de faim. Barre pleine. Sprint impossible. »
|
||||
- **Plans :** tentative de sprint au rang initial ; achat du premier rang de Faim ; sprint réussi avec plus de six points.
|
||||
- **Texte / voix :** « Sanctuary garde le seuil absolu de Minecraft. Ton premier rang de Faim débloque réellement la course. »
|
||||
- **Sortie :** `SANCTUARY / FAIM 010`.
|
||||
- **Source :** [faim et seuil de sprint](../../sanctuary/PROGRESSION.md#faim--sept-améliorations).
|
||||
|
||||
## 011 — Le souffle n'encombre plus l'écran
|
||||
|
||||
- **Format / durée :** avant / après, 10 s.
|
||||
- **Accroche :** « Pourquoi afficher ton air quand tu es sur terre ? »
|
||||
- **Plans :** HUD terrestre sans bulles ; entrée dans l'eau ; apparition de trois à dix bulles selon le rang.
|
||||
- **Texte / voix :** « La réserve de souffle n'apparaît que lorsqu'elle devient utile. »
|
||||
- **Sortie :** `SANCTUARY / HUD 011`.
|
||||
- **Source :** [HUD de souffle](../../README.md#progression-sanctuary-alpha).
|
||||
|
||||
## 012 — L'expérience achète des capacités permanentes
|
||||
|
||||
- **Format / durée :** une règle, 14 s.
|
||||
- **Accroche :** « Dans Sanctuary, l'XP ne sert pas qu'aux enchantements. »
|
||||
- **Plans :** niveaux disponibles ; écran Progression ; achat du rang suivant ; mort rapide puis retour du rang.
|
||||
- **Texte / voix :** « Vie, faim, minage, souffle, construction et inventaire progressent avec les niveaux vanilla. Les achats survivent à la mort. »
|
||||
- **Sortie :** `SANCTUARY / PROGRESSION 012`.
|
||||
- **Source :** [améliorations et récompenses](../../sanctuary/PROGRESSION.md#4-améliorations-et-récompenses).
|
||||
|
||||
## 013 — La progression appartient au serveur
|
||||
|
||||
- **Format / durée :** note de laboratoire, 16 s.
|
||||
- **Accroche :** « Le client demande. Le serveur décide. »
|
||||
- **Plans :** achat valide ; tentative sans assez d'XP ; action refusée sans désynchronisation.
|
||||
- **Texte / voix :** « Le prix n'est jamais choisi par le client. Le serveur vérifie le rang, le solde et applique chaque capacité. »
|
||||
- **Sortie :** `SANCTUARY / LABO 013`.
|
||||
- **Source :** [contrat serveur de progression](../../README.md#progression-sanctuary-alpha).
|
||||
|
||||
## 014 — Une carte composée de 160 000 points
|
||||
|
||||
- **Format / durée :** révélation, 15 s.
|
||||
- **Accroche :** « L'Atlas n'est pas une image vue du ciel. »
|
||||
- **Plans :** ouverture avec `M` ; rotation lente du nuage de points ; zoom sur le relief d'un chunk.
|
||||
- **Texte / voix :** « C'est une vue GPU orientable, construite depuis la hauteur et la couleur du terrain exploré. »
|
||||
- **Sortie :** `SANCTUARY / ATLAS 014`.
|
||||
- **Source :** [Atlas en nuage de points](../../README.md#progression-sanctuary-alpha).
|
||||
|
||||
## 015 — Le monde calculé n'est pas le monde découvert
|
||||
|
||||
- **Format / durée :** une règle, 14 s.
|
||||
- **Accroche :** « L'Atlas connaît les biomes. Pas tes souvenirs. »
|
||||
- **Plans :** fond de biomes discret ; marche vers une zone inconnue ; apparition du profil détaillé après chargement du chunk.
|
||||
- **Texte / voix :** « Le fog of war sépare le monde prévisible des lieux que tu as réellement visités. »
|
||||
- **Sortie :** `SANCTUARY / ATLAS 015`.
|
||||
- **Source :** [fog of war persistant](../../README.md#progression-sanctuary-alpha).
|
||||
|
||||
## 016 — La carte peut exister dans le monde
|
||||
|
||||
- **Format / durée :** interaction, 12 s.
|
||||
- **Accroche :** « Maintiens `Mouse 5`. »
|
||||
- **Plans :** vue FPS normale ; apparition de la projection Atlas dans le monde ; regard vers l'horizon et disparition progressive.
|
||||
- **Texte / voix :** « Son opacité suit ton regard pour ne pas masquer le paysage. »
|
||||
- **Sortie :** `SANCTUARY / ATLAS 016`.
|
||||
- **Source :** [projection temps réel de l'Atlas](../../README.md#progression-sanctuary-alpha).
|
||||
|
||||
## 017 — Une mini-carte à neuf positions
|
||||
|
||||
- **Format / durée :** interaction cachée, 13 s.
|
||||
- **Accroche :** « La même carte peut devenir une mini-carte. »
|
||||
- **Plans :** activation avec `Mouse 5` ; déplacement entre trois des neuf ancrages ; réglage du zoom à la molette.
|
||||
- **Texte / voix :** « Permanente, maintenue ou désactivée. Placée là où ton interface en a besoin. »
|
||||
- **Sortie :** `SANCTUARY / ATLAS 017`.
|
||||
- **Source :** [modes et placements de mini-carte](../../README.md#progression-sanctuary-alpha).
|
||||
|
||||
## 018 — L'Atlas observe les traces du monde
|
||||
|
||||
- **Format / durée :** découverte, 15 s.
|
||||
- **Accroche :** « Certains lieux se cartographient sans marqueur manuel. »
|
||||
- **Plans :** passage près d'un coffre, d'un portail et d'un générateur ; ouverture de l'Atlas ; trois pictogrammes fixes visibles.
|
||||
- **Texte / voix :** « Coffres, portails, générateurs et rails souterrains deviennent des points d'intérêt locaux. »
|
||||
- **Sortie :** `SANCTUARY / ATLAS 018`.
|
||||
- **Source :** [détection des lieux Atlas](../../README.md#progression-sanctuary-alpha).
|
||||
|
||||
## 019 — Maison et Arbre ne sont jamais devinés
|
||||
|
||||
- **Format / durée :** une règle, 14 s.
|
||||
- **Accroche :** « Sanctuary refuse de décider où se trouve ta maison. »
|
||||
- **Plans :** Personal Computer posé ; clic sur « Maison ici » ; apparition du pictogramme et de son libellé dans l'Atlas.
|
||||
- **Texte / voix :** « Ces lieux sont personnels. Tu les déclares toi-même depuis ton ordinateur. »
|
||||
- **Sortie :** `SANCTUARY / ATLAS 019`.
|
||||
- **Source :** [Personal Computer et lieux personnels](../../README.md#red-stoner-et-photographie).
|
||||
|
||||
## 020 — Le monde principal est un archipel flottant
|
||||
|
||||
- **Format / durée :** chronique du monde, 18 s.
|
||||
- **Accroche :** aucun texte pendant les deux premières secondes.
|
||||
- **Plans :** départ sur l'île centrale ; travelling très lent au-dessus de la couronne de vide ; révélation des îles procédurales extérieures.
|
||||
- **Texte / voix :** « Sanctuary remplace le monde par défaut : un refuge central, du vide, puis un archipel sans fin. »
|
||||
- **Sortie :** `SANCTUARY / MONDE 020`.
|
||||
- **Source :** [génération du monde principal](../../README.md#génération-du-monde-principal).
|
||||
|
||||
## 021 — Le vide protège le début de partie
|
||||
|
||||
- **Format / durée :** une règle du monde, 15 s.
|
||||
- **Accroche :** « Cette absence de terrain est volontaire. »
|
||||
- **Plans :** bord de l'île ; longue focale sur le vide ; îles lointaines reprenant au-delà de la couronne.
|
||||
- **Texte / voix :** « Le refuge initial est séparé du monde procédural. S'en échapper devient une étape de progression. »
|
||||
- **Sortie :** `SANCTUARY / MONDE 021`.
|
||||
- **Source :** [départ de partie](../../sanctuary/PROGRESSION.md#2-départ-de-partie).
|
||||
|
||||
## 022 — 108 biomes, sans charger Terralith
|
||||
|
||||
- **Format / durée :** preuve visuelle, 18 s.
|
||||
- **Accroche :** « Sanctuary distribue 108 biomes natifs. »
|
||||
- **Plans :** trois biomes très contrastés en coupes franches ; survol d'une transition ; écran final sobre avec `108`.
|
||||
- **Texte / voix :** « Huit biomes historiques et cent créations Terramix, générés sous les règles climatiques natives du pack. »
|
||||
- **Sortie :** `SANCTUARY / BIOMES 022`.
|
||||
- **Source :** [biomes natifs d'Another World](../../anotherworld/README.md#biomes-natifs).
|
||||
|
||||
## 023 — Le climat dessine des provinces
|
||||
|
||||
- **Format / durée :** note de conception, 17 s.
|
||||
- **Accroche :** « Les biomes ne sont pas tirés au hasard chunk par chunk. »
|
||||
- **Plans :** travelling continu traversant une zone chaude et sèche ; transition vers une zone humide ; carte large montrant les grands contours.
|
||||
- **Texte / voix :** « Température et humidité forment de grandes provinces. Une variation locale casse ensuite leurs frontières. »
|
||||
- **Sortie :** `SANCTUARY / CLIMAT 023`.
|
||||
- **Source :** [prototype climatique de l'alpha.189](../../CHANGELOG.md#2620-alpha189).
|
||||
|
||||
## 024 — Les Sulfur Caves s'annoncent dans la pierre
|
||||
|
||||
- **Format / durée :** découverte, 15 s.
|
||||
- **Accroche :** « Avant la caverne, la roche te prévient. »
|
||||
- **Plans :** tunnel de deepslate ; première amorce de soufre ; révélation des piscines et pointes de la Sulfur Cave.
|
||||
- **Texte / voix :** « Une trace visible précède la formation principale. L'exploration peut se lire dans les parois. »
|
||||
- **Sortie :** `SANCTUARY / CAVERNES 024`.
|
||||
- **Source :** [Sulfur Caves de l'alpha.179](../../CHANGELOG.md#2620-alpha179).
|
||||
|
||||
## 025 — Un donjon peut être suspendu sous une île
|
||||
|
||||
- **Format / durée :** chronique du monde, 17 s.
|
||||
- **Accroche :** « Certains donjons n'ont pas de sol. »
|
||||
- **Plans :** île flottante vue de côté ; descente verticale ; révélation d'un donjon de cobblestone accroché par des chaînes.
|
||||
- **Texte / voix :** « S'il trouve de la matière au-dessus, le donjon s'y suspend. Sans ancrage, il n'apparaît pas. »
|
||||
- **Sortie :** `SANCTUARY / DONJONS 025`.
|
||||
- **Source :** [donjons suspendus de l'alpha.188](../../CHANGELOG.md#2620-alpha188).
|
||||
|
||||
## 026 — Une ravine tapissée de seize couleurs
|
||||
|
||||
- **Format / durée :** révélation, 14 s.
|
||||
- **Accroche :** « Ce biome est volontairement presque introuvable. »
|
||||
- **Plans :** marche dans un paysage sombre ; arrivée au bord ; plongée lente dans une Rainbow Ravine verticale.
|
||||
- **Texte / voix :** « Les seize blocs arc-en-ciel suivent les entailles irrégulières du relief, du sommet jusqu'au fond. »
|
||||
- **Sortie :** `SANCTUARY / BIOMES 026`.
|
||||
- **Source :** [Rainbow Ravine de l'alpha.114](../../CHANGELOG.md#2620-alpha114).
|
||||
|
||||
## 027 — Un monde Minecraft fini en 128 × 128 × 64
|
||||
|
||||
- **Format / durée :** chronique historique, 18 s.
|
||||
- **Accroche :** « Ce monde possède une vraie fin. »
|
||||
- **Plans :** panorama de l'île Indev ; plage, caverne et maison en coupes courtes ; approche de la limite extérieure.
|
||||
- **Texte / voix :** « Alpha reconstruit un volume déterministe de 128 × 128 × 64 blocs. Rien ne se génère au-delà. »
|
||||
- **Sortie :** `SANCTUARY / ALPHA 027`.
|
||||
- **Source :** [contrat de génération Alpha](../../sanctuary/ALPHA.md#contrat-de-génération).
|
||||
|
||||
## 028 — Le portail d'Alpha se construit en cobblestone moussue
|
||||
|
||||
- **Format / durée :** interaction, 15 s.
|
||||
- **Accroche :** « Quatre sur cinq. Deux sur trois à l'intérieur. »
|
||||
- **Plans :** construction du cadre extérieur 4 × 5 ; allumage au briquet ; arrivée sûre hors de la maison Indev.
|
||||
- **Texte / voix :** « Le serveur valide le cadre, la dimension et l'arrivée. La première traversée crée une paire persistante. »
|
||||
- **Sortie :** `SANCTUARY / PORTAIL 028`.
|
||||
- **Source :** [accès à Alpha](../../sanctuary/ALPHA.md#accès).
|
||||
|
||||
## 029 — Les textures Alpha n'existent que dans Alpha
|
||||
|
||||
- **Format / durée :** avant / après, 16 s.
|
||||
- **Accroche :** « Un resource pack peut appartenir à une dimension. »
|
||||
- **Plans :** monde normal avec la pile habituelle ; traversée vers Alpha et rechargement ; retour et restauration exacte de l'apparence précédente.
|
||||
- **Texte / voix :** « Ambiance compose Golden Days Base puis Alpha uniquement pendant la visite, sans redistribuer leurs fichiers. »
|
||||
- **Sortie :** `SANCTUARY / AMBIANCE 029`.
|
||||
- **Source :** [composition Golden Days](../../ambiance/GOLDEN_DAYS.md).
|
||||
|
||||
## 030 — Notch habite encore dans la maison Indev
|
||||
|
||||
- **Format / durée :** chronique, 16 s.
|
||||
- **Accroche :** « Dans ce monde, Notch est un résident. Pas un spawn aléatoire. »
|
||||
- **Plans :** approche silencieuse de la maison ; Notch à l'intérieur ; même maison après un redémarrage de la scène.
|
||||
- **Texte / voix :** « Il existe en un seul exemplaire persistant. S'il disparaît, Sanctuary sait le ramener chez lui. »
|
||||
- **Sortie :** `SANCTUARY / ALPHA 030`.
|
||||
- **Source :** [résident d'Alpha](../../sanctuary/ALPHA.md#résident-et-jalon-mondial).
|
||||
|
||||
## 031 — La première victoire crée un seul Magic Carpet
|
||||
|
||||
- **Format / durée :** récompense, 18 s.
|
||||
- **Accroche :** « Ce coffre n'existe qu'après la première victoire mondiale. »
|
||||
- **Plans :** fin du combat de Notch ; feu d'artifice près de la maison ; ouverture du coffre avec le tapis placé au centre.
|
||||
- **Texte / voix :** « La réservation et la livraison sont sauvegardées séparément : même un redémarrage ne duplique pas la récompense. »
|
||||
- **Sortie :** `SANCTUARY / RÉCOMPENSE 031`.
|
||||
- **Source :** [première récompense du Magic Carpet](../../iliketomoveit/MAGIC_CARPET.md#première-récompense).
|
||||
|
||||
## 032 — Une Waystone doit être découverte physiquement
|
||||
|
||||
- **Format / durée :** une règle, 13 s.
|
||||
- **Accroche :** « Poser une pierre ne suffit pas pour la connaître. »
|
||||
- **Plans :** menu sans destination ; arrivée à pied et contact avec la pierre ; destination maintenant visible.
|
||||
- **Texte / voix :** « Chaque joueur possède son propre réseau de lieux réellement visités. »
|
||||
- **Sortie :** `SANCTUARY / WAYSTONE 032`.
|
||||
- **Source :** [Waystones locales](../../README.md#waystones-locaux).
|
||||
|
||||
## 033 — Le voyage coûte la distance parcourue
|
||||
|
||||
- **Format / durée :** une règle, 12 s.
|
||||
- **Accroche :** « Une téléportation lointaine ne coûte pas comme un saut de voisinage. »
|
||||
- **Plans :** deux destinations à des distances différentes ; affichage des coûts ; voyage vers la plus lointaine.
|
||||
- **Texte / voix :** « Le serveur facture un niveau d'XP par tranche commencée de 512 blocs et recherche une arrivée sûre. »
|
||||
- **Sortie :** `SANCTUARY / WAYSTONE 033`.
|
||||
- **Source :** [coût des Waystones](../../README.md#waystones-locaux).
|
||||
|
||||
## 034 — Une pierre peut être renommée sans commande
|
||||
|
||||
- **Format / durée :** interaction cachée, 10 s.
|
||||
- **Accroche :** « Ouvre une Waystone. Appuie sur `R`. »
|
||||
- **Plans :** ouverture du réseau ; saisie d'un nom court ; validation avec `Entrée` puis affichage immédiat.
|
||||
- **Texte / voix :** « `Échap` annule. Le nouveau nom reste attaché à la pierre. »
|
||||
- **Sortie :** `SANCTUARY / WAYSTONE 034`.
|
||||
- **Source :** [renommage des Waystones](../../README.md#waystones-locaux).
|
||||
|
||||
## 035 — Un ascenseur peut descendre vers l'inconnu
|
||||
|
||||
- **Format / durée :** interaction cachée, 13 s.
|
||||
- **Accroche :** « Tu peux descendre vers un étage que tu n'as jamais visité. »
|
||||
- **Plans :** deux Waystones alignées en X/Z ; accroupissement sur celle du haut ; arrivée en bas et découverte de la destination.
|
||||
- **Texte / voix :** « La montée reste interdite tant que l'étage supérieur n'a pas été découvert. »
|
||||
- **Sortie :** `SANCTUARY / ASCENSEUR 035`.
|
||||
- **Source :** [ascenseur vertical Waystone](../../README.md#waystones-locaux).
|
||||
|
||||
## 036 — Le grappin conserve ton élan
|
||||
|
||||
- **Format / durée :** démonstration, 14 s.
|
||||
- **Accroche :** « Ce grappin ne te téléporte pas vers son crochet. »
|
||||
- **Plans :** lancement ; balancement en arc lisible ; lâcher au point bas et conservation de la vitesse.
|
||||
- **Texte / voix :** « Charge, longueur de corde, rappel, descente et restitution de l'élan composent le mouvement. »
|
||||
- **Sortie :** `SANCTUARY / GRAPPIN 036`.
|
||||
- **Source :** [grappin et poulies](../../README.md#grappin-et-poulies).
|
||||
|
||||
## 037 — Un grappin fluide, même quand le serveur décide
|
||||
|
||||
- **Format / durée :** problème / Sanctuary, 16 s.
|
||||
- **Accroche :** « En multijoueur, un grappin peut devenir une suite de corrections. »
|
||||
- **Plans :** capture authentique d'un ancien mouvement saccadé si disponible ; version actuelle fluide ; très bref arrêt sur la position réconciliée.
|
||||
- **Texte / voix :** « Le client prédit le même pendule. Le serveur reste l'autorité et ne corrige que dans une limite bornée. »
|
||||
- **Sortie :** `SANCTUARY / LABO 037`.
|
||||
- **Source :** [prédiction du grappin, alpha.196](../../CHANGELOG.md#2620-alpha196).
|
||||
|
||||
## 038 — Deux poulies créent une tyrolienne
|
||||
|
||||
- **Format / durée :** démonstration, 12 s.
|
||||
- **Accroche :** « Deux points. Jusqu'à 128 blocs. »
|
||||
- **Plans :** liaison des deux têtes ; vue latérale de la ligne ; traversée complète à vitesse constante.
|
||||
- **Texte / voix :** « Les collisions sont vérifiées pendant tout le trajet. »
|
||||
- **Sortie :** `SANCTUARY / POULIE 038`.
|
||||
- **Source :** [poulies et tyrolienne](../../README.md#grappin-et-poulies).
|
||||
|
||||
## 039 — La tyrolienne peut piloter une machine
|
||||
|
||||
- **Format / durée :** interaction entre systèmes, 12 s.
|
||||
- **Accroche :** « Le passage d'un joueur devient un signal. »
|
||||
- **Plans :** lampe redstone éteinte reliée à la poulie ; joueur traversant ; signal 15 et lampe allumée pendant le passage.
|
||||
- **Texte / voix :** « Le déplacement peut devenir une entrée d'automatisation. »
|
||||
- **Sortie :** `SANCTUARY / SYSTÈMES 039`.
|
||||
- **Source :** [signal redstone des poulies](../../README.md#grappin-et-poulies).
|
||||
|
||||
## 040 — Un Creeper peut naître avec sept apparences
|
||||
|
||||
- **Format / durée :** collection, 15 s.
|
||||
- **Accroche :** « C'est toujours le Creeper vanilla. »
|
||||
- **Plans :** même cadre, coupes rapides entre classic, simple, pig, business, better, shiny et starry night ; arrêt sur le dernier.
|
||||
- **Texte / voix :** « L'entité, la charge et l'œuf restent vanilla. L'apparence et le tempérament deviennent persistants. »
|
||||
- **Sortie :** `SANCTUARY / CREEPER 040`.
|
||||
- **Source :** [apparences de Creepers](../../itsalive/README.md#creepers).
|
||||
|
||||
## 041 — Une fleur peut rendre un Creeper heureux
|
||||
|
||||
- **Format / durée :** interaction, 11 s.
|
||||
- **Accroche :** « Essaie de lui donner une fleur. »
|
||||
- **Plans :** approche dangereuse ; remise de la fleur ; fleur visible sur la tête et Creeper suivant le joueur.
|
||||
- **Texte / voix :** « Le changement n'est pas cosmétique : son tempérament devient heureux. »
|
||||
- **Sortie :** `SANCTUARY / CREEPER 041`.
|
||||
- **Source :** [tempérament heureux](../../itsalive/README.md#creepers).
|
||||
|
||||
## 042 — Le bonheur d'un Creeper est réversible
|
||||
|
||||
- **Format / durée :** interaction cachée, 10 s.
|
||||
- **Accroche :** « Tu peux reprendre la fleur. »
|
||||
- **Plans :** Creeper heureux ; tonte ; fleur rendue au joueur et retour immédiat du tempérament dangereux.
|
||||
- **Texte / voix :** « Rien n'est supprimé silencieusement. Même la fleur revient. »
|
||||
- **Sortie :** `SANCTUARY / CREEPER 042`.
|
||||
- **Source :** [tonte du Creeper](../../itsalive/README.md#creepers).
|
||||
|
||||
## 043 — Une poule sur 256 peut pondre ceci
|
||||
|
||||
- **Format / durée :** découverte rare, 12 s.
|
||||
- **Accroche :** plan fixe sur l'œuf doré, sans texte pendant une seconde.
|
||||
- **Plans :** poule vanilla ; ponte d'un œuf doré mise en scène avec une capture authentique ou une reproduction de laboratoire clairement identifiée ; lancer de l'œuf.
|
||||
- **Texte / voix :** « Une poule vanilla possède une chance sur 256 de pondre l'entrée vers une nouvelle lignée. »
|
||||
- **Sortie :** `SANCTUARY / ŒUF DORÉ 043`.
|
||||
- **Source :** [œuf doré et poules à gemmes](../../itsalive/README.md#poules-à-gemmes).
|
||||
|
||||
## 044 — Les poules peuvent pondre des gemmes
|
||||
|
||||
- **Format / durée :** collection, 15 s.
|
||||
- **Accroche :** « Verte, rouge ou bleue. Trois cadences différentes. »
|
||||
- **Plans :** bébé issu de l'œuf doré ; trois variantes adultes ; émeraude, rubis et saphir pondus en coupes distinctes.
|
||||
- **Texte / voix :** « L'émeraude est la plus rapide. Le saphir, la plus lente. »
|
||||
- **Sortie :** `SANCTUARY / POULLES 044`.
|
||||
- **Source :** [variantes et cadences des poules à gemmes](../../itsalive/README.md#poules-à-gemmes).
|
||||
|
||||
## 045 — Une vache sur 64 se tient debout
|
||||
|
||||
- **Format / durée :** découverte absurde, 11 s.
|
||||
- **Accroche :** plan fixe d'une vache normale qui sort du cadre, puis révélation de la vache debout.
|
||||
- **Plans :** contraste des deux silhouettes ; marche de la variante ; plan final frontal.
|
||||
- **Texte / voix :** « La mutation est rare, persistante et peut se transmettre par l'élevage. »
|
||||
- **Sortie :** `SANCTUARY / VACHE 045`.
|
||||
- **Source :** [vaches debout rares](../../itsalive/README.md#vaches-debout-rares).
|
||||
|
||||
## 046 — Chaque villageois reçoit un nom stable
|
||||
|
||||
- **Format / durée :** une règle, 12 s.
|
||||
- **Accroche :** « Ce villageois ne changera pas de nom au redémarrage. »
|
||||
- **Plans :** trois villageois avec noms différents ; sortie puis retour dans le monde ; mêmes noms visibles.
|
||||
- **Texte / voix :** « Le nom vient de son UUID. Un nom donné manuellement n'est jamais remplacé. »
|
||||
- **Sortie :** `SANCTUARY / VILLAGE 046`.
|
||||
- **Source :** [villageois nommés](../../itsalive/README.md#villageois-nommés).
|
||||
|
||||
## 047 — Il existe seize Mooblooms
|
||||
|
||||
- **Format / durée :** collection, 16 s.
|
||||
- **Accroche :** « Une seule créature. Seize fleurs. »
|
||||
- **Plans :** travelling devant une ligne de variantes ; gros plan sur les fleurs du dos ; gros plan sur celle qui suit la tête.
|
||||
- **Texte / voix :** « Chaque variante est synchronisée, sauvegardée et possède son propre œuf de test. »
|
||||
- **Sortie :** `SANCTUARY / MOOBLOOM 047`.
|
||||
- **Source :** [seize variantes de Moobloom](../../itsalive/README.md#mooblooms).
|
||||
|
||||
## 048 — Un veau Moobloom hérite d'un de ses parents
|
||||
|
||||
- **Format / durée :** expérience, 13 s.
|
||||
- **Accroche :** « Deux fleurs différentes. Une seule pour le veau. »
|
||||
- **Plans :** deux parents de variantes distinctes ; reproduction ; révélation de la variante héritée.
|
||||
- **Texte / voix :** « Le veau choisit aléatoirement la fleur de l'un de ses deux parents. »
|
||||
- **Sortie :** `SANCTUARY / HÉRITAGE 048`.
|
||||
- **Source :** [élevage des Mooblooms](../../itsalive/README.md#mooblooms).
|
||||
|
||||
## 049 — Les papillons se nourrissent de fleurs
|
||||
|
||||
- **Format / durée :** chronique calme, 14 s.
|
||||
- **Accroche :** aucun texte pendant le premier vol.
|
||||
- **Plans :** groupe de papillons autour d'un biome fleuri ; joueur tenant une fleur ; reproduction puis réaction agressive après une attaque.
|
||||
- **Texte / voix :** « Ils suivent les fleurs, se reproduisent avec elles et se défendent lorsqu'on les attaque. »
|
||||
- **Sortie :** `SANCTUARY / PAPILLONS 049`.
|
||||
- **Source :** [papillons](../../itsalive/README.md#papillons).
|
||||
|
||||
## 050 — Le Ghost ne reste jamais jusqu'au matin
|
||||
|
||||
- **Format / durée :** horreur minimale, 15 s.
|
||||
- **Accroche :** murmure hors champ, sans texte.
|
||||
- **Plans :** Ghost traversant un mur ; charge courte vers le joueur ; lever du jour et disparition.
|
||||
- **Texte / voix :** « La nuit, il hante, murmure, frappe, puis disparaît. Le jour le supprime. »
|
||||
- **Sortie :** `SANCTUARY / GHOST 050`.
|
||||
- **Source :** [Real Ghost](../../itsalive/README.md#real-ghost).
|
||||
|
||||
## 051 — Bracken recule quand tu le regardes
|
||||
|
||||
- **Format / durée :** horreur minimale, 15 s.
|
||||
- **Accroche :** « Ne détourne pas les yeux. Mais ne le fixe pas trop longtemps. »
|
||||
- **Plans :** Bracken approchant dans le dos ; regard du joueur et recul ; rage après un regard prolongé.
|
||||
- **Texte / voix :** « Il préfère attaquer par derrière. Les haches et le feu lui font davantage de dégâts. »
|
||||
- **Sortie :** `SANCTUARY / BRACKEN 051`.
|
||||
- **Source :** [comportement de Bracken](../../itsalive/README.md#real-ghost).
|
||||
|
||||
## 052 — Sanctuary supprime les Phantoms
|
||||
|
||||
- **Format / durée :** une règle, 9 s.
|
||||
- **Accroche :** « Les Phantoms n'existent pas ici. »
|
||||
- **Plans :** nuit après plusieurs jours sans dormir ; ciel vide ; tentative de chargement d'un Phantom immédiatement supprimé en laboratoire.
|
||||
- **Texte / voix :** « C'est la politique héritée de Real Ghost et No Phantoms. »
|
||||
- **Sortie :** `SANCTUARY / RÈGLE 052`.
|
||||
- **Source :** [suppression des Phantoms](../../itsalive/README.md#real-ghost).
|
||||
|
||||
## 053 — Une baleine de 36 blocs traverse le ciel
|
||||
|
||||
- **Format / durée :** chronique du monde, 18 s.
|
||||
- **Accroche :** garder les deux premières secondes entièrement silencieuses.
|
||||
- **Plans :** ombre passant sur le terrain ; révélation latérale de la baleine ; plan large avec son sillage nuageux.
|
||||
- **Texte / voix :** « Elle est géante, intangible, invulnérable et ne donne aucun butin. Elle existe seulement pour traverser le monde. »
|
||||
- **Sortie :** `SANCTUARY / BALEINE 053`.
|
||||
- **Source :** [baleine volante géante](../../itsalive/README.md#baleine-volante-géante).
|
||||
|
||||
## 054 — Cinq Infectés, une seule entité persistante
|
||||
|
||||
- **Format / durée :** collection, 15 s.
|
||||
- **Accroche :** « Rouge, jaune, orange, violet, vert. »
|
||||
- **Plans :** ligne des cinq variantes ; gros plans sur les t-shirts ; attaque simultanée puis coupe noire.
|
||||
- **Texte / voix :** « Infecté, vomiteur, cracheur, hunter et chargeur partagent la même base, mais pas le même comportement. »
|
||||
- **Sortie :** `SANCTUARY / INFECTÉS 054`.
|
||||
- **Source :** [cinq variantes d'Infectés](../../itsalive/README.md#infectés).
|
||||
|
||||
## 055 — Le Vomiteur transforme la vision en danger
|
||||
|
||||
- **Format / durée :** démonstration, 13 s.
|
||||
- **Accroche :** attaque reçue avant l'apparition du texte.
|
||||
- **Plans :** Vomiteur ; overlay de projections ; désorientation et monstres proches attirés vers le joueur.
|
||||
- **Texte / voix :** « Neuf secondes de vision masquée. Douze secondes de désorientation. Et le bruit attire les monstres. »
|
||||
- **Sortie :** `SANCTUARY / INFECTÉS 055`.
|
||||
- **Source :** [attaque du Vomiteur](../../itsalive/README.md#infectés).
|
||||
|
||||
## 056 — Le Cracheur combat à distance
|
||||
|
||||
- **Format / durée :** démonstration, 11 s.
|
||||
- **Accroche :** « Celui-ci n'a pas besoin de te toucher. »
|
||||
- **Plans :** projectile acide en trajectoire ; impact ; effets Poison et Lenteur visibles.
|
||||
- **Texte / voix :** « Une boule d'acide blesse, empoisonne et ralentit. »
|
||||
- **Sortie :** `SANCTUARY / INFECTÉS 056`.
|
||||
- **Source :** [attaque du Cracheur](../../itsalive/README.md#infectés).
|
||||
|
||||
## 057 — Quatre pressions pour repousser le Hunter
|
||||
|
||||
- **Format / durée :** règle de survie, 16 s.
|
||||
- **Accroche :** écran noir au moment de la capture.
|
||||
- **Plans :** bond corrigé vers une cible mobile ; Hunter assis sur la tête ; quatre pressions distinctes d'accroupissement puis libération.
|
||||
- **Texte / voix :** « Il verrouille sa victime pendant six secondes. Résister quatre fois le repousse. »
|
||||
- **Sortie :** `SANCTUARY / INFECTÉS 057`.
|
||||
- **Source :** [capture du Hunter](../../itsalive/README.md#infectés).
|
||||
|
||||
## 058 — Le Chargeur devient vulnérable après l'impact
|
||||
|
||||
- **Format / durée :** règle de combat, 12 s.
|
||||
- **Accroche :** « Esquiver est aussi une attaque. »
|
||||
- **Plans :** préparation lisible ; charge et esquive latérale ; courte récupération pendant laquelle le joueur riposte.
|
||||
- **Texte / voix :** « Il percute et repousse. Puis il doit récupérer. »
|
||||
- **Sortie :** `SANCTUARY / INFECTÉS 058`.
|
||||
- **Source :** [comportement du Chargeur](../../itsalive/README.md#infectés).
|
||||
|
||||
## 059 — Un œuf équipé devient un pouvoir
|
||||
|
||||
- **Format / durée :** manifeste de système, 16 s.
|
||||
- **Accroche :** « Les œufs d'apparition ne servent plus seulement à faire apparaître des créatures. »
|
||||
- **Plans :** œuf équipé dans le slot de familier ; apparition du familier visuel ; activation avec `G` de trois pouvoirs contrastés en coupes rapides.
|
||||
- **Texte / voix :** « Une essence à la fois. Un passif. Un actif choisi et validé par le serveur. »
|
||||
- **Sortie :** `SANCTUARY / FAMILIERS 059`.
|
||||
- **Source :** [contrat des pouvoirs de familiers](../../sanctuary/COMPANION_POWERS.md#contrat-de-jeu).
|
||||
|
||||
## 060 — Certaines cultures poussent sur deux blocs
|
||||
|
||||
- **Format / durée :** time-lapse, 12 s.
|
||||
- **Accroche :** « Le maïs ne reste pas une texture plate. »
|
||||
- **Plans :** plant bas ; accéléré des huit stades ; maïs, tomate ou concombre devenant réellement haut de deux blocs.
|
||||
- **Texte / voix :** « La moitié supérieure reste synchronisée avec le plant inférieur. »
|
||||
- **Sortie :** `SANCTUARY / AGRICULTURE 060`.
|
||||
- **Source :** [agriculture It's Alive](../../itsalive/README.md#agriculture).
|
||||
|
||||
## 061 — Récolter sans arracher la plante
|
||||
|
||||
- **Format / durée :** interaction, 11 s.
|
||||
- **Accroche :** « Certaines récoltes sont pérennes. »
|
||||
- **Plans :** plant mûr de tomate ; clic de récolte donnant les fruits ; retour visuel du plant à un stade plus jeune.
|
||||
- **Texte / voix :** « Fraise, tomate, concombre, poivron, aubergine et raisin repoussent après la récolte. »
|
||||
- **Sortie :** `SANCTUARY / AGRICULTURE 061`.
|
||||
- **Source :** [cultures pérennes](../../itsalive/README.md#agriculture).
|
||||
|
||||
## 062 — Le riz exige une vraie rizière
|
||||
|
||||
- **Format / durée :** une règle, 12 s.
|
||||
- **Accroche :** « Pas de riz sur une simple terre labourée. »
|
||||
- **Plans :** plantation refusée sur une parcelle ordinaire ; source d'eau pleine posée sur de la boue ; croissance complète du riz immergé.
|
||||
- **Texte / voix :** « Le décor et la règle agricole racontent la même chose. »
|
||||
- **Sortie :** `SANCTUARY / CUISINE 062`.
|
||||
- **Source :** [riz immergé](../../itsalive/FOOD_WIKI.md#riz--une-vraie-rizière).
|
||||
|
||||
## 063 — Le haricot change selon le moment de récolte
|
||||
|
||||
- **Format / durée :** comparaison, 12 s.
|
||||
- **Accroche :** « Récolté jeune ou récolté sec, ce n'est pas le même ingrédient. »
|
||||
- **Plans :** récolte d'un plant jeune ; même cadrage sur le plant mûr ; comparaison des deux objets.
|
||||
- **Texte / voix :** « Le temps de récolte devient un choix culinaire. »
|
||||
- **Sortie :** `SANCTUARY / CUISINE 063`.
|
||||
- **Source :** [haricot vert ou sec](../../itsalive/FOOD_WIKI.md#haricot--vert-ou-sec).
|
||||
|
||||
## 064 — Six machines, six gestes culinaires
|
||||
|
||||
- **Format / durée :** collection, 18 s.
|
||||
- **Accroche :** « Presser. Moudre. Poêler. Bouillir. Cuire. Fermenter. »
|
||||
- **Plans :** une action lisible par poste, toujours avec la même caméra et la même table de travail ; résultat final aligné.
|
||||
- **Texte / voix :** « La cuisine de Sanctuary n'est pas un unique menu de recettes. »
|
||||
- **Sortie :** `SANCTUARY / CUISINE 064`.
|
||||
- **Source :** [postes de cuisine](../../itsalive/FOOD_WIKI.md#postes-de-cuisine).
|
||||
|
||||
## 065 — Le livre culinaire possède 64 pages à découvrir
|
||||
|
||||
- **Format / durée :** découverte, 15 s.
|
||||
- **Accroche :** « Les recettes ne sont pas toutes connues au départ. »
|
||||
- **Plans :** livre presque vide ; obtention d'une page ; nouvelle recette révélée ; livre posé sur un pupitre et lu par un autre joueur.
|
||||
- **Texte / voix :** « Le catalogue est personnel dans l'inventaire et temporairement partageable sur un pupitre. »
|
||||
- **Sortie :** `SANCTUARY / LIVRE 065`.
|
||||
- **Source :** [64 pages et partage du livre](../../itsalive/FOOD_WIKI.md#les-64-pages-de-recettes).
|
||||
|
||||
## 066 — Canaplia possède seize familles
|
||||
|
||||
- **Format / durée :** collection, 16 s.
|
||||
- **Accroche :** « Seize plantes. Seize découvertes. Seize effets. »
|
||||
- **Plans :** grille ou travelling de cultures matures ; trois têtes et résines en gros plan ; Livre interdit refermé.
|
||||
- **Texte / voix :** « Chaque famille possède sa culture, sa progression et son profil serveur. »
|
||||
- **Sortie :** `SANCTUARY / CANAPLIA 066`.
|
||||
- **Source :** [migration Canaplia](../../CANAPLIA.md).
|
||||
|
||||
## 067 — Le climat décide où découvrir Canaplia
|
||||
|
||||
- **Format / durée :** exploration, 15 s.
|
||||
- **Accroche :** « Toutes les plantes ne peuvent pas apparaître partout. »
|
||||
- **Plans :** recherche dans un biome incompatible ; voyage vers le bon climat ; découverte de la variante sauvage.
|
||||
- **Texte / voix :** « Les sources sont distribuées par climat. Ember reste au Nether. Indigo reste dans l'End. »
|
||||
- **Sortie :** `SANCTUARY / CANAPLIA 067`.
|
||||
- **Source :** [découvertes climatiques de l'alpha.208](../../CHANGELOG.md#2620-alpha208).
|
||||
|
||||
## 068 — Une hybridation vérifie réellement ses parents
|
||||
|
||||
- **Format / durée :** expérience, 15 s.
|
||||
- **Accroche :** « Deux plantes côte à côte ne suffisent pas. »
|
||||
- **Plans :** hybridation refusée dans de mauvaises conditions ; correction de l'humidité, de la lumière ou de l'heure ; résultat obtenu.
|
||||
- **Texte / voix :** « Le serveur revalide les parents matures, l'eau, le climat, la lumière et le moment. »
|
||||
- **Sortie :** `SANCTUARY / CANAPLIA 068`.
|
||||
- **Source :** [six hybridations validées côté serveur](../../CHANGELOG.md#2620-alpha208).
|
||||
|
||||
## 069 — Dix têtes deviennent une résine
|
||||
|
||||
- **Format / durée :** transformation, 12 s.
|
||||
- **Accroche :** « Dix têtes identiques. Une seule résine. »
|
||||
- **Plans :** dix objets réunis ; transformation ; application et aura persistante visualisée sobrement.
|
||||
- **Texte / voix :** « L'aura dure 90 secondes et reste attachée à son état serveur. »
|
||||
- **Sortie :** `SANCTUARY / CANAPLIA 069`.
|
||||
- **Source :** [résines Canaplia de l'alpha.208](../../CHANGELOG.md#2620-alpha208).
|
||||
|
||||
## 070 — Un bad trip doit rester accessible
|
||||
|
||||
- **Format / durée :** note de conception, 16 s.
|
||||
- **Accroche :** « Un effet psychédélique ne doit pas rendre le jeu injouable. »
|
||||
- **Plans :** profil normal ; profil bad trip à intensité modérée ; écran des réglages montrant réduction des mouvements, wobble et ghosting.
|
||||
- **Texte / voix :** « Ambiance compose un seul effet à la fois et laisse réduire ses mouvements visuels. »
|
||||
- **Sortie :** `SANCTUARY / ACCESSIBILITÉ 070`.
|
||||
- **Source :** [profils Canaplia et réglages Ambiance](../../CHANGELOG.md#2620-alpha208).
|
||||
|
||||
## 071 — Le Livre interdit est personnel, puis partageable
|
||||
|
||||
- **Format / durée :** interaction, 14 s.
|
||||
- **Accroche :** « Ton livre connaît tes découvertes. Pas celles de tout le serveur. »
|
||||
- **Plans :** deux joueurs avec catalogues différents ; pose sur pupitre ; consultation temporaire par le second joueur ; retrait du livre.
|
||||
- **Texte / voix :** « Sur un pupitre, le savoir se partage. Dans l'inventaire, il redevient personnel. »
|
||||
- **Sortie :** `SANCTUARY / CANAPLIA 071`.
|
||||
- **Source :** [Livre interdit de Canaplia](../../CANAPLIA.md).
|
||||
|
||||
## 072 — Le ciel suit l'heure réelle du serveur
|
||||
|
||||
- **Format / durée :** time-lapse, 15 s.
|
||||
- **Accroche :** « Ici, midi dans le jeu peut être midi dehors. »
|
||||
- **Plans :** horloge système ou carton d'heure sobre ; ciel correspondant ; accéléré contrôlé montrant la continuité du cycle.
|
||||
- **Texte / voix :** « Ambiance peut synchroniser le cycle du ciel sur le fuseau du serveur ou sur un décalage UTC fixe. »
|
||||
- **Sortie :** `SANCTUARY / TEMPS 072`.
|
||||
- **Source :** [runtime Realtime de l'alpha.126](../../CHANGELOG.md#2620-alpha126).
|
||||
|
||||
## 073 — Le calendrier commence le 17 mai 2009
|
||||
|
||||
- **Format / durée :** détail d'auteur, 11 s.
|
||||
- **Accroche :** « Le premier jour de ce monde a une date. »
|
||||
- **Plans :** joueur tenant une horloge ; apparition du HUD calendrier ; gros plan sur la date et le mois.
|
||||
- **Texte / voix :** « L'époque minecraftienne de Sanctuary commence le 17 mai 2009 et possède douze mois. »
|
||||
- **Sortie :** `SANCTUARY / CALENDRIER 073`.
|
||||
- **Source :** [calendrier Realtime](../../CHANGELOG.md#2620-alpha126).
|
||||
|
||||
## 074 — La météo de la journée est déterministe
|
||||
|
||||
- **Format / durée :** une règle du monde, 15 s.
|
||||
- **Accroche :** « La météo n'est choisie qu'une fois par jour. »
|
||||
- **Plans :** matin clair ; transition vers brouillard ou pluie légère ; consultation d'un appareil de prévision.
|
||||
- **Texte / voix :** « Éclaircies, ciel couvert, brouillard, pluie légère ou pluie normale composent le profil quotidien. »
|
||||
- **Sortie :** `SANCTUARY / MÉTÉO 074`.
|
||||
- **Source :** [profils météo de l'alpha.128](../../CHANGELOG.md#2620-alpha128) ; [appareils de prévision de l'alpha.131](../../CHANGELOG.md#2620-alpha131).
|
||||
|
||||
## 075 — Une pleine lune sur quatre devient rouge
|
||||
|
||||
- **Format / durée :** chronique nocturne, 16 s.
|
||||
- **Accroche :** lune blanche pendant deux secondes, puis coupe vers la lune de sang.
|
||||
- **Plans :** pleine lune et nuit blanche ; autre cycle en rouge ; plan du paysage sous la nouvelle ambiance.
|
||||
- **Texte / voix :** « Le cycle vanilla reste sur huit jours. La première nuit de sang arrive au jour 24. »
|
||||
- **Sortie :** `SANCTUARY / LUNE 075`.
|
||||
- **Source :** [cycle lunaire de l'alpha.128](../../CHANGELOG.md#2620-alpha128).
|
||||
|
||||
## 076 — Le premier jour refuse la mauvaise météo
|
||||
|
||||
- **Format / durée :** une règle du monde, 10 s.
|
||||
- **Accroche :** « Le premier jour doit rester lisible. »
|
||||
- **Plans :** première apparition ; ciel clair ; accéléré jusqu'au coucher sans pluie ni brouillard.
|
||||
- **Texte / voix :** « Le jour zéro est toujours clair. L'orage est rare et interdit pendant cette première journée. »
|
||||
- **Sortie :** `SANCTUARY / MÉTÉO 076`.
|
||||
- **Source :** [premier jour de l'alpha.128](../../CHANGELOG.md#2620-alpha128).
|
||||
|
||||
## 077 — Tout bloc lumineux tenu éclaire réellement
|
||||
|
||||
- **Format / durée :** avant / après, 12 s.
|
||||
- **Accroche :** « La torche n'a pas besoin d'être posée. »
|
||||
- **Plans :** grotte noire ; torche prise en main principale ; deuxième lumière tenue en main secondaire avec le terrain et les entités éclairés.
|
||||
- **Texte / voix :** « La lumière locale reprend le niveau vanilla du bloc et décroît d'un niveau par bloc. »
|
||||
- **Sortie :** `SANCTUARY / LUMIÈRE 077`.
|
||||
- **Source :** [Dynamic Torches de l'alpha.130](../../CHANGELOG.md#2620-alpha130).
|
||||
|
||||
## 078 — La pluie ne doit pas simplement assombrir l'image
|
||||
|
||||
- **Format / durée :** avant / après conceptuel, 14 s.
|
||||
- **Accroche :** « Une météo différente n'a pas besoin d'un filtre gris. »
|
||||
- **Plans :** paysage normal ; même cadrage sous pluie légère ; même cadrage sous orage avec seulement la portée du brouillard transformée.
|
||||
- **Texte / voix :** « Les profils météo modifient la visibilité sans écraser la colorimétrie du monde. »
|
||||
- **Sortie :** `SANCTUARY / AMBIANCE 078`.
|
||||
- **Source :** [signature visuelle de l'alpha.129](../../CHANGELOG.md#2620-alpha129).
|
||||
|
||||
## 079 — Un shader remplace proprement l'étalonnage final
|
||||
|
||||
- **Format / durée :** note de laboratoire, 14 s.
|
||||
- **Accroche :** « Deux traitements d'image superposés donnent rarement une meilleure image. »
|
||||
- **Plans :** Ambiance sans shader ; activation d'Iris ; rendu final sans double étalonnage.
|
||||
- **Texte / voix :** « Quand Iris est actif, son traitement final remplace celui d'Ambiance au lieu de s'y empiler. »
|
||||
- **Sortie :** `SANCTUARY / LABO 079`.
|
||||
- **Source :** [compatibilité Iris de l'alpha.129](../../CHANGELOG.md#2620-alpha129).
|
||||
|
||||
## 080 — L'appareil photo capture l'image après le shader
|
||||
|
||||
- **Format / durée :** problème / Sanctuary, 14 s.
|
||||
- **Accroche :** « Une photo doit ressembler à ce que le joueur voit. »
|
||||
- **Plans :** scène avec shader ; visée Canon ; carte photo finale comparée au cadre avant le HUD.
|
||||
- **Texte / voix :** « Canon capture après la composition finale d'Iris et avant l'interface. »
|
||||
- **Sortie :** `SANCTUARY / PHOTO 080`.
|
||||
- **Source :** [capture Canon compatible shaders](../../CHANGELOG.md#2620-alpha129).
|
||||
|
||||
## 081 — Le disque n'était pas trop grave à cause du pitch
|
||||
|
||||
- **Format / durée :** bug / correction, 18 s.
|
||||
- **Accroche :** « Ce disque jouait trop lentement et trop grave. »
|
||||
- **Plans :** extrait de test autorisé du symptôme historique ; carton très bref `stéréo 48 kHz → mono` ; lecture corrigée au même endroit.
|
||||
- **Texte / voix :** « La source stéréo était mal convertie. Sanctuary moyenne maintenant chaque paire gauche-droite, avec une vitesse OpenAL normale. »
|
||||
- **Sortie :** `SANCTUARY / LABO 081`.
|
||||
- **Source :** [correction audio de l'alpha.221](../../CHANGELOG.md#2620-alpha221).
|
||||
|
||||
## 082 — Une demi-dalle peut ouvrir 128 conteneurs
|
||||
|
||||
- **Format / durée :** révélation fonctionnelle, 15 s.
|
||||
- **Accroche :** « Ce terminal ne contient aucun objet. »
|
||||
- **Plans :** terminal fixé sur un coffre ; recul révélant le réseau contigu ; ouverture de la grille agrégée.
|
||||
- **Texte / voix :** « Il donne accès à un réseau borné de 128 coffres, coffres piégés ou barils chargés. »
|
||||
- **Sortie :** `SANCTUARY / STOCKAGE 082`.
|
||||
- **Source :** [terminal Red-Stoner de l'alpha.218](../../CHANGELOG.md#2620-alpha218).
|
||||
|
||||
## 083 — Le terminal affiche 54 types par page
|
||||
|
||||
- **Format / durée :** interaction, 12 s.
|
||||
- **Accroche :** « Une page. Cinquante-quatre types. »
|
||||
- **Plans :** première page ; défilement ; changement de page en conservant l'ordre des emplacements.
|
||||
- **Texte / voix :** « La grille agrège les types d'objets, pas les cases physiques des coffres. »
|
||||
- **Sortie :** `SANCTUARY / STOCKAGE 083`.
|
||||
- **Source :** [pagination du terminal, alpha.218](../../CHANGELOG.md#2620-alpha218).
|
||||
|
||||
## 084 — La recherche du terminal comprend Unicode
|
||||
|
||||
- **Format / durée :** démonstration d'interface, 13 s.
|
||||
- **Accroche :** « Accents et alphabets Unicode restent recherchables. »
|
||||
- **Plans :** saisie d'un nom Unicode réellement présent dans le catalogue ; filtrage instantané ; retrait d'une pile sans déplacement des résultats restants.
|
||||
- **Texte / voix :** « Les quantités deviennent compactes et les emplacements ne glissent plus lorsqu'une pile est vidée. »
|
||||
- **Sortie :** `SANCTUARY / STOCKAGE 084`.
|
||||
- **Source :** [recherche et stabilité du terminal, alpha.222](../../CHANGELOG.md#2620-alpha222).
|
||||
|
||||
## 085 — Le terminal se comporte comme un vrai conteneur
|
||||
|
||||
- **Format / durée :** problème / Sanctuary, 15 s.
|
||||
- **Accroche :** « Pas de bouton propriétaire pour fermer. Pas de clics réinventés. »
|
||||
- **Plans :** clic gauche ; clic droit ; shift-clic depuis une sixième rangée d'inventaire ; fermeture vanilla.
|
||||
- **Texte / voix :** « Les gestes de Minecraft fonctionnent sur toutes les rangées débloquées. Le serveur décide où déposer chaque pile. »
|
||||
- **Sortie :** `SANCTUARY / STOCKAGE 085`.
|
||||
- **Source :** [menu de conteneur du terminal, alpha.220](../../CHANGELOG.md#2620-alpha220).
|
||||
|
||||
## 086 — Un clic molette trie sans toucher à la hotbar
|
||||
|
||||
- **Format / durée :** interaction cachée, 10 s.
|
||||
- **Accroche :** « Ouvre un coffre. Clique sur la molette. »
|
||||
- **Plans :** coffre volontairement désordonné ; clic molette ; rangement immédiat, puis gros plan prouvant que la hotbar n'a pas bougé.
|
||||
- **Texte / voix :** « Coffres, barils, Shulkers, entonnoirs, distributeurs et inventaire joueur utilisent la même règle. »
|
||||
- **Sortie :** `SANCTUARY / RANGEMENT 086`.
|
||||
- **Source :** [tri natif de l'alpha.224](../../CHANGELOG.md#2620-alpha224).
|
||||
|
||||
## 087 — Un ordinateur Minecraft avec six ports redstone
|
||||
|
||||
- **Format / durée :** démonstration technique, 17 s.
|
||||
- **Accroche :** « Ce bloc exécute réellement un programme assembleur. »
|
||||
- **Plans :** saisie d'un programme court ; impulsion sur un port directionnel ; résultat sur lampe et comparateur.
|
||||
- **Texte / voix :** « Programme borné, RAM, inventaire, six ports, impulsions et sortie comparateur : la machine virtuelle historique reste jouable. »
|
||||
- **Sortie :** `SANCTUARY / RED-STONER 087`.
|
||||
- **Source :** [ordinateur 8 bits](../../README.md#red-stoner-et-photographie).
|
||||
|
||||
## 088 — Seize pellicules, une photo sur carte
|
||||
|
||||
- **Format / durée :** collection, 16 s.
|
||||
- **Accroche :** « L'appareil ne crée pas un fichier hors du jeu. »
|
||||
- **Plans :** choix de trois pellicules contrastées parmi les seize ; visée ; trois cartes photo exposées côte à côte.
|
||||
- **Texte / voix :** « Canon transforme la scène en objet Minecraft que l'on peut conserver et montrer. »
|
||||
- **Sortie :** `SANCTUARY / CANON 088`.
|
||||
- **Source :** [Canon et styles de pellicule](../../README.md#red-stoner-et-photographie).
|
||||
|
||||
## 089 — Un coffre-fort est une coque creuse de 26 blocs
|
||||
|
||||
- **Format / durée :** construction, 15 s.
|
||||
- **Accroche :** « Ni un mur. Ni un cube plein. »
|
||||
- **Plans :** structure incorrecte refusée ; coque 3 × 3 × 3 avec centre libre ; activation des textures sur les six faces.
|
||||
- **Texte / voix :** « Les 26 Safe Blocks doivent appartenir au même joueur. Le centre peut cacher n'importe quel bloc. »
|
||||
- **Sortie :** `SANCTUARY / COFFRE-FORT 089`.
|
||||
- **Source :** [coque du coffre-fort, alpha.205](../../CHANGELOG.md#2620-alpha205).
|
||||
|
||||
## 090 — Pour braquer un coffre, il faut déjà en posséder un
|
||||
|
||||
- **Format / durée :** règle de braquage, 18 s.
|
||||
- **Accroche :** « Un voleur doit mettre sa propre banque en jeu. »
|
||||
- **Plans :** forage refusé sans coffre personnel ; construction ou preuve du coffre ; forage continu de 60 secondes et transfert final.
|
||||
- **Texte / voix :** « Le propriétaire doit être connecté. Une réussite vole 20 %, consomme la foreuse, alerte la victime et protège le coffre pendant 24 heures. »
|
||||
- **Sortie :** `SANCTUARY / BRAQUAGE 090`.
|
||||
- **Source :** [forage et transfert de l'alpha.202](../../CHANGELOG.md#2620-alpha202) ; [condition de propriété de l'alpha.225](../../CHANGELOG.md#2620-alpha225).
|
||||
|
||||
## 091 — Le Creeperlock explose sans casser le monde
|
||||
|
||||
- **Format / durée :** arsenal, 12 s.
|
||||
- **Accroche :** « Une explosion de combat, pas une explosion de terrain. »
|
||||
- **Plans :** tir sur plusieurs cibles ; souffle et recul ; même décor intact après l'impact.
|
||||
- **Texte / voix :** « Le Creeperlock consomme une poudre à canon et inflige des dégâts selon la distance, sans détruire de bloc. »
|
||||
- **Sortie :** `SANCTUARY / OUCH 091`.
|
||||
- **Source :** [Creeperlock](../../ouch/README.md#arsenal).
|
||||
|
||||
## 092 — La Mitraillette tire les métaux de ton inventaire
|
||||
|
||||
- **Format / durée :** arsenal, 14 s.
|
||||
- **Accroche :** « La munition décide des dégâts. »
|
||||
- **Plans :** pépites cuivre, fer, or, argent et titane alignées ; tir continu ; pépite tenue en main secondaire choisie en priorité.
|
||||
- **Texte / voix :** « Cinq métaux, cinq puissances. Un tir toutes les cinq ticks jusqu'à 18 blocs. »
|
||||
- **Sortie :** `SANCTUARY / OUCH 092`.
|
||||
- **Source :** [Mitraillette](../../ouch/README.md#arsenal).
|
||||
|
||||
## 093 — L'Expulseur transforme ton inventaire en projectiles
|
||||
|
||||
- **Format / durée :** arsenal absurde, 13 s.
|
||||
- **Accroche :** « Ce qui se trouve dans ton sac peut devenir une arme. »
|
||||
- **Plans :** inventaire principal contenant les munitions improvisées ; jusqu'à huit objets projetés ; hotbar montrée intacte.
|
||||
- **Texte / voix :** « Les objets restent physiques et récupérables. Les neuf cases de la barre d'action ne sont jamais consommées. »
|
||||
- **Sortie :** `SANCTUARY / OUCH 093`.
|
||||
- **Source :** [Expulseur](../../ouch/README.md#arsenal).
|
||||
|
||||
## 094 — L'Aspirateur retire aussi le bloc visé
|
||||
|
||||
- **Format / durée :** arsenal utilitaire, 13 s.
|
||||
- **Accroche :** « Attirer les objets n'était que la moitié de la fonction. »
|
||||
- **Plans :** objets au sol attirés dans un rayon de dix blocs ; visée d'un bloc cassable ; extraction et récupération.
|
||||
- **Texte / voix :** « Il refuse les fluides, les block entities et toute interaction sans permission. »
|
||||
- **Sortie :** `SANCTUARY / OUCH 094`.
|
||||
- **Source :** [Aspirateur](../../ouch/README.md#arsenal).
|
||||
|
||||
## 095 — Chaque minerai brut change le Mining Rifle
|
||||
|
||||
- **Format / durée :** arsenal, 17 s.
|
||||
- **Accroche :** « Cuivre, fer, or, argent ou titane : le fusil ne tire pas la même chose. »
|
||||
- **Plans :** cinq munitions ; rechargement plus rapide avec l'or ; bonus argent sur un mort-vivant ; titane traversant une seconde cible.
|
||||
- **Texte / voix :** « L'argent ajoute des dégâts aux morts-vivants. Le titane conserve 75 % des dégâts sur une seconde cible. »
|
||||
- **Sortie :** `SANCTUARY / OUCH 095`.
|
||||
- **Source :** [Mining Rifle](../../ouch/README.md#arsenal).
|
||||
|
||||
## 096 — L'Arc bêta tire quatre flèches par seconde
|
||||
|
||||
- **Format / durée :** arsenal historique, 13 s.
|
||||
- **Accroche :** « Avant l'animation de charge, l'arc tirait immédiatement. »
|
||||
- **Plans :** premier clic instantané ; maintien produisant quatre tirs par seconde ; relâchement sans projectile supplémentaire.
|
||||
- **Texte / voix :** « Chaque tir consomme une vraie flèche vanilla et conserve ses effets compatibles. »
|
||||
- **Sortie :** `SANCTUARY / OUCH 096`.
|
||||
- **Source :** [Arc bêta](../../ouch/README.md#arsenal).
|
||||
|
||||
## 097 — Le Shop commence avec trois offres
|
||||
|
||||
- **Format / durée :** progression économique, 14 s.
|
||||
- **Accroche :** « Le magasin n'est pas entièrement ouvert au début. »
|
||||
- **Plans :** trois cartes actives et six verrouillées ; achat d'un emplacement ; grille progressivement étendue jusqu'à neuf offres.
|
||||
- **Texte / voix :** « Les six emplacements supplémentaires coûtent 2, 4, 8, 12, 18 puis 25 niveaux. »
|
||||
- **Sortie :** `SANCTUARY / SHOP 097`.
|
||||
- **Source :** [progression du Shop, alpha.193](../../CHANGELOG.md#2620-alpha193).
|
||||
|
||||
## 098 — Le marché noir est construit avec de vrais panneaux
|
||||
|
||||
- **Format / durée :** interaction sociale, 18 s.
|
||||
- **Accroche :** « Ici, une annonce existe parce qu'un panneau existe dans le monde. »
|
||||
- **Plans :** vendeur tenant un objet et cliquant sur un panneau ; prix et pseudonyme visibles ; achat par un second joueur puis livraison Mailbox.
|
||||
- **Texte / voix :** « L'objet, le paiement et les composants sont séquestrés et livrés par le serveur. Casser le panneau obéit à son propriétaire. »
|
||||
- **Sortie :** `SANCTUARY / MARCHÉ 098`.
|
||||
- **Source :** [Black Market physique](../../sanctuary/BLACK_MARKET.md#utilisation).
|
||||
|
||||
## 099 — Les joueurs peuvent écrire leurs propres quêtes
|
||||
|
||||
- **Format / durée :** interaction sociale, 18 s.
|
||||
- **Accroche :** « KILL. BRING. PAY. Une quête écrite par un autre joueur. »
|
||||
- **Plans :** création sur le panneau rose ; lecture et confirmation par un second joueur ; complétion puis arrivée de la récompense dans la Mailbox.
|
||||
- **Texte / voix :** « La récompense est déposée dès la publication. Le serveur suit l'objectif et ne la livre qu'une fois. »
|
||||
- **Sortie :** `SANCTUARY / QUÊTES 099`.
|
||||
- **Source :** [demandes communautaires, alpha.201](../../CHANGELOG.md#2620-alpha201).
|
||||
|
||||
## 100 — Appuie sur J
|
||||
|
||||
- **Format / durée :** absurdité sèche, 9 s.
|
||||
- **Accroche :** plan parfaitement sérieux du joueur, immobile au centre d'une pièce blanche.
|
||||
- **Plans :** pression sur `J` ; action de six secondes ; boule créée, puis transformation de neuf boules en cube dans une coupe séparée.
|
||||
- **Texte / voix :** « Sanctuary réinterprète aussi les mauvaises idées. »
|
||||
- **Sortie :** `SANCTUARY / ONLY FUN 100`.
|
||||
- **Source :** [action et cube](../../onlyfun/ABJECTION.md#abj-01--actions-serveur) ; [raccourci configurable](../../onlyfun/ABJECTION.md#abj-03--raccourci-configurable).
|
||||
|
||||
## Ordre de production recommandé
|
||||
|
||||
Ne pas tourner les vidéos dans leur ordre de publication. Les regrouper par décor réduit fortement le temps de capture :
|
||||
|
||||
1. **Laboratoire interface :** 003 à 019, 083 à 086 et 097.
|
||||
2. **Archipel et worldgen :** 002, 020 à 027, 053 et 072 à 079.
|
||||
3. **Alpha / maison Indev :** 028 à 031.
|
||||
4. **Terrain de déplacement :** 032 à 039.
|
||||
5. **Bestiaire contrôlé :** 040 à 059.
|
||||
6. **Ferme et cuisine :** 060 à 071.
|
||||
7. **Atelier Red-Stoner / Another World :** 080 à 090.
|
||||
8. **Stand de tir Ouch :** 091 à 096.
|
||||
9. **Place de serveur :** 098 à 100.
|
||||
|
||||
## Premier lot public conseillé
|
||||
|
||||
Avant de produire les cent vidéos, valider la grammaire avec douze épisodes très différents : **001, 003, 006, 014, 020, 031, 041, 053, 062, 077, 086 et 099**. Ils couvrent le manifeste, l'interface, une mécanique spectaculaire, l'exploration, une créature, l'agriculture, l'ambiance et le multijoueur sans nécessiter cent tournages pour apprendre ce qui fonctionne.
|
||||
+10
-10
@@ -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_version=0.0.0-alpha.56
|
||||
anotherworld_lifecycle=active
|
||||
itsalive_version=0.0.0-alpha.36
|
||||
itsalive_version=0.0.0-alpha.38
|
||||
itsalive_lifecycle=active
|
||||
ouch_version=0.0.0-alpha.10
|
||||
ouch_version=0.0.0-alpha.11
|
||||
ouch_lifecycle=active
|
||||
masterkey_version=0.0.0-alpha.8
|
||||
masterkey_version=0.0.0-alpha.9
|
||||
masterkey_lifecycle=active
|
||||
iliketomoveit_version=0.0.0-alpha.15
|
||||
iliketomoveit_version=0.0.0-alpha.18
|
||||
iliketomoveit_lifecycle=active
|
||||
onlyfun_version=0.0.0-alpha.24
|
||||
onlyfun_version=0.0.0-alpha.27
|
||||
onlyfun_lifecycle=active
|
||||
sanctuary_version=0.0.0-alpha.119
|
||||
sanctuary_version=0.0.0-alpha.130
|
||||
sanctuary_lifecycle=active
|
||||
pack_version=26.2.0-alpha.210
|
||||
pack_version=26.2.0-alpha.226
|
||||
maven_group=fr.koka99cab.sanctuary26
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# Magic Carpet et Totem de Notch
|
||||
|
||||
I Like To Move It possède l'objet, la plateforme temporaire, la remise physique
|
||||
de la récompense et la compatibilité de l'ancien Totem. Sanctuary reste
|
||||
propriétaire du jalon mondial et de la réservation unique ; Only Fun possède le
|
||||
catalogue et applique le jalon avant d'exposer l'offre.
|
||||
|
||||
## Identités et provenance
|
||||
|
||||
| Rôle | ID | Statut |
|
||||
|---|---|---|
|
||||
| objet canonique | `iliketomoveit:magic_carpet` | distribué après Notch puis achetable au Shop |
|
||||
| Totem historique | `portals:notch_totem` | alias caché, lisible et converti vers l'objet canonique |
|
||||
| jalon mondial | `sanctuary:notch_defeated` | autorité serveur Sanctuary |
|
||||
|
||||
La conversion utilise `ItemStack.transmuteCopy` : quantité, composants et nom
|
||||
personnalisé sont conservés. Les inventaires joueurs sont traités à la connexion,
|
||||
les conteneurs vanilla lors de leur chargement et les entités d'item dès leur
|
||||
tick serveur. Un Totem produit exactement un tapis et les nouveaux mondes ne
|
||||
distribuent jamais l'ancien ID. La donnée I Like To Move It passe en v4 ; une
|
||||
pile déjà convertie ne correspond plus à l'alias et ne peut donc pas être
|
||||
convertie une seconde fois.
|
||||
|
||||
Chaque tapis porte une provenance Sanctuary sans autorité de gameplay :
|
||||
`notch_reward`, `shop_purchase` ou `legacy_notch_totem`. Le serveur ne se fie
|
||||
jamais à cette étiquette pour autoriser un achat ; elle sert à l'historique, au
|
||||
diagnostic et à l'infobulle.
|
||||
|
||||
## Première récompense
|
||||
|
||||
Après la première victoire mondiale, Sanctuary réserve atomiquement la
|
||||
récompense puis I Like To Move It crée un coffre déterministe près de la maison
|
||||
de Notch. Le tapis unique est placé au centre et un feu d'artifice signale le
|
||||
coffre. Sanctuary ne marque la livraison terminée qu'après avoir retrouvé ou
|
||||
créé ce coffre.
|
||||
|
||||
Les états `initial_reward_claimed` et `initial_reward_delivered` sont distincts.
|
||||
Une reprise entre les deux relance la création de façon idempotente ; un coffre
|
||||
déjà créé et marqué est reconnu sans recevoir de second tapis. Les anciennes
|
||||
sauvegardes v1 dont la récompense était déjà déclarée remise sont migrées vers
|
||||
l'état livré afin de ne rien dupliquer.
|
||||
|
||||
## Plateforme temporaire
|
||||
|
||||
Tenir le tapis en main principale ou secondaire maintient neuf blocs de verre
|
||||
sous le joueur. Seul l'air est remplacé : terrain, liquides, plantes et blocs
|
||||
construits restent intacts. Le joueur conserve ses capacités normales ; seul le
|
||||
dégât de chute est réinitialisé tant que la plateforme est active.
|
||||
|
||||
Chaque position possède un ensemble implicite de propriétaires. Deux tapis
|
||||
peuvent se chevaucher et le verre ne disparaît qu'après le départ du dernier.
|
||||
Le nettoyage est effectué côté serveur au relâchement, à la déconnexion, au
|
||||
changement de dimension et pendant l'arrêt normal du serveur.
|
||||
|
||||
## Remplacement au Shop
|
||||
|
||||
Only Fun associe le Magic Carpet au resolver public
|
||||
`SanctuaryWorldMilestones`. Avant `sanctuary:notch_defeated`, l'offre est
|
||||
absente des snapshots et toute tentative réseau est rejetée par Sanctuary.
|
||||
Après la victoire, elle rejoint la rotation normale au prix configuré de
|
||||
1 536 rubis et 128 saphirs : les saphirs débloquent le catalogue, les rubis
|
||||
paient chaque remplacement et la Mailbox effectue la livraison différée.
|
||||
|
||||
Un achat porte la provenance `shop_purchase`, est journalisé comme remplacement
|
||||
et ne touche jamais aux états de réservation ou de coffre de la récompense
|
||||
initiale.
|
||||
@@ -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.
|
||||
|
||||
+68
-11
@@ -24,6 +24,22 @@ 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.register("magicCarpetLedgerSmoke", JavaExec) {
|
||||
group = "verification"
|
||||
description = "Checks Magic Carpet overlap, dimension-change and shutdown cleanup ownership."
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = "fr.koka99cab.sanctuary26.iliketomoveit.transport.MagicCarpetLedgerSmoke"
|
||||
dependsOn tasks.named("testClasses")
|
||||
}
|
||||
|
||||
tasks.named("test") {
|
||||
// Runtime contracts are executable smoke tasks, not JUnit engine tests.
|
||||
failOnNoDiscoveredTests = false
|
||||
@@ -92,11 +108,11 @@ tasks.register("verifyWaystones") {
|
||||
if (!missingResources.isEmpty()) throw new GradleException("Missing Waystone resources: ${missingResources}")
|
||||
|
||||
def manifest = new JsonSlurper().parse(file("src/main/resources/fabric.mod.json"))
|
||||
if (manifest.custom?.sanctuary26?.data_version != 3
|
||||
if (manifest.custom?.sanctuary26?.data_version != 4
|
||||
|| manifest.custom?.sanctuary26?.network_protocol != 5
|
||||
|| manifest.depends?.anotherworld != ">=0.0.0-alpha.46"
|
||||
|| manifest.depends?.sanctuary != ">=0.0.0-alpha.21") {
|
||||
throw new GradleException("I Like To Move It must expose data v3, protocol 5 and its Another World alpha.46/Sanctuary alpha.21 dependencies")
|
||||
|| manifest.depends?.sanctuary != ">=0.0.0-alpha.127") {
|
||||
throw new GradleException("I Like To Move It must expose data v4, protocol 5 and its Another World alpha.46/Sanctuary alpha.127 dependencies")
|
||||
}
|
||||
["fr_fr", "en_us", "ru_ru"].each { language ->
|
||||
def translations = new JsonSlurper().parse(file("src/main/resources/assets/iliketomoveit/lang/${language}.json"))
|
||||
@@ -195,9 +211,9 @@ tasks.register("verifyGrappling") {
|
||||
def manifest = new JsonSlurper().parse(file("src/main/resources/fabric.mod.json"))
|
||||
if (!manifest.mixins?.any { it.config == "iliketomoveit.mixins.json" }
|
||||
|| !manifest.mixins?.any { it.config == "iliketomoveit.client.mixins.json" && it.environment == "client" }
|
||||
|| manifest.custom?.sanctuary26?.data_version != 3
|
||||
|| manifest.custom?.sanctuary26?.data_version != 4
|
||||
|| manifest.custom?.sanctuary26?.network_protocol != 5) {
|
||||
throw new GradleException("I Like To Move It must keep aggregate data v3/protocol 5 and register its common/client mixins")
|
||||
throw new GradleException("I Like To Move It must expose aggregate data v4/protocol 5 and register its common/client mixins")
|
||||
}
|
||||
["fr_fr", "en_us", "ru_ru"].each { language ->
|
||||
def translations = new JsonSlurper().parse(file("src/main/resources/assets/iliketomoveit/lang/${language}.json"))
|
||||
@@ -268,7 +284,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 +321,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.18"
|
||||
|| atlasCavernsMigration.target?.pack_version != "26.2.0-alpha.150"
|
||||
|| atlasCavernsMigration.target?.modules?.iliketomoveit != "0.0.0-alpha.9"
|
||||
|| atlasCavernsMigration.portals?.active_routes != [
|
||||
@@ -313,7 +329,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 +357,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.18"
|
||||
|| 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 +386,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.18"
|
||||
|| 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 +418,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.18"
|
||||
|| 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,8 +434,47 @@ 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.18"
|
||||
|| 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("magicCarpetLedgerSmoke")
|
||||
dependsOn tasks.named("waystoneElevatorRulesSmoke")
|
||||
dependsOn tasks.named("happyGhastCruiseSpeedSmoke")
|
||||
dependsOn tasks.named("verifyWaystones")
|
||||
@@ -428,4 +483,6 @@ tasks.named("check") {
|
||||
dependsOn tasks.named("verifyWaystoneDescendingRelease")
|
||||
dependsOn tasks.named("verifyPortals")
|
||||
dependsOn tasks.named("verifySilverAircraft")
|
||||
dependsOn tasks.named("alphaPortalContractSmoke")
|
||||
dependsOn tasks.named("verifyAlphaPortal")
|
||||
}
|
||||
|
||||
+5
-1
@@ -8,6 +8,8 @@ import fr.koka99cab.sanctuary26.iliketomoveit.pulley.PulleyHeadController;
|
||||
import fr.koka99cab.sanctuary26.iliketomoveit.registry.GrapplingLootTables;
|
||||
import fr.koka99cab.sanctuary26.iliketomoveit.registry.ILikeToMoveItRegistries;
|
||||
import fr.koka99cab.sanctuary26.iliketomoveit.transport.MagicCarpetController;
|
||||
import fr.koka99cab.sanctuary26.iliketomoveit.transport.MagicCarpetMigrationService;
|
||||
import fr.koka99cab.sanctuary26.iliketomoveit.transport.NotchCarpetRewardService;
|
||||
import fr.koka99cab.sanctuary26.iliketomoveit.waystone.WaystoneElevatorController;
|
||||
import net.fabricmc.api.ModInitializer;
|
||||
import net.minecraft.resources.Identifier;
|
||||
@@ -17,7 +19,7 @@ import org.slf4j.LoggerFactory;
|
||||
public final class ILikeToMoveItMod implements ModInitializer {
|
||||
public static final String MOD_ID = "iliketomoveit";
|
||||
public static final String DISPLAY_NAME = "I Like To Move It";
|
||||
public static final int CURRENT_DATA_VERSION = 3;
|
||||
public static final int CURRENT_DATA_VERSION = 4;
|
||||
public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID);
|
||||
|
||||
public ILikeToMoveItMod() {
|
||||
@@ -37,6 +39,8 @@ public final class ILikeToMoveItMod implements ModInitializer {
|
||||
GrappleController.register();
|
||||
PulleyHeadController.register();
|
||||
MagicCarpetController.register();
|
||||
MagicCarpetMigrationService.register();
|
||||
NotchCarpetRewardService.register();
|
||||
WaystoneElevatorController.initialize();
|
||||
LOGGER.info("[{}] Portals, vehicles, grappling, pulleys, Magic Carpet and dimension-local Waystones ready (data v{}, network v{}).",
|
||||
DISPLAY_NAME, CURRENT_DATA_VERSION, ILikeToMoveItNetworking.PROTOCOL_VERSION);
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package fr.koka99cab.sanctuary26.iliketomoveit.item;
|
||||
|
||||
import fr.koka99cab.sanctuary26.sanctuary.api.item.SanctuaryItemProvenance;
|
||||
import java.util.function.Consumer;
|
||||
import net.minecraft.ChatFormatting;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.world.item.Item;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.item.TooltipFlag;
|
||||
import net.minecraft.world.item.component.TooltipDisplay;
|
||||
|
||||
/** Epic carpet with explicit behaviour and provenance in its tooltip. */
|
||||
public final class MagicCarpetItem extends Item {
|
||||
public MagicCarpetItem(Properties properties) {
|
||||
super(properties);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void appendHoverText(ItemStack stack, TooltipContext context, TooltipDisplay display,
|
||||
Consumer<Component> tooltip, TooltipFlag flag) {
|
||||
tooltip.accept(Component.translatable("item.iliketomoveit.magic_carpet.description")
|
||||
.withStyle(ChatFormatting.GRAY));
|
||||
var origin = SanctuaryItemProvenance.origin(stack);
|
||||
if (SanctuaryItemProvenance.NOTCH_REWARD.equals(origin)) {
|
||||
tooltip.accept(Component.translatable("item.iliketomoveit.magic_carpet.origin.notch")
|
||||
.withStyle(ChatFormatting.GOLD));
|
||||
} else if (SanctuaryItemProvenance.SHOP_PURCHASE.equals(origin)) {
|
||||
tooltip.accept(Component.translatable("item.iliketomoveit.magic_carpet.origin.shop")
|
||||
.withStyle(ChatFormatting.AQUA));
|
||||
} else if (SanctuaryItemProvenance.LEGACY_NOTCH_TOTEM.equals(origin)) {
|
||||
tooltip.accept(Component.translatable("item.iliketomoveit.magic_carpet.origin.legacy")
|
||||
.withStyle(ChatFormatting.DARK_PURPLE));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isFoil(ItemStack stack) {
|
||||
return SanctuaryItemProvenance.hasOrigin(stack, SanctuaryItemProvenance.NOTCH_REWARD)
|
||||
|| super.isFoil(stack);
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package fr.koka99cab.sanctuary26.iliketomoveit.mixin;
|
||||
|
||||
import fr.koka99cab.sanctuary26.iliketomoveit.transport.MagicCarpetMigration;
|
||||
import net.minecraft.world.Container;
|
||||
import net.minecraft.world.level.block.entity.BaseContainerBlockEntity;
|
||||
import net.minecraft.world.level.storage.ValueInput;
|
||||
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;
|
||||
|
||||
/** Converts historical Totems as loaded chests and other vanilla containers are decoded. */
|
||||
@Mixin(BaseContainerBlockEntity.class)
|
||||
abstract class MagicCarpetContainerMixin {
|
||||
@Inject(method = "loadAdditional", at = @At("TAIL"))
|
||||
private void sanctuary26$migrateNotchTotem(ValueInput input, CallbackInfo ci) {
|
||||
MagicCarpetMigration.migrateContainer((Container) (Object) this);
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package fr.koka99cab.sanctuary26.iliketomoveit.mixin;
|
||||
|
||||
import fr.koka99cab.sanctuary26.iliketomoveit.transport.MagicCarpetMigration;
|
||||
import net.minecraft.world.entity.item.ItemEntity;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
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;
|
||||
|
||||
/** Converts historical Totems already present as loaded item entities. */
|
||||
@Mixin(ItemEntity.class)
|
||||
abstract class MagicCarpetItemEntityMixin {
|
||||
@Inject(method = "tick", at = @At("HEAD"))
|
||||
private void sanctuary26$migrateNotchTotem(CallbackInfo ci) {
|
||||
ItemEntity self = (ItemEntity) (Object) this;
|
||||
ItemStack current = self.getItem();
|
||||
ItemStack replacement = MagicCarpetMigration.migrateStack(current);
|
||||
if (replacement != current) self.setItem(replacement);
|
||||
}
|
||||
}
|
||||
+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);
|
||||
|
||||
+6
-1
@@ -3,6 +3,7 @@ package fr.koka99cab.sanctuary26.iliketomoveit.registry;
|
||||
import fr.koka99cab.sanctuary26.iliketomoveit.ILikeToMoveItMod;
|
||||
import fr.koka99cab.sanctuary26.iliketomoveit.item.AircraftItem;
|
||||
import fr.koka99cab.sanctuary26.iliketomoveit.item.GrapplingRodItem;
|
||||
import fr.koka99cab.sanctuary26.iliketomoveit.item.MagicCarpetItem;
|
||||
import net.minecraft.core.Registry;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
@@ -25,7 +26,11 @@ public final class ILikeToMoveItItems {
|
||||
key -> new BoatItem(ILikeToMoveItEntityTypes.MAGIC_CLOUD,
|
||||
new Item.Properties().stacksTo(1).rarity(Rarity.UNCOMMON).setId(key)));
|
||||
public static final Item MAGIC_CARPET = register("magic_carpet",
|
||||
key -> new Item(new Item.Properties().stacksTo(1).rarity(Rarity.EPIC).setId(key)));
|
||||
key -> new MagicCarpetItem(new Item.Properties().stacksTo(1).rarity(Rarity.EPIC).setId(key)));
|
||||
/** Hidden read-preserving alias for inventories saved by the historical MagicCarpet mod. */
|
||||
public static final Item LEGACY_NOTCH_TOTEM = register(
|
||||
Identifier.fromNamespaceAndPath("portals", "notch_totem"),
|
||||
key -> new MagicCarpetItem(new Item.Properties().stacksTo(1).rarity(Rarity.EPIC).setId(key)));
|
||||
public static final Item GRAPPLING_HAND = register("grappling_hand",
|
||||
key -> new Item(new Item.Properties().setId(key)));
|
||||
public static final Item GRAPPLING_ROD = register("grappling_rod",
|
||||
|
||||
+16
-25
@@ -1,10 +1,7 @@
|
||||
package fr.koka99cab.sanctuary26.iliketomoveit.transport;
|
||||
|
||||
import fr.koka99cab.sanctuary26.iliketomoveit.ILikeToMoveItMod;
|
||||
import fr.koka99cab.sanctuary26.iliketomoveit.registry.ILikeToMoveItItems;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Iterator;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
@@ -27,7 +24,7 @@ public final class MagicCarpetController {
|
||||
private static final int RADIUS = 1;
|
||||
private static final Block CARPET_BLOCK = Blocks.GLASS;
|
||||
private static final BlockState CARPET_STATE = CARPET_BLOCK.defaultBlockState();
|
||||
private static final Map<UUID, CarpetState> ACTIVE_CARPETS = new HashMap<>();
|
||||
private static final MagicCarpetLedger ACTIVE_CARPETS = new MagicCarpetLedger();
|
||||
|
||||
private MagicCarpetController() {
|
||||
}
|
||||
@@ -45,8 +42,8 @@ public final class MagicCarpetController {
|
||||
connected.add(id);
|
||||
boolean active = !player.isSpectator() && (isMagicCarpet(player.getMainHandItem()) || isMagicCarpet(player.getOffhandItem()));
|
||||
if (active) {
|
||||
if (!ACTIVE_CARPETS.containsKey(id)) {
|
||||
ACTIVE_CARPETS.put(id, CarpetState.empty(player.level().dimension()));
|
||||
if (!ACTIVE_CARPETS.contains(id)) {
|
||||
ACTIVE_CARPETS.track(id, player.level().dimension(), Set.of());
|
||||
player.sendOverlayMessage(Component.translatable("message.iliketomoveit.magic_carpet.enabled"));
|
||||
}
|
||||
updateCarpet(id, player);
|
||||
@@ -54,27 +51,26 @@ public final class MagicCarpetController {
|
||||
deactivate(server, id, true);
|
||||
}
|
||||
}
|
||||
Iterator<Map.Entry<UUID, CarpetState>> iterator = ACTIVE_CARPETS.entrySet().iterator();
|
||||
while (iterator.hasNext()) {
|
||||
Map.Entry<UUID, CarpetState> entry = iterator.next();
|
||||
if (!connected.contains(entry.getKey())) {
|
||||
removePositions(server, entry.getKey(), entry.getValue(), entry.getValue().positions());
|
||||
iterator.remove();
|
||||
for (UUID owner : ACTIVE_CARPETS.owners()) {
|
||||
if (!connected.contains(owner)) {
|
||||
MagicCarpetLedger.State state = ACTIVE_CARPETS.remove(owner);
|
||||
if (state != null) removePositions(server, owner, state, state.positions());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isMagicCarpet(ItemStack stack) {
|
||||
return !stack.isEmpty() && stack.is(ILikeToMoveItItems.MAGIC_CARPET);
|
||||
return MagicCarpetMigration.isAnyCarpet(stack);
|
||||
}
|
||||
|
||||
private static void updateCarpet(UUID owner, ServerPlayer player) {
|
||||
ServerLevel level = player.level();
|
||||
CarpetState previous = ACTIVE_CARPETS.getOrDefault(owner, CarpetState.empty(level.dimension()));
|
||||
MagicCarpetLedger.State previous = ACTIVE_CARPETS.state(owner, level.dimension());
|
||||
Set<BlockPos> target = carpetPositions(player.blockPosition().below(player.isCrouching() ? 2 : 1));
|
||||
if (!previous.dimension().equals(level.dimension())) {
|
||||
removePositions(level.getServer(), owner, previous, previous.positions());
|
||||
previous = CarpetState.empty(level.dimension());
|
||||
ACTIVE_CARPETS.remove(owner);
|
||||
previous = ACTIVE_CARPETS.state(owner, level.dimension());
|
||||
} else {
|
||||
Set<BlockPos> stale = new HashSet<>(previous.positions());
|
||||
stale.removeAll(target);
|
||||
@@ -91,7 +87,7 @@ public final class MagicCarpetController {
|
||||
placed.add(pos);
|
||||
}
|
||||
}
|
||||
ACTIVE_CARPETS.put(owner, new CarpetState(level.dimension(), Set.copyOf(placed)));
|
||||
ACTIVE_CARPETS.track(owner, level.dimension(), placed);
|
||||
player.resetFallDistance();
|
||||
}
|
||||
|
||||
@@ -104,14 +100,14 @@ public final class MagicCarpetController {
|
||||
}
|
||||
|
||||
private static void deactivate(MinecraftServer server, UUID owner, boolean notify) {
|
||||
CarpetState state = ACTIVE_CARPETS.remove(owner);
|
||||
MagicCarpetLedger.State state = ACTIVE_CARPETS.remove(owner);
|
||||
if (state == null) return;
|
||||
removePositions(server, owner, state, state.positions());
|
||||
ServerPlayer player = server.getPlayerList().getPlayer(owner);
|
||||
if (notify && player != null) player.sendOverlayMessage(Component.translatable("message.iliketomoveit.magic_carpet.disabled"));
|
||||
}
|
||||
|
||||
private static void removePositions(MinecraftServer server, UUID owner, CarpetState state, Set<BlockPos> positions) {
|
||||
private static void removePositions(MinecraftServer server, UUID owner, MagicCarpetLedger.State state, Set<BlockPos> positions) {
|
||||
ServerLevel level = server.getLevel(state.dimension());
|
||||
if (level == null) return;
|
||||
for (BlockPos pos : positions) {
|
||||
@@ -122,18 +118,13 @@ public final class MagicCarpetController {
|
||||
}
|
||||
|
||||
private static boolean isTrackedByAnother(UUID owner, ResourceKey<Level> dimension, BlockPos pos) {
|
||||
return ACTIVE_CARPETS.entrySet().stream().anyMatch(entry -> !entry.getKey().equals(owner)
|
||||
&& entry.getValue().dimension().equals(dimension) && entry.getValue().positions().contains(pos));
|
||||
return ACTIVE_CARPETS.trackedByAnother(owner, dimension, pos);
|
||||
}
|
||||
|
||||
private static void reset(MinecraftServer server) {
|
||||
Map<UUID, CarpetState> copy = new HashMap<>(ACTIVE_CARPETS);
|
||||
ACTIVE_CARPETS.clear();
|
||||
Map<UUID, MagicCarpetLedger.State> copy = ACTIVE_CARPETS.drain();
|
||||
copy.forEach((owner, state) -> removePositions(server, owner, state, state.positions()));
|
||||
ILikeToMoveItMod.LOGGER.debug("Magic Carpet temporary platforms cleaned.");
|
||||
}
|
||||
|
||||
private record CarpetState(ResourceKey<Level> dimension, Set<BlockPos> positions) {
|
||||
private static CarpetState empty(ResourceKey<Level> dimension) { return new CarpetState(dimension, Set.of()); }
|
||||
}
|
||||
}
|
||||
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package fr.koka99cab.sanctuary26.iliketomoveit.transport;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.world.level.Level;
|
||||
|
||||
/** Pure ownership ledger preventing overlapping temporary platforms from deleting each other. */
|
||||
public final class MagicCarpetLedger {
|
||||
private final Map<UUID, State> carpets = new HashMap<>();
|
||||
|
||||
public boolean contains(UUID owner) {
|
||||
return carpets.containsKey(owner);
|
||||
}
|
||||
|
||||
public State state(UUID owner, ResourceKey<Level> dimension) {
|
||||
return carpets.getOrDefault(owner, State.empty(dimension));
|
||||
}
|
||||
|
||||
public void track(UUID owner, ResourceKey<Level> dimension, Set<BlockPos> positions) {
|
||||
carpets.put(owner, new State(dimension, Set.copyOf(positions)));
|
||||
}
|
||||
|
||||
public State remove(UUID owner) {
|
||||
return carpets.remove(owner);
|
||||
}
|
||||
|
||||
public Set<UUID> owners() {
|
||||
return Set.copyOf(carpets.keySet());
|
||||
}
|
||||
|
||||
public boolean trackedByAnother(UUID owner, ResourceKey<Level> dimension, BlockPos pos) {
|
||||
return carpets.entrySet().stream().anyMatch(entry -> !entry.getKey().equals(owner)
|
||||
&& entry.getValue().dimension().equals(dimension)
|
||||
&& entry.getValue().positions().contains(pos));
|
||||
}
|
||||
|
||||
public Map<UUID, State> drain() {
|
||||
Map<UUID, State> copy = Map.copyOf(carpets);
|
||||
carpets.clear();
|
||||
return copy;
|
||||
}
|
||||
|
||||
public record State(ResourceKey<Level> dimension, Set<BlockPos> positions) {
|
||||
public State {
|
||||
positions = Set.copyOf(positions);
|
||||
}
|
||||
|
||||
private static State empty(ResourceKey<Level> dimension) {
|
||||
return new State(dimension, Set.of());
|
||||
}
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package fr.koka99cab.sanctuary26.iliketomoveit.transport;
|
||||
|
||||
import fr.koka99cab.sanctuary26.iliketomoveit.registry.ILikeToMoveItItems;
|
||||
import fr.koka99cab.sanctuary26.sanctuary.api.item.SanctuaryItemProvenance;
|
||||
import net.minecraft.world.Container;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
|
||||
/** Read-preserving conversion from the historical Totem identity to the canonical carpet. */
|
||||
public final class MagicCarpetMigration {
|
||||
private MagicCarpetMigration() {
|
||||
}
|
||||
|
||||
public static boolean isAnyCarpet(ItemStack stack) {
|
||||
return stack != null && !stack.isEmpty()
|
||||
&& (stack.is(ILikeToMoveItItems.MAGIC_CARPET)
|
||||
|| stack.is(ILikeToMoveItItems.LEGACY_NOTCH_TOTEM));
|
||||
}
|
||||
|
||||
public static ItemStack migrateStack(ItemStack stack) {
|
||||
if (stack == null || stack.isEmpty() || !stack.is(ILikeToMoveItItems.LEGACY_NOTCH_TOTEM)) return stack;
|
||||
ItemStack migrated = stack.transmuteCopy(ILikeToMoveItItems.MAGIC_CARPET, stack.getCount());
|
||||
SanctuaryItemProvenance.stampIfAbsent(migrated, SanctuaryItemProvenance.LEGACY_NOTCH_TOTEM);
|
||||
return migrated;
|
||||
}
|
||||
|
||||
public static int migrateContainer(Container container) {
|
||||
if (container == null) return 0;
|
||||
int migrated = 0;
|
||||
for (int slot = 0; slot < container.getContainerSize(); slot++) {
|
||||
ItemStack current = container.getItem(slot);
|
||||
ItemStack replacement = migrateStack(current);
|
||||
if (replacement == current) continue;
|
||||
container.setItem(slot, replacement);
|
||||
migrated++;
|
||||
}
|
||||
if (migrated > 0) container.setChanged();
|
||||
return migrated;
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package fr.koka99cab.sanctuary26.iliketomoveit.transport;
|
||||
|
||||
import fr.koka99cab.sanctuary26.iliketomoveit.ILikeToMoveItMod;
|
||||
import fr.koka99cab.sanctuary26.iliketomoveit.data.ILikeToMoveItData;
|
||||
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents;
|
||||
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
|
||||
/** Converts online inventories once their historical alias has been decoded. */
|
||||
public final class MagicCarpetMigrationService {
|
||||
private MagicCarpetMigrationService() {
|
||||
}
|
||||
|
||||
public static void register() {
|
||||
ServerPlayConnectionEvents.JOIN.register((handler, sender, server) -> migrate(handler.player));
|
||||
ServerTickEvents.END_SERVER_TICK.register(MagicCarpetMigrationService::tick);
|
||||
}
|
||||
|
||||
private static void tick(MinecraftServer server) {
|
||||
ILikeToMoveItData.get(server);
|
||||
if (server.overworld().getGameTime() % 20L != 0L) return;
|
||||
for (ServerPlayer player : server.getPlayerList().getPlayers()) migrate(player);
|
||||
}
|
||||
|
||||
private static void migrate(ServerPlayer player) {
|
||||
int migrated = MagicCarpetMigration.migrateContainer(player.getInventory());
|
||||
migrated += MagicCarpetMigration.migrateContainer(player.getEnderChestInventory());
|
||||
if (migrated > 0) {
|
||||
ILikeToMoveItMod.LOGGER.info("[Magic Carpet] Migrated {} historical Totem stack(s) for {}.",
|
||||
migrated, player.getGameProfile().name());
|
||||
}
|
||||
}
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
package fr.koka99cab.sanctuary26.iliketomoveit.transport;
|
||||
|
||||
import fr.koka99cab.sanctuary26.iliketomoveit.ILikeToMoveItMod;
|
||||
import fr.koka99cab.sanctuary26.iliketomoveit.registry.ILikeToMoveItItems;
|
||||
import fr.koka99cab.sanctuary26.sanctuary.api.item.SanctuaryItemProvenance;
|
||||
import fr.koka99cab.sanctuary26.sanctuary.api.notch.SanctuaryNotchMilestone;
|
||||
import fr.koka99cab.sanctuary26.sanctuary.api.world.SanctuaryAlphaWorld;
|
||||
import fr.koka99cab.sanctuary26.sanctuary.world.SanctuaryDimensions;
|
||||
import it.unimi.dsi.fastutil.ints.IntArrayList;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.component.DataComponents;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.Container;
|
||||
import net.minecraft.world.entity.projectile.FireworkRocketEntity;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.item.Items;
|
||||
import net.minecraft.world.item.component.FireworkExplosion;
|
||||
import net.minecraft.world.item.component.Fireworks;
|
||||
import net.minecraft.world.level.block.Block;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.entity.ChestBlockEntity;
|
||||
|
||||
/** Idempotently materializes the globally reserved Magic Carpet in Alpha. */
|
||||
public final class NotchCarpetRewardService {
|
||||
private static final int REWARD_SLOT = 13;
|
||||
|
||||
private NotchCarpetRewardService() {
|
||||
}
|
||||
|
||||
public static void register() {
|
||||
ServerTickEvents.END_SERVER_TICK.register(NotchCarpetRewardService::tick);
|
||||
}
|
||||
|
||||
private static void tick(MinecraftServer server) {
|
||||
if (server.overworld().getGameTime() % 20L != 0L) return;
|
||||
var state = SanctuaryNotchMilestone.snapshot(server);
|
||||
if (!state.defeated() || state.initialRewardDelivered()) return;
|
||||
if (!state.initialRewardClaimed() && !SanctuaryNotchMilestone.claimInitialReward(server)) return;
|
||||
ServerLevel alpha = server.getLevel(SanctuaryDimensions.ALPHA);
|
||||
if (alpha == null) return;
|
||||
BlockPos home = SanctuaryAlphaWorld.residence(alpha.getSeed());
|
||||
if (!alpha.isLoaded(home)) return;
|
||||
RewardChest result = ensureRewardChest(alpha, home);
|
||||
if (result == null || !SanctuaryNotchMilestone.completeInitialReward(server)) return;
|
||||
if (result.created()) {
|
||||
launchFirework(alpha, result.pos());
|
||||
server.getPlayerList().broadcastSystemMessage(
|
||||
Component.translatable("message.iliketomoveit.magic_carpet.reward_created"), false);
|
||||
}
|
||||
ILikeToMoveItMod.LOGGER.info(
|
||||
"[Magic Carpet] Completed unique Notch reward at {} (origin=notch_reward, recovered={}).",
|
||||
result.pos().toShortString(), !result.created());
|
||||
}
|
||||
|
||||
private static RewardChest ensureRewardChest(ServerLevel level, BlockPos home) {
|
||||
List<BlockPos> candidates = candidates(home);
|
||||
for (BlockPos pos : candidates) {
|
||||
if (containsInitialReward(level, pos)) return new RewardChest(pos, false);
|
||||
}
|
||||
for (BlockPos pos : candidates) {
|
||||
if (!level.getBlockState(pos).isAir() || !level.getBlockState(pos.above()).isAir()
|
||||
|| level.getBlockState(pos.below()).isAir()) continue;
|
||||
if (!level.setBlock(pos, Blocks.CHEST.defaultBlockState(), Block.UPDATE_ALL)) continue;
|
||||
if (level.getBlockEntity(pos) instanceof ChestBlockEntity chest) {
|
||||
chest.setItem(REWARD_SLOT, rewardStack());
|
||||
chest.setChanged();
|
||||
level.sendBlockUpdated(pos, level.getBlockState(pos), level.getBlockState(pos), Block.UPDATE_ALL);
|
||||
return new RewardChest(pos, true);
|
||||
}
|
||||
level.setBlock(pos, Blocks.AIR.defaultBlockState(), Block.UPDATE_ALL);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean containsInitialReward(ServerLevel level, BlockPos pos) {
|
||||
if (level.getBlockState(pos).getBlock() != Blocks.CHEST
|
||||
|| !(level.getBlockEntity(pos) instanceof Container container)) return false;
|
||||
for (ItemStack stack : container) {
|
||||
if (stack.is(ILikeToMoveItItems.MAGIC_CARPET)
|
||||
&& SanctuaryItemProvenance.hasOrigin(stack, SanctuaryItemProvenance.NOTCH_REWARD)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static ItemStack rewardStack() {
|
||||
ItemStack reward = new ItemStack(ILikeToMoveItItems.MAGIC_CARPET);
|
||||
reward.set(DataComponents.CUSTOM_NAME,
|
||||
Component.translatable("item.iliketomoveit.magic_carpet.notch_reward_name"));
|
||||
SanctuaryItemProvenance.stamp(reward, SanctuaryItemProvenance.NOTCH_REWARD);
|
||||
return reward;
|
||||
}
|
||||
|
||||
private static List<BlockPos> candidates(BlockPos home) {
|
||||
ArrayList<BlockPos> positions = new ArrayList<>();
|
||||
for (int y : new int[] {0, 1, -1, 2}) {
|
||||
for (int radius = 2; radius <= 7; radius++) {
|
||||
for (int x = -radius; x <= radius; x++) for (int z = -radius; z <= radius; z++) {
|
||||
if (Math.max(Math.abs(x), Math.abs(z)) != radius) continue;
|
||||
BlockPos candidate = home.offset(x, y, z).immutable();
|
||||
if (SanctuaryAlphaWorld.containsHorizontal(candidate)) positions.add(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
return List.copyOf(positions);
|
||||
}
|
||||
|
||||
private static void launchFirework(ServerLevel level, BlockPos pos) {
|
||||
ItemStack rocket = new ItemStack(Items.FIREWORK_ROCKET);
|
||||
FireworkExplosion explosion = new FireworkExplosion(FireworkExplosion.Shape.STAR,
|
||||
new IntArrayList(new int[] {0xE8B923, 0xF1F1F1}),
|
||||
new IntArrayList(new int[] {0x9B59B6}), true, true);
|
||||
rocket.set(DataComponents.FIREWORKS, new Fireworks(1, List.of(explosion)));
|
||||
level.addFreshEntity(new FireworkRocketEntity(level, pos.getX() + 0.5D,
|
||||
pos.getY() + 1.2D, pos.getZ() + 0.5D, rocket));
|
||||
}
|
||||
|
||||
private record RewardChest(BlockPos pos, boolean created) {
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,13 @@
|
||||
"itemGroup.iliketomoveit.transport": "I Like To Move It",
|
||||
"item.iliketomoveit.aircraft": "Aircraft",
|
||||
"item.iliketomoveit.magic_cloud": "Magic Cloud",
|
||||
"item.iliketomoveit.magic_carpet": "Magic Carpet (laboratory)",
|
||||
"item.iliketomoveit.magic_carpet": "Magic Carpet",
|
||||
"item.portals.notch_totem": "Notch Totem (legacy)",
|
||||
"item.iliketomoveit.magic_carpet.notch_reward_name": "Notch's Magic Carpet",
|
||||
"item.iliketomoveit.magic_carpet.description": "Hold it to maintain a temporary 3 × 3 platform beneath your feet.",
|
||||
"item.iliketomoveit.magic_carpet.origin.notch": "Unique world reward from Notch",
|
||||
"item.iliketomoveit.magic_carpet.origin.shop": "Replacement purchased from the Shop",
|
||||
"item.iliketomoveit.magic_carpet.origin.legacy": "Converted from the historical Notch Totem",
|
||||
"block.iliketomoveit.waystone": "Waystone",
|
||||
"block.waystone.waystone": "Waystone (legacy)",
|
||||
"entity.iliketomoveit.aircraft": "Aircraft",
|
||||
@@ -11,6 +17,7 @@
|
||||
"container.iliketomoveit.aircraft_engine": "Aircraft Engine",
|
||||
"message.iliketomoveit.magic_carpet.enabled": "Magic Carpet enabled",
|
||||
"message.iliketomoveit.magic_carpet.disabled": "Magic Carpet disabled",
|
||||
"message.iliketomoveit.magic_carpet.reward_created": "Notch's unique Magic Carpet appeared in a chest near his house.",
|
||||
"subtitles.iliketomoveit.magic_cloud.fly": "Magic Cloud flies",
|
||||
"screen.iliketomoveit.waystone.title": "Waystones",
|
||||
"screen.iliketomoveit.waystone.empty": "No other waystone in this dimension",
|
||||
|
||||
@@ -3,7 +3,13 @@
|
||||
"itemGroup.iliketomoveit.transport": "I Like To Move It",
|
||||
"item.iliketomoveit.aircraft": "Avion",
|
||||
"item.iliketomoveit.magic_cloud": "Nuage magique",
|
||||
"item.iliketomoveit.magic_carpet": "Tapis magique (laboratoire)",
|
||||
"item.iliketomoveit.magic_carpet": "Tapis magique",
|
||||
"item.portals.notch_totem": "Totem de Notch (hérité)",
|
||||
"item.iliketomoveit.magic_carpet.notch_reward_name": "Tapis magique de Notch",
|
||||
"item.iliketomoveit.magic_carpet.description": "Tenu en main, il maintient une plateforme temporaire 3 × 3 sous vos pieds.",
|
||||
"item.iliketomoveit.magic_carpet.origin.notch": "Récompense mondiale unique de Notch",
|
||||
"item.iliketomoveit.magic_carpet.origin.shop": "Remplacement acheté au Shop",
|
||||
"item.iliketomoveit.magic_carpet.origin.legacy": "Converti depuis le Totem de Notch historique",
|
||||
"block.iliketomoveit.waystone": "Pierre de voyage",
|
||||
"block.waystone.waystone": "Pierre de voyage (héritée)",
|
||||
"entity.iliketomoveit.aircraft": "Avion",
|
||||
@@ -11,6 +17,7 @@
|
||||
"container.iliketomoveit.aircraft_engine": "Moteur de l'avion",
|
||||
"message.iliketomoveit.magic_carpet.enabled": "Tapis magique activé",
|
||||
"message.iliketomoveit.magic_carpet.disabled": "Tapis magique désactivé",
|
||||
"message.iliketomoveit.magic_carpet.reward_created": "Le Tapis magique unique de Notch est apparu dans un coffre près de sa maison.",
|
||||
"subtitles.iliketomoveit.magic_cloud.fly": "Le nuage magique vole",
|
||||
"screen.iliketomoveit.waystone.title": "Pierres de voyage",
|
||||
"screen.iliketomoveit.waystone.empty": "Aucune autre pierre dans cette dimension",
|
||||
|
||||
@@ -3,7 +3,13 @@
|
||||
"itemGroup.iliketomoveit.transport": "I Like To Move It",
|
||||
"item.iliketomoveit.aircraft": "Самолёт",
|
||||
"item.iliketomoveit.magic_cloud": "Волшебное облако",
|
||||
"item.iliketomoveit.magic_carpet": "Ковёр-самолёт (лаборатория)",
|
||||
"item.iliketomoveit.magic_carpet": "Ковёр-самолёт",
|
||||
"item.portals.notch_totem": "Тотем Нотча (старый)",
|
||||
"item.iliketomoveit.magic_carpet.notch_reward_name": "Ковёр-самолёт Нотча",
|
||||
"item.iliketomoveit.magic_carpet.description": "В руке поддерживает временную платформу 3 × 3 под ногами.",
|
||||
"item.iliketomoveit.magic_carpet.origin.notch": "Уникальная мировая награда за Нотча",
|
||||
"item.iliketomoveit.magic_carpet.origin.shop": "Замена, купленная в Магазине",
|
||||
"item.iliketomoveit.magic_carpet.origin.legacy": "Преобразован из исторического Тотема Нотча",
|
||||
"block.iliketomoveit.waystone": "Камень пути",
|
||||
"block.waystone.waystone": "Камень пути (старый)",
|
||||
"entity.iliketomoveit.aircraft": "Самолёт",
|
||||
@@ -11,6 +17,7 @@
|
||||
"container.iliketomoveit.aircraft_engine": "Двигатель самолёта",
|
||||
"message.iliketomoveit.magic_carpet.enabled": "Ковёр-самолёт включён",
|
||||
"message.iliketomoveit.magic_carpet.disabled": "Ковёр-самолёт выключен",
|
||||
"message.iliketomoveit.magic_carpet.reward_created": "Уникальный ковёр-самолёт Нотча появился в сундуке возле его дома.",
|
||||
"subtitles.iliketomoveit.magic_cloud.fly": "Волшебное облако летит",
|
||||
"screen.iliketomoveit.waystone.title": "Камни пути",
|
||||
"screen.iliketomoveit.waystone.empty": "В этом измерении нет других камней",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"parent": "minecraft:item/generated",
|
||||
"textures": {
|
||||
"layer0": "minecraft:block/purple_wool"
|
||||
"layer0": "iliketomoveit:item/magic_carpet"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 366 B |
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"model": {
|
||||
"type": "minecraft:model",
|
||||
"model": "iliketomoveit:item/magic_carpet"
|
||||
}
|
||||
}
|
||||
@@ -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"],
|
||||
|
||||
@@ -33,13 +33,13 @@
|
||||
"java": ">=25",
|
||||
"fabric-api": ">=0.154.2+26.2",
|
||||
"anotherworld": ">=0.0.0-alpha.46",
|
||||
"sanctuary": ">=0.0.0-alpha.21"
|
||||
"sanctuary": ">=0.0.0-alpha.127"
|
||||
},
|
||||
"custom": {
|
||||
"sanctuary26": {
|
||||
"system_type": "gameplay-system",
|
||||
"namespace": "iliketomoveit",
|
||||
"data_version": 3,
|
||||
"data_version": 4,
|
||||
"network_protocol": 5,
|
||||
"lifecycle": "${lifecycle}",
|
||||
"pack_version": "${pack_version}",
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
"package": "fr.koka99cab.sanctuary26.iliketomoveit.mixin",
|
||||
"compatibilityLevel": "JAVA_25",
|
||||
"mixins": [
|
||||
"HappyGhastMixin"
|
||||
"HappyGhastMixin",
|
||||
"MagicCarpetContainerMixin",
|
||||
"MagicCarpetItemEntityMixin"
|
||||
],
|
||||
"injectors": {
|
||||
"defaultRequire": 1
|
||||
|
||||
+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);
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package fr.koka99cab.sanctuary26.iliketomoveit.transport;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.world.level.Level;
|
||||
|
||||
/** Regression checks for overlap, world changes and shutdown cleanup ownership. */
|
||||
public final class MagicCarpetLedgerSmoke {
|
||||
private MagicCarpetLedgerSmoke() {
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
MagicCarpetLedger ledger = new MagicCarpetLedger();
|
||||
UUID first = UUID.fromString("00000000-0000-0000-0000-000000000001");
|
||||
UUID second = UUID.fromString("00000000-0000-0000-0000-000000000002");
|
||||
ResourceKey<Level> firstWorld = ResourceKey.create(Registries.DIMENSION,
|
||||
Identifier.fromNamespaceAndPath("test", "first"));
|
||||
ResourceKey<Level> secondWorld = ResourceKey.create(Registries.DIMENSION,
|
||||
Identifier.fromNamespaceAndPath("test", "second"));
|
||||
BlockPos shared = new BlockPos(4, 70, 4);
|
||||
ledger.track(first, firstWorld, Set.of(shared));
|
||||
ledger.track(second, firstWorld, Set.of(shared));
|
||||
if (!ledger.trackedByAnother(first, firstWorld, shared)) {
|
||||
throw new AssertionError("Overlapping carpets lost their second owner");
|
||||
}
|
||||
ledger.remove(first);
|
||||
if (ledger.trackedByAnother(second, firstWorld, shared)) {
|
||||
throw new AssertionError("Removed owner still controls the shared glass");
|
||||
}
|
||||
ledger.track(second, secondWorld, Set.of(new BlockPos(0, 64, 0)));
|
||||
if (!ledger.state(second, secondWorld).dimension().equals(secondWorld)
|
||||
|| !ledger.state(second, firstWorld).dimension().equals(secondWorld)) {
|
||||
throw new AssertionError("Dimension change did not replace the tracked world");
|
||||
}
|
||||
Map<UUID, MagicCarpetLedger.State> shutdown = ledger.drain();
|
||||
if (shutdown.size() != 1 || !ledger.owners().isEmpty()) {
|
||||
throw new AssertionError("Server shutdown did not atomically drain temporary platforms");
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
-1
@@ -2,7 +2,15 @@
|
||||
|
||||
It's Alive possède le vivant de Sanctuary. Il enrichit directement le Creeper vanilla et accueille désormais les papillons de Pipallon, les Mooblooms fleuries et les créatures de Real Ghost — baleine volante comprise — sous un référencement unifié `itsalive:*`.
|
||||
|
||||
## Version courante — `0.0.0-alpha.36`
|
||||
## Version courante — `0.0.0-alpha.37`
|
||||
|
||||
## Résident Notch
|
||||
|
||||
- `itsalive:notch` est l'unique résident persistant de la maison Indev dans `sanctuary:alpha` ; il n'apparaît jamais naturellement et ne peut pas être invoqué par les commandes génériques.
|
||||
- Son UUID canonique est sauvegardé, les doublons sont supprimés et le service le recrée au domicile s'il manque. L'alias caché `portals:notch` charge les anciennes entités du prototype sans dupliquer les règles.
|
||||
- Le premier coup volontaire d'un joueur transforme l'humain en Titan. Le nombre de participants est borné à huit, la santé va de 360 à 600 et un combat abandonné se réinitialise après quinze secondes sans participant actif.
|
||||
- It's Alive possède l'entité et son cycle de vie ; les attaques passent exclusivement par l'API publique `NotchCombatApi`, implémentée par Ouch.
|
||||
- Diagnostics : `/masterkey notch inspect` et `/masterkey notch restore`.
|
||||
|
||||
## Creepers
|
||||
|
||||
|
||||
+15
-6
@@ -35,6 +35,14 @@ tasks.register("culinaryPartialMatchingSmoke", JavaExec) {
|
||||
mainClass = "fr.koka99cab.sanctuary26.itsalive.culinary.recipe.CulinaryPartialMatchingSmoke"
|
||||
}
|
||||
|
||||
tasks.register("notchFightRulesSmoke", JavaExec) {
|
||||
group = "verification"
|
||||
description = "Checks bounded Notch Titan scaling, reset timing and reflected-fireball damage."
|
||||
dependsOn tasks.named("testClasses")
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = "fr.koka99cab.sanctuary26.itsalive.entity.notch.NotchFightRulesSmoke"
|
||||
}
|
||||
|
||||
tasks.named("test") {
|
||||
// Quantity contracts are executable smoke tasks, not JUnit engine tests.
|
||||
failOnNoDiscoveredTests = false
|
||||
@@ -43,6 +51,7 @@ tasks.named("test") {
|
||||
tasks.named("check") {
|
||||
dependsOn tasks.named("canapliaFoundationSmoke")
|
||||
dependsOn tasks.named("canapliaGameplaySmoke")
|
||||
dependsOn tasks.named("notchFightRulesSmoke")
|
||||
}
|
||||
|
||||
def generatedResources = layout.buildDirectory.dir("generated/resources")
|
||||
@@ -556,7 +565,7 @@ tasks.register("verifyItsAlive") {
|
||||
def climateSource = file("src/main/java/fr/koka99cab/sanctuary26/itsalive/agriculture/ClimateCropForaging.java").text
|
||||
def pineappleSource = file("src/main/java/fr/koka99cab/sanctuary26/itsalive/agriculture/PineappleItem.java").text
|
||||
def worldgenSource = file("src/main/java/fr/koka99cab/sanctuary26/itsalive/agriculture/ItsAliveAgricultureWorldGeneration.java").text
|
||||
if (project.version.toString() != "0.0.0-alpha.36"
|
||||
if (project.version.toString() != "0.0.0-alpha.38"
|
||||
|| climateMigration.target?.pack_version != "26.2.0-alpha.157"
|
||||
|| climateMigration.target?.modules?.itsalive != "0.0.0-alpha.24"
|
||||
|| !climateSource.contains("PlayerBlockBreakEvents.AFTER.register")
|
||||
@@ -1313,7 +1322,7 @@ tasks.register("verifyCulinaryReliability") {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (project.version.toString() != "0.0.0-alpha.36"
|
||||
if (project.version.toString() != "0.0.0-alpha.38"
|
||||
|| migration.target?.modules?.itsalive != "0.0.0-alpha.28"
|
||||
|| migration.culinary_fixes?.food_press_contents_destroyed_on_break != true
|
||||
|| migration.culinary_fixes?.comparator_contract?.blocks?.size() != 6
|
||||
@@ -1351,7 +1360,7 @@ tasks.register("verifyCauldronBatching") {
|
||||
def recipe = file("src/main/java/fr/koka99cab/sanctuary26/itsalive/culinary/recipe/CulinaryProcessRecipe.java").text
|
||||
def cauldron = file("src/main/java/fr/koka99cab/sanctuary26/itsalive/culinary/block/entity/CookingCauldronBlockEntity.java").text
|
||||
def timed = file("src/main/java/fr/koka99cab/sanctuary26/itsalive/culinary/block/entity/AbstractTimedCulinaryBlockEntity.java").text
|
||||
if (project.version.toString() != "0.0.0-alpha.36"
|
||||
if (project.version.toString() != "0.0.0-alpha.38"
|
||||
|| migration.target?.modules?.itsalive != "0.0.0-alpha.29"
|
||||
|| migration.cauldron_batches?.logical_stack_limit != 64
|
||||
|| migration.cauldron_batches?.identity != "same_item_same_components"
|
||||
@@ -1389,7 +1398,7 @@ tasks.register("verifyFermentationBarrelAutomation") {
|
||||
def partialMatching = file("src/main/java/fr/koka99cab/sanctuary26/itsalive/culinary/recipe/CulinaryPartialMatching.java").text
|
||||
def blockstate = new JsonSlurper().parse(
|
||||
file("src/main/resources/assets/itsalive/blockstates/fermentation_barrel.json"))
|
||||
if (project.version.toString() != "0.0.0-alpha.36"
|
||||
if (project.version.toString() != "0.0.0-alpha.38"
|
||||
|| migration.source?.modules?.itsalive != "0.0.0-alpha.29"
|
||||
|| migration.target?.modules?.itsalive != "0.0.0-alpha.30"
|
||||
|| migration.fermentation_barrel?.maximum_units_per_input_slot != 1
|
||||
@@ -1437,7 +1446,7 @@ tasks.register("verifyCulinaryLecternRelease") {
|
||||
[(locale): new JsonSlurper().parse(file(
|
||||
"src/main/resources/assets/itsalive/lang/${locale}.json"))]
|
||||
}
|
||||
if (project.version.toString() != "0.0.0-alpha.36"
|
||||
if (project.version.toString() != "0.0.0-alpha.38"
|
||||
|| migration.target?.modules?.itsalive != "0.0.0-alpha.31"
|
||||
|| migration.culinary_lectern?.book_tag != "minecraft:lectern_books"
|
||||
|| migration.culinary_lectern?.owner_catalog_shared_while_placed != true
|
||||
@@ -1500,7 +1509,7 @@ tasks.register("verifyCanapliaForbiddenBook") {
|
||||
fr_fr: "Livre interdit",
|
||||
en_us: "Forbidden Book",
|
||||
ru_ru: "Запретная книга"]
|
||||
if (project.version.toString() != "0.0.0-alpha.36"
|
||||
if (project.version.toString() != "0.0.0-alpha.38"
|
||||
|| !pageItem.contains("CanapliaKnowledge.learnRandomUnknown(serverPlayer)")
|
||||
|| !knowledge.contains("unknownVariants(data.personal(player.getUUID()))")
|
||||
|| !knowledge.contains("data.personal(inscription.ownerId())")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package fr.koka99cab.sanctuary26.itsalive;
|
||||
|
||||
import fr.koka99cab.sanctuary26.itsalive.canaplia.CanapliaFoundation;
|
||||
import fr.koka99cab.sanctuary26.itsalive.entity.notch.NotchResidentService;
|
||||
import fr.koka99cab.sanctuary26.itsalive.network.ItsAliveNetworking;
|
||||
import fr.koka99cab.sanctuary26.itsalive.culinary.knowledge.CulinaryRecipeKnowledge;
|
||||
import fr.koka99cab.sanctuary26.itsalive.registry.ItsAliveRegistries;
|
||||
@@ -13,7 +14,7 @@ import org.slf4j.LoggerFactory;
|
||||
public final class ItsAliveMod implements ModInitializer {
|
||||
public static final String MOD_ID = "itsalive";
|
||||
public static final String DISPLAY_NAME = "It's Alive !";
|
||||
public static final int CURRENT_DATA_VERSION = 3;
|
||||
public static final int CURRENT_DATA_VERSION = 4;
|
||||
public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID);
|
||||
|
||||
public ItsAliveMod() {
|
||||
@@ -27,6 +28,7 @@ public final class ItsAliveMod implements ModInitializer {
|
||||
public void onInitialize() {
|
||||
CanapliaFoundation.initialize();
|
||||
ItsAliveRegistries.initialize();
|
||||
NotchResidentService.initialize();
|
||||
ItsAliveNetworking.initialize();
|
||||
CulinaryRecipeKnowledge.initialize();
|
||||
ProceduralVillagerNames.initialize();
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package fr.koka99cab.sanctuary26.itsalive.api.notch;
|
||||
|
||||
import java.util.Objects;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.entity.LivingEntity;
|
||||
|
||||
/** Public bridge allowing the combat-owning Ouch module to drive Notch attacks. */
|
||||
public final class NotchCombatApi {
|
||||
private static Engine engine = Engine.NOOP;
|
||||
private static boolean registered;
|
||||
|
||||
private NotchCombatApi() {
|
||||
}
|
||||
|
||||
public static synchronized void register(Engine implementation) {
|
||||
Objects.requireNonNull(implementation, "implementation");
|
||||
if (registered) throw new IllegalStateException("A Notch combat engine is already registered");
|
||||
engine = implementation;
|
||||
registered = true;
|
||||
}
|
||||
|
||||
public static void telegraph(ServerLevel level, LivingEntity boss, LivingEntity target, Attack attack) {
|
||||
engine.telegraph(level, boss, target, attack);
|
||||
}
|
||||
|
||||
public static void execute(ServerLevel level, LivingEntity boss, LivingEntity target, Attack attack) {
|
||||
engine.execute(level, boss, target, attack);
|
||||
}
|
||||
|
||||
public enum Attack {
|
||||
MELEE,
|
||||
FIREBALL,
|
||||
LIGHTNING
|
||||
}
|
||||
|
||||
public interface Engine {
|
||||
Engine NOOP = new Engine() {
|
||||
@Override public void telegraph(ServerLevel level, LivingEntity boss, LivingEntity target, Attack attack) { }
|
||||
@Override public void execute(ServerLevel level, LivingEntity boss, LivingEntity target, Attack attack) { }
|
||||
};
|
||||
|
||||
void telegraph(ServerLevel level, LivingEntity boss, LivingEntity target, Attack attack);
|
||||
|
||||
void execute(ServerLevel level, LivingEntity boss, LivingEntity target, Attack attack);
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -34,7 +34,8 @@ public final class CanapliaHarvestService {
|
||||
|
||||
public static void onMatureHarvest(ServerLevel level, ServerPlayer player, BlockPos pos, BlockState state) {
|
||||
if (!state.is(CanapliaContent.HEMP_CROP) || !CanapliaContent.HEMP_CROP.isMaxAge(state)) return;
|
||||
CanapliaVariantKey parent = state.getValue(CanapliaHempCropBlock.VARIANT);
|
||||
CanapliaVariantKey parent = CanapliaVariantLifecycle.harvestedFromPlant(
|
||||
state.getValue(CanapliaHempCropBlock.VARIANT));
|
||||
CanapliaKnowledge.discover(player, parent, "harvest");
|
||||
ClimateCropForaging.CropClimate climate = ClimateCropForaging.climate(level.getBiome(pos));
|
||||
AmbianceLunarHarvestApi.Conditions lunar = AmbianceLunarHarvestApi.evaluate(level, pos);
|
||||
@@ -65,7 +66,8 @@ public final class CanapliaHarvestService {
|
||||
if (pos.equals(center)) continue;
|
||||
BlockState state = level.getBlockState(pos);
|
||||
if (state.is(CanapliaContent.HEMP_CROP) && CanapliaContent.HEMP_CROP.isMaxAge(state)) {
|
||||
variants.add(state.getValue(CanapliaHempCropBlock.VARIANT));
|
||||
variants.add(CanapliaVariantLifecycle.harvestedFromPlant(
|
||||
state.getValue(CanapliaHempCropBlock.VARIANT)));
|
||||
}
|
||||
}
|
||||
return Set.copyOf(variants);
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package fr.koka99cab.sanctuary26.itsalive.canaplia;
|
||||
|
||||
/** Stable variant hand-off across placement, growth, block-state persistence and harvest. */
|
||||
public final class CanapliaVariantLifecycle {
|
||||
private CanapliaVariantLifecycle() {
|
||||
}
|
||||
|
||||
public static CanapliaVariantKey plantedFromSeed(CanapliaVariantKey seedVariant) {
|
||||
return require(seedVariant);
|
||||
}
|
||||
|
||||
public static CanapliaVariantKey afterGrowth(CanapliaVariantKey currentVariant) {
|
||||
return require(currentVariant);
|
||||
}
|
||||
|
||||
public static CanapliaVariantKey upperFromLower(CanapliaVariantKey lowerVariant) {
|
||||
return require(lowerVariant);
|
||||
}
|
||||
|
||||
public static CanapliaVariantKey harvestedFromPlant(CanapliaVariantKey plantVariant) {
|
||||
return require(plantVariant);
|
||||
}
|
||||
|
||||
public static String persistedKey(CanapliaVariantKey variant) {
|
||||
return require(variant).key();
|
||||
}
|
||||
|
||||
public static CanapliaVariantKey fromPersistedKey(String key) {
|
||||
return CanapliaVariantKey.byKey(key).orElse(CanapliaVariantKey.COMMON);
|
||||
}
|
||||
|
||||
private static CanapliaVariantKey require(CanapliaVariantKey variant) {
|
||||
if (variant == null) throw new IllegalArgumentException("Canaplia variant cannot be null");
|
||||
return variant;
|
||||
}
|
||||
}
|
||||
+17
-6
@@ -2,6 +2,7 @@ package fr.koka99cab.sanctuary26.itsalive.canaplia.block;
|
||||
|
||||
import fr.koka99cab.sanctuary26.itsalive.canaplia.CanapliaContent;
|
||||
import fr.koka99cab.sanctuary26.itsalive.canaplia.CanapliaHarvestService;
|
||||
import fr.koka99cab.sanctuary26.itsalive.canaplia.CanapliaVariantLifecycle;
|
||||
import fr.koka99cab.sanctuary26.itsalive.canaplia.CanapliaVariantKey;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
@@ -43,19 +44,28 @@ public final class CanapliaHempCropBlock extends CropBlock {
|
||||
|
||||
@Override
|
||||
protected ItemStack getCloneItemStack(LevelReader level, BlockPos pos, BlockState state, boolean includeData) {
|
||||
return new ItemStack(CanapliaContent.family(state.getValue(VARIANT)).seeds());
|
||||
CanapliaVariantKey variant = CanapliaVariantLifecycle.harvestedFromPlant(state.getValue(VARIANT));
|
||||
return new ItemStack(CanapliaContent.family(variant).seeds());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void randomTick(BlockState state, ServerLevel level, BlockPos pos, RandomSource random) {
|
||||
super.randomTick(state, level, pos, random);
|
||||
synchronizeUpper(level, pos, level.getBlockState(pos));
|
||||
if (level.getRawBrightness(pos, 0) < 9) return;
|
||||
int age = getAge(state);
|
||||
if (age >= getMaxAge()) return;
|
||||
float speed = getGrowthSpeed(this, level, pos);
|
||||
if (random.nextInt((int) (25.0F / speed) + 1) != 0) return;
|
||||
BlockState grown = state.setValue(AGE, age + 1).setValue(VARIANT,
|
||||
CanapliaVariantLifecycle.afterGrowth(state.getValue(VARIANT)));
|
||||
level.setBlock(pos, grown, Block.UPDATE_CLIENTS);
|
||||
synchronizeUpper(level, pos, grown);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void growCrops(Level level, BlockPos pos, BlockState state) {
|
||||
int age = Math.min(getMaxAge(), getAge(state) + getBonemealAgeIncrease(level));
|
||||
BlockState grown = state.setValue(AGE, age);
|
||||
BlockState grown = state.setValue(AGE, age).setValue(VARIANT,
|
||||
CanapliaVariantLifecycle.afterGrowth(state.getValue(VARIANT)));
|
||||
level.setBlock(pos, grown, Block.UPDATE_CLIENTS);
|
||||
synchronizeUpper(level, pos, grown);
|
||||
}
|
||||
@@ -78,7 +88,7 @@ public final class CanapliaHempCropBlock extends CropBlock {
|
||||
if (!(level instanceof ServerLevel serverLevel) || !(player instanceof ServerPlayer serverPlayer)) {
|
||||
return InteractionResult.FAIL;
|
||||
}
|
||||
CanapliaVariantKey variant = state.getValue(VARIANT);
|
||||
CanapliaVariantKey variant = CanapliaVariantLifecycle.harvestedFromPlant(state.getValue(VARIANT));
|
||||
Block.popResource(serverLevel, pos,
|
||||
new ItemStack(CanapliaContent.family(variant).bud(), 1 + serverLevel.getRandom().nextInt(3)));
|
||||
CanapliaHarvestService.onMatureHarvest(serverLevel, serverPlayer, pos, state);
|
||||
@@ -97,7 +107,8 @@ public final class CanapliaHempCropBlock extends CropBlock {
|
||||
if (getAge(state) >= UPPER_START_AGE) {
|
||||
if (aboveState.isAir() || aboveState.is(CanapliaContent.HEMP_TOP)) {
|
||||
level.setBlock(above, CanapliaContent.HEMP_TOP.defaultBlockState()
|
||||
.setValue(AGE, getAge(state)).setValue(VARIANT, state.getValue(VARIANT)), Block.UPDATE_CLIENTS);
|
||||
.setValue(AGE, getAge(state)).setValue(VARIANT,
|
||||
CanapliaVariantLifecycle.upperFromLower(state.getValue(VARIANT))), Block.UPDATE_CLIENTS);
|
||||
}
|
||||
} else if (aboveState.is(CanapliaContent.HEMP_TOP)) {
|
||||
level.removeBlock(above, false);
|
||||
|
||||
+4
-1
@@ -1,6 +1,7 @@
|
||||
package fr.koka99cab.sanctuary26.itsalive.canaplia.block;
|
||||
|
||||
import fr.koka99cab.sanctuary26.itsalive.canaplia.CanapliaContent;
|
||||
import fr.koka99cab.sanctuary26.itsalive.canaplia.CanapliaVariantLifecycle;
|
||||
import fr.koka99cab.sanctuary26.itsalive.canaplia.CanapliaVariantKey;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
@@ -26,7 +27,9 @@ public final class CanapliaHempTopBlock extends CropBlock {
|
||||
|
||||
@Override
|
||||
protected ItemStack getCloneItemStack(LevelReader level, BlockPos pos, BlockState state, boolean includeData) {
|
||||
return new ItemStack(CanapliaContent.family(state.getValue(CanapliaHempCropBlock.VARIANT)).seeds());
|
||||
CanapliaVariantKey variant = CanapliaVariantLifecycle.harvestedFromPlant(
|
||||
state.getValue(CanapliaHempCropBlock.VARIANT));
|
||||
return new ItemStack(CanapliaContent.family(variant).seeds());
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+9
-3
@@ -1,5 +1,6 @@
|
||||
package fr.koka99cab.sanctuary26.itsalive.canaplia.item;
|
||||
|
||||
import fr.koka99cab.sanctuary26.itsalive.canaplia.CanapliaVariantLifecycle;
|
||||
import fr.koka99cab.sanctuary26.itsalive.canaplia.CanapliaVariantKey;
|
||||
import fr.koka99cab.sanctuary26.itsalive.canaplia.block.CanapliaHempCropBlock;
|
||||
import fr.koka99cab.sanctuary26.itsalive.canaplia.block.CanapliaResinBlock;
|
||||
@@ -24,12 +25,17 @@ public final class CanapliaVariantBlockItem extends BlockItem {
|
||||
@Override
|
||||
protected BlockState getPlacementState(BlockPlaceContext context) {
|
||||
BlockState state = super.getPlacementState(context);
|
||||
if (state == null) return null;
|
||||
return applyVariant(state, variant);
|
||||
}
|
||||
|
||||
public static BlockState applyVariant(BlockState state, CanapliaVariantKey variant) {
|
||||
if (state == null || variant == null) return state;
|
||||
CanapliaVariantKey placed = CanapliaVariantLifecycle.plantedFromSeed(variant);
|
||||
if (state.hasProperty(CanapliaHempCropBlock.VARIANT)) {
|
||||
return state.setValue(CanapliaHempCropBlock.VARIANT, variant);
|
||||
return state.setValue(CanapliaHempCropBlock.VARIANT, placed);
|
||||
}
|
||||
if (state.hasProperty(CanapliaResinBlock.VARIANT)) {
|
||||
return state.setValue(CanapliaResinBlock.VARIANT, variant);
|
||||
return state.setValue(CanapliaResinBlock.VARIANT, placed);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import fr.koka99cab.sanctuary26.itsalive.client.model.ButterflyModel;
|
||||
import fr.koka99cab.sanctuary26.itsalive.client.model.GhostModel;
|
||||
import fr.koka99cab.sanctuary26.itsalive.client.model.FlyingWhaleModel;
|
||||
import fr.koka99cab.sanctuary26.itsalive.client.model.StandingCowModel;
|
||||
import fr.koka99cab.sanctuary26.itsalive.client.model.NotchModel;
|
||||
import fr.koka99cab.sanctuary26.itsalive.client.renderer.BrackenRenderer;
|
||||
import fr.koka99cab.sanctuary26.itsalive.client.renderer.ButterflyRenderer;
|
||||
import fr.koka99cab.sanctuary26.itsalive.client.renderer.GhostRenderer;
|
||||
@@ -12,6 +13,7 @@ import fr.koka99cab.sanctuary26.itsalive.client.renderer.GemChickenRenderer;
|
||||
import fr.koka99cab.sanctuary26.itsalive.client.renderer.FlyingWhaleRenderer;
|
||||
import fr.koka99cab.sanctuary26.itsalive.client.renderer.InfectedRenderer;
|
||||
import fr.koka99cab.sanctuary26.itsalive.client.renderer.MoobloomRenderer;
|
||||
import fr.koka99cab.sanctuary26.itsalive.client.renderer.NotchRenderer;
|
||||
import fr.koka99cab.sanctuary26.itsalive.culinary.client.renderer.FryingPanBlockEntityRenderer;
|
||||
import fr.koka99cab.sanctuary26.itsalive.culinary.client.renderer.FermentationBarrelBlockEntityRenderer;
|
||||
import fr.koka99cab.sanctuary26.itsalive.culinary.client.FermentationBarrelHud;
|
||||
@@ -62,6 +64,7 @@ public final class ItsAliveClient implements ClientModInitializer {
|
||||
ModelLayerRegistry.registerModelLayer(StandingCowModel.TEMPERATE_LAYER, StandingCowModel::createTemperateLayer);
|
||||
ModelLayerRegistry.registerModelLayer(StandingCowModel.COLD_LAYER, StandingCowModel::createColdLayer);
|
||||
ModelLayerRegistry.registerModelLayer(StandingCowModel.WARM_LAYER, StandingCowModel::createWarmLayer);
|
||||
ModelLayerRegistry.registerModelLayer(NotchModel.LAYER_LOCATION, NotchModel::createBodyLayer);
|
||||
EntityRendererRegistry.register(ItsAliveEntityTypes.BUTTERFLY, ButterflyRenderer::new);
|
||||
EntityRendererRegistry.register(ItsAliveEntityTypes.GHOST, GhostRenderer::new);
|
||||
EntityRendererRegistry.register(ItsAliveEntityTypes.BRACKEN, BrackenRenderer::new);
|
||||
@@ -70,6 +73,8 @@ public final class ItsAliveClient implements ClientModInitializer {
|
||||
EntityRendererRegistry.register(ItsAliveEntityTypes.MOOBLOOM, MoobloomRenderer::new);
|
||||
EntityRendererRegistry.register(ItsAliveEntityTypes.GEM_CHICKEN, GemChickenRenderer::new);
|
||||
EntityRendererRegistry.register(ItsAliveEntityTypes.ACID_GLOB, ThrownItemRenderer::new);
|
||||
EntityRendererRegistry.register(ItsAliveEntityTypes.NOTCH, NotchRenderer::new);
|
||||
EntityRendererRegistry.register(ItsAliveEntityTypes.LEGACY_NOTCH, NotchRenderer::new);
|
||||
BlockEntityRendererRegistry.register(
|
||||
ItsAliveFoodProcessing.FRYING_PAN_BLOCK_ENTITY,
|
||||
FryingPanBlockEntityRenderer::new
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package fr.koka99cab.sanctuary26.itsalive.client.model;
|
||||
|
||||
import fr.koka99cab.sanctuary26.itsalive.ItsAliveMod;
|
||||
import fr.koka99cab.sanctuary26.itsalive.client.renderer.state.NotchRenderState;
|
||||
import net.minecraft.client.model.HumanoidModel;
|
||||
import net.minecraft.client.model.geom.ModelLayerLocation;
|
||||
import net.minecraft.client.model.geom.ModelPart;
|
||||
import net.minecraft.client.model.geom.builders.CubeDeformation;
|
||||
import net.minecraft.client.model.geom.builders.LayerDefinition;
|
||||
|
||||
public final class NotchModel extends HumanoidModel<NotchRenderState> {
|
||||
public static final ModelLayerLocation LAYER_LOCATION = new ModelLayerLocation(ItsAliveMod.id("notch"), "main");
|
||||
|
||||
public NotchModel(ModelPart root) {
|
||||
super(root);
|
||||
}
|
||||
|
||||
public static LayerDefinition createBodyLayer() {
|
||||
return LayerDefinition.create(HumanoidModel.createMesh(CubeDeformation.NONE, 0.0F), 64, 64);
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package fr.koka99cab.sanctuary26.itsalive.client.renderer;
|
||||
|
||||
import com.mojang.blaze3d.vertex.PoseStack;
|
||||
import fr.koka99cab.sanctuary26.itsalive.ItsAliveMod;
|
||||
import fr.koka99cab.sanctuary26.itsalive.client.model.NotchModel;
|
||||
import fr.koka99cab.sanctuary26.itsalive.client.renderer.state.NotchRenderState;
|
||||
import fr.koka99cab.sanctuary26.itsalive.entity.notch.NotchEntity;
|
||||
import net.minecraft.client.renderer.entity.EntityRendererProvider;
|
||||
import net.minecraft.client.renderer.entity.HumanoidMobRenderer;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.util.Mth;
|
||||
|
||||
public final class NotchRenderer extends HumanoidMobRenderer<NotchEntity, NotchRenderState, NotchModel> {
|
||||
private static final Identifier TEXTURE = ItsAliveMod.id("textures/entity/notch.png");
|
||||
|
||||
public NotchRenderer(EntityRendererProvider.Context context) {
|
||||
super(context, new NotchModel(context.bakeLayer(NotchModel.LAYER_LOCATION)), 0.5F);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Identifier getTextureLocation(NotchRenderState state) {
|
||||
return TEXTURE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public NotchRenderState createRenderState() {
|
||||
return new NotchRenderState();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void extractRenderState(NotchEntity notch, NotchRenderState state, float partialTick) {
|
||||
super.extractRenderState(notch, state, partialTick);
|
||||
state.titan = notch.isTitan();
|
||||
state.titanTransformationProgress = notch.titanTransformationProgress(partialTick);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void scale(NotchRenderState state, PoseStack poseStack) {
|
||||
super.scale(state, poseStack);
|
||||
float scale = state.titan
|
||||
? Mth.lerp(state.titanTransformationProgress, 1.0F, NotchEntity.TITAN_RENDER_SCALE)
|
||||
: 1.0F;
|
||||
poseStack.scale(scale, scale, scale);
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package fr.koka99cab.sanctuary26.itsalive.client.renderer.state;
|
||||
|
||||
import net.minecraft.client.renderer.entity.state.HumanoidRenderState;
|
||||
|
||||
public final class NotchRenderState extends HumanoidRenderState {
|
||||
public boolean titan;
|
||||
public float titanTransformationProgress;
|
||||
}
|
||||
+495
@@ -0,0 +1,495 @@
|
||||
package fr.koka99cab.sanctuary26.itsalive.entity.notch;
|
||||
|
||||
import fr.koka99cab.sanctuary26.itsalive.ItsAliveMod;
|
||||
import fr.koka99cab.sanctuary26.itsalive.api.notch.NotchCombatApi;
|
||||
import fr.koka99cab.sanctuary26.sanctuary.api.notch.SanctuaryNotchMilestone;
|
||||
import fr.koka99cab.sanctuary26.sanctuary.api.world.SanctuaryAlphaWorld;
|
||||
import java.util.EnumSet;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.particles.ParticleTypes;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.network.syncher.EntityDataAccessor;
|
||||
import net.minecraft.network.syncher.EntityDataSerializers;
|
||||
import net.minecraft.network.syncher.SynchedEntityData;
|
||||
import net.minecraft.server.level.ServerBossEvent;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.sounds.SoundEvents;
|
||||
import net.minecraft.util.Mth;
|
||||
import net.minecraft.world.BossEvent;
|
||||
import net.minecraft.world.damagesource.DamageSource;
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import net.minecraft.world.entity.EntityDimensions;
|
||||
import net.minecraft.world.entity.LivingEntity;
|
||||
import net.minecraft.world.entity.Mob;
|
||||
import net.minecraft.world.entity.PathfinderMob;
|
||||
import net.minecraft.world.entity.Pose;
|
||||
import net.minecraft.world.entity.ai.attributes.AttributeInstance;
|
||||
import net.minecraft.world.entity.ai.attributes.AttributeSupplier;
|
||||
import net.minecraft.world.entity.ai.attributes.Attributes;
|
||||
import net.minecraft.world.entity.ai.goal.FloatGoal;
|
||||
import net.minecraft.world.entity.ai.goal.Goal;
|
||||
import net.minecraft.world.entity.ai.goal.LookAtPlayerGoal;
|
||||
import net.minecraft.world.entity.ai.goal.RandomLookAroundGoal;
|
||||
import net.minecraft.world.entity.ai.goal.WaterAvoidingRandomStrollGoal;
|
||||
import net.minecraft.world.entity.ai.goal.target.NearestAttackableTargetGoal;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.entity.projectile.Projectile;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.storage.ValueInput;
|
||||
import net.minecraft.world.level.storage.ValueOutput;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
|
||||
/** The unique human resident of Alpha and his server-authoritative Titan phase. */
|
||||
public final class NotchEntity extends PathfinderMob {
|
||||
private static final EntityDataAccessor<Byte> PHASE = SynchedEntityData.defineId(
|
||||
NotchEntity.class, EntityDataSerializers.BYTE);
|
||||
private static final EntityDataAccessor<Integer> PHASE_STARTED_TICK = SynchedEntityData.defineId(
|
||||
NotchEntity.class, EntityDataSerializers.INT);
|
||||
public static final float TITAN_RENDER_SCALE = 8.0F;
|
||||
private static final float TITAN_HITBOX_SCALE = 5.0F;
|
||||
private static final double HUMAN_HOME_RADIUS = 9.0D;
|
||||
private static final double TITAN_HOME_RADIUS = 54.0D;
|
||||
private static final double PARTICIPANT_RADIUS = 64.0D;
|
||||
private static final String DATA_VERSION_KEY = "ItsAliveDataVersion";
|
||||
private static final String PARTICIPANTS_KEY = "NotchParticipants";
|
||||
private static final String LEGACY_REWARD_KEY = "NotchRewardGranted";
|
||||
private final ServerBossEvent bossEvent = new ServerBossEvent(UUID.randomUUID(),
|
||||
Component.translatable("boss.itsalive.notch_titan"), BossEvent.BossBarColor.RED,
|
||||
BossEvent.BossBarOverlay.NOTCHED_20);
|
||||
private final LinkedHashSet<UUID> participants = new LinkedHashSet<>();
|
||||
private BlockPos home;
|
||||
private int ticksWithoutParticipant;
|
||||
private float titanMaximumHealth = NotchFightRules.MIN_TITAN_HEALTH;
|
||||
private boolean legacyRewardGranted;
|
||||
|
||||
public NotchEntity(net.minecraft.world.entity.EntityType<? extends NotchEntity> type, Level level) {
|
||||
super(type, level);
|
||||
this.xpReward = 0;
|
||||
this.setCustomName(Component.translatable("entity.itsalive.notch"));
|
||||
this.setCustomNameVisible(true);
|
||||
this.setPersistenceRequired();
|
||||
this.bossEvent.setDarkenScreen(true);
|
||||
this.bossEvent.setCreateWorldFog(true);
|
||||
this.bossEvent.setVisible(false);
|
||||
this.applyPhaseAttributes(true);
|
||||
}
|
||||
|
||||
public static AttributeSupplier.Builder createAttributes() {
|
||||
return Mob.createMobAttributes()
|
||||
.add(Attributes.MAX_HEALTH, NotchFightRules.HUMAN_HEALTH)
|
||||
.add(Attributes.ATTACK_DAMAGE, 1.0D)
|
||||
.add(Attributes.FOLLOW_RANGE, 96.0D)
|
||||
.add(Attributes.MOVEMENT_SPEED, 0.16D)
|
||||
.add(Attributes.KNOCKBACK_RESISTANCE, 1.0D)
|
||||
.add(Attributes.ARMOR, 0.0D)
|
||||
.add(Attributes.ARMOR_TOUGHNESS, 0.0D)
|
||||
.add(Attributes.STEP_HEIGHT, 2.0D);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void defineSynchedData(SynchedEntityData.Builder builder) {
|
||||
super.defineSynchedData(builder);
|
||||
builder.define(PHASE, (byte)NotchFightRules.Phase.HUMAN.id());
|
||||
builder.define(PHASE_STARTED_TICK, 0);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void registerGoals() {
|
||||
this.goalSelector.addGoal(0, new FloatGoal(this));
|
||||
this.goalSelector.addGoal(1, new TitanCombatGoal());
|
||||
this.goalSelector.addGoal(5, new BoundedStrollGoal());
|
||||
this.goalSelector.addGoal(6, new LookAtPlayerGoal(this, Player.class, 24.0F));
|
||||
this.goalSelector.addGoal(7, new RandomLookAroundGoal(this));
|
||||
this.targetSelector.addGoal(1, new NearestAttackableTargetGoal<>(this, Player.class, 10,
|
||||
true, false, (target, level) -> this.isTitan() && target instanceof Player player
|
||||
&& this.canParticipate(player)));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void customServerAiStep(ServerLevel level) {
|
||||
super.customServerAiStep(level);
|
||||
this.ensureHome(level);
|
||||
this.bossEvent.setVisible(this.isTitan());
|
||||
if (this.isTitan()) {
|
||||
this.collectParticipants(level);
|
||||
this.bossEvent.setProgress(Mth.clamp(this.getHealth() / this.getMaxHealth(), 0.0F, 1.0F));
|
||||
if (this.hasActiveParticipant(level)) {
|
||||
this.ticksWithoutParticipant = 0;
|
||||
} else if (NotchFightRules.shouldReset(++this.ticksWithoutParticipant)) {
|
||||
this.resetInterruptedFight(level);
|
||||
return;
|
||||
}
|
||||
this.returnInsideBoundary(TITAN_HOME_RADIUS);
|
||||
} else {
|
||||
this.bossEvent.setProgress(1.0F);
|
||||
this.ticksWithoutParticipant = 0;
|
||||
if (this.getTarget() != null) this.setTarget(null);
|
||||
this.returnInsideBoundary(HUMAN_HOME_RADIUS + 4.0D);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hurtServer(ServerLevel level, DamageSource source, float damage) {
|
||||
if (this.isDefeated()) return false;
|
||||
Entity attacker = source.getEntity();
|
||||
if (this.isHuman()) {
|
||||
if (attacker instanceof ServerPlayer player) {
|
||||
this.beginFight(level, player);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (attacker == this) return false;
|
||||
if (source.getDirectEntity() instanceof Projectile projectile && projectile.getOwner() == this) return false;
|
||||
if (attacker instanceof ServerPlayer player) this.addParticipant(player);
|
||||
boolean hurt = super.hurtServer(level, source, Math.max(0.0F, damage));
|
||||
if (hurt && attacker instanceof LivingEntity living && this.canAttack(living)) this.setTarget(living);
|
||||
return hurt;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void die(DamageSource source) {
|
||||
if (this.isTitan() && this.level() instanceof ServerLevel level) {
|
||||
this.completeFight(level);
|
||||
return;
|
||||
}
|
||||
super.die(source);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean canAttack(LivingEntity target) {
|
||||
return this.isTitan() && super.canAttack(target);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void checkFallDamage(double distance, boolean landed, BlockState state, BlockPos pos) {
|
||||
}
|
||||
|
||||
public void initializeAtHome(BlockPos home, boolean defeated) {
|
||||
this.home = home.immutable();
|
||||
this.snapTo(home.getX() + 0.5D, home.getY(), home.getZ() + 0.5D, 180.0F, 0.0F);
|
||||
this.setYHeadRot(180.0F);
|
||||
this.setPhase(defeated ? NotchFightRules.Phase.DEFEATED : NotchFightRules.Phase.HUMAN, true);
|
||||
}
|
||||
|
||||
public void restoreAtHome(BlockPos home, boolean defeated) {
|
||||
this.participants.clear();
|
||||
this.ticksWithoutParticipant = 0;
|
||||
this.setTarget(null);
|
||||
this.getNavigation().stop();
|
||||
this.setDeltaMovement(Vec3.ZERO);
|
||||
this.initializeAtHome(home, defeated);
|
||||
}
|
||||
|
||||
public NotchFightRules.Phase phase() {
|
||||
return NotchFightRules.Phase.byId(this.entityData.get(PHASE));
|
||||
}
|
||||
|
||||
public boolean isHuman() {
|
||||
return phase() == NotchFightRules.Phase.HUMAN;
|
||||
}
|
||||
|
||||
public boolean isTitan() {
|
||||
return phase() == NotchFightRules.Phase.TITAN;
|
||||
}
|
||||
|
||||
public boolean isDefeated() {
|
||||
return phase() == NotchFightRules.Phase.DEFEATED;
|
||||
}
|
||||
|
||||
public float titanTransformationProgress(float partialTick) {
|
||||
if (!isTitan()) return 0.0F;
|
||||
return Mth.clamp((this.tickCount + partialTick - this.entityData.get(PHASE_STARTED_TICK)) / 30.0F,
|
||||
0.0F, 1.0F);
|
||||
}
|
||||
|
||||
public boolean legacyRewardGranted() {
|
||||
return legacyRewardGranted;
|
||||
}
|
||||
|
||||
public Set<UUID> participantIds() {
|
||||
return Set.copyOf(participants);
|
||||
}
|
||||
|
||||
private void beginFight(ServerLevel level, ServerPlayer attacker) {
|
||||
this.participants.clear();
|
||||
this.addParticipant(attacker);
|
||||
level.players().stream()
|
||||
.filter(this::canParticipate)
|
||||
.sorted(java.util.Comparator.comparingDouble(this::distanceToSqr))
|
||||
.forEach(this::addParticipant);
|
||||
this.titanMaximumHealth = NotchFightRules.titanHealth(participants.size());
|
||||
this.setPhase(NotchFightRules.Phase.TITAN, true);
|
||||
this.setTarget(attacker);
|
||||
this.bossEvent.setVisible(true);
|
||||
this.spawnLightningRing(level, 4);
|
||||
level.explode(this, this.getX(), this.getY() + 1.0D, this.getZ(), 4.0F,
|
||||
Level.ExplosionInteraction.BLOCK);
|
||||
this.playSound(SoundEvents.WITHER_SPAWN, 1.4F, 0.72F);
|
||||
}
|
||||
|
||||
private void completeFight(ServerLevel level) {
|
||||
this.collectParticipants(level);
|
||||
this.setPhase(NotchFightRules.Phase.DEFEATED, true);
|
||||
this.setTarget(null);
|
||||
this.getNavigation().stop();
|
||||
this.setDeltaMovement(Vec3.ZERO);
|
||||
this.deathTime = 0;
|
||||
this.bossEvent.setVisible(false);
|
||||
this.spawnLightningRing(level, 3);
|
||||
level.sendParticles(ParticleTypes.EXPLOSION_EMITTER, this.getX(), this.getY() + 1.0D,
|
||||
this.getZ(), 1, 0.0D, 0.0D, 0.0D, 0.0D);
|
||||
this.playSound(SoundEvents.WITHER_DEATH, 1.25F, 1.15F);
|
||||
SanctuaryNotchMilestone.recordVictory(level, participants);
|
||||
}
|
||||
|
||||
private void resetInterruptedFight(ServerLevel level) {
|
||||
this.setPhase(NotchFightRules.Phase.HUMAN, true);
|
||||
this.participants.clear();
|
||||
this.setTarget(null);
|
||||
this.getNavigation().stop();
|
||||
this.setDeltaMovement(Vec3.ZERO);
|
||||
this.bossEvent.setVisible(false);
|
||||
if (home != null) this.snapTo(home.getX() + 0.5D, home.getY(), home.getZ() + 0.5D, 180.0F, 0.0F);
|
||||
level.sendParticles(ParticleTypes.SMOKE, this.getX(), this.getY() + 1.0D, this.getZ(),
|
||||
18, 0.8D, 1.0D, 0.8D, 0.04D);
|
||||
}
|
||||
|
||||
private void setPhase(NotchFightRules.Phase phase, boolean heal) {
|
||||
this.entityData.set(PHASE, (byte)phase.id());
|
||||
this.entityData.set(PHASE_STARTED_TICK, this.tickCount);
|
||||
this.applyPhaseAttributes(heal);
|
||||
this.refreshDimensions();
|
||||
}
|
||||
|
||||
private void applyPhaseAttributes(boolean heal) {
|
||||
boolean titan = isTitan();
|
||||
setBaseValue(Attributes.MAX_HEALTH, titan ? titanMaximumHealth : NotchFightRules.HUMAN_HEALTH);
|
||||
setBaseValue(Attributes.ATTACK_DAMAGE, titan ? 18.0D : 1.0D);
|
||||
setBaseValue(Attributes.MOVEMENT_SPEED, titan ? 0.235D : 0.16D);
|
||||
setBaseValue(Attributes.ARMOR, titan ? 10.0D : 0.0D);
|
||||
setBaseValue(Attributes.ARMOR_TOUGHNESS, titan ? 8.0D : 0.0D);
|
||||
if (heal) this.setHealth(this.getMaxHealth());
|
||||
}
|
||||
|
||||
private void setBaseValue(net.minecraft.core.Holder<net.minecraft.world.entity.ai.attributes.Attribute> attribute,
|
||||
double value) {
|
||||
AttributeInstance instance = this.getAttribute(attribute);
|
||||
if (instance != null) instance.setBaseValue(value);
|
||||
}
|
||||
|
||||
private void ensureHome(ServerLevel level) {
|
||||
if (home == null) home = SanctuaryAlphaWorld.residence(level.getSeed());
|
||||
}
|
||||
|
||||
private void returnInsideBoundary(double radius) {
|
||||
if (home == null || this.distanceToSqr(Vec3.atCenterOf(home)) <= radius * radius) return;
|
||||
this.getNavigation().stop();
|
||||
this.snapTo(home.getX() + 0.5D, home.getY(), home.getZ() + 0.5D, this.getYRot(), this.getXRot());
|
||||
}
|
||||
|
||||
private boolean canParticipate(Player player) {
|
||||
return player.isAlive() && !player.isSpectator() && player.level() == this.level()
|
||||
&& this.distanceToSqr(player) <= PARTICIPANT_RADIUS * PARTICIPANT_RADIUS;
|
||||
}
|
||||
|
||||
private void addParticipant(Player player) {
|
||||
if (participants.size() < NotchFightRules.MAX_PARTICIPANTS && canParticipate(player)) {
|
||||
participants.add(player.getUUID());
|
||||
}
|
||||
}
|
||||
|
||||
private void collectParticipants(ServerLevel level) {
|
||||
level.players().stream().filter(this::canParticipate)
|
||||
.sorted(java.util.Comparator.comparingDouble(this::distanceToSqr))
|
||||
.forEach(this::addParticipant);
|
||||
}
|
||||
|
||||
private boolean hasActiveParticipant(ServerLevel level) {
|
||||
return participants.stream().map(level.getServer().getPlayerList()::getPlayer)
|
||||
.anyMatch(player -> player != null && canParticipate(player));
|
||||
}
|
||||
|
||||
private void spawnLightningRing(ServerLevel level, int strikes) {
|
||||
for (int index = 0; index < strikes; index++) {
|
||||
double angle = Math.PI * 2.0D * index / Math.max(1, strikes);
|
||||
net.minecraft.world.entity.LightningBolt lightning = net.minecraft.world.entity.EntityTypes.LIGHTNING_BOLT
|
||||
.create(level, net.minecraft.world.entity.EntitySpawnReason.TRIGGERED);
|
||||
if (lightning == null) continue;
|
||||
lightning.setVisualOnly(true);
|
||||
lightning.setPos(this.getX() + Math.cos(angle) * 2.4D, this.getY(),
|
||||
this.getZ() + Math.sin(angle) * 2.4D);
|
||||
level.addFreshEntity(lightning);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected EntityDimensions getDefaultDimensions(Pose pose) {
|
||||
EntityDimensions dimensions = super.getDefaultDimensions(pose);
|
||||
return isTitan() ? dimensions.scale(TITAN_HITBOX_SCALE) : dimensions;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSyncedDataUpdated(EntityDataAccessor<?> key) {
|
||||
super.onSyncedDataUpdated(key);
|
||||
if (PHASE.equals(key)) this.refreshDimensions();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void addAdditionalSaveData(ValueOutput output) {
|
||||
super.addAdditionalSaveData(output);
|
||||
output.putInt(DATA_VERSION_KEY, ItsAliveMod.CURRENT_DATA_VERSION);
|
||||
output.putByte("NotchPhase", this.entityData.get(PHASE));
|
||||
output.putInt("NotchPhaseStartedTick", this.entityData.get(PHASE_STARTED_TICK));
|
||||
output.putFloat("NotchStoredHealth", this.getHealth());
|
||||
output.putFloat("NotchTitanMaximumHealth", this.titanMaximumHealth);
|
||||
output.putString(PARTICIPANTS_KEY, participants.stream().map(UUID::toString).collect(Collectors.joining(",")));
|
||||
output.putBoolean(LEGACY_REWARD_KEY, legacyRewardGranted);
|
||||
if (home != null) {
|
||||
output.putInt("NotchHomeX", home.getX());
|
||||
output.putInt("NotchHomeY", home.getY());
|
||||
output.putInt("NotchHomeZ", home.getZ());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void readAdditionalSaveData(ValueInput input) {
|
||||
super.readAdditionalSaveData(input);
|
||||
NotchFightRules.Phase phase = NotchFightRules.Phase.byId(input.getByteOr("NotchPhase", (byte)0));
|
||||
this.entityData.set(PHASE, (byte)phase.id());
|
||||
this.entityData.set(PHASE_STARTED_TICK, input.getIntOr("NotchPhaseStartedTick", 0));
|
||||
this.titanMaximumHealth = Mth.clamp(input.getFloatOr("NotchTitanMaximumHealth",
|
||||
NotchFightRules.MIN_TITAN_HEALTH), NotchFightRules.MIN_TITAN_HEALTH,
|
||||
NotchFightRules.MAX_TITAN_HEALTH);
|
||||
this.participants.clear();
|
||||
input.getString(PARTICIPANTS_KEY).ifPresent(value -> {
|
||||
for (String token : value.split(",")) {
|
||||
if (participants.size() >= NotchFightRules.MAX_PARTICIPANTS) break;
|
||||
try { participants.add(UUID.fromString(token)); } catch (IllegalArgumentException ignored) { }
|
||||
}
|
||||
});
|
||||
this.legacyRewardGranted = input.getBooleanOr(LEGACY_REWARD_KEY, false);
|
||||
if (input.getInt("NotchHomeX").isPresent() && input.getInt("NotchHomeY").isPresent()
|
||||
&& input.getInt("NotchHomeZ").isPresent()) {
|
||||
this.home = new BlockPos(input.getIntOr("NotchHomeX", 0), input.getIntOr("NotchHomeY", 0),
|
||||
input.getIntOr("NotchHomeZ", 0));
|
||||
}
|
||||
this.applyPhaseAttributes(false);
|
||||
this.setHealth(Mth.clamp(input.getFloatOr("NotchStoredHealth", this.getMaxHealth()),
|
||||
1.0F, this.getMaxHealth()));
|
||||
this.refreshDimensions();
|
||||
this.bossEvent.setVisible(isTitan());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void startSeenByPlayer(ServerPlayer player) {
|
||||
super.startSeenByPlayer(player);
|
||||
this.bossEvent.addPlayer(player);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stopSeenByPlayer(ServerPlayer player) {
|
||||
super.stopSeenByPlayer(player);
|
||||
this.bossEvent.removePlayer(player);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean removeWhenFarAway(double distance) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getMaxSpawnClusterSize() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
private final class BoundedStrollGoal extends WaterAvoidingRandomStrollGoal {
|
||||
private BoundedStrollGoal() {
|
||||
super(NotchEntity.this, 0.72D, 0.008F);
|
||||
}
|
||||
|
||||
@Override public boolean canUse() { return !NotchEntity.this.isTitan() && super.canUse(); }
|
||||
@Override public boolean canContinueToUse() { return !NotchEntity.this.isTitan() && super.canContinueToUse(); }
|
||||
}
|
||||
|
||||
private final class TitanCombatGoal extends Goal {
|
||||
private static final double STRAFE_DISTANCE_SQUARED = 18.0D * 18.0D;
|
||||
private int rangedCooldown;
|
||||
private int meleeCooldown;
|
||||
private int attackIndex;
|
||||
private int windup;
|
||||
private NotchCombatApi.Attack pendingAttack;
|
||||
private boolean clockwise;
|
||||
private int strafeTicks;
|
||||
|
||||
private TitanCombatGoal() {
|
||||
this.setFlags(EnumSet.of(Goal.Flag.MOVE, Goal.Flag.LOOK));
|
||||
}
|
||||
|
||||
@Override public boolean canUse() {
|
||||
LivingEntity target = NotchEntity.this.getTarget();
|
||||
return NotchEntity.this.isTitan() && target != null && target.isAlive();
|
||||
}
|
||||
|
||||
@Override public boolean canContinueToUse() { return canUse(); }
|
||||
|
||||
@Override public void start() {
|
||||
rangedCooldown = 45;
|
||||
meleeCooldown = 0;
|
||||
attackIndex = 0;
|
||||
windup = 0;
|
||||
pendingAttack = null;
|
||||
clockwise = NotchEntity.this.getRandom().nextBoolean();
|
||||
}
|
||||
|
||||
@Override public void stop() {
|
||||
NotchEntity.this.getNavigation().stop();
|
||||
pendingAttack = null;
|
||||
}
|
||||
|
||||
@Override public boolean requiresUpdateEveryTick() { return true; }
|
||||
|
||||
@Override public void tick() {
|
||||
LivingEntity target = NotchEntity.this.getTarget();
|
||||
if (target == null || !(NotchEntity.this.level() instanceof ServerLevel level)) return;
|
||||
double distanceSquared = NotchEntity.this.distanceToSqr(target);
|
||||
boolean canSee = NotchEntity.this.getSensing().hasLineOfSight(target);
|
||||
NotchEntity.this.getLookControl().setLookAt(target, 30.0F, 30.0F);
|
||||
if (!canSee || distanceSquared > STRAFE_DISTANCE_SQUARED) {
|
||||
NotchEntity.this.getNavigation().moveTo(target, 1.12D);
|
||||
} else {
|
||||
NotchEntity.this.getNavigation().stop();
|
||||
if (++strafeTicks >= 45) { strafeTicks = 0; clockwise = !clockwise; }
|
||||
NotchEntity.this.getMoveControl().strafe(distanceSquared < 100.0D ? -0.45F : 0.28F,
|
||||
clockwise ? 0.7F : -0.7F);
|
||||
}
|
||||
if (meleeCooldown > 0) meleeCooldown--;
|
||||
if (distanceSquared <= 64.0D && meleeCooldown == 0) {
|
||||
NotchCombatApi.execute(level, NotchEntity.this, target, NotchCombatApi.Attack.MELEE);
|
||||
meleeCooldown = 24;
|
||||
}
|
||||
if (pendingAttack != null) {
|
||||
if (--windup <= 0) {
|
||||
NotchCombatApi.execute(level, NotchEntity.this, target, pendingAttack);
|
||||
pendingAttack = null;
|
||||
rangedCooldown = 65 + NotchEntity.this.getRandom().nextInt(32);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (rangedCooldown > 0) rangedCooldown--;
|
||||
if (canSee && rangedCooldown == 0) {
|
||||
pendingAttack = attackIndex++ % 5 == 4
|
||||
? NotchCombatApi.Attack.LIGHTNING : NotchCombatApi.Attack.FIREBALL;
|
||||
windup = pendingAttack == NotchCombatApi.Attack.LIGHTNING ? 24 : 14;
|
||||
NotchCombatApi.telegraph(level, NotchEntity.this, target, pendingAttack);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package fr.koka99cab.sanctuary26.itsalive.entity.notch;
|
||||
|
||||
import net.minecraft.util.Mth;
|
||||
|
||||
/** Pure bounded rules shared by the live Notch entity and deterministic tests. */
|
||||
public final class NotchFightRules {
|
||||
public static final int MAX_PARTICIPANTS = 8;
|
||||
public static final int RESET_AFTER_EMPTY_TICKS = 20 * 15;
|
||||
public static final float HUMAN_HEALTH = 20.0F;
|
||||
public static final float MIN_TITAN_HEALTH = 360.0F;
|
||||
public static final float MAX_TITAN_HEALTH = 600.0F;
|
||||
public static final float RETURNED_FIREBALL_DAMAGE = 40.0F;
|
||||
|
||||
private NotchFightRules() {
|
||||
}
|
||||
|
||||
public static float titanHealth(int participantCount) {
|
||||
int bounded = Mth.clamp(participantCount, 1, MAX_PARTICIPANTS);
|
||||
return Math.min(MAX_TITAN_HEALTH, MIN_TITAN_HEALTH + (bounded - 1) * 40.0F);
|
||||
}
|
||||
|
||||
public static boolean shouldReset(int ticksWithoutParticipant) {
|
||||
return ticksWithoutParticipant >= RESET_AFTER_EMPTY_TICKS;
|
||||
}
|
||||
|
||||
public enum Phase {
|
||||
HUMAN(0),
|
||||
TITAN(1),
|
||||
DEFEATED(2);
|
||||
|
||||
private final int id;
|
||||
|
||||
Phase(int id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public int id() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public static Phase byId(int id) {
|
||||
return switch (id) {
|
||||
case 1 -> TITAN;
|
||||
case 2 -> DEFEATED;
|
||||
default -> HUMAN;
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
package fr.koka99cab.sanctuary26.itsalive.entity.notch;
|
||||
|
||||
import com.mojang.serialization.Codec;
|
||||
import com.mojang.serialization.codecs.RecordCodecBuilder;
|
||||
import fr.koka99cab.sanctuary26.itsalive.ItsAliveMod;
|
||||
import fr.koka99cab.sanctuary26.itsalive.registry.ItsAliveEntityTypes;
|
||||
import fr.koka99cab.sanctuary26.sanctuary.api.notch.SanctuaryNotchMilestone;
|
||||
import fr.koka99cab.sanctuary26.sanctuary.api.world.SanctuaryAlphaWorld;
|
||||
import fr.koka99cab.sanctuary26.sanctuary.world.SanctuaryDimensions;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.util.datafix.DataFixTypes;
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import net.minecraft.world.entity.EntitySpawnReason;
|
||||
import net.minecraft.world.level.saveddata.SavedData;
|
||||
import net.minecraft.world.level.saveddata.SavedDataType;
|
||||
|
||||
/** Maintains exactly one persistent Notch resident in the finite Alpha world. */
|
||||
public final class NotchResidentService {
|
||||
private static final int CHECK_INTERVAL = 40;
|
||||
private static boolean initialized;
|
||||
|
||||
private NotchResidentService() {
|
||||
}
|
||||
|
||||
public static void initialize() {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
ServerTickEvents.END_LEVEL_TICK.register(NotchResidentService::tick);
|
||||
ItsAliveMod.LOGGER.debug("[{}] Registered the unique Alpha resident service.",
|
||||
ItsAliveMod.DISPLAY_NAME);
|
||||
}
|
||||
|
||||
public static Inspection inspect(MinecraftServer server) {
|
||||
ServerLevel level = server.getLevel(SanctuaryDimensions.ALPHA);
|
||||
if (level == null) return Inspection.unavailable();
|
||||
SpawnState state = level.getDataStorage().computeIfAbsent(SpawnState.TYPE);
|
||||
List<NotchEntity> residents = loadedResidents(level);
|
||||
NotchEntity canonical = selectCanonical(residents, state.canonicalUuid()).orElse(null);
|
||||
return new Inspection(true, residents.size(), canonical == null ? null : canonical.getUUID(),
|
||||
canonical == null ? null : canonical.phase(), canonical == null ? null : canonical.blockPosition(),
|
||||
SanctuaryNotchMilestone.isDefeated(server));
|
||||
}
|
||||
|
||||
/** Force-loads the house chunk, removes duplicates and restores the canonical resident. */
|
||||
public static Inspection restore(MinecraftServer server) {
|
||||
ServerLevel level = server.getLevel(SanctuaryDimensions.ALPHA);
|
||||
if (level == null) return Inspection.unavailable();
|
||||
BlockPos home = SanctuaryAlphaWorld.residence(level.getSeed());
|
||||
level.getChunkAt(home);
|
||||
reconcile(level, true);
|
||||
return inspect(server);
|
||||
}
|
||||
|
||||
private static void tick(ServerLevel level) {
|
||||
if (!level.dimension().equals(SanctuaryDimensions.ALPHA)
|
||||
|| level.players().isEmpty()
|
||||
|| level.getGameTime() % CHECK_INTERVAL != 0) return;
|
||||
reconcile(level, false);
|
||||
}
|
||||
|
||||
private static void reconcile(ServerLevel level, boolean forceHome) {
|
||||
BlockPos home = SanctuaryAlphaWorld.residence(level.getSeed());
|
||||
if (!forceHome && !level.isLoaded(home)) return;
|
||||
SpawnState state = level.getDataStorage().computeIfAbsent(SpawnState.TYPE);
|
||||
List<NotchEntity> residents = loadedResidents(level);
|
||||
NotchEntity canonical = selectCanonical(residents, state.canonicalUuid()).orElse(null);
|
||||
if (canonical == null) {
|
||||
canonical = ItsAliveEntityTypes.NOTCH.create(level, EntitySpawnReason.EVENT);
|
||||
if (canonical == null) return;
|
||||
canonical.initializeAtHome(home, SanctuaryNotchMilestone.isDefeated(level.getServer()));
|
||||
if (!level.addFreshEntity(canonical)) return;
|
||||
residents = List.of(canonical);
|
||||
ItsAliveMod.LOGGER.info("[{}] Placed Notch in the Alpha residence at {}.",
|
||||
ItsAliveMod.DISPLAY_NAME, home);
|
||||
}
|
||||
for (NotchEntity resident : residents) {
|
||||
if (resident != canonical) resident.discard();
|
||||
}
|
||||
state.setCanonicalUuid(canonical.getUUID());
|
||||
if (canonical.isDefeated() && !SanctuaryNotchMilestone.isDefeated(level.getServer())) {
|
||||
SanctuaryNotchMilestone.migrateLegacyVictory(level, canonical.legacyRewardGranted());
|
||||
}
|
||||
boolean defeated = SanctuaryNotchMilestone.isDefeated(level.getServer());
|
||||
if (forceHome || defeated != canonical.isDefeated()) canonical.restoreAtHome(home, defeated);
|
||||
}
|
||||
|
||||
private static List<NotchEntity> loadedResidents(ServerLevel level) {
|
||||
List<NotchEntity> residents = new ArrayList<>();
|
||||
for (Entity entity : level.getAllEntities()) {
|
||||
if (entity instanceof NotchEntity notch && !notch.isRemoved()) residents.add(notch);
|
||||
}
|
||||
return residents;
|
||||
}
|
||||
|
||||
private static Optional<NotchEntity> selectCanonical(List<NotchEntity> residents, UUID storedUuid) {
|
||||
return residents.stream().min(Comparator
|
||||
.comparing((NotchEntity notch) -> !notch.getUUID().equals(storedUuid))
|
||||
.thenComparing(notch -> notch.getType() != ItsAliveEntityTypes.NOTCH)
|
||||
.thenComparing(notch -> notch.getUUID().toString()));
|
||||
}
|
||||
|
||||
public record Inspection(boolean alphaAvailable, int loadedResidents, UUID canonicalUuid,
|
||||
NotchFightRules.Phase phase, BlockPos position, boolean worldDefeated) {
|
||||
private static Inspection unavailable() {
|
||||
return new Inspection(false, 0, null, null, null, false);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class SpawnState extends SavedData {
|
||||
private static final Codec<SpawnState> CODEC = RecordCodecBuilder.create(instance -> instance.group(
|
||||
Codec.INT.optionalFieldOf("data_version", ItsAliveMod.CURRENT_DATA_VERSION)
|
||||
.forGetter(state -> state.dataVersion),
|
||||
Codec.STRING.optionalFieldOf("canonical_uuid", "").forGetter(state -> state.canonicalUuid)
|
||||
).apply(instance, SpawnState::new));
|
||||
private static final SavedDataType<SpawnState> TYPE = new SavedDataType<>(
|
||||
ItsAliveMod.id("alpha_notch_resident"), SpawnState::new, CODEC, DataFixTypes.LEVEL);
|
||||
private int dataVersion;
|
||||
private String canonicalUuid;
|
||||
|
||||
private SpawnState() {
|
||||
this(ItsAliveMod.CURRENT_DATA_VERSION, "");
|
||||
}
|
||||
|
||||
private SpawnState(int dataVersion, String canonicalUuid) {
|
||||
this.dataVersion = Math.max(1, dataVersion);
|
||||
this.canonicalUuid = canonicalUuid == null ? "" : canonicalUuid;
|
||||
if (this.dataVersion < ItsAliveMod.CURRENT_DATA_VERSION) {
|
||||
this.dataVersion = ItsAliveMod.CURRENT_DATA_VERSION;
|
||||
setDirty();
|
||||
}
|
||||
}
|
||||
|
||||
private UUID canonicalUuid() {
|
||||
try {
|
||||
return UUID.fromString(canonicalUuid);
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
return new UUID(0L, 0L);
|
||||
}
|
||||
}
|
||||
|
||||
private void setCanonicalUuid(UUID uuid) {
|
||||
String value = uuid.toString();
|
||||
if (value.equals(canonicalUuid)) return;
|
||||
canonicalUuid = value;
|
||||
setDirty();
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
-1
@@ -9,6 +9,7 @@ import fr.koka99cab.sanctuary26.itsalive.entity.FlyingWhaleEntity;
|
||||
import fr.koka99cab.sanctuary26.itsalive.entity.GemChickenEntity;
|
||||
import fr.koka99cab.sanctuary26.itsalive.entity.InfectedEntity;
|
||||
import fr.koka99cab.sanctuary26.itsalive.entity.MoobloomEntity;
|
||||
import fr.koka99cab.sanctuary26.itsalive.entity.notch.NotchEntity;
|
||||
import java.util.function.Predicate;
|
||||
import net.fabricmc.fabric.api.biome.v1.BiomeModifications;
|
||||
import net.fabricmc.fabric.api.biome.v1.BiomeSelectionContext;
|
||||
@@ -44,6 +45,8 @@ public final class ItsAliveEntityTypes {
|
||||
public static final Identifier ACID_GLOB_ID = ItsAliveMod.id("acid_glob");
|
||||
public static final Identifier MOOBLOOM_ID = ItsAliveMod.id("moobloom");
|
||||
public static final Identifier GEM_CHICKEN_ID = ItsAliveMod.id("gem_chicken");
|
||||
public static final Identifier NOTCH_ID = ItsAliveMod.id("notch");
|
||||
public static final Identifier LEGACY_NOTCH_ID = Identifier.fromNamespaceAndPath("portals", "notch");
|
||||
|
||||
public static final EntityType<ButterflyEntity> BUTTERFLY = Registry.register(
|
||||
BuiltInRegistries.ENTITY_TYPE,
|
||||
@@ -167,6 +170,10 @@ public final class ItsAliveEntityTypes {
|
||||
.build(ResourceKey.create(Registries.ENTITY_TYPE, GEM_CHICKEN_ID))
|
||||
);
|
||||
|
||||
public static final EntityType<NotchEntity> NOTCH = registerNotch(NOTCH_ID);
|
||||
/** Hidden alias used only to deserialize Notch entities written by the historical Portals prototype. */
|
||||
public static final EntityType<NotchEntity> LEGACY_NOTCH = registerNotch(LEGACY_NOTCH_ID);
|
||||
|
||||
private ItsAliveEntityTypes() {
|
||||
}
|
||||
|
||||
@@ -192,7 +199,19 @@ public final class ItsAliveEntityTypes {
|
||||
entity.discard();
|
||||
}
|
||||
});
|
||||
ItsAliveMod.LOGGER.info("[{}] Registered papillon, 16-variant moobloom, three gem chickens, ghost, bracken, flying whale and five-role infected; vanilla phantoms disabled.",
|
||||
ItsAliveMod.LOGGER.info("[{}] Registered papillon, 16-variant moobloom, three gem chickens, ghost, bracken, flying whale, five-role infected and the unique Alpha resident; vanilla phantoms disabled.",
|
||||
ItsAliveMod.DISPLAY_NAME);
|
||||
}
|
||||
|
||||
private static EntityType<NotchEntity> registerNotch(Identifier id) {
|
||||
return Registry.register(BuiltInRegistries.ENTITY_TYPE, id,
|
||||
FabricEntityType.Builder.createMob(NotchEntity::new, MobCategory.CREATURE,
|
||||
mob -> mob.defaultAttributes(NotchEntity::createAttributes))
|
||||
.sized(0.6F, 1.8F)
|
||||
.clientTrackingRange(96)
|
||||
.updateInterval(2)
|
||||
.noSummon()
|
||||
.noLootTable()
|
||||
.build(ResourceKey.create(Registries.ENTITY_TYPE, id)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,5 +288,7 @@
|
||||
"hud.itsalive.cooking_cauldron.assembly": "Mixture incomplete: %s",
|
||||
"hud.itsalive.cooking_cauldron.cooking": "Cooking: %s",
|
||||
"hud.itsalive.cooking_cauldron.expired": "Mixture failed — collect the failed dish",
|
||||
"hud.itsalive.cooking_cauldron.ready": "Dish ready"
|
||||
"hud.itsalive.cooking_cauldron.ready": "Dish ready",
|
||||
"entity.itsalive.notch": "Notch",
|
||||
"boss.itsalive.notch_titan": "Notch Titan"
|
||||
}
|
||||
|
||||
@@ -288,5 +288,7 @@
|
||||
"hud.itsalive.cooking_cauldron.assembly": "Mélange incomplet : %s",
|
||||
"hud.itsalive.cooking_cauldron.cooking": "Cuisson : %s",
|
||||
"hud.itsalive.cooking_cauldron.expired": "Plat raté — récupère le résultat",
|
||||
"hud.itsalive.cooking_cauldron.ready": "Plat prêt"
|
||||
"hud.itsalive.cooking_cauldron.ready": "Plat prêt",
|
||||
"entity.itsalive.notch": "Notch",
|
||||
"boss.itsalive.notch_titan": "Notch Titan"
|
||||
}
|
||||
|
||||
@@ -288,5 +288,7 @@
|
||||
"hud.itsalive.cooking_cauldron.assembly": "Mixture incomplete: %s",
|
||||
"hud.itsalive.cooking_cauldron.cooking": "Cooking: %s",
|
||||
"hud.itsalive.cooking_cauldron.expired": "Mixture failed — collect the failed dish",
|
||||
"hud.itsalive.cooking_cauldron.ready": "Dish ready"
|
||||
"hud.itsalive.cooking_cauldron.ready": "Dish ready",
|
||||
"entity.itsalive.notch": "Нотч",
|
||||
"boss.itsalive.notch_titan": "Нотч-Титан"
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
@@ -38,13 +38,13 @@
|
||||
"fabric-api": ">=0.154.2+26.2",
|
||||
"anotherworld": "*",
|
||||
"ambiance": ">=0.0.0-alpha.14",
|
||||
"sanctuary": ">=0.0.0-alpha.117"
|
||||
"sanctuary": ">=0.0.0-alpha.126"
|
||||
},
|
||||
"custom": {
|
||||
"sanctuary26": {
|
||||
"system_type": "gameplay-system",
|
||||
"namespace": "itsalive",
|
||||
"data_version": 3,
|
||||
"data_version": 4,
|
||||
"network_protocol": 4,
|
||||
"lifecycle": "${lifecycle}",
|
||||
"pack_version": "${pack_version}",
|
||||
|
||||
+16
@@ -61,6 +61,22 @@ public final class CanapliaGameplaySmoke {
|
||||
Set.of(), Temperature.COLD, Humidity.BALANCED, 12, false, false, true, true, false)).isEmpty(),
|
||||
"lunar harvest requires a sky dimension");
|
||||
|
||||
for (CanapliaVariantKey variant : CanapliaVariantKey.values()) {
|
||||
CanapliaVariantKey placed = CanapliaVariantLifecycle.plantedFromSeed(variant);
|
||||
assertEquals(variant, placed,
|
||||
"historical seed placement variant " + variant.key());
|
||||
CanapliaVariantKey grown = CanapliaVariantLifecycle.afterGrowth(placed);
|
||||
assertEquals(variant, grown,
|
||||
"natural growth variant " + variant.key());
|
||||
CanapliaVariantKey persisted = CanapliaVariantLifecycle.fromPersistedKey(
|
||||
CanapliaVariantLifecycle.persistedKey(grown));
|
||||
assertEquals(variant, persisted, "persisted variant " + variant.key());
|
||||
assertEquals(variant, CanapliaVariantLifecycle.upperFromLower(persisted),
|
||||
"upper crop variant " + variant.key());
|
||||
assertEquals(variant, CanapliaVariantLifecycle.harvestedFromPlant(persisted),
|
||||
"harvest variant " + variant.key());
|
||||
}
|
||||
|
||||
CanapliaEffectRules.Plan minimum = CanapliaEffectRules.plan(0, 1, 0, 0);
|
||||
assertEquals(CanapliaEffectRules.MIN_PRIMARY_TICKS, minimum.primaryTicks(), "minimum primary duration");
|
||||
assertTrue(!minimum.badTrip(), "nonzero bad-trip roll");
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package fr.koka99cab.sanctuary26.itsalive.entity.notch;
|
||||
|
||||
public final class NotchFightRulesSmoke {
|
||||
private NotchFightRulesSmoke() {
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
assertFloat(NotchFightRules.MIN_TITAN_HEALTH, NotchFightRules.titanHealth(-20),
|
||||
"health below one participant");
|
||||
assertFloat(400.0F, NotchFightRules.titanHealth(2), "two participant scaling");
|
||||
assertFloat(NotchFightRules.MAX_TITAN_HEALTH,
|
||||
NotchFightRules.titanHealth(NotchFightRules.MAX_PARTICIPANTS), "maximum scaling");
|
||||
assertFloat(NotchFightRules.MAX_TITAN_HEALTH, NotchFightRules.titanHealth(200),
|
||||
"participant bound");
|
||||
if (NotchFightRules.shouldReset(NotchFightRules.RESET_AFTER_EMPTY_TICKS - 1)) {
|
||||
throw new AssertionError("Fight reset one tick too early");
|
||||
}
|
||||
if (!NotchFightRules.shouldReset(NotchFightRules.RESET_AFTER_EMPTY_TICKS)) {
|
||||
throw new AssertionError("Fight did not reset at the documented threshold");
|
||||
}
|
||||
if (NotchFightRules.RETURNED_FIREBALL_DAMAGE != 40.0F
|
||||
|| NotchFightRules.Phase.byId(-1) != NotchFightRules.Phase.HUMAN
|
||||
|| NotchFightRules.Phase.byId(1) != NotchFightRules.Phase.TITAN
|
||||
|| NotchFightRules.Phase.byId(2) != NotchFightRules.Phase.DEFEATED) {
|
||||
throw new AssertionError("Notch phase or reflected-fireball contract changed");
|
||||
}
|
||||
}
|
||||
|
||||
private static void assertFloat(float expected, float actual, String label) {
|
||||
if (Float.compare(expected, actual) != 0) {
|
||||
throw new AssertionError(label + ": expected " + expected + ", got " + actual);
|
||||
}
|
||||
}
|
||||
}
|
||||
+38
@@ -16,6 +16,7 @@ import fr.koka99cab.sanctuary26.itsalive.creeper.ItsAliveCreeper;
|
||||
import fr.koka99cab.sanctuary26.itsalive.creeper.ItsAliveCreepers;
|
||||
import fr.koka99cab.sanctuary26.itsalive.entity.InfectedEntity;
|
||||
import fr.koka99cab.sanctuary26.itsalive.entity.InfectedVariant;
|
||||
import fr.koka99cab.sanctuary26.itsalive.entity.notch.NotchResidentService;
|
||||
import fr.koka99cab.sanctuary26.itsalive.registry.ItsAliveEntityTypes;
|
||||
import fr.koka99cab.sanctuary26.ambiance.environment.AmbianceDailyWeather;
|
||||
import fr.koka99cab.sanctuary26.ambiance.environment.AmbianceEnvironmentController;
|
||||
@@ -94,6 +95,7 @@ public final class MasterKeyCommands {
|
||||
.then(creeperCommand())
|
||||
.then(infectedCommand())
|
||||
.then(ambianceCommand())
|
||||
.then(notchCommand())
|
||||
.then(SanctuaryProgressionCommands.command())
|
||||
.then(panoramaCommand())
|
||||
.then(teleportCommand())
|
||||
@@ -113,6 +115,42 @@ public final class MasterKeyCommands {
|
||||
);
|
||||
}
|
||||
|
||||
private static com.mojang.brigadier.builder.ArgumentBuilder<CommandSourceStack, ?> notchCommand() {
|
||||
return Commands.literal("notch")
|
||||
.executes(context -> inspectNotch(context.getSource()))
|
||||
.then(Commands.literal("inspect")
|
||||
.executes(context -> inspectNotch(context.getSource())))
|
||||
.then(Commands.literal("restore")
|
||||
.executes(context -> restoreNotch(context.getSource())));
|
||||
}
|
||||
|
||||
private static int inspectNotch(CommandSourceStack source) {
|
||||
NotchResidentService.Inspection inspection = NotchResidentService.inspect(source.getServer());
|
||||
if (!inspection.alphaAvailable()) {
|
||||
source.sendFailure(Component.translatable("command.masterkey.notch.alpha_unavailable"));
|
||||
return 0;
|
||||
}
|
||||
source.sendSuccess(() -> Component.translatable("command.masterkey.notch.inspect",
|
||||
inspection.loadedResidents(),
|
||||
inspection.canonicalUuid() == null ? "-" : inspection.canonicalUuid().toString(),
|
||||
inspection.phase() == null ? "-" : inspection.phase().name().toLowerCase(java.util.Locale.ROOT),
|
||||
inspection.position() == null ? "-" : inspection.position().toShortString(),
|
||||
inspection.worldDefeated()), false);
|
||||
return Command.SINGLE_SUCCESS;
|
||||
}
|
||||
|
||||
private static int restoreNotch(CommandSourceStack source) {
|
||||
NotchResidentService.Inspection inspection = NotchResidentService.restore(source.getServer());
|
||||
if (!inspection.alphaAvailable() || inspection.canonicalUuid() == null) {
|
||||
source.sendFailure(Component.translatable("command.masterkey.notch.restore.failure"));
|
||||
return 0;
|
||||
}
|
||||
source.sendSuccess(() -> Component.translatable("command.masterkey.notch.restore.success",
|
||||
inspection.canonicalUuid().toString(), inspection.loadedResidents(),
|
||||
inspection.phase().name().toLowerCase(java.util.Locale.ROOT)), true);
|
||||
return Command.SINGLE_SUCCESS;
|
||||
}
|
||||
|
||||
private static com.mojang.brigadier.builder.ArgumentBuilder<CommandSourceStack, ?> ambianceCommand() {
|
||||
var root = Commands.literal("ambiance")
|
||||
.executes(context -> ambianceStatus(context.getSource()))
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
{
|
||||
"mod.masterkey.description": "Owns administration, diagnostics and developer tools.",
|
||||
"command.masterkey.help": "MasterKey lab: give, spawn, creeper, infected, ambiance, progression, panorama, teleport, dump, debug.",
|
||||
"command.masterkey.help": "MasterKey lab: give, spawn, creeper, infected, ambiance, notch, progression, panorama, teleport, dump, debug.",
|
||||
"command.masterkey.notch.alpha_unavailable": "The Alpha dimension is not loaded.",
|
||||
"command.masterkey.notch.inspect": "Notch: %1$s loaded resident(s), UUID %2$s, phase %3$s, position %4$s, world victory: %5$s.",
|
||||
"command.masterkey.notch.restore.success": "Notch restored: UUID %1$s, %2$s loaded resident, phase %3$s.",
|
||||
"command.masterkey.notch.restore.failure": "Notch could not be restored in Alpha.",
|
||||
"command.masterkey.ambiance.status": "Weather: %1$s; lunar night: %2$s (%3$s).",
|
||||
"command.masterkey.ambiance.mode.auto": "automatic",
|
||||
"command.masterkey.ambiance.mode.forced": "forced",
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
{
|
||||
"mod.masterkey.description": "Possède l'administration, les diagnostics et les outils de développement.",
|
||||
"command.masterkey.help": "Laboratoire MasterKey : give, spawn, creeper, infected, ambiance, progression, panorama, teleport, dump, debug.",
|
||||
"command.masterkey.help": "Laboratoire MasterKey : give, spawn, creeper, infected, ambiance, notch, progression, panorama, teleport, dump, debug.",
|
||||
"command.masterkey.notch.alpha_unavailable": "La dimension Alpha n'est pas chargée.",
|
||||
"command.masterkey.notch.inspect": "Notch : %1$s résident(s) chargé(s), UUID %2$s, phase %3$s, position %4$s, victoire mondiale : %5$s.",
|
||||
"command.masterkey.notch.restore.success": "Notch restauré : UUID %1$s, %2$s résident chargé, phase %3$s.",
|
||||
"command.masterkey.notch.restore.failure": "Impossible de restaurer Notch dans Alpha.",
|
||||
"command.masterkey.ambiance.status": "Météo : %1$s ; nuit lunaire : %2$s (%3$s).",
|
||||
"command.masterkey.ambiance.mode.auto": "automatique",
|
||||
"command.masterkey.ambiance.mode.forced": "forcée",
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
{
|
||||
"mod.masterkey.description": "Отвечает за администрирование, диагностику и инструменты разработчика.",
|
||||
"command.masterkey.help": "Лаборатория MasterKey: give, spawn, creeper, infected, ambiance, progression, panorama, teleport, dump, debug.",
|
||||
"command.masterkey.help": "Лаборатория MasterKey: give, spawn, creeper, infected, ambiance, notch, progression, panorama, teleport, dump, debug.",
|
||||
"command.masterkey.notch.alpha_unavailable": "Измерение Alpha не загружено.",
|
||||
"command.masterkey.notch.inspect": "Notch: загружено жителей — %1$s, UUID %2$s, фаза %3$s, позиция %4$s, мировая победа: %5$s.",
|
||||
"command.masterkey.notch.restore.success": "Notch восстановлен: UUID %1$s, загружено жителей — %2$s, фаза %3$s.",
|
||||
"command.masterkey.notch.restore.failure": "Не удалось восстановить Notch в Alpha.",
|
||||
"command.masterkey.ambiance.status": "Погода: %1$s; лунная ночь: %2$s (%3$s).",
|
||||
"command.masterkey.ambiance.mode.auto": "автоматически",
|
||||
"command.masterkey.ambiance.mode.forced": "принудительно",
|
||||
|
||||
@@ -23,8 +23,8 @@
|
||||
"minecraft": "~26.2",
|
||||
"java": ">=25",
|
||||
"fabric-api": ">=0.154.2+26.2",
|
||||
"itsalive": ">=0.0.0-alpha.1",
|
||||
"sanctuary": ">=0.0.0-alpha.17",
|
||||
"itsalive": ">=0.0.0-alpha.37",
|
||||
"sanctuary": ">=0.0.0-alpha.126",
|
||||
"ambiance": "*"
|
||||
},
|
||||
"custom": {
|
||||
|
||||
@@ -48,7 +48,7 @@ public final class OnlyFunMod implements ModInitializer {
|
||||
OnlyFunCommands.register();
|
||||
AbjectionActions.initialize();
|
||||
AbjectionCommands.register();
|
||||
LOGGER.info("[{}] Loot generator, natural Lucky Blocks, mob lootboxes and chicken rules ready (data v{}, network v{}).",
|
||||
LOGGER.info("[{}] Loot generator, boss-gated Shop, natural Lucky Blocks, mob lootboxes and chicken rules ready (data v{}, network v{}).",
|
||||
DISPLAY_NAME, CURRENT_DATA_VERSION, OnlyFunNetworking.PROTOCOL_VERSION);
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -2,6 +2,7 @@ package fr.koka99cab.sanctuary26.onlyfun.loot;
|
||||
|
||||
import fr.koka99cab.sanctuary26.onlyfun.OnlyFunMod;
|
||||
import fr.koka99cab.sanctuary26.onlyfun.registry.OnlyFunRegistries;
|
||||
import fr.koka99cab.sanctuary26.sanctuary.api.entity.SanctuaryEntityPolicy;
|
||||
import net.fabricmc.fabric.api.entity.event.v1.ServerLivingEntityEvents;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
@@ -38,7 +39,8 @@ public final class OnlyFunLootboxDrops {
|
||||
}
|
||||
|
||||
private static void afterDeath(LivingEntity entity, DamageSource source) {
|
||||
if (!(entity instanceof Mob mob) || !(entity.level() instanceof ServerLevel level)) return;
|
||||
if (SanctuaryEntityPolicy.isTemporaryCompanion(entity)
|
||||
|| !(entity instanceof Mob mob) || !(entity.level() instanceof ServerLevel level)) return;
|
||||
ServerPlayer killer = playerKiller(entity, source);
|
||||
if (killer == null || killer.isSpectator()) return;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+12
-17
@@ -1,29 +1,22 @@
|
||||
package fr.koka99cab.sanctuary26.onlyfun.shop;
|
||||
|
||||
import java.util.Objects;
|
||||
import fr.koka99cab.sanctuary26.sanctuary.api.notch.SanctuaryNotchMilestone;
|
||||
import fr.koka99cab.sanctuary26.sanctuary.api.progression.SanctuaryWorldMilestones;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
|
||||
/** Keeps exceptional boss rewards out of the Shop until their future boss owner unlocks them. */
|
||||
/** Keeps exceptional boss rewards out of the Shop until their public world milestone is reached. */
|
||||
public final class OnlyFunBossShopGate {
|
||||
public static final Identifier MAGIC_CLOUD = Identifier.fromNamespaceAndPath("iliketomoveit", "magic_cloud");
|
||||
public static final Identifier MAGIC_CARPET = Identifier.fromNamespaceAndPath("iliketomoveit", "magic_carpet");
|
||||
private static final Set<Identifier> BOSS_REWARDS = Set.of(MAGIC_CLOUD, MAGIC_CARPET);
|
||||
private static final BossUnlockResolver LOCKED = (server, reward) -> false;
|
||||
private static volatile BossUnlockResolver resolver = LOCKED;
|
||||
private static final Map<Identifier, Identifier> IMPLEMENTED_MILESTONES = Map.of(
|
||||
MAGIC_CARPET, SanctuaryNotchMilestone.NOTCH_DEFEATED);
|
||||
|
||||
private OnlyFunBossShopGate() {}
|
||||
|
||||
/** Called later by the module that owns the bosses and their persistent defeat state. */
|
||||
public static synchronized void registerUnlockResolver(BossUnlockResolver value) {
|
||||
Objects.requireNonNull(value, "value");
|
||||
if (resolver != LOCKED && resolver != value) {
|
||||
throw new IllegalStateException("A boss Shop unlock resolver is already registered");
|
||||
}
|
||||
resolver = value;
|
||||
}
|
||||
|
||||
public static boolean requiresBoss(String itemId) {
|
||||
Identifier id = Identifier.tryParse(itemId);
|
||||
return id != null && BOSS_REWARDS.contains(id);
|
||||
@@ -31,11 +24,13 @@ public final class OnlyFunBossShopGate {
|
||||
|
||||
public static boolean isAvailable(MinecraftServer server, String itemId) {
|
||||
Identifier id = Identifier.tryParse(itemId);
|
||||
return id == null || !BOSS_REWARDS.contains(id) || resolver.isUnlocked(server, id);
|
||||
if (id == null || !BOSS_REWARDS.contains(id)) return true;
|
||||
Identifier milestone = requiredMilestone(id);
|
||||
return milestone != null && SanctuaryWorldMilestones.isReached(server, milestone);
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
public interface BossUnlockResolver {
|
||||
boolean isUnlocked(MinecraftServer server, Identifier rewardItem);
|
||||
/** Returns null while the corresponding boss contract is not implemented. */
|
||||
public static Identifier requiredMilestone(Identifier rewardItem) {
|
||||
return IMPLEMENTED_MILESTONES.get(rewardItem);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,8 +26,8 @@
|
||||
"minecraft": "~26.2",
|
||||
"java": ">=25",
|
||||
"fabric-api": ">=0.154.2+26.2",
|
||||
"iliketomoveit": "*",
|
||||
"sanctuary": "*"
|
||||
"iliketomoveit": ">=0.0.0-alpha.17",
|
||||
"sanctuary": ">=0.0.0-alpha.129"
|
||||
},
|
||||
"custom": {
|
||||
"sanctuary26": {
|
||||
|
||||
+14
-6
@@ -3,6 +3,7 @@ package fr.koka99cab.sanctuary26.onlyfun.shop;
|
||||
import fr.koka99cab.sanctuary26.onlyfun.policy.OnlyFunItemPolicy;
|
||||
import fr.koka99cab.sanctuary26.sanctuary.shop.ShopOffer;
|
||||
import fr.koka99cab.sanctuary26.sanctuary.shop.ShopOfferCatalog;
|
||||
import fr.koka99cab.sanctuary26.sanctuary.api.notch.SanctuaryNotchMilestone;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
@@ -81,18 +82,25 @@ 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"),
|
||||
"Magic Cloud must remain boss-gated");
|
||||
require(!OnlyFunBossShopGate.isAvailable(null, "iliketomoveit:magic_carpet")
|
||||
&& !OnlyFunBossShopGate.isAvailable(null, "iliketomoveit:magic_cloud"),
|
||||
"Boss rewards must be unavailable while their bosses do not exist");
|
||||
OnlyFunBossShopGate.registerUnlockResolver((server, reward) ->
|
||||
reward.equals(OnlyFunBossShopGate.MAGIC_CARPET));
|
||||
require(OnlyFunBossShopGate.isAvailable(null, "iliketomoveit:magic_carpet")
|
||||
&& !OnlyFunBossShopGate.isAvailable(null, "iliketomoveit:magic_cloud"),
|
||||
"Each future boss must unlock only its corresponding reward");
|
||||
"Boss rewards must reject lookups without authoritative server state");
|
||||
require(SanctuaryNotchMilestone.NOTCH_DEFEATED.equals(
|
||||
OnlyFunBossShopGate.requiredMilestone(OnlyFunBossShopGate.MAGIC_CARPET)),
|
||||
"Magic Carpet must resolve the public Notch milestone");
|
||||
require(OnlyFunBossShopGate.requiredMilestone(OnlyFunBossShopGate.MAGIC_CLOUD) == null,
|
||||
"Magic Cloud must stay locked until its own boss is implemented");
|
||||
|
||||
for (String protectedId : List.of(
|
||||
"anotherworld:ruby", "anotherworld:ruby_block", "anotherworld:deepslate_ruby_ore",
|
||||
|
||||
+10
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
Ouch possède le combat, les dégâts et la destruction dans Sanctuary 26.2.
|
||||
|
||||
## Version courante — `0.0.0-alpha.5`
|
||||
## Version courante — `0.0.0-alpha.11`
|
||||
|
||||
La première version active migre les quatre objets du mod historique Gunz dans
|
||||
le namespace unique `ouch:*` et les rassemble dans l’onglet créatif
|
||||
@@ -79,6 +79,15 @@ n’ajoutent ni payload réseau, ni donnée persistante, ni munition enregistré
|
||||
L’Arc bêta réutilise volontairement l’apparence statique du bow vanilla et se
|
||||
fabrique sans forme avec un arc, une redstone et une ficelle.
|
||||
|
||||
## Combat de Notch Titan
|
||||
|
||||
Ouch implémente le pont public de combat exposé par It's Alive. Le Titan poursuit
|
||||
ses participants, annonce visuellement ses attaques puis alterne mêlée, boules de
|
||||
feu renvoyables et éclairs visuels avec dégâts serveur. Une boule renvoyée par un
|
||||
joueur inflige 40 dégâts au Titan. Les explosions de projectiles utilisent
|
||||
`ExplosionInteraction.NONE` et les autres attaques ne modifient aucun bloc ;
|
||||
seule la transformation initiale, pilotée par l'entité, peut endommager le décor.
|
||||
|
||||
## Compatibilité
|
||||
|
||||
Les anciens IDs `gunz:*` ne sont pas réenregistrés. Le contrat
|
||||
|
||||
+1
-1
@@ -118,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.10"
|
||||
if (project.version.toString() != "0.0.0-alpha.11"
|
||||
|| 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"]
|
||||
|
||||
@@ -4,6 +4,7 @@ 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 fr.koka99cab.sanctuary26.ouch.notch.OuchNotchCombat;
|
||||
import net.fabricmc.api.ClientModInitializer;
|
||||
import net.fabricmc.fabric.api.client.rendering.v1.EntityRendererRegistry;
|
||||
import net.minecraft.client.renderer.entity.EntityRenderer;
|
||||
@@ -16,6 +17,8 @@ public final class OuchClient implements ClientModInitializer {
|
||||
public void onInitializeClient() {
|
||||
EntityRendererRegistry.register(OuchAbjectionProjectiles.ENTITY,
|
||||
context -> new ThrownItemRenderer<>(context));
|
||||
EntityRendererRegistry.register(OuchNotchCombat.FIREBALL,
|
||||
context -> new ThrownItemRenderer<>(context, 3.0F, true));
|
||||
registerWeatherRenderer(OuchWeatherTnt.ENTITY);
|
||||
registerWeatherRenderer(OuchWeatherTnt.REDSTONER_COMPAT_ENTITY);
|
||||
registerWeatherRenderer(OuchWeatherTnt.LEGACY_ENTITY);
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package fr.koka99cab.sanctuary26.ouch.entity;
|
||||
|
||||
import fr.koka99cab.sanctuary26.itsalive.entity.notch.NotchEntity;
|
||||
import fr.koka99cab.sanctuary26.itsalive.entity.notch.NotchFightRules;
|
||||
import fr.koka99cab.sanctuary26.ouch.notch.OuchNotchCombat;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import net.minecraft.world.entity.EntityType;
|
||||
import net.minecraft.world.entity.LivingEntity;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.entity.projectile.hurtingprojectile.Fireball;
|
||||
import net.minecraft.world.item.enchantment.EnchantmentHelper;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.minecraft.world.phys.EntityHitResult;
|
||||
import net.minecraft.world.phys.HitResult;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
|
||||
/** Large, reflectable Titan fireball whose explosion never changes blocks. */
|
||||
public final class NotchFireballEntity extends Fireball {
|
||||
private static final int MAX_LIFETIME_TICKS = 200;
|
||||
private static final float DIRECT_HIT_DAMAGE = 10.0F;
|
||||
private static final float EXPLOSION_POWER = 2.0F;
|
||||
|
||||
public NotchFireballEntity(EntityType<? extends NotchFireballEntity> type, Level level) {
|
||||
super(type, level);
|
||||
}
|
||||
|
||||
public NotchFireballEntity(ServerLevel level, LivingEntity owner, Vec3 direction) {
|
||||
super(OuchNotchCombat.FIREBALL, owner, direction, level);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void tick() {
|
||||
super.tick();
|
||||
if (this.tickCount > MAX_LIFETIME_TICKS) this.discard();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onHit(HitResult hitResult) {
|
||||
super.onHit(hitResult);
|
||||
if (!(this.level() instanceof ServerLevel level)) return;
|
||||
level.explode(this, this.getX(), this.getY(), this.getZ(), EXPLOSION_POWER, false,
|
||||
Level.ExplosionInteraction.NONE);
|
||||
this.discard();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onHitEntity(EntityHitResult hitResult) {
|
||||
super.onHitEntity(hitResult);
|
||||
if (!(this.level() instanceof ServerLevel level)) return;
|
||||
Entity target = hitResult.getEntity();
|
||||
Entity owner = this.getOwner();
|
||||
if (target instanceof NotchEntity && owner instanceof NotchEntity) return;
|
||||
var source = this.damageSources().fireball(this, owner);
|
||||
float damage = target instanceof NotchEntity && owner instanceof Player
|
||||
? NotchFightRules.RETURNED_FIREBALL_DAMAGE : DIRECT_HIT_DAMAGE;
|
||||
if (target.hurtServer(level, source, damage)) {
|
||||
EnchantmentHelper.doPostAttackEffects(level, target, source);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package fr.koka99cab.sanctuary26.ouch.notch;
|
||||
|
||||
import fr.koka99cab.sanctuary26.itsalive.api.notch.NotchCombatApi;
|
||||
import fr.koka99cab.sanctuary26.ouch.OuchMod;
|
||||
import fr.koka99cab.sanctuary26.ouch.entity.NotchFireballEntity;
|
||||
import net.minecraft.core.Registry;
|
||||
import net.minecraft.core.particles.ParticleTypes;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.sounds.SoundEvents;
|
||||
import net.minecraft.sounds.SoundSource;
|
||||
import net.minecraft.world.InteractionHand;
|
||||
import net.minecraft.world.entity.EntitySpawnReason;
|
||||
import net.minecraft.world.entity.EntityType;
|
||||
import net.minecraft.world.entity.EntityTypes;
|
||||
import net.minecraft.world.entity.LightningBolt;
|
||||
import net.minecraft.world.entity.LivingEntity;
|
||||
import net.minecraft.world.entity.MobCategory;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
|
||||
/** Ouch-owned implementation of the server-authoritative Notch attack bridge. */
|
||||
public final class OuchNotchCombat implements NotchCombatApi.Engine {
|
||||
private static final Identifier FIREBALL_ID = OuchMod.id("notch_fireball");
|
||||
private static final ResourceKey<EntityType<?>> FIREBALL_KEY = ResourceKey.create(
|
||||
Registries.ENTITY_TYPE, FIREBALL_ID);
|
||||
public static final EntityType<NotchFireballEntity> FIREBALL = Registry.register(
|
||||
BuiltInRegistries.ENTITY_TYPE, FIREBALL_ID,
|
||||
EntityType.Builder.<NotchFireballEntity>of(NotchFireballEntity::new, MobCategory.MISC)
|
||||
.sized(1.0F, 1.0F)
|
||||
.clientTrackingRange(64)
|
||||
.updateInterval(1)
|
||||
.noSave()
|
||||
.noLootTable()
|
||||
.build(FIREBALL_KEY));
|
||||
private static boolean initialized;
|
||||
|
||||
private OuchNotchCombat() {
|
||||
}
|
||||
|
||||
public static void initialize() {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
NotchCombatApi.register(new OuchNotchCombat());
|
||||
OuchMod.LOGGER.debug("[{}] Registered the Notch Titan combat engine.", OuchMod.DISPLAY_NAME);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void telegraph(ServerLevel level, LivingEntity boss, LivingEntity target,
|
||||
NotchCombatApi.Attack attack) {
|
||||
if (attack == NotchCombatApi.Attack.FIREBALL) {
|
||||
level.sendParticles(ParticleTypes.FLAME, boss.getX(), boss.getEyeY(), boss.getZ(),
|
||||
28, 0.7D, 1.0D, 0.7D, 0.035D);
|
||||
level.playSound(null, boss.blockPosition(), SoundEvents.BLAZE_SHOOT, SoundSource.HOSTILE,
|
||||
0.9F, 0.55F);
|
||||
} else if (attack == NotchCombatApi.Attack.LIGHTNING) {
|
||||
level.sendParticles(ParticleTypes.ELECTRIC_SPARK, target.getX(), target.getY() + 0.1D,
|
||||
target.getZ(), 42, 1.25D, 0.06D, 1.25D, 0.08D);
|
||||
level.playSound(null, target.blockPosition(), SoundEvents.TRIDENT_THUNDER.value(),
|
||||
SoundSource.HOSTILE, 1.0F, 1.35F);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute(ServerLevel level, LivingEntity boss, LivingEntity target,
|
||||
NotchCombatApi.Attack attack) {
|
||||
switch (attack) {
|
||||
case MELEE -> melee(level, boss, target);
|
||||
case FIREBALL -> fireball(level, boss, target);
|
||||
case LIGHTNING -> lightning(level, boss, target);
|
||||
}
|
||||
}
|
||||
|
||||
private static void melee(ServerLevel level, LivingEntity boss, LivingEntity target) {
|
||||
boss.swing(InteractionHand.MAIN_HAND);
|
||||
if (!target.hurtServer(level, level.damageSources().mobAttack(boss), 18.0F)) return;
|
||||
pushAway(boss, target, 1.4D, 0.48D);
|
||||
level.sendParticles(ParticleTypes.EXPLOSION, target.getX(), target.getY() + 1.0D,
|
||||
target.getZ(), 4, 0.35D, 0.55D, 0.35D, 0.02D);
|
||||
}
|
||||
|
||||
private static void fireball(ServerLevel level, LivingEntity boss, LivingEntity target) {
|
||||
Vec3 origin = boss.getEyePosition().add(boss.getLookAngle().scale(1.5D));
|
||||
Vec3 aim = target.getEyePosition().subtract(origin).normalize();
|
||||
NotchFireballEntity projectile = new NotchFireballEntity(level, boss, aim);
|
||||
projectile.setPos(origin);
|
||||
level.addFreshEntity(projectile);
|
||||
}
|
||||
|
||||
private static void lightning(ServerLevel level, LivingEntity boss, LivingEntity target) {
|
||||
LightningBolt lightning = EntityTypes.LIGHTNING_BOLT.create(level, EntitySpawnReason.TRIGGERED);
|
||||
if (lightning != null) {
|
||||
lightning.setVisualOnly(true);
|
||||
lightning.setPos(target.position());
|
||||
level.addFreshEntity(lightning);
|
||||
}
|
||||
if (target.hurtServer(level, level.damageSources().lightningBolt(), 7.0F)) {
|
||||
pushAway(boss, target, 1.0D, 0.35D);
|
||||
}
|
||||
}
|
||||
|
||||
private static void pushAway(LivingEntity boss, LivingEntity target, double strength, double lift) {
|
||||
Vec3 horizontal = target.position().subtract(boss.position()).multiply(1.0D, 0.0D, 1.0D);
|
||||
if (horizontal.lengthSqr() < 1.0E-6D) horizontal = boss.getLookAngle().multiply(1.0D, 0.0D, 1.0D);
|
||||
horizontal = horizontal.normalize().scale(strength);
|
||||
target.push(horizontal.x, lift, horizontal.z);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package fr.koka99cab.sanctuary26.ouch.registry;
|
||||
|
||||
import fr.koka99cab.sanctuary26.ouch.OuchMod;
|
||||
import fr.koka99cab.sanctuary26.ouch.notch.OuchNotchCombat;
|
||||
|
||||
/**
|
||||
* Single registration entry point for the Ouch namespace.
|
||||
@@ -20,6 +21,7 @@ public final class OuchRegistries {
|
||||
OuchItems.initialize();
|
||||
OuchWeatherTnt.initialize();
|
||||
OuchAbjectionProjectiles.initialize();
|
||||
OuchNotchCombat.initialize();
|
||||
OuchCreativeTab.initialize();
|
||||
OuchMod.LOGGER.debug("[{}] Weapon registry hub initialized.", OuchMod.DISPLAY_NAME);
|
||||
}
|
||||
|
||||
@@ -26,7 +26,8 @@
|
||||
"anotherworld": ">=0.0.0-alpha.46",
|
||||
"ambiance": ">=0.0.0-alpha.12",
|
||||
"onlyfun": ">=0.0.0-alpha.23",
|
||||
"sanctuary": ">=0.0.0-alpha.110"
|
||||
"itsalive": ">=0.0.0-alpha.37",
|
||||
"sanctuary": ">=0.0.0-alpha.126"
|
||||
},
|
||||
"custom": {
|
||||
"sanctuary26": {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user