release: Sanctuary 26.2.0-alpha.236 — retrait de ZEVENT #162

Merged
koka merged 1 commits from release/26.2.0-alpha.236 into main 2026-09-04 08:44:20 +00:00
39 changed files with 307 additions and 1679 deletions
+10
View File
@@ -10,6 +10,16 @@ Ce journal suit les **versions du modpack**. Lorsqu'un seul module change, sa ve
Aucun changement non publié.
## `26.2.0-alpha.236`
Module temporaire supprimé : **ZEVENT `0.0.0-alpha.3`**.
- retire entièrement le sous-projet, le JAR client/serveur et les identifiants `zevent:*` devenus redondants ;
- conserve Dream Displays `1.9.6`, qui fournit directement les écrans et la lecture des flux vidéo ;
- retire automatiquement `zevent-0.0.0-alpha.3.jar` des installations gérées par Packwiz ;
- ne convertit pas les anciens écrans muraux ZEVENT : ils doivent être remplacés par des écrans Dream Displays ;
- conserve sans changement les sauvegardes, inventaires, monnaies et protocoles des dix modules Sanctuary restants.
## `26.2.0-alpha.235`
Module modifié temporairement : **ZEVENT `0.0.0-alpha.3`**.
+5 -4
View File
@@ -1,6 +1,6 @@
# Sanctuary 26.2
Workspace Fabric multi-projet de Sanctuary 2.0. Il construit temporairement onze JAR pour le week-end ZEVENT, sur Minecraft 26.2, Fabric Loader 0.19.3, Fabric API 0.159.0+26.2, Loom 1.17, Gradle 9.5.1 et Java 25.
Workspace Fabric multi-projet de Sanctuary 2.0. Il construit dix JAR, sur Minecraft 26.2, Fabric Loader 0.19.3, Fabric API 0.159.0+26.2, Loom 1.17, Gradle 9.5.1 et Java 25.
Auteur : **KOKA99CAB**
Licence : **GNU GPL 3.0 or Later** (`GPL-3.0-or-later`)
@@ -19,11 +19,12 @@ 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, inclus pour le week-end |
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.235`**. Cette release conserve notamment Another World **`0.0.0-alpha.58`**, Sanctuary **`0.0.0-alpha.139`** et Red-Stoner **`0.0.0-alpha.20`**, passe temporairement ZEVENT en **`0.0.0-alpha.3`** et remplace MCEF Modern par Dream Displays **`1.9.6`** afin de décoder réellement les directs Twitch en H.264/AAC.
Version courante du pack distribué : **`26.2.0-alpha.236`**. Cette release conserve les dix modules Sanctuary, notamment Another World **`0.0.0-alpha.58`**, Sanctuary **`0.0.0-alpha.139`** et Red-Stoner **`0.0.0-alpha.20`**, retire entièrement le module temporaire ZEVENT et garde Dream Displays **`1.9.6`** comme solution d’écran vidéo.
Lalpha.236 supprime le JAR et le code ZEVENT devenus redondants. Packwiz retire automatiquement `zevent-0.0.0-alpha.3.jar` du client et du serveur, tandis que Dream Displays reste installé des deux côtés. Les écrans `zevent:zevent_screen` et leurs chaînes enregistrées ne sont pas convertibles : ils doivent être remplacés par des écrans Dream Displays. Les sauvegardes et données des dix autres modules restent inchangées.
Lalpha.235 corrige l’écran noir de ZEVENT. MCEF chargeait correctement linterface et les liens Twitch, mais son Chromium ne prenait pas en charge les codecs des directs. L’écran utilise désormais le résolveur HLS et le décodeur natif de Dream Displays, conserve ses options et sa synchronisation serveur, réessaie automatiquement un flux indisponible et indique clairement lorsquune chaîne est hors ligne. Le volume suit toujours la catégorie Disques, décroît jusqu’à 64 blocs et seul l’écran visible le plus proche reste audible.
@@ -423,7 +424,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`. Pour lalpha.235, `zevent` reste temporairement dans les archives du pack et utilise Dream Displays pour la lecture native des flux Twitch.
`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`. Depuis lalpha.236, Dream Displays fournit directement les écrans vidéo et le module temporaire `zevent` nest plus construit ni distribué.
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.
+242 -249
View File
File diff suppressed because it is too large Load Diff
+1 -3
View File
@@ -30,7 +30,5 @@ onlyfun_version=0.0.0-alpha.27
onlyfun_lifecycle=active
sanctuary_version=0.0.0-alpha.139
sanctuary_lifecycle=active
zevent_version=0.0.0-alpha.3
zevent_lifecycle=active
pack_version=26.2.0-alpha.235
pack_version=26.2.0-alpha.236
maven_group=fr.koka99cab.sanctuary26
@@ -0,0 +1,43 @@
{
"schema_version": 1,
"status": "released",
"released_in": "26.2.0-alpha.236",
"pack": {
"source_version": "26.2.0-alpha.235",
"target_version": "26.2.0-alpha.236"
},
"removed_module": {
"id": "zevent",
"source_module_version": "0.0.0-alpha.3",
"target_module_version": null,
"source_directory_removed": true,
"client_jar_removed": true,
"server_jar_removed": true,
"registry_ids_removed": [
"zevent:zevent_screen"
]
},
"replacement": {
"module": "dreamdisplays",
"version": "1.9.6",
"modrinth_project_id": "4IIKQyBu",
"modrinth_version_id": "GxyW2ksW",
"client_and_server_required": true,
"automatic_screen_conversion": false
},
"compatibility": {
"world_reset_required": false,
"world_backup_recommended": true,
"existing_zevent_screens_must_be_replaced": true,
"remaining_module_data_preserved": true,
"remaining_network_protocols_changed": false,
"client_and_server_must_remove_zevent": true
},
"distribution": {
"packwiz_index_refreshed": true,
"prism_modrinth_index_updated": true,
"zevent_jar_absent": true,
"dream_displays_required": true,
"distributed_module_count": 10
}
}
-4
View File
@@ -81,7 +81,3 @@ metafile = true
file = "mods/sodium.pw.toml"
hash = "f06c0b9150da9877b99cb265d037c067e509340d9bf347e02cef2043c110e963"
metafile = true
[[files]]
file = "mods/zevent-0.0.0-alpha.3.jar"
hash = "98f1af94549e95fabf99141130c2a56b650165bb405cae57af488c2811b907be"
+2 -2
View File
@@ -1,12 +1,12 @@
name = "Sanctuary 26.2"
author = "KOKA99CAB"
version = "26.2.0-alpha.235"
version = "26.2.0-alpha.236"
pack-format = "packwiz:1.1.0"
[index]
file = "index.toml"
hash-format = "sha256"
hash = "4b92999f44991f7d4cbcbf221949ec2fd50602156da16d0655a6e89a554dfda2"
hash = "f9229ef42ae4f731d8642ec2e8f674d5593ad5b3e8c5deb87837fd490cc353e9"
[versions]
fabric = "0.19.3"
+1 -1
View File
@@ -3,7 +3,7 @@
"game": "minecraft",
"name": "Sanctuary 26.2",
"summary": "Sanctuary 26.2 — progression, exploration, worldgen and multiplayer systems by KOKA99CAB.",
"versionId": "26.2.0-alpha.235",
"versionId": "26.2.0-alpha.236",
"files": [
{
"path": "mods/fabric-api-0.159.0+26.2.jar",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"schema_version": 1,
"pack_version": "26.2.0-alpha.235",
"pack_version": "26.2.0-alpha.236",
"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.235.mrpack",
"primary_file": "sanctuary-26.2.0-alpha.236.mrpack",
"shared_client_server_pack": true,
"update_owner": "packwiz-installer",
"check_on_launch": true,
+1 -2
View File
@@ -21,6 +21,5 @@ include(
"onlyfun",
"sanctuary",
"events",
"masterkey",
"zevent"
"masterkey"
)
-19
View File
@@ -1,19 +0,0 @@
# ZEVENT
Mod Fabric événementiel distribué séparément du pack Sanctuary. Il ajoute un écran mural multibloc de 9×5 blocs qui lit une chaîne Twitch en direct.
## Utilisation
1. Poser l'écran contre un mur libre de 9×5 blocs. Le bloc visé devient le centre de l'écran.
2. Faire un clic droit sur n'importe laquelle de ses 45 cases.
3. Saisir un nom de chaîne (`zeventfr`) ou coller une URL Twitch, puis valider.
La chaîne est enregistrée par le serveur et synchronisée aux joueurs. Le client résout le flux Twitch puis le décode nativement 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 [Dream Displays](https://modrinth.com/mod/dream-displays) `1.9.6` pour la résolution HLS Twitch, le décodage H.264/AAC et l'envoi des images vers le GPU. Une connexion Internet est nécessaire. Cette version est épinglée : l'adaptateur ZEVENT utilise `DisplayScreen` pour posséder un lecteur local sans l'enregistrer comme écran Dream Displays, puis réutilise uniquement son `RenderType` dans le renderer du multibloc ZEVENT. Toute mise à jour de Dream Displays doit donc être validée avec `:zevent:check` et un essai de flux Twitch réel.
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`, `_`). Le client ne construit ensuite que l'URL canonique `https://www.twitch.tv/<chaine>` remise au résolveur Twitch de Dream Displays.
Sources du lecteur : [Dream Displays](https://github.com/arnodoelinger/dreamdisplays).
-19
View File
@@ -1,19 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<defs>
<linearGradient id="screen" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#18221d"/>
<stop offset="1" stop-color="#060807"/>
</linearGradient>
<linearGradient id="live" x1="0" y1="1" x2="1" y2="0">
<stop offset="0" stop-color="#1bdc7a"/>
<stop offset="1" stop-color="#b7ff39"/>
</linearGradient>
</defs>
<rect width="1024" height="1024" rx="176" fill="#090d0b"/>
<rect x="94" y="168" width="836" height="520" rx="54" fill="url(#screen)" stroke="url(#live)" stroke-width="28"/>
<path d="M364 300h330L458 556h252" fill="none" stroke="#f5fff8" stroke-width="74" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M292 807h440" stroke="#314139" stroke-width="42" stroke-linecap="round"/>
<path d="M512 688v119" stroke="#314139" stroke-width="42"/>
<circle cx="829" cy="258" r="42" fill="#ff405f"/>
<circle cx="829" cy="258" r="77" fill="none" stroke="#ff405f" stroke-width="14" opacity=".32"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

-13
View File
@@ -1,13 +0,0 @@
repositories {
mavenCentral()
}
dependencies {
implementation "maven.modrinth:dreamdisplays:GxyW2ksW"
testImplementation "org.junit.jupiter:junit-jupiter:5.11.4"
testRuntimeOnly "org.junit.platform:junit-platform-launcher:1.11.4"
}
tasks.named("test") {
useJUnitPlatform()
}
@@ -1,30 +0,0 @@
package fr.koka99cab.sanctuary26.zevent;
import fr.koka99cab.sanctuary26.zevent.network.ZeventNetworking;
import fr.koka99cab.sanctuary26.zevent.registry.ZeventRegistries;
import net.fabricmc.api.ModInitializer;
import net.minecraft.resources.Identifier;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public final class ZeventMod implements ModInitializer {
public static final String MOD_ID = "zevent";
public static final String DISPLAY_NAME = "ZEVENT";
public static final int CURRENT_DATA_VERSION = 1;
public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID);
public ZeventMod() {
}
public static Identifier id(String path) {
return Identifier.fromNamespaceAndPath(MOD_ID, path);
}
@Override
public void onInitialize() {
ZeventRegistries.initialize();
ZeventNetworking.initialize();
LOGGER.info("[{}] 9x5 Twitch screen ready (data v{}, network v{}).",
DISPLAY_NAME, CURRENT_DATA_VERSION, ZeventNetworking.PROTOCOL_VERSION);
}
}
@@ -1,206 +0,0 @@
package fr.koka99cab.sanctuary26.zevent.block;
import com.mojang.serialization.MapCodec;
import fr.koka99cab.sanctuary26.zevent.block.entity.ZeventScreenBlockEntity;
import fr.koka99cab.sanctuary26.zevent.network.ZeventNetworking;
import java.util.Map;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.util.RandomSource;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.context.BlockPlaceContext;
import net.minecraft.world.level.BlockGetter;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.LevelReader;
import net.minecraft.world.level.ScheduledTickAccess;
import net.minecraft.world.level.block.BaseEntityBlock;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.Mirror;
import net.minecraft.world.level.block.RenderShape;
import net.minecraft.world.level.block.Rotation;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockBehaviour;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.StateDefinition;
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
import net.minecraft.world.level.block.state.properties.EnumProperty;
import net.minecraft.world.level.block.state.properties.IntegerProperty;
import net.minecraft.world.phys.BlockHitResult;
import net.minecraft.world.phys.shapes.CollisionContext;
import net.minecraft.world.phys.shapes.VoxelShape;
/** One block type represents all 45 cells; only its central cell owns playback state. */
public final class ZeventScreenBlock extends BaseEntityBlock {
public static final MapCodec<ZeventScreenBlock> CODEC = simpleCodec(ZeventScreenBlock::new);
public static final EnumProperty<Direction> FACING = BlockStateProperties.HORIZONTAL_FACING;
public static final IntegerProperty COLUMN = IntegerProperty.create("column", 0, ZeventScreenLayout.WIDTH - 1);
public static final IntegerProperty ROW = IntegerProperty.create("row", 0, ZeventScreenLayout.HEIGHT - 1);
private static final ThreadLocal<Boolean> REMOVING_STRUCTURE = ThreadLocal.withInitial(() -> false);
private static final Map<Direction, VoxelShape> SHAPES = Map.of(
Direction.NORTH, Block.box(0.0D, 0.0D, 14.0D, 16.0D, 16.0D, 16.0D),
Direction.SOUTH, Block.box(0.0D, 0.0D, 0.0D, 16.0D, 16.0D, 2.0D),
Direction.WEST, Block.box(14.0D, 0.0D, 0.0D, 16.0D, 16.0D, 16.0D),
Direction.EAST, Block.box(0.0D, 0.0D, 0.0D, 2.0D, 16.0D, 16.0D)
);
public ZeventScreenBlock(BlockBehaviour.Properties properties) {
super(properties);
registerDefaultState(stateDefinition.any()
.setValue(FACING, Direction.NORTH)
.setValue(COLUMN, ZeventScreenLayout.CONTROLLER_COLUMN)
.setValue(ROW, ZeventScreenLayout.CONTROLLER_ROW));
}
@Override
public MapCodec<ZeventScreenBlock> codec() {
return CODEC;
}
@Override
public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
return new ZeventScreenBlockEntity(pos, state);
}
@Override
protected RenderShape getRenderShape(BlockState state) {
return RenderShape.MODEL;
}
@Override
public BlockState getStateForPlacement(BlockPlaceContext context) {
Direction facing = context.getClickedFace();
if (!facing.getAxis().isHorizontal()) return null;
BlockPos controller = context.getClickedPos();
if (!canPlaceScreen(context.getLevel(), controller, facing)) return null;
return defaultBlockState().setValue(FACING, facing);
}
@Override
public void setPlacedBy(Level level, BlockPos pos, BlockState state, LivingEntity placer, ItemStack stack) {
super.setPlacedBy(level, pos, state, placer, stack);
if (level.isClientSide() || !ZeventScreenLayout.isController(state)) return;
Direction facing = state.getValue(FACING);
for (int row = 0; row < ZeventScreenLayout.HEIGHT; row++) {
for (int column = 0; column < ZeventScreenLayout.WIDTH; column++) {
if (column == ZeventScreenLayout.CONTROLLER_COLUMN
&& row == ZeventScreenLayout.CONTROLLER_ROW) continue;
BlockPos part = ZeventScreenLayout.partPos(pos, facing, column, row);
level.setBlock(part, defaultBlockState().setValue(FACING, facing)
.setValue(COLUMN, column).setValue(ROW, row), Block.UPDATE_ALL);
}
}
}
@Override
protected boolean canSurvive(BlockState state, LevelReader level, BlockPos pos) {
Direction facing = state.getValue(FACING);
BlockPos support = pos.relative(facing.getOpposite());
return level.getBlockState(support).isFaceSturdy(level, support, facing);
}
@Override
protected BlockState updateShape(BlockState state, LevelReader level, ScheduledTickAccess ticks,
BlockPos pos, Direction direction, BlockPos neighborPos, BlockState neighborState, RandomSource random) {
return direction == state.getValue(FACING).getOpposite() && !state.canSurvive(level, pos)
? Blocks.AIR.defaultBlockState()
: super.updateShape(state, level, ticks, pos, direction, neighborPos, neighborState, random);
}
@Override
protected VoxelShape getShape(BlockState state, BlockGetter level, BlockPos pos, CollisionContext context) {
return SHAPES.get(state.getValue(FACING));
}
@Override
protected InteractionResult useItemOn(ItemStack stack, BlockState state, Level level, BlockPos pos,
Player player, InteractionHand hand, BlockHitResult hit) {
return openOptions(state, level, pos, player);
}
@Override
protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos,
Player player, BlockHitResult hit) {
return openOptions(state, level, pos, player);
}
@Override
public BlockState playerWillDestroy(Level level, BlockPos pos, BlockState state, Player player) {
if (!level.isClientSide()) removeOtherParts(level, pos, state);
return super.playerWillDestroy(level, pos, state, player);
}
@Override
protected void affectNeighborsAfterRemoval(BlockState state, ServerLevel level, BlockPos pos,
boolean movedByPiston) {
if (!REMOVING_STRUCTURE.get()) removeOtherParts(level, pos, state);
super.affectNeighborsAfterRemoval(state, level, pos, movedByPiston);
}
@Override
protected void createBlockStateDefinition(StateDefinition.Builder<Block, BlockState> builder) {
builder.add(FACING, COLUMN, ROW);
}
@Override
protected BlockState rotate(BlockState state, Rotation rotation) {
return state.setValue(FACING, rotation.rotate(state.getValue(FACING)));
}
@Override
protected BlockState mirror(BlockState state, Mirror mirror) {
return state.rotate(mirror.getRotation(state.getValue(FACING)));
}
private static boolean canPlaceScreen(LevelReader level, BlockPos controller, Direction facing) {
for (int row = 0; row < ZeventScreenLayout.HEIGHT; row++) {
for (int column = 0; column < ZeventScreenLayout.WIDTH; column++) {
BlockPos part = ZeventScreenLayout.partPos(controller, facing, column, row);
if (level.isOutsideBuildHeight(part) || !level.getBlockState(part).isAir()) return false;
BlockPos support = part.relative(facing.getOpposite());
if (!level.getBlockState(support).isFaceSturdy(level, support, facing)) return false;
}
}
return true;
}
private static InteractionResult openOptions(BlockState state, Level level, BlockPos pos, Player player) {
if (level.isClientSide()) return InteractionResult.SUCCESS;
BlockPos controller = ZeventScreenLayout.controllerPos(pos, state);
if (player instanceof ServerPlayer serverPlayer
&& level.getBlockEntity(controller) instanceof ZeventScreenBlockEntity screen) {
ZeventNetworking.openOptions(serverPlayer, screen);
return InteractionResult.SUCCESS_SERVER;
}
return InteractionResult.CONSUME;
}
private static void removeOtherParts(Level level, BlockPos brokenPos, BlockState brokenState) {
if (!(brokenState.getBlock() instanceof ZeventScreenBlock)) return;
BlockPos controller = ZeventScreenLayout.controllerPos(brokenPos, brokenState);
Direction facing = brokenState.getValue(FACING);
REMOVING_STRUCTURE.set(true);
try {
for (int row = 0; row < ZeventScreenLayout.HEIGHT; row++) {
for (int column = 0; column < ZeventScreenLayout.WIDTH; column++) {
BlockPos part = ZeventScreenLayout.partPos(controller, facing, column, row);
if (part.equals(brokenPos)) continue;
BlockState candidate = level.getBlockState(part);
if (candidate.getBlock() instanceof ZeventScreenBlock
&& ZeventScreenLayout.controllerPos(part, candidate).equals(controller)) {
level.setBlock(part, Blocks.AIR.defaultBlockState(),
Block.UPDATE_ALL | Block.UPDATE_SUPPRESS_DROPS);
}
}
}
} finally {
REMOVING_STRUCTURE.set(false);
}
}
}
@@ -1,64 +0,0 @@
package fr.koka99cab.sanctuary26.zevent.block;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.world.level.block.state.BlockState;
/** Pure 9x5 layout math shared by placement, interaction and teardown. */
public final class ZeventScreenLayout {
public static final int WIDTH = 9;
public static final int HEIGHT = 5;
public static final int CONTROLLER_COLUMN = WIDTH / 2;
public static final int CONTROLLER_ROW = HEIGHT / 2;
private ZeventScreenLayout() {
}
public static Direction right(Direction facing) {
if (!facing.getAxis().isHorizontal()) throw new IllegalArgumentException("Horizontal facing required");
return facing.getCounterClockWise();
}
public static BlockPos partPos(BlockPos controller, Direction facing, int column, int row) {
validateCell(column, row);
Direction right = right(facing);
return controller.offset(
right.getStepX() * (column - CONTROLLER_COLUMN),
row - CONTROLLER_ROW,
right.getStepZ() * (column - CONTROLLER_COLUMN)
);
}
/** Returns the minimum world-space corner used by rectangular media renderers. */
public static BlockPos anchor(BlockPos controller, Direction facing) {
BlockPos first = partPos(controller, facing, 0, 0);
BlockPos last = partPos(controller, facing, WIDTH - 1, HEIGHT - 1);
return new BlockPos(
Math.min(first.getX(), last.getX()),
Math.min(first.getY(), last.getY()),
Math.min(first.getZ(), last.getZ()));
}
public static BlockPos controllerPos(BlockPos part, BlockState state) {
Direction facing = state.getValue(ZeventScreenBlock.FACING);
int column = state.getValue(ZeventScreenBlock.COLUMN);
int row = state.getValue(ZeventScreenBlock.ROW);
Direction right = right(facing);
return part.offset(
-right.getStepX() * (column - CONTROLLER_COLUMN),
-(row - CONTROLLER_ROW),
-right.getStepZ() * (column - CONTROLLER_COLUMN)
);
}
public static boolean isController(BlockState state) {
return state.getValue(ZeventScreenBlock.COLUMN) == CONTROLLER_COLUMN
&& state.getValue(ZeventScreenBlock.ROW) == CONTROLLER_ROW;
}
private static void validateCell(int column, int row) {
if (column < 0 || column >= WIDTH || row < 0 || row >= HEIGHT) {
throw new IllegalArgumentException("Invalid ZEVENT screen cell " + column + "," + row);
}
}
}
@@ -1,75 +0,0 @@
package fr.koka99cab.sanctuary26.zevent.block.entity;
import fr.koka99cab.sanctuary26.zevent.ZeventMod;
import fr.koka99cab.sanctuary26.zevent.registry.ZeventRegistries;
import fr.koka99cab.sanctuary26.zevent.twitch.TwitchChannel;
import net.minecraft.core.BlockPos;
import net.minecraft.core.HolderLookup;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.protocol.Packet;
import net.minecraft.network.protocol.game.ClientGamePacketListener;
import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.storage.ValueInput;
import net.minecraft.world.level.storage.ValueOutput;
/** The central cell persists one bounded Twitch channel; all other cells are stateless mirrors. */
public final class ZeventScreenBlockEntity extends BlockEntity {
private static final String DATA_VERSION_KEY = "DataVersion";
private static final String CHANNEL_KEY = "Channel";
private int dataVersion = ZeventMod.CURRENT_DATA_VERSION;
private String channel = TwitchChannel.DEFAULT;
public ZeventScreenBlockEntity(BlockPos pos, BlockState state) {
super(ZeventRegistries.ZEVENT_SCREEN_BLOCK_ENTITY, pos, state);
}
public String channel() {
return channel;
}
public boolean setChannel(String requested) {
if (dataVersion > ZeventMod.CURRENT_DATA_VERSION) return false;
String normalized = TwitchChannel.normalize(requested).orElse(null);
if (normalized == null) return false;
if (normalized.equals(channel)) return true;
channel = normalized;
setChanged();
if (level != null) {
BlockState state = getBlockState();
level.sendBlockUpdated(worldPosition, state, state, Block.UPDATE_CLIENTS);
}
return true;
}
public boolean writable() {
return dataVersion <= ZeventMod.CURRENT_DATA_VERSION;
}
@Override
protected void saveAdditional(ValueOutput output) {
super.saveAdditional(output);
output.putInt(DATA_VERSION_KEY, dataVersion);
output.putString(CHANNEL_KEY, channel);
}
@Override
protected void loadAdditional(ValueInput input) {
super.loadAdditional(input);
dataVersion = input.getIntOr(DATA_VERSION_KEY, 1);
channel = TwitchChannel.normalize(input.getStringOr(CHANNEL_KEY, TwitchChannel.DEFAULT))
.orElse(TwitchChannel.DEFAULT);
}
@Override
public Packet<ClientGamePacketListener> getUpdatePacket() {
return ClientboundBlockEntityDataPacket.create(this);
}
@Override
public CompoundTag getUpdateTag(HolderLookup.Provider lookup) {
return saveWithoutMetadata(lookup);
}
}
@@ -1,29 +0,0 @@
package fr.koka99cab.sanctuary26.zevent.client;
import fr.koka99cab.sanctuary26.zevent.ZeventMod;
import fr.koka99cab.sanctuary26.zevent.client.gui.ZeventScreenOptionsScreen;
import fr.koka99cab.sanctuary26.zevent.client.render.ZeventScreenBlockEntityRenderer;
import fr.koka99cab.sanctuary26.zevent.client.twitch.TwitchBrowserManager;
import fr.koka99cab.sanctuary26.zevent.network.OpenScreenOptionsPayload;
import fr.koka99cab.sanctuary26.zevent.registry.ZeventRegistries;
import net.fabricmc.api.ClientModInitializer;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
import net.fabricmc.fabric.api.client.rendering.v1.BlockEntityRendererRegistry;
public final class ZeventClient implements ClientModInitializer {
@Override
public void onInitializeClient() {
BlockEntityRendererRegistry.register(ZeventRegistries.ZEVENT_SCREEN_BLOCK_ENTITY,
ZeventScreenBlockEntityRenderer::new);
ClientPlayNetworking.registerGlobalReceiver(OpenScreenOptionsPayload.TYPE, (payload, context) ->
context.client().execute(() -> context.client().gui.setScreen(
new ZeventScreenOptionsScreen(context.client().gui.screen(), payload))));
ClientTickEvents.END_CLIENT_TICK.register(TwitchBrowserManager::tick);
ClientPlayConnectionEvents.DISCONNECT.register((handler, client) -> TwitchBrowserManager.clear(client));
ClientLifecycleEvents.CLIENT_STOPPING.register(TwitchBrowserManager::shutdown);
ZeventMod.LOGGER.info("[{}] Native Twitch renderer registered.", ZeventMod.DISPLAY_NAME);
}
}
@@ -1,117 +0,0 @@
package fr.koka99cab.sanctuary26.zevent.client.gui;
import fr.koka99cab.sanctuary26.zevent.network.OpenScreenOptionsPayload;
import fr.koka99cab.sanctuary26.zevent.network.SaveScreenChannelPayload;
import fr.koka99cab.sanctuary26.zevent.twitch.TwitchChannel;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.components.EditBox;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.network.chat.Component;
/** Small in-game configuration surface opened by right-clicking any screen cell. */
public final class ZeventScreenOptionsScreen extends Screen {
private static final int PANEL_WIDTH = 340;
private static final int PANEL_HEIGHT = 184;
private static final int BACKDROP = 0xE0121715;
private static final int BORDER = 0xFF3BEA8C;
private static final int TITLE = 0xFFF5FFF9;
private static final int TEXT = 0xFFC7D7CE;
private static final int ERROR = 0xFFFF6B78;
private final Screen parent;
private final OpenScreenOptionsPayload payload;
private EditBox channel;
private Button save;
private Component validation = Component.empty();
public ZeventScreenOptionsScreen(Screen parent, OpenScreenOptionsPayload payload) {
super(Component.translatable("screen.zevent.options.title"));
this.parent = parent;
this.payload = payload;
}
@Override
protected void init() {
int x = panelX() + 16;
int y = panelY();
channel = addRenderableWidget(new EditBox(font, x, y + 54, PANEL_WIDTH - 32, 20,
Component.translatable("screen.zevent.options.channel")));
channel.setMaxLength(TwitchChannel.MAX_INPUT_LENGTH);
channel.setValue(payload.channel());
channel.setHint(Component.translatable("screen.zevent.options.hint"));
channel.setEditable(payload.writable());
channel.setResponder(value -> refreshValidation());
Button official = addRenderableWidget(Button.builder(
Component.translatable("screen.zevent.options.official"), button -> select("zeventfr"))
.bounds(x, y + 82, 150, 20).build());
Button zerator = addRenderableWidget(Button.builder(
Component.translatable("screen.zevent.options.zerator"), button -> select("zerator"))
.bounds(x + 158, y + 82, 150, 20).build());
official.active = payload.writable();
zerator.active = payload.writable();
save = addRenderableWidget(Button.builder(Component.translatable("gui.done"), button -> submit())
.bounds(x, y + 142, 150, 20).build());
addRenderableWidget(Button.builder(Component.translatable("gui.cancel"), button -> onClose())
.bounds(x + 158, y + 142, 150, 20).build());
refreshValidation();
setInitialFocus(channel);
}
@Override
public void tick() {
refreshValidation();
}
@Override
public void extractRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float tickDelta) {
int x = panelX();
int y = panelY();
graphics.fill(x, y, x + PANEL_WIDTH, y + PANEL_HEIGHT, BACKDROP);
graphics.outline(x, y, PANEL_WIDTH, PANEL_HEIGHT, BORDER);
graphics.centeredText(font, title, width / 2, y + 12, TITLE);
graphics.text(font, Component.translatable("screen.zevent.options.current", payload.channel()),
x + 16, y + 31, TEXT);
graphics.text(font, Component.translatable("screen.zevent.options.channel"), x + 16, y + 43, TEXT);
if (!validation.getString().isEmpty()) graphics.text(font, validation, x + 16, y + 113, ERROR);
super.extractRenderState(graphics, mouseX, mouseY, tickDelta);
}
private void select(String selected) {
if (payload.writable()) channel.setValue(selected);
}
private void refreshValidation() {
if (channel == null || save == null) return;
boolean valid = TwitchChannel.normalize(channel.getValue()).isPresent();
validation = valid ? Component.empty() : Component.translatable("screen.zevent.options.invalid");
save.active = payload.writable() && valid && ClientPlayNetworking.canSend(SaveScreenChannelPayload.TYPE);
}
private void submit() {
String normalized = TwitchChannel.normalize(channel.getValue()).orElse(null);
if (normalized == null || !save.active) return;
ClientPlayNetworking.send(new SaveScreenChannelPayload(payload.pos(), normalized));
onClose();
}
private int panelX() {
return (width - PANEL_WIDTH) / 2;
}
private int panelY() {
return Math.max(2, (height - PANEL_HEIGHT) / 2);
}
@Override
public void onClose() {
if (minecraft != null) minecraft.gui.setScreen(parent);
}
@Override
public boolean isPauseScreen() {
return false;
}
}
@@ -1,90 +0,0 @@
package fr.koka99cab.sanctuary26.zevent.client.render;
import com.mojang.blaze3d.vertex.PoseStack;
import com.mojang.blaze3d.vertex.VertexConsumer;
import fr.koka99cab.sanctuary26.zevent.block.ZeventScreenBlock;
import fr.koka99cab.sanctuary26.zevent.block.ZeventScreenLayout;
import fr.koka99cab.sanctuary26.zevent.block.entity.ZeventScreenBlockEntity;
import fr.koka99cab.sanctuary26.zevent.client.render.state.ZeventScreenRenderState;
import fr.koka99cab.sanctuary26.zevent.client.twitch.TwitchBrowserManager;
import net.minecraft.client.renderer.SubmitNodeCollector;
import net.minecraft.client.renderer.blockentity.BlockEntityRenderer;
import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider;
import net.minecraft.client.renderer.feature.ModelFeatureRenderer;
import net.minecraft.client.renderer.state.level.CameraRenderState;
import net.minecraft.core.Direction;
import net.minecraft.world.phys.Vec3;
/** Projects one decoded video 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.renderType = null;
if (!ZeventScreenLayout.isController(screen.getBlockState()) || screen.getLevel() == null) return;
state.facing = screen.getBlockState().getValue(ZeventScreenBlock.FACING);
state.renderType = TwitchBrowserManager.markVisible(
screen.getLevel(), screen.getBlockPos(), state.facing, screen.channel());
}
@Override
public void submit(ZeventScreenRenderState state, PoseStack poseStack,
SubmitNodeCollector submitNodeCollector, CameraRenderState cameraRenderState) {
if (state.renderType == null) return;
submitNodeCollector.submitCustomGeometry(poseStack,
state.renderType,
(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, -HALF_WIDTH, -HALF_HEIGHT, 0.0F, 1.0F);
vertex(consumer, pose, centerX, centerZ, right, HALF_WIDTH, -HALF_HEIGHT, 1.0F, 1.0F);
vertex(consumer, pose, centerX, centerZ, right, HALF_WIDTH, HALF_HEIGHT, 1.0F, 0.0F);
vertex(consumer, pose, centerX, centerZ, right, -HALF_WIDTH, HALF_HEIGHT, 0.0F, 0.0F);
}
private static void vertex(VertexConsumer consumer, PoseStack.Pose pose, float centerX, float centerZ,
Direction right, float horizontal, float vertical, float u, float v) {
consumer.addVertex(pose,
centerX + right.getStepX() * horizontal,
0.5F + vertical,
centerZ + right.getStepZ() * horizontal)
.setUv(u, v)
.setColor(0xFFFFFFFF);
}
}
@@ -1,10 +0,0 @@
package fr.koka99cab.sanctuary26.zevent.client.render.state;
import net.minecraft.client.renderer.blockentity.state.BlockEntityRenderState;
import net.minecraft.client.renderer.rendertype.RenderType;
import net.minecraft.core.Direction;
public final class ZeventScreenRenderState extends BlockEntityRenderState {
public Direction facing = Direction.NORTH;
public RenderType renderType;
}
@@ -1,243 +0,0 @@
package fr.koka99cab.sanctuary26.zevent.client.twitch;
import com.dreamdisplays.api.display.model.property.DisplayFacing;
import com.dreamdisplays.api.display.model.property.DisplayRotation;
import com.dreamdisplays.api.media.audio.service.AudioAcousticsService;
import com.dreamdisplays.api.media.audio.service.keys.AudioAcousticsServices;
import com.dreamdisplays.api.playback.model.PlaybackMode;
import com.dreamdisplays.platform.client.core.DreamServices;
import com.dreamdisplays.platform.client.displays.DisplayScreen;
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.nio.charset.StandardCharsets;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.rendertype.RenderType;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.network.chat.Component;
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 native Twitch players supplied by Dream Displays. */
public final class TwitchBrowserManager {
private static final int MAXIMUM_PLAYERS = 2;
private static final int STALE_TICKS = 40;
private static final int AUDIBLE_FRESH_TICKS = 10;
private static final int RETRY_TICKS = 20 * 30;
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 Map<ScreenKey, Session> SESSIONS = new LinkedHashMap<>();
private static boolean initializationNoticeShown;
private TwitchBrowserManager() {
}
public static RenderType markVisible(Level level, BlockPos pos, Direction facing, 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 && session.facing != facing) {
remove(session);
session = null;
}
if (session == null) {
if (!admit(client, pos)) return null;
session = start(client, key, facing, channel);
SESSIONS.put(key, session);
}
session.lastSeenTick = level.getGameTime();
if (!session.channel.equals(channel)) session.changeChannel(channel);
if (session.player == null) return null;
try {
session.player.fitTexture();
return session.player.isVideoStarted() ? session.player.getRenderType() : null;
} catch (RuntimeException | LinkageError exception) {
ZeventMod.LOGGER.warn("Unable to upload a ZEVENT Twitch frame", exception);
return 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(session);
continue;
}
if (session.player != null) session.player.tick(session.key.pos);
if (session.player != null) {
if (session.player.isVideoStarted()) session.errorReported = false;
if (session.player.getMediaError() != null) {
if (!session.errorReported) {
session.errorReported = true;
client.player.sendOverlayMessage(Component.translatable(
"message.zevent.player.unavailable", session.channel));
ZeventMod.LOGGER.warn("Twitch channel {} could not be played: {}",
session.channel, session.player.getMediaError().getMessage());
}
if (now - session.lastRetryTick >= RETRY_TICKS) {
session.lastRetryTick = now;
session.player.retryVideo();
}
}
}
}
updateVolumes(client, now);
}
public static void clear(Minecraft client) {
for (Session session : SESSIONS.values().toArray(Session[]::new)) remove(session);
SESSIONS.clear();
initializationNoticeShown = false;
}
public static void shutdown(Minecraft client) {
clear(client);
}
private static boolean admit(Minecraft client, BlockPos requested) {
if (SESSIONS.size() < MAXIMUM_PLAYERS) 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(farthest);
return true;
}
private static Session start(Minecraft client, ScreenKey key, Direction facing, String channel) {
Session session = new Session(key, facing, channel);
if (!initializationNoticeShown && client.player != null) {
initializationNoticeShown = true;
client.player.sendOverlayMessage(Component.translatable("message.zevent.player.initializing"));
}
DisplayScreen player = null;
try {
BlockPos anchor = ZeventScreenLayout.anchor(key.pos, facing);
UUID displayId = UUID.nameUUIDFromBytes(("zevent:" + key.dimension.identifier() + ":" + key.pos.asLong())
.getBytes(StandardCharsets.UTF_8));
player = new DisplayScreen(
displayId,
client.player.getUUID(),
anchor.getX(), anchor.getY(), anchor.getZ(),
DisplayFacing.valueOf(facing.getName().toUpperCase(Locale.ROOT)),
ZeventScreenLayout.WIDTH, ZeventScreenLayout.HEIGHT,
PlaybackMode.LOCAL, 720, DisplayRotation.NONE,
key.dimension.identifier().toString());
session.player = player;
// ZEVENT owns distance and the Minecraft Records slider; keep Dream's DSP on its legacy-gain path.
player.setAcousticsEnabled(false);
player.createTexture();
player.loadVideo(TwitchChannel.streamUrl(channel), "");
} catch (RuntimeException | LinkageError exception) {
if (player != null) {
try {
unregisterPlayer(player);
} catch (RuntimeException | LinkageError cleanupException) {
exception.addSuppressed(cleanupException);
}
}
session.player = null;
ZeventMod.LOGGER.error("Unable to create the native ZEVENT Twitch player", exception);
if (client.player != null) client.player.sendOverlayMessage(Component.translatable(
"message.zevent.player.unavailable", channel));
}
return session;
}
private static void updateVolumes(Minecraft client, long now) {
Session nearest = SESSIONS.values().stream()
.filter(session -> session.player != 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()) {
if (session.player == null) continue;
float volume = session == nearest ? attenuatedVolume(client, session.key.pos) : 0.0F;
session.player.setVideoVolume(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(Session session) {
if (SESSIONS.remove(session.key) == null) return;
if (session.player != null) {
try {
unregisterPlayer(session.player);
} catch (RuntimeException | LinkageError exception) {
ZeventMod.LOGGER.warn("Unable to close a ZEVENT Twitch player cleanly", exception);
}
session.player = null;
}
}
private static void unregisterPlayer(DisplayScreen player) {
player.setVideoVolume(0.0F);
UUID displayId = player.getUuid();
try {
player.unregister();
} finally {
AudioAcousticsService acoustics = DreamServices.INSTANCE.getRegistry()
.getOrNull(AudioAcousticsServices.INSTANCE.getACOUSTICS());
if (acoustics != null) acoustics.unregisterSource(displayId);
}
}
private record ScreenKey(ResourceKey<Level> dimension, BlockPos pos) {
}
private static final class Session {
private final ScreenKey key;
private final Direction facing;
private String channel;
private DisplayScreen player;
private long lastSeenTick;
private long lastRetryTick;
private boolean errorReported;
private Session(ScreenKey key, Direction facing, String channel) {
this.key = key;
this.facing = facing;
this.channel = channel;
}
private void changeChannel(String next) {
channel = next;
lastRetryTick = 0L;
errorReported = false;
if (player != null) player.loadVideo(TwitchChannel.streamUrl(next), "");
}
}
}
@@ -1,37 +0,0 @@
package fr.koka99cab.sanctuary26.zevent.network;
import fr.koka99cab.sanctuary26.zevent.ZeventMod;
import fr.koka99cab.sanctuary26.zevent.twitch.TwitchChannel;
import net.minecraft.core.BlockPos;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
public record OpenScreenOptionsPayload(BlockPos pos, String channel, boolean writable)
implements CustomPacketPayload {
public static final Type<OpenScreenOptionsPayload> TYPE =
new Type<>(ZeventMod.id("s2c/open_screen_options"));
public static final StreamCodec<RegistryFriendlyByteBuf, OpenScreenOptionsPayload> STREAM_CODEC =
StreamCodec.of(OpenScreenOptionsPayload::write, OpenScreenOptionsPayload::read);
public OpenScreenOptionsPayload {
pos = pos == null ? BlockPos.ZERO : pos.immutable();
channel = TwitchChannel.normalize(channel).orElse(TwitchChannel.DEFAULT);
}
private static OpenScreenOptionsPayload read(RegistryFriendlyByteBuf buffer) {
return new OpenScreenOptionsPayload(buffer.readBlockPos(),
buffer.readUtf(TwitchChannel.MAX_NAME_LENGTH), buffer.readBoolean());
}
private static void write(RegistryFriendlyByteBuf buffer, OpenScreenOptionsPayload payload) {
buffer.writeBlockPos(payload.pos);
buffer.writeUtf(payload.channel, TwitchChannel.MAX_NAME_LENGTH);
buffer.writeBoolean(payload.writable);
}
@Override
public Type<? extends CustomPacketPayload> type() {
return TYPE;
}
}
@@ -1,36 +0,0 @@
package fr.koka99cab.sanctuary26.zevent.network;
import fr.koka99cab.sanctuary26.zevent.ZeventMod;
import fr.koka99cab.sanctuary26.zevent.twitch.TwitchChannel;
import net.minecraft.core.BlockPos;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
public record SaveScreenChannelPayload(BlockPos pos, String channel) implements CustomPacketPayload {
public static final Type<SaveScreenChannelPayload> TYPE =
new Type<>(ZeventMod.id("c2s/save_screen_channel"));
public static final StreamCodec<RegistryFriendlyByteBuf, SaveScreenChannelPayload> STREAM_CODEC =
StreamCodec.of(SaveScreenChannelPayload::write, SaveScreenChannelPayload::read);
public SaveScreenChannelPayload {
pos = pos == null ? BlockPos.ZERO : pos.immutable();
channel = channel == null ? "" : channel.strip();
if (channel.length() > TwitchChannel.MAX_INPUT_LENGTH) channel = "";
}
private static SaveScreenChannelPayload read(RegistryFriendlyByteBuf buffer) {
return new SaveScreenChannelPayload(buffer.readBlockPos(),
buffer.readUtf(TwitchChannel.MAX_INPUT_LENGTH));
}
private static void write(RegistryFriendlyByteBuf buffer, SaveScreenChannelPayload payload) {
buffer.writeBlockPos(payload.pos);
buffer.writeUtf(payload.channel, TwitchChannel.MAX_INPUT_LENGTH);
}
@Override
public Type<? extends CustomPacketPayload> type() {
return TYPE;
}
}
@@ -1,57 +0,0 @@
package fr.koka99cab.sanctuary26.zevent.network;
import fr.koka99cab.sanctuary26.zevent.ZeventMod;
import fr.koka99cab.sanctuary26.zevent.block.ZeventScreenLayout;
import fr.koka99cab.sanctuary26.zevent.block.entity.ZeventScreenBlockEntity;
import fr.koka99cab.sanctuary26.zevent.twitch.TwitchChannel;
import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry;
import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.phys.Vec3;
public final class ZeventNetworking {
public static final int PROTOCOL_VERSION = 1;
private static final double MAX_INTERACTION_DISTANCE_SQR = 64.0D;
private static boolean initialized;
private ZeventNetworking() {
}
public static void initialize() {
if (initialized) return;
initialized = true;
PayloadTypeRegistry.clientboundPlay().register(OpenScreenOptionsPayload.TYPE,
OpenScreenOptionsPayload.STREAM_CODEC);
PayloadTypeRegistry.serverboundPlay().register(SaveScreenChannelPayload.TYPE,
SaveScreenChannelPayload.STREAM_CODEC);
ServerPlayNetworking.registerGlobalReceiver(SaveScreenChannelPayload.TYPE, (payload, context) ->
context.server().execute(() -> saveChannel(context.player(), payload)));
}
public static void openOptions(ServerPlayer player, ZeventScreenBlockEntity screen) {
if (player.distanceToSqr(Vec3.atCenterOf(screen.getBlockPos())) > MAX_INTERACTION_DISTANCE_SQR
|| !ServerPlayNetworking.canSend(player, OpenScreenOptionsPayload.TYPE)) return;
ServerPlayNetworking.send(player, new OpenScreenOptionsPayload(
screen.getBlockPos(), screen.channel(), screen.writable()));
}
private static void saveChannel(ServerPlayer player, SaveScreenChannelPayload payload) {
if (player.distanceToSqr(Vec3.atCenterOf(payload.pos())) > MAX_INTERACTION_DISTANCE_SQR) {
player.sendOverlayMessage(Component.translatable("message.zevent.screen.too_far"));
return;
}
if (!(player.level().getBlockEntity(payload.pos()) instanceof ZeventScreenBlockEntity screen)
|| !ZeventScreenLayout.isController(screen.getBlockState())) {
player.sendOverlayMessage(Component.translatable("message.zevent.screen.missing"));
return;
}
String normalized = TwitchChannel.normalize(payload.channel()).orElse(null);
if (normalized == null) return;
if (!screen.setChannel(normalized)) {
player.sendOverlayMessage(Component.translatable("message.zevent.screen.newer_data"));
return;
}
player.sendOverlayMessage(Component.translatable("screen.zevent.options.saved"));
}
}
@@ -1,69 +0,0 @@
package fr.koka99cab.sanctuary26.zevent.registry;
import fr.koka99cab.sanctuary26.zevent.ZeventMod;
import fr.koka99cab.sanctuary26.zevent.block.ZeventScreenBlock;
import fr.koka99cab.sanctuary26.zevent.block.entity.ZeventScreenBlockEntity;
import net.fabricmc.fabric.api.object.builder.v1.block.entity.FabricBlockEntityTypeBuilder;
import net.minecraft.core.Registry;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.core.registries.Registries;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.world.item.BlockItem;
import net.minecraft.world.item.CreativeModeTab;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.SoundType;
import net.minecraft.world.level.block.entity.BlockEntityType;
import net.minecraft.world.level.block.state.BlockBehaviour;
import net.minecraft.world.level.material.MapColor;
public final class ZeventRegistries {
private static final Identifier SCREEN_ID = ZeventMod.id("zevent_screen");
public static final ZeventScreenBlock ZEVENT_SCREEN = Registry.register(
BuiltInRegistries.BLOCK,
blockKey(SCREEN_ID),
new ZeventScreenBlock(BlockBehaviour.Properties.of()
.mapColor(MapColor.COLOR_BLACK)
.strength(3.5F, 8.0F)
.sound(SoundType.METAL)
.noOcclusion()
.setId(blockKey(SCREEN_ID)))
);
public static final Item ZEVENT_SCREEN_ITEM = Registry.register(
BuiltInRegistries.ITEM,
itemKey(SCREEN_ID),
new BlockItem(ZEVENT_SCREEN, new Item.Properties().useBlockDescriptionPrefix().setId(itemKey(SCREEN_ID)))
);
public static final BlockEntityType<ZeventScreenBlockEntity> ZEVENT_SCREEN_BLOCK_ENTITY = Registry.register(
BuiltInRegistries.BLOCK_ENTITY_TYPE,
ZeventMod.id("zevent_screen"),
FabricBlockEntityTypeBuilder.create(ZeventScreenBlockEntity::new, ZEVENT_SCREEN).build()
);
public static final CreativeModeTab CREATIVE_TAB = Registry.register(
BuiltInRegistries.CREATIVE_MODE_TAB,
ResourceKey.create(Registries.CREATIVE_MODE_TAB, ZeventMod.id("zevent")),
CreativeModeTab.builder(CreativeModeTab.Row.TOP, 0)
.title(Component.translatable("itemGroup.zevent.zevent"))
.icon(() -> new ItemStack(ZEVENT_SCREEN_ITEM))
.displayItems((parameters, output) -> output.accept(ZEVENT_SCREEN_ITEM))
.build()
);
private ZeventRegistries() {
}
public static void initialize() {
// Class initialization owns all registry writes.
}
private static ResourceKey<Block> blockKey(Identifier id) {
return ResourceKey.create(Registries.BLOCK, id);
}
private static ResourceKey<Item> itemKey(Identifier id) {
return ResourceKey.create(Registries.ITEM, id);
}
}
@@ -1,63 +0,0 @@
package fr.koka99cab.sanctuary26.zevent.twitch;
import java.net.URI;
import java.util.Locale;
import java.util.Optional;
import java.util.regex.Pattern;
/** Converts user input to the only external identifier the server persists. */
public final class TwitchChannel {
public static final String DEFAULT = "zeventfr";
public static final int MAX_INPUT_LENGTH = 128;
public static final int MAX_NAME_LENGTH = 25;
private static final Pattern NAME = Pattern.compile("[a-z0-9_]{1," + MAX_NAME_LENGTH + "}");
private TwitchChannel() {
}
public static Optional<String> normalize(String input) {
if (input == null) return Optional.empty();
String candidate = input.strip().toLowerCase(Locale.ROOT);
if (candidate.isEmpty() || candidate.length() > MAX_INPUT_LENGTH) return Optional.empty();
if (candidate.startsWith("@")) candidate = candidate.substring(1);
if (candidate.startsWith("twitch.tv/") || candidate.startsWith("www.twitch.tv/")) {
candidate = "https://" + candidate;
}
if (candidate.startsWith("http://") || candidate.startsWith("https://")) {
candidate = channelFromUrl(candidate).orElse("");
}
int query = candidate.indexOf('?');
if (query >= 0) candidate = candidate.substring(0, query);
int fragment = candidate.indexOf('#');
if (fragment >= 0) candidate = candidate.substring(0, fragment);
candidate = candidate.replaceAll("^/+|/+$", "");
return NAME.matcher(candidate).matches() ? Optional.of(candidate) : Optional.empty();
}
public static boolean valid(String channel) {
return channel != null && NAME.matcher(channel).matches();
}
public static String streamUrl(String channel) {
if (!valid(channel)) throw new IllegalArgumentException("Invalid Twitch channel");
return "https://www.twitch.tv/" + channel;
}
private static Optional<String> channelFromUrl(String input) {
try {
URI uri = URI.create(input);
String host = uri.getHost();
if (host == null || !(host.equals("twitch.tv") || host.equals("www.twitch.tv"))) {
return Optional.empty();
}
String path = uri.getPath();
if (path == null) return Optional.empty();
String[] parts = path.split("/");
for (String part : parts) if (!part.isBlank()) return Optional.of(part);
} catch (IllegalArgumentException ignored) {
// Invalid URLs are rejected by the common name validation below.
}
return Optional.empty();
}
}
@@ -1,8 +0,0 @@
{
"multipart": [
{ "when": { "facing": "north" }, "apply": { "model": "zevent:block/zevent_screen" } },
{ "when": { "facing": "east" }, "apply": { "model": "zevent:block/zevent_screen", "y": 90 } },
{ "when": { "facing": "south" }, "apply": { "model": "zevent:block/zevent_screen", "y": 180 } },
{ "when": { "facing": "west" }, "apply": { "model": "zevent:block/zevent_screen", "y": 270 } }
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

@@ -1,6 +0,0 @@
{
"model": {
"type": "minecraft:model",
"model": "zevent:block/zevent_screen"
}
}
@@ -1,18 +0,0 @@
{
"mod.zevent.description": "Streams ZEVENT Twitch channels on a 9x5 wall screen.",
"itemGroup.zevent.zevent": "ZEVENT",
"block.zevent.zevent_screen": "9x5 ZEVENT Screen",
"screen.zevent.options.title": "ZEVENT Screen",
"screen.zevent.options.channel": "Twitch channel",
"screen.zevent.options.hint": "Name or twitch.tv/channel URL",
"screen.zevent.options.official": "Official ZEVENT",
"screen.zevent.options.zerator": "ZeratoR",
"screen.zevent.options.current": "Now playing: twitch.tv/%s",
"screen.zevent.options.invalid": "Invalid channel name (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 decoder…",
"message.zevent.player.unavailable": "Channel twitch.tv/%s is offline or temporarily unavailable."
}
@@ -1,18 +0,0 @@
{
"mod.zevent.description": "Diffuse des chaînes Twitch du ZEVENT sur un écran mural 9×5.",
"itemGroup.zevent.zevent": "ZEVENT",
"block.zevent.zevent_screen": "Écran ZEVENT 9×5",
"screen.zevent.options.title": "Écran ZEVENT",
"screen.zevent.options.channel": "Chaîne Twitch",
"screen.zevent.options.hint": "Nom ou URL twitch.tv/chaine",
"screen.zevent.options.official": "ZEVENT officiel",
"screen.zevent.options.zerator": "ZeratoR",
"screen.zevent.options.current": "Diffusion actuelle : twitch.tv/%s",
"screen.zevent.options.invalid": "Nom de chaîne invalide (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 décodeur Twitch…",
"message.zevent.player.unavailable": "La chaîne twitch.tv/%s est hors ligne ou momentanément inaccessible."
}
@@ -1,18 +0,0 @@
{
"mod.zevent.description": "Транслирует Twitch-каналы ZEVENT на настенном экране 9×5.",
"itemGroup.zevent.zevent": "ZEVENT",
"block.zevent.zevent_screen": "Экран ZEVENT 9×5",
"screen.zevent.options.title": "Экран ZEVENT",
"screen.zevent.options.channel": "Канал Twitch",
"screen.zevent.options.hint": "Имя или URL twitch.tv/channel",
"screen.zevent.options.official": "Официальный ZEVENT",
"screen.zevent.options.zerator": "ZeratoR",
"screen.zevent.options.current": "Сейчас: twitch.tv/%s",
"screen.zevent.options.invalid": "Неверное имя канала (1–25 символов: a-z, 0-9, _).",
"screen.zevent.options.saved": "Канал Twitch обновлён.",
"message.zevent.screen.too_far": "Вы слишком далеко от экрана.",
"message.zevent.screen.missing": "Экран ZEVENT больше не существует.",
"message.zevent.screen.newer_data": "Этот экран использует более новые данны и доступен только для чтения.",
"message.zevent.player.initializing": "Инициализация декодера Twitch…",
"message.zevent.player.unavailable": "Канал twitch.tv/%s не в сети или временно недоступен."
}
@@ -1,21 +0,0 @@
{
"parent": "minecraft:block/block",
"textures": {
"particle": "minecraft:block/black_concrete",
"screen": "minecraft:block/black_concrete"
},
"elements": [
{
"from": [0, 0, 14],
"to": [16, 16, 16],
"faces": {
"north": { "texture": "#screen", "cullface": "north" },
"south": { "texture": "#screen", "cullface": "south" },
"west": { "texture": "#screen", "cullface": "west" },
"east": { "texture": "#screen", "cullface": "east" },
"up": { "texture": "#screen", "cullface": "up" },
"down": { "texture": "#screen", "cullface": "down" }
}
}
]
}
@@ -1,20 +0,0 @@
{
"type": "minecraft:block",
"pools": [
{
"rolls": 1,
"entries": [
{
"type": "minecraft:item",
"name": "zevent:zevent_screen"
}
],
"conditions": [
{
"condition": "minecraft:survives_explosion"
}
]
}
],
"random_sequence": "zevent:blocks/zevent_screen"
}
@@ -1,19 +0,0 @@
{
"type": "minecraft:crafting_shaped",
"category": "building",
"pattern": [
"BBB",
"GTG",
"BRB"
],
"key": {
"B": "minecraft:black_concrete",
"G": "minecraft:tinted_glass",
"T": "minecraft:redstone_torch",
"R": "minecraft:redstone_block"
},
"result": {
"id": "zevent:zevent_screen",
"count": 1
}
}
-44
View File
@@ -1,44 +0,0 @@
{
"schemaVersion": 1,
"id": "zevent",
"version": "${version}",
"name": "ZEVENT",
"description": "Watch ZEVENT Twitch channels on a 9x5 in-world screen.",
"authors": [
"KOKA99CAB"
],
"license": "GPL-3.0-or-later",
"icon": "assets/zevent/icon.png",
"environment": "*",
"entrypoints": {
"main": [
"fr.koka99cab.sanctuary26.zevent.ZeventMod"
],
"client": [
"fr.koka99cab.sanctuary26.zevent.client.ZeventClient"
]
},
"depends": {
"fabricloader": ">=0.19.3",
"minecraft": "~26.2",
"java": ">=25",
"fabric-api": ">=0.159.0+26.2",
"dreamdisplays": ">=1.9.6"
},
"custom": {
"sanctuary26": {
"system_type": "server-authoritative-screen",
"namespace": "zevent",
"data_version": 1,
"network_protocol": 1,
"lifecycle": "${lifecycle}",
"pack_version": "${pack_version}",
"distribution": "temporary-weekend-pack-module",
"descriptions": {
"fr_fr": "Diffuse des chaînes Twitch du ZEVENT sur un écran mural 9×5.",
"en_us": "Streams ZEVENT Twitch channels on a 9x5 wall screen.",
"ru_ru": "Транслирует Twitch-каналы ZEVENT на настенном экране 9×5."
}
}
}
}
@@ -1,34 +0,0 @@
package fr.koka99cab.sanctuary26.zevent.block;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import org.junit.jupiter.api.Test;
class ZeventScreenLayoutTest {
@Test
void mediaAnchorAlwaysCoversTheCompleteNineByFiveScreen() {
BlockPos controller = new BlockPos(20, 70, -10);
for (Direction facing : Direction.Plane.HORIZONTAL) {
BlockPos anchor = ZeventScreenLayout.anchor(controller, facing);
for (int column = 0; column < ZeventScreenLayout.WIDTH; column++) {
for (int row = 0; row < ZeventScreenLayout.HEIGHT; row++) {
BlockPos part = ZeventScreenLayout.partPos(controller, facing, column, row);
assertTrue(part.getY() >= anchor.getY());
assertTrue(part.getY() < anchor.getY() + ZeventScreenLayout.HEIGHT);
if (facing.getAxis() == Direction.Axis.Z) {
assertTrue(part.getX() >= anchor.getX());
assertTrue(part.getX() < anchor.getX() + ZeventScreenLayout.WIDTH);
assertEquals(anchor.getZ(), part.getZ());
} else {
assertTrue(part.getZ() >= anchor.getZ());
assertTrue(part.getZ() < anchor.getZ() + ZeventScreenLayout.WIDTH);
assertEquals(anchor.getX(), part.getX());
}
}
}
}
}
}
@@ -1,29 +0,0 @@
package fr.koka99cab.sanctuary26.zevent.twitch;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
class TwitchChannelTest {
@Test
void normalizesNamesAndOfficialUrls() {
assertEquals("zeventfr", TwitchChannel.normalize(" @ZEventFR ").orElseThrow());
assertEquals("zerator", TwitchChannel.normalize("https://www.twitch.tv/ZeratoR/videos").orElseThrow());
assertEquals("etoiles", TwitchChannel.normalize("twitch.tv/etoiles?ref=test").orElseThrow());
}
@Test
void rejectsForeignUrlsAndUnboundedNames() {
assertTrue(TwitchChannel.normalize("https://example.com/zeventfr").isEmpty());
assertTrue(TwitchChannel.normalize("bad-channel!").isEmpty());
assertTrue(TwitchChannel.normalize("a".repeat(TwitchChannel.MAX_NAME_LENGTH + 1)).isEmpty());
}
@Test
void buildsOnlyValidatedPlaybackUrls() {
assertEquals("https://www.twitch.tv/zeventfr", TwitchChannel.streamUrl("zeventfr"));
org.junit.jupiter.api.Assertions.assertThrows(IllegalArgumentException.class,
() -> TwitchChannel.streamUrl("bad-channel!"));
}
}