From 0ad9763666ead36bede21f010f2e4b8d5004d56d Mon Sep 17 00:00:00 2001 From: koka Date: Fri, 4 Sep 2026 01:53:19 +0200 Subject: [PATCH] Add standalone ZEVENT Twitch screen mod --- README.md | 5 +- gradle.properties | 2 + settings.gradle | 3 +- zevent/README.md | 19 ++ zevent/art/icon.svg | 19 ++ zevent/build.gradle | 14 ++ .../sanctuary26/zevent/ZeventMod.java | 30 +++ .../zevent/block/ZeventScreenBlock.java | 206 ++++++++++++++++++ .../zevent/block/ZeventScreenLayout.java | 54 +++++ .../block/entity/ZeventScreenBlockEntity.java | 75 +++++++ .../zevent/client/ZeventClient.java | 29 +++ .../client/gui/ZeventScreenOptionsScreen.java | 117 ++++++++++ .../ZeventScreenBlockEntityRenderer.java | 95 ++++++++ .../render/state/ZeventScreenRenderState.java | 10 + .../zevent/client/twitch/BrowserTexture.java | 34 +++ .../client/twitch/TwitchBrowserManager.java | 206 ++++++++++++++++++ .../client/twitch/TwitchPlayerPageServer.java | 108 +++++++++ .../network/OpenScreenOptionsPayload.java | 37 ++++ .../network/SaveScreenChannelPayload.java | 36 +++ .../zevent/network/ZeventNetworking.java | 57 +++++ .../zevent/registry/ZeventRegistries.java | 69 ++++++ .../zevent/twitch/TwitchChannel.java | 58 +++++ .../zevent/blockstates/zevent_screen.json | 8 + .../src/main/resources/assets/zevent/icon.png | Bin 0 -> 21774 bytes .../assets/zevent/items/zevent_screen.json | 6 + .../resources/assets/zevent/lang/en_us.json | 17 ++ .../resources/assets/zevent/lang/fr_fr.json | 17 ++ .../resources/assets/zevent/lang/ru_ru.json | 17 ++ .../zevent/models/block/zevent_screen.json | 21 ++ .../loot_table/blocks/zevent_screen.json | 20 ++ .../data/zevent/recipe/zevent_screen.json | 19 ++ zevent/src/main/resources/fabric.mod.json | 44 ++++ .../twitch/TwitchPlayerPageServerTest.java | 30 +++ .../zevent/twitch/TwitchChannelTest.java | 22 ++ 34 files changed, 1501 insertions(+), 3 deletions(-) create mode 100644 zevent/README.md create mode 100644 zevent/art/icon.svg create mode 100644 zevent/build.gradle create mode 100644 zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/ZeventMod.java create mode 100644 zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/block/ZeventScreenBlock.java create mode 100644 zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/block/ZeventScreenLayout.java create mode 100644 zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/block/entity/ZeventScreenBlockEntity.java create mode 100644 zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/client/ZeventClient.java create mode 100644 zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/client/gui/ZeventScreenOptionsScreen.java create mode 100644 zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/client/render/ZeventScreenBlockEntityRenderer.java create mode 100644 zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/client/render/state/ZeventScreenRenderState.java create mode 100644 zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/client/twitch/BrowserTexture.java create mode 100644 zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/client/twitch/TwitchBrowserManager.java create mode 100644 zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/client/twitch/TwitchPlayerPageServer.java create mode 100644 zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/network/OpenScreenOptionsPayload.java create mode 100644 zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/network/SaveScreenChannelPayload.java create mode 100644 zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/network/ZeventNetworking.java create mode 100644 zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/registry/ZeventRegistries.java create mode 100644 zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/twitch/TwitchChannel.java create mode 100644 zevent/src/main/resources/assets/zevent/blockstates/zevent_screen.json create mode 100644 zevent/src/main/resources/assets/zevent/icon.png create mode 100644 zevent/src/main/resources/assets/zevent/items/zevent_screen.json create mode 100644 zevent/src/main/resources/assets/zevent/lang/en_us.json create mode 100644 zevent/src/main/resources/assets/zevent/lang/fr_fr.json create mode 100644 zevent/src/main/resources/assets/zevent/lang/ru_ru.json create mode 100644 zevent/src/main/resources/assets/zevent/models/block/zevent_screen.json create mode 100644 zevent/src/main/resources/data/zevent/loot_table/blocks/zevent_screen.json create mode 100644 zevent/src/main/resources/data/zevent/recipe/zevent_screen.json create mode 100644 zevent/src/main/resources/fabric.mod.json create mode 100644 zevent/src/test/java/fr/koka99cab/sanctuary26/zevent/client/twitch/TwitchPlayerPageServerTest.java create mode 100644 zevent/src/test/java/fr/koka99cab/sanctuary26/zevent/twitch/TwitchChannelTest.java diff --git a/README.md b/README.md index 5b1bd37..74c0935 100644 --- a/README.md +++ b/README.md @@ -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,6 +19,7 @@ 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. @@ -418,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 `_version` et `_lifecycle` n’activent 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 `_version` et `_lifecycle` n’activent et ne font avancer que le module concerné. Le contrat de distribution est dans `pack/release.json`. Le sous-projet `zevent` reste volontairement hors de `moduleIds` et des archives du pack : son JAR et sa dépendance cliente MCEF sont distribués séparément pour l’événement. La version globale du pack n’est pas injectée dans les JAR inchangés : leur contenu et leur numéro restent immuables. Le lanceur possède la composition exacte du pack, tandis que chaque manifest conserve la version effective de son module. diff --git a/gradle.properties b/gradle.properties index 4c5437d..4fa9d84 100644 --- a/gradle.properties +++ b/gradle.properties @@ -30,5 +30,7 @@ onlyfun_version=0.0.0-alpha.27 onlyfun_lifecycle=active sanctuary_version=0.0.0-alpha.139 sanctuary_lifecycle=active +zevent_version=0.0.0-alpha.1 +zevent_lifecycle=active pack_version=26.2.0-alpha.233 maven_group=fr.koka99cab.sanctuary26 diff --git a/settings.gradle b/settings.gradle index f7665ef..3628eb6 100644 --- a/settings.gradle +++ b/settings.gradle @@ -21,5 +21,6 @@ include( "onlyfun", "sanctuary", "events", - "masterkey" + "masterkey", + "zevent" ) diff --git a/zevent/README.md b/zevent/README.md new file mode 100644 index 0000000..92d6076 --- /dev/null +++ b/zevent/README.md @@ -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/). diff --git a/zevent/art/icon.svg b/zevent/art/icon.svg new file mode 100644 index 0000000..f0000f9 --- /dev/null +++ b/zevent/art/icon.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/zevent/build.gradle b/zevent/build.gradle new file mode 100644 index 0000000..389f73b --- /dev/null +++ b/zevent/build.gradle @@ -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() +} diff --git a/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/ZeventMod.java b/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/ZeventMod.java new file mode 100644 index 0000000..b5bc4e5 --- /dev/null +++ b/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/ZeventMod.java @@ -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); + } +} diff --git a/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/block/ZeventScreenBlock.java b/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/block/ZeventScreenBlock.java new file mode 100644 index 0000000..45a504e --- /dev/null +++ b/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/block/ZeventScreenBlock.java @@ -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 CODEC = simpleCodec(ZeventScreenBlock::new); + public static final EnumProperty 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 REMOVING_STRUCTURE = ThreadLocal.withInitial(() -> false); + private static final Map 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 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 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); + } + } +} diff --git a/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/block/ZeventScreenLayout.java b/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/block/ZeventScreenLayout.java new file mode 100644 index 0000000..2d2db38 --- /dev/null +++ b/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/block/ZeventScreenLayout.java @@ -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); + } + } +} diff --git a/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/block/entity/ZeventScreenBlockEntity.java b/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/block/entity/ZeventScreenBlockEntity.java new file mode 100644 index 0000000..fd259ca --- /dev/null +++ b/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/block/entity/ZeventScreenBlockEntity.java @@ -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 getUpdatePacket() { + return ClientboundBlockEntityDataPacket.create(this); + } + + @Override + public CompoundTag getUpdateTag(HolderLookup.Provider lookup) { + return saveWithoutMetadata(lookup); + } +} diff --git a/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/client/ZeventClient.java b/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/client/ZeventClient.java new file mode 100644 index 0000000..c7f5da6 --- /dev/null +++ b/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/client/ZeventClient.java @@ -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); + } +} diff --git a/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/client/gui/ZeventScreenOptionsScreen.java b/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/client/gui/ZeventScreenOptionsScreen.java new file mode 100644 index 0000000..c4c44ca --- /dev/null +++ b/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/client/gui/ZeventScreenOptionsScreen.java @@ -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; + } +} diff --git a/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/client/render/ZeventScreenBlockEntityRenderer.java b/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/client/render/ZeventScreenBlockEntityRenderer.java new file mode 100644 index 0000000..91beb4b --- /dev/null +++ b/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/client/render/ZeventScreenBlockEntityRenderer.java @@ -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 { + 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()); + } +} diff --git a/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/client/render/state/ZeventScreenRenderState.java b/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/client/render/state/ZeventScreenRenderState.java new file mode 100644 index 0000000..24d26d8 --- /dev/null +++ b/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/client/render/state/ZeventScreenRenderState.java @@ -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; +} diff --git a/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/client/twitch/BrowserTexture.java b/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/client/twitch/BrowserTexture.java new file mode 100644 index 0000000..f65fa5f --- /dev/null +++ b/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/client/twitch/BrowserTexture.java @@ -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. + } +} diff --git a/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/client/twitch/TwitchBrowserManager.java b/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/client/twitch/TwitchBrowserManager.java new file mode 100644 index 0000000..ba01911 --- /dev/null +++ b/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/client/twitch/TwitchBrowserManager.java @@ -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 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 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()); + } + } + } +} diff --git a/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/client/twitch/TwitchPlayerPageServer.java b/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/client/twitch/TwitchPlayerPageServer.java new file mode 100644 index 0000000..11f0466 --- /dev/null +++ b/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/client/twitch/TwitchPlayerPageServer.java @@ -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 """ + + + + + + + + +
+ + """.replace("__CHANNEL__", channel); + } +} diff --git a/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/network/OpenScreenOptionsPayload.java b/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/network/OpenScreenOptionsPayload.java new file mode 100644 index 0000000..4ada7e8 --- /dev/null +++ b/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/network/OpenScreenOptionsPayload.java @@ -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 TYPE = + new Type<>(ZeventMod.id("s2c/open_screen_options")); + public static final StreamCodec 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 type() { + return TYPE; + } +} diff --git a/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/network/SaveScreenChannelPayload.java b/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/network/SaveScreenChannelPayload.java new file mode 100644 index 0000000..09f2a29 --- /dev/null +++ b/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/network/SaveScreenChannelPayload.java @@ -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 TYPE = + new Type<>(ZeventMod.id("c2s/save_screen_channel")); + public static final StreamCodec 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 type() { + return TYPE; + } +} diff --git a/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/network/ZeventNetworking.java b/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/network/ZeventNetworking.java new file mode 100644 index 0000000..42a0a31 --- /dev/null +++ b/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/network/ZeventNetworking.java @@ -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")); + } +} diff --git a/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/registry/ZeventRegistries.java b/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/registry/ZeventRegistries.java new file mode 100644 index 0000000..22108bc --- /dev/null +++ b/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/registry/ZeventRegistries.java @@ -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 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 blockKey(Identifier id) { + return ResourceKey.create(Registries.BLOCK, id); + } + + private static ResourceKey itemKey(Identifier id) { + return ResourceKey.create(Registries.ITEM, id); + } +} diff --git a/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/twitch/TwitchChannel.java b/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/twitch/TwitchChannel.java new file mode 100644 index 0000000..df4659c --- /dev/null +++ b/zevent/src/main/java/fr/koka99cab/sanctuary26/zevent/twitch/TwitchChannel.java @@ -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 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 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(); + } +} diff --git a/zevent/src/main/resources/assets/zevent/blockstates/zevent_screen.json b/zevent/src/main/resources/assets/zevent/blockstates/zevent_screen.json new file mode 100644 index 0000000..82ea0e4 --- /dev/null +++ b/zevent/src/main/resources/assets/zevent/blockstates/zevent_screen.json @@ -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 } } + ] +} diff --git a/zevent/src/main/resources/assets/zevent/icon.png b/zevent/src/main/resources/assets/zevent/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..bb44706921d189270a5748df2b219542d8e4902c GIT binary patch literal 21774 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 literal 0 HcmV?d00001 diff --git a/zevent/src/main/resources/assets/zevent/items/zevent_screen.json b/zevent/src/main/resources/assets/zevent/items/zevent_screen.json new file mode 100644 index 0000000..0ef0d1a --- /dev/null +++ b/zevent/src/main/resources/assets/zevent/items/zevent_screen.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "zevent:block/zevent_screen" + } +} diff --git a/zevent/src/main/resources/assets/zevent/lang/en_us.json b/zevent/src/main/resources/assets/zevent/lang/en_us.json new file mode 100644 index 0000000..af11973 --- /dev/null +++ b/zevent/src/main/resources/assets/zevent/lang/en_us.json @@ -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 (1–25 characters: a-z, 0-9, _).", + "screen.zevent.options.saved": "Twitch channel updated.", + "message.zevent.screen.too_far": "You are too far away from the screen.", + "message.zevent.screen.missing": "The ZEVENT screen no longer exists.", + "message.zevent.screen.newer_data": "This screen uses newer data and is read-only.", + "message.zevent.player.initializing": "Initializing the Twitch player…" +} diff --git a/zevent/src/main/resources/assets/zevent/lang/fr_fr.json b/zevent/src/main/resources/assets/zevent/lang/fr_fr.json new file mode 100644 index 0000000..7c0a796 --- /dev/null +++ b/zevent/src/main/resources/assets/zevent/lang/fr_fr.json @@ -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 (1–25 caractères : a-z, 0-9, _).", + "screen.zevent.options.saved": "Chaîne Twitch mise à jour.", + "message.zevent.screen.too_far": "Tu es trop loin de l'écran.", + "message.zevent.screen.missing": "L'écran ZEVENT n'existe plus.", + "message.zevent.screen.newer_data": "Cet écran utilise des données plus récentes et reste en lecture seule.", + "message.zevent.player.initializing": "Initialisation du lecteur Twitch…" +} diff --git a/zevent/src/main/resources/assets/zevent/lang/ru_ru.json b/zevent/src/main/resources/assets/zevent/lang/ru_ru.json new file mode 100644 index 0000000..304f8c7 --- /dev/null +++ b/zevent/src/main/resources/assets/zevent/lang/ru_ru.json @@ -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…" +} diff --git a/zevent/src/main/resources/assets/zevent/models/block/zevent_screen.json b/zevent/src/main/resources/assets/zevent/models/block/zevent_screen.json new file mode 100644 index 0000000..2ccf859 --- /dev/null +++ b/zevent/src/main/resources/assets/zevent/models/block/zevent_screen.json @@ -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" } + } + } + ] +} diff --git a/zevent/src/main/resources/data/zevent/loot_table/blocks/zevent_screen.json b/zevent/src/main/resources/data/zevent/loot_table/blocks/zevent_screen.json new file mode 100644 index 0000000..25681e1 --- /dev/null +++ b/zevent/src/main/resources/data/zevent/loot_table/blocks/zevent_screen.json @@ -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" +} diff --git a/zevent/src/main/resources/data/zevent/recipe/zevent_screen.json b/zevent/src/main/resources/data/zevent/recipe/zevent_screen.json new file mode 100644 index 0000000..f688811 --- /dev/null +++ b/zevent/src/main/resources/data/zevent/recipe/zevent_screen.json @@ -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 + } +} diff --git a/zevent/src/main/resources/fabric.mod.json b/zevent/src/main/resources/fabric.mod.json new file mode 100644 index 0000000..f3cfd72 --- /dev/null +++ b/zevent/src/main/resources/fabric.mod.json @@ -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." + } + } + } +} diff --git a/zevent/src/test/java/fr/koka99cab/sanctuary26/zevent/client/twitch/TwitchPlayerPageServerTest.java b/zevent/src/test/java/fr/koka99cab/sanctuary26/zevent/client/twitch/TwitchPlayerPageServerTest.java new file mode 100644 index 0000000..e7980c5 --- /dev/null +++ b/zevent/src/test/java/fr/koka99cab/sanctuary26/zevent/client/twitch/TwitchPlayerPageServerTest.java @@ -0,0 +1,30 @@ +package fr.koka99cab.sanctuary26.zevent.client.twitch; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import org.junit.jupiter.api.Test; + +class TwitchPlayerPageServerTest { + @Test + void servesOnlyTheBoundedOfficialPlayerPage() throws Exception { + TwitchPlayerPageServer server = new TwitchPlayerPageServer(); + try (HttpClient client = HttpClient.newHttpClient()) { + String url = server.playerUrl("zeventfr"); + HttpResponse response = client.send( + HttpRequest.newBuilder(URI.create(url)).GET().build(), HttpResponse.BodyHandlers.ofString()); + assertEquals(200, response.statusCode()); + assertTrue(response.body().contains("https://player.twitch.tv/js/embed/v1.js")); + assertTrue(response.body().contains("const initialChannel=\"zeventfr\"")); + assertTrue(response.headers().firstValue("Content-Security-Policy").isPresent()); + assertThrows(IllegalArgumentException.class, () -> server.playerUrl("bad-channel!")); + } finally { + server.stop(); + } + } +} diff --git a/zevent/src/test/java/fr/koka99cab/sanctuary26/zevent/twitch/TwitchChannelTest.java b/zevent/src/test/java/fr/koka99cab/sanctuary26/zevent/twitch/TwitchChannelTest.java new file mode 100644 index 0000000..f6b4d09 --- /dev/null +++ b/zevent/src/test/java/fr/koka99cab/sanctuary26/zevent/twitch/TwitchChannelTest.java @@ -0,0 +1,22 @@ +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()); + } +} -- 2.54.0