feat(zevent): ajouter l’écran Twitch temporaire du week-end #159

Merged
koka merged 1 commits from feature/zevent-screen into main 2026-09-04 00:10:51 +00:00
34 changed files with 1501 additions and 3 deletions
+3 -2
View File
@@ -1,6 +1,6 @@
# Sanctuary 26.2
Workspace Fabric multi-projet de Sanctuary 2.0. Il construit dix JAR indépendants sur Minecraft 26.2, Fabric Loader 0.19.3, Fabric API 0.159.0+26.2, Loom 1.17, Gradle 9.5.1 et Java 25.
Workspace Fabric multi-projet de Sanctuary 2.0. Il construit les dix JAR du pack et le mod événementiel ZEVENT distribué à part, sur Minecraft 26.2, Fabric Loader 0.19.3, Fabric API 0.159.0+26.2, Loom 1.17, Gradle 9.5.1 et Java 25.
Auteur : **KOKA99CAB**
Licence : **GNU GPL 3.0 or Later** (`GPL-3.0-or-later`)
@@ -19,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 `<module>_version` et `<module>_lifecycle` nactivent et ne font avancer que le module concerné. Le contrat de distribution est dans `pack/release.json`.
`pack_version` dans `gradle.properties` est la version du pack complet. `mod_version=0.0.0-alpha.0` et `module_lifecycle=standby` sont les valeurs par défaut des modules en veille ; les propriétés `<module>_version` et `<module>_lifecycle` nactivent et ne font avancer que le module concerné. Le contrat de distribution est dans `pack/release.json`. Le sous-projet `zevent` reste volontairement hors de `moduleIds` et des archives du pack : son JAR et sa dépendance cliente MCEF sont distribués séparément pour l’événement.
La version globale du pack nest pas injectée dans les JAR inchangés : leur contenu et leur numéro restent immuables. Le lanceur possède la composition exacte du pack, tandis que chaque manifest conserve la version effective de son module.
+2
View File
@@ -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
+2 -1
View File
@@ -21,5 +21,6 @@ include(
"onlyfun",
"sanctuary",
"events",
"masterkey"
"masterkey",
"zevent"
)
+19
View File
@@ -0,0 +1,19 @@
# ZEVENT
Mod Fabric événementiel distribué séparément du pack Sanctuary. Il ajoute un écran mural multibloc de 9×5 blocs qui lit une chaîne Twitch en direct.
## Utilisation
1. Poser l'écran contre un mur libre de 9×5 blocs. Le bloc visé devient le centre de l'écran.
2. Faire un clic droit sur n'importe laquelle de ses 45 cases.
3. Saisir un nom de chaîne (`zeventfr`) ou coller une URL Twitch, puis valider.
La chaîne est enregistrée par le serveur et synchronisée aux joueurs. Le client rend le lecteur Twitch officiel dans la surface. Son volume utilise la catégorie **Disques** de Minecraft et diminue jusqu'au silence à 64 blocs. Seul l'écran le plus proche est audible et deux flux visibles au maximum sont décodés par client.
## Dépendance et limites
Le client exige [MCEF Modern](https://modrinth.com/mod/mcef-modern) `0.3.3+mc26.2.jcef146.0.10`. MCEF télécharge son runtime Chromium natif lors de la première utilisation ; une connexion Internet est donc nécessaire. Le lecteur utilise le backend OpenGL pris en charge par cette version de MCEF.
Le serveur ne reçoit jamais d'URL arbitraire : les entrées sont ramenées à un identifiant Twitch de 1 à 25 caractères (`a-z`, `0-9`, `_`). La page locale est liée uniquement à l'interface loopback et n'expose que le lecteur officiel `player.twitch.tv`.
Documentation du lecteur : [Twitch Embedded Video & Clips](https://dev.twitch.tv/docs/embed/video-and-clips/).
+19
View File
@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<defs>
<linearGradient id="screen" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#18221d"/>
<stop offset="1" stop-color="#060807"/>
</linearGradient>
<linearGradient id="live" x1="0" y1="1" x2="1" y2="0">
<stop offset="0" stop-color="#1bdc7a"/>
<stop offset="1" stop-color="#b7ff39"/>
</linearGradient>
</defs>
<rect width="1024" height="1024" rx="176" fill="#090d0b"/>
<rect x="94" y="168" width="836" height="520" rx="54" fill="url(#screen)" stroke="url(#live)" stroke-width="28"/>
<path d="M364 300h330L458 556h252" fill="none" stroke="#f5fff8" stroke-width="74" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M292 807h440" stroke="#314139" stroke-width="42" stroke-linecap="round"/>
<path d="M512 688v119" stroke="#314139" stroke-width="42"/>
<circle cx="829" cy="258" r="42" fill="#ff405f"/>
<circle cx="829" cy="258" r="77" fill="none" stroke="#ff405f" stroke-width="14" opacity=".32"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

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

After

Width:  |  Height:  |  Size: 21 KiB

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