feat(zevent): ajouter l’écran Twitch temporaire du week-end #159
@@ -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` 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 `<module>_version` et `<module>_lifecycle` n’activent et ne font avancer que le module concerné. Le contrat de distribution est dans `pack/release.json`. Le sous-projet `zevent` reste volontairement hors de `moduleIds` et des archives du pack : son JAR et sa dépendance cliente MCEF sont distribués séparément pour l’événement.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -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
@@ -21,5 +21,6 @@ include(
|
||||
"onlyfun",
|
||||
"sanctuary",
|
||||
"events",
|
||||
"masterkey"
|
||||
"masterkey",
|
||||
"zevent"
|
||||
)
|
||||
|
||||
@@ -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/).
|
||||
@@ -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 |
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+75
@@ -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);
|
||||
}
|
||||
}
|
||||
+117
@@ -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;
|
||||
}
|
||||
}
|
||||
+95
@@ -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());
|
||||
}
|
||||
}
|
||||
+10
@@ -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;
|
||||
}
|
||||
+34
@@ -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.
|
||||
}
|
||||
}
|
||||
+206
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+108
@@ -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);
|
||||
}
|
||||
}
|
||||
+37
@@ -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;
|
||||
}
|
||||
}
|
||||
+36
@@ -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 (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…"
|
||||
}
|
||||
@@ -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…"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user