Compare commits

...
Author SHA1 Message Date
ChrisM-PekandCursor 77fa9c5d21 feat(onlyfun): add Nourra firework with a skin-face burst
Verification 26.2 / check (pull_request) Canceled after 0s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 14:54:45 +02:00
koka da61a3b1ec Merge pull request 'release: Sanctuary 26.2.0-alpha.242 — MyDisplay build 4' (#170) from release/26.2.0-alpha.242 into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: koka/sanctuary#170
2026-09-05 19:08:46 +00:00
koka bd7ee21e7f release: update MyDisplay build 4
Verification 26.2 / check (pull_request) Canceled after 0s
2026-09-05 20:43:58 +02:00
koka b4a4e25684 Merge pull request 'release: Sanctuary 26.2.0-alpha.241 — optimisations MyDisplay' (#168) from release/26.2.0-alpha.241 into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: koka/sanctuary#168
2026-09-05 07:38:16 +00:00
koka d95d8af8ec release: update MyDisplay performance build
Verification 26.2 / check (pull_request) Canceled after 0s
2026-09-05 09:34:04 +02:00
koka 29db709e7e Merge pull request 'release: update MyDisplay build 2' (#166) from release/26.2.0-alpha.240 into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: koka/sanctuary#166
2026-09-04 15:50:59 +00:00
koka 9b432f2ecc release: update MyDisplay build 2
Verification 26.2 / check (pull_request) Canceled after 0s
2026-09-04 17:42:34 +02:00
koka d1f9d6a30e Merge pull request 'release: replace Dream Displays with MyDisplay' (#165) from release/26.2.0-alpha.239 into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: koka/sanctuary#165
2026-09-04 14:05:18 +00:00
koka aefb23deee release: update MyDisplay build
Verification 26.2 / check (pull_request) Canceled after 0s
2026-09-04 15:37:29 +02:00
koka 7f2b53e41d release: replace Dream Displays with MyDisplay 2026-09-04 14:43:04 +02:00
koka 60819a31ab Merge pull request 'release: update Dream Displays audio test build revision 2' (#164) from release/26.2.0-alpha.238 into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: koka/sanctuary#164
2026-09-04 11:42:04 +00:00
koka e692a051ac release: update Dream Displays audio test build
Verification 26.2 / check (pull_request) Canceled after 0s
2026-09-04 13:28:50 +02:00
koka 22fb027398 Merge pull request 'release: Sanctuary 26.2.0-alpha.237 — Dream Displays audio-buffer-test' (#163) from release/26.2.0-alpha.237 into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: koka/sanctuary#163
2026-09-04 10:47:33 +00:00
koka 2d0ae127a9 release: use Dream Displays audio buffer test build
Verification 26.2 / check (pull_request) Canceled after 0s
2026-09-04 12:45:26 +02:00
koka 253c8f132e Merge pull request 'release: Sanctuary 26.2.0-alpha.236 — retrait de ZEVENT' (#162) from release/26.2.0-alpha.236 into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: koka/sanctuary#162
2026-09-04 08:44:19 +00:00
koka c973d535a8 chore(pack): remove temporary ZEVENT module
Verification 26.2 / check (pull_request) Canceled after 0s
2026-09-04 04:17:21 +02:00
koka a764ba9c42 Merge pull request 'fix(zevent): rétablir la vidéo Twitch et publier l’alpha.235' (#161) from fix/zevent-twitch-playback into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: koka/sanctuary#161
2026-09-04 01:37:16 +00:00
koka 1cb14b997f fix(zevent): decode Twitch streams natively
Verification 26.2 / check (pull_request) Canceled after 0s
2026-09-04 03:16:59 +02:00
koka 78457fd80b build(itsalive): declare Canaplia source dependency 2026-09-04 03:16:59 +02:00
koka 8c8f7bbbde Merge pull request 'release: Sanctuary 26.2.0-alpha.234 avec ZEVENT' (#160) from release/26.2.0-alpha.234 into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: koka/sanctuary#160
2026-09-04 00:29:12 +00:00
koka ac4542079f release: add ZEVENT weekend pack in alpha.234
Verification 26.2 / check (pull_request) Canceled after 0s
2026-09-04 02:22:24 +02:00
koka 5403938320 Merge pull request 'feat(zevent): ajouter l’écran Twitch temporaire du week-end' (#159) from feature/zevent-screen into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: koka/sanctuary#159
2026-09-04 00:10:50 +00:00
75 changed files with 1638 additions and 1742 deletions
+96
View File
@@ -10,6 +10,102 @@ Ce journal suit les **versions du modpack**. Lorsqu'un seul module change, sa ve
Aucun changement non publié.
## `26.2.0-alpha.242`
Dépendance mise à jour : **MyDisplay `1.0.0` (quatrième build fourni)**.
- remplace le troisième JAR MyDisplay sous un nom distinct afin de forcer sa mise à jour par Packwiz ;
- actualise les composants client de performance, de session multimédia et de lecture WaterMedia ;
- conserve l’adaptation du manifeste pour le serveur dédié et les traductions EN/FR/RU ;
- conserve WaterMedia `3.0.0.23`, ses binaires `3.0.0.6`, les écrans existants, les sauvegardes et les protocoles ;
- ne modifie aucun des dix modules Sanctuary.
## `26.2.0-alpha.241`
Dépendance mise à jour : **MyDisplay `1.0.0` (troisième build fourni)**.
- remplace le second JAR MyDisplay sous un nom distinct pour forcer sa mise à jour par Packwiz ;
- accélère la copie ARGB vers RGBA avec un `IntBuffer` et adapte la résolution de décodage WaterMedia à la distance ;
- plafonne et priorise les uploads vidéo par proximité, sans masquer les écrans qui attendent leur prochaine image ;
- suspend les uploads hors champ, coupe le rendu au-delà de 128 blocs et regroupe le rendu du mur ;
- partage Streamlink entre les écrans identiques et échelonne le démarrage des lecteurs ;
- évite de créer les décodeurs audio OpenAL/FFmpeg lorsque l’écran est muet et sécurise les captures F2 ;
- conserve WaterMedia `3.0.0.23`, ses binaires `3.0.0.6`, les écrans existants, les sauvegardes et les protocoles.
## `26.2.0-alpha.240`
Dépendance mise à jour : **MyDisplay `1.0.0` (second build fourni)**.
- remplace le premier JAR MyDisplay par une nouvelle révision sous un nom distinct pour contourner les caches Packwiz ;
- ajoute la sélection côté client de l’écran MyDisplay actuellement visé et met à jour la gestion multimédia associée ;
- conserve WaterMedia `3.0.0.23` et WaterMedia Binaries `3.0.0.6` côté client ;
- conserve l’adaptation du manifeste pour le serveur dédié et complète toujours EN/FR avec le russe ;
- rend le résolveur des dépendances Packwiz compatible avec les téléchargements directs privés épinglés ;
- conserve les écrans MyDisplay existants, les dix modules Sanctuary, leurs sauvegardes et leurs protocoles.
## `26.2.0-alpha.239`
Dépendances remplacées : **MyDisplay `1.0.0`**, **WaterMedia `3.0.0.23`** et **WaterMedia Binaries `3.0.0.6`**.
- retire Dream Displays et son second build de test audio du client comme du serveur ;
- ajoute MyDisplay côté client et serveur pour les blocs-écrans, leur configuration et leur synchronisation ;
- ajoute WaterMedia et ses binaires natifs côté client uniquement pour la lecture et le rendu multimédia ;
- adapte seulement le manifeste de MyDisplay afin que ses dépendances client optionnelles ne bloquent pas le serveur dédié, et complète ses traductions EN/FR avec le russe ;
- ne convertit pas les écrans Dream Displays déjà placés, qui doivent être remplacés par des écrans MyDisplay ;
- conserve les dix modules Sanctuary, leurs versions, sauvegardes et protocoles sans changement ;
- garde le module temporaire ZEVENT entièrement absent du workspace et du pack.
## `26.2.0-alpha.238`
Dépendance remplacée : **Dream Displays `1.10.0-dev` (audio-buffer-test, révision 2)**.
- remplace le premier JAR de test Dream Displays par la nouvelle révision fournie ;
- utilise un nouveau nom de fichier afin de contourner les caches et de garantir son téléchargement ;
- distribue ce build côté client et serveur avec ses nouvelles empreintes vérifiées ;
- conserve les dix modules Sanctuary, leurs versions, sauvegardes et protocoles sans changement ;
- garde le module temporaire ZEVENT entièrement absent du workspace et du pack.
## `26.2.0-alpha.237`
Dépendance remplacée : **Dream Displays `1.10.0-dev` (audio-buffer-test)**.
- remplace Dream Displays `1.9.6` par le JAR de test fourni pour corriger la gestion du tampon audio ;
- utilise un nom de fichier distinct afin que Packwiz supprime l’ancien JAR et ne réutilise pas son cache ;
- distribue le nouveau build côté client et serveur depuis le dépôt de téléchargement Sanctuary avec empreintes vérifiées ;
- conserve les dix modules Sanctuary, leurs versions, sauvegardes et protocoles sans changement ;
- garde le module temporaire ZEVENT entièrement absent du workspace et du pack.
## `26.2.0-alpha.236`
Module temporaire supprimé : **ZEVENT `0.0.0-alpha.3`**.
- retire entièrement le sous-projet, le JAR client/serveur et les identifiants `zevent:*` devenus redondants ;
- conserve Dream Displays `1.9.6`, qui fournit directement les écrans et la lecture des flux vidéo ;
- retire automatiquement `zevent-0.0.0-alpha.3.jar` des installations gérées par Packwiz ;
- ne convertit pas les anciens écrans muraux ZEVENT : ils doivent être remplacés par des écrans Dream Displays ;
- conserve sans changement les sauvegardes, inventaires, monnaies et protocoles des dix modules Sanctuary restants.
## `26.2.0-alpha.235`
Module modifié temporairement : **ZEVENT `0.0.0-alpha.3`**.
- remplace MCEF Modern, dont Chromium ne sait pas décoder les directs Twitch en H.264/AAC, par Dream Displays `1.9.6` et son pipeline vidéo natif ;
- conserve le bloc-écran 9 × 5, les chaînes enregistrées, l’interface et le protocole réseau ZEVENT 1 sans migration de monde ;
- réutilise directement la texture GPU décodée dans le renderer ZEVENT, avec une limite de 720p et deux flux visibles au maximum par client ;
- maintient le volume dans la catégorie Disques, l’atténuation jusqu’à 64 blocs et un seul écran audible à la fois ;
- réessaie automatiquement les chaînes momentanément indisponibles et affiche désormais un message explicite lorsqu’une chaîne est hors ligne ;
- valide le démarrage client natif sur macOS Apple Silicon ainsi que la résolution d’un direct Twitch actif en cinq qualités H.264 jusqu’en 1080p avec audio AAC.
## `26.2.0-alpha.234`
Module ajouté temporairement : **ZEVENT `0.0.0-alpha.2`**.
- ajoute pour le week-end le bloc-écran ZEVENT, une surface multibloc de 9 × 5 qui diffuse une chaîne Twitch officielle dans le monde ;
- permet de saisir un nom de chaîne ou une URL Twitch par clic droit, puis enregistre et synchronise la chaîne côté serveur ;
- relie le volume à la catégorie Disques, l’atténue jusqu’à 64 blocs et limite chaque client à deux lecteurs visibles ;
- ajoute MCEF Modern `0.3.3+mc26.2.jcef146.0.10` uniquement côté client, sans imposer Chromium au serveur dédié ;
- conserve les données et protocoles des dix modules Sanctuary existants ; ZEVENT démarre en données v1 et protocole 1, avec mise à jour simultanée du client et du serveur.
## `26.2.0-alpha.233`
Modules modifiés : **Sanctuary `0.0.0-alpha.139`** et **Red-Stoner `0.0.0-alpha.20`**.
+21 -4
View File
@@ -1,6 +1,6 @@
# Sanctuary 26.2
Workspace Fabric multi-projet de Sanctuary 2.0. Il construit les dix JAR du pack et le mod événementiel ZEVENT distribué à part, sur Minecraft 26.2, Fabric Loader 0.19.3, Fabric API 0.159.0+26.2, Loom 1.17, Gradle 9.5.1 et Java 25.
Workspace Fabric multi-projet de Sanctuary 2.0. Il construit dix JAR, sur Minecraft 26.2, Fabric Loader 0.19.3, Fabric API 0.159.0+26.2, Loom 1.17, Gradle 9.5.1 et Java 25.
Auteur : **KOKA99CAB**
Licence : **GNU GPL 3.0 or Later** (`GPL-3.0-or-later`)
@@ -19,11 +19,28 @@ Licence : **GNU GPL 3.0 or Later** (`GPL-3.0-or-later`)
| `sanctuary` | Sanctuary | progression et aventure | progression globale, classements publics, Atlas et monde principal flottant |
| `events` | Events | événements serveur | socle vide |
| `masterkey` | MasterKey | administration et diagnostic | laboratoire minimal |
| `zevent` | ZEVENT | diffusion Twitch temporaire | écran mural 9×5, hors pack principal |
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.233`**. Cette release distribue Another World **`0.0.0-alpha.58`**, Sanctuary **`0.0.0-alpha.139`** et Red-Stoner **`0.0.0-alpha.20`** ; les sept autres modules conservent leur version issue de l'alpha.231.
Version courante du pack distribué : **`26.2.0-alpha.242`**. Cette release conserve les dix modules Sanctuary, notamment Another World **`0.0.0-alpha.58`**, Sanctuary **`0.0.0-alpha.139`** et Red-Stoner **`0.0.0-alpha.20`**, garde ZEVENT entièrement retiré et met à jour MyDisplay **`1.0.0`** vers le quatrième build fourni, avec WaterMedia **`3.0.0.23`** et WaterMedia Binaries **`3.0.0.6`**.
L’alpha.242 remplace le troisième JAR MyDisplay par `mydisplay-1.0.0-sanctuary-4.jar`. Cette révision actualise exclusivement le pipeline client de performance, de session et de lecture WaterMedia, sans changer les blocs, les données ni le protocole réseau. Le manifeste reste compatible avec le serveur dédié, les traductions EN/FR/RU sont alignées, les écrans déjà placés restent compatibles et le nom distinct force Packwiz à retirer le build précédent.
L’alpha.241 remplace le second JAR MyDisplay par `mydisplay-1.0.0-sanctuary-3.jar`. Le rendu garde tous les écrans visibles tout en réduisant la charge client : copie vidéo ARGB vers RGBA par `IntBuffer`, résolution WaterMedia adaptative selon la distance, priorité et budget des uploads, gel de l’image hors champ, distance de rendu limitée à 128 blocs, rendu mural groupé et démarrage échelonné. Les flux Streamlink identiques sont partagés, l’audio muet ne crée plus de décodeur OpenAL/FFmpeg et les captures F2 réinitialisent proprement le tampon en suspendant les uploads. Le manifeste reste compatible avec le serveur dédié, les traductions EN/FR/RU sont alignées et les écrans déjà placés restent compatibles.
L’alpha.240 remplace le premier JAR MyDisplay par `mydisplay-1.0.0-sanctuary-2.jar`, dont le nom distinct force Packwiz à télécharger le nouveau build et à supprimer le précédent. Cette révision ajoute notamment la sélection côté client d’un écran MyDisplay visé. Le manifeste conserve l’adaptation serveur dédié et les traductions EN/FR/RU restent alignées. Les écrans MyDisplay déjà placés restent compatibles.
L’alpha.239 distribue MyDisplay côté client et serveur, et réserve WaterMedia ainsi que ses binaires natifs au client. Packwiz retire automatiquement Dream Displays ; ses anciens écrans déjà posés ne sont pas convertis et doivent être remplacés par des écrans MyDisplay.
L’alpha.238 distribue la seconde révision `dreamdisplays-fabric-26.2-audio-buffer-test-2.jar` côté client et serveur.
L’alpha.237 remplace Dream Displays `1.9.6` par le premier build de test `1.10.0-dev` destiné à corriger la gestion du tampon audio.
L’alpha.236 supprime le JAR et le code ZEVENT devenus redondants. Packwiz retire automatiquement `zevent-0.0.0-alpha.3.jar` du client et du serveur, tandis que Dream Displays reste installé des deux côtés. Les écrans `zevent:zevent_screen` et leurs chaînes enregistrées ne sont pas convertibles : ils doivent être remplacés par des écrans Dream Displays. Les sauvegardes et données des dix autres modules restent inchangées.
L’alpha.235 corrige l’écran noir de ZEVENT. MCEF chargeait correctement l’interface et les liens Twitch, mais son Chromium ne prenait pas en charge les codecs des directs. L’écran utilise désormais le résolveur HLS et le décodeur natif de Dream Displays, conserve ses options et sa synchronisation serveur, réessaie automatiquement un flux indisponible et indique clairement lorsqu’une chaîne est hors ligne. Le volume suit toujours la catégorie Disques, décroît jusqu’à 64 blocs et seul l’écran visible le plus proche reste audible.
L’alpha.234 ajoute pour le week-end l’écran Twitch ZEVENT de 9 × 5 blocs. Un clic droit permet de choisir une chaîne officielle ; la sélection est enregistrée par le serveur, tandis que l’image et le son sont rendus localement avec MCEF Modern. Le volume suit la catégorie Disques et décroît jusqu’à 64 blocs. Les mondes et les dix modules Sanctuary existants restent compatibles ; le client et le serveur doivent toutefois recevoir le JAR ZEVENT, et seul le client reçoit MCEF.
L’alpha.233 remplace le bloc-écran des trophées par un véritable panneau Vanilla en chêne pâle, posable au sol ou au mur. Son éditeur permet de rechercher une statistique, composer jusqu’à seize pages et choisir l’affichage des noms et valeurs ; le propriétaire ou un opérateur garde le contrôle de sa configuration tandis que le changement de page reste public. L’item `redstoner:trophy_panel` conserve son ID et les anciens panneaux posés sont migrés automatiquement. Les classements affichent désormais le vrai pseudo connu du joueur au lieu d’un fragment d’UUID. Les sauvegardes Sanctuary restent en données v15/protocole 24 ; Red-Stoner passe aux données v5 et au protocole 9, ce qui exige la même version côté client et serveur.
@@ -419,7 +436,7 @@ Le menu pause est lui aussi possédé par Sanctuary. Il ne conserve que Retour a
## Versions du modpack et mises à jour Modrinth
`pack_version` dans `gradle.properties` est la version du pack complet. `mod_version=0.0.0-alpha.0` et `module_lifecycle=standby` sont les valeurs par défaut des modules en veille ; les propriétés `<module>_version` et `<module>_lifecycle` n’activent et ne font avancer que le module concerné. Le contrat de distribution est dans `pack/release.json`. Le sous-projet `zevent` reste volontairement hors de `moduleIds` et des archives du pack : son JAR et sa dépendance cliente MCEF sont distribués séparément pour l’événement.
`pack_version` dans `gradle.properties` est la version du pack complet. `mod_version=0.0.0-alpha.0` et `module_lifecycle=standby` sont les valeurs par défaut des modules en veille ; les propriétés `<module>_version` et `<module>_lifecycle` n’activent et ne font avancer que le module concerné. Le contrat de distribution est dans `pack/release.json`. Depuis l’alpha.236, le module temporaire `zevent` n’est plus construit ni distribué ; l’alpha.240 utilise le second build MyDisplay fourni et son moteur client WaterMedia à la place de Dream Displays.
La version globale du pack n’est pas injectée dans les JAR inchangés : leur contenu et leur numéro restent immuables. Le lanceur possède la composition exacte du pack, tandis que chaque manifest conserve la version effective de son module.
+607 -228
View File
File diff suppressed because it is too large Load Diff
+1 -3
View File
@@ -30,7 +30,5 @@ onlyfun_version=0.0.0-alpha.27
onlyfun_lifecycle=active
sanctuary_version=0.0.0-alpha.139
sanctuary_lifecycle=active
zevent_version=0.0.0-alpha.1
zevent_lifecycle=active
pack_version=26.2.0-alpha.233
pack_version=26.2.0-alpha.242
maven_group=fr.koka99cab.sanctuary26
+1
View File
@@ -1261,6 +1261,7 @@ tasks.named("processResources") {
tasks.named("sourcesJar") {
dependsOn tasks.named("generateAgricultureResources")
dependsOn tasks.named("generateCanapliaResources")
}
tasks.register("verifyCulinaryReliability") {
Binary file not shown.

After

Width:  |  Height:  |  Size: 641 B

@@ -0,0 +1,100 @@
package fr.koka99cab.sanctuary26.onlyfun.client;
import fr.koka99cab.sanctuary26.onlyfun.item.SkinFaceFireworkExplosions;
import fr.koka99cab.sanctuary26.onlyfun.mixin.client.ParticleLifetimeAccessor;
import net.minecraft.client.Minecraft;
import net.minecraft.client.particle.Particle;
import net.minecraft.core.particles.ColorParticleOption;
import net.minecraft.core.particles.DustParticleOptions;
import net.minecraft.core.particles.ParticleTypes;
import net.minecraft.sounds.SoundEvents;
import net.minecraft.sounds.SoundSource;
import net.minecraft.util.RandomSource;
import net.minecraft.world.entity.projectile.FireworkRocketEntity;
import net.minecraft.world.phys.Vec3;
/** Paints the baked skin as a firework burst of coloured sparks, facing the player. */
public final class SkinFaceFireworkClient {
public static final double PIXEL = 1.85D;
public static final double LARGE_PIXEL = 2.7D;
public static final float SPARK_SIZE = 3.6F;
public static final float LARGE_SPARK_SIZE = 5.15F;
public static final int SPARK_LIFETIME_TICKS = 38;
private static final int SUBPIXELS = 2;
private SkinFaceFireworkClient() {
}
public static void explode(FireworkRocketEntity rocket) {
Minecraft minecraft = Minecraft.getInstance();
if (minecraft.level == null || minecraft.particleEngine == null) return;
Vec3 origin = rocket.position();
RandomSource random = rocket.level().getRandom();
minecraft.level.playLocalSound(origin.x, origin.y, origin.z,
SoundEvents.FIREWORK_ROCKET_BLAST, SoundSource.AMBIENT,
20.0F, 0.95F + random.nextFloat() * 0.1F, true);
minecraft.level.addParticle(ColorParticleOption.create(ParticleTypes.FLASH, 0xFFFFFFFF),
origin.x, origin.y, origin.z, 0.0D, 0.0D, 0.0D);
Vec3 look = facing(minecraft, origin);
Vec3 right = look.cross(new Vec3(0.0D, 1.0D, 0.0D));
if (right.lengthSqr() < 1.0E-6D) right = new Vec3(1.0D, 0.0D, 0.0D);
right = right.normalize();
Vec3 up = new Vec3(0.0D, 1.0D, 0.0D);
int[] pixels = SkinFaceFireworkExplosions.portrait();
int width = SkinFaceFireworkExplosions.WIDTH;
int height = SkinFaceFireworkExplosions.portraitHeight();
boolean large = largeBurst(rocket);
double pixel = burstPixel(large);
float sparkSize = burstSparkSize(large);
double midX = (width - 1) / 2.0D;
double midY = (height - 1) / 2.0D;
double step = 1.0D / SUBPIXELS;
for (int row = 0; row < height; row++) {
for (int col = 0; col < width; col++) {
int color = visibleSpark(pixels[row * width + col]);
DustParticleOptions dust = new DustParticleOptions(color, sparkSize);
for (int subY = 0; subY < SUBPIXELS; subY++) {
for (int subX = 0; subX < SUBPIXELS; subX++) {
double px = (col - midX) + (subX + 0.5D) * step - 0.5D;
double py = (midY - row) - (subY + 0.5D) * step + 0.5D;
Vec3 offset = right.scale(px * pixel).add(up.scale(py * pixel));
Vec3 pos = origin.add(offset);
Vec3 velocity = offset.scale(0.05D);
Particle spark = minecraft.particleEngine.createParticle(
dust, pos.x, pos.y, pos.z, velocity.x, velocity.y, velocity.z);
if (spark != null) {
((ParticleLifetimeAccessor) spark).onlyfun$setLifetime(SPARK_LIFETIME_TICKS);
}
}
}
}
}
}
public static boolean largeBurst(FireworkRocketEntity rocket) {
return (rocket.getUUID().getLeastSignificantBits() & 1L) != 0L;
}
public static double burstPixel(boolean large) {
return large ? LARGE_PIXEL : PIXEL;
}
public static float burstSparkSize(boolean large) {
return large ? LARGE_SPARK_SIZE : SPARK_SIZE;
}
private static Vec3 facing(Minecraft minecraft, Vec3 origin) {
Vec3 camera = minecraft.gameRenderer.mainCamera().position();
Vec3 horizontal = new Vec3(origin.x - camera.x, 0.0D, origin.z - camera.z);
if (horizontal.lengthSqr() < 1.0E-4D) return new Vec3(0.0D, 0.0D, 1.0D);
return horizontal.normalize();
}
private static int visibleSpark(int rgb) {
int red = rgb >> 16 & 0xFF;
int green = rgb >> 8 & 0xFF;
int blue = rgb & 0xFF;
if (red + green + blue < 24) return 0x2A2A2A;
return rgb;
}
}
@@ -0,0 +1,23 @@
package fr.koka99cab.sanctuary26.onlyfun.item;
import java.util.function.Consumer;
import net.minecraft.ChatFormatting;
import net.minecraft.network.chat.Component;
import net.minecraft.world.item.FireworkRocketItem;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.TooltipFlag;
import net.minecraft.world.item.component.TooltipDisplay;
/** Firework rocket that bursts into the baked Nourra skin face. */
public final class NourraFireworkItem extends FireworkRocketItem {
public NourraFireworkItem(Properties properties) {
super(properties);
}
@Override
public void appendHoverText(ItemStack stack, TooltipContext context, TooltipDisplay display,
Consumer<Component> tooltip, TooltipFlag flag) {
tooltip.accept(Component.translatable("tooltip.onlyfun.nourra_firework").withStyle(ChatFormatting.AQUA));
super.appendHoverText(stack, context, display, tooltip, flag);
}
}
@@ -0,0 +1,83 @@
package fr.koka99cab.sanctuary26.onlyfun.item;
import fr.koka99cab.sanctuary26.onlyfun.registry.OnlyFunRegistries;
import java.util.List;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.component.Fireworks;
/**
* Baked Nourra head (NameMC 6166a439e9354904): inner face, hood overlay, hood top.
* The portrait is drawn client-side with firework sparks.
*/
public final class SkinFaceFireworkExplosions {
public static final int WIDTH = 8;
public static final int HEIGHT = 8;
public static final int TRANSPARENT = -1;
/** Inner head front, top-left origin, row-major RGB. */
public static final int[] FACE = {
0x000000, 0x020202, 0x0A0A0A, 0x111111, 0x111111, 0x0A0A0A, 0x020202, 0x000000,
0x020202, 0x0A0A0A, 0x111111, 0x181818, 0x181818, 0x111111, 0x0A0A0A, 0x020202,
0x0A0A0A, 0x111111, 0x181818, 0x212121, 0x212121, 0x181818, 0x111111, 0x0A0A0A,
0x111111, 0x181818, 0x212121, 0x2B2B2B, 0x2B2B2B, 0x212121, 0x181818, 0x111111,
0x111111, 0x000000, 0x000000, 0xFFF0E1, 0xFFF0E1, 0x000000, 0x000000, 0x111111,
0x0A0A0A, 0xFFFFFF, 0x4A9DFC, 0xFDE8D7, 0xFFF0E1, 0x4A9DFC, 0xFFFFFF, 0x0A0A0A,
0x020202, 0xFFFFFF, 0x4A9DFC, 0xFDE8D7, 0xFDE8D7, 0x4A9DFC, 0xFFFFFF, 0x020202,
0x000000, 0xF0C6A5, 0xF7D5B9, 0xFCE1CA, 0xFCE1CA, 0xF7D5B9, 0xF0C6A5, 0x000000
};
/** Hat/hood overlay on the front; {@link #TRANSPARENT} keeps the inner face visible. */
public static final int[] HOOD = {
0x000000, 0x0D0D0D, 0xCBCBCB, 0xD8D8D8, 0xD8D8D8, 0xCBCBCB, 0x0D0D0D, 0x000000,
0x0D0D0D, 0x707070, 0xD8D8D8, 0xE5E5E5, 0xE5E5E5, 0xD8D8D8, 0x707070, 0x0D0D0D,
0xCBCBCB, 0xD8D8D8, 0xFFFFFF, 0x212121, 0x212121, 0x181818, 0xD8D8D8, 0xCBCBCB,
0xD8D8D8, 0x181818, 0x212121, 0x2B2B2B, 0x2B2B2B, 0x212121, 0x181818, 0xD8D8D8,
0x111111, 0x181818, 0x212121, 0x2B2B2B, TRANSPARENT, TRANSPARENT, TRANSPARENT, 0x111111,
0x0A0A0A, 0x111111, 0x181818, TRANSPARENT, TRANSPARENT, TRANSPARENT, TRANSPARENT, TRANSPARENT,
0x020202, 0x0A0A0A, TRANSPARENT, TRANSPARENT, TRANSPARENT, TRANSPARENT, TRANSPARENT, TRANSPARENT,
TRANSPARENT, 0x020202, TRANSPARENT, TRANSPARENT, TRANSPARENT, TRANSPARENT, TRANSPARENT, TRANSPARENT
};
/** Front edge of the hood top (skin overlay top rows closest to the face). */
public static final int[] HOOD_TOP = {
0xBEBEBE, 0xCBCBCB, 0xD8D8D8, 0xD8D8D8, 0xD8D8D8, 0xD8D8D8, 0xCBCBCB, 0xBEBEBE,
0xB8B8B8, 0xB8B8B8, 0xCBCBCB, 0xD8D8D8, 0xD8D8D8, 0xCBCBCB, 0xB8B8B8, 0xB8B8B8
};
private SkinFaceFireworkExplosions() {
}
/** Vanilla flight 3 minus half a gunpowder step, so the burst sits between 2 and 3. */
public static final int LIFETIME_TRIM_TICKS = 5;
public static Fireworks defaultFireworks() {
return new Fireworks(3, List.of());
}
public static int midFlightLifetime(int vanillaLifetime) {
return Math.max(1, vanillaLifetime - LIFETIME_TRIM_TICKS);
}
public static boolean isSkinFaceRocket(ItemStack stack) {
return !stack.isEmpty() && stack.is(OnlyFunRegistries.NOURRA_FIREWORK);
}
public static int opaqueHoodPixels() {
int count = 0;
for (int color : HOOD) {
if (color != TRANSPARENT) count++;
}
return count;
}
/** Hood top (2 rows) then the 8×8 face with the hood overlay composited. */
public static int[] portrait() {
int[] pixels = new int[WIDTH * (HEIGHT + 2)];
System.arraycopy(HOOD_TOP, 0, pixels, 0, HOOD_TOP.length);
for (int index = 0; index < FACE.length; index++) {
pixels[HOOD_TOP.length + index] = HOOD[index] == TRANSPARENT ? FACE[index] : HOOD[index];
}
return pixels;
}
public static int portraitHeight() {
return HEIGHT + HOOD_TOP.length / WIDTH;
}
}
@@ -0,0 +1,25 @@
package fr.koka99cab.sanctuary26.onlyfun.mixin;
import fr.koka99cab.sanctuary26.onlyfun.item.SkinFaceFireworkExplosions;
import net.minecraft.world.entity.projectile.FireworkRocketEntity;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Shadow;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
@Mixin(FireworkRocketEntity.class)
public abstract class FireworkRocketLifetimeMixin {
@Shadow
private int life;
@Shadow
private int lifetime;
@Inject(method = "tick", at = @At("HEAD"))
private void onlyfun$midFlightLifetime(CallbackInfo callback) {
if (this.life != 0) return;
FireworkRocketEntity rocket = (FireworkRocketEntity) (Object) this;
if (!SkinFaceFireworkExplosions.isSkinFaceRocket(rocket.getItem())) return;
this.lifetime = SkinFaceFireworkExplosions.midFlightLifetime(this.lifetime);
}
}
@@ -0,0 +1,22 @@
package fr.koka99cab.sanctuary26.onlyfun.mixin.client;
import fr.koka99cab.sanctuary26.onlyfun.client.SkinFaceFireworkClient;
import fr.koka99cab.sanctuary26.onlyfun.item.SkinFaceFireworkExplosions;
import net.minecraft.world.entity.projectile.FireworkRocketEntity;
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(FireworkRocketEntity.class)
public abstract class FireworkRocketEntityMixin {
@Inject(method = "handleEntityEvent", at = @At("HEAD"), cancellable = true)
private void onlyfun$drawSkinFace(byte id, CallbackInfo callback) {
if (id != 17) return;
FireworkRocketEntity rocket = (FireworkRocketEntity) (Object) this;
if (!rocket.level().isClientSide()) return;
if (!SkinFaceFireworkExplosions.isSkinFaceRocket(rocket.getItem())) return;
SkinFaceFireworkClient.explode(rocket);
callback.cancel();
}
}
@@ -0,0 +1,11 @@
package fr.koka99cab.sanctuary26.onlyfun.mixin.client;
import net.minecraft.client.particle.Particle;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.gen.Accessor;
@Mixin(Particle.class)
public interface ParticleLifetimeAccessor {
@Accessor("lifetime")
void onlyfun$setLifetime(int lifetime);
}
@@ -28,6 +28,7 @@ public final class OnlyFunCreativeTab {
output.accept(OnlyFunRegistries.CAPE_LOOTBOX);
output.accept(OnlyFunRegistries.SPAWN_EGG_LOOTBOX);
output.accept(OnlyFunRegistries.ANOMALY_LOOTBOX);
output.accept(OnlyFunRegistries.NOURRA_FIREWORK);
output.accept(AbjectionRegistries.SHIT_BALL);
output.accept(AbjectionRegistries.SHIT_CUBE_ITEM);
})
@@ -3,6 +3,8 @@ package fr.koka99cab.sanctuary26.onlyfun.registry;
import fr.koka99cab.sanctuary26.onlyfun.OnlyFunMod;
import fr.koka99cab.sanctuary26.onlyfun.block.LuckyBlockBlock;
import fr.koka99cab.sanctuary26.onlyfun.item.LootboxItem;
import fr.koka99cab.sanctuary26.onlyfun.item.SkinFaceFireworkExplosions;
import fr.koka99cab.sanctuary26.onlyfun.item.NourraFireworkItem;
import fr.koka99cab.sanctuary26.onlyfun.loot.OnlyFunGenerator;
import fr.koka99cab.sanctuary26.onlyfun.loot.OnlyFunSource;
import java.util.function.Function;
@@ -32,6 +34,9 @@ public final class OnlyFunRegistries {
public static final Item CAPE_LOOTBOX = registerLootbox("cape_lootbox", OnlyFunSource.CAPE_LOOTBOX);
public static final Item SPAWN_EGG_LOOTBOX = registerLootbox("spawn_egg_lootbox", OnlyFunSource.SPAWN_EGG_LOOTBOX);
public static final Item ANOMALY_LOOTBOX = registerLootbox("anomaly_lootbox", OnlyFunSource.ANOMALY_LOOTBOX);
public static final Item NOURRA_FIREWORK = registerItem("nourra_firework",
properties -> new NourraFireworkItem(properties.stacksTo(64)
.component(DataComponents.FIREWORKS, SkinFaceFireworkExplosions.defaultFireworks())));
private static boolean initialized;
private OnlyFunRegistries() {
@@ -61,6 +66,11 @@ public final class OnlyFunRegistries {
OnlyFunMod.LOGGER.debug("[{}] Item and block registries initialized.", OnlyFunMod.DISPLAY_NAME);
}
private static Item registerItem(String path, Function<Item.Properties, Item> factory) {
ResourceKey<Item> key = ResourceKey.create(Registries.ITEM, OnlyFunMod.id(path));
return Registry.register(BuiltInRegistries.ITEM, key, factory.apply(new Item.Properties().setId(key)));
}
private static Item registerLootbox(String path, OnlyFunSource source) {
ResourceKey<Item> key = ResourceKey.create(Registries.ITEM, OnlyFunMod.id(path));
return Registry.register(BuiltInRegistries.ITEM, key,
@@ -1,6 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "zevent:block/zevent_screen"
"model": "onlyfun:item/nourra_firework"
}
}
@@ -6,6 +6,8 @@
"item.onlyfun.cape_lootbox": "Cape Lootbox",
"item.onlyfun.spawn_egg_lootbox": "Spawn Egg Lootbox",
"item.onlyfun.anomaly_lootbox": "Anomaly Lootbox",
"item.onlyfun.nourra_firework": "Nourra Firework",
"tooltip.onlyfun.nourra_firework": "The burst paints the skin face, at normal or larger size.",
"item.onlyfun.quality_gear": "Quality Gear",
"item.onlyfun.impossible_tool": "Impossible Tool",
"item.onlyfun.impossible_book": "Impossible Book",
@@ -6,6 +6,8 @@
"item.onlyfun.cape_lootbox": "Lootbox de capes",
"item.onlyfun.spawn_egg_lootbox": "Lootbox d'œufs",
"item.onlyfun.anomaly_lootbox": "Lootbox anomalie",
"item.onlyfun.nourra_firework": "Feu d'artifice Nourra",
"tooltip.onlyfun.nourra_firework": "L'explosion dessine le visage, en taille normale ou plus grande.",
"item.onlyfun.quality_gear": "Équipement de qualité",
"item.onlyfun.impossible_tool": "Outil impossible",
"item.onlyfun.impossible_book": "Livre impossible",
@@ -6,6 +6,8 @@
"item.onlyfun.cape_lootbox": "Лутбокс плащей",
"item.onlyfun.spawn_egg_lootbox": "Лутбокс яиц призыва",
"item.onlyfun.anomaly_lootbox": "Лутбокс-аномалия",
"item.onlyfun.nourra_firework": "Фейерверк Нурры",
"tooltip.onlyfun.nourra_firework": "Взрыв рисует лицо скина обычного или увеличенного размера.",
"item.onlyfun.quality_gear": "Качественное снаряжение",
"item.onlyfun.impossible_tool": "Невозможный инструмент",
"item.onlyfun.impossible_book": "Невозможная книга",
@@ -0,0 +1,6 @@
{
"parent": "minecraft:item/generated",
"textures": {
"layer0": "onlyfun:item/nourra_firework"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 280 B

@@ -0,0 +1,13 @@
{
"type": "minecraft:crafting_shapeless",
"category": "misc",
"ingredients": [
"minecraft:firework_rocket",
"minecraft:light_blue_dye",
"minecraft:paper"
],
"result": {
"count": 1,
"id": "onlyfun:nourra_firework"
}
}
@@ -21,6 +21,13 @@
"fr.koka99cab.sanctuary26.onlyfun.client.OnlyFunClient"
]
},
"mixins": [
"onlyfun.mixins.json",
{
"config": "onlyfun.client.mixins.json",
"environment": "client"
}
],
"depends": {
"fabricloader": ">=0.19.3",
"minecraft": "~26.2",
@@ -0,0 +1,12 @@
{
"required": true,
"package": "fr.koka99cab.sanctuary26.onlyfun.mixin.client",
"compatibilityLevel": "JAVA_25",
"client": [
"FireworkRocketEntityMixin",
"ParticleLifetimeAccessor"
],
"injectors": {
"defaultRequire": 1
}
}
@@ -0,0 +1,11 @@
{
"required": true,
"package": "fr.koka99cab.sanctuary26.onlyfun.mixin",
"compatibilityLevel": "JAVA_25",
"mixins": [
"FireworkRocketLifetimeMixin"
],
"injectors": {
"defaultRequire": 1
}
}
@@ -0,0 +1,66 @@
package fr.koka99cab.sanctuary26.onlyfun.item;
import fr.koka99cab.sanctuary26.onlyfun.client.SkinFaceFireworkClient;
import net.minecraft.SharedConstants;
import net.minecraft.server.Bootstrap;
import net.minecraft.world.item.component.Fireworks;
/** Executable check for the baked Nourra face and hood firework contract. */
public final class SkinFaceFireworkSmoke {
private SkinFaceFireworkSmoke() {
}
public static void main(String[] args) {
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
verify();
}
public static void verify() {
require(SkinFaceFireworkExplosions.WIDTH == 8 && SkinFaceFireworkExplosions.HEIGHT == 8,
"Face grid is no longer 8×8");
require(SkinFaceFireworkExplosions.FACE.length == 64, "Face palette length drifted");
require(SkinFaceFireworkExplosions.HOOD.length == 64, "Hood overlay length drifted");
require(SkinFaceFireworkExplosions.HOOD_TOP.length == 16, "Hood top strip length drifted");
require(SkinFaceFireworkExplosions.FACE[5 * 8 + 2] == 0x4A9DFC
&& SkinFaceFireworkExplosions.FACE[5 * 8 + 5] == 0x4A9DFC,
"Blue eyes are missing from the inner face");
require(SkinFaceFireworkExplosions.FACE[4 * 8 + 3] == 0xFFF0E1,
"Skin tone is missing from the baked face");
require(SkinFaceFireworkExplosions.HOOD[2] == 0xCBCBCB,
"Hood overlay is missing from the top of the face");
require(SkinFaceFireworkExplosions.HOOD[5 * 8 + 2] == 0x181818,
"Hood no longer covers the left eye");
require(SkinFaceFireworkExplosions.HOOD[5 * 8 + 5] == SkinFaceFireworkExplosions.TRANSPARENT,
"Hood opening no longer leaves the right eye visible");
require(SkinFaceFireworkExplosions.opaqueHoodPixels() == 43,
"Opaque hood pixel count drifted");
Fireworks fireworks = SkinFaceFireworkExplosions.defaultFireworks();
require(fireworks.flightDuration() == 3 && fireworks.explosions().isEmpty(),
"Default firework burst contract changed");
require(SkinFaceFireworkExplosions.LIFETIME_TRIM_TICKS == 5
&& SkinFaceFireworkExplosions.midFlightLifetime(45) == 40,
"Mid-flight lifetime trim drifted");
int[] portrait = SkinFaceFireworkExplosions.portrait();
require(portrait.length == 80 && SkinFaceFireworkExplosions.portraitHeight() == 10,
"Portrait grid drifted");
require(portrait[16 + 5 * 8 + 5] == 0x4A9DFC,
"Right eye is missing from the composited portrait");
require(portrait[2] == 0xD8D8D8,
"Hood top is missing from the portrait");
require(SkinFaceFireworkClient.PIXEL == 1.85D, "Portrait scale drifted");
require(SkinFaceFireworkClient.LARGE_PIXEL == 2.7D, "Large portrait scale drifted");
require(SkinFaceFireworkClient.burstPixel(false) == SkinFaceFireworkClient.PIXEL
&& SkinFaceFireworkClient.burstSparkSize(false) == SkinFaceFireworkClient.SPARK_SIZE,
"Normal burst scale drifted");
require(SkinFaceFireworkClient.burstPixel(true) == SkinFaceFireworkClient.LARGE_PIXEL
&& SkinFaceFireworkClient.burstSparkSize(true) == SkinFaceFireworkClient.LARGE_SPARK_SIZE,
"Large burst scale drifted");
require(SkinFaceFireworkClient.SPARK_LIFETIME_TICKS == 38, "Spark lifetime drifted");
System.out.println("Only Fun Nourra firework: 8×8 face with hood overlay ready.");
}
private static void require(boolean condition, String message) {
if (!condition) throw new IllegalStateException(message);
}
}
@@ -1,5 +1,6 @@
package fr.koka99cab.sanctuary26.onlyfun.loot;
import fr.koka99cab.sanctuary26.onlyfun.item.SkinFaceFireworkSmoke;
import fr.koka99cab.sanctuary26.onlyfun.shop.OnlyFunShopCatalogSmoke;
import fr.koka99cab.sanctuary26.onlyfun.world.CobbleDungeonPlacementSmoke;
import java.util.EnumMap;
@@ -18,6 +19,7 @@ public final class OnlyFunGeneratorSmoke {
Bootstrap.bootStrap();
OnlyFunShopCatalogSmoke.verify();
CobbleDungeonPlacementSmoke.verify();
SkinFaceFireworkSmoke.verify();
OnlyFunGenerator.initialize();
require(OnlyFunGenerator.ANOMALY_DENOMINATOR == 256, "Anomaly odds changed from 1/256");
require(OnlyFunGenerator.PITY_START == 24, "Pity threshold changed");
@@ -0,0 +1,51 @@
{
"schema_version": 1,
"status": "released",
"released_in": "26.2.0-alpha.234",
"temporary_until": "2026-09-07",
"pack": {
"source_version": "26.2.0-alpha.233",
"target_version": "26.2.0-alpha.234"
},
"modules": {
"zevent": {
"source_module_version": null,
"target_module_version": "0.0.0-alpha.2",
"data_version": 1,
"network_protocol": 1,
"client_and_server_required": true
}
},
"screen": {
"block_id": "zevent:zevent_screen",
"width_blocks": 9,
"height_blocks": 5,
"server_authoritative_channel": true,
"maximum_channel_input_length": 128,
"maximum_channel_name_length": 25,
"maximum_visible_browsers_per_client": 2,
"audible_distance_blocks": 64
},
"dependencies": {
"mcef_modern": {
"version": "0.3.3+mc26.2.jcef146.0.10",
"modrinth_project_id": "YJFfu5pH",
"modrinth_version_id": "FOXbUPNd",
"environment": "client_only"
}
},
"compatibility": {
"existing_saves_supported": true,
"new_world_required": false,
"save_reset_required": false,
"existing_module_data_versions_changed": false,
"existing_module_network_protocols_changed": false,
"zevent_client_server_update_required": true
},
"distribution": {
"packwiz_index_refreshed": true,
"prism_modrinth_index_updated": true,
"shared_client_server_mrpack": true,
"mcef_client_only": true
}
}
@@ -0,0 +1,51 @@
{
"schema_version": 1,
"status": "released",
"released_in": "26.2.0-alpha.235",
"temporary_until": "2026-09-07",
"pack": {
"source_version": "26.2.0-alpha.234",
"target_version": "26.2.0-alpha.235"
},
"modules": {
"zevent": {
"source_module_version": "0.0.0-alpha.2",
"target_module_version": "0.0.0-alpha.3",
"data_version": 1,
"network_protocol": 1,
"client_and_server_required": true
}
},
"playback": {
"mcef_removed": true,
"native_hls_decoder": true,
"h264_video": true,
"aac_audio": true,
"maximum_quality_height": 720,
"automatic_retry_seconds": 30,
"offline_feedback": true
},
"dependencies": {
"dream_displays": {
"version": "1.9.6",
"modrinth_project_id": "4IIKQyBu",
"modrinth_version_id": "GxyW2ksW",
"environment": "client_and_server"
}
},
"compatibility": {
"existing_saves_supported": true,
"new_world_required": false,
"save_reset_required": false,
"data_version_changed": false,
"network_protocol_changed": false,
"zevent_client_server_update_required": true
},
"distribution": {
"packwiz_index_refreshed": true,
"prism_modrinth_index_updated": true,
"shared_client_server_mrpack": true,
"mcef_removed": true,
"dream_displays_required": true
}
}
@@ -0,0 +1,43 @@
{
"schema_version": 1,
"status": "released",
"released_in": "26.2.0-alpha.236",
"pack": {
"source_version": "26.2.0-alpha.235",
"target_version": "26.2.0-alpha.236"
},
"removed_module": {
"id": "zevent",
"source_module_version": "0.0.0-alpha.3",
"target_module_version": null,
"source_directory_removed": true,
"client_jar_removed": true,
"server_jar_removed": true,
"registry_ids_removed": [
"zevent:zevent_screen"
]
},
"replacement": {
"module": "dreamdisplays",
"version": "1.9.6",
"modrinth_project_id": "4IIKQyBu",
"modrinth_version_id": "GxyW2ksW",
"client_and_server_required": true,
"automatic_screen_conversion": false
},
"compatibility": {
"world_reset_required": false,
"world_backup_recommended": true,
"existing_zevent_screens_must_be_replaced": true,
"remaining_module_data_preserved": true,
"remaining_network_protocols_changed": false,
"client_and_server_must_remove_zevent": true
},
"distribution": {
"packwiz_index_refreshed": true,
"prism_modrinth_index_updated": true,
"zevent_jar_absent": true,
"dream_displays_required": true,
"distributed_module_count": 10
}
}
@@ -0,0 +1,33 @@
{
"schema_version": 1,
"status": "released",
"released_in": "26.2.0-alpha.237",
"pack": {
"source_version": "26.2.0-alpha.236",
"target_version": "26.2.0-alpha.237"
},
"dependency": {
"id": "dreamdisplays",
"source_version": "1.9.6",
"target_version": "1.10.0-dev",
"target_filename": "dreamdisplays-fabric-26.2-audio-buffer-test.jar",
"sha256": "cea9efcea0401ebb44a471f43539c582ffae02fbd9b36d393c7aacdfc198ec74",
"client_and_server_required": true,
"pinned_test_build": true,
"audio_buffer_fix": true
},
"compatibility": {
"world_reset_required": false,
"save_migration_required": false,
"sanctuary_module_versions_changed": false,
"network_protocols_changed": false,
"client_and_server_must_update": true
},
"distribution": {
"packwiz_index_refreshed": true,
"prism_modrinth_index_updated": true,
"old_dreamdisplays_jar_removed": true,
"zevent_jar_absent": true,
"distributed_module_count": 10
}
}
@@ -0,0 +1,34 @@
{
"schema_version": 1,
"status": "released",
"released_in": "26.2.0-alpha.238",
"pack": {
"source_version": "26.2.0-alpha.237",
"target_version": "26.2.0-alpha.238"
},
"dependency": {
"id": "dreamdisplays",
"source_version": "1.10.0-dev",
"target_version": "1.10.0-dev",
"source_filename": "dreamdisplays-fabric-26.2-audio-buffer-test.jar",
"target_filename": "dreamdisplays-fabric-26.2-audio-buffer-test-2.jar",
"sha256": "fae5681de3209dba98d6359fb878e7cf230cde08e96c08fb71394aed35823188",
"client_and_server_required": true,
"pinned_test_build": true,
"audio_buffer_fix": true
},
"compatibility": {
"world_reset_required": false,
"save_migration_required": false,
"sanctuary_module_versions_changed": false,
"network_protocols_changed": false,
"client_and_server_must_update": true
},
"distribution": {
"packwiz_index_refreshed": true,
"prism_modrinth_index_updated": true,
"old_dreamdisplays_jar_removed": true,
"zevent_jar_absent": true,
"distributed_module_count": 10
}
}
@@ -0,0 +1,57 @@
{
"schema_version": 1,
"status": "released",
"released_in": "26.2.0-alpha.239",
"pack": {
"source_version": "26.2.0-alpha.238",
"target_version": "26.2.0-alpha.239"
},
"removed_dependencies": [
{
"id": "dreamdisplays",
"version": "1.10.0-dev",
"filename": "dreamdisplays-fabric-26.2-audio-buffer-test-2.jar"
}
],
"added_dependencies": [
{
"id": "mydisplay",
"version": "1.0.0",
"filename": "mydisplay-1.0.0-sanctuary.jar",
"sha256": "22cd63386bc7ba6961f586949f65b4a2a6cb335d75aa155bcdf0a633f1f9d907",
"side": "both",
"manifest_client_dependencies_optional": true,
"languages": ["en_us", "fr_fr", "ru_ru"]
},
{
"id": "watermedia",
"version": "3.0.0.23",
"filename": "watermedia-3.0.0.23.jar",
"sha256": "c3baefc8fbb005e349b3424f7df8f9d89b18524d5f7a358c6df73ca5e1d98b38",
"side": "client"
},
{
"id": "watermedia_binaries",
"version": "3.0.0.6",
"filename": "watermedia-binaries-3.0.0.6.jar",
"sha256": "2d9333061c3d2330033fe68837a31f546dbf58984455b0cfec0deeff6b1f121e",
"side": "client"
}
],
"compatibility": {
"world_reset_required": false,
"save_migration_required": false,
"sanctuary_module_versions_changed": false,
"network_protocols_changed": false,
"client_and_server_must_update": true,
"existing_dreamdisplays_blocks_converted": false
},
"distribution": {
"packwiz_index_refreshed": true,
"prism_modrinth_index_updated": true,
"old_dreamdisplays_jar_removed": true,
"watermedia_client_only": true,
"zevent_jar_absent": true,
"distributed_module_count": 10
}
}
@@ -0,0 +1,38 @@
{
"schema_version": 1,
"status": "released",
"released_in": "26.2.0-alpha.240",
"pack": {
"source_version": "26.2.0-alpha.239",
"target_version": "26.2.0-alpha.240"
},
"dependency": {
"id": "mydisplay",
"version": "1.0.0",
"source_filename": "mydisplay-1.0.0-sanctuary.jar",
"target_filename": "mydisplay-1.0.0-sanctuary-2.jar",
"source_sha256": "22cd63386bc7ba6961f586949f65b4a2a6cb335d75aa155bcdf0a633f1f9d907",
"target_sha256": "9ff1645731dc2dfdc248e3c07ac923aba9dfe001a1580a981767f948d4f7427f",
"side": "both",
"manifest_client_dependencies_optional": true,
"languages": ["en_us", "fr_fr", "ru_ru"],
"screen_picker_added": true
},
"compatibility": {
"world_reset_required": false,
"save_migration_required": false,
"sanctuary_module_versions_changed": false,
"network_protocols_changed": false,
"client_and_server_must_update": true,
"existing_mydisplay_blocks_compatible": true
},
"distribution": {
"packwiz_index_refreshed": true,
"prism_modrinth_index_updated": true,
"old_mydisplay_jar_removed": true,
"direct_download_dependencies_skipped_by_modrinth_updater": true,
"watermedia_client_only": true,
"zevent_jar_absent": true,
"distributed_module_count": 10
}
}
@@ -0,0 +1,53 @@
{
"schema_version": 1,
"status": "released",
"released_in": "26.2.0-alpha.241",
"pack": {
"source_version": "26.2.0-alpha.240",
"target_version": "26.2.0-alpha.241"
},
"dependency": {
"id": "mydisplay",
"version": "1.0.0",
"source_filename": "mydisplay-1.0.0-sanctuary-2.jar",
"target_filename": "mydisplay-1.0.0-sanctuary-3.jar",
"source_sha256": "9ff1645731dc2dfdc248e3c07ac923aba9dfe001a1580a981767f948d4f7427f",
"provided_sha256": "45a7dc0f9852ef776bce1ee81f1683b6333e773299b7e5eb96363cf885c8c6ef",
"target_sha256": "97a8b7fc79bb68d661e6d0563c54b90179e6a34339c22a433f858cdff6862cbc",
"side": "both",
"manifest_client_dependencies_optional": true,
"languages": ["en_us", "fr_fr", "ru_ru"]
},
"optimizations": {
"argb_to_rgba_int_buffer": true,
"distance_based_decode_lod": true,
"distant_live_quality_cap_after_five": true,
"proximity_prioritized_upload_budget": true,
"last_frame_kept_when_deferred": true,
"offscreen_cpu_upload_paused": true,
"draw_distance_blocks": 128,
"single_wall_render_pass": true,
"shared_streamlink_by_channel_and_quality": true,
"staggered_player_start": true,
"muted_audio_decoder_disabled": true,
"screenshot_render_buffer_reset": true,
"screenshot_upload_pause": true
},
"compatibility": {
"world_reset_required": false,
"save_migration_required": false,
"sanctuary_module_versions_changed": false,
"network_protocols_changed": false,
"client_and_server_must_update": true,
"existing_mydisplay_blocks_compatible": true,
"all_screens_remain_visible": true
},
"distribution": {
"packwiz_index_refreshed": true,
"prism_modrinth_index_updated": true,
"old_mydisplay_jar_removed": true,
"watermedia_client_only": true,
"zevent_jar_absent": true,
"distributed_module_count": 10
}
}
@@ -0,0 +1,43 @@
{
"schema_version": 1,
"status": "released",
"released_in": "26.2.0-alpha.242",
"pack": {
"source_version": "26.2.0-alpha.241",
"target_version": "26.2.0-alpha.242"
},
"dependency": {
"id": "mydisplay",
"version": "1.0.0",
"source_filename": "mydisplay-1.0.0-sanctuary-3.jar",
"target_filename": "mydisplay-1.0.0-sanctuary-4.jar",
"source_sha256": "97a8b7fc79bb68d661e6d0563c54b90179e6a34339c22a433f858cdff6862cbc",
"provided_sha256": "4c1bf4aa7140e613ff520b04263cb4733561c2e04b39a21b3cb7783f610119b9",
"target_sha256": "6c3bb4e7f562c6730a6cfa23a162a1fa0641aed7105cb6b809bb5a097aa2e559",
"side": "both",
"manifest_client_dependencies_optional": true,
"languages": ["en_us", "fr_fr", "ru_ru"],
"changed_classes": [
"com.mydisplay.client.media.DisplayPerf",
"com.mydisplay.client.media.DisplaySession",
"com.mydisplay.client.media.WaterMediaBackend",
"com.mydisplay.client.media.WaterMediaBackend$Playback"
]
},
"compatibility": {
"world_reset_required": false,
"save_migration_required": false,
"sanctuary_module_versions_changed": false,
"network_protocols_changed": false,
"client_and_server_must_update": true,
"existing_mydisplay_blocks_compatible": true
},
"distribution": {
"packwiz_index_refreshed": true,
"prism_modrinth_index_updated": true,
"old_mydisplay_jar_removed": true,
"watermedia_client_only": true,
"zevent_jar_absent": true,
"distributed_module_count": 10
}
}
+1 -1
View File
@@ -10,7 +10,7 @@ Cette commande :
1. reconstruit les dix modules Sanctuary ;
2. remplace leurs anciens JAR dans ce pack ;
3. sélectionne la dernière version Modrinth marquée `release` de Fabric API, Simple Voice Chat, Sodium et Iris, ou `beta` pour JEI qui ne publie pas de release 26.2 ; aucune alpha n'est acceptée ;
3. sélectionne la dernière version Modrinth marquée `release` de Fabric API, Simple Voice Chat, Sodium et Iris, ou `beta` pour JEI qui ne publie pas de release 26.2 ; aucune alpha n'est acceptée et les dépendances privées en téléchargement direct restent épinglées sans interrogation Modrinth ;
4. rafraîchit `index.toml` ;
5. exporte `build/distributions/sanctuary-<version>-packwiz.mrpack` ;
6. produit `build/distributions/sanctuary-<version>-packwiz-site.zip`, à extraire sur un hébergement HTTPS statique.
+25 -10
View File
@@ -14,15 +14,15 @@ hash = "41f4f4c88c71d0a23446d5014f7407d55ad477441847099cad24492b032335a0"
[[files]]
file = "mods/ambiance-0.0.0-alpha.22.jar"
hash = "c72a87d3ea187e65f3d459d18954bfc557deeb741deea143e610604a3af5985f"
hash = "960322cd387c47588f6fd76202c0abf2c9993cf0effa82e19f8bb31d86ab07a4"
[[files]]
file = "mods/anotherworld-0.0.0-alpha.58.jar"
hash = "51f0122c222317fd65a642f8568a3d822732a874ecd5e4433592ae96e2a535a8"
hash = "8936b0a5bf3f122afc752cba5e8d96ac92cfb1458785c3fac13d25f51cb4814c"
[[files]]
file = "mods/events-0.0.0-alpha.0.jar"
hash = "1c1623d4d40973346d3143657461e916e38142a867e7a6cf0da054ad998dbb0e"
hash = "1fe3fc2f6ca371f8dde44e0bb535b1e70df0e9c4de3f56002f9f3ec6d5826745"
[[files]]
file = "mods/fabric-api.pw.toml"
@@ -31,7 +31,7 @@ metafile = true
[[files]]
file = "mods/iliketomoveit-0.0.0-alpha.18.jar"
hash = "6a38653f4898e2fe2df6f8935b516073185f7c0d3913164e769ebafcc487b318"
hash = "a964e17e139757f948f67aeae73730c97af2c5932bffcc4d366d084c7cdf53ee"
[[files]]
file = "mods/iris.pw.toml"
@@ -40,7 +40,7 @@ metafile = true
[[files]]
file = "mods/itsalive-0.0.0-alpha.38.jar"
hash = "0e40259acf98cf3d64dfdebe3441ffb3a09aa64de6cf1b2c8dcaa4af7c8d0e82"
hash = "97a29e1d4875265854d0b4757c6e0c286a35e626df1b8d13b465b85502381309"
[[files]]
file = "mods/jei.pw.toml"
@@ -49,23 +49,28 @@ metafile = true
[[files]]
file = "mods/masterkey-0.0.0-alpha.9.jar"
hash = "b42b25f1eabc0b1e7660f0071e8f105d82ceb078b4bd9b7c570df8ce2c59601c"
hash = "95265c4aa4ebb89d5e89e3245917f47a4044db192f88995cfee2bdf63f348b79"
[[files]]
file = "mods/mydisplay.pw.toml"
hash = "a5538de6d9c38cf37ff0882451fd025c8a24c7cb250482baa4ca5918d1129b06"
metafile = true
[[files]]
file = "mods/onlyfun-0.0.0-alpha.27.jar"
hash = "4e2c95c54fa3f591d3e1b6f7454211caaa4fb5f7ef31cd5b6aa477d03be18e2e"
hash = "dcd5003390c132f3421c12550f851846548b4d1cc541d3ada0deb35c65bb3f54"
[[files]]
file = "mods/ouch-0.0.0-alpha.11.jar"
hash = "8dcec6870fba091adda5df2a2f719e97d0f5dcf86f14c5f99035c7aebaf6c119"
hash = "08f3355b0240c30a65314e8173f2268a1cf4b0a5ae4dac1766e49939e3e5a256"
[[files]]
file = "mods/redstoner-0.0.0-alpha.20.jar"
hash = "669ca9a357874448c68ca45fdf26d827d5094b1ef1ee07f948a564b33cc1a79c"
hash = "39f0cba6213e4efcd40b8b72cf7812b56315e1d637803a87a2672b00bc4ee4a0"
[[files]]
file = "mods/sanctuary-0.0.0-alpha.139.jar"
hash = "62d06cfe57badc5295fdcaf1ad9d0eb6d7ba67345bdc5fc8ca6d689817f70c01"
hash = "22a37980bd7b121cbfe65e853a6c6a9f8549bbf4af9b4fdd5f22dbd04df273b0"
[[files]]
file = "mods/simple-voice-chat.pw.toml"
@@ -76,3 +81,13 @@ metafile = true
file = "mods/sodium.pw.toml"
hash = "f06c0b9150da9877b99cb265d037c067e509340d9bf347e02cef2043c110e963"
metafile = true
[[files]]
file = "mods/watermedia-binaries.pw.toml"
hash = "1b7c4327062eb0f70d815a2461b0ded2a2495eed8fca0fd00d75467a4aa777da"
metafile = true
[[files]]
file = "mods/watermedia.pw.toml"
hash = "c99e2e5e4d26ec3414e6af55f252091fd728a52f1a97c69a14cacdda7af15a88"
metafile = true
+9
View File
@@ -0,0 +1,9 @@
name = "MyDisplay"
filename = "mydisplay-1.0.0-sanctuary-4.jar"
side = "both"
pin = true
[download]
url = "https://download.botsu.cloud/sanctuary/artifacts/mydisplay-1.0.0-sanctuary-4.jar"
hash-format = "sha256"
hash = "6c3bb4e7f562c6730a6cfa23a162a1fa0641aed7105cb6b809bb5a097aa2e559"
@@ -0,0 +1,9 @@
name = "WaterMedia: Binaries"
filename = "watermedia-binaries-3.0.0.6.jar"
side = "client"
pin = true
[download]
url = "https://download.botsu.cloud/sanctuary/artifacts/watermedia-binaries-3.0.0.6.jar"
hash-format = "sha256"
hash = "2d9333061c3d2330033fe68837a31f546dbf58984455b0cfec0deeff6b1f121e"
+9
View File
@@ -0,0 +1,9 @@
name = "WaterMedia"
filename = "watermedia-3.0.0.23.jar"
side = "client"
pin = true
[download]
url = "https://download.botsu.cloud/sanctuary/artifacts/watermedia-3.0.0.23.jar"
hash-format = "sha256"
hash = "c3baefc8fbb005e349b3424f7df8f9d89b18524d5f7a358c6df73ca5e1d98b38"
+2 -2
View File
@@ -1,12 +1,12 @@
name = "Sanctuary 26.2"
author = "KOKA99CAB"
version = "26.2.0-alpha.233"
version = "26.2.0-alpha.242"
pack-format = "packwiz:1.1.0"
[index]
file = "index.toml"
hash-format = "sha256"
hash = "c666b031c2ac27a8f01cc4d12d6e471ea7471e9e5e7ce02567ee8a6649f9142a"
hash = "0a55cea4e39c4df93a2d52133295afdf55798e23516f1209cab56d50e5339051"
[versions]
fabric = "0.19.3"
+46 -1
View File
@@ -3,7 +3,7 @@
"game": "minecraft",
"name": "Sanctuary 26.2",
"summary": "Sanctuary 26.2 — progression, exploration, worldgen and multiplayer systems by KOKA99CAB.",
"versionId": "26.2.0-alpha.233",
"versionId": "26.2.0-alpha.242",
"files": [
{
"path": "mods/fabric-api-0.159.0+26.2.jar",
@@ -79,6 +79,51 @@
"https://cdn.modrinth.com/data/YL57xq9U/versions/oaD6KQls/iris-fabric-1.11.2%2Bmc26.2.jar"
],
"fileSize": 2820763
},
{
"path": "mods/mydisplay-1.0.0-sanctuary-4.jar",
"hashes": {
"sha1": "2253da2b8e1b8d0111dc63a0d926022acef2c422",
"sha512": "a8fcf35c6d465ef16140a5c699762939aa12e4fccae3891fbbdb38e4f14ba4cfd978193e2ef2c165ecc820927104c5e35b42fd65eeb802a6280dc2faca0e39bd"
},
"env": {
"client": "required",
"server": "required"
},
"downloads": [
"https://download.botsu.cloud/sanctuary/artifacts/mydisplay-1.0.0-sanctuary-4.jar"
],
"fileSize": 112172
},
{
"path": "mods/watermedia-3.0.0.23.jar",
"hashes": {
"sha1": "a644ab079cc9ed19a62b181e0252be5a9a422166",
"sha512": "b2f9c31828285bf7cb039b91e8756b35c2a9e425d614d9c767bdcd752475a7abf4f025d6f6005d74f5c610c298fdadf01e53b29124d1d9fd03554fff71b1993b"
},
"env": {
"client": "required",
"server": "unsupported"
},
"downloads": [
"https://download.botsu.cloud/sanctuary/artifacts/watermedia-3.0.0.23.jar"
],
"fileSize": 3625444
},
{
"path": "mods/watermedia-binaries-3.0.0.6.jar",
"hashes": {
"sha1": "8f132fc2fec0fe91a9a085c11088f0773da187fc",
"sha512": "45b5c1d645f75fb54de4d7e92b54f50372bd9d24c7543b57e6d32ba6b35ce346250bc871ba1a39c8b1988a423d2fca75a4806ae75af502d194455caabb470897"
},
"env": {
"client": "required",
"server": "unsupported"
},
"downloads": [
"https://download.botsu.cloud/sanctuary/artifacts/watermedia-binaries-3.0.0.6.jar"
],
"fileSize": 143725808
}
],
"dependencies": {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"schema_version": 1,
"pack_version": "26.2.0-alpha.233",
"pack_version": "26.2.0-alpha.242",
"release_channel": "alpha",
"minecraft_version": "26.2",
"fabric_loader_version": "0.19.3",
@@ -9,7 +9,7 @@
"project_id": "FgOqyAXd",
"project_slug": "sanctuary-koka99",
"project_url": "https://modrinth.com/modpack/sanctuary-koka99",
"primary_file": "sanctuary-26.2.0-alpha.233.mrpack",
"primary_file": "sanctuary-26.2.0-alpha.242.mrpack",
"shared_client_server_pack": true,
"update_owner": "packwiz-installer",
"check_on_launch": true,
+6
View File
@@ -58,6 +58,12 @@ def main() -> int:
for metafile in metafiles:
text = metafile.read_text(encoding="utf-8")
if "[update.modrinth]" not in text:
label = field(text, "name")
if not re.search(r"^\s*pin\s*=\s*true\s*$", text, re.MULTILINE):
raise RuntimeError(f"Direct-download dependency {label!r} must stay pinned")
print(f"{label}: pinned direct download, skipped", flush=True)
continue
project_id = field(text, "mod-id")
current_version = field(text, "version")
release, channel = latest_supported(project_id)
+1 -2
View File
@@ -21,6 +21,5 @@ include(
"onlyfun",
"sanctuary",
"events",
"masterkey",
"zevent"
"masterkey"
)
-19
View File
@@ -1,19 +0,0 @@
# ZEVENT
Mod Fabric événementiel distribué séparément du pack Sanctuary. Il ajoute un écran mural multibloc de 9×5 blocs qui lit une chaîne Twitch en direct.
## Utilisation
1. Poser l'écran contre un mur libre de 9×5 blocs. Le bloc visé devient le centre de l'écran.
2. Faire un clic droit sur n'importe laquelle de ses 45 cases.
3. Saisir un nom de chaîne (`zeventfr`) ou coller une URL Twitch, puis valider.
La chaîne est enregistrée par le serveur et synchronisée aux joueurs. Le client rend le lecteur Twitch officiel dans la surface. Son volume utilise la catégorie **Disques** de Minecraft et diminue jusqu'au silence à 64 blocs. Seul l'écran le plus proche est audible et deux flux visibles au maximum sont décodés par client.
## Dépendance et limites
Le client exige [MCEF Modern](https://modrinth.com/mod/mcef-modern) `0.3.3+mc26.2.jcef146.0.10`. MCEF télécharge son runtime Chromium natif lors de la première utilisation ; une connexion Internet est donc nécessaire. Le lecteur utilise le backend OpenGL pris en charge par cette version de MCEF.
Le serveur ne reçoit jamais d'URL arbitraire : les entrées sont ramenées à un identifiant Twitch de 1 à 25 caractères (`a-z`, `0-9`, `_`). La page locale est liée uniquement à l'interface loopback et n'expose que le lecteur officiel `player.twitch.tv`.
Documentation du lecteur : [Twitch Embedded Video & Clips](https://dev.twitch.tv/docs/embed/video-and-clips/).
-19
View File
@@ -1,19 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<defs>
<linearGradient id="screen" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#18221d"/>
<stop offset="1" stop-color="#060807"/>
</linearGradient>
<linearGradient id="live" x1="0" y1="1" x2="1" y2="0">
<stop offset="0" stop-color="#1bdc7a"/>
<stop offset="1" stop-color="#b7ff39"/>
</linearGradient>
</defs>
<rect width="1024" height="1024" rx="176" fill="#090d0b"/>
<rect x="94" y="168" width="836" height="520" rx="54" fill="url(#screen)" stroke="url(#live)" stroke-width="28"/>
<path d="M364 300h330L458 556h252" fill="none" stroke="#f5fff8" stroke-width="74" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M292 807h440" stroke="#314139" stroke-width="42" stroke-linecap="round"/>
<path d="M512 688v119" stroke="#314139" stroke-width="42"/>
<circle cx="829" cy="258" r="42" fill="#ff405f"/>
<circle cx="829" cy="258" r="77" fill="none" stroke="#ff405f" stroke-width="14" opacity=".32"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

-14
View File
@@ -1,14 +0,0 @@
repositories {
mavenCentral()
}
dependencies {
implementation "maven.modrinth:mcef-modern:FOXbUPNd"
compileOnly "me.friwi:jcef-api:jcef-d3de827+cef-146.0.10+g8219561+chromium-146.0.7680.179"
testImplementation "org.junit.jupiter:junit-jupiter:5.11.4"
testRuntimeOnly "org.junit.platform:junit-platform-launcher:1.11.4"
}
tasks.named("test") {
useJUnitPlatform()
}
@@ -1,30 +0,0 @@
package fr.koka99cab.sanctuary26.zevent;
import fr.koka99cab.sanctuary26.zevent.network.ZeventNetworking;
import fr.koka99cab.sanctuary26.zevent.registry.ZeventRegistries;
import net.fabricmc.api.ModInitializer;
import net.minecraft.resources.Identifier;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public final class ZeventMod implements ModInitializer {
public static final String MOD_ID = "zevent";
public static final String DISPLAY_NAME = "ZEVENT";
public static final int CURRENT_DATA_VERSION = 1;
public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID);
public ZeventMod() {
}
public static Identifier id(String path) {
return Identifier.fromNamespaceAndPath(MOD_ID, path);
}
@Override
public void onInitialize() {
ZeventRegistries.initialize();
ZeventNetworking.initialize();
LOGGER.info("[{}] 9x5 Twitch screen ready (data v{}, network v{}).",
DISPLAY_NAME, CURRENT_DATA_VERSION, ZeventNetworking.PROTOCOL_VERSION);
}
}
@@ -1,206 +0,0 @@
package fr.koka99cab.sanctuary26.zevent.block;
import com.mojang.serialization.MapCodec;
import fr.koka99cab.sanctuary26.zevent.block.entity.ZeventScreenBlockEntity;
import fr.koka99cab.sanctuary26.zevent.network.ZeventNetworking;
import java.util.Map;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.util.RandomSource;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.context.BlockPlaceContext;
import net.minecraft.world.level.BlockGetter;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.LevelReader;
import net.minecraft.world.level.ScheduledTickAccess;
import net.minecraft.world.level.block.BaseEntityBlock;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.Mirror;
import net.minecraft.world.level.block.RenderShape;
import net.minecraft.world.level.block.Rotation;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockBehaviour;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.StateDefinition;
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
import net.minecraft.world.level.block.state.properties.EnumProperty;
import net.minecraft.world.level.block.state.properties.IntegerProperty;
import net.minecraft.world.phys.BlockHitResult;
import net.minecraft.world.phys.shapes.CollisionContext;
import net.minecraft.world.phys.shapes.VoxelShape;
/** One block type represents all 45 cells; only its central cell owns playback state. */
public final class ZeventScreenBlock extends BaseEntityBlock {
public static final MapCodec<ZeventScreenBlock> CODEC = simpleCodec(ZeventScreenBlock::new);
public static final EnumProperty<Direction> FACING = BlockStateProperties.HORIZONTAL_FACING;
public static final IntegerProperty COLUMN = IntegerProperty.create("column", 0, ZeventScreenLayout.WIDTH - 1);
public static final IntegerProperty ROW = IntegerProperty.create("row", 0, ZeventScreenLayout.HEIGHT - 1);
private static final ThreadLocal<Boolean> REMOVING_STRUCTURE = ThreadLocal.withInitial(() -> false);
private static final Map<Direction, VoxelShape> SHAPES = Map.of(
Direction.NORTH, Block.box(0.0D, 0.0D, 14.0D, 16.0D, 16.0D, 16.0D),
Direction.SOUTH, Block.box(0.0D, 0.0D, 0.0D, 16.0D, 16.0D, 2.0D),
Direction.WEST, Block.box(14.0D, 0.0D, 0.0D, 16.0D, 16.0D, 16.0D),
Direction.EAST, Block.box(0.0D, 0.0D, 0.0D, 2.0D, 16.0D, 16.0D)
);
public ZeventScreenBlock(BlockBehaviour.Properties properties) {
super(properties);
registerDefaultState(stateDefinition.any()
.setValue(FACING, Direction.NORTH)
.setValue(COLUMN, ZeventScreenLayout.CONTROLLER_COLUMN)
.setValue(ROW, ZeventScreenLayout.CONTROLLER_ROW));
}
@Override
public MapCodec<ZeventScreenBlock> codec() {
return CODEC;
}
@Override
public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
return new ZeventScreenBlockEntity(pos, state);
}
@Override
protected RenderShape getRenderShape(BlockState state) {
return RenderShape.MODEL;
}
@Override
public BlockState getStateForPlacement(BlockPlaceContext context) {
Direction facing = context.getClickedFace();
if (!facing.getAxis().isHorizontal()) return null;
BlockPos controller = context.getClickedPos();
if (!canPlaceScreen(context.getLevel(), controller, facing)) return null;
return defaultBlockState().setValue(FACING, facing);
}
@Override
public void setPlacedBy(Level level, BlockPos pos, BlockState state, LivingEntity placer, ItemStack stack) {
super.setPlacedBy(level, pos, state, placer, stack);
if (level.isClientSide() || !ZeventScreenLayout.isController(state)) return;
Direction facing = state.getValue(FACING);
for (int row = 0; row < ZeventScreenLayout.HEIGHT; row++) {
for (int column = 0; column < ZeventScreenLayout.WIDTH; column++) {
if (column == ZeventScreenLayout.CONTROLLER_COLUMN
&& row == ZeventScreenLayout.CONTROLLER_ROW) continue;
BlockPos part = ZeventScreenLayout.partPos(pos, facing, column, row);
level.setBlock(part, defaultBlockState().setValue(FACING, facing)
.setValue(COLUMN, column).setValue(ROW, row), Block.UPDATE_ALL);
}
}
}
@Override
protected boolean canSurvive(BlockState state, LevelReader level, BlockPos pos) {
Direction facing = state.getValue(FACING);
BlockPos support = pos.relative(facing.getOpposite());
return level.getBlockState(support).isFaceSturdy(level, support, facing);
}
@Override
protected BlockState updateShape(BlockState state, LevelReader level, ScheduledTickAccess ticks,
BlockPos pos, Direction direction, BlockPos neighborPos, BlockState neighborState, RandomSource random) {
return direction == state.getValue(FACING).getOpposite() && !state.canSurvive(level, pos)
? Blocks.AIR.defaultBlockState()
: super.updateShape(state, level, ticks, pos, direction, neighborPos, neighborState, random);
}
@Override
protected VoxelShape getShape(BlockState state, BlockGetter level, BlockPos pos, CollisionContext context) {
return SHAPES.get(state.getValue(FACING));
}
@Override
protected InteractionResult useItemOn(ItemStack stack, BlockState state, Level level, BlockPos pos,
Player player, InteractionHand hand, BlockHitResult hit) {
return openOptions(state, level, pos, player);
}
@Override
protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos,
Player player, BlockHitResult hit) {
return openOptions(state, level, pos, player);
}
@Override
public BlockState playerWillDestroy(Level level, BlockPos pos, BlockState state, Player player) {
if (!level.isClientSide()) removeOtherParts(level, pos, state);
return super.playerWillDestroy(level, pos, state, player);
}
@Override
protected void affectNeighborsAfterRemoval(BlockState state, ServerLevel level, BlockPos pos,
boolean movedByPiston) {
if (!REMOVING_STRUCTURE.get()) removeOtherParts(level, pos, state);
super.affectNeighborsAfterRemoval(state, level, pos, movedByPiston);
}
@Override
protected void createBlockStateDefinition(StateDefinition.Builder<Block, BlockState> builder) {
builder.add(FACING, COLUMN, ROW);
}
@Override
protected BlockState rotate(BlockState state, Rotation rotation) {
return state.setValue(FACING, rotation.rotate(state.getValue(FACING)));
}
@Override
protected BlockState mirror(BlockState state, Mirror mirror) {
return state.rotate(mirror.getRotation(state.getValue(FACING)));
}
private static boolean canPlaceScreen(LevelReader level, BlockPos controller, Direction facing) {
for (int row = 0; row < ZeventScreenLayout.HEIGHT; row++) {
for (int column = 0; column < ZeventScreenLayout.WIDTH; column++) {
BlockPos part = ZeventScreenLayout.partPos(controller, facing, column, row);
if (level.isOutsideBuildHeight(part) || !level.getBlockState(part).isAir()) return false;
BlockPos support = part.relative(facing.getOpposite());
if (!level.getBlockState(support).isFaceSturdy(level, support, facing)) return false;
}
}
return true;
}
private static InteractionResult openOptions(BlockState state, Level level, BlockPos pos, Player player) {
if (level.isClientSide()) return InteractionResult.SUCCESS;
BlockPos controller = ZeventScreenLayout.controllerPos(pos, state);
if (player instanceof ServerPlayer serverPlayer
&& level.getBlockEntity(controller) instanceof ZeventScreenBlockEntity screen) {
ZeventNetworking.openOptions(serverPlayer, screen);
return InteractionResult.SUCCESS_SERVER;
}
return InteractionResult.CONSUME;
}
private static void removeOtherParts(Level level, BlockPos brokenPos, BlockState brokenState) {
if (!(brokenState.getBlock() instanceof ZeventScreenBlock)) return;
BlockPos controller = ZeventScreenLayout.controllerPos(brokenPos, brokenState);
Direction facing = brokenState.getValue(FACING);
REMOVING_STRUCTURE.set(true);
try {
for (int row = 0; row < ZeventScreenLayout.HEIGHT; row++) {
for (int column = 0; column < ZeventScreenLayout.WIDTH; column++) {
BlockPos part = ZeventScreenLayout.partPos(controller, facing, column, row);
if (part.equals(brokenPos)) continue;
BlockState candidate = level.getBlockState(part);
if (candidate.getBlock() instanceof ZeventScreenBlock
&& ZeventScreenLayout.controllerPos(part, candidate).equals(controller)) {
level.setBlock(part, Blocks.AIR.defaultBlockState(),
Block.UPDATE_ALL | Block.UPDATE_SUPPRESS_DROPS);
}
}
}
} finally {
REMOVING_STRUCTURE.set(false);
}
}
}
@@ -1,54 +0,0 @@
package fr.koka99cab.sanctuary26.zevent.block;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.world.level.block.state.BlockState;
/** Pure 9x5 layout math shared by placement, interaction and teardown. */
public final class ZeventScreenLayout {
public static final int WIDTH = 9;
public static final int HEIGHT = 5;
public static final int CONTROLLER_COLUMN = WIDTH / 2;
public static final int CONTROLLER_ROW = HEIGHT / 2;
private ZeventScreenLayout() {
}
public static Direction right(Direction facing) {
if (!facing.getAxis().isHorizontal()) throw new IllegalArgumentException("Horizontal facing required");
return facing.getCounterClockWise();
}
public static BlockPos partPos(BlockPos controller, Direction facing, int column, int row) {
validateCell(column, row);
Direction right = right(facing);
return controller.offset(
right.getStepX() * (column - CONTROLLER_COLUMN),
row - CONTROLLER_ROW,
right.getStepZ() * (column - CONTROLLER_COLUMN)
);
}
public static BlockPos controllerPos(BlockPos part, BlockState state) {
Direction facing = state.getValue(ZeventScreenBlock.FACING);
int column = state.getValue(ZeventScreenBlock.COLUMN);
int row = state.getValue(ZeventScreenBlock.ROW);
Direction right = right(facing);
return part.offset(
-right.getStepX() * (column - CONTROLLER_COLUMN),
-(row - CONTROLLER_ROW),
-right.getStepZ() * (column - CONTROLLER_COLUMN)
);
}
public static boolean isController(BlockState state) {
return state.getValue(ZeventScreenBlock.COLUMN) == CONTROLLER_COLUMN
&& state.getValue(ZeventScreenBlock.ROW) == CONTROLLER_ROW;
}
private static void validateCell(int column, int row) {
if (column < 0 || column >= WIDTH || row < 0 || row >= HEIGHT) {
throw new IllegalArgumentException("Invalid ZEVENT screen cell " + column + "," + row);
}
}
}
@@ -1,75 +0,0 @@
package fr.koka99cab.sanctuary26.zevent.block.entity;
import fr.koka99cab.sanctuary26.zevent.ZeventMod;
import fr.koka99cab.sanctuary26.zevent.registry.ZeventRegistries;
import fr.koka99cab.sanctuary26.zevent.twitch.TwitchChannel;
import net.minecraft.core.BlockPos;
import net.minecraft.core.HolderLookup;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.protocol.Packet;
import net.minecraft.network.protocol.game.ClientGamePacketListener;
import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.storage.ValueInput;
import net.minecraft.world.level.storage.ValueOutput;
/** The central cell persists one bounded Twitch channel; all other cells are stateless mirrors. */
public final class ZeventScreenBlockEntity extends BlockEntity {
private static final String DATA_VERSION_KEY = "DataVersion";
private static final String CHANNEL_KEY = "Channel";
private int dataVersion = ZeventMod.CURRENT_DATA_VERSION;
private String channel = TwitchChannel.DEFAULT;
public ZeventScreenBlockEntity(BlockPos pos, BlockState state) {
super(ZeventRegistries.ZEVENT_SCREEN_BLOCK_ENTITY, pos, state);
}
public String channel() {
return channel;
}
public boolean setChannel(String requested) {
if (dataVersion > ZeventMod.CURRENT_DATA_VERSION) return false;
String normalized = TwitchChannel.normalize(requested).orElse(null);
if (normalized == null) return false;
if (normalized.equals(channel)) return true;
channel = normalized;
setChanged();
if (level != null) {
BlockState state = getBlockState();
level.sendBlockUpdated(worldPosition, state, state, Block.UPDATE_CLIENTS);
}
return true;
}
public boolean writable() {
return dataVersion <= ZeventMod.CURRENT_DATA_VERSION;
}
@Override
protected void saveAdditional(ValueOutput output) {
super.saveAdditional(output);
output.putInt(DATA_VERSION_KEY, dataVersion);
output.putString(CHANNEL_KEY, channel);
}
@Override
protected void loadAdditional(ValueInput input) {
super.loadAdditional(input);
dataVersion = input.getIntOr(DATA_VERSION_KEY, 1);
channel = TwitchChannel.normalize(input.getStringOr(CHANNEL_KEY, TwitchChannel.DEFAULT))
.orElse(TwitchChannel.DEFAULT);
}
@Override
public Packet<ClientGamePacketListener> getUpdatePacket() {
return ClientboundBlockEntityDataPacket.create(this);
}
@Override
public CompoundTag getUpdateTag(HolderLookup.Provider lookup) {
return saveWithoutMetadata(lookup);
}
}
@@ -1,29 +0,0 @@
package fr.koka99cab.sanctuary26.zevent.client;
import fr.koka99cab.sanctuary26.zevent.ZeventMod;
import fr.koka99cab.sanctuary26.zevent.client.gui.ZeventScreenOptionsScreen;
import fr.koka99cab.sanctuary26.zevent.client.render.ZeventScreenBlockEntityRenderer;
import fr.koka99cab.sanctuary26.zevent.client.twitch.TwitchBrowserManager;
import fr.koka99cab.sanctuary26.zevent.network.OpenScreenOptionsPayload;
import fr.koka99cab.sanctuary26.zevent.registry.ZeventRegistries;
import net.fabricmc.api.ClientModInitializer;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
import net.fabricmc.fabric.api.client.rendering.v1.BlockEntityRendererRegistry;
public final class ZeventClient implements ClientModInitializer {
@Override
public void onInitializeClient() {
BlockEntityRendererRegistry.register(ZeventRegistries.ZEVENT_SCREEN_BLOCK_ENTITY,
ZeventScreenBlockEntityRenderer::new);
ClientPlayNetworking.registerGlobalReceiver(OpenScreenOptionsPayload.TYPE, (payload, context) ->
context.client().execute(() -> context.client().gui.setScreen(
new ZeventScreenOptionsScreen(context.client().gui.screen(), payload))));
ClientTickEvents.END_CLIENT_TICK.register(TwitchBrowserManager::tick);
ClientPlayConnectionEvents.DISCONNECT.register((handler, client) -> TwitchBrowserManager.clear(client));
ClientLifecycleEvents.CLIENT_STOPPING.register(TwitchBrowserManager::shutdown);
ZeventMod.LOGGER.info("[{}] Twitch browser renderer registered.", ZeventMod.DISPLAY_NAME);
}
}
@@ -1,117 +0,0 @@
package fr.koka99cab.sanctuary26.zevent.client.gui;
import fr.koka99cab.sanctuary26.zevent.network.OpenScreenOptionsPayload;
import fr.koka99cab.sanctuary26.zevent.network.SaveScreenChannelPayload;
import fr.koka99cab.sanctuary26.zevent.twitch.TwitchChannel;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.components.EditBox;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.network.chat.Component;
/** Small in-game configuration surface opened by right-clicking any screen cell. */
public final class ZeventScreenOptionsScreen extends Screen {
private static final int PANEL_WIDTH = 340;
private static final int PANEL_HEIGHT = 184;
private static final int BACKDROP = 0xE0121715;
private static final int BORDER = 0xFF3BEA8C;
private static final int TITLE = 0xFFF5FFF9;
private static final int TEXT = 0xFFC7D7CE;
private static final int ERROR = 0xFFFF6B78;
private final Screen parent;
private final OpenScreenOptionsPayload payload;
private EditBox channel;
private Button save;
private Component validation = Component.empty();
public ZeventScreenOptionsScreen(Screen parent, OpenScreenOptionsPayload payload) {
super(Component.translatable("screen.zevent.options.title"));
this.parent = parent;
this.payload = payload;
}
@Override
protected void init() {
int x = panelX() + 16;
int y = panelY();
channel = addRenderableWidget(new EditBox(font, x, y + 54, PANEL_WIDTH - 32, 20,
Component.translatable("screen.zevent.options.channel")));
channel.setMaxLength(TwitchChannel.MAX_INPUT_LENGTH);
channel.setValue(payload.channel());
channel.setHint(Component.translatable("screen.zevent.options.hint"));
channel.setEditable(payload.writable());
channel.setResponder(value -> refreshValidation());
Button official = addRenderableWidget(Button.builder(
Component.translatable("screen.zevent.options.official"), button -> select("zeventfr"))
.bounds(x, y + 82, 150, 20).build());
Button zerator = addRenderableWidget(Button.builder(
Component.translatable("screen.zevent.options.zerator"), button -> select("zerator"))
.bounds(x + 158, y + 82, 150, 20).build());
official.active = payload.writable();
zerator.active = payload.writable();
save = addRenderableWidget(Button.builder(Component.translatable("gui.done"), button -> submit())
.bounds(x, y + 142, 150, 20).build());
addRenderableWidget(Button.builder(Component.translatable("gui.cancel"), button -> onClose())
.bounds(x + 158, y + 142, 150, 20).build());
refreshValidation();
setInitialFocus(channel);
}
@Override
public void tick() {
refreshValidation();
}
@Override
public void extractRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float tickDelta) {
int x = panelX();
int y = panelY();
graphics.fill(x, y, x + PANEL_WIDTH, y + PANEL_HEIGHT, BACKDROP);
graphics.outline(x, y, PANEL_WIDTH, PANEL_HEIGHT, BORDER);
graphics.centeredText(font, title, width / 2, y + 12, TITLE);
graphics.text(font, Component.translatable("screen.zevent.options.current", payload.channel()),
x + 16, y + 31, TEXT);
graphics.text(font, Component.translatable("screen.zevent.options.channel"), x + 16, y + 43, TEXT);
if (!validation.getString().isEmpty()) graphics.text(font, validation, x + 16, y + 113, ERROR);
super.extractRenderState(graphics, mouseX, mouseY, tickDelta);
}
private void select(String selected) {
if (payload.writable()) channel.setValue(selected);
}
private void refreshValidation() {
if (channel == null || save == null) return;
boolean valid = TwitchChannel.normalize(channel.getValue()).isPresent();
validation = valid ? Component.empty() : Component.translatable("screen.zevent.options.invalid");
save.active = payload.writable() && valid && ClientPlayNetworking.canSend(SaveScreenChannelPayload.TYPE);
}
private void submit() {
String normalized = TwitchChannel.normalize(channel.getValue()).orElse(null);
if (normalized == null || !save.active) return;
ClientPlayNetworking.send(new SaveScreenChannelPayload(payload.pos(), normalized));
onClose();
}
private int panelX() {
return (width - PANEL_WIDTH) / 2;
}
private int panelY() {
return Math.max(2, (height - PANEL_HEIGHT) / 2);
}
@Override
public void onClose() {
if (minecraft != null) minecraft.gui.setScreen(parent);
}
@Override
public boolean isPauseScreen() {
return false;
}
}
@@ -1,95 +0,0 @@
package fr.koka99cab.sanctuary26.zevent.client.render;
import com.mojang.blaze3d.vertex.PoseStack;
import com.mojang.blaze3d.vertex.VertexConsumer;
import fr.koka99cab.sanctuary26.zevent.block.ZeventScreenBlock;
import fr.koka99cab.sanctuary26.zevent.block.ZeventScreenLayout;
import fr.koka99cab.sanctuary26.zevent.block.entity.ZeventScreenBlockEntity;
import fr.koka99cab.sanctuary26.zevent.client.render.state.ZeventScreenRenderState;
import fr.koka99cab.sanctuary26.zevent.client.twitch.TwitchBrowserManager;
import net.minecraft.client.renderer.SubmitNodeCollector;
import net.minecraft.client.renderer.blockentity.BlockEntityRenderer;
import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider;
import net.minecraft.client.renderer.feature.ModelFeatureRenderer;
import net.minecraft.client.renderer.rendertype.RenderTypes;
import net.minecraft.client.renderer.state.level.CameraRenderState;
import net.minecraft.client.renderer.texture.OverlayTexture;
import net.minecraft.core.Direction;
import net.minecraft.util.LightCoordsUtil;
import net.minecraft.world.phys.Vec3;
/** Projects one browser texture over the central block entity's complete 9x5 surface. */
public final class ZeventScreenBlockEntityRenderer
implements BlockEntityRenderer<ZeventScreenBlockEntity, ZeventScreenRenderState> {
private static final int VIEW_DISTANCE = 128;
private static final double VIEW_DISTANCE_SQR = VIEW_DISTANCE * VIEW_DISTANCE;
private static final float HALF_WIDTH = 4.42F;
private static final float HALF_HEIGHT = 2.42F;
private static final float CENTER_TO_FRONT = 0.369F;
public ZeventScreenBlockEntityRenderer(BlockEntityRendererProvider.Context context) {
}
@Override
public ZeventScreenRenderState createRenderState() {
return new ZeventScreenRenderState();
}
@Override
public void extractRenderState(ZeventScreenBlockEntity screen, ZeventScreenRenderState state,
float tickProgress, Vec3 cameraPos, ModelFeatureRenderer.CrumblingOverlay crumblingOverlay) {
BlockEntityRenderer.super.extractRenderState(screen, state, tickProgress, cameraPos, crumblingOverlay);
state.texture = null;
if (!ZeventScreenLayout.isController(screen.getBlockState()) || screen.getLevel() == null) return;
state.facing = screen.getBlockState().getValue(ZeventScreenBlock.FACING);
state.texture = TwitchBrowserManager.markVisible(screen.getLevel(), screen.getBlockPos(), screen.channel());
}
@Override
public void submit(ZeventScreenRenderState state, PoseStack poseStack,
SubmitNodeCollector submitNodeCollector, CameraRenderState cameraRenderState) {
if (state.texture == null) return;
submitNodeCollector.submitCustomGeometry(poseStack,
RenderTypes.entityTranslucentEmissive(state.texture),
(pose, consumer) -> drawScreen(pose, consumer, state.facing));
}
@Override
public boolean shouldRenderOffScreen() {
return true;
}
@Override
public int getViewDistance() {
return VIEW_DISTANCE;
}
@Override
public boolean shouldRender(ZeventScreenBlockEntity screen, Vec3 cameraPos) {
return ZeventScreenLayout.isController(screen.getBlockState())
&& Vec3.atCenterOf(screen.getBlockPos()).distanceToSqr(cameraPos) <= VIEW_DISTANCE_SQR;
}
private static void drawScreen(PoseStack.Pose pose, VertexConsumer consumer, Direction facing) {
Direction right = ZeventScreenLayout.right(facing);
float centerX = 0.5F - facing.getStepX() * CENTER_TO_FRONT;
float centerZ = 0.5F - facing.getStepZ() * CENTER_TO_FRONT;
vertex(consumer, pose, centerX, centerZ, right, facing, -HALF_WIDTH, -HALF_HEIGHT, 0.0F, 1.0F);
vertex(consumer, pose, centerX, centerZ, right, facing, HALF_WIDTH, -HALF_HEIGHT, 1.0F, 1.0F);
vertex(consumer, pose, centerX, centerZ, right, facing, HALF_WIDTH, HALF_HEIGHT, 1.0F, 0.0F);
vertex(consumer, pose, centerX, centerZ, right, facing, -HALF_WIDTH, HALF_HEIGHT, 0.0F, 0.0F);
}
private static void vertex(VertexConsumer consumer, PoseStack.Pose pose, float centerX, float centerZ,
Direction right, Direction normal, float horizontal, float vertical, float u, float v) {
consumer.addVertex(pose,
centerX + right.getStepX() * horizontal,
0.5F + vertical,
centerZ + right.getStepZ() * horizontal)
.setColor(0xFFFFFFFF)
.setUv(u, v)
.setOverlay(OverlayTexture.NO_OVERLAY)
.setLight(LightCoordsUtil.FULL_BRIGHT)
.setNormal(pose, normal.getStepX(), 0.0F, normal.getStepZ());
}
}
@@ -1,10 +0,0 @@
package fr.koka99cab.sanctuary26.zevent.client.render.state;
import net.minecraft.client.renderer.blockentity.state.BlockEntityRenderState;
import net.minecraft.core.Direction;
import net.minecraft.resources.Identifier;
public final class ZeventScreenRenderState extends BlockEntityRenderState {
public Direction facing = Direction.NORTH;
public Identifier texture;
}
@@ -1,34 +0,0 @@
package fr.koka99cab.sanctuary26.zevent.client.twitch;
import com.mojang.blaze3d.textures.GpuTexture;
import com.mojang.blaze3d.textures.GpuTextureView;
import net.dimaskama.mcef.api.MCEFBrowser;
import net.minecraft.client.renderer.texture.AbstractTexture;
/** Texture-manager bridge; MCEF remains the sole owner of the GPU allocation. */
final class BrowserTexture extends AbstractTexture {
private final MCEFBrowser browser;
BrowserTexture(MCEFBrowser browser) {
this.browser = browser;
}
@Override
public GpuTexture getTexture() {
GpuTexture current = browser.getTexture();
if (current == null) throw new IllegalStateException("MCEF browser texture is not ready");
return current;
}
@Override
public GpuTextureView getTextureView() {
GpuTextureView current = browser.getTextureView();
if (current == null) throw new IllegalStateException("MCEF browser texture view is not ready");
return current;
}
@Override
public void close() {
// The browser closes its changing texture and view; the texture manager only drops this bridge.
}
}
@@ -1,206 +0,0 @@
package fr.koka99cab.sanctuary26.zevent.client.twitch;
import fr.koka99cab.sanctuary26.zevent.ZeventMod;
import fr.koka99cab.sanctuary26.zevent.block.ZeventScreenLayout;
import fr.koka99cab.sanctuary26.zevent.block.entity.ZeventScreenBlockEntity;
import fr.koka99cab.sanctuary26.zevent.twitch.TwitchChannel;
import java.io.IOException;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.Map;
import net.dimaskama.mcef.api.MCEFApi;
import net.dimaskama.mcef.api.MCEFBrowser;
import net.minecraft.client.Minecraft;
import net.minecraft.core.BlockPos;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.sounds.SoundSource;
import net.minecraft.world.level.Level;
import net.minecraft.world.phys.Vec3;
/** Owns a small, distance-bounded set of official Twitch browser players. */
public final class TwitchBrowserManager {
private static final int BROWSER_WIDTH = 900;
private static final int BROWSER_HEIGHT = 500;
private static final int MAXIMUM_BROWSERS = 2;
private static final int STALE_TICKS = 40;
private static final int AUDIBLE_FRESH_TICKS = 10;
private static final double AUDIO_DISTANCE = 64.0D;
private static final double AUDIO_DISTANCE_SQR = AUDIO_DISTANCE * AUDIO_DISTANCE;
private static final double FULL_VOLUME_DISTANCE = 4.0D;
private static final TwitchPlayerPageServer PAGE_SERVER = new TwitchPlayerPageServer();
private static final Map<ScreenKey, Session> SESSIONS = new LinkedHashMap<>();
private static boolean initializationNoticeShown;
private TwitchBrowserManager() {
}
public static Identifier markVisible(Level level, BlockPos pos, String requestedChannel) {
Minecraft client = Minecraft.getInstance();
if (client.level != level || client.player == null) return null;
String channel = TwitchChannel.normalize(requestedChannel).orElse(TwitchChannel.DEFAULT);
ScreenKey key = new ScreenKey(level.dimension(), pos.immutable());
Session session = SESSIONS.get(key);
if (session == null) {
if (!admit(client, pos)) return null;
session = new Session(key, channel, textureId(key));
SESSIONS.put(key, session);
}
session.lastSeenTick = level.getGameTime();
if (!session.channel.equals(channel)) session.changeChannel(channel);
if (session.browser == null && !session.starting && !session.failed) start(client, session);
return session.browser != null && session.browser.getTextureView() != null ? session.textureId : null;
}
public static void tick(Minecraft client) {
if (client.level == null || client.player == null) {
clear(client);
return;
}
long now = client.level.getGameTime();
for (Session session : SESSIONS.values().toArray(Session[]::new)) {
boolean wrongLevel = !session.key.dimension.equals(client.level.dimension());
boolean stale = now < session.lastSeenTick || now - session.lastSeenTick > STALE_TICKS;
boolean missing = !client.level.hasChunkAt(session.key.pos)
|| !(client.level.getBlockEntity(session.key.pos) instanceof ZeventScreenBlockEntity screen)
|| !ZeventScreenLayout.isController(screen.getBlockState());
if (wrongLevel || stale || missing) remove(client, session);
}
if (now % 5L == 0L) updateVolumes(client, now);
}
public static void clear(Minecraft client) {
for (Session session : SESSIONS.values().toArray(Session[]::new)) remove(client, session);
SESSIONS.clear();
initializationNoticeShown = false;
}
public static void shutdown(Minecraft client) {
clear(client);
PAGE_SERVER.stop();
}
private static boolean admit(Minecraft client, BlockPos requested) {
if (SESSIONS.size() < MAXIMUM_BROWSERS) return true;
Session farthest = SESSIONS.values().stream().max(Comparator.comparingDouble(
session -> distanceSqr(client, session.key.pos))).orElse(null);
if (farthest == null || distanceSqr(client, requested) >= distanceSqr(client, farthest.key.pos)) return false;
remove(client, farthest);
return true;
}
private static void start(Minecraft client, Session session) {
session.starting = true;
if (!initializationNoticeShown && client.player != null) {
initializationNoticeShown = true;
client.player.sendOverlayMessage(Component.translatable("message.zevent.player.initializing"));
}
MCEFApi.getInstanceFuture().whenComplete((api, error) -> client.execute(() -> {
if (SESSIONS.get(session.key) != session) return;
session.starting = false;
if (error != null) {
session.failed = true;
ZeventMod.LOGGER.error("Unable to initialize MCEF for the ZEVENT screen", error);
return;
}
try {
MCEFBrowser browser = api.createBrowser(PAGE_SERVER.playerUrl(session.channel), false);
browser.resize(BROWSER_WIDTH, BROWSER_HEIGHT);
browser.setFocus(false);
session.browser = browser;
client.getTextureManager().register(session.textureId, new BrowserTexture(browser));
session.textureRegistered = true;
} catch (IOException | RuntimeException | LinkageError exception) {
session.failed = true;
ZeventMod.LOGGER.error("Unable to create the ZEVENT Twitch player", exception);
}
}));
}
private static void updateVolumes(Minecraft client, long now) {
Session nearest = SESSIONS.values().stream()
.filter(session -> session.browser != null && now - session.lastSeenTick <= AUDIBLE_FRESH_TICKS)
.filter(session -> distanceSqr(client, session.key.pos) <= AUDIO_DISTANCE_SQR)
.min(Comparator.comparingDouble(session -> distanceSqr(client, session.key.pos)))
.orElse(null);
for (Session session : SESSIONS.values()) {
float volume = session == nearest ? attenuatedVolume(client, session.key.pos) : 0.0F;
session.updateVolume(volume);
}
}
private static float attenuatedVolume(Minecraft client, BlockPos pos) {
double distance = Math.sqrt(distanceSqr(client, pos));
double attenuation = distance <= FULL_VOLUME_DISTANCE ? 1.0D
: 1.0D - (distance - FULL_VOLUME_DISTANCE) / (AUDIO_DISTANCE - FULL_VOLUME_DISTANCE);
float records = client.options.getFinalSoundSourceVolume(SoundSource.RECORDS);
return Math.clamp((float) attenuation, 0.0F, 1.0F) * records;
}
private static double distanceSqr(Minecraft client, BlockPos pos) {
return client.player == null ? Double.POSITIVE_INFINITY
: client.player.distanceToSqr(Vec3.atCenterOf(pos));
}
private static void remove(Minecraft client, Session session) {
if (SESSIONS.remove(session.key) == null) return;
if (session.browser != null) session.updateVolume(0.0F);
if (session.textureRegistered) client.getTextureManager().release(session.textureId);
if (session.browser != null) {
try {
session.browser.close();
} catch (RuntimeException | LinkageError exception) {
ZeventMod.LOGGER.warn("Unable to close a ZEVENT browser cleanly", exception);
}
session.browser = null;
}
}
private static Identifier textureId(ScreenKey key) {
String dimension = Integer.toUnsignedString(key.dimension.identifier().hashCode(), 36);
return ZeventMod.id("live/" + dimension + "/" + Long.toUnsignedString(key.pos.asLong(), 36));
}
private record ScreenKey(ResourceKey<Level> dimension, BlockPos pos) {
}
private static final class Session {
private final ScreenKey key;
private final Identifier textureId;
private String channel;
private MCEFBrowser browser;
private long lastSeenTick;
private float lastVolume = -1.0F;
private boolean starting;
private boolean failed;
private boolean textureRegistered;
private Session(ScreenKey key, String channel, Identifier textureId) {
this.key = key;
this.channel = channel;
this.textureId = textureId;
}
private void changeChannel(String next) {
channel = next;
if (browser == null) return;
execute("window.zeventSetChannel&&window.zeventSetChannel('" + next + "');");
}
private void updateVolume(float volume) {
if (browser == null || Math.abs(volume - lastVolume) < 0.01F) return;
lastVolume = volume;
execute("window.zeventSetVolume&&window.zeventSetVolume(" + Float.toString(volume) + ");");
}
private void execute(String script) {
try {
String url = browser.getCefBrowser().getURL();
browser.getCefBrowser().executeJavaScript(script, url == null ? "" : url, 0);
} catch (RuntimeException | LinkageError exception) {
ZeventMod.LOGGER.debug("ZEVENT browser command failed: {}", exception.getMessage());
}
}
}
}
@@ -1,108 +0,0 @@
package fr.koka99cab.sanctuary26.zevent.client.twitch;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import fr.koka99cab.sanctuary26.zevent.twitch.TwitchChannel;
import java.io.IOException;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/** Loopback-only origin required by Twitch's official interactive embed. */
final class TwitchPlayerPageServer {
private static final String PLAYER_PATH = "/player/";
private HttpServer server;
private ExecutorService executor;
synchronized String playerUrl(String channel) throws IOException {
if (!TwitchChannel.valid(channel)) throw new IllegalArgumentException("Invalid Twitch channel");
ensureStarted();
return "http://localhost:" + server.getAddress().getPort() + PLAYER_PATH + channel;
}
synchronized void stop() {
if (server != null) {
server.stop(0);
server = null;
}
if (executor != null) {
executor.shutdownNow();
executor = null;
}
}
private void ensureStarted() throws IOException {
if (server != null) return;
server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
executor = Executors.newSingleThreadExecutor(runnable -> {
Thread thread = new Thread(runnable, "ZEVENT Twitch page");
thread.setDaemon(true);
return thread;
});
server.setExecutor(executor);
server.createContext(PLAYER_PATH, this::servePlayer);
server.start();
}
private void servePlayer(HttpExchange exchange) throws IOException {
try (exchange) {
if (!"GET".equals(exchange.getRequestMethod())) {
exchange.sendResponseHeaders(405, -1L);
return;
}
String path = exchange.getRequestURI().getPath();
String channel = path.startsWith(PLAYER_PATH)
? TwitchChannel.normalize(path.substring(PLAYER_PATH.length())).orElse(null)
: null;
if (channel == null) {
exchange.sendResponseHeaders(404, -1L);
return;
}
byte[] body = page(channel).getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "text/html; charset=utf-8");
exchange.getResponseHeaders().set("Cache-Control", "no-store");
exchange.getResponseHeaders().set("X-Content-Type-Options", "nosniff");
exchange.getResponseHeaders().set("Referrer-Policy", "no-referrer");
exchange.getResponseHeaders().set("Content-Security-Policy",
"default-src 'self' https: wss: blob: data:; script-src 'unsafe-inline' https://player.twitch.tv; "
+ "frame-src https://player.twitch.tv; style-src 'unsafe-inline'");
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
}
}
private static String page(String channel) {
return """
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
html,body,#player{margin:0;width:100%;height:100%;overflow:hidden;background:#000}
iframe{display:block;width:100%;height:100%;border:0}
</style>
<script src="https://player.twitch.tv/js/embed/v1.js"></script>
</head>
<body><div id="player"></div>
<script>
const initialChannel="__CHANNEL__";
let desiredVolume=0;
let player=new Twitch.Player("player",{
width:"100%",height:"100%",channel:initialChannel,
autoplay:true,muted:true,parent:["localhost"]
});
window.zeventSetVolume=function(value){
desiredVolume=Math.max(0,Math.min(1,Number(value)||0));
if(!player)return;
player.setVolume(desiredVolume);
player.setMuted(desiredVolume<=0.001);
};
window.zeventSetChannel=function(channel){if(player)player.setChannel(channel);};
player.addEventListener(Twitch.Player.READY,function(){window.zeventSetVolume(desiredVolume);});
</script></body></html>
""".replace("__CHANNEL__", channel);
}
}
@@ -1,37 +0,0 @@
package fr.koka99cab.sanctuary26.zevent.network;
import fr.koka99cab.sanctuary26.zevent.ZeventMod;
import fr.koka99cab.sanctuary26.zevent.twitch.TwitchChannel;
import net.minecraft.core.BlockPos;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
public record OpenScreenOptionsPayload(BlockPos pos, String channel, boolean writable)
implements CustomPacketPayload {
public static final Type<OpenScreenOptionsPayload> TYPE =
new Type<>(ZeventMod.id("s2c/open_screen_options"));
public static final StreamCodec<RegistryFriendlyByteBuf, OpenScreenOptionsPayload> STREAM_CODEC =
StreamCodec.of(OpenScreenOptionsPayload::write, OpenScreenOptionsPayload::read);
public OpenScreenOptionsPayload {
pos = pos == null ? BlockPos.ZERO : pos.immutable();
channel = TwitchChannel.normalize(channel).orElse(TwitchChannel.DEFAULT);
}
private static OpenScreenOptionsPayload read(RegistryFriendlyByteBuf buffer) {
return new OpenScreenOptionsPayload(buffer.readBlockPos(),
buffer.readUtf(TwitchChannel.MAX_NAME_LENGTH), buffer.readBoolean());
}
private static void write(RegistryFriendlyByteBuf buffer, OpenScreenOptionsPayload payload) {
buffer.writeBlockPos(payload.pos);
buffer.writeUtf(payload.channel, TwitchChannel.MAX_NAME_LENGTH);
buffer.writeBoolean(payload.writable);
}
@Override
public Type<? extends CustomPacketPayload> type() {
return TYPE;
}
}
@@ -1,36 +0,0 @@
package fr.koka99cab.sanctuary26.zevent.network;
import fr.koka99cab.sanctuary26.zevent.ZeventMod;
import fr.koka99cab.sanctuary26.zevent.twitch.TwitchChannel;
import net.minecraft.core.BlockPos;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
public record SaveScreenChannelPayload(BlockPos pos, String channel) implements CustomPacketPayload {
public static final Type<SaveScreenChannelPayload> TYPE =
new Type<>(ZeventMod.id("c2s/save_screen_channel"));
public static final StreamCodec<RegistryFriendlyByteBuf, SaveScreenChannelPayload> STREAM_CODEC =
StreamCodec.of(SaveScreenChannelPayload::write, SaveScreenChannelPayload::read);
public SaveScreenChannelPayload {
pos = pos == null ? BlockPos.ZERO : pos.immutable();
channel = channel == null ? "" : channel.strip();
if (channel.length() > TwitchChannel.MAX_INPUT_LENGTH) channel = "";
}
private static SaveScreenChannelPayload read(RegistryFriendlyByteBuf buffer) {
return new SaveScreenChannelPayload(buffer.readBlockPos(),
buffer.readUtf(TwitchChannel.MAX_INPUT_LENGTH));
}
private static void write(RegistryFriendlyByteBuf buffer, SaveScreenChannelPayload payload) {
buffer.writeBlockPos(payload.pos);
buffer.writeUtf(payload.channel, TwitchChannel.MAX_INPUT_LENGTH);
}
@Override
public Type<? extends CustomPacketPayload> type() {
return TYPE;
}
}
@@ -1,57 +0,0 @@
package fr.koka99cab.sanctuary26.zevent.network;
import fr.koka99cab.sanctuary26.zevent.ZeventMod;
import fr.koka99cab.sanctuary26.zevent.block.ZeventScreenLayout;
import fr.koka99cab.sanctuary26.zevent.block.entity.ZeventScreenBlockEntity;
import fr.koka99cab.sanctuary26.zevent.twitch.TwitchChannel;
import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry;
import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.phys.Vec3;
public final class ZeventNetworking {
public static final int PROTOCOL_VERSION = 1;
private static final double MAX_INTERACTION_DISTANCE_SQR = 64.0D;
private static boolean initialized;
private ZeventNetworking() {
}
public static void initialize() {
if (initialized) return;
initialized = true;
PayloadTypeRegistry.clientboundPlay().register(OpenScreenOptionsPayload.TYPE,
OpenScreenOptionsPayload.STREAM_CODEC);
PayloadTypeRegistry.serverboundPlay().register(SaveScreenChannelPayload.TYPE,
SaveScreenChannelPayload.STREAM_CODEC);
ServerPlayNetworking.registerGlobalReceiver(SaveScreenChannelPayload.TYPE, (payload, context) ->
context.server().execute(() -> saveChannel(context.player(), payload)));
}
public static void openOptions(ServerPlayer player, ZeventScreenBlockEntity screen) {
if (player.distanceToSqr(Vec3.atCenterOf(screen.getBlockPos())) > MAX_INTERACTION_DISTANCE_SQR
|| !ServerPlayNetworking.canSend(player, OpenScreenOptionsPayload.TYPE)) return;
ServerPlayNetworking.send(player, new OpenScreenOptionsPayload(
screen.getBlockPos(), screen.channel(), screen.writable()));
}
private static void saveChannel(ServerPlayer player, SaveScreenChannelPayload payload) {
if (player.distanceToSqr(Vec3.atCenterOf(payload.pos())) > MAX_INTERACTION_DISTANCE_SQR) {
player.sendOverlayMessage(Component.translatable("message.zevent.screen.too_far"));
return;
}
if (!(player.level().getBlockEntity(payload.pos()) instanceof ZeventScreenBlockEntity screen)
|| !ZeventScreenLayout.isController(screen.getBlockState())) {
player.sendOverlayMessage(Component.translatable("message.zevent.screen.missing"));
return;
}
String normalized = TwitchChannel.normalize(payload.channel()).orElse(null);
if (normalized == null) return;
if (!screen.setChannel(normalized)) {
player.sendOverlayMessage(Component.translatable("message.zevent.screen.newer_data"));
return;
}
player.sendOverlayMessage(Component.translatable("screen.zevent.options.saved"));
}
}
@@ -1,69 +0,0 @@
package fr.koka99cab.sanctuary26.zevent.registry;
import fr.koka99cab.sanctuary26.zevent.ZeventMod;
import fr.koka99cab.sanctuary26.zevent.block.ZeventScreenBlock;
import fr.koka99cab.sanctuary26.zevent.block.entity.ZeventScreenBlockEntity;
import net.fabricmc.fabric.api.object.builder.v1.block.entity.FabricBlockEntityTypeBuilder;
import net.minecraft.core.Registry;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.core.registries.Registries;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.world.item.BlockItem;
import net.minecraft.world.item.CreativeModeTab;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.SoundType;
import net.minecraft.world.level.block.entity.BlockEntityType;
import net.minecraft.world.level.block.state.BlockBehaviour;
import net.minecraft.world.level.material.MapColor;
public final class ZeventRegistries {
private static final Identifier SCREEN_ID = ZeventMod.id("zevent_screen");
public static final ZeventScreenBlock ZEVENT_SCREEN = Registry.register(
BuiltInRegistries.BLOCK,
blockKey(SCREEN_ID),
new ZeventScreenBlock(BlockBehaviour.Properties.of()
.mapColor(MapColor.COLOR_BLACK)
.strength(3.5F, 8.0F)
.sound(SoundType.METAL)
.noOcclusion()
.setId(blockKey(SCREEN_ID)))
);
public static final Item ZEVENT_SCREEN_ITEM = Registry.register(
BuiltInRegistries.ITEM,
itemKey(SCREEN_ID),
new BlockItem(ZEVENT_SCREEN, new Item.Properties().useBlockDescriptionPrefix().setId(itemKey(SCREEN_ID)))
);
public static final BlockEntityType<ZeventScreenBlockEntity> ZEVENT_SCREEN_BLOCK_ENTITY = Registry.register(
BuiltInRegistries.BLOCK_ENTITY_TYPE,
ZeventMod.id("zevent_screen"),
FabricBlockEntityTypeBuilder.create(ZeventScreenBlockEntity::new, ZEVENT_SCREEN).build()
);
public static final CreativeModeTab CREATIVE_TAB = Registry.register(
BuiltInRegistries.CREATIVE_MODE_TAB,
ResourceKey.create(Registries.CREATIVE_MODE_TAB, ZeventMod.id("zevent")),
CreativeModeTab.builder(CreativeModeTab.Row.TOP, 0)
.title(Component.translatable("itemGroup.zevent.zevent"))
.icon(() -> new ItemStack(ZEVENT_SCREEN_ITEM))
.displayItems((parameters, output) -> output.accept(ZEVENT_SCREEN_ITEM))
.build()
);
private ZeventRegistries() {
}
public static void initialize() {
// Class initialization owns all registry writes.
}
private static ResourceKey<Block> blockKey(Identifier id) {
return ResourceKey.create(Registries.BLOCK, id);
}
private static ResourceKey<Item> itemKey(Identifier id) {
return ResourceKey.create(Registries.ITEM, id);
}
}
@@ -1,58 +0,0 @@
package fr.koka99cab.sanctuary26.zevent.twitch;
import java.net.URI;
import java.util.Locale;
import java.util.Optional;
import java.util.regex.Pattern;
/** Converts user input to the only external identifier the server persists. */
public final class TwitchChannel {
public static final String DEFAULT = "zeventfr";
public static final int MAX_INPUT_LENGTH = 128;
public static final int MAX_NAME_LENGTH = 25;
private static final Pattern NAME = Pattern.compile("[a-z0-9_]{1," + MAX_NAME_LENGTH + "}");
private TwitchChannel() {
}
public static Optional<String> normalize(String input) {
if (input == null) return Optional.empty();
String candidate = input.strip().toLowerCase(Locale.ROOT);
if (candidate.isEmpty() || candidate.length() > MAX_INPUT_LENGTH) return Optional.empty();
if (candidate.startsWith("@")) candidate = candidate.substring(1);
if (candidate.startsWith("twitch.tv/") || candidate.startsWith("www.twitch.tv/")) {
candidate = "https://" + candidate;
}
if (candidate.startsWith("http://") || candidate.startsWith("https://")) {
candidate = channelFromUrl(candidate).orElse("");
}
int query = candidate.indexOf('?');
if (query >= 0) candidate = candidate.substring(0, query);
int fragment = candidate.indexOf('#');
if (fragment >= 0) candidate = candidate.substring(0, fragment);
candidate = candidate.replaceAll("^/+|/+$", "");
return NAME.matcher(candidate).matches() ? Optional.of(candidate) : Optional.empty();
}
public static boolean valid(String channel) {
return channel != null && NAME.matcher(channel).matches();
}
private static Optional<String> channelFromUrl(String input) {
try {
URI uri = URI.create(input);
String host = uri.getHost();
if (host == null || !(host.equals("twitch.tv") || host.equals("www.twitch.tv"))) {
return Optional.empty();
}
String path = uri.getPath();
if (path == null) return Optional.empty();
String[] parts = path.split("/");
for (String part : parts) if (!part.isBlank()) return Optional.of(part);
} catch (IllegalArgumentException ignored) {
// Invalid URLs are rejected by the common name validation below.
}
return Optional.empty();
}
}
@@ -1,8 +0,0 @@
{
"multipart": [
{ "when": { "facing": "north" }, "apply": { "model": "zevent:block/zevent_screen" } },
{ "when": { "facing": "east" }, "apply": { "model": "zevent:block/zevent_screen", "y": 90 } },
{ "when": { "facing": "south" }, "apply": { "model": "zevent:block/zevent_screen", "y": 180 } },
{ "when": { "facing": "west" }, "apply": { "model": "zevent:block/zevent_screen", "y": 270 } }
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

@@ -1,17 +0,0 @@
{
"mod.zevent.description": "Streams ZEVENT Twitch channels on a 9x5 wall screen.",
"itemGroup.zevent.zevent": "ZEVENT",
"block.zevent.zevent_screen": "9x5 ZEVENT Screen",
"screen.zevent.options.title": "ZEVENT Screen",
"screen.zevent.options.channel": "Twitch channel",
"screen.zevent.options.hint": "Name or twitch.tv/channel URL",
"screen.zevent.options.official": "Official ZEVENT",
"screen.zevent.options.zerator": "ZeratoR",
"screen.zevent.options.current": "Now playing: twitch.tv/%s",
"screen.zevent.options.invalid": "Invalid channel name (1–25 characters: a-z, 0-9, _).",
"screen.zevent.options.saved": "Twitch channel updated.",
"message.zevent.screen.too_far": "You are too far away from the screen.",
"message.zevent.screen.missing": "The ZEVENT screen no longer exists.",
"message.zevent.screen.newer_data": "This screen uses newer data and is read-only.",
"message.zevent.player.initializing": "Initializing the Twitch player…"
}
@@ -1,17 +0,0 @@
{
"mod.zevent.description": "Diffuse des chaînes Twitch du ZEVENT sur un écran mural 9×5.",
"itemGroup.zevent.zevent": "ZEVENT",
"block.zevent.zevent_screen": "Écran ZEVENT 9×5",
"screen.zevent.options.title": "Écran ZEVENT",
"screen.zevent.options.channel": "Chaîne Twitch",
"screen.zevent.options.hint": "Nom ou URL twitch.tv/chaine",
"screen.zevent.options.official": "ZEVENT officiel",
"screen.zevent.options.zerator": "ZeratoR",
"screen.zevent.options.current": "Diffusion actuelle : twitch.tv/%s",
"screen.zevent.options.invalid": "Nom de chaîne invalide (1–25 caractères : a-z, 0-9, _).",
"screen.zevent.options.saved": "Chaîne Twitch mise à jour.",
"message.zevent.screen.too_far": "Tu es trop loin de l'écran.",
"message.zevent.screen.missing": "L'écran ZEVENT n'existe plus.",
"message.zevent.screen.newer_data": "Cet écran utilise des données plus récentes et reste en lecture seule.",
"message.zevent.player.initializing": "Initialisation du lecteur Twitch…"
}
@@ -1,17 +0,0 @@
{
"mod.zevent.description": "Транслирует Twitch-каналы ZEVENT на настенном экране 9×5.",
"itemGroup.zevent.zevent": "ZEVENT",
"block.zevent.zevent_screen": "Экран ZEVENT 9×5",
"screen.zevent.options.title": "Экран ZEVENT",
"screen.zevent.options.channel": "Канал Twitch",
"screen.zevent.options.hint": "Имя или URL twitch.tv/channel",
"screen.zevent.options.official": "Официальный ZEVENT",
"screen.zevent.options.zerator": "ZeratoR",
"screen.zevent.options.current": "Сейчас: twitch.tv/%s",
"screen.zevent.options.invalid": "Неверное имя канала (1–25 символов: a-z, 0-9, _).",
"screen.zevent.options.saved": "Канал Twitch обновлён.",
"message.zevent.screen.too_far": "Вы слишком далеко от экрана.",
"message.zevent.screen.missing": "Экран ZEVENT больше не существует.",
"message.zevent.screen.newer_data": "Этот экран использует более новые данны и доступен только для чтения.",
"message.zevent.player.initializing": "Инициализация плеера Twitch…"
}
@@ -1,21 +0,0 @@
{
"parent": "minecraft:block/block",
"textures": {
"particle": "minecraft:block/black_concrete",
"screen": "minecraft:block/black_concrete"
},
"elements": [
{
"from": [0, 0, 14],
"to": [16, 16, 16],
"faces": {
"north": { "texture": "#screen", "cullface": "north" },
"south": { "texture": "#screen", "cullface": "south" },
"west": { "texture": "#screen", "cullface": "west" },
"east": { "texture": "#screen", "cullface": "east" },
"up": { "texture": "#screen", "cullface": "up" },
"down": { "texture": "#screen", "cullface": "down" }
}
}
]
}
@@ -1,20 +0,0 @@
{
"type": "minecraft:block",
"pools": [
{
"rolls": 1,
"entries": [
{
"type": "minecraft:item",
"name": "zevent:zevent_screen"
}
],
"conditions": [
{
"condition": "minecraft:survives_explosion"
}
]
}
],
"random_sequence": "zevent:blocks/zevent_screen"
}
@@ -1,19 +0,0 @@
{
"type": "minecraft:crafting_shaped",
"category": "building",
"pattern": [
"BBB",
"GTG",
"BRB"
],
"key": {
"B": "minecraft:black_concrete",
"G": "minecraft:tinted_glass",
"T": "minecraft:redstone_torch",
"R": "minecraft:redstone_block"
},
"result": {
"id": "zevent:zevent_screen",
"count": 1
}
}
-44
View File
@@ -1,44 +0,0 @@
{
"schemaVersion": 1,
"id": "zevent",
"version": "${version}",
"name": "ZEVENT",
"description": "Watch ZEVENT Twitch channels on a 9x5 in-world screen.",
"authors": [
"KOKA99CAB"
],
"license": "GPL-3.0-or-later",
"icon": "assets/zevent/icon.png",
"environment": "*",
"entrypoints": {
"main": [
"fr.koka99cab.sanctuary26.zevent.ZeventMod"
],
"client": [
"fr.koka99cab.sanctuary26.zevent.client.ZeventClient"
]
},
"depends": {
"fabricloader": ">=0.19.3",
"minecraft": "~26.2",
"java": ">=25",
"fabric-api": ">=0.159.0+26.2",
"mcef-modern": ">=0.3.3"
},
"custom": {
"sanctuary26": {
"system_type": "server-authoritative-screen",
"namespace": "zevent",
"data_version": 1,
"network_protocol": 1,
"lifecycle": "${lifecycle}",
"pack_version": "${pack_version}",
"distribution": "standalone-event-mod",
"descriptions": {
"fr_fr": "Diffuse des chaînes Twitch du ZEVENT sur un écran mural 9×5.",
"en_us": "Streams ZEVENT Twitch channels on a 9x5 wall screen.",
"ru_ru": "Транслирует Twitch-каналы ZEVENT на настенном экране 9×5."
}
}
}
}
@@ -1,30 +0,0 @@
package fr.koka99cab.sanctuary26.zevent.client.twitch;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import org.junit.jupiter.api.Test;
class TwitchPlayerPageServerTest {
@Test
void servesOnlyTheBoundedOfficialPlayerPage() throws Exception {
TwitchPlayerPageServer server = new TwitchPlayerPageServer();
try (HttpClient client = HttpClient.newHttpClient()) {
String url = server.playerUrl("zeventfr");
HttpResponse<String> response = client.send(
HttpRequest.newBuilder(URI.create(url)).GET().build(), HttpResponse.BodyHandlers.ofString());
assertEquals(200, response.statusCode());
assertTrue(response.body().contains("https://player.twitch.tv/js/embed/v1.js"));
assertTrue(response.body().contains("const initialChannel=\"zeventfr\""));
assertTrue(response.headers().firstValue("Content-Security-Policy").isPresent());
assertThrows(IllegalArgumentException.class, () -> server.playerUrl("bad-channel!"));
} finally {
server.stop();
}
}
}
@@ -1,22 +0,0 @@
package fr.koka99cab.sanctuary26.zevent.twitch;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
class TwitchChannelTest {
@Test
void normalizesNamesAndOfficialUrls() {
assertEquals("zeventfr", TwitchChannel.normalize(" @ZEventFR ").orElseThrow());
assertEquals("zerator", TwitchChannel.normalize("https://www.twitch.tv/ZeratoR/videos").orElseThrow());
assertEquals("etoiles", TwitchChannel.normalize("twitch.tv/etoiles?ref=test").orElseThrow());
}
@Test
void rejectsForeignUrlsAndUnboundedNames() {
assertTrue(TwitchChannel.normalize("https://example.com/zeventfr").isEmpty());
assertTrue(TwitchChannel.normalize("bad-channel!").isEmpty());
assertTrue(TwitchChannel.normalize("a".repeat(TwitchChannel.MAX_NAME_LENGTH + 1)).isEmpty());
}
}