Compare commits

..
Author SHA1 Message Date
koka 0ad9763666 Add standalone ZEVENT Twitch screen mod
Verification 26.2 / check (pull_request) Canceled after 0s
2026-09-04 01:53:19 +02:00
koka 0196b6efa5 Merge pull request 'release: Sanctuary 26.2.0-alpha.233' (#155) from release/26.2.0-alpha.233 into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #155
2026-09-03 02:25:18 +00:00
koka 3bdc5fe241 release: prepare Sanctuary 26.2.0-alpha.233
Verification 26.2 / check (pull_request) Canceled after 0s
2026-09-03 04:10:03 +02:00
koka 789faeb708 Merge pull request 'feat(redstoner): refaire les trophées comme panneaux Vanilla' (#154) from integration/trophy-signs-vanilla into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #154
2026-09-03 02:01:31 +00:00
koka 3491158785 feat(redstoner): rebuild trophy panels as vanilla signs
Verification 26.2 / check (pull_request) Canceled after 0s
2026-09-03 03:15:02 +02:00
koka c0229d4ea9 Merge pull request 'Canon : enregistrer les photos dans les screenshots et publier alpha.232' (#153) from release/26.2.0-alpha.232 into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #153
2026-09-03 00:26:13 +00:00
koka ddaf71c7cd release: prepare Sanctuary 26.2.0-alpha.232
Verification 26.2 / check (pull_request) Canceled after 0s
2026-09-03 02:22:46 +02:00
koka 7e1ed85307 Merge pull request 'Corriger la cape, le terminal et le panneau de trophées d’alpha.231' (#152) from fix/alpha231-cape-terminal-trophy-panel into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #152
2026-09-02 23:37:50 +00:00
koka 5840621acf fix: correct alpha231 cape terminal and trophy panel
Verification 26.2 / check (pull_request) Canceled after 0s
2026-09-03 01:35:09 +02:00
koka 974cea20b5 Merge pull request 'release: Sanctuary 26.2.0-alpha.231' (#151) from release/26.2.0-alpha.231 into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #151
2026-09-02 22:44:12 +00:00
koka 81a98ed197 release: include readable trophy panels in alpha.231
Verification 26.2 / check (pull_request) Canceled after 0s
2026-09-02 18:42:33 +02:00
koka b022be5739 Merge pull request 'fix(redstoner): rendre les panneaux de trophées lisibles' (#150) from fix/trophy-panel-display into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #150
2026-09-02 16:29:25 +00:00
koka 274d4c1ffa release: prepare Sanctuary 26.2.0-alpha.231 2026-09-02 15:16:01 +02:00
102 changed files with 4325 additions and 453 deletions
+31 -1
View File
@@ -8,13 +8,43 @@ Ce journal suit les **versions du modpack**. Lorsqu'un seul module change, sa ve
## Non publié
Aucun changement non publié.
## `26.2.0-alpha.233`
Modules modifiés : **Sanctuary `0.0.0-alpha.139`** et **Red-Stoner `0.0.0-alpha.20`**.
- remplace le bloc-écran `redstoner:trophy_panel` par un vrai panneau Vanilla en chêne pâle, posable au sol ou au mur, sans changer lID de litem déjà présent dans les inventaires et recettes ;
- ouvre à la première pose un éditeur blanc permettant de rechercher une statistique Vanilla, composer et réordonner jusqu’à seize pages, puis choisir entre noms et valeurs, noms seuls ou valeurs seules ;
- verrouille la configuration et la destruction au créateur ou à un opérateur, tout en laissant le clic droit public faire défiler les pages ;
- conserve lancien bloc uniquement comme alias de migration : chaque plaque déjà posée devient automatiquement un panneau mural en gardant propriétaire, statistiques et page courante ;
- répare les classements importés qui montraient les huit premiers caractères de lUUID : Sanctuary récupère désormais le vrai pseudo depuis le cache local du serveur et utilise `?` plutôt quun fragment dUUID si aucun nom nest connu ;
- ajoute les payloads bornés de l’éditeur, fait passer Red-Stoner aux données v5 et au protocole réseau 9, sans changer les données v15 ni le protocole 24 de Sanctuary. Client et serveur Red-Stoner doivent être mis à jour ensemble.
## `26.2.0-alpha.232`
Modules modifiés : **Another World `0.0.0-alpha.58`**, **Sanctuary `0.0.0-alpha.138`** et **Red-Stoner `0.0.0-alpha.19`**.
- ferme le ticket #146 en enregistrant chaque cliché pris avec un appareil Canon dans le dossier `screenshots` de linstance, à côté des captures F2 ;
- exporte le carré cadré à sa résolution native avec le filtre de la pellicule, sans le réduire aux 128 × 128 pixels de la carte en jeu ;
- écrit le PNG hors du thread de rendu et réserve un nom horodaté unique afin d’éviter les freezes comme les écrasements lors de plusieurs clichés rapprochés ;
- remplace le volume de la cape à motifs par les deux faces opposées de l’îlot UV exact du bouclier, redimensionnées aux proportions 10 × 16 de la cape, afin de corriger la projection et le culling sans afficher le manche ni l’épaisseur du bouclier ;
- laisse la touche dinventaire fermer le terminal tant que la recherche nest pas focalisée, mais la traite comme du texte dans le champ de recherche afin que la lettre `E` ne ferme plus l’écran ;
- utilise les huit lignes du panneau de trophées pour afficher le titre et jusqu’à sept rangs, avec une page de noms suivie dune page de valeurs pour chaque statistique afin que les longues durées de jeu restent lisibles ;
- fait configurer par un œuf dapparition le classement vanilla des créatures correspondantes tuées, par exemple les zombies tués avec l’œuf de zombie ;
- conserve les IDs et les sauvegardes Sanctuary v15/Red-Stoner v4 ; le snapshot client du panneau passant de trois à sept entrées, le protocole Red-Stoner passe de 7 à 8 et exige la mise à jour du client et du serveur.
## `26.2.0-alpha.231`
Modules modifiés : **Sanctuary `0.0.0-alpha.137`** et **Red-Stoner `0.0.0-alpha.18`**.
- transforme le panneau de trophées en plaque verticale de chêne, corrige le texte auparavant masqué et utilise les quatre lignes pour le titre paginé ainsi que les trois places du podium ;
- permet de passer à la page suivante dun clic droit et de revenir à la précédente avec `Maj` + clic droit, sans changer les IDs, les sauvegardes Red-Stoner v4 ni le protocole réseau 7.
- garantit sur chaque niveau de la Backroom un coffre par cellule de 3 × 3 chunks dans les nouveaux chunks, au lieu dun coffre aléatoire réparti entre les cinq niveaux ;
- resserre aussi la grille des lits de sortie de 10 × 10 à 3 × 3 chunks sur chacun des cinq niveaux afin quils soient réellement trouvables ;
- ne change ni le butin ordinaire des coffres, ni les données Sanctuary v15, ni le protocole réseau 24 ; les nouveaux coffres et lits apparaissent dans les chunks qui nont pas encore été générés.
- actualise JEI vers la bêta Fabric `30.29.0.201`, qui corrige un crash avec les mods utilisant latlas de textures ;
- ne change ni le butin ordinaire des coffres, ni les données Sanctuary v15/Red-Stoner v4, ni leurs protocoles réseau 24/7 ; les nouveaux coffres et lits apparaissent dans les chunks qui nont pas encore été générés.
## `26.2.0-alpha.230`
+11 -6
View File
@@ -1,6 +1,6 @@
# Sanctuary 26.2
Workspace Fabric multi-projet de Sanctuary 2.0. Il construit dix JAR indépendants 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 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.
Auteur : **KOKA99CAB**
Licence : **GNU GPL 3.0 or Later** (`GPL-3.0-or-later`)
@@ -19,12 +19,17 @@ 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.230`**. Cette release distribue Sanctuary **`0.0.0-alpha.136`** et Red-Stoner **`0.0.0-alpha.17`** ; les huit autres modules conservent leur version issue de l'alpha.229.
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.
Le prochain Red-Stoner **`0.0.0-alpha.18`** présente le panneau de trophées comme une plaque verticale de chêne et rend enfin ses quatre lignes lisibles : titre et numéro de page, puis les trois places du podium. Un clic droit avance dune page et `Maj` + clic droit revient à la précédente ; les données v4 et le protocole réseau 7 restent inchangés.
Lalpha.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 laffichage 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. Litem `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 dun fragment dUUID. 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.
Lalpha.232 enregistre automatiquement chaque cliché Canon dans le dossier `screenshots` de linstance, à côté des captures F2. Le PNG conserve le carré réellement cadré, sa résolution et le filtre de la pellicule ; l’écriture asynchrone et les noms uniques évitent les freezes et les écrasements. Cette release corrige aussi la cape blanche décorée, la touche `E` du terminal et étend le panneau de trophées à huit lignes. Chaque statistique y possède une page de noms puis une page de valeurs ; un œuf dapparition ajoute directement le classement des créatures correspondantes tuées. Les sauvegardes restent en données Another World v5, Sanctuary v15 et Red-Stoner v4 ; le snapshot du panneau passant à sept entrées, le protocole Red-Stoner passe à 8.
Lalpha.231 rend les sorties et le butin de la Backroom réellement trouvables sur ses cinq niveaux. Chaque niveau garantit désormais un coffre et un lit de sortie par cellule de 3 × 3 chunks dans les nouveaux chunks, sans ajouter de livres au butin. Le panneau de trophées devient une plaque verticale de chêne avec quatre lignes lisibles : titre et numéro de page, puis les trois places du podium. Un clic droit avance dune page et `Maj` + clic droit revient à la précédente. JEI passe à la bêta Fabric `30.29.0.201`, qui corrige un crash lié à latlas de textures. Les sauvegardes et les chunks déjà générés restent compatibles ; Sanctuary conserve les données v15/protocole 24 et Red-Stoner les données v4/protocole 7.
Lalpha.230 attribue les quêtes communautaires terminées à leur preneur et répartit les lits de sortie rares sur chacun des cinq niveaux de la Backroom. Elle ajoute aussi les classements publics et le panneau mural `redstoner:trophy_panel`, corrige la projection des couleurs et motifs de la cape blanche avec les sprites de bouclier, et permet d’équiper une poule adulte avec larmure de loup vanilla en écailles de tatou. Les sauvegardes restent compatibles ; Sanctuary passe aux données v15 et Red-Stoner aux données v4 sans changer leurs protocoles réseau 24 et 7.
@@ -323,9 +328,9 @@ Le guide détaillé est dans [`masterkey/tools/PANORAMA.md`](masterkey/tools/PAN
## Red-Stoner et photographie
Red-Stoner possède sept blocs actifs sous son namespace : `eight_bit_computer`, `controller`, `personal_computer`, `drawer`, `particuleur`, `storage_terminal` et `trophy_panel`. Son onglet créatif expose aussi les trois appareils Canon et les seize styles de pellicule.
Red-Stoner possède ses appareils sous son namespace ainsi que la paire de panneaux Vanilla `trophy_sign`/`trophy_wall_sign`. Litem historique `redstoner:trophy_panel` est conservé afin que les inventaires et recettes existants restent valides ; il place désormais cette paire, tandis que lancien bloc mural du même ID ne subsiste que le temps de sa migration automatique. Son onglet créatif expose le panneau de trophées avec les ordinateurs, le Particuleur et le terminal de stockage.
Le panneau mural `redstoner:trophy_panel` consomme lAPI publique de classement de Sanctuary sans conserver les scores. Le joueur qui le pose peut ajouter des pages statistiques en cliquant avec un item, tandis que tous les visiteurs peuvent faire défiler les pages. Sanctuary importe une fois les statistiques vanilla existantes, garde les classements par UUID — champion déconnecté compris — et fournit seulement un podium borné à trois entrées. Les composants privés de litem servant dicône et la base complète des joueurs ne sont jamais synchronisés au client.
Le panneau de trophées consomme lAPI publique de classement de Sanctuary sans conserver les scores. Sa première pose ouvre un éditeur blanc : le créateur choisit dans le catalogue Vanilla jusqu’à seize statistiques, leur ordre et un affichage noms+valeurs, noms seuls ou valeurs seules. `Maj` + clic droit rouvre ensuite cet éditeur pour le créateur ou un opérateur ; le clic droit ordinaire fait défiler publiquement les pages. La configuration et la destruction sont protégées par UUID. Chaque page utilise les quatre lignes natives du panneau — titre puis trois rangs — et se rafraîchit côté serveur. Sanctuary importe une fois les statistiques Vanilla existantes, conserve les classements par UUID — champion déconnecté compris — et résout les vrais pseudos depuis le cache du serveur au lieu dafficher un début dUUID.
L'ordinateur 8 bits et le contrôleur exécutent la machine virtuelle historique : programme assembleur borné à 4096 caractères, RAM, inventaire, six ports redstone directionnels, impulsions et sortie comparateur. Le contrôleur dispose de 1024 cellules de RAM contre 256 pour l'ordinateur. L'instruction destinée à la matrice d'affichage renvoie volontairement une erreur claire tant que ce périphérique reste différé.
@@ -414,7 +419,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` nactivent et ne font avancer que le module concerné. Le contrat de distribution est dans `pack/release.json`.
`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` nactivent 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.
La version globale du pack nest 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.
+12 -3
View File
@@ -15,6 +15,14 @@ tasks.register("vaultRulesSmoke", JavaExec) {
mainClass = "fr.koka99cab.sanctuary26.anotherworld.vault.VaultRulesSmoke"
}
tasks.register("canonScreenshotFilesSmoke", JavaExec) {
group = "verification"
description = "Checks Canon photo exports use the vanilla screenshots directory and collision-safe names."
dependsOn tasks.named("testClasses")
classpath = sourceSets.test.runtimeClasspath
mainClass = "fr.koka99cab.canon.client.CanonScreenshotFilesSmoke"
}
tasks.named("test") {
failOnNoDiscoveredTests = false
}
@@ -1956,7 +1964,7 @@ tasks.register("verifyAnotherWorldCatalog") {
"src/main/resources/assets/redstoner/textures/item/weather_gauge.png",
"src/main/resources/data/redstoner/recipe/weather_gauge.json"
]
if (project.version.toString() != "0.0.0-alpha.57"
if (project.version.toString() != "0.0.0-alpha.58"
|| sulfurMigration.target?.modules?.anotherworld != "0.0.0-alpha.44"
|| sulfurMigration.sulfur_caves?.sanctuary?.dripstone_cell_period != 2
|| !javaText.contains('Identifier.parse("sanctuary:sulfur_caves")')
@@ -3261,6 +3269,7 @@ tasks.register("verifyAnotherWorldCatalog") {
tasks.named("check") {
dependsOn tasks.named("verifyAnotherWorldCatalog")
dependsOn tasks.named("vaultRulesSmoke")
dependsOn tasks.named("canonScreenshotFilesSmoke")
}
tasks.register("verifyClimateBiomeMixer") {
@@ -3273,7 +3282,7 @@ tasks.register("verifyClimateBiomeMixer") {
"pack/migrations/26.2.0-alpha.188-beta-climate-provinces-to-alpha.189.json"))
def mixer = file(
"src/main/java/fr/koka99cab/sanctuary26/anotherworld/mixin/MultiNoiseBiomeSourceParameterListPresetMixin.java").text
if (project.version.toString() != "0.0.0-alpha.57"
if (project.version.toString() != "0.0.0-alpha.58"
|| migration.source?.pack_version != "26.2.0-alpha.188"
|| migration.target?.pack_version != "26.2.0-alpha.189"
|| migration.target?.modules?.anotherworld != "0.0.0-alpha.48"
@@ -3315,7 +3324,7 @@ tasks.register("verifyBackroomMaterialIntegration") {
"sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/api/backroom/BackroomMaterialApi.java").text
def rootBuild = rootProject.file("build.gradle").text
def manifest = new JsonSlurper().parse(file("src/main/resources/fabric.mod.json"))
if (project.version.toString() != "0.0.0-alpha.57"
if (project.version.toString() != "0.0.0-alpha.58"
|| !initializer.contains("BackroomMaterialApi.registerAnomalyMaterial(AnotherWorldBlocks.TURQUOISE_MOSS)")
|| !blocks.contains('"turquoise_moss"')
|| !api.contains("public static void registerAnomalyMaterial")
@@ -0,0 +1,38 @@
package fr.koka99cab.canon.client;
import java.io.IOException;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.Path;
final class CanonScreenshotFiles {
private static final String SCREENSHOT_DIRECTORY = "screenshots";
private static final int MAX_SUFFIX = 10_000;
private CanonScreenshotFiles() {
}
static Path reserve(Path gameDirectory, String timestamp) throws IOException {
Path screenshots = gameDirectory.resolve(SCREENSHOT_DIRECTORY);
Files.createDirectories(screenshots);
for (int suffix = 1; suffix <= MAX_SUFFIX; suffix++) {
Path candidate = screenshots.resolve(fileName(timestamp, suffix));
try {
return Files.createFile(candidate);
} catch (FileAlreadyExistsException ignored) {
// Keep the vanilla timestamp readable while avoiding same-second collisions.
}
}
throw new IOException("Could not reserve a unique Canon screenshot filename");
}
static String fileName(String timestamp, int suffix) {
if (timestamp == null || timestamp.isBlank()) {
throw new IllegalArgumentException("timestamp must not be blank");
}
if (suffix < 1) {
throw new IllegalArgumentException("suffix must be positive");
}
return timestamp + "-canon" + (suffix == 1 ? "" : "_" + suffix) + ".png";
}
}
@@ -8,12 +8,15 @@ import fr.koka99cab.canon.network.CapturePhotoPayload;
import fr.koka99cab.canon.photo.PhotoMapPalette;
import com.mojang.blaze3d.platform.NativeImage;
import com.mojang.blaze3d.platform.Window;
import java.nio.file.Files;
import java.nio.file.Path;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
import net.minecraft.client.Minecraft;
import net.minecraft.client.Screenshot;
import net.minecraft.network.chat.Component;
import net.minecraft.sounds.SoundEvents;
import net.minecraft.util.ARGB;
import net.minecraft.util.Util;
import net.minecraft.world.item.ItemStack;
public final class ClientPhotoCapture {
@@ -65,6 +68,7 @@ public final class ClientPhotoCapture {
ClientPlayNetworking.send(new CapturePhotoPayload(colors));
client.player.getCooldowns().addCooldown(capture.cameraStack(), CanonConstants.CAMERA_COOLDOWN_TICKS);
client.player.playSound(SoundEvents.SPYGLASS_USE, 0.7F, 1.35F);
exportScreenshot(client, screenshot, capture.style(), offsetX, offsetY, cropWidth, cropHeight);
} catch (Exception exception) {
CanonMod.LOGGER.warn("Failed to capture photo", exception);
if (client.player != null) {
@@ -74,6 +78,72 @@ public final class ClientPhotoCapture {
});
}
private static void exportScreenshot(
Minecraft client,
NativeImage screenshot,
PhotoStyle style,
int offsetX,
int offsetY,
int cropWidth,
int cropHeight
) {
NativeImage exported = null;
Path target = null;
try {
exported = new NativeImage(cropWidth, cropHeight, false);
screenshot.copyRect(exported, offsetX, offsetY, 0, 0, cropWidth, cropHeight, false, false);
target = CanonScreenshotFiles.reserve(client.gameDirectory.toPath(), Util.getFilenameFormattedDateTime());
} catch (Exception exception) {
if (exported != null) exported.close();
if (target != null) deleteReservedFile(target);
notifyScreenshotFailure(client, exception);
return;
}
NativeImage image = exported;
Path output = target;
Util.ioPool().execute(() -> {
try (image) {
applyStyle(image, style);
image.writeToFile(output);
client.execute(() -> {
if (client.player != null) {
client.player.sendSystemMessage(Component.translatable(
"message.canon.camera.screenshot_saved", output.getFileName().toString()));
}
});
} catch (Exception exception) {
deleteReservedFile(output);
notifyScreenshotFailure(client, exception);
}
});
}
private static void applyStyle(NativeImage image, PhotoStyle style) {
for (int y = 0; y < image.getHeight(); y++) {
for (int x = 0; x < image.getWidth(); x++) {
image.setPixel(x, y, style.applyFilter(image.getPixel(x, y)));
}
}
}
private static void notifyScreenshotFailure(Minecraft client, Exception exception) {
CanonMod.LOGGER.warn("Failed to save Canon photo in the screenshots directory", exception);
client.execute(() -> {
if (client.player != null) {
client.player.sendSystemMessage(Component.translatable("message.canon.camera.screenshot_failed"));
}
});
}
private static void deleteReservedFile(Path target) {
try {
Files.deleteIfExists(target);
} catch (Exception cleanupException) {
CanonMod.LOGGER.debug("Could not remove incomplete Canon screenshot {}", target, cleanupException);
}
}
private static int clamp(int value, int min, int max) {
return Math.max(min, Math.min(max, value));
}
@@ -24,6 +24,8 @@
"message.canon.camera.full": "The camera is already full.",
"message.canon.camera.captured": "Photo captured.",
"message.canon.camera.capture_failed": "Could not capture the current frame.",
"message.canon.camera.screenshot_saved": "Photo saved to screenshots: %s",
"message.canon.camera.screenshot_failed": "The in-game photo was captured, but its PNG could not be saved to screenshots.",
"style.canon.white": "Neutral",
"style.canon.white.effect": "Neutral stock with soft paper whites.",
"style.canon.light_gray": "Matte",
@@ -24,6 +24,8 @@
"message.canon.camera.full": "L'appareil photo est deja plein.",
"message.canon.camera.captured": "Photo prise.",
"message.canon.camera.capture_failed": "Impossible de capturer l'image actuelle.",
"message.canon.camera.screenshot_saved": "Photo sauvegardee dans screenshots : %s",
"message.canon.camera.screenshot_failed": "La photo en jeu est prise, mais son PNG n'a pas pu etre sauvegarde dans screenshots.",
"style.canon.white": "Neutre",
"style.canon.white.effect": "Rendu neutre avec des blancs doux.",
"style.canon.light_gray": "Mat",
@@ -24,6 +24,8 @@
"message.canon.camera.full": "Фотоаппарат уже полностью заряжен.",
"message.canon.camera.captured": "Фотография сделана.",
"message.canon.camera.capture_failed": "Не удалось снять текущий кадр.",
"message.canon.camera.screenshot_saved": "Фотография сохранена в screenshots: %s",
"message.canon.camera.screenshot_failed": "Снимок в игре сделан, но PNG не удалось сохранить в screenshots.",
"style.canon.white": "Нейтральный",
"style.canon.white.effect": "Нейтральная плёнка с мягкими белыми тонами.",
"style.canon.light_gray": "Матовый",
@@ -0,0 +1,33 @@
package fr.koka99cab.canon.client;
import java.nio.file.Files;
import java.nio.file.Path;
public final class CanonScreenshotFilesSmoke {
private CanonScreenshotFilesSmoke() {
}
public static void main(String[] args) throws Exception {
String timestamp = "2026-09-03_01.23.45";
require(CanonScreenshotFiles.fileName(timestamp, 1).equals("2026-09-03_01.23.45-canon.png"),
"first screenshot filename");
require(CanonScreenshotFiles.fileName(timestamp, 2).equals("2026-09-03_01.23.45-canon_2.png"),
"same-second screenshot filename");
Path gameDirectory = Files.createTempDirectory("canon-screenshot-smoke-");
Path first = CanonScreenshotFiles.reserve(gameDirectory, timestamp);
Path second = CanonScreenshotFiles.reserve(gameDirectory, timestamp);
require(first.getParent().equals(gameDirectory.resolve("screenshots")),
"screenshots directory");
require(first.getFileName().toString().equals("2026-09-03_01.23.45-canon.png"),
"first reserved screenshot");
require(second.getFileName().toString().equals("2026-09-03_01.23.45-canon_2.png"),
"collision-safe screenshot");
require(Files.isRegularFile(first) && Files.isRegularFile(second),
"reserved screenshot files");
}
private static void require(boolean condition, String contract) {
if (!condition) throw new IllegalStateException("Invalid Canon screenshot contract: " + contract);
}
}
+783 -335
View File
File diff suppressed because it is too large Load Diff
+6 -4
View File
@@ -14,9 +14,9 @@ mod_version=0.0.0-alpha.0
module_lifecycle=standby
ambiance_version=0.0.0-alpha.22
ambiance_lifecycle=active
redstoner_version=0.0.0-alpha.18
redstoner_version=0.0.0-alpha.20
redstoner_lifecycle=active
anotherworld_version=0.0.0-alpha.57
anotherworld_version=0.0.0-alpha.58
anotherworld_lifecycle=active
itsalive_version=0.0.0-alpha.38
itsalive_lifecycle=active
@@ -28,7 +28,9 @@ iliketomoveit_version=0.0.0-alpha.18
iliketomoveit_lifecycle=active
onlyfun_version=0.0.0-alpha.27
onlyfun_lifecycle=active
sanctuary_version=0.0.0-alpha.137
sanctuary_version=0.0.0-alpha.139
sanctuary_lifecycle=active
pack_version=26.2.0-alpha.230
zevent_version=0.0.0-alpha.1
zevent_lifecycle=active
pack_version=26.2.0-alpha.233
maven_group=fr.koka99cab.sanctuary26
@@ -0,0 +1,56 @@
{
"schema_version": 1,
"status": "released",
"source": {
"pack_version": "26.2.0-alpha.230",
"modules": {
"sanctuary": "0.0.0-alpha.136",
"redstoner": "0.0.0-alpha.17"
}
},
"target": {
"pack_version": "26.2.0-alpha.231",
"modules": {
"sanctuary": "0.0.0-alpha.137",
"redstoner": "0.0.0-alpha.18"
}
},
"backroom_landmarks": {
"level_count": 5,
"loot_chest_cell_size_chunks": 3,
"exit_bed_source_cell_size_chunks": 10,
"exit_bed_target_cell_size_chunks": 3,
"books_added_to_loot": false,
"new_chunks_only": true
},
"trophy_panel": {
"wall_plate_model": "oak",
"readable_line_count": 4,
"page_title_and_three_podium_rows": true,
"right_click_next_page": true,
"sneak_right_click_previous_page": true
},
"compatibility": {
"existing_saves_supported": true,
"new_world_required": false,
"save_reset_required": false,
"persistent_data_formats_changed": false,
"network_protocols_changed": false,
"sanctuary_data_version": 15,
"sanctuary_network_protocol": 24,
"redstoner_data_version": 4,
"redstoner_network_protocol": 7,
"client_server_update_required": true
},
"distribution": {
"pack_release_updated": true,
"packwiz_index_refreshed": true,
"prism_modrinth_index_updated": true,
"shared_client_server_mrpack": true,
"jei": {
"source": "30.29.0.199",
"target": "30.29.0.201",
"packwiz_and_prism_synchronized": true
}
}
}
@@ -0,0 +1,58 @@
{
"schema_version": 1,
"status": "released",
"source": {
"pack_version": "26.2.0-alpha.231",
"modules": {
"anotherworld": "0.0.0-alpha.57",
"sanctuary": "0.0.0-alpha.137",
"redstoner": "0.0.0-alpha.18"
}
},
"target": {
"pack_version": "26.2.0-alpha.232",
"modules": {
"anotherworld": "0.0.0-alpha.58",
"sanctuary": "0.0.0-alpha.138",
"redstoner": "0.0.0-alpha.19"
}
},
"fixed_issues": [146],
"canon_photo_screenshots": {
"directory": "screenshots",
"viewfinder_crop_only": true,
"film_filter_preserved": true,
"full_crop_resolution": true,
"asynchronous_png_write": true,
"collision_safe_names": true
},
"alpha231_follow_up": {
"migration": "alpha231-follow-up-cape-terminal-trophy.json",
"banner_cape_fixed": true,
"terminal_inventory_key_fixed": true,
"trophy_panel_visible_lines": 8,
"trophy_panel_entries_per_view": 7,
"spawn_egg_kill_ranking": true
},
"compatibility": {
"existing_saves_supported": true,
"new_world_required": false,
"save_reset_required": false,
"persistent_data_formats_changed": false,
"network_protocols_changed": true,
"anotherworld_data_version": 5,
"anotherworld_network_protocol": 2,
"sanctuary_data_version": 15,
"sanctuary_network_protocol": 24,
"redstoner_data_version": 4,
"redstoner_source_network_protocol": 7,
"redstoner_target_network_protocol": 8,
"client_server_update_required": true
},
"distribution": {
"pack_release_updated": true,
"packwiz_index_refreshed": true,
"prism_modrinth_index_updated": true,
"shared_client_server_mrpack": true
}
}
@@ -0,0 +1,37 @@
{
"schema_version": 1,
"status": "released",
"released_in": "26.2.0-alpha.232",
"modules": {
"sanctuary": {
"source_module_version": "0.0.0-alpha.137",
"target_module_version": "0.0.0-alpha.138",
"data_version": 15,
"network_protocol": 24
},
"redstoner": {
"source_module_version": "0.0.0-alpha.18",
"target_module_version": "0.0.0-alpha.19",
"data_version": 4,
"source_network_protocol": 7,
"target_network_protocol": 8
}
},
"fixes": {
"banner_cape_flat_shield_uv_faces": true,
"banner_cape_double_sided_culling": true,
"terminal_inventory_key_captured_while_searching": true,
"trophy_panel_visible_lines": 8,
"trophy_panel_entries_per_view": 7,
"trophy_panel_names_then_values": true,
"spawn_egg_maps_to_entity_killed_statistic": true
},
"compatibility": {
"existing_saves_supported": true,
"new_world_required": false,
"save_reset_required": false,
"persistent_data_formats_changed": false,
"network_protocols_changed": true,
"client_server_update_required": true
}
}
@@ -0,0 +1,64 @@
{
"schema_version": 1,
"status": "released",
"released_in": "26.2.0-alpha.233",
"pack": {
"source_version": "26.2.0-alpha.232",
"target_version": "26.2.0-alpha.233"
},
"modules": {
"sanctuary": {
"source_module_version": "0.0.0-alpha.138",
"target_module_version": "0.0.0-alpha.139",
"data_version": 15,
"network_protocol": 24
},
"redstoner": {
"source_module_version": "0.0.0-alpha.19",
"target_module_version": "0.0.0-alpha.20",
"source_data_version": 4,
"target_data_version": 5,
"source_network_protocol": 8,
"target_network_protocol": 9
}
},
"trophy_sign": {
"owner": "redstoner",
"item_id_preserved": "redstoner:trophy_panel",
"standing_block_id": "redstoner:trophy_sign",
"wall_block_id": "redstoner:trophy_wall_sign",
"legacy_block_id": "redstoner:trophy_panel",
"legacy_blocks_migrate_on_load": true,
"vanilla_sign_block_entity": true,
"vanilla_wood_type": "pale_oak",
"save_key": "redstoner:trophy_signs",
"save_data_version": 1,
"maximum_signs": 16384,
"maximum_pages_per_sign": 16,
"editor_opens_on_first_placement": true,
"owner_or_operator_configuration": true,
"owner_or_operator_breaking": true,
"public_right_click_page_cycle": true,
"display_modes": ["names_and_values", "names", "values"],
"server_revalidates_distance_owner_block_and_metrics": true
},
"ranking_api": {
"client_catalog_built_from_synchronized_registries": true,
"catalog_sent_over_network": false,
"cached_player_names_repair_uuid_prefix_placeholders": true,
"uuid_prefixes_never_used_as_display_fallback": true
},
"compatibility": {
"existing_saves_supported": true,
"existing_item_stacks_supported": true,
"new_world_required": false,
"save_reset_required": false,
"monotone_data_migration": true,
"client_server_update_required": true
},
"distribution": {
"packwiz_index_refreshed": true,
"prism_modrinth_index_updated": true,
"shared_client_server_mrpack": true
}
}
+14 -14
View File
@@ -14,15 +14,15 @@ hash = "41f4f4c88c71d0a23446d5014f7407d55ad477441847099cad24492b032335a0"
[[files]]
file = "mods/ambiance-0.0.0-alpha.22.jar"
hash = "cdc60ed8f15ca910f41954d9acb6a74ac103e85c73f665fd1bc67be7eab1f15a"
hash = "c72a87d3ea187e65f3d459d18954bfc557deeb741deea143e610604a3af5985f"
[[files]]
file = "mods/anotherworld-0.0.0-alpha.57.jar"
hash = "634a122b406bd7206fd19947687fd7cb37e4cc4098822eecf3f0b7d1fea79d90"
file = "mods/anotherworld-0.0.0-alpha.58.jar"
hash = "51f0122c222317fd65a642f8568a3d822732a874ecd5e4433592ae96e2a535a8"
[[files]]
file = "mods/events-0.0.0-alpha.0.jar"
hash = "09bd8dfccca004de46ad3237728ef532de50d04c31b88f1e33721204d3c70cd8"
hash = "1c1623d4d40973346d3143657461e916e38142a867e7a6cf0da054ad998dbb0e"
[[files]]
file = "mods/fabric-api.pw.toml"
@@ -31,7 +31,7 @@ metafile = true
[[files]]
file = "mods/iliketomoveit-0.0.0-alpha.18.jar"
hash = "cd026c4735e945ec6cc19261165033dc10bcbbba75af412f4145e54437a61854"
hash = "6a38653f4898e2fe2df6f8935b516073185f7c0d3913164e769ebafcc487b318"
[[files]]
file = "mods/iris.pw.toml"
@@ -40,32 +40,32 @@ metafile = true
[[files]]
file = "mods/itsalive-0.0.0-alpha.38.jar"
hash = "d905a05edbcb841b173ef5b0090fe7a117ec06e119fba7e184338fec7671fde6"
hash = "0e40259acf98cf3d64dfdebe3441ffb3a09aa64de6cf1b2c8dcaa4af7c8d0e82"
[[files]]
file = "mods/jei.pw.toml"
hash = "9cc4a91f57effc48b94f6acc1f43067bb8eb99fd1df3397612c55761de9ec2c2"
hash = "80d0fa55c1bbc0659e792b047c2bb64c446c2bf92557d9d74777333a8a68a318"
metafile = true
[[files]]
file = "mods/masterkey-0.0.0-alpha.9.jar"
hash = "cef973db96c7523fe828a2ca57ce1283ed1da3b1ae3c646a88ea601ecfe4e825"
hash = "b42b25f1eabc0b1e7660f0071e8f105d82ceb078b4bd9b7c570df8ce2c59601c"
[[files]]
file = "mods/onlyfun-0.0.0-alpha.27.jar"
hash = "1ff5b8853c5a813bbb6c6cf5787c8311d8e72760ed0e9c2adb1da897241e4e1e"
hash = "4e2c95c54fa3f591d3e1b6f7454211caaa4fb5f7ef31cd5b6aa477d03be18e2e"
[[files]]
file = "mods/ouch-0.0.0-alpha.11.jar"
hash = "fd609cfe5f96ae1d2b41b2da7faf077047514772b56bbe130251db3bf72c2ac8"
hash = "8dcec6870fba091adda5df2a2f719e97d0f5dcf86f14c5f99035c7aebaf6c119"
[[files]]
file = "mods/redstoner-0.0.0-alpha.17.jar"
hash = "d7bef2bdf533d2c83f7b05549c01a77028dd5edc60418c7244447ca61160f216"
file = "mods/redstoner-0.0.0-alpha.20.jar"
hash = "669ca9a357874448c68ca45fdf26d827d5094b1ef1ee07f948a564b33cc1a79c"
[[files]]
file = "mods/sanctuary-0.0.0-alpha.136.jar"
hash = "973c12a3f26eb516bc1a053ebd18dab3f845645807448ff5638a1e9b26ba28bd"
file = "mods/sanctuary-0.0.0-alpha.139.jar"
hash = "62d06cfe57badc5295fdcaf1ad9d0eb6d7ba67345bdc5fc8ca6d689817f70c01"
[[files]]
file = "mods/simple-voice-chat.pw.toml"
+4 -4
View File
@@ -1,14 +1,14 @@
name = "Just Enough Items (JEI)"
filename = "jei-26.2-fabric-30.29.0.199.jar"
filename = "jei-26.2-fabric-30.29.0.201.jar"
side = "both"
pin = true
[download]
url = "https://cdn.modrinth.com/data/u6dRKJwZ/versions/astSvyl6/jei-26.2-fabric-30.29.0.199.jar"
url = "https://cdn.modrinth.com/data/u6dRKJwZ/versions/d3HeviSL/jei-26.2-fabric-30.29.0.201.jar"
hash-format = "sha512"
hash = "2849ff6a4cc91822798f0c70e7749c298b911c1037fc74e9ab0881734b8d5cb6507fb3169b4a240ef6c37eb6f7c573794c46073ff352f160feee245233b2abe5"
hash = "2279cb0ec4e41d9eebad80c2ecac76d19c8bc8e5aac69ba13f35b77494625c70d66edc74d59959116012bfb2f464b55806dd69fbf6387f25679b1553953e329a"
[update]
[update.modrinth]
mod-id = "u6dRKJwZ"
version = "astSvyl6"
version = "d3HeviSL"
+2 -2
View File
@@ -1,12 +1,12 @@
name = "Sanctuary 26.2"
author = "KOKA99CAB"
version = "26.2.0-alpha.230"
version = "26.2.0-alpha.233"
pack-format = "packwiz:1.1.0"
[index]
file = "index.toml"
hash-format = "sha256"
hash = "a78df91699f1ffa661e1bf59101891a217bd74334ee338b96824f5a9b745e443"
hash = "c666b031c2ac27a8f01cc4d12d6e471ea7471e9e5e7ce02567ee8a6649f9142a"
[versions]
fabric = "0.19.3"
+6 -6
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.230",
"versionId": "26.2.0-alpha.233",
"files": [
{
"path": "mods/fabric-api-0.159.0+26.2.jar",
@@ -21,19 +21,19 @@
"fileSize": 2535793
},
{
"path": "mods/jei-26.2-fabric-30.29.0.199.jar",
"path": "mods/jei-26.2-fabric-30.29.0.201.jar",
"hashes": {
"sha1": "1d6ab9269be16f85fb13a34a36b34ad07a9b32a7",
"sha512": "2849ff6a4cc91822798f0c70e7749c298b911c1037fc74e9ab0881734b8d5cb6507fb3169b4a240ef6c37eb6f7c573794c46073ff352f160feee245233b2abe5"
"sha1": "e82447aff07e87a372cf817bc9c28b0d77b6f55c",
"sha512": "2279cb0ec4e41d9eebad80c2ecac76d19c8bc8e5aac69ba13f35b77494625c70d66edc74d59959116012bfb2f464b55806dd69fbf6387f25679b1553953e329a"
},
"env": {
"client": "required",
"server": "required"
},
"downloads": [
"https://cdn.modrinth.com/data/u6dRKJwZ/versions/astSvyl6/jei-26.2-fabric-30.29.0.199.jar"
"https://cdn.modrinth.com/data/u6dRKJwZ/versions/d3HeviSL/jei-26.2-fabric-30.29.0.201.jar"
],
"fileSize": 1849188
"fileSize": 1860103
},
{
"path": "mods/voicechat-fabric-2.6.22+26.2.jar",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"schema_version": 1,
"pack_version": "26.2.0-alpha.230",
"pack_version": "26.2.0-alpha.233",
"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.230.mrpack",
"primary_file": "sanctuary-26.2.0-alpha.233.mrpack",
"shared_client_server_pack": true,
"update_owner": "packwiz-installer",
"check_on_launch": true,
@@ -4,6 +4,7 @@ import fr.koka99cab.sanctuary26.redstoner.api.RedStonerApi;
import fr.koka99cab.sanctuary26.redstoner.network.RedStonerNetworking;
import fr.koka99cab.sanctuary26.redstoner.registry.RedStonerRegistries;
import fr.koka99cab.sanctuary26.redstoner.registry.RedStonerMenus;
import fr.koka99cab.sanctuary26.redstoner.trophy.TrophySignService;
import fr.koka99cab.sanctuary26.sanctuary.api.server.SanctuaryServerIdentity;
import net.fabricmc.api.ModInitializer;
import net.minecraft.resources.Identifier;
@@ -13,7 +14,7 @@ import org.slf4j.LoggerFactory;
public final class RedStonerMod implements ModInitializer {
public static final String MOD_ID = "redstoner";
public static final String DISPLAY_NAME = "Red-Stoner";
public static final int CURRENT_DATA_VERSION = 4;
public static final int CURRENT_DATA_VERSION = 5;
public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID);
public RedStonerMod() {
@@ -27,6 +28,7 @@ public final class RedStonerMod implements ModInitializer {
public void onInitialize() {
RedStonerApi.registerServerIdentityProvider(SanctuaryServerIdentity::get);
RedStonerRegistries.initialize();
TrophySignService.initialize();
RedStonerMenus.initialize();
RedStonerNetworking.initialize();
LOGGER.info("[{}] Computers, Drawer, Particuleur, Storage Terminal and Trophy Panel ready (data v{}, network v{}).",
@@ -5,6 +5,7 @@ import com.mojang.serialization.codecs.RecordCodecBuilder;
import fr.koka99cab.sanctuary26.redstoner.block.TrophyPanelBlock;
import fr.koka99cab.sanctuary26.redstoner.registry.RedStonerRegistries;
import fr.koka99cab.sanctuary26.redstoner.trophy.TrophyPanelRules;
import fr.koka99cab.sanctuary26.redstoner.trophy.TrophySignService;
import fr.koka99cab.sanctuary26.sanctuary.api.ranking.RankingMetric;
import fr.koka99cab.sanctuary26.sanctuary.api.ranking.RankingSnapshot;
import fr.koka99cab.sanctuary26.sanctuary.api.ranking.RankingUnit;
@@ -36,6 +37,7 @@ public final class TrophyPanelBlockEntity extends BlockEntity implements ItemOwn
private static final int DATA_VERSION = 1;
private static final int REFRESH_INTERVAL_TICKS = 20;
private static final String CLIENT_SNAPSHOT_TAG = "VisibleSnapshot";
private static final String CLIENT_VALUES_VIEW_TAG = "ValuesView";
private static final Codec<Page> PAGE_CODEC = RecordCodecBuilder.create(instance -> instance.group(
Identifier.CODEC.fieldOf("metric").forGetter(Page::metricId),
ItemStack.OPTIONAL_CODEC.fieldOf("icon").forGetter(Page::icon)
@@ -60,7 +62,9 @@ public final class TrophyPanelBlockEntity extends BlockEntity implements ItemOwn
private String ownerName = "?";
private List<Page> pages = List.of(defaultPage());
private int currentPage;
private boolean valuesView;
private long lastRefreshTick = Long.MIN_VALUE;
private boolean migrationAttempted;
private ClientSnapshot visibleSnapshot = ClientSnapshot.empty(SanctuaryRankingApi.defaultMetric());
public TrophyPanelBlockEntity(BlockPos pos, BlockState state) {
@@ -70,6 +74,13 @@ public final class TrophyPanelBlockEntity extends BlockEntity implements ItemOwn
public static void serverTick(net.minecraft.world.level.Level level, BlockPos pos, BlockState state,
TrophyPanelBlockEntity panel) {
if (!(level instanceof ServerLevel serverLevel)) return;
if (!panel.migrationAttempted) {
panel.migrationAttempted = true;
if (TrophySignService.migrateLegacy(serverLevel, pos, state.getValue(TrophyPanelBlock.FACING),
panel.owner, panel.ownerName, panel.pages.stream().map(Page::metricId).toList(), panel.currentPage)) {
return;
}
}
long now = level.getGameTime();
if (panel.lastRefreshTick != Long.MIN_VALUE && now - panel.lastRefreshTick < REFRESH_INTERVAL_TICKS) return;
panel.lastRefreshTick = now;
@@ -104,6 +115,7 @@ public final class TrophyPanelBlockEntity extends BlockEntity implements ItemOwn
currentPage = Math.max(0, pages.indexOf(pages.stream()
.filter(page -> offered.stream().anyMatch(metric -> metric.id().equals(page.metricId())))
.findFirst().orElse(pages.getFirst())));
valuesView = false;
ownerName = cleanName(player.getGameProfile().name());
syncPersistent();
if (level instanceof ServerLevel serverLevel) refresh(serverLevel);
@@ -120,7 +132,10 @@ public final class TrophyPanelBlockEntity extends BlockEntity implements ItemOwn
private boolean movePage(int offset) {
if (!writable() || pages.isEmpty()) return false;
currentPage = TrophyPanelRules.pageByOffset(currentPage, pages.size(), offset);
TrophyPanelRules.DisplayPage displayPage = TrophyPanelRules.displayPageByOffset(
currentPage, valuesView, pages.size(), offset);
currentPage = displayPage.metricPage();
valuesView = displayPage.valuesView();
syncPersistent();
if (level instanceof ServerLevel serverLevel) refresh(serverLevel);
return true;
@@ -131,11 +146,15 @@ public final class TrophyPanelBlockEntity extends BlockEntity implements ItemOwn
}
public int pageNumber() {
return currentPage + 1;
return TrophyPanelRules.displayPageNumber(currentPage, valuesView, pages.size());
}
public int pageCount() {
return pages.size();
return TrophyPanelRules.displayPageCount(pages.size());
}
public boolean valuesView() {
return valuesView;
}
public String ownerName() {
@@ -174,6 +193,7 @@ public final class TrophyPanelBlockEntity extends BlockEntity implements ItemOwn
pages = input.read("Pages", PAGE_CODEC.listOf(0, TrophyPanelRules.MAX_PAGES))
.map(TrophyPanelBlockEntity::sanitizePages).orElseGet(() -> List.of(defaultPage()));
currentPage = Math.clamp(input.getIntOr("CurrentPage", 0), 0, pages.size() - 1);
valuesView = input.getBooleanOr(CLIENT_VALUES_VIEW_TAG, false);
visibleSnapshot = input.read(CLIENT_SNAPSHOT_TAG, CLIENT_SNAPSHOT_CODEC)
.orElseGet(() -> ClientSnapshot.empty(metricFor(currentPage().metricId())));
}
@@ -187,6 +207,7 @@ public final class TrophyPanelBlockEntity extends BlockEntity implements ItemOwn
public CompoundTag getUpdateTag(HolderLookup.Provider lookup) {
CompoundTag tag = saveCustomOnly(lookup);
tag.store(CLIENT_SNAPSHOT_TAG, CLIENT_SNAPSHOT_CODEC, visibleSnapshot);
tag.putBoolean(CLIENT_VALUES_VIEW_TAG, valuesView);
return tag;
}
@@ -4,10 +4,12 @@ import fr.koka99cab.sanctuary26.redstoner.RedStonerMod;
import fr.koka99cab.sanctuary26.redstoner.client.gui.ComputerScreen;
import fr.koka99cab.sanctuary26.redstoner.client.gui.PersonalComputerScreen;
import fr.koka99cab.sanctuary26.redstoner.client.gui.StorageTerminalScreen;
import fr.koka99cab.sanctuary26.redstoner.client.gui.TrophySignEditorScreen;
import fr.koka99cab.sanctuary26.redstoner.client.renderer.DrawerBlockEntityRenderer;
import fr.koka99cab.sanctuary26.redstoner.client.renderer.TrophyPanelBlockEntityRenderer;
import fr.koka99cab.sanctuary26.redstoner.network.OpenComputerPayload;
import fr.koka99cab.sanctuary26.redstoner.network.OpenPersonalComputerPayload;
import fr.koka99cab.sanctuary26.redstoner.network.OpenTrophySignEditorPayload;
import fr.koka99cab.sanctuary26.redstoner.registry.RedStonerMenus;
import fr.koka99cab.sanctuary26.redstoner.registry.RedStonerRegistries;
import net.fabricmc.api.ClientModInitializer;
@@ -33,6 +35,9 @@ public final class RedStonerClient implements ClientModInitializer {
context.client().gui.setScreen(new PersonalComputerScreen(payload));
}
}));
ClientPlayNetworking.registerGlobalReceiver(OpenTrophySignEditorPayload.TYPE, (payload, context) ->
context.client().execute(() -> context.client().gui.setScreen(
new TrophySignEditorScreen(context.client().gui.screen(), payload))));
RedStonerMod.LOGGER.info("[{}] Client renderers and laboratory screens ready.", RedStonerMod.DISPLAY_NAME);
}
@@ -1,5 +1,6 @@
package fr.koka99cab.sanctuary26.redstoner.client.gui;
import fr.koka99cab.sanctuary26.redstoner.storage.StorageTerminalKeyRules;
import fr.koka99cab.sanctuary26.redstoner.storage.StorageTerminalMenu;
import fr.koka99cab.sanctuary26.redstoner.storage.StorageTerminalRules;
import fr.koka99cab.sanctuary26.redstoner.storage.StorageTerminalSort;
@@ -139,10 +140,9 @@ public final class StorageTerminalScreen extends AbstractContainerScreen<Storage
@Override
public boolean keyPressed(KeyEvent event) {
if (searchBox != null && searchBox.isFocused() && minecraft != null
&& minecraft.options.keyInventory.matches(event)) {
clearSearchFocus();
}
if (StorageTerminalKeyRules.capturesInventoryKey(
searchBox != null && searchBox.isFocused(), minecraft != null
&& minecraft.options.keyInventory.matches(event))) return true;
return super.keyPressed(event);
}
@@ -0,0 +1,265 @@
package fr.koka99cab.sanctuary26.redstoner.client.gui;
import fr.koka99cab.sanctuary26.redstoner.network.OpenTrophySignEditorPayload;
import fr.koka99cab.sanctuary26.redstoner.network.SaveTrophySignPayload;
import fr.koka99cab.sanctuary26.redstoner.trophy.TrophyDisplayMode;
import fr.koka99cab.sanctuary26.redstoner.trophy.TrophyPanelRules;
import fr.koka99cab.sanctuary26.sanctuary.api.ranking.RankingMetric;
import fr.koka99cab.sanctuary26.sanctuary.api.ranking.RankingUnit;
import fr.koka99cab.sanctuary26.sanctuary.api.ranking.SanctuaryRankingApi;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
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;
import net.minecraft.resources.Identifier;
/** White, bounded editor inspired by the community quest-sign screen. */
public final class TrophySignEditorScreen extends Screen {
private static final int VISIBLE_ROWS = 5;
private static final int PANEL_WIDTH = 328;
private static final int PANEL_HEIGHT = 250;
private static final int COLUMN_WIDTH = 148;
private static final int GAP = 8;
private static final int BACKDROP = 0xD0F1F0EC;
private static final int BORDER = 0xFFFFFFFF;
private static final int TITLE = 0xFF343434;
private static final int MUTED = 0xFF6A6A6A;
private final Screen parent;
private final OpenTrophySignEditorPayload payload;
private final List<Identifier> pages;
private final List<MetricOption> catalog = new ArrayList<>();
private final List<MetricOption> filtered = new ArrayList<>();
private final Button[] resultButtons = new Button[VISIBLE_ROWS];
private final Button[] pageButtons = new Button[VISIBLE_ROWS];
private EditBox search;
private Button previousResults;
private Button nextResults;
private Button previousPages;
private Button nextPages;
private Button moveUp;
private Button moveDown;
private Button remove;
private Button displayMode;
private Button save;
private TrophyDisplayMode mode;
private int catalogOffset;
private int pageOffset;
private int selectedPage = -1;
public TrophySignEditorScreen(Screen parent, OpenTrophySignEditorPayload payload) {
super(Component.translatable("screen.redstoner.trophy_sign.title"));
this.parent = parent;
this.payload = payload;
this.pages = new ArrayList<>(payload.pages());
this.mode = TrophyDisplayMode.byKey(payload.display());
if (!pages.isEmpty()) selectedPage = 0;
}
@Override
protected void init() {
int x = panelX() + 12;
int y = panelY();
buildCatalog();
search = addRenderableWidget(new EditBox(font, x, y + 38, COLUMN_WIDTH, 20,
Component.translatable("screen.redstoner.trophy_sign.search")));
search.setMaxLength(64);
search.setHint(Component.translatable("screen.redstoner.trophy_sign.search"));
search.setResponder(value -> filterCatalog());
for (int slot = 0; slot < VISIBLE_ROWS; slot++) {
int resultSlot = slot;
resultButtons[slot] = addRenderableWidget(Button.builder(Component.empty(),
button -> addResult(resultSlot)).bounds(x, y + 62 + slot * 22, COLUMN_WIDTH, 20).build());
int pageSlot = slot;
pageButtons[slot] = addRenderableWidget(Button.builder(Component.empty(),
button -> selectPage(pageSlot)).bounds(x + COLUMN_WIDTH + GAP, y + 38 + slot * 22,
COLUMN_WIDTH, 20).build());
}
previousResults = addRenderableWidget(Button.builder(Component.literal(""), button -> moveCatalog(-VISIBLE_ROWS))
.bounds(x, y + 174, 72, 20).build());
nextResults = addRenderableWidget(Button.builder(Component.literal(""), button -> moveCatalog(VISIBLE_ROWS))
.bounds(x + 76, y + 174, 72, 20).build());
previousPages = addRenderableWidget(Button.builder(Component.literal(""), button -> movePageWindow(-VISIBLE_ROWS))
.bounds(x + COLUMN_WIDTH + GAP, y + 174, 72, 20).build());
nextPages = addRenderableWidget(Button.builder(Component.literal(""), button -> movePageWindow(VISIBLE_ROWS))
.bounds(x + COLUMN_WIDTH + GAP + 76, y + 174, 72, 20).build());
moveUp = addRenderableWidget(Button.builder(Component.literal(""), button -> moveSelected(-1))
.bounds(x + COLUMN_WIDTH + GAP, y + 150, 46, 20).build());
moveDown = addRenderableWidget(Button.builder(Component.literal(""), button -> moveSelected(1))
.bounds(x + COLUMN_WIDTH + GAP + 50, y + 150, 46, 20).build());
remove = addRenderableWidget(Button.builder(Component.translatable("screen.redstoner.trophy_sign.remove"),
button -> removeSelected()).bounds(x + COLUMN_WIDTH + GAP + 100, y + 150, 48, 20).build());
displayMode = addRenderableWidget(Button.builder(displayLabel(), button -> {
mode = mode.next();
button.setMessage(displayLabel());
}).bounds(x, y + 198, COLUMN_WIDTH * 2 + GAP, 20).build());
save = addRenderableWidget(Button.builder(Component.translatable("gui.done"), button -> submit())
.bounds(x, y + 224, COLUMN_WIDTH, 20).build());
addRenderableWidget(Button.builder(Component.translatable("gui.cancel"), button -> onClose())
.bounds(x + COLUMN_WIDTH + GAP, y + 224, COLUMN_WIDTH, 20).build());
filterCatalog();
refreshWidgets();
setInitialFocus(search);
}
@Override
public void tick() {
refreshWidgets();
}
@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 + 10, TITLE);
graphics.text(font, Component.translatable("screen.redstoner.trophy_sign.available"),
x + 12, y + 27, MUTED);
graphics.text(font, Component.translatable("screen.redstoner.trophy_sign.pages",
pages.size(), TrophyPanelRules.MAX_PAGES), x + 12 + COLUMN_WIDTH + GAP, y + 27, MUTED);
super.extractRenderState(graphics, mouseX, mouseY, tickDelta);
}
private void buildCatalog() {
catalog.clear();
for (RankingMetric metric : SanctuaryRankingApi.configurableMetrics()) {
Component label = SanctuaryRankingApi.displayName(metric);
catalog.add(new MetricOption(metric, label, (label.getString() + " " + metric.id())
.toLowerCase(Locale.ROOT)));
}
catalog.sort(Comparator.comparing(option -> option.label().getString(), String.CASE_INSENSITIVE_ORDER));
}
private void filterCatalog() {
if (search == null) return;
String query = search.getValue().strip().toLowerCase(Locale.ROOT);
filtered.clear();
for (MetricOption option : catalog) if (query.isEmpty() || option.searchText().contains(query)) filtered.add(option);
catalogOffset = 0;
refreshWidgets();
}
private void addResult(int slot) {
int index = catalogOffset + slot;
if (index < 0 || index >= filtered.size()) return;
Identifier id = filtered.get(index).metric().id();
int existing = pages.indexOf(id);
if (existing >= 0) {
selectedPage = existing;
ensureSelectedVisible();
} else if (pages.size() < TrophyPanelRules.MAX_PAGES) {
pages.add(id);
selectedPage = pages.size() - 1;
ensureSelectedVisible();
}
refreshWidgets();
}
private void selectPage(int slot) {
int index = pageOffset + slot;
if (index >= 0 && index < pages.size()) selectedPage = index;
refreshWidgets();
}
private void moveSelected(int offset) {
int target = selectedPage + offset;
if (selectedPage < 0 || target < 0 || target >= pages.size()) return;
Identifier page = pages.remove(selectedPage);
pages.add(target, page);
selectedPage = target;
ensureSelectedVisible();
refreshWidgets();
}
private void removeSelected() {
if (selectedPage < 0 || selectedPage >= pages.size()) return;
pages.remove(selectedPage);
selectedPage = pages.isEmpty() ? -1 : Math.min(selectedPage, pages.size() - 1);
ensureSelectedVisible();
refreshWidgets();
}
private void moveCatalog(int amount) {
int maximum = Math.max(0, filtered.size() - VISIBLE_ROWS);
catalogOffset = Math.clamp(catalogOffset + amount, 0, maximum);
refreshWidgets();
}
private void movePageWindow(int amount) {
int maximum = Math.max(0, pages.size() - VISIBLE_ROWS);
pageOffset = Math.clamp(pageOffset + amount, 0, maximum);
refreshWidgets();
}
private void ensureSelectedVisible() {
if (selectedPage < pageOffset) pageOffset = selectedPage;
if (selectedPage >= pageOffset + VISIBLE_ROWS) pageOffset = selectedPage - VISIBLE_ROWS + 1;
pageOffset = Math.max(0, pageOffset);
}
private void refreshWidgets() {
if (save == null) return;
for (int slot = 0; slot < VISIBLE_ROWS; slot++) {
int resultIndex = catalogOffset + slot;
Button result = resultButtons[slot];
result.visible = resultIndex < filtered.size();
if (result.visible) {
MetricOption option = filtered.get(resultIndex);
result.setMessage(Component.literal(pages.contains(option.metric().id()) ? "" : "+ ")
.append(option.label()));
result.active = pages.contains(option.metric().id()) || pages.size() < TrophyPanelRules.MAX_PAGES;
}
int pageIndex = pageOffset + slot;
Button page = pageButtons[slot];
page.visible = pageIndex < pages.size();
if (page.visible) page.setMessage(Component.literal(pageIndex == selectedPage ? "" : "")
.append(Component.literal((pageIndex + 1) + ". ")).append(metricLabel(pages.get(pageIndex))));
}
previousResults.active = catalogOffset > 0;
nextResults.active = catalogOffset + VISIBLE_ROWS < filtered.size();
previousPages.active = pageOffset > 0;
nextPages.active = pageOffset + VISIBLE_ROWS < pages.size();
moveUp.active = selectedPage > 0;
moveDown.active = selectedPage >= 0 && selectedPage + 1 < pages.size();
remove.active = selectedPage >= 0;
save.active = !pages.isEmpty() && ClientPlayNetworking.canSend(SaveTrophySignPayload.TYPE);
}
private Component metricLabel(Identifier id) {
RankingMetric metric = SanctuaryRankingApi.metric(id).orElseGet(() ->
new RankingMetric(id, "metric.sanctuary.ranking.unknown", RankingUnit.COUNT));
return SanctuaryRankingApi.displayName(metric);
}
private Component displayLabel() {
return Component.translatable("screen.redstoner.trophy_sign.display",
Component.translatable("screen.redstoner.trophy_sign.display." + mode.key()));
}
private void submit() {
if (pages.isEmpty() || !ClientPlayNetworking.canSend(SaveTrophySignPayload.TYPE)) return;
ClientPlayNetworking.send(new SaveTrophySignPayload(payload.pos(), pages, mode.key()));
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; }
private record MetricOption(RankingMetric metric, Component label, String searchText) {}
}
@@ -5,7 +5,11 @@ import com.mojang.math.Axis;
import fr.koka99cab.sanctuary26.redstoner.block.TrophyPanelBlock;
import fr.koka99cab.sanctuary26.redstoner.block.entity.TrophyPanelBlockEntity;
import fr.koka99cab.sanctuary26.redstoner.client.renderer.state.TrophyPanelRenderState;
import fr.koka99cab.sanctuary26.redstoner.trophy.TrophyPanelRules;
import fr.koka99cab.sanctuary26.sanctuary.api.ranking.RankingMetric;
import fr.koka99cab.sanctuary26.sanctuary.api.ranking.RankingUnit;
import fr.koka99cab.sanctuary26.sanctuary.api.ranking.SanctuaryRankingApi;
import java.util.Arrays;
import net.minecraft.client.gui.Font;
import net.minecraft.client.renderer.SubmitNodeCollector;
import net.minecraft.client.renderer.blockentity.BlockEntityRenderer;
@@ -21,7 +25,7 @@ public final class TrophyPanelBlockEntityRenderer
implements BlockEntityRenderer<TrophyPanelBlockEntity, TrophyPanelRenderState> {
private static final int MAX_TEXT_WIDTH = 90;
private static final float TEXT_SCALE = 0.010416667F;
private static final float[] LINE_HEIGHTS = {0.70F, 0.59F, 0.48F, 0.37F};
private static final float[] LINE_HEIGHTS = {0.86F, 0.75F, 0.64F, 0.53F, 0.42F, 0.31F, 0.20F, 0.09F};
private static final int TEXT_COLOR = 0xFF3D281A;
private final Font font;
@@ -39,26 +43,27 @@ public final class TrophyPanelBlockEntityRenderer
float tickProgress, Vec3 cameraPos, ModelFeatureRenderer.CrumblingOverlay crumblingOverlay) {
BlockEntityRenderer.super.extractRenderState(panel, state, tickProgress, cameraPos, crumblingOverlay);
state.facing = panel.getBlockState().getValue(TrophyPanelBlock.FACING);
Arrays.fill(state.lines, Component.empty());
ItemStack icon = panel.currentPage().icon();
TrophyPanelBlockEntity.ClientSnapshot snapshot = panel.visibleSnapshot();
RankingUnit unit = snapshot.unit();
Component metric = !icon.isEmpty() ? Component.translatable(snapshot.translationKey(), icon.getHoverName())
: Component.translatable(snapshot.translationKey());
Component metric = SanctuaryRankingApi.displayName(
new RankingMetric(snapshot.metricId(), snapshot.translationKey(), unit), icon);
state.lines[0] = Component.translatable("block.redstoner.trophy_panel.page_title",
panel.pageNumber(), panel.pageCount(), metric);
if (snapshot.podium().isEmpty()) {
state.lines[1] = Component.translatable("block.redstoner.trophy_panel.no_score");
state.lines[2] = Component.empty();
state.lines[3] = Component.empty();
} else {
for (int index = 0; index < 3; index++) {
for (int index = 0; index < TrophyPanelRules.ENTRIES_PER_VIEW; index++) {
if (index >= snapshot.podium().size()) {
state.lines[index + 1] = Component.empty();
continue;
break;
}
TrophyPanelBlockEntity.ClientEntry entry = snapshot.podium().get(index);
state.lines[index + 1] = Component.translatable("block.redstoner.trophy_panel.podium",
entry.rank(), entry.name(), unit.format(entry.value()));
state.lines[index + 1] = panel.valuesView()
? Component.translatable("block.redstoner.trophy_panel.value",
entry.rank(), unit.format(entry.value()))
: Component.translatable("block.redstoner.trophy_panel.player",
entry.rank(), entry.name());
}
}
}
@@ -1,13 +1,16 @@
package fr.koka99cab.sanctuary26.redstoner.client.renderer.state;
import fr.koka99cab.sanctuary26.redstoner.trophy.TrophyPanelRules;
import net.minecraft.client.renderer.blockentity.state.BlockEntityRenderState;
import net.minecraft.core.Direction;
import net.minecraft.network.chat.Component;
public final class TrophyPanelRenderState extends BlockEntityRenderState {
public static final int LINE_COUNT = 4;
public final Component[] lines = {
Component.empty(), Component.empty(), Component.empty(), Component.empty()
};
public static final int LINE_COUNT = TrophyPanelRules.LINE_COUNT;
public final Component[] lines = new Component[LINE_COUNT];
public Direction facing = Direction.NORTH;
public TrophyPanelRenderState() {
java.util.Arrays.fill(lines, Component.empty());
}
}
@@ -0,0 +1,51 @@
package fr.koka99cab.sanctuary26.redstoner.network;
import fr.koka99cab.sanctuary26.redstoner.RedStonerMod;
import fr.koka99cab.sanctuary26.redstoner.trophy.TrophyDisplayMode;
import fr.koka99cab.sanctuary26.redstoner.trophy.TrophyPanelRules;
import java.util.ArrayList;
import java.util.List;
import net.minecraft.core.BlockPos;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.resources.Identifier;
/** Opens the white trophy-sign editor with only its small current configuration. */
public record OpenTrophySignEditorPayload(BlockPos pos, List<Identifier> pages, String display)
implements CustomPacketPayload {
public static final Type<OpenTrophySignEditorPayload> TYPE =
new Type<>(RedStonerMod.id("s2c/open_trophy_sign_editor"));
public static final StreamCodec<RegistryFriendlyByteBuf, OpenTrophySignEditorPayload> STREAM_CODEC =
StreamCodec.of(OpenTrophySignEditorPayload::write, OpenTrophySignEditorPayload::read);
public OpenTrophySignEditorPayload {
pos = pos == null ? BlockPos.ZERO : pos.immutable();
pages = TrophyPanelRules.sanitizeMetricIds(pages);
display = TrophyDisplayMode.byKey(display).key();
}
private static OpenTrophySignEditorPayload read(RegistryFriendlyByteBuf buffer) {
BlockPos pos = buffer.readBlockPos();
int count = boundedCount(buffer.readVarInt());
List<Identifier> pages = new ArrayList<>(count);
for (int index = 0; index < count; index++) pages.add(Identifier.parse(buffer.readUtf(256)));
return new OpenTrophySignEditorPayload(pos, pages, buffer.readUtf(24));
}
private static void write(RegistryFriendlyByteBuf buffer, OpenTrophySignEditorPayload payload) {
buffer.writeBlockPos(payload.pos);
buffer.writeVarInt(payload.pages.size());
for (Identifier page : payload.pages) buffer.writeUtf(page.toString(), 256);
buffer.writeUtf(payload.display, 24);
}
private static int boundedCount(int count) {
if (count < 0 || count > TrophyPanelRules.MAX_PAGES) {
throw new IllegalArgumentException("Invalid trophy-sign page count: " + count);
}
return count;
}
@Override public Type<? extends CustomPacketPayload> type() { return TYPE; }
}
@@ -4,6 +4,7 @@ import fr.koka99cab.sanctuary26.redstoner.RedStonerMod;
import fr.koka99cab.sanctuary26.redstoner.block.entity.ComputerBlockEntity;
import fr.koka99cab.sanctuary26.redstoner.block.entity.PersonalComputerBlockEntity;
import fr.koka99cab.sanctuary26.redstoner.registry.RedStonerRegistries;
import fr.koka99cab.sanctuary26.redstoner.trophy.TrophySignService;
import fr.koka99cab.sanctuary26.sanctuary.api.atlas.SanctuaryAtlasLandmarks;
import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry;
import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
@@ -12,7 +13,7 @@ import net.minecraft.world.entity.player.Player;
import net.minecraft.world.phys.Vec3;
public final class RedStonerNetworking {
public static final int PROTOCOL_VERSION = 7;
public static final int PROTOCOL_VERSION = 9;
private static boolean initialized;
private RedStonerNetworking() {
@@ -25,6 +26,8 @@ public final class RedStonerNetworking {
initialized = true;
PayloadTypeRegistry.clientboundPlay().register(OpenComputerPayload.TYPE, OpenComputerPayload.STREAM_CODEC);
PayloadTypeRegistry.clientboundPlay().register(OpenPersonalComputerPayload.TYPE, OpenPersonalComputerPayload.STREAM_CODEC);
PayloadTypeRegistry.clientboundPlay().register(OpenTrophySignEditorPayload.TYPE,
OpenTrophySignEditorPayload.STREAM_CODEC);
PayloadTypeRegistry.serverboundPlay().register(SaveComputerProgramPayload.TYPE, SaveComputerProgramPayload.STREAM_CODEC);
PayloadTypeRegistry.serverboundPlay().register(PlaceAtlasLandmarkPayload.TYPE, PlaceAtlasLandmarkPayload.STREAM_CODEC);
PayloadTypeRegistry.serverboundPlay().register(RequestPersonalComputerSnapshotPayload.TYPE,
@@ -33,6 +36,8 @@ public final class RedStonerNetworking {
AcknowledgePersonalComputerIntroPayload.STREAM_CODEC);
PayloadTypeRegistry.serverboundPlay().register(ShutdownPersonalComputerPayload.TYPE,
ShutdownPersonalComputerPayload.STREAM_CODEC);
PayloadTypeRegistry.serverboundPlay().register(SaveTrophySignPayload.TYPE,
SaveTrophySignPayload.STREAM_CODEC);
ServerPlayNetworking.registerGlobalReceiver(SaveComputerProgramPayload.TYPE, (payload, context) ->
context.server().execute(() -> saveProgram(context.player(), payload)));
ServerPlayNetworking.registerGlobalReceiver(PlaceAtlasLandmarkPayload.TYPE, (payload, context) ->
@@ -43,6 +48,8 @@ public final class RedStonerNetworking {
context.server().execute(() -> acknowledgePersonalComputerIntro(context.player(), payload)));
ServerPlayNetworking.registerGlobalReceiver(ShutdownPersonalComputerPayload.TYPE, (payload, context) ->
context.server().execute(() -> shutdownPersonalComputer(context.player(), payload)));
ServerPlayNetworking.registerGlobalReceiver(SaveTrophySignPayload.TYPE, (payload, context) ->
context.server().execute(() -> TrophySignService.save(context.player(), payload)));
RedStonerMod.LOGGER.debug("[{}] Networking contract v{} registered.", RedStonerMod.DISPLAY_NAME, PROTOCOL_VERSION);
}
@@ -0,0 +1,51 @@
package fr.koka99cab.sanctuary26.redstoner.network;
import fr.koka99cab.sanctuary26.redstoner.RedStonerMod;
import fr.koka99cab.sanctuary26.redstoner.trophy.TrophyDisplayMode;
import fr.koka99cab.sanctuary26.redstoner.trophy.TrophyPanelRules;
import java.util.ArrayList;
import java.util.List;
import net.minecraft.core.BlockPos;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.resources.Identifier;
/** Bounded editor submission; ownership, distance, block and metrics are all revalidated server-side. */
public record SaveTrophySignPayload(BlockPos pos, List<Identifier> pages, String display)
implements CustomPacketPayload {
public static final Type<SaveTrophySignPayload> TYPE =
new Type<>(RedStonerMod.id("c2s/save_trophy_sign"));
public static final StreamCodec<RegistryFriendlyByteBuf, SaveTrophySignPayload> STREAM_CODEC =
StreamCodec.of(SaveTrophySignPayload::write, SaveTrophySignPayload::read);
public SaveTrophySignPayload {
pos = pos == null ? BlockPos.ZERO : pos.immutable();
pages = TrophyPanelRules.sanitizeMetricIds(pages);
display = TrophyDisplayMode.byKey(display).key();
}
private static SaveTrophySignPayload read(RegistryFriendlyByteBuf buffer) {
BlockPos pos = buffer.readBlockPos();
int count = boundedCount(buffer.readVarInt());
List<Identifier> pages = new ArrayList<>(count);
for (int index = 0; index < count; index++) pages.add(Identifier.parse(buffer.readUtf(256)));
return new SaveTrophySignPayload(pos, pages, buffer.readUtf(24));
}
private static void write(RegistryFriendlyByteBuf buffer, SaveTrophySignPayload payload) {
buffer.writeBlockPos(payload.pos);
buffer.writeVarInt(payload.pages.size());
for (Identifier page : payload.pages) buffer.writeUtf(page.toString(), 256);
buffer.writeUtf(payload.display, 24);
}
private static int boundedCount(int count) {
if (count < 0 || count > TrophyPanelRules.MAX_PAGES) {
throw new IllegalArgumentException("Invalid trophy-sign page count: " + count);
}
return count;
}
@Override public Type<? extends CustomPacketPayload> type() { return TYPE; }
}
@@ -12,8 +12,10 @@ import fr.koka99cab.sanctuary26.redstoner.block.entity.DrawerBlockEntity;
import fr.koka99cab.sanctuary26.redstoner.block.entity.PersonalComputerBlockEntity;
import fr.koka99cab.sanctuary26.redstoner.block.entity.ParticuleurBlockEntity;
import fr.koka99cab.sanctuary26.redstoner.block.entity.TrophyPanelBlockEntity;
import fr.koka99cab.sanctuary26.redstoner.trophy.TrophySignItem;
import java.util.function.Function;
import net.fabricmc.fabric.api.event.player.AttackBlockCallback;
import net.fabricmc.fabric.api.object.builder.v1.block.entity.FabricBlockEntityType;
import net.fabricmc.fabric.api.object.builder.v1.block.entity.FabricBlockEntityTypeBuilder;
import net.minecraft.core.Registry;
import net.minecraft.core.registries.BuiltInRegistries;
@@ -28,10 +30,15 @@ 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.Blocks;
import net.minecraft.world.level.block.SoundType;
import net.minecraft.world.level.block.StandingSignBlock;
import net.minecraft.world.level.block.WallSignBlock;
import net.minecraft.world.level.block.entity.BlockEntityType;
import net.minecraft.world.level.block.entity.BlockEntityTypes;
import net.minecraft.world.level.block.state.BlockBehaviour;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.properties.WoodType;
import net.minecraft.world.level.material.MapColor;
public final class RedStonerRegistries {
@@ -79,6 +86,18 @@ public final class RedStonerRegistries {
BlockBehaviour.Properties.of().mapColor(MapColor.WOOD).strength(2.0F).sound(SoundType.WOOD)
.requiresCorrectToolForDrops().noOcclusion().setId(blockKey(RedStonerMod.id("trophy_panel")))
);
public static final StandingSignBlock TROPHY_SIGN = registerBlock(
RedStonerMod.id("trophy_sign"),
properties -> new StandingSignBlock(WoodType.PALE_OAK, properties),
BlockBehaviour.Properties.ofFullCopy(Blocks.PALE_OAK_SIGN).mapColor(MapColor.SNOW)
.setId(blockKey(RedStonerMod.id("trophy_sign")))
);
public static final WallSignBlock TROPHY_WALL_SIGN = registerBlock(
RedStonerMod.id("trophy_wall_sign"),
properties -> new WallSignBlock(WoodType.PALE_OAK, properties),
BlockBehaviour.Properties.ofFullCopy(Blocks.PALE_OAK_WALL_SIGN).mapColor(MapColor.SNOW)
.setId(blockKey(RedStonerMod.id("trophy_wall_sign")))
);
public static final ComputerBlock LEGACY_EIGHT_BIT_COMPUTER = registerBlock(
LEGACY_COMPUTER_ID, ComputerBlock::new, computerProperties(LEGACY_COMPUTER_ID, MapColor.COLOR_RED));
@@ -100,7 +119,7 @@ public final class RedStonerRegistries {
public static final Item PARTICULEUR_ITEM = registerBlockItem(RedStonerMod.id("particuleur"), PARTICULEUR);
public static final Item STORAGE_TERMINAL_ITEM = registerBlockItem(
RedStonerMod.id("storage_terminal"), STORAGE_TERMINAL);
public static final Item TROPHY_PANEL_ITEM = registerBlockItem(RedStonerMod.id("trophy_panel"), TROPHY_PANEL);
public static final Item TROPHY_PANEL_ITEM = registerTrophySignItem();
public static final Item LEGACY_EIGHT_BIT_COMPUTER_ITEM = registerBlockItem(LEGACY_COMPUTER_ID, LEGACY_EIGHT_BIT_COMPUTER);
public static final Item LEGACY_CONTROLLER_ITEM = registerBlockItem(LEGACY_CONTROLLER_ID, LEGACY_CONTROLLER);
@@ -153,8 +172,16 @@ public final class RedStonerRegistries {
return;
}
initialized = true;
FabricBlockEntityType signType = (FabricBlockEntityType) (Object) BlockEntityTypes.SIGN;
signType.addValidBlock(TROPHY_SIGN);
signType.addValidBlock(TROPHY_WALL_SIGN);
registerDrawerAttack();
RedStonerMod.LOGGER.info("[{}] Registered seven active blocks and five legacy block aliases.", RedStonerMod.DISPLAY_NAME);
RedStonerMod.LOGGER.info("[{}] Registered the vanilla trophy-sign pair and retained the old panel for migration.",
RedStonerMod.DISPLAY_NAME);
}
public static boolean isTrophySign(Block block) {
return block == TROPHY_SIGN || block == TROPHY_WALL_SIGN;
}
public static boolean isController(BlockState state) {
@@ -199,6 +226,14 @@ public final class RedStonerRegistries {
new BlockItem(block, new Item.Properties().useBlockDescriptionPrefix().setId(key)));
}
private static Item registerTrophySignItem() {
Identifier id = RedStonerMod.id("trophy_panel");
ResourceKey<Item> key = ResourceKey.create(Registries.ITEM, id);
return Registry.register(BuiltInRegistries.ITEM, key,
new TrophySignItem(TROPHY_SIGN, TROPHY_WALL_SIGN,
new Item.Properties().useBlockDescriptionPrefix().setId(key)));
}
private static ResourceKey<Block> blockKey(Identifier id) {
return ResourceKey.create(Registries.BLOCK, id);
}
@@ -0,0 +1,10 @@
package fr.koka99cab.sanctuary26.redstoner.storage;
/** Keyboard routing shared by the terminal screen and its deterministic smoke test. */
public final class StorageTerminalKeyRules {
private StorageTerminalKeyRules() {}
public static boolean capturesInventoryKey(boolean searchFocused, boolean inventoryKey) {
return searchFocused && inventoryKey;
}
}
@@ -0,0 +1,28 @@
package fr.koka99cab.sanctuary26.redstoner.trophy;
/** Stable, persisted choice for the three ranking rows of a vanilla trophy sign. */
public enum TrophyDisplayMode {
NAMES_AND_VALUES("names_and_values"),
NAMES("names"),
VALUES("values");
private final String key;
TrophyDisplayMode(String key) {
this.key = key;
}
public String key() {
return key;
}
public TrophyDisplayMode next() {
TrophyDisplayMode[] modes = values();
return modes[(ordinal() + 1) % modes.length];
}
public static TrophyDisplayMode byKey(String key) {
for (TrophyDisplayMode mode : values()) if (mode.key.equals(key)) return mode;
return NAMES_AND_VALUES;
}
}
@@ -9,6 +9,12 @@ import net.minecraft.resources.Identifier;
/** Pure bounded rules shared by the trophy panel and its smoke tests. */
public final class TrophyPanelRules {
public static final int MAX_PAGES = 16;
public static final int SIGN_LINE_COUNT = 4;
public static final int SIGN_ENTRIES_PER_PAGE = SIGN_LINE_COUNT - 1;
/** Legacy plate dimensions kept only while old block entities migrate. */
public static final int LINE_COUNT = 8;
public static final int ENTRIES_PER_VIEW = LINE_COUNT - 1;
private static final int VIEWS_PER_METRIC = 2;
private TrophyPanelRules() {}
@@ -28,10 +34,41 @@ public final class TrophyPanelRules {
return pageCount <= 0 ? 0 : Math.floorMod(currentPage + offset, pageCount);
}
public static DisplayPage displayPageByOffset(int metricPage, boolean valuesView,
int metricCount, int offset) {
int safeMetricCount = Math.max(1, metricCount);
int safeMetricPage = Math.clamp(metricPage, 0, safeMetricCount - 1);
int currentDisplayPage = safeMetricPage * VIEWS_PER_METRIC + (valuesView ? 1 : 0);
int displayPage = Math.floorMod(currentDisplayPage + offset,
safeMetricCount * VIEWS_PER_METRIC);
return new DisplayPage(displayPage / VIEWS_PER_METRIC,
displayPage % VIEWS_PER_METRIC == 1);
}
public static int displayPageNumber(int metricPage, boolean valuesView, int metricCount) {
int safeMetricCount = Math.max(1, metricCount);
return Math.clamp(metricPage, 0, safeMetricCount - 1) * VIEWS_PER_METRIC
+ (valuesView ? 2 : 1);
}
public static int displayPageCount(int metricCount) {
return Math.max(1, metricCount) * VIEWS_PER_METRIC;
}
public static List<Identifier> mergeMetricIds(List<Identifier> current, List<Identifier> offered) {
LinkedHashSet<Identifier> merged = new LinkedHashSet<>();
if (current != null) merged.addAll(current);
if (offered != null) merged.addAll(offered);
return List.copyOf(new ArrayList<>(merged).subList(0, Math.min(MAX_PAGES, merged.size())));
}
public static List<Identifier> sanitizeMetricIds(List<Identifier> requested) {
LinkedHashSet<Identifier> unique = new LinkedHashSet<>();
if (requested != null) {
requested.stream().filter(java.util.Objects::nonNull).limit(MAX_PAGES).forEach(unique::add);
}
return List.copyOf(unique);
}
public record DisplayPage(int metricPage, boolean valuesView) {}
}
@@ -0,0 +1,180 @@
package fr.koka99cab.sanctuary26.redstoner.trophy;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import fr.koka99cab.sanctuary26.redstoner.RedStonerMod;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import net.minecraft.core.BlockPos;
import net.minecraft.resources.Identifier;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.saveddata.SavedData;
import net.minecraft.world.level.saveddata.SavedDataType;
/** Versioned, server-owned configurations for the vanilla block entities used by trophy signs. */
public final class TrophySignData extends SavedData {
public static final int DATA_VERSION = 1;
public static final String SAVE_KEY = "trophy_signs";
public static final int MAX_SIGNS = 16_384;
private static final Codec<Config> CONFIG_CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.STRING.fieldOf("dimension").forGetter(Config::dimension),
Codec.LONG.fieldOf("pos").forGetter(Config::pos),
Codec.STRING.fieldOf("owner").forGetter(Config::owner),
Codec.STRING.optionalFieldOf("owner_name", "?").forGetter(Config::ownerName),
Identifier.CODEC.listOf(0, TrophyPanelRules.MAX_PAGES).optionalFieldOf("pages", List.of())
.forGetter(Config::pages),
Codec.INT.optionalFieldOf("current_page", 0).forGetter(Config::currentPage),
Codec.STRING.optionalFieldOf("display", TrophyDisplayMode.NAMES_AND_VALUES.key())
.forGetter(Config::display),
Codec.BOOL.optionalFieldOf("configured", false).forGetter(Config::configured)
).apply(instance, Config::new));
private static final Codec<TrophySignData> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.INT.optionalFieldOf("data_version", DATA_VERSION).forGetter(data -> data.dataVersion),
CONFIG_CODEC.listOf().optionalFieldOf("signs", List.of()).forGetter(TrophySignData::entriesForCodec)
).apply(instance, TrophySignData::new));
private static final SavedDataType<TrophySignData> TYPE = new SavedDataType<>(
RedStonerMod.id(SAVE_KEY), TrophySignData::new, CODEC, null);
private int dataVersion;
private final List<Config> signs;
public TrophySignData() {
this(DATA_VERSION, List.of());
}
private TrophySignData(int dataVersion, List<Config> signs) {
this.dataVersion = dataVersion;
this.signs = new ArrayList<>(signs == null ? List.of()
: signs.stream().limit(MAX_SIGNS).toList());
}
public static TrophySignData get(MinecraftServer server) {
ServerLevel overworld = server.getLevel(Level.OVERWORLD);
if (overworld == null) throw new IllegalStateException("Overworld unavailable for trophy-sign data");
TrophySignData data = overworld.getDataStorage().computeIfAbsent(TYPE);
data.upgradeIfRequired();
return data;
}
public boolean writable() {
return dataVersion <= DATA_VERSION;
}
public List<Config> signs() {
return List.copyOf(signs);
}
public Optional<Config> signAt(ServerLevel level, BlockPos pos) {
return signAt(dimension(level), pos.asLong());
}
public Optional<Config> signAt(String dimension, long pos) {
return signs.stream().filter(sign -> sign.dimension().equals(dimension) && sign.pos() == pos).findFirst();
}
public boolean add(Config config) {
if (!writable() || signs.size() >= MAX_SIGNS
|| signAt(config.dimension(), config.pos()).isPresent()) return false;
signs.add(config);
setDirty();
return true;
}
public boolean replace(Config replacement) {
if (!writable()) return false;
for (int index = 0; index < signs.size(); index++) {
Config current = signs.get(index);
if (!current.dimension().equals(replacement.dimension()) || current.pos() != replacement.pos()) continue;
signs.set(index, replacement);
setDirty();
return true;
}
return false;
}
public boolean remove(Config config) {
if (!writable()) return false;
boolean removed = signs.remove(config);
if (removed) setDirty();
return removed;
}
private List<Config> entriesForCodec() {
return List.copyOf(signs);
}
private void upgradeIfRequired() {
if (dataVersion < DATA_VERSION) {
int previous = dataVersion;
dataVersion = DATA_VERSION;
setDirty();
RedStonerMod.LOGGER.info("[{}] Migrated trophy-sign data from v{} to v{}.",
RedStonerMod.DISPLAY_NAME, previous, dataVersion);
} else if (dataVersion > DATA_VERSION) {
RedStonerMod.LOGGER.warn("[{}] Trophy-sign data v{} is newer than supported v{}.",
RedStonerMod.DISPLAY_NAME, dataVersion, DATA_VERSION);
}
}
public static String dimension(ServerLevel level) {
return level.dimension().identifier().toString();
}
public record Config(String dimension, long pos, String owner, String ownerName,
List<Identifier> pages, int currentPage, String display, boolean configured) {
public Config {
dimension = clean(dimension, 128, "minecraft:overworld");
owner = cleanUuid(owner);
ownerName = clean(ownerName, 32, "?");
pages = TrophyPanelRules.sanitizeMetricIds(pages);
currentPage = pages.isEmpty() ? 0 : Math.clamp(currentPage, 0, pages.size() - 1);
display = TrophyDisplayMode.byKey(display).key();
configured = configured && !pages.isEmpty();
}
public static Config draft(ServerLevel level, BlockPos pos, UUID ownerId, String ownerName) {
return new Config(TrophySignData.dimension(level), pos.asLong(), ownerId.toString(), ownerName,
List.of(), 0, TrophyDisplayMode.NAMES_AND_VALUES.key(), false);
}
public UUID ownerId() {
return UUID.fromString(owner);
}
public TrophyDisplayMode displayMode() {
return TrophyDisplayMode.byKey(display);
}
public Identifier currentMetric() {
return pages.get(Math.clamp(currentPage, 0, pages.size() - 1));
}
public Config withConfiguration(List<Identifier> requestedPages, TrophyDisplayMode mode,
String retainedOwnerName) {
List<Identifier> sanitized = TrophyPanelRules.sanitizeMetricIds(requestedPages);
return new Config(dimension, pos, owner, retainedOwnerName, sanitized, 0, mode.key(), true);
}
public Config withCurrentPage(int page) {
return new Config(dimension, pos, owner, ownerName, pages, page, display, configured);
}
}
private static String clean(String value, int maximum, String fallback) {
String clean = value == null || value.isBlank() ? fallback : value.strip();
return clean.substring(0, Math.min(maximum, clean.length()));
}
private static String cleanUuid(String value) {
try {
return UUID.fromString(value).toString();
} catch (IllegalArgumentException | NullPointerException ignored) {
return new UUID(0L, 0L).toString();
}
}
}
@@ -0,0 +1,30 @@
package fr.koka99cab.sanctuary26.redstoner.trophy;
import net.minecraft.core.BlockPos;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.SignItem;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.entity.SignBlockEntity;
import net.minecraft.world.level.block.state.BlockState;
/** The historical trophy-panel item now places an ordinary standing or wall sign. */
public final class TrophySignItem extends SignItem {
public TrophySignItem(Block standing, Block wall, Properties properties) {
super(standing, wall, properties);
}
@Override
protected boolean updateCustomBlockEntityTag(BlockPos pos, Level level, Player player,
ItemStack stack, BlockState state) {
if (level instanceof ServerLevel serverLevel && player instanceof ServerPlayer serverPlayer
&& level.getBlockEntity(pos) instanceof SignBlockEntity sign) {
TrophySignService.activatePlacedSign(serverPlayer, serverLevel, pos, sign);
}
// The bounded trophy editor replaces the vanilla text editor.
return true;
}
}
@@ -0,0 +1,271 @@
package fr.koka99cab.sanctuary26.redstoner.trophy;
import fr.koka99cab.sanctuary26.redstoner.network.OpenTrophySignEditorPayload;
import fr.koka99cab.sanctuary26.redstoner.network.SaveTrophySignPayload;
import fr.koka99cab.sanctuary26.redstoner.registry.RedStonerRegistries;
import fr.koka99cab.sanctuary26.sanctuary.api.ranking.RankingMetric;
import fr.koka99cab.sanctuary26.sanctuary.api.ranking.RankingSnapshot;
import fr.koka99cab.sanctuary26.sanctuary.api.ranking.RankingUnit;
import fr.koka99cab.sanctuary26.sanctuary.api.ranking.SanctuaryRankingApi;
import java.util.List;
import java.util.UUID;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents;
import net.fabricmc.fabric.api.event.player.PlayerBlockBreakEvents;
import net.fabricmc.fabric.api.event.player.UseBlockCallback;
import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
import net.minecraft.ChatFormatting;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.server.permissions.Permissions;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.SignBlock;
import net.minecraft.world.level.block.WallSignBlock;
import net.minecraft.world.level.block.entity.SignBlockEntity;
import net.minecraft.world.level.block.entity.SignText;
/** Server-authoritative ownership, paging, editing and refresh behavior for white vanilla trophy signs. */
public final class TrophySignService {
private static final int REFRESH_INTERVAL_TICKS = 20;
private static final int MAX_INSPECTIONS_PER_REFRESH = 128;
private static int tickCounter;
private static int refreshCursor;
private static boolean initialized;
private TrophySignService() {}
public static void initialize() {
if (initialized) return;
initialized = true;
UseBlockCallback.EVENT.register((player, level, hand, hit) -> {
BlockPos pos = hit.getBlockPos();
if (hand != InteractionHand.MAIN_HAND
|| !RedStonerRegistries.isTrophySign(level.getBlockState(pos).getBlock())
|| !(level.getBlockEntity(pos) instanceof SignBlockEntity sign)) {
return InteractionResult.PASS;
}
if (level.isClientSide()) return InteractionResult.SUCCESS;
return player instanceof ServerPlayer serverPlayer
? interact(serverPlayer, (ServerLevel) level, pos, sign)
: InteractionResult.PASS;
});
PlayerBlockBreakEvents.BEFORE.register((level, player, pos, state, blockEntity) -> {
if (!(level instanceof ServerLevel serverLevel) || !RedStonerRegistries.isTrophySign(state.getBlock())) {
return true;
}
TrophySignData.Config config = TrophySignData.get(serverLevel.getServer())
.signAt(serverLevel, pos).orElse(null);
if (config == null || config.ownerId().equals(player.getUUID())) return true;
if (player instanceof ServerPlayer serverPlayer && isOperator(serverPlayer)) return true;
if (player instanceof ServerPlayer serverPlayer) message(serverPlayer, "protected");
return false;
});
PlayerBlockBreakEvents.AFTER.register((level, player, pos, state, blockEntity) -> {
if (!(level instanceof ServerLevel serverLevel) || !RedStonerRegistries.isTrophySign(state.getBlock())) {
return;
}
TrophySignData data = TrophySignData.get(serverLevel.getServer());
data.signAt(serverLevel, pos).ifPresent(data::remove);
});
ServerTickEvents.END_SERVER_TICK.register(TrophySignService::tick);
}
/** Creates a blank owned sign and immediately opens its custom editor. */
public static void activatePlacedSign(ServerPlayer player, ServerLevel level, BlockPos pos,
SignBlockEntity sign) {
TrophySignData data = TrophySignData.get(level.getServer());
TrophySignData.Config existing = data.signAt(level, pos).orElse(null);
if (existing == null) {
TrophySignData.Config draft = TrophySignData.Config.draft(level, pos, player.getUUID(),
player.getGameProfile().name());
if (!data.add(draft)) {
message(player, data.writable() ? "registry_full" : "newer_data");
return;
}
existing = draft;
}
writeBlankSign(level, sign);
openEditor(player, existing);
}
private static InteractionResult interact(ServerPlayer player, ServerLevel level, BlockPos pos,
SignBlockEntity sign) {
TrophySignData data = TrophySignData.get(level.getServer());
TrophySignData.Config config = data.signAt(level, pos).orElse(null);
if (config == null) {
activatePlacedSign(player, level, pos, sign);
return InteractionResult.SUCCESS_SERVER;
}
boolean canConfigure = TrophyPanelRules.canConfigure(config.ownerId(), player.getUUID(), isOperator(player));
if (!config.configured() || player.isSecondaryUseActive()) {
if (canConfigure) openEditor(player, config);
else message(player, "not_owner");
return InteractionResult.SUCCESS_SERVER;
}
int page = TrophyPanelRules.nextPage(config.currentPage(), config.pages().size());
TrophySignData.Config advanced = config.withCurrentPage(page);
if (!data.replace(advanced)) {
message(player, "newer_data");
return InteractionResult.CONSUME;
}
writeSign(level, sign, advanced);
message(player, "page", advanced.currentPage() + 1, advanced.pages().size());
return InteractionResult.SUCCESS_SERVER;
}
public static void save(ServerPlayer player, SaveTrophySignPayload payload) {
if (player.distanceToSqr(net.minecraft.world.phys.Vec3.atCenterOf(payload.pos())) > 64.0D) {
message(player, "too_far");
return;
}
ServerLevel level = player.level();
if (!RedStonerRegistries.isTrophySign(level.getBlockState(payload.pos()).getBlock())
|| !(level.getBlockEntity(payload.pos()) instanceof SignBlockEntity sign)) {
message(player, "missing");
return;
}
TrophySignData data = TrophySignData.get(level.getServer());
TrophySignData.Config config = data.signAt(level, payload.pos()).orElse(null);
if (config == null || !TrophyPanelRules.canConfigure(config.ownerId(), player.getUUID(), isOperator(player))) {
message(player, "not_owner");
return;
}
List<Identifier> pages = TrophyPanelRules.sanitizeMetricIds(payload.pages()).stream()
.filter(SanctuaryRankingApi::isConfigurableMetric).toList();
if (pages.isEmpty() || pages.size() != payload.pages().size()) {
message(player, "invalid");
return;
}
TrophySignData.Config configured = config.withConfiguration(pages,
TrophyDisplayMode.byKey(payload.display()), config.ownerName());
if (!data.replace(configured)) {
message(player, "newer_data");
return;
}
writeSign(level, sign, configured);
message(player, "saved");
}
public static boolean migrateLegacy(ServerLevel level, BlockPos pos, Direction facing, UUID owner,
String ownerName, List<Identifier> requestedPages, int currentPage) {
TrophySignData data = TrophySignData.get(level.getServer());
if (!data.writable()) return false;
UUID retainedOwner = owner == null ? new UUID(0L, 0L) : owner;
TrophySignData.Config previous = data.signAt(level, pos).orElse(null);
if (previous == null && data.signs().size() >= TrophySignData.MAX_SIGNS) return false;
List<Identifier> pages = TrophyPanelRules.sanitizeMetricIds(requestedPages).stream()
.filter(SanctuaryRankingApi::isConfigurableMetric).toList();
if (pages.isEmpty()) pages = List.of(SanctuaryRankingApi.defaultMetric().id());
var state = RedStonerRegistries.TROPHY_WALL_SIGN.defaultBlockState()
.setValue(WallSignBlock.FACING, facing)
.setValue(SignBlock.WATERLOGGED, false);
if (!state.canSurvive(level, pos) || !level.setBlock(pos, state, Block.UPDATE_ALL)) return false;
TrophySignData.Config migrated = new TrophySignData.Config(TrophySignData.dimension(level), pos.asLong(),
retainedOwner.toString(), ownerName, pages, currentPage, TrophyDisplayMode.NAMES_AND_VALUES.key(), true);
boolean stored = previous == null ? data.add(migrated) : data.replace(migrated);
if (!stored) return false;
if (level.getBlockEntity(pos) instanceof SignBlockEntity sign) writeSign(level, sign, migrated);
return true;
}
private static void openEditor(ServerPlayer player, TrophySignData.Config config) {
if (!ServerPlayNetworking.canSend(player, OpenTrophySignEditorPayload.TYPE)) {
message(player, "client_required");
return;
}
ServerPlayNetworking.send(player, new OpenTrophySignEditorPayload(
BlockPos.of(config.pos()), config.pages(), config.display()));
}
private static void tick(MinecraftServer server) {
if (++tickCounter < REFRESH_INTERVAL_TICKS) return;
tickCounter = 0;
TrophySignData data = TrophySignData.get(server);
List<TrophySignData.Config> signs = data.signs();
if (signs.isEmpty()) {
refreshCursor = 0;
return;
}
int inspections = Math.min(MAX_INSPECTIONS_PER_REFRESH, signs.size());
for (int offset = 0; offset < inspections; offset++) {
TrophySignData.Config config = signs.get(Math.floorMod(refreshCursor + offset, signs.size()));
ServerLevel level = level(server, config.dimension());
BlockPos pos = BlockPos.of(config.pos());
if (level == null || !level.isLoaded(pos)) continue;
if (!RedStonerRegistries.isTrophySign(level.getBlockState(pos).getBlock())
|| !(level.getBlockEntity(pos) instanceof SignBlockEntity sign)) {
data.remove(config);
continue;
}
if (config.configured()) writeSign(level, sign, config);
else writeBlankSign(level, sign);
}
refreshCursor = Math.floorMod(refreshCursor + inspections, Math.max(1, signs.size()));
}
private static void writeBlankSign(ServerLevel level, SignBlockEntity sign) {
writeText(level, sign, new SignText());
}
private static void writeSign(ServerLevel level, SignBlockEntity sign, TrophySignData.Config config) {
Identifier metricId = config.currentMetric();
RankingSnapshot snapshot = SanctuaryRankingApi.snapshot(level.getServer(), metricId, config.ownerId());
RankingMetric metric = snapshot.metric();
RankingUnit unit = metric.unit();
SignText text = new SignText().setMessage(0,
Component.translatable("block.redstoner.trophy_sign.page_title",
config.currentPage() + 1, config.pages().size(), SanctuaryRankingApi.displayName(metric))
.withStyle(ChatFormatting.DARK_GRAY, ChatFormatting.BOLD));
if (snapshot.podium().isEmpty()) {
text = text.setMessage(1, Component.translatable("block.redstoner.trophy_sign.no_score")
.withStyle(ChatFormatting.GRAY));
} else {
for (int index = 0; index < TrophyPanelRules.SIGN_ENTRIES_PER_PAGE
&& index < snapshot.podium().size(); index++) {
RankingSnapshot.Entry entry = snapshot.podium().get(index);
text = text.setMessage(index + 1, rankingLine(config.displayMode(), entry, unit));
}
}
writeText(level, sign, text);
}
private static Component rankingLine(TrophyDisplayMode mode, RankingSnapshot.Entry entry, RankingUnit unit) {
return switch (mode) {
case NAMES_AND_VALUES -> Component.translatable("block.redstoner.trophy_sign.names_and_values",
entry.rank(), entry.playerName(), unit.format(entry.value()));
case NAMES -> Component.translatable("block.redstoner.trophy_sign.names",
entry.rank(), entry.playerName());
case VALUES -> Component.translatable("block.redstoner.trophy_sign.values",
entry.rank(), unit.format(entry.value()));
};
}
private static void writeText(ServerLevel level, SignBlockEntity sign, SignText text) {
sign.setText(text, true);
sign.setText(text, false);
sign.setWaxed(true);
sign.setChanged();
level.sendBlockUpdated(sign.getBlockPos(), sign.getBlockState(), sign.getBlockState(), 3);
}
private static ServerLevel level(MinecraftServer server, String dimension) {
for (ServerLevel level : server.getAllLevels()) {
if (TrophySignData.dimension(level).equals(dimension)) return level;
}
return null;
}
private static boolean isOperator(ServerPlayer player) {
return player.createCommandSourceStack().permissions().hasPermission(Permissions.COMMANDS_GAMEMASTER);
}
private static void message(ServerPlayer player, String key, Object... args) {
player.sendSystemMessage(Component.translatable("message.redstoner.trophy_sign." + key, args));
}
}
@@ -0,0 +1,20 @@
{
"variants": {
"rotation=0": { "model": "redstoner:block/trophy_sign_rot_0" },
"rotation=1": { "model": "redstoner:block/trophy_sign_rot_1" },
"rotation=2": { "model": "redstoner:block/trophy_sign_rot_2" },
"rotation=3": { "model": "redstoner:block/trophy_sign_rot_3" },
"rotation=4": { "model": "redstoner:block/trophy_sign_rot_0", "y": 90 },
"rotation=5": { "model": "redstoner:block/trophy_sign_rot_1", "y": 90 },
"rotation=6": { "model": "redstoner:block/trophy_sign_rot_2", "y": 90 },
"rotation=7": { "model": "redstoner:block/trophy_sign_rot_3", "y": 90 },
"rotation=8": { "model": "redstoner:block/trophy_sign_rot_0", "y": 180 },
"rotation=9": { "model": "redstoner:block/trophy_sign_rot_1", "y": 180 },
"rotation=10": { "model": "redstoner:block/trophy_sign_rot_2", "y": 180 },
"rotation=11": { "model": "redstoner:block/trophy_sign_rot_3", "y": 180 },
"rotation=12": { "model": "redstoner:block/trophy_sign_rot_0", "y": 270 },
"rotation=13": { "model": "redstoner:block/trophy_sign_rot_1", "y": 270 },
"rotation=14": { "model": "redstoner:block/trophy_sign_rot_2", "y": 270 },
"rotation=15": { "model": "redstoner:block/trophy_sign_rot_3", "y": 270 }
}
}
@@ -0,0 +1,8 @@
{
"variants": {
"facing=east": { "model": "redstoner:block/trophy_wall_sign", "y": 270 },
"facing=north": { "model": "redstoner:block/trophy_wall_sign", "y": 180 },
"facing=south": { "model": "redstoner:block/trophy_wall_sign" },
"facing=west": { "model": "redstoner:block/trophy_wall_sign", "y": 90 }
}
}
@@ -1,6 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "redstoner:block/trophy_panel"
"model": "redstoner:item/trophy_sign"
}
}
@@ -64,8 +64,36 @@
"screen.redstoner.storage_terminal.error.too_large": "Network too large (maximum 128 storage blocks).",
"screen.redstoner.storage_terminal.error.locked": "The network contains a locked storage block.",
"block.redstoner.trophy_panel": "Trophy Panel",
"block.redstoner.trophy_sign": "Trophy Sign",
"block.redstoner.trophy_wall_sign": "Wall Trophy Sign",
"block.redstoner.trophy_sign.page_title": "[%s/%s] %s",
"block.redstoner.trophy_sign.names_and_values": "#%s %s · %s",
"block.redstoner.trophy_sign.names": "#%s %s",
"block.redstoner.trophy_sign.values": "#%s %s",
"block.redstoner.trophy_sign.no_score": "No score yet",
"screen.redstoner.trophy_sign.title": "Configure Trophy Sign",
"screen.redstoner.trophy_sign.search": "Search for a statistic…",
"screen.redstoner.trophy_sign.available": "Available statistics",
"screen.redstoner.trophy_sign.pages": "Pages %s/%s",
"screen.redstoner.trophy_sign.remove": "Remove",
"screen.redstoner.trophy_sign.display": "Display: %s",
"screen.redstoner.trophy_sign.display.names_and_values": "names and values",
"screen.redstoner.trophy_sign.display.names": "names only",
"screen.redstoner.trophy_sign.display.values": "values only",
"message.redstoner.trophy_sign.not_owner": "Only the creator or an operator can configure this sign.",
"message.redstoner.trophy_sign.protected": "Only the creator or an operator can break this sign.",
"message.redstoner.trophy_sign.saved": "Trophy sign configured.",
"message.redstoner.trophy_sign.page": "Page %s/%s",
"message.redstoner.trophy_sign.invalid": "The configuration contains an invalid statistic.",
"message.redstoner.trophy_sign.missing": "This trophy sign no longer exists.",
"message.redstoner.trophy_sign.too_far": "You are too far away from this sign.",
"message.redstoner.trophy_sign.client_required": "An up-to-date Red-Stoner client is required to configure this sign.",
"message.redstoner.trophy_sign.registry_full": "This world has reached its trophy-sign limit.",
"message.redstoner.trophy_sign.newer_data": "This sign data comes from a newer version and remains read-only.",
"block.redstoner.trophy_panel.page_title": "[%s/%s] %s",
"block.redstoner.trophy_panel.podium": "#%s %s · %s",
"block.redstoner.trophy_panel.player": "#%s %s",
"block.redstoner.trophy_panel.value": "#%s %s",
"block.redstoner.trophy_panel.no_score": "No score yet",
"block.redstoner.trophy_panel.champion": "#1 %s · %s",
"block.redstoner.trophy_panel.owner_rank": "%s · #%s · %s",
@@ -64,8 +64,36 @@
"screen.redstoner.storage_terminal.error.too_large": "Réseau trop grand (128 stockages maximum).",
"screen.redstoner.storage_terminal.error.locked": "Le réseau contient un stockage verrouillé.",
"block.redstoner.trophy_panel": "Panneau de trophées",
"block.redstoner.trophy_sign": "Panneau de trophées",
"block.redstoner.trophy_wall_sign": "Panneau mural de trophées",
"block.redstoner.trophy_sign.page_title": "[%s/%s] %s",
"block.redstoner.trophy_sign.names_and_values": "#%s %s · %s",
"block.redstoner.trophy_sign.names": "#%s %s",
"block.redstoner.trophy_sign.values": "#%s %s",
"block.redstoner.trophy_sign.no_score": "Aucun score",
"screen.redstoner.trophy_sign.title": "Configurer le panneau de trophées",
"screen.redstoner.trophy_sign.search": "Rechercher une statistique…",
"screen.redstoner.trophy_sign.available": "Statistiques disponibles",
"screen.redstoner.trophy_sign.pages": "Pages %s/%s",
"screen.redstoner.trophy_sign.remove": "Retirer",
"screen.redstoner.trophy_sign.display": "Affichage : %s",
"screen.redstoner.trophy_sign.display.names_and_values": "noms et valeurs",
"screen.redstoner.trophy_sign.display.names": "noms seulement",
"screen.redstoner.trophy_sign.display.values": "valeurs seulement",
"message.redstoner.trophy_sign.not_owner": "Seul le créateur ou un opérateur peut configurer ce panneau.",
"message.redstoner.trophy_sign.protected": "Seul le créateur ou un opérateur peut casser ce panneau.",
"message.redstoner.trophy_sign.saved": "Panneau de trophées configuré.",
"message.redstoner.trophy_sign.page": "Page %s/%s",
"message.redstoner.trophy_sign.invalid": "La configuration contient une statistique invalide.",
"message.redstoner.trophy_sign.missing": "Ce panneau de trophées nexiste plus.",
"message.redstoner.trophy_sign.too_far": "Tu es trop loin de ce panneau.",
"message.redstoner.trophy_sign.client_required": "Un client Red-Stoner à jour est requis pour configurer ce panneau.",
"message.redstoner.trophy_sign.registry_full": "La limite de panneaux de trophées du monde est atteinte.",
"message.redstoner.trophy_sign.newer_data": "Ces données de panneau proviennent dune version plus récente et restent en lecture seule.",
"block.redstoner.trophy_panel.page_title": "[%s/%s] %s",
"block.redstoner.trophy_panel.podium": "#%s %s · %s",
"block.redstoner.trophy_panel.player": "#%s %s",
"block.redstoner.trophy_panel.value": "#%s %s",
"block.redstoner.trophy_panel.no_score": "Aucun score",
"block.redstoner.trophy_panel.champion": "#1 %s · %s",
"block.redstoner.trophy_panel.owner_rank": "%s · #%s · %s",
@@ -64,8 +64,36 @@
"screen.redstoner.storage_terminal.error.too_large": "Сеть слишком велика (не более 128 хранилищ).",
"screen.redstoner.storage_terminal.error.locked": "В сети есть запертое хранилище.",
"block.redstoner.trophy_panel": "Панель трофеев",
"block.redstoner.trophy_sign": "Табличка трофеев",
"block.redstoner.trophy_wall_sign": "Настенная табличка трофеев",
"block.redstoner.trophy_sign.page_title": "[%s/%s] %s",
"block.redstoner.trophy_sign.names_and_values": "#%s %s · %s",
"block.redstoner.trophy_sign.names": "#%s %s",
"block.redstoner.trophy_sign.values": "#%s %s",
"block.redstoner.trophy_sign.no_score": "Результатов пока нет",
"screen.redstoner.trophy_sign.title": "Настройка таблички трофеев",
"screen.redstoner.trophy_sign.search": "Найти статистику…",
"screen.redstoner.trophy_sign.available": "Доступная статистика",
"screen.redstoner.trophy_sign.pages": "Страницы %s/%s",
"screen.redstoner.trophy_sign.remove": "Удалить",
"screen.redstoner.trophy_sign.display": "Отображение: %s",
"screen.redstoner.trophy_sign.display.names_and_values": "имена и значения",
"screen.redstoner.trophy_sign.display.names": "только имена",
"screen.redstoner.trophy_sign.display.values": "только значения",
"message.redstoner.trophy_sign.not_owner": "Настраивать эту табличку может только создатель или оператор.",
"message.redstoner.trophy_sign.protected": "Сломать эту табличку может только создатель или оператор.",
"message.redstoner.trophy_sign.saved": "Табличка трофеев настроена.",
"message.redstoner.trophy_sign.page": "Страница %s/%s",
"message.redstoner.trophy_sign.invalid": "Настройка содержит недопустимую статистику.",
"message.redstoner.trophy_sign.missing": "Этой таблички трофеев больше нет.",
"message.redstoner.trophy_sign.too_far": "Вы слишком далеко от этой таблички.",
"message.redstoner.trophy_sign.client_required": "Для настройки нужен обновлённый клиент Red-Stoner.",
"message.redstoner.trophy_sign.registry_full": "Достигнут предел табличек трофеев для этого мира.",
"message.redstoner.trophy_sign.newer_data": "Данные таблички созданы более новой версией и доступны только для чтения.",
"block.redstoner.trophy_panel.page_title": "[%s/%s] %s",
"block.redstoner.trophy_panel.podium": "#%s %s · %s",
"block.redstoner.trophy_panel.player": "#%s %s",
"block.redstoner.trophy_panel.value": "#%s %s",
"block.redstoner.trophy_panel.no_score": "Результатов пока нет",
"block.redstoner.trophy_panel.champion": "#1 %s · %s",
"block.redstoner.trophy_panel.owner_rank": "%s · #%s · %s",
@@ -0,0 +1,7 @@
{
"parent": "minecraft:block/template_sign_rot_0",
"textures": {
"all": "minecraft:block/pale_oak_sign",
"particle": "minecraft:block/pale_oak_planks"
}
}
@@ -0,0 +1,7 @@
{
"parent": "minecraft:block/template_sign_rot_1",
"textures": {
"all": "minecraft:block/pale_oak_sign",
"particle": "minecraft:block/pale_oak_planks"
}
}
@@ -0,0 +1,7 @@
{
"parent": "minecraft:block/template_sign_rot_2",
"textures": {
"all": "minecraft:block/pale_oak_sign",
"particle": "minecraft:block/pale_oak_planks"
}
}
@@ -0,0 +1,7 @@
{
"parent": "minecraft:block/template_sign_rot_3",
"textures": {
"all": "minecraft:block/pale_oak_sign",
"particle": "minecraft:block/pale_oak_planks"
}
}
@@ -0,0 +1,7 @@
{
"parent": "minecraft:block/template_wall_sign",
"textures": {
"all": "minecraft:block/pale_oak_sign",
"particle": "minecraft:block/pale_oak_planks"
}
}
@@ -0,0 +1,6 @@
{
"parent": "minecraft:item/generated",
"textures": {
"layer0": "minecraft:item/pale_oak_sign"
}
}
@@ -0,0 +1,4 @@
{
"replace": false,
"values": ["redstoner:trophy_sign", "redstoner:trophy_wall_sign"]
}
@@ -0,0 +1,4 @@
{
"replace": false,
"values": ["redstoner:trophy_sign"]
}
@@ -0,0 +1,4 @@
{
"replace": false,
"values": ["redstoner:trophy_wall_sign"]
}
@@ -0,0 +1,4 @@
{
"replace": false,
"values": ["redstoner:trophy_panel"]
}
@@ -0,0 +1,15 @@
{
"type": "minecraft:block",
"pools": [
{
"conditions": [
{ "condition": "minecraft:survives_explosion" }
],
"entries": [
{ "type": "minecraft:item", "name": "redstoner:trophy_panel" }
],
"rolls": 1
}
],
"random_sequence": "redstoner:blocks/trophy_sign"
}
@@ -0,0 +1,15 @@
{
"type": "minecraft:block",
"pools": [
{
"conditions": [
{ "condition": "minecraft:survives_explosion" }
],
"entries": [
{ "type": "minecraft:item", "name": "redstoner:trophy_panel" }
],
"rolls": 1
}
],
"random_sequence": "redstoner:blocks/trophy_wall_sign"
}
@@ -1,17 +1,10 @@
{
"type": "minecraft:crafting_shaped",
"category": "redstone",
"pattern": [
"NGN",
"GRG",
"NSN"
"type": "minecraft:crafting_shapeless",
"category": "building",
"ingredients": [
"#minecraft:signs",
"minecraft:white_dye"
],
"key": {
"N": "minecraft:gold_nugget",
"G": "minecraft:black_stained_glass_pane",
"R": "minecraft:redstone_lamp",
"S": "minecraft:oak_sign"
},
"result": {
"id": "redstoner:trophy_panel",
"count": 1
+3 -3
View File
@@ -24,14 +24,14 @@
"java": ">=25",
"fabric-api": ">=0.154.2+26.2",
"anotherworld": "*",
"sanctuary": "*"
"sanctuary": ">=0.0.0-alpha.139"
},
"custom": {
"sanctuary26": {
"system_type": "gameplay-system",
"namespace": "redstoner",
"data_version": 4,
"network_protocol": 7,
"data_version": 5,
"network_protocol": 9,
"lifecycle": "${lifecycle}",
"pack_version": "${pack_version}",
"descriptions": {
@@ -27,6 +27,7 @@ public final class StorageTerminalSmoke {
verifyPagination();
verifyAggregationAndTransfers();
verifySearch();
verifySearchKeyboardRouting();
verifySorting();
verifyStableLayout();
}
@@ -145,6 +146,15 @@ public final class StorageTerminalSmoke {
"Terminal search retained an unrelated item");
}
private static void verifySearchKeyboardRouting() {
require(StorageTerminalKeyRules.capturesInventoryKey(true, true),
"The inventory key escaped the focused terminal search field");
require(!StorageTerminalKeyRules.capturesInventoryKey(false, true),
"The inventory key no longer closes an unfocused terminal");
require(!StorageTerminalKeyRules.capturesInventoryKey(true, false),
"The terminal captured an unrelated search-field key");
}
private static Holder<Item> stackable(Item item) {
return new Holder.Direct<>(item, DataComponentMap.builder()
.set(DataComponents.MAX_STACK_SIZE, 64).build());
@@ -19,6 +19,24 @@ public final class TrophyPanelRulesSmoke {
"Reverse public page cycling does not wrap");
require(TrophyPanelRules.pageByOffset(4, 0, 1) == 0,
"An empty panel produced an invalid page index");
TrophyPanelRules.DisplayPage values = TrophyPanelRules.displayPageByOffset(0, false, 2, 1);
TrophyPanelRules.DisplayPage secondMetric = TrophyPanelRules.displayPageByOffset(
values.metricPage(), values.valuesView(), 2, 1);
TrophyPanelRules.DisplayPage wrapped = TrophyPanelRules.displayPageByOffset(0, false, 2, -1);
require(values.metricPage() == 0 && values.valuesView()
&& secondMetric.metricPage() == 1 && !secondMetric.valuesView()
&& wrapped.metricPage() == 1 && wrapped.valuesView(),
"Name and value views do not cycle as consecutive public pages");
require(TrophyPanelRules.LINE_COUNT == 8 && TrophyPanelRules.ENTRIES_PER_VIEW == 7
&& TrophyPanelRules.displayPageNumber(1, true, 2) == 4
&& TrophyPanelRules.displayPageCount(2) == 4,
"The panel does not use eight lines or number both metric views correctly");
require(TrophyPanelRules.SIGN_LINE_COUNT == 4 && TrophyPanelRules.SIGN_ENTRIES_PER_PAGE == 3,
"The vanilla trophy sign must reserve one title line and three ranking lines");
require(TrophyDisplayMode.byKey("names") == TrophyDisplayMode.NAMES
&& TrophyDisplayMode.NAMES.next() == TrophyDisplayMode.VALUES
&& TrophyDisplayMode.byKey("invalid") == TrophyDisplayMode.NAMES_AND_VALUES,
"Persisted trophy-sign display modes are not stable or safely defaulted");
Identifier first = Identifier.parse("sanctuary:first");
Identifier second = Identifier.parse("sanctuary:second");
@@ -29,6 +47,9 @@ public final class TrophyPanelRulesSmoke {
require(merged.get(0).equals(first) && merged.get(1).equals(second),
"Existing page order was not preserved");
require(merged.stream().distinct().count() == merged.size(), "Duplicate metric pages were retained");
List<Identifier> sanitized = TrophyPanelRules.sanitizeMetricIds(List.of(first, second, first));
require(sanitized.equals(List.of(first, second)),
"The trophy editor did not preserve page order while removing duplicates");
}
private static void require(boolean condition, String message) {
+30 -16
View File
@@ -124,6 +124,14 @@ tasks.register("capeSyncPayloadSmoke", JavaExec) {
mainClass = "fr.koka99cab.sanctuary26.sanctuary.network.CapeSyncPayloadSmoke"
}
tasks.register("capeModelSmoke", JavaExec) {
group = "verification"
description = "Checks the double-sided shield-pattern plane used by banner capes."
dependsOn tasks.named("testClasses")
classpath = sourceSets.test.runtimeClasspath
mainClass = "fr.koka99cab.sanctuary26.sanctuary.client.SanctuaryBannerCapeModelSmoke"
}
tasks.register("chickenArmorSmoke", JavaExec) {
group = "verification"
description = "Checks that only adult unarmored chickens accept armadillo-scute armor."
@@ -207,7 +215,7 @@ tasks.register("verifyBlackMarket") {
def questData = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/QuestBoardData.java").text
def quests = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/QuestBoardService.java").text
def inventoryView = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/api/inventory/SanctuaryPlayerInventoryView.java").text
if (project.version.toString() != "0.0.0-alpha.137"
if (project.version.toString() != "0.0.0-alpha.139"
|| !data.contains('SAVE_KEY = "black_market"') || !data.contains("DATA_VERSION = 2")
|| !data.contains("ItemStack.CODEC.fieldOf(\"item\")") || !data.contains("replaceListing")
|| !service.contains("LISTING_FEE_RUBIES = 1") || !service.contains("COMMISSION_PERCENT = 5")
@@ -2923,7 +2931,7 @@ tasks.register("verifyShopCurrencyRowsRelease") {
"src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/BlackMarketService.java").text
def shop = file(
"src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/ShopService.java").text
if (project.version.toString() != "0.0.0-alpha.137"
if (project.version.toString() != "0.0.0-alpha.139"
|| migration.target?.modules?.sanctuary != "0.0.0-alpha.117"
|| migration.shop_currency_inventory?.all_unlocked_rows_counted != true
|| migration.shop_currency_inventory?.overflow_rows_counted != true
@@ -3089,7 +3097,7 @@ tasks.register("verifyProgressivePauseMenu") {
"pack/migrations/26.2.0-alpha.190-progressive-pause-menu-to-alpha.191.json"))
def pauseMenu = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/mixin/client/PauseScreenMixin.java").text
def costs = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/progression/SanctuaryProgressionCosts.java").text
if (project.version.toString() != "0.0.0-alpha.137"
if (project.version.toString() != "0.0.0-alpha.139"
|| migration.target?.pack_version != "26.2.0-alpha.191"
|| migration.target?.modules?.sanctuary != "0.0.0-alpha.109"
|| migration.pause_menu?.progression_full_width != true
@@ -3127,7 +3135,7 @@ tasks.register("verifyShopPricingLayout") {
"pack/migrations/26.2.0-alpha.192-shop-pricing-layout-to-alpha.193.json"))
def screen = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/client/SanctuaryShopScreen.java").text
def progression = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/ShopProgression.java").text
if (project.version.toString() != "0.0.0-alpha.137"
if (project.version.toString() != "0.0.0-alpha.139"
|| migration.target?.modules?.sanctuary != "0.0.0-alpha.110"
|| migration.shop_layout?.locked_offer_buttons != 6
|| migration.shop_layout?.button_slots != [4, 5, 6, 7, 8, 9]
@@ -3158,7 +3166,7 @@ tasks.register("verifyShopCsvBossGates") {
"pack/migrations/26.2.0-alpha.194-shop-csv-boss-gates-to-alpha.195.json"))
def provider = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/ShopCatalogProvider.java").text
def catalog = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/ShopOfferCatalog.java").text
if (project.version.toString() != "0.0.0-alpha.137"
if (project.version.toString() != "0.0.0-alpha.139"
|| migration.target?.pack_version != "26.2.0-alpha.195"
|| migration.target?.modules?.sanctuary != "0.0.0-alpha.111"
|| migration.compatibility?.sanctuary_data_version != 11
@@ -3189,7 +3197,7 @@ tasks.register("verifyDeliveryBoxOrientation") {
def blockstate = new JsonSlurper().parse(
file("src/main/resources/assets/sanctuary/blockstates/delivery_box.json"))
def expectedVariants = ["facing=north", "facing=east", "facing=south", "facing=west"].toSet()
if (project.version.toString() != "0.0.0-alpha.137"
if (project.version.toString() != "0.0.0-alpha.139"
|| migration.target?.modules?.sanctuary != "0.0.0-alpha.112"
|| migration.mailbox?.id != "sanctuary:delivery_box"
|| migration.mailbox?.horizontal_facing != true
@@ -3217,7 +3225,7 @@ tasks.register("verifyInventoryShiftClick") {
def mixin = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/mixin/AbstractContainerMenuMixin.java").text
def screen = file("src/main/java/fr/koka99cab/sanctuary26/sanctuary/mixin/client/AbstractContainerScreenMixin.java").text
def gui = file("INVENTORY_GUI.md").text
if (project.version.toString() != "0.0.0-alpha.137"
if (project.version.toString() != "0.0.0-alpha.139"
|| !transfer.contains("shouldAppendOverflow")
|| !transfer.contains("isVanillaStorageRange")
|| !transfer.contains("hotbarStart")
@@ -3263,7 +3271,7 @@ tasks.register("verifyQuestRequests") {
new JsonSlurper().parse(file("src/main/resources/data/sanctuary/loot_table/blocks/${path}.json"))
.pools[0].entries[0].name
}
if (project.version.toString() != "0.0.0-alpha.137"
if (project.version.toString() != "0.0.0-alpha.139"
|| migration.target?.modules?.sanctuary != "0.0.0-alpha.113"
|| migration.quest_requests?.save_key != "quest_requests"
|| migration.quest_requests?.data_version != 1
@@ -3325,7 +3333,7 @@ tasks.register("verifyQuestRequestContracts") {
def locales = ["fr_fr", "en_us", "ru_ru"].collectEntries { locale ->
[(locale): new JsonSlurper().parse(file("src/main/resources/assets/sanctuary/lang/${locale}.json"))]
}
if (project.version.toString() != "0.0.0-alpha.137"
if (project.version.toString() != "0.0.0-alpha.139"
|| migration.source?.pack_version != "26.2.0-alpha.198"
|| migration.source?.modules?.sanctuary != "0.0.0-alpha.114"
|| migration.target?.pack_version != "26.2.0-alpha.199"
@@ -3394,7 +3402,7 @@ tasks.register("verifyQuestRequestEscrow") {
def locales = ["fr_fr", "en_us", "ru_ru"].collectEntries { locale ->
[(locale): new JsonSlurper().parse(file("src/main/resources/assets/sanctuary/lang/${locale}.json"))]
}
if (project.version.toString() != "0.0.0-alpha.137"
if (project.version.toString() != "0.0.0-alpha.139"
|| migration.source?.pack_version != "26.2.0-alpha.200"
|| migration.source?.modules?.sanctuary != "0.0.0-alpha.115"
|| migration.target?.pack_version != "26.2.0-alpha.201"
@@ -3473,7 +3481,7 @@ tasks.register("verifyCraftableBannerCape") {
def locales = ["fr_fr", "en_us", "ru_ru"].collect {
new JsonSlurper().parse(file("src/main/resources/assets/sanctuary/lang/${it}.json"))
}
if (project.version.toString() != "0.0.0-alpha.137"
if (project.version.toString() != "0.0.0-alpha.139"
|| migration.target?.modules?.sanctuary != "0.0.0-alpha.130"
|| migration.white_cape?.crafting_material_count != 6
|| migration.white_cape?.maximum_pattern_layers != 6
@@ -3525,9 +3533,14 @@ tasks.register("verifyFinalSanctuaryTickets") {
def locales = ["fr_fr", "en_us", "ru_ru"].collect {
new JsonSlurper().parse(file("src/main/resources/assets/sanctuary/lang/${it}.json"))
}
if (project.version.toString() != "0.0.0-alpha.137"
|| !capeRenderer.contains("SHIELD_PATTERN_X_SCALE = 1.2F")
|| !capeRenderer.contains("SHIELD_PATTERN_Y_SCALE = 1.375F")
if (project.version.toString() != "0.0.0-alpha.139"
|| !capeRenderer.contains("SHIELD_FACE_WIDTH = 12.0F")
|| !capeRenderer.contains("SHIELD_FACE_HEIGHT = 22.0F")
|| !capeRenderer.contains("CAPE_WIDTH / SHIELD_FACE_WIDTH")
|| !capeRenderer.contains("CAPE_HEIGHT / SHIELD_FACE_HEIGHT")
|| !capeRenderer.contains('addOrReplaceChild("front"')
|| !capeRenderer.contains('addOrReplaceChild("back"')
|| !capeRenderer.contains("Set.of(Direction.NORTH)")
|| !capeRenderer.contains("model, state, false, appearance.baseColor()")
|| !armorLayer.contains("EquipmentClientInfo.LayerType.WOLF_BODY")
|| !armorRules.contains("held.is(Items.WOLF_ARMOR)")
@@ -3574,7 +3587,7 @@ tasks.register("verifyAlphaIndev") {
def locales = ["fr_fr", "en_us", "ru_ru"].collect {
new JsonSlurper().parse(file("src/main/resources/assets/sanctuary/lang/${it}.json"))
}
if (project.version.toString() != "0.0.0-alpha.137"
if (project.version.toString() != "0.0.0-alpha.139"
|| migration.target?.modules?.sanctuary != "0.0.0-alpha.122"
|| migration.alpha_world?.dimension != "sanctuary:alpha"
|| migration.alpha_world?.legacy_dimension != "portals:alpha"
@@ -3634,7 +3647,7 @@ tasks.register("verifyBackroom") {
def locales = ["fr_fr", "en_us", "ru_ru"].collect {
new JsonSlurper().parse(file("src/main/resources/assets/sanctuary/lang/${it}.json"))
}
if (project.version.toString() != "0.0.0-alpha.137"
if (project.version.toString() != "0.0.0-alpha.139"
|| migration.source_data_version != 13 || migration.target_data_version != 14
|| migration.integration_modules?.anotherworld?.source != "0.0.0-alpha.56"
|| migration.integration_modules?.anotherworld?.target != "0.0.0-alpha.57"
@@ -3696,6 +3709,7 @@ tasks.named("check") {
dependsOn tasks.named("alphaIndevGeneratorSmoke")
dependsOn tasks.named("capeJoinSyncSmoke")
dependsOn tasks.named("capeSyncPayloadSmoke")
dependsOn tasks.named("capeModelSmoke")
dependsOn tasks.named("chickenArmorSmoke")
dependsOn tasks.named("inventorySortSmoke")
dependsOn tasks.named("entityPolicySmoke")
@@ -4,9 +4,9 @@ import java.util.List;
import java.util.UUID;
import net.minecraft.resources.Identifier;
/** Server snapshot intentionally bounded to one visible metric and three podium entries. */
/** Server snapshot intentionally bounded to one visible metric and seven panel rows. */
public record RankingSnapshot(RankingMetric metric, List<Entry> podium, Placement owner) {
public static final int MAX_PODIUM_SIZE = 3;
public static final int MAX_PODIUM_SIZE = 7;
public RankingSnapshot {
podium = podium == null ? List.of() : List.copyOf(podium.stream().limit(MAX_PODIUM_SIZE).toList());
@@ -8,6 +8,7 @@ import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents;
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
@@ -29,8 +30,11 @@ public final class SanctuaryRankingApi {
if (initialized) return;
initialized = true;
register(DEFAULT_METRIC);
ServerLifecycleEvents.SERVER_STARTED.register(server ->
SanctuaryRankingData.get(server).importVanillaStats(server));
ServerLifecycleEvents.SERVER_STARTED.register(server -> {
SanctuaryRankingData data = SanctuaryRankingData.get(server);
data.importVanillaStats(server);
data.resolveCachedNames(server);
});
ServerPlayConnectionEvents.JOIN.register((handler, sender, server) -> {
SanctuaryRankingData data = SanctuaryRankingData.get(server);
data.updateName(handler.player.getUUID(), handler.player.getGameProfile().name());
@@ -66,6 +70,29 @@ public final class SanctuaryRankingApi {
return metrics;
}
/**
* Complete client-safe catalog used by configuration screens. The catalog is rebuilt from
* synchronized vanilla registries, so it never has to be transferred in one large payload.
*/
public static List<RankingMetric> configurableMetrics() {
return VanillaRankingMetric.configurableMetrics(METRICS.values());
}
/** True when a submitted identifier resolves to a registered or existing vanilla statistic. */
public static boolean isConfigurableMetric(Identifier id) {
return id != null && (METRICS.containsKey(id) || VanillaRankingMetric.isAvailable(id));
}
/** Localized label for a metric configured from its representative item. */
public static Component displayName(RankingMetric metric, ItemStack icon) {
return VanillaRankingMetric.displayName(metric, icon);
}
/** Localized label resolved directly from the metric target, without requiring a representative item. */
public static Component displayName(RankingMetric metric) {
return VanillaRankingMetric.displayName(metric, ItemStack.EMPTY);
}
public static boolean eligible(ServerPlayer player) {
return isRealOnlinePlayer(player) && !player.isCreative() && !player.isSpectator();
}
@@ -200,7 +200,7 @@ final class SanctuaryRankingData extends SavedData {
if (Files.isDirectory(statsDirectory)) {
try (Stream<Path> files = Files.list(statsDirectory)) {
files.filter(path -> path.getFileName().toString().endsWith(".json"))
.sorted().forEach(this::importVanillaFile);
.sorted().forEach(path -> importVanillaFile(server, path));
} catch (IOException exception) {
SanctuaryMod.LOGGER.warn("[{}] Could not import existing vanilla statistics.",
SanctuaryMod.DISPLAY_NAME, exception);
@@ -212,6 +212,10 @@ final class SanctuaryRankingData extends SavedData {
}
void importVanillaFile(Path file) {
importVanillaFile(null, file);
}
private void importVanillaFile(MinecraftServer server, Path file) {
String fileName = file.getFileName().toString();
String idText = fileName.substring(0, fileName.length() - ".json".length());
UUID playerId;
@@ -221,6 +225,7 @@ final class SanctuaryRankingData extends SavedData {
return;
}
try (Reader reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) {
String resolvedName = cachedName(server, playerId);
JsonObject root = JsonParser.parseReader(reader).getAsJsonObject();
JsonObject stats = root.has("stats") && root.get("stats").isJsonObject()
? root.getAsJsonObject("stats") : new JsonObject();
@@ -233,8 +238,8 @@ final class SanctuaryRankingData extends SavedData {
|| !valueEntry.getValue().getAsJsonPrimitive().isNumber()) continue;
Identifier metricId = VanillaRankingMetric.id(type, target);
long value = Math.max(0L, valueEntry.getValue().getAsLong());
recordValue(playerId, shortFallbackName(playerId), metricId, value);
observeVanilla(playerId, shortFallbackName(playerId), metricId, value, false);
recordValue(playerId, resolvedName, metricId, value);
observeVanilla(playerId, resolvedName, metricId, value, false);
}
}
} catch (RuntimeException | IOException exception) {
@@ -243,6 +248,21 @@ final class SanctuaryRankingData extends SavedData {
}
}
/** Repairs the old eight-character UUID placeholders from the server's local profile cache. */
void resolveCachedNames(MinecraftServer server) {
if (!writable() || server == null) return;
boolean changed = false;
for (Map.Entry<String, Profile> entry : new ArrayList<>(players.entrySet())) {
UUID playerId = UUID.fromString(entry.getKey());
Profile profile = entry.getValue();
if (!isUuidFallback(profile.lastName(), playerId)) continue;
String resolved = cachedName(server, playerId);
players.put(entry.getKey(), new Profile(resolved, profile.metrics(), profile.vanillaObserved()));
changed = true;
}
if (changed) setDirty();
}
private long takeReachedOrder() {
long current = nextReachedOrder;
if (nextReachedOrder < Long.MAX_VALUE) nextReachedOrder++;
@@ -269,8 +289,17 @@ final class SanctuaryRankingData extends SavedData {
return clean.length() > 32 ? clean.substring(0, 32) : clean;
}
private static String shortFallbackName(UUID playerId) {
return playerId.toString().substring(0, 8);
private static String cachedName(MinecraftServer server, UUID playerId) {
if (server == null) return "?";
return server.services().nameToIdCache().get(playerId)
.map(profile -> cleanName(profile.name())).orElse("?");
}
private static boolean isUuidFallback(String name, UUID playerId) {
if (name == null || name.isBlank() || name.equals("?")) return true;
String uuid = playerId.toString();
return (name.length() == 8 || name.length() == 32)
&& uuid.regionMatches(true, 0, name, 0, name.length());
}
record Score(long value, long reachedOrder) {
@@ -2,21 +2,37 @@ package fr.koka99cab.sanctuary26.sanctuary.api.ranking;
import fr.koka99cab.sanctuary26.sanctuary.SanctuaryMod;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.stats.Stats;
import net.minecraft.world.item.BlockItem;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.SpawnEggItem;
/** Stable encoding and server reader for persisted vanilla statistic identifiers. */
final class VanillaRankingMetric {
private static final String PREFIX = "vanilla/";
private static final List<String> ITEM_FAMILIES = List.of(
"mined", "crafted", "used", "broken", "picked_up", "dropped");
private static final List<String> ENTITY_FAMILIES = List.of("killed", "killed_by");
private static final List<Identifier> TIME_STATISTICS = List.of(
Stats.PLAY_TIME, Stats.TOTAL_WORLD_TIME, Stats.TIME_SINCE_DEATH,
Stats.TIME_SINCE_REST, Stats.CROUCH_TIME);
private static final List<Identifier> DISTANCE_STATISTICS = List.of(
Stats.WALK_ONE_CM, Stats.CROUCH_ONE_CM, Stats.SPRINT_ONE_CM,
Stats.WALK_ON_WATER_ONE_CM, Stats.FALL_ONE_CM, Stats.CLIMB_ONE_CM,
Stats.FLY_ONE_CM, Stats.WALK_UNDER_WATER_ONE_CM, Stats.MINECART_ONE_CM,
Stats.BOAT_ONE_CM, Stats.PIG_ONE_CM, Stats.HAPPY_GHAST_ONE_CM,
Stats.HORSE_ONE_CM, Stats.AVIATE_ONE_CM, Stats.SWIM_ONE_CM,
Stats.STRIDER_ONE_CM, Stats.NAUTILUS_ONE_CM);
private VanillaRankingMetric() {}
@@ -34,15 +50,56 @@ final class VanillaRankingMetric {
if (source == null) {
return new RankingMetric(id, "metric.sanctuary.ranking.unknown", RankingUnit.COUNT);
}
boolean playTime = source.type().getPath().equals("custom") && source.target().equals(Stats.PLAY_TIME);
RankingUnit unit = playTime ? RankingUnit.TICKS : RankingUnit.COUNT;
boolean custom = source.type().getPath().equals("custom");
boolean playTime = custom && source.target().equals(Stats.PLAY_TIME);
RankingUnit unit = custom && TIME_STATISTICS.contains(source.target()) ? RankingUnit.TICKS
: custom && DISTANCE_STATISTICS.contains(source.target()) ? RankingUnit.CENTIMETERS
: RankingUnit.COUNT;
return new RankingMetric(id, "metric.sanctuary.ranking."
+ (playTime ? "play_time" : source.type().getPath()), unit);
}
static List<RankingMetric> configurableMetrics(Collection<RankingMetric> registered) {
Map<Identifier, RankingMetric> metrics = new LinkedHashMap<>();
if (registered != null) registered.forEach(metric -> metrics.put(metric.id(), metric));
for (Identifier target : BuiltInRegistries.CUSTOM_STAT) {
RankingMetric metric = descriptor(id(Identifier.withDefaultNamespace("custom"), target));
metrics.putIfAbsent(metric.id(), metric);
}
for (BlockItem blockItem : BuiltInRegistries.ITEM.stream()
.filter(BlockItem.class::isInstance).map(BlockItem.class::cast).toList()) {
Identifier target = BuiltInRegistries.BLOCK.getKey(blockItem.getBlock());
RankingMetric metric = descriptor(id(Identifier.withDefaultNamespace("mined"), target));
metrics.putIfAbsent(metric.id(), metric);
}
for (Item item : BuiltInRegistries.ITEM) {
Identifier target = BuiltInRegistries.ITEM.getKey(item);
for (String family : ITEM_FAMILIES) {
if (family.equals("mined")) continue;
RankingMetric metric = descriptor(id(Identifier.withDefaultNamespace(family), target));
metrics.putIfAbsent(metric.id(), metric);
}
}
for (var entityType : BuiltInRegistries.ENTITY_TYPE) {
Identifier target = BuiltInRegistries.ENTITY_TYPE.getKey(entityType);
for (String family : ENTITY_FAMILIES) {
RankingMetric metric = descriptor(id(Identifier.withDefaultNamespace(family), target));
metrics.putIfAbsent(metric.id(), metric);
}
}
return List.copyOf(metrics.values());
}
static List<RankingMetric> itemMetrics(ItemStack icon) {
if (icon == null || icon.isEmpty()) return List.of();
Item item = icon.getItem();
if (item instanceof SpawnEggItem) {
var entityType = SpawnEggItem.getType(icon);
if (entityType == null) return List.of();
Identifier entityId = BuiltInRegistries.ENTITY_TYPE.getKey(entityType);
return List.of(new RankingMetric(id(Identifier.withDefaultNamespace("killed"), entityId),
"metric.sanctuary.ranking.killed", RankingUnit.COUNT));
}
Identifier itemId = BuiltInRegistries.ITEM.getKey(item);
List<RankingMetric> metrics = new ArrayList<>();
for (String family : ITEM_FAMILIES) {
@@ -53,6 +110,39 @@ final class VanillaRankingMetric {
return List.copyOf(metrics);
}
static Component displayName(RankingMetric metric, ItemStack icon) {
Source source = parse(metric.id()).orElse(null);
if (source == null) return Component.translatable(metric.translationKey());
String family = source.type().getPath();
if (family.equals("custom")) {
return source.target().equals(Stats.PLAY_TIME)
? Component.translatable(metric.translationKey())
: Component.translatable(source.target().toLanguageKey("stat"));
}
Component target = switch (family) {
case "mined" -> BuiltInRegistries.BLOCK.getOptional(source.target())
.<Component>map(block -> block.getName()).orElseGet(() -> iconName(icon));
case "killed", "killed_by" -> BuiltInRegistries.ENTITY_TYPE.getOptional(source.target())
.<Component>map(type -> type.getDescription()).orElseGet(() -> iconName(icon));
default -> BuiltInRegistries.ITEM.getOptional(source.target())
.<Component>map(item -> new ItemStack(item).getHoverName()).orElseGet(() -> iconName(icon));
};
return Component.translatable(metric.translationKey(), target);
}
static boolean isAvailable(Identifier metricId) {
Source source = parse(metricId).orElse(null);
if (source == null) return false;
return switch (source.type().getPath()) {
case "custom" -> BuiltInRegistries.CUSTOM_STAT.getOptional(source.target()).isPresent();
case "mined" -> BuiltInRegistries.BLOCK.getOptional(source.target()).isPresent();
case "crafted", "used", "broken", "picked_up", "dropped" ->
BuiltInRegistries.ITEM.getOptional(source.target()).isPresent();
case "killed", "killed_by" -> BuiltInRegistries.ENTITY_TYPE.getOptional(source.target()).isPresent();
default -> false;
};
}
static Optional<Source> parse(Identifier metricId) {
if (!metricId.getNamespace().equals(SanctuaryMod.MOD_ID)
|| !metricId.getPath().startsWith(PREFIX)) return Optional.empty();
@@ -79,10 +169,18 @@ final class VanillaRankingMetric {
.map(item -> (long) player.getStats().getValue(Stats.ITEM_PICKED_UP, item)).orElse(0L);
case "dropped" -> BuiltInRegistries.ITEM.getOptional(target)
.map(item -> (long) player.getStats().getValue(Stats.ITEM_DROPPED, item)).orElse(0L);
case "killed" -> BuiltInRegistries.ENTITY_TYPE.getOptional(target)
.map(type -> (long) player.getStats().getValue(Stats.ENTITY_KILLED, type)).orElse(0L);
case "killed_by" -> BuiltInRegistries.ENTITY_TYPE.getOptional(target)
.map(type -> (long) player.getStats().getValue(Stats.ENTITY_KILLED_BY, type)).orElse(0L);
case "custom" -> (long) player.getStats().getValue(Stats.CUSTOM, target);
default -> 0L;
};
}
private static Component iconName(ItemStack icon) {
return icon == null || icon.isEmpty() ? Component.literal("?") : icon.getHoverName();
}
record Source(Identifier type, Identifier target) {}
}
@@ -2,6 +2,7 @@ package fr.koka99cab.sanctuary26.sanctuary.client;
import com.mojang.blaze3d.vertex.PoseStack;
import fr.koka99cab.sanctuary26.sanctuary.SanctuaryMod;
import java.util.Set;
import net.fabricmc.fabric.api.client.rendering.v1.LivingEntityRenderLayerRegistrationCallback;
import net.fabricmc.fabric.api.client.rendering.v1.ModelLayerRegistry;
import net.minecraft.client.model.geom.ModelLayerLocation;
@@ -23,6 +24,7 @@ import net.minecraft.client.renderer.entity.state.AvatarRenderState;
import net.minecraft.client.renderer.texture.OverlayTexture;
import net.minecraft.client.resources.model.EquipmentAssetManager;
import net.minecraft.client.resources.model.EquipmentClientInfo;
import net.minecraft.core.Direction;
import net.minecraft.core.component.DataComponents;
import net.minecraft.world.entity.EntityTypes;
import net.minecraft.world.item.ItemStack;
@@ -30,8 +32,10 @@ import net.minecraft.world.item.equipment.Equippable;
/** Renders a banner-decorated cape with vanilla cape physics and banner pattern sprites. */
public final class SanctuaryBannerCapeLayer extends RenderLayer<AvatarRenderState, PlayerModel> {
private static final float SHIELD_PATTERN_X_SCALE = 1.2F;
private static final float SHIELD_PATTERN_Y_SCALE = 1.375F;
private static final float SHIELD_FACE_WIDTH = 12.0F;
private static final float SHIELD_FACE_HEIGHT = 22.0F;
private static final float CAPE_WIDTH = 10.0F;
private static final float CAPE_HEIGHT = 16.0F;
private static final ModelLayerLocation MODEL_LAYER =
new ModelLayerLocation(SanctuaryMod.id("banner_cape"), "main");
private final PlayerCapeModel model;
@@ -63,17 +67,27 @@ public final class SanctuaryBannerCapeLayer extends RenderLayer<AvatarRenderStat
helper.register(new SanctuaryBannerCapeLayer(parent, context));
}
private static LayerDefinition createLayer() {
static LayerDefinition createLayer() {
MeshDefinition mesh = PlayerModel.createMesh(CubeDeformation.NONE, false);
PartDefinition root = mesh.getRoot().clearRecursively();
PartDefinition body = root.getChild("body");
body.addOrReplaceChild("cape", CubeListBuilder.create().texOffs(0, 0)
.addBox(-5.0F, 0.0F, -1.0F, 10.0F, 16.0F, 1.0F,
CubeDeformation.NONE, SHIELD_PATTERN_X_SCALE, SHIELD_PATTERN_Y_SCALE),
PartPose.offsetAndRotation(0.0F, 0.0F, 2.0F, 0.0F, (float) Math.PI, 0.0F));
PartDefinition cape = body.addOrReplaceChild("cape", CubeListBuilder.create(),
PartPose.offsetAndRotation(0.0F, 0.0F, 2.0F, 0.0F, (float) Math.PI, 0.0F)
.scaled(CAPE_WIDTH / SHIELD_FACE_WIDTH, CAPE_HEIGHT / SHIELD_FACE_HEIGHT, 1.0F));
cape.addOrReplaceChild("front", shieldPatternFace(), PartPose.offset(0.0F, 0.0F, -1.0F));
cape.addOrReplaceChild("back", shieldPatternFace(),
PartPose.offsetAndRotation(0.0F, 0.0F, -1.0F, 0.0F, (float) Math.PI, 0.0F));
return LayerDefinition.create(mesh, 64, 64);
}
private static CubeListBuilder shieldPatternFace() {
// Shield patterns paint this exact 12x22 UV island. Two opposed planes keep
// the cloth visible from both sides without rendering the shield thickness or handle.
return CubeListBuilder.create().texOffs(1, 1)
.addBox(-6.0F, 0.0F, 0.0F, SHIELD_FACE_WIDTH, SHIELD_FACE_HEIGHT, 0.0F,
Set.of(Direction.NORTH));
}
@Override
public void submit(PoseStack poseStack, SubmitNodeCollector collector, int packedLight,
AvatarRenderState state, float yRot, float xRot) {
@@ -538,6 +538,8 @@
"metric.sanctuary.ranking.broken": "%s broken",
"metric.sanctuary.ranking.picked_up": "%s picked up",
"metric.sanctuary.ranking.dropped": "%s dropped",
"metric.sanctuary.ranking.killed": "Killed: %s",
"metric.sanctuary.ranking.killed_by": "Killed by: %s",
"metric.sanctuary.ranking.custom": "Custom statistic",
"metric.sanctuary.ranking.unknown": "Unknown statistic"
}
@@ -538,6 +538,8 @@
"metric.sanctuary.ranking.broken": "%s cassé",
"metric.sanctuary.ranking.picked_up": "%s ramassé",
"metric.sanctuary.ranking.dropped": "%s jeté",
"metric.sanctuary.ranking.killed": "Tués : %s",
"metric.sanctuary.ranking.killed_by": "Tué par : %s",
"metric.sanctuary.ranking.custom": "Statistique personnalisée",
"metric.sanctuary.ranking.unknown": "Statistique inconnue"
}
@@ -494,6 +494,8 @@
"metric.sanctuary.ranking.broken": "Сломано: %s",
"metric.sanctuary.ranking.picked_up": "Подобрано: %s",
"metric.sanctuary.ranking.dropped": "Выброшено: %s",
"metric.sanctuary.ranking.killed": "Убито: %s",
"metric.sanctuary.ranking.killed_by": "Убит существом: %s",
"metric.sanctuary.ranking.custom": "Пользовательская статистика",
"metric.sanctuary.ranking.unknown": "Неизвестная статистика"
}
@@ -3,12 +3,22 @@ package fr.koka99cab.sanctuary26.sanctuary.api.ranking;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import net.minecraft.SharedConstants;
import net.minecraft.core.Holder;
import net.minecraft.core.component.DataComponentMap;
import net.minecraft.core.component.DataComponents;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.resources.Identifier;
import net.minecraft.server.Bootstrap;
import net.minecraft.stats.Stats;
import net.minecraft.world.entity.EntityTypes;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.item.component.TypedEntityData;
/** Pure ranking smoke for ordering, tied ranks, saturation and newer-save protection. */
public final class SanctuaryRankingModelSmoke {
@@ -36,6 +46,12 @@ public final class SanctuaryRankingModelSmoke {
&& tied.podium().get(2).rank() == 3, "Equal values must retain the same competition rank");
require(tied.owner().rank() == 1 && tied.owner().value() == 10L,
"Panel owner placement must reflect the real tied rank");
for (int index = 4; index <= 9; index++) {
UUID player = UUID.fromString("00000000-0000-0000-0000-" + String.format("%012d", index));
data.recordValue(player, "Player" + index, metricId, 10L - index);
}
require(data.snapshot(metric, bob).podium().size() == RankingSnapshot.MAX_PODIUM_SIZE,
"The eight-line panel must expose exactly seven bounded ranking rows");
data.increment(chris, "Chris", metricId, Long.MAX_VALUE);
require(data.score(chris, metricId) == SanctuaryRankingData.MAX_VALUE,
@@ -57,6 +73,16 @@ public final class SanctuaryRankingModelSmoke {
require(VanillaRankingMetric.parse(vanillaId).orElseThrow().target().equals(Stats.PLAY_TIME)
&& VanillaRankingMetric.descriptor(vanillaId).unit() == RankingUnit.TICKS,
"Vanilla metric IDs and units must round-trip stably");
List<RankingMetric> zombieMetrics = VanillaRankingMetric.itemMetrics(
new ItemStack(new Holder.Direct<Item>(Items.ZOMBIE_SPAWN_EGG, DataComponentMap.builder()
.set(DataComponents.MAX_STACK_SIZE, 64)
.set(DataComponents.ENTITY_DATA, TypedEntityData.of(EntityTypes.ZOMBIE, new CompoundTag()))
.build())));
VanillaRankingMetric.Source zombieSource = VanillaRankingMetric.parse(
zombieMetrics.getFirst().id()).orElseThrow();
require(zombieMetrics.size() == 1 && zombieSource.type().getPath().equals("killed")
&& zombieSource.target().equals(Identifier.withDefaultNamespace("zombie")),
"A zombie spawn egg must configure the zombies-killed ranking");
Path temporary = Files.createTempDirectory("sanctuary-ranking-migration-");
Path oldStats = temporary.resolve(alex + ".json");
@@ -67,6 +93,9 @@ public final class SanctuaryRankingModelSmoke {
imported.importVanillaFile(oldStats);
require(imported.score(alex, usedStick) == 12L,
"Existing vanilla statistics were not imported monotonically exactly once");
require(imported.snapshot(VanillaRankingMetric.descriptor(usedStick), alex).podium().getFirst()
.playerName().equals("?"),
"An import without a cached profile must use '?' rather than exposing a shortened UUID");
Files.deleteIfExists(oldStats);
Files.deleteIfExists(temporary);
@@ -0,0 +1,80 @@
package fr.koka99cab.sanctuary26.sanctuary.client;
import com.mojang.blaze3d.vertex.PoseStack;
import java.util.ArrayList;
import java.util.List;
import net.minecraft.client.model.geom.ModelPart;
import org.joml.Vector3f;
import org.joml.Vector3fc;
/** Verifies the shield-pattern UV island, cape proportions and double-sided plane. */
public final class SanctuaryBannerCapeModelSmoke {
private static final float EPSILON = 0.0001F;
private SanctuaryBannerCapeModelSmoke() {}
public static void main(String[] args) {
ModelPart cape = SanctuaryBannerCapeLayer.createLayer().bakeRoot()
.getChild("body").getChild("cape");
List<Float> normalZ = new ArrayList<>();
List<ModelPart.Vertex> vertices = new ArrayList<>();
cape.visit(new PoseStack(), (pose, path, cubeIndex, cube) -> {
for (ModelPart.Polygon polygon : cube.polygons) {
normalZ.add(pose.transformNormal(polygon.normal(), new Vector3f()).z());
vertices.addAll(List.of(polygon.vertices()));
}
});
require(normalZ.size() == 2 && normalZ.stream().anyMatch(value -> value < -0.99F)
&& normalZ.stream().anyMatch(value -> value > 0.99F),
"The banner cape must contain one visible plane for each culling direction");
require(close(minU(vertices), 1.0F / 64.0F) && close(maxU(vertices), 13.0F / 64.0F)
&& close(minV(vertices), 1.0F / 64.0F) && close(maxV(vertices), 23.0F / 64.0F),
"The cape no longer samples the shield plate's exact pattern UV island");
List<Vector3fc> extents = new ArrayList<>();
cape.getExtentsForGui(new PoseStack(), extents::add);
require(close(minX(extents), -5.0F / 16.0F) && close(maxX(extents), 5.0F / 16.0F)
&& close(minY(extents), 0.0F) && close(maxY(extents), 1.0F),
"The shield-pattern plane does not retain the vanilla 10x16 cape proportions");
}
private static float minU(List<ModelPart.Vertex> vertices) {
return (float) vertices.stream().mapToDouble(ModelPart.Vertex::u).min().orElseThrow();
}
private static float maxU(List<ModelPart.Vertex> vertices) {
return (float) vertices.stream().mapToDouble(ModelPart.Vertex::u).max().orElseThrow();
}
private static float minV(List<ModelPart.Vertex> vertices) {
return (float) vertices.stream().mapToDouble(ModelPart.Vertex::v).min().orElseThrow();
}
private static float maxV(List<ModelPart.Vertex> vertices) {
return (float) vertices.stream().mapToDouble(ModelPart.Vertex::v).max().orElseThrow();
}
private static float minX(List<Vector3fc> vertices) {
return (float) vertices.stream().mapToDouble(Vector3fc::x).min().orElseThrow();
}
private static float maxX(List<Vector3fc> vertices) {
return (float) vertices.stream().mapToDouble(Vector3fc::x).max().orElseThrow();
}
private static float minY(List<Vector3fc> vertices) {
return (float) vertices.stream().mapToDouble(Vector3fc::y).min().orElseThrow();
}
private static float maxY(List<Vector3fc> vertices) {
return (float) vertices.stream().mapToDouble(Vector3fc::y).max().orElseThrow();
}
private static boolean close(float actual, float expected) {
return Math.abs(actual - expected) <= EPSILON;
}
private static void require(boolean condition, String message) {
if (!condition) throw new AssertionError(message);
}
}
+2 -1
View File
@@ -21,5 +21,6 @@ include(
"onlyfun",
"sanctuary",
"events",
"masterkey"
"masterkey",
"zevent"
)
+19
View File
@@ -0,0 +1,19 @@
# 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
@@ -0,0 +1,19 @@
<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>

After

Width:  |  Height:  |  Size: 1.0 KiB

+14
View File
@@ -0,0 +1,14 @@
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()
}
@@ -0,0 +1,30 @@
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);
}
}
@@ -0,0 +1,206 @@
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);
}
}
}
@@ -0,0 +1,54 @@
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);
}
}
}
@@ -0,0 +1,75 @@
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);
}
}
@@ -0,0 +1,29 @@
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);
}
}
@@ -0,0 +1,117 @@
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;
}
}
@@ -0,0 +1,95 @@
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());
}
}
@@ -0,0 +1,10 @@
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;
}
@@ -0,0 +1,34 @@
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.
}
}
@@ -0,0 +1,206 @@
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());
}
}
}
}
@@ -0,0 +1,108 @@
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);
}
}
@@ -0,0 +1,37 @@
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;
}
}
@@ -0,0 +1,36 @@
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;
}
}
@@ -0,0 +1,57 @@
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"));
}
}
@@ -0,0 +1,69 @@
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);
}
}
@@ -0,0 +1,58 @@
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();
}
}
@@ -0,0 +1,8 @@
{
"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.

After

Width:  |  Height:  |  Size: 21 KiB

@@ -0,0 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "zevent:block/zevent_screen"
}
}
@@ -0,0 +1,17 @@
{
"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 (125 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…"
}
@@ -0,0 +1,17 @@
{
"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 (125 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…"
}
@@ -0,0 +1,17 @@
{
"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…"
}
@@ -0,0 +1,21 @@
{
"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" }
}
}
]
}
@@ -0,0 +1,20 @@
{
"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"
}
@@ -0,0 +1,19 @@
{
"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
@@ -0,0 +1,44 @@
{
"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."
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More