From 4ec5b0d028a23a9f753770988ffba61015e2e49a Mon Sep 17 00:00:00 2001 From: koka Date: Thu, 17 Sep 2026 16:35:46 +0200 Subject: [PATCH] Add sculpture corner and height placement, voxel chips and discovered workshop models --- README.md | 18 +-- docs/sculpture-placement-beta125.md | 94 ++++++++++++++ gradle.properties | 4 +- mods/sanctuary/build.gradle | 4 + .../client/Sculpture113ClientChecks.java | 12 +- .../client/Sculpture122ClientChecks.java | 6 +- .../client/Sculpture125ClientChecks.java | 118 ++++++++++++++++++ .../client/Sculpture125ParticleChecks.java | 36 ++++++ .../client/Sculpture125WorkshopChecks.java | 53 ++++++++ .../sanctuary/client/ClayWorkshopScreen.java | 38 +++++- .../sanctuary/client/SculptureParticles.java | 70 +++++++++++ .../sanctuary/client/StatuaryRenderer.java | 16 +-- .../mixin/client/SculptureParticlesMixin.java | 26 ++++ .../sanctuary/plans/ClayWorkshopMenu.java | 9 +- .../koka/sanctuary/plans/SculptureAnchor.java | 28 +++-- .../sanctuary/plans/SculptureGeometry.java | 17 +-- .../koka/sanctuary/plans/SculptureHeight.java | 19 +++ .../fr/koka/sanctuary/plans/Statuary.java | 11 +- .../koka/sanctuary/plans/StatuaryEntity.java | 2 +- .../sanctuary/plans/StatueDiscoveries.java | 10 ++ .../assets/minecraft/atlases/blocks.json | 5 + .../assets/sanctuary/lang/en_us.json | 6 +- .../assets/sanctuary/lang/fr_fr.json | 6 +- .../src/main/resources/sanctuary.mixins.json | 3 +- packwiz/pack.toml | 2 +- 25 files changed, 548 insertions(+), 65 deletions(-) create mode 100644 docs/sculpture-placement-beta125.md create mode 100644 mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/Sculpture125ClientChecks.java create mode 100644 mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/Sculpture125ParticleChecks.java create mode 100644 mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/Sculpture125WorkshopChecks.java create mode 100644 mods/sanctuary/src/main/java/fr/koka/sanctuary/client/SculptureParticles.java create mode 100644 mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/client/SculptureParticlesMixin.java create mode 100644 mods/sanctuary/src/main/java/fr/koka/sanctuary/plans/SculptureHeight.java create mode 100644 mods/sanctuary/src/main/resources/assets/minecraft/atlases/blocks.json diff --git a/README.md b/README.md index 4272b10..a840dbc 100644 --- a/README.md +++ b/README.md @@ -20,13 +20,11 @@ La [vision complète](docs/vision.md) conserve les intentions ; le L’économie, les claims et les dimensions décrits dans la vision restent à implémenter. -La version **beta.124** utilise une seule boîte ajustée au volume de chaque -sculpture, commune à la sélection et aux collisions. Les détails ne créent plus -de petites marches ; l'orientation, l'ancrage et la lumière restent cohérents. -Le [ticket de livraison](docs/sculpture-bounds-beta124.md) détaille le calcul -mis en cache et les vérifications en jeu et archives réussies. La release est -publiée et l’instance **Sanctuary Beta** est synchronisée, sauvegardes et réglages -conservés. Les packs sont disponibles dans le dossier habituel `build/`. +La version **beta.125** étend les sculptures au centre, aux bords et aux coins, +en bas, au milieu ou en haut du bloc. Les éclats reprennent la couleur des voxels +et le Clay Workshop propose les animaux déjà découverts, en plus des imports GLB. +Le [ticket de livraison](docs/sculpture-placement-beta125.md) décrit le placement, +la compatibilité des anciennes sculptures et les vérifications. La [livraison précédente](docs/clay-workshop-integration-beta110.md) réunit déjà l’atelier, les œufs et la neige saisonnière dans l’instance **Sanctuary Beta**. Les [synchronisations antérieures](docs/git-sync-beta092.md) restent documentées. @@ -37,6 +35,12 @@ La [carte native beta.005](docs/atlas-beta005.md) prend le relais après l’étude de Xaero et des alternatives libres. +## beta.125 — Sculptures : placement, éclats et animaux + +Coins et trois hauteurs sur la grille 1/16 ; particules issues des voxels ; +modèles découverts disponibles dans le Clay Workshop. +[Contrat et vérifications](docs/sculpture-placement-beta125.md). + ## beta.124 — Une boîte par sculpture Sélection et collisions simplifiées, dimensions ajustées et lumière conservée. diff --git a/docs/sculpture-placement-beta125.md b/docs/sculpture-placement-beta125.md new file mode 100644 index 0000000..0a2c95d --- /dev/null +++ b/docs/sculpture-placement-beta125.md @@ -0,0 +1,94 @@ +# SCULPT-11 — Placement, éclats et modèles découverts — beta.125 + +Contrat du 17 septembre 2026, branche `codex/sculpture-placement-beta125`. + +Le point visé choisit neuf positions horizontales (centre, quatre bords et quatre +coins) et trois hauteurs (bas, milieu et haut). Sur un support, la sculpture +repose au bas de son bloc ; sous un plafond, elle touche le haut ; sur une face +latérale, elle suit la hauteur visée et reste contre le support. Les sculptures +restent debout et s'orientent selon le regard horizontal du joueur. +La bande centrale occupe les huit pixels médians, limites incluses. Toute pose +reste sur la grille 1/16 : une dimension impaire utilise le centre entier le +plus proche, jamais un demi-voxel. Rendu, boîte unique de sélection et collision +partagent les mêmes offsets. La lumière conserve les règles de beta.122. + +## Compatibilité additive des états existants + +Les cinq anciennes valeurs `anchor` gardent exactement leurs noms. Quatre +valeurs de coin sont ajoutées. La nouvelle propriété `vertical_anchor` accepte +`bottom`, `center`, `top` et `original`, valeur par défaut réservée à la +compatibilité. La lecture d'un ancien bloc sans cette propriété conserve +l'ancienne hauteur, même si le modèle a des marges transparentes en bas. +Le bloc possède 144 états : 108 placements nouveaux et 36 états de compatibilité. + +Le schéma 1 des sculptures, leurs données de voxels et tous leurs identifiants +restent inchangés. Aucun monde n'est parcouru ou réécrit, aucun chunk régénéré. +Les nouveaux ancrages nécessitent beta.125 ; le retour à une version antérieure +ne garantit pas leur position. Ce contrat autorise l'ajout compatible des états, +pas une migration destructive des sauvegardes personnelles. + +## Particules + +Les coups et la casse émettent des éclats de la couleur des surfaces voxelisées +réellement affichées, avec leur orientation et leur ancrage. Un coup vise la +surface réelle la plus proche du point sélectionné, même devant un trou visuel. +La casse échantillonne au plus 64 faces ; aucun calcul de texture n'est refait. +Les éclats utilisent une taille de voxel, la gravité et la lumière natives. +Une petite mémoire par monde (128 entrées de 64 faces, validité 40 ticks) conserve les +surfaces si la suppression du bloc précède l'événement de casse. Les autres +blocs gardent leurs particules habituelles. + +## Clay Workshop + +L'onglet Animaux utilise exactement les découvertes serveur du catalogue de +statues (rencontre, victoire ou mort face à l'espèce). Le menu transmet cette +liste à chaque ouverture, sans dépendre d'une visite préalable au Métabli. +Le modèle natif et sa texture sont capturés localement, voxelisés en miniature, +puis recolorés par la matière choisie. Le résultat reste fabriqué et payé côté +serveur. Les sélections d'espèces non découvertes sont refusées. L'onglet Imports +conserve les fichiers GLB. Aucun nouveau format de sculpture n'est introduit. +Libellés FR et EN. + +## Vérifications + +Le parcours natif `Sculpture125ClientChecks` passe en **1 min 2 s**, client +Minecraft 26.3 sur macOS et serveur intégré jetable (graines 125 et 122). + +- 54 clics réels sur les six faces : centres, bords, coins et hauteurs. +- 432 contextes de pose, coordonnées positives/négatives, quatre orientations, + rotations et miroirs ; 144 états valides. +- 20 anciens états sans `vertical_anchor` conservent leur hauteur et marges. +- Couleur exacte des éclats au point frappé, surfaces réellement occupées, + limite de 64 fragments, événement après suppression et particules normales + d'un bloc de pierre témoin. +- Atelier : découvertes par rencontre, victoire et mort, transmission native + du menu, refus serveur d'une espèce inconnue, trois modèles natifs (vache, + mouton, cochon), paiement d'un bloc en survie et import GLB conservé. +- Sauvegarde et reconnexion avec les nouveaux coins et hauteurs. +- 576 formes comparées aux limites du maillage affiché, boîte unique partagée, + cache réutilisé et 12 arêtes ; 9 216 empreintes orientées sur grille entière. +- 75 sculptures superposées : ciel à 15, lumière de bloc à 13 après traversée, + pièce noire à 0. Pose dans le joueur refusée, même devant un trou visuel. + +Marqueurs dans `build/sculpture125-client.log`. Captures de plafond, murs et +atelier relues dans `build/sculpture125-evidence/`. Aucun essai Windows, +client distant ou shader tiers ; trois espèces vérifiées visuellement, sans +prétendre avoir inspecté chaque espèce. Aucun benchmark FPS global. + +Le GameTest dédié demeure exclu conformément au refus antérieur de son EULA. +Les mondes de test sont jetables ; aucun monde personnel n'est ouvert. + +`./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest` +passe en **2 min 16 s**, 126 tâches (110 exécutées, 16 à jour). Les sources +des JAR correspondent exactement au dépôt. La comparaison avec beta.124 +limite les différences aux classes de placement, particules et atelier prévues, +aux nouveaux libellés FR/EN, au mixin client et à la source blanche de particules. +Les packs normal/Test partagent le même JAR, sans monde ni fixture ; le template +reproduit les ressources du mod et du pack intégré. + +- Sanctuary-beta.125.mrpack : 10286360 octets, SHA-256 `9277e59c640c40392ab3750b402bb840020884878f6ece6978e6c4653e18655e`. +- Sanctuary-Test-beta.125.mrpack : 10305282 octets, SHA-256 `9e3dbfa14b5382ba4eb62a78fa7d2161758c2f0e1fdc50fbd5ed776fd4ce32c5`. +- JAR Sanctuary : SHA-256 `b38b75b900dd8cbe015318c739d5547fd7eac52f4337fa517e98f3f6f0ce3b46`. + +Reçu local : `build/sculpture125-artifact.json`. Les deux packs et le template +sont copiés et vérifiés dans le dossier habituel `sanctuary-beta/build/`. diff --git a/gradle.properties b/gradle.properties index 793edeb..4525f4c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -9,8 +9,8 @@ loom_version=1.17.20 fabric_api_version=0.160.5+26.3 # Release counter: beta.001, .002, .003, ... (see docs/versioning.md). -mod_version=beta.124 -pack_version=beta.124 +mod_version=beta.125 +pack_version=beta.125 resource_pack_version=beta.121 maven_group=fr.koka.sanctuary jei_version=30.32.0-sanctuary.3 diff --git a/mods/sanctuary/build.gradle b/mods/sanctuary/build.gradle index 72261a7..c0ee4bd 100644 --- a/mods/sanctuary/build.gradle +++ b/mods/sanctuary/build.gradle @@ -93,6 +93,10 @@ tasks.named('runGameTest') { } tasks.named('processGametestResources') { + def sculpture125Client = providers.gradleProperty('sanctuarySculpture125ClientTests').map { it.toBoolean() }.getOrElse(false) + inputs.property('sanctuarySculpture125ClientTests', sculpture125Client) + if (sculpture125Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Sculpture125ClientChecks') } + def wrench123Client = providers.gradleProperty('sanctuaryWrench123ClientTests').map { it.toBoolean() }.getOrElse(false) inputs.property('sanctuaryWrench123ClientTests', wrench123Client) if (wrench123Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Wrench123ClientChecks') } diff --git a/mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/Sculpture113ClientChecks.java b/mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/Sculpture113ClientChecks.java index 15b2f58..d4c3dfe 100644 --- a/mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/Sculpture113ClientChecks.java +++ b/mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/Sculpture113ClientChecks.java @@ -41,7 +41,7 @@ final class Sculpture113ClientChecks { check(Statuary.BLOCK.getStateForPlacement(context).getValue(Statuary.ANCHOR)==SculptureAnchor.of(edge.getOpposite()),"Side hit anchors against supporting block"); } } - for(var facing:Direction.Plane.HORIZONTAL)for(var anchor:SculptureAnchor.values()){ + for(var facing:Direction.Plane.HORIZONTAL)for(var anchor:List.of(SculptureAnchor.CENTER,SculptureAnchor.NORTH,SculptureAnchor.SOUTH,SculptureAnchor.EAST,SculptureAnchor.WEST)){ var state=Statuary.BLOCK.defaultBlockState().setValue(Statuary.FACING,facing).setValue(Statuary.ANCHOR,anchor); for(var rotation:Rotation.values()){ var actual=state.rotate(rotation); @@ -74,7 +74,7 @@ final class Sculpture113ClientChecks { } var itemMesh=new StatuaryRenderer.ItemRenderer().extractArgument(StatuaryEntity.item(sculpture)); check(itemMesh.equals(mesh(entity(client,sculpture,Direction.SOUTH,SculptureAnchor.CENTER))),"Item preview shares exact centered grid geometry"); - System.out.println("SCULPTURE113_GEOMETRY_PASS 5120 footprints/orientations/anchors and colored direction markers"); + System.out.println("SCULPTURE113_GEOMETRY_PASS 9216 footprints/orientations/anchors and colored direction markers"); } private static StatuaryEntity entity(Minecraft client,VoxelSculpture sculpture,Direction facing,SculptureAnchor anchor){ var entity=new StatuaryEntity(BlockPos.ZERO,Statuary.BLOCK.defaultBlockState().setValue(Statuary.FACING,facing).setValue(Statuary.ANCHOR,anchor));entity.setLevel(client.level); @@ -94,10 +94,12 @@ final class Sculpture113ClientChecks { var anchor=entity.getBlockState().getValue(Statuary.ANCHOR); switch(anchor){ case NORTH -> check(minZ==0,"North edge flush");case SOUTH -> check(maxZ==1,"South edge flush"); - case WEST -> check(minX==0,"West edge flush");case EAST -> check(maxX==1,"East edge flush");default -> {} + case WEST -> check(minX==0,"West edge flush");case EAST -> check(maxX==1,"East edge flush"); + case NORTH_WEST -> check(minX==0&&minZ==0,"North-west corner flush");case NORTH_EAST -> check(maxX==1&&minZ==0,"North-east corner flush"); + case SOUTH_WEST -> check(minX==0&&maxZ==1,"South-west corner flush");case SOUTH_EAST -> check(maxX==1&&maxZ==1,"South-east corner flush");default -> {} } - if(anchor!=SculptureAnchor.WEST&&anchor!=SculptureAnchor.EAST)check(Math.abs(minX-(1-maxX))<=1/16f,"Other axis centered to nearest whole voxel"); - if(anchor!=SculptureAnchor.NORTH&&anchor!=SculptureAnchor.SOUTH)check(Math.abs(minZ-(1-maxZ))<=1/16f,"Other axis centered to nearest whole voxel"); + if(anchor==SculptureAnchor.CENTER||anchor==SculptureAnchor.NORTH||anchor==SculptureAnchor.SOUTH)check(Math.abs(minX-(1-maxX))<=1/16f,"Other axis centered to nearest whole voxel"); + if(anchor==SculptureAnchor.CENTER||anchor==SculptureAnchor.EAST||anchor==SculptureAnchor.WEST)check(Math.abs(minZ-(1-maxZ))<=1/16f,"Other axis centered to nearest whole voxel"); } private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);} } diff --git a/mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/Sculpture122ClientChecks.java b/mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/Sculpture122ClientChecks.java index 5959334..21175c9 100644 --- a/mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/Sculpture122ClientChecks.java +++ b/mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/Sculpture122ClientChecks.java @@ -135,8 +135,8 @@ public final class Sculpture122ClientChecks implements FabricClientGameTest { var padded=new VoxelSculpture(MODEL,"Padded",13,5,15,List.of(new VoxelSculpture.Cell(2,2,3,-1),new VoxelSculpture.Cell(6,4,10,-1))); var dense=new ArrayList();for(int x=0;x<16;x++)for(int y=0;y<16;y++)for(int z=0;z<16;z++)dense.add(new VoxelSculpture.Cell(x,y,z,-1)); var models=List.of(ARCH,padded,new VoxelSculpture(MODEL,"Dense",16,16,16,dense),new VoxelSculpture(MODEL,"One voxel",1,1,1,List.of(new VoxelSculpture.Cell(0,0,0,-1)))); - int cases=0;for(var model:models)for(var direction:Direction.Plane.HORIZONTAL)for(var anchor:SculptureAnchor.values()){ - var state=Statuary.BLOCK.defaultBlockState().setValue(Statuary.FACING,direction).setValue(Statuary.ANCHOR,anchor);var entity=new StatuaryEntity(BlockPos.ZERO,state);entity.setLevel(m.level);load(entity,model,m.level); + int cases=0;for(var model:models)for(var direction:Direction.Plane.HORIZONTAL)for(var anchor:SculptureAnchor.values())for(var height:SculptureHeight.values()){ + var state=Statuary.BLOCK.defaultBlockState().setValue(Statuary.FACING,direction).setValue(Statuary.ANCHOR,anchor).setValue(Statuary.HEIGHT,height);var entity=new StatuaryEntity(BlockPos.ZERO,state);entity.setLevel(m.level);load(entity,model,m.level); var shape=entity.shape(state);check(shape==entity.shape(state),"Repeated geometry queries reuse the same shape");var boxes=shape.toAabbs();double volume=boxes.stream().mapToDouble(b->b.getXsize()*b.getYsize()*b.getZsize()).sum();check(boxes.size()==1&&volume>=model.cells().size()/4096d,"One box encloses all occupied cells"); var outline=entity.shape(state);check(outline==entity.shape(state),"Repeated outline queries reuse the cached box"); check(outline.toAabbs().size()==1&&outline.bounds().equals(shape.bounds()),"One outline box matches exact rendered extents for every orientation and anchor"); @@ -149,7 +149,7 @@ public final class Sculpture122ClientChecks implements FabricClientGameTest { cases++; } System.out.println("SCULPTURE124_BOUNDS_PASS "+cases+" cached single boxes agree with exact renderer extents; 12 outline edges each"); - System.out.println("SCULPTURE122_GEOMETRY_PASS "+cases+" native bounding boxes agree with emitted mesh extents, plus cached 12-edge selection bounds for holes, padding, dense 4096 cells, single voxel, 4 directions and 5 anchors"); + System.out.println("SCULPTURE122_GEOMETRY_PASS "+cases+" native bounding boxes agree with emitted mesh extents, plus cached 12-edge selection bounds for holes, padding, dense 4096 cells, single voxel, 4 directions, 9 anchors and 4 vertical modes"); } private static void place(ServerLevel level,BlockPos pos,VoxelSculpture model,Direction facing,SculptureAnchor anchor){ var state=Statuary.BLOCK.defaultBlockState().setValue(Statuary.FACING,facing).setValue(Statuary.ANCHOR,anchor);level.setBlockAndUpdate(pos,state);var entity=(StatuaryEntity)level.getBlockEntity(pos);load(entity,model,level);entity.setChanged();level.sendBlockUpdated(pos,state,state,3); diff --git a/mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/Sculpture125ClientChecks.java b/mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/Sculpture125ClientChecks.java new file mode 100644 index 0000000..130d491 --- /dev/null +++ b/mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/Sculpture125ClientChecks.java @@ -0,0 +1,118 @@ +package fr.koka.sanctuary.client; + +import com.mojang.blaze3d.platform.InputConstants; +import fr.koka.sanctuary.gametest.Starter039TestSupport; +import fr.koka.sanctuary.plans.*; +import java.util.*; +import java.util.concurrent.atomic.AtomicBoolean; +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; +import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest; +import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState; +import net.minecraft.client.input.KeyEvent; +import net.minecraft.core.*; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.nbt.*; +import net.minecraft.network.chat.contents.TranslatableContents; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.*; +import net.minecraft.util.ProblemReporter; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.item.context.BlockPlaceContext; +import net.minecraft.world.level.block.*; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.storage.TagValueInput; +import net.minecraft.world.phys.*; + +/** Native clicks on all six support faces; additive saved-state compatibility and real renderer bounds. */ +public final class Sculpture125ClientChecks implements FabricClientGameTest { + private static final BlockPos SUPPORT=new BlockPos(0,4,0); + private static final String MODEL="sanctuary:model/"+"2".repeat(64); + private static final VoxelSculpture STATUE=statue(); + private static final SculptureAnchor[][] ANCHORS={{SculptureAnchor.NORTH_WEST,SculptureAnchor.WEST,SculptureAnchor.SOUTH_WEST},{SculptureAnchor.NORTH,SculptureAnchor.CENTER,SculptureAnchor.SOUTH},{SculptureAnchor.NORTH_EAST,SculptureAnchor.EAST,SculptureAnchor.SOUTH_EAST}}; + private static final SculptureHeight[] HEIGHTS={SculptureHeight.BOTTOM,SculptureHeight.CENTER,SculptureHeight.TOP}; + private record Spot(Direction face,int u,int v){ + Vec3 hit(BlockPos p){double a=.1+.4*u,b=.1+.4*v;return switch(face){ + case UP,DOWN->new Vec3(p.getX()+a,p.getY()+(face==Direction.UP?1:0),p.getZ()+b); + case NORTH,SOUTH->new Vec3(p.getX()+a,p.getY()+b,p.getZ()+(face==Direction.SOUTH?1:0)); + case WEST,EAST->new Vec3(p.getX()+(face==Direction.EAST?1:0),p.getY()+b,p.getZ()+a); + };} + SculptureAnchor anchor(){return switch(face){case UP,DOWN->ANCHORS[u][v];case NORTH->ANCHORS[u][2];case SOUTH->ANCHORS[u][0];case EAST->ANCHORS[0][u];case WEST->ANCHORS[2][u];};} + SculptureHeight height(){return face==Direction.UP?SculptureHeight.BOTTOM:face==Direction.DOWN?SculptureHeight.TOP:HEIGHTS[v];} + } + public void runTest(ClientGameTestContext c){ + var intro=new AtomicBoolean(true); + c.runOnClient(m->{m.options.renderDistance().set(6);m.options.simulationDistance().set(5);m.options.pauseOnLostFocus=false;IntroClient.setQuickTestIntro(true);}); + ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen screen){ + Starter039TestSupport.selectFirst(screen);var enter=new KeyEvent(InputConstants.KEY_RETURN,0,0); + screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter); + screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter);intro.set(false); + }}); + net.fabricmc.fabric.api.client.gametest.v1.world.TestWorldSave saved; + var persistent=new HashMap(); + try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Sculpture125 "+UUID.randomUUID());s.setSeed("125");s.setAllowCommands(true);s.setGameMode(WorldCreationUiState.SelectedGameMode.CREATIVE);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()){ + c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);var server=world.getServer(); + server.runOnServer(s->{var p=player(s);var l=p.level();for(int x=-8;x<=8;x++)for(int z=-8;z<=8;z++)l.setBlockAndUpdate(new BlockPos(x,0,z),Blocks.STONE.defaultBlockState());p.getAbilities().flying=true;p.onUpdateAbilities();p.getInventory().setItem(p.getInventory().getSelectedSlot(),StatuaryEntity.item(STATUE));p.inventoryMenu.broadcastFullState(); + s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary time vanilla");s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"time set noon");s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary weather vanilla");s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"weather clear");contexts(p);legacy(p);Sculpture113ClientChecks.placementContexts(p);l.setBlockAndUpdate(SUPPORT,Blocks.STONE.defaultBlockState()); + }); + c.waitFor(m->m.player.getMainHandItem().is(Statuary.ITEM)&&m.level.getBlockState(SUPPORT).is(Blocks.STONE),150); + int clicks=0; + for(var face:Direction.values()){ + Vec3 eye= switch(face){case UP->new Vec3(.5,6,3);case DOWN->new Vec3(.5,1.3,2.5);case NORTH->new Vec3(.5,3,-2.2);case SOUTH->new Vec3(.5,3,3.2);case EAST->new Vec3(3.2,3,.5);case WEST->new Vec3(-2.2,3,.5);}; + server.runOnServer(s->{var p=player(s);p.teleportTo(eye.x,eye.y,eye.z);p.setDeltaMovement(Vec3.ZERO);p.getAbilities().flying=true;p.onUpdateAbilities();});c.waitFor(m->m.player.distanceToSqr(eye)<.05,100);c.runOnClient(m->{m.player.getAbilities().flying=true;m.player.setDeltaMovement(Vec3.ZERO);m.player.onUpdateAbilities();});c.waitTicks(10); + for(int u=0;u<3;u++)for(int v=0;v<3;v++){ + var spot=new Spot(face,u,v);var target=SUPPORT.relative(face); + server.runOnServer(s->{for(var direction:Direction.values())player(s).level().setBlockAndUpdate(SUPPORT.relative(direction),Blocks.AIR.defaultBlockState());}); + c.waitFor(m->m.level.getBlockState(target).isAir(),100); + c.runOnClient(m->m.player.lookAt(net.minecraft.commands.arguments.EntityAnchorArgument.Anchor.EYES,spot.hit(SUPPORT)));c.waitTicks(3); + c.runOnClient(m->check(m.hitResult instanceof BlockHitResult h&&h.getBlockPos().equals(SUPPORT)&&h.getDirection()==face,"Native cursor hits requested support face "+spot+" instead of "+m.hitResult+" at "+m.hitResult.getLocation()+" eye "+m.player.getEyePosition()+" flying "+m.player.getAbilities().flying)); + var facing=c.computeOnClient(m->{System.out.println("SCULPTURE125_AIM "+spot+" actual "+m.hitResult.getLocation()+" eye "+m.player.getEyePosition());return m.player.getDirection();});c.getInput().holdKey(o->o.keyUse);c.waitTicks(1);c.getInput().releaseKey(o->o.keyUse);c.waitTicks(3); + c.waitFor(m->m.level.getBlockEntity(target) instanceof StatuaryEntity e&&STATUE.equals(e.sculpture()),100); + server.runOnServer(s->{var l=player(s).level();var state=l.getBlockState(target);check(state.getValue(Statuary.ANCHOR)==spot.anchor()&&state.getValue(Statuary.HEIGHT)==spot.height()&&state.getValue(Statuary.FACING)==facing,"Server uses aimed position and horizontal gaze for "+spot+" got "+state+" expected facing "+facing);}); + c.runOnClient(m->{var e=(StatuaryEntity)m.level.getBlockEntity(target);renderBounds(e);}); + if(u==0&&v==2)c.takeScreenshot("sculpture125-"+face.getSerializedName());clicks++; + } + } + System.out.println("SCULPTURE125_CLICKS_PASS "+clicks+" real placements: all six faces, every corner/edge/center and supported heights"); + c.runOnClient(m->Sculpture125ParticleChecks.exercise(m,SUPPORT.relative(Direction.EAST))); + Sculpture125WorkshopChecks.exercise(c,server); + server.runOnServer(s->{var l=player(s).level();for(var direction:Direction.values())l.setBlockAndUpdate(SUPPORT.relative(direction),Blocks.AIR.defaultBlockState());for(int x=0;x<3;x++)for(int y=0;y<3;y++){ + var pos=new BlockPos(x*2-2,y+1,0);l.setBlockAndUpdate(pos.north(),Blocks.STONE.defaultBlockState());var state=Statuary.BLOCK.defaultBlockState().setValue(Statuary.FACING,Direction.SOUTH).setValue(Statuary.ANCHOR,ANCHORS[x][0]).setValue(Statuary.HEIGHT,HEIGHTS[y]);place(l,pos,state);persistent.put(pos,state); + }player(s).teleportTo(.5,2,5);}); + c.waitFor(m->m.player.distanceToSqr(.5,2,5)<.05,100);c.runOnClient(m->m.player.lookAt(net.minecraft.commands.arguments.EntityAnchorArgument.Anchor.EYES,new Vec3(.5,2.5,.5)));c.waitTicks(8);c.takeScreenshot("sculpture125-wall-placements");saved=world.getWorldSave(); + } + try(var world=saved.open()){ + c.waitFor(m->m.player!=null&&m.gui.screen()==null,1200);world.getServer().runOnServer(s->{for(var entry:persistent.entrySet()){var state=player(s).level().getBlockState(entry.getKey());check(state==entry.getValue(),"New anchors and heights persist exactly");check(STATUE.equals(((StatuaryEntity)player(s).level().getBlockEntity(entry.getKey())).sculpture()),"Model data persists unchanged");}}); + c.waitFor(m->persistent.keySet().stream().allMatch(p->m.level.getBlockEntity(p) instanceof StatuaryEntity e&&STATUE.equals(e.sculpture())),150);c.runOnClient(m->persistent.keySet().forEach(p->renderBounds((StatuaryEntity)m.level.getBlockEntity(p)))); + } + new Sculpture122ClientChecks().runTest(c); + System.out.println("SCULPTURE125_PASS new placement gestures, native picking, collision/render agreement, legacy states and saved new positions"); + } + private static void contexts(ServerPlayer p){int count=0; + for(var pos:List.of(new BlockPos(5,3,5),new BlockPos(-5,3,-5))){p.level().setBlockAndUpdate(pos,Blocks.STONE.defaultBlockState()); + for(int turn=0;turn<4;turn++){p.setYRot(turn*90);for(var face:Direction.values())for(int u=0;u<3;u++)for(int v=0;v<3;v++){ + var spot=new Spot(face,u,v);var context=new BlockPlaceContext(p,InteractionHand.MAIN_HAND,StatuaryEntity.item(STATUE),new BlockHitResult(spot.hit(pos),face,pos,false));var state=Statuary.BLOCK.getStateForPlacement(context); + check(context.getClickedPos().equals(pos.relative(face))&&state.getValue(Statuary.ANCHOR)==spot.anchor()&&state.getValue(Statuary.HEIGHT)==spot.height()&&state.getValue(Statuary.FACING)==p.getDirection(),"Native placement context agrees at signed coordinates: "+spot);count++; + }}p.level().setBlockAndUpdate(pos,Blocks.AIR.defaultBlockState()); + } + check(Statuary.BLOCK.getStateDefinition().getPossibleStates().size()==144,"108 placement states plus 36 legacy-height states"); + for(var anchor:SculptureAnchor.values())for(var height:SculptureHeight.values()){ + var state=Statuary.BLOCK.defaultBlockState().setValue(Statuary.ANCHOR,anchor).setValue(Statuary.HEIGHT,height);var box=SculptureGeometry.shape(STATUE,Direction.SOUTH,anchor,height).bounds(); + for(var rotation:Rotation.values()){var rotated=state.rotate(rotation);check(rotated.getValue(Statuary.HEIGHT)==height,"Yaw rotation retains height");var b=SculptureGeometry.shape(STATUE,rotated.getValue(Statuary.FACING),rotated.getValue(Statuary.ANCHOR),height).bounds();double ex=box.getCenter().x-.5,ez=box.getCenter().z-.5;double tx=switch(rotation){case NONE->ex;case CLOCKWISE_90->-ez;case CLOCKWISE_180->-ex;case COUNTERCLOCKWISE_90->ez;};double tz=switch(rotation){case NONE->ez;case CLOCKWISE_90->ex;case CLOCKWISE_180->-ez;case COUNTERCLOCKWISE_90->-ex;};check(Math.abs((b.getCenter().x-.5)-tx)<=1/16d&&Math.abs((b.getCenter().z-.5)-tz)<=1/16d&&b.minY==box.minY,"Rotated anchor follows native structure rotation without half voxels");} + for(var mirror:Mirror.values()){var mirrored=state.mirror(mirror);var b=SculptureGeometry.shape(STATUE,mirrored.getValue(Statuary.FACING),mirrored.getValue(Statuary.ANCHOR),height).bounds();double x=mirror==Mirror.FRONT_BACK?1-box.getCenter().x:box.getCenter().x,z=mirror==Mirror.LEFT_RIGHT?1-box.getCenter().z:box.getCenter().z;check(Math.abs(b.getCenter().x-x)<=1/16d&&Math.abs(b.getCenter().z-z)<=1/16d&&b.minY==box.minY,"Mirrored corner and vertical placement follow native structure mirror");} + } + System.out.println("SCULPTURE125_CONTEXT_PASS "+count+" contexts, signed coordinates, four headings, rotations/mirrors and 144 valid states"); + } + private static void legacy(ServerPlayer p){int n=0; + for(var facing:Direction.Plane.HORIZONTAL)for(var anchor:List.of(SculptureAnchor.CENTER,SculptureAnchor.NORTH,SculptureAnchor.SOUTH,SculptureAnchor.WEST,SculptureAnchor.EAST)){ + var state=Statuary.BLOCK.defaultBlockState().setValue(Statuary.FACING,facing).setValue(Statuary.ANCHOR,anchor).setValue(Statuary.HEIGHT,SculptureHeight.TOP);var nbt=NbtUtils.writeBlockState(state);nbt.getCompoundOrEmpty("properties").remove("vertical_anchor");var restored=NbtUtils.readBlockState(BuiltInRegistries.BLOCK,nbt); + check(restored.getValue(Statuary.HEIGHT)==SculptureHeight.ORIGINAL&&restored.getValue(Statuary.ANCHOR)==anchor&&restored.getValue(Statuary.FACING)==facing,"Old NBT keeps orientation and anchor and defaults original Y");var b=SculptureGeometry.shape(STATUE,facing,anchor,restored.getValue(Statuary.HEIGHT)).bounds();check(b.minY==2/16d&&b.maxY==10/16d,"Legacy vertical padding is preserved exactly");n++; + }System.out.println("SCULPTURE125_LEGACY_PASS "+n+" old blockstates retain exact placements without rewriting payloads"); + } + private static void renderBounds(StatuaryEntity entity){var renderer=new StatuaryRenderer();var render=renderer.createRenderState();renderer.extractRenderState(entity,render,0,Vec3.ZERO,null);double x0=1,y0=1,z0=1,x1=0,y1=0,z1=0;for(var q:render.mesh){x0=Math.min(x0,q.x());y0=Math.min(y0,q.y());z0=Math.min(z0,q.z());x1=Math.max(x1,q.x()+1/16d);y1=Math.max(y1,q.y()+1/16d);z1=Math.max(z1,q.z()+1/16d);}var bounds=entity.shape(entity.getBlockState()).bounds();check(bounds.equals(new AABB(x0,y0,z0,x1,y1,z1)),"Native single box follows actual rendered offsets on all three axes");Sculpture113ClientChecks.rendered(entity);} + private static void place(ServerLevel level,BlockPos pos,BlockState state){level.setBlockAndUpdate(pos,state);var e=(StatuaryEntity)level.getBlockEntity(pos);var data=new CompoundTag();data.put("sculpture",StatuaryEntity.encode(STATUE));e.loadWithComponents(TagValueInput.create(ProblemReporter.DISCARDING,level.registryAccess(),data));e.setChanged();level.sendBlockUpdated(pos,state,state,3);} + private static VoxelSculpture statue(){var cells=new ArrayList();for(int y=2;y<10;y++)for(int x=y<6?2:1;x<=(y<6?4:5);x++)for(int z=y<6?1:0;z<=(y<6?3:4);z++)cells.add(new VoxelSculpture.Cell(x,y,z,y<6?0xff44aabb:0xffddb88b));return new VoxelSculpture(MODEL,"Placement statue",7,10,5,cells);} + private static ServerPlayer player(MinecraftServer s){return s.getPlayerList().getPlayers().getFirst();} + private static void check(boolean ok,String reason){if(!ok)throw new AssertionError(reason);} +} diff --git a/mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/Sculpture125ParticleChecks.java b/mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/Sculpture125ParticleChecks.java new file mode 100644 index 0000000..84086ec --- /dev/null +++ b/mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/Sculpture125ParticleChecks.java @@ -0,0 +1,36 @@ +package fr.koka.sanctuary.client; + +import fr.koka.sanctuary.plans.*; +import java.lang.reflect.Field; +import java.util.*; +import net.minecraft.client.Minecraft; +import net.minecraft.client.particle.*; +import net.minecraft.core.*; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.phys.*; + +final class Sculpture125ParticleChecks { + static void exercise(Minecraft mc,BlockPos pos){ + var entity=(StatuaryEntity)mc.level.getBlockEntity(pos);var state=entity.getBlockState(); + var render=new StatuaryRenderer();var rs=render.createRenderState();render.extractRenderState(entity,rs,0,Vec3.ZERO,null); + Set colors=new HashSet<>();for(var q:rs.mesh)colors.add(q.color()); + var queue=queue(mc);queue.clear();var q=rs.mesh.getLast();var hit=Vec3.atLowerCornerOf(pos).add(SculptureParticles.center(q)); + mc.hitResult=new BlockHitResult(hit,Direction.UP,pos,false);mc.level.addBreakingBlockEffects(pos,Direction.UP,false); + check(queue.size()==1&&queue.peek() instanceof SculptureParticles.Fragment,"Native hit emits one voxel chip, no clay debris"); + var chip=(SingleQuadParticle)queue.peek();check(color(chip)==q.color(),"Hit chip uses the exact targeted voxel color"); + queue.clear();mc.level.addDestroyBlockEffect(pos,state);verify(queue,rs.mesh,colors,pos);int count=queue.size(); + queue.clear();mc.level.setBlock(pos,Blocks.AIR.defaultBlockState(),3);check(mc.level.getBlockEntity(pos)==null,"Native block update removes sculpture before break effect"); + mc.level.addDestroyBlockEffect(pos,state);verify(queue,rs.mesh,colors,pos);check(queue.size()==count,"Late break event retains voxel colors and offsets"); + queue.clear();mc.level.addDestroyBlockEffect(pos,Blocks.STONE.defaultBlockState());check(!queue.isEmpty()&&queue.stream().noneMatch(SculptureParticles.Fragment.class::isInstance),"Other blocks retain native particles");queue.clear(); + System.out.println("SCULPTURE125_PARTICLES_PASS native hit/destroy hooks, exact voxel colors, real surface origins, 64-chip bound, late removal and ordinary block regression"); + } + private static void verify(Queue queue,List mesh,Set colors,BlockPos pos){ + check(!queue.isEmpty()&&queue.size()<=64,"Bounded destruction particles");var observed=new HashSet(); + for(var p:queue){check(p instanceof SculptureParticles.Fragment,"No clay fallback particles");int color=color((SingleQuadParticle)p);observed.add(color);check(colors.contains(color),"Particle retains an actual model color");var point=new Vec3(number(p,"x"),number(p,"y"),number(p,"z")).subtract(Vec3.atLowerCornerOf(pos));check(mesh.stream().anyMatch(q->q.color()==color&&SculptureParticles.center(q).equals(point)),"Particle belongs to an actual colored voxel face after rotation/anchor");check(((SingleQuadParticle)p).getQuadSize(0)==1/32f,"One voxel diameter, no oversized clay chips");} + check(observed.size()==colors.size(),"Both fixture material colors occur in destruction chips"); + } + @SuppressWarnings("unchecked") private static Queue queue(Minecraft mc){try{var f=ParticleEngine.class.getDeclaredField("particlesToAdd");f.setAccessible(true);return (Queue)f.get(mc.particleEngine);}catch(Exception e){throw new AssertionError(e);}} + private static double number(Object value,String name){try{Class type=value.getClass();Field f=null;while(type!=null){try{f=type.getDeclaredField(name);break;}catch(NoSuchFieldException ignored){type=type.getSuperclass();}}f.setAccessible(true);return ((Number)f.get(value)).doubleValue();}catch(Exception e){throw new AssertionError(e);}} + private static int color(SingleQuadParticle p){return 0xff000000|Math.round((float)number(p,"rCol")*255)<<16|Math.round((float)number(p,"gCol")*255)<<8|Math.round((float)number(p,"bCol")*255);} + private static void check(boolean ok,String reason){if(!ok)throw new AssertionError(reason);} +} diff --git a/mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/Sculpture125WorkshopChecks.java b/mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/Sculpture125WorkshopChecks.java new file mode 100644 index 0000000..f423dd2 --- /dev/null +++ b/mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/Sculpture125WorkshopChecks.java @@ -0,0 +1,53 @@ +package fr.koka.sanctuary.client; + +import fr.koka.sanctuary.companion.CompanionService; +import fr.koka.sanctuary.plans.*; +import java.nio.file.*; +import java.util.*; +import net.fabricmc.fabric.api.client.gametest.v1.context.*; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.input.KeyEvent; +import net.minecraft.core.BlockPos; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.stats.Stats; +import net.minecraft.world.entity.EntityTypes; +import net.minecraft.world.inventory.ContainerInput; +import net.minecraft.world.item.*; +import net.minecraft.world.level.GameType; + +final class Sculpture125WorkshopChecks { + static void exercise(ClientGameTestContext c,TestServerContext server){ + var pos=new BlockPos(4,1,4); + server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.teleportTo(3,1,3);p.setGameMode(GameType.SURVIVAL);p.getAbilities().invulnerable=true;p.onUpdateAbilities();p.level().setBlockAndUpdate(pos,Statuary.WORKSHOP.defaultBlockState()); + CompanionService.save(p,CompanionService.record(p).note("seen_mobs",""));Statuary.open(p,pos); + }); + c.waitForScreen(ClayWorkshopScreen.class); + c.runOnClient(m->{var menu=(ClayWorkshopMenu)m.player.containerMenu;check(!menu.view.statues().contains("minecraft:cow"),"Unseen cow is absent without visiting Metabli");var screen=(ClayWorkshopScreen)m.gui.screen();screen.load(EntityTypes.COW);check(screen.selected()==null&&!screen.busy(),"Unseen animal cannot start local generation");}); + server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var menu=(ClayWorkshopMenu)p.containerMenu;menu.getSlot(0).set(new ItemStack(Items.OAK_PLANKS,2));var locked=new VoxelSculpture(StatueDiscoveries.modelId("minecraft:cow"),"Cow",1,1,1,List.of(new VoxelSculpture.Cell(0,0,0,-1)));menu.select(p,locked,Items.OAK_PLANKS,new SculpturePalette(List.of(-1)));check(menu.getSlot(1).getItem().isEmpty(),"Server rejects a locked animal model despite valid material");p.closeContainer(); + CompanionService.save(p,CompanionService.record(p).note("seen_mobs","cow"));p.awardStat(Stats.ENTITY_KILLED.get(EntityTypes.SHEEP));p.awardStat(Stats.ENTITY_KILLED_BY.get(EntityTypes.PIG)); + }); + c.waitFor(m->m.gui.screen()==null,100); + server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();Statuary.open(p,pos);var menu=(ClayWorkshopMenu)p.containerMenu;check(menu.view.statues().equals(StatueDiscoveries.known(p)),"Workshop uses the exact statue catalogue discoveries");menu.getSlot(0).set(new ItemStack(Items.OAK_PLANKS,4));menu.broadcastChanges();}); + c.waitForScreen(ClayWorkshopScreen.class); + c.runOnClient(m->{m.options.guiScale().set(2);m.getWindow().setWindowed(1280,800);m.resizeGui();var menu=(ClayWorkshopMenu)m.player.containerMenu;check(menu.view.statues().containsAll(List.of("minecraft:cow","minecraft:sheep","minecraft:pig")),"Encounter, kill and killed-by unlocks are transmitted");}); + for(var type:List.of(EntityTypes.COW,EntityTypes.SHEEP,EntityTypes.PIG)){ + String model=StatueDiscoveries.modelId(BuiltInRegistries.ENTITY_TYPE.getKey(type).toString()); + c.runOnClient(m->{var screen=(ClayWorkshopScreen)m.gui.screen();screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getString().equals(type.getDescription().getString())).findFirst().orElseThrow().onPress(new KeyEvent(257,0,0));}); + c.waitTicks(10);c.runOnClient(m->{var screen=(ClayWorkshopScreen)m.gui.screen();System.out.println("SCULPTURE125_WORKSHOP_DEBUG "+type+" busy "+screen.busy()+" source "+(screen.selected()==null?"null":screen.selected().modelId())+" input "+m.player.containerMenu.getSlot(0).getItem()+" output "+m.player.containerMenu.getSlot(1).getItem());}); + c.waitFor(m->{var screen=(ClayWorkshopScreen)m.gui.screen();return !screen.busy()&&screen.selected()!=null&&screen.selected().modelId().equals(model)&&m.player.containerMenu.getSlot(1).getItem().is(Statuary.ITEM)&&StatuaryEntity.sculpture(m.player.containerMenu.getSlot(1).getItem()).modelId().equals(model);},250); + c.runOnClient(m->{var screen=(ClayWorkshopScreen)m.gui.screen();var source=screen.selected();check(source.cells().size()>20&&Math.max(source.width(),Math.max(source.height(),source.depth()))==16,"Native animal model voxelized at miniature scale");check(ItemStack.matches(screen.preview(),m.player.containerMenu.getSlot(1).getItem()),"Animal preview equals server-paid palette result");}); + c.takeScreenshot("sculpture125-workshop-"+BuiltInRegistries.ENTITY_TYPE.getKey(type).getPath()); + } + c.runOnClient(m->m.gameMode.handleContainerInput(m.player.containerMenu.containerId,1,0,ContainerInput.PICKUP,m.player)); + c.waitFor(m->m.player.containerMenu.getCarried().is(Statuary.ITEM)&&m.player.containerMenu.getSlot(0).getItem().getCount()==3,100); + server.waitFor(s->{var menu=s.getPlayerList().getPlayers().getFirst().containerMenu;return menu.getCarried().is(Statuary.ITEM)&&menu.getSlot(0).getItem().getCount()==3;},100); + server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();check(p.containerMenu.getSlot(0).getItem().getCount()==3&&p.containerMenu.getCarried().is(Statuary.ITEM),"Exactly one block consumed for native animal sculpture in survival");}); + Path file=PlanClient.directory().resolve("sculpture125-"+UUID.randomUUID()+".glb"); + try{Files.createDirectories(file.getParent());Files.write(file,Statuary106ClientChecks.fixture(true));c.runOnClient(m->{var screen=(ClayWorkshopScreen)m.gui.screen();screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getString().equals(ClayWorkshopScreen.text("imports").getString())).findFirst().orElseThrow().onPress(new KeyEvent(257,0,0));screen.load(file);}); + var id=GlbModel.load(file).id();c.waitFor(m->{var screen=(ClayWorkshopScreen)m.gui.screen();return !screen.busy()&&screen.selected()!=null&&screen.selected().modelId().equals(id)&&id.equals(Optional.ofNullable(StatuaryEntity.sculpture(m.player.containerMenu.getSlot(1).getItem())).map(VoxelSculpture::modelId).orElse(""));},250);c.takeScreenshot("sculpture125-workshop-import"); + }catch(Exception e){throw new AssertionError(e);}finally{try{Files.deleteIfExists(file);}catch(Exception ignored){}} + server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.closeContainer();p.setGameMode(GameType.CREATIVE);p.getAbilities().flying=true;p.onUpdateAbilities();});c.waitFor(m->m.gui.screen()==null,100); + System.out.println("SCULPTURE125_WORKSHOP_PASS encounter/kill/death unlocks, native menu sync, locked server rejection, three textured animals, paid survival craft and GLB import"); + } + private static void check(boolean ok,String reason){if(!ok)throw new AssertionError(reason);} +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/ClayWorkshopScreen.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/ClayWorkshopScreen.java index 18c8030..24213aa 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/ClayWorkshopScreen.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/ClayWorkshopScreen.java @@ -21,6 +21,8 @@ public final class ClayWorkshopScreen extends AbstractContainerScreen{var t=new Thread(r,"Sanctuary clay models");t.setDaemon(true);return t;}); + private boolean imported; + private List> animals=List.of(); private List files=List.of();private int page,generation,ticks;private boolean busy,pending; private VoxelSculpture source,previewSource;private ItemStack preview=ItemStack.EMPTY,lastMaterial=ItemStack.EMPTY; private SculpturePalette palette;private Object sampledResources; @@ -38,12 +40,18 @@ public final class ClayWorkshopScreen extends AbstractContainerScreen=files.size())break;var file=files.get(at); - var label=Component.literal(file.getFileName().toString());var b=addRenderableWidget(Button.builder(label,p->load(file)).bounds(leftPos+10,topPos+32+i*18,128,17).build());b.setTooltip(Tooltip.create(label));} + animals=menu.view.statues().stream().map(net.minecraft.resources.Identifier::tryParse).filter(Objects::nonNull) + .>map(id->net.minecraft.core.registries.BuiltInRegistries.ENTITY_TYPE.getValue(id)).filter(Objects::nonNull) + .sorted(Comparator.comparing(t->t.getDescription().getString())).toList(); + int count=imported?files.size():animals.size();page=Math.min(page,Math.max(0,(count-1)/4)); + addRenderableWidget(Button.builder(text("animals"),b->{imported=false;page=0;rebuildWidgets();}).bounds(leftPos+10,topPos+19,62,16).build()).active=imported; + addRenderableWidget(Button.builder(text("imports"),b->{imported=true;page=0;rebuildWidgets();}).bounds(leftPos+74,topPos+19,64,16).build()).active=!imported; + for(int i=0;i<4;i++){int at=page*4+i;if(at>=count)break; + var label=imported?Component.literal(files.get(at).getFileName().toString()):animals.get(at).getDescription(); + var b=addRenderableWidget(Button.builder(label,p->{if(imported)load(files.get(at));else load(animals.get(at));}).bounds(leftPos+10,topPos+36+i*17,128,17).build());b.setTooltip(Tooltip.create(label));} addRenderableWidget(Button.builder(Component.literal("←"),b->{page--;rebuildWidgets();}).bounds(leftPos+10,topPos+106,22,18).build()).active=page>0; addRenderableWidget(Button.builder(text("folder"),b->folder()).bounds(leftPos+35,topPos+106,78,18).build()); - addRenderableWidget(Button.builder(Component.literal("→"),b->{page++;rebuildWidgets();}).bounds(leftPos+116,topPos+106,22,18).build()).active=(page+1)*4{page++;rebuildWidgets();}).bounds(leftPos+116,topPos+106,22,18).build()).active=(page+1)*4rebuildWidgets()).bounds(leftPos+10,topPos+127,128,18).build()); rotate=addRenderableWidget(Button.builder(text("rotate"),b->{if(source!=null){source=source.rotate();pending=true;}}).bounds(leftPos+148,topPos+127,152,18).build());rotate.active=source!=null&&!busy; rotate.setTooltip(Tooltip.create(text("placement"))); @@ -58,6 +66,24 @@ public final class ClayWorkshopScreen extends AbstractContainerScreen type){ + var id=net.minecraft.core.registries.BuiltInRegistries.ENTITY_TYPE.getKey(type).toString(); + if(busy||!menu.view.statues().contains(id))return; + try{ + var entity=type.create(minecraft.level,net.minecraft.world.entity.EntitySpawnReason.MOB_SUMMONED); + if(entity==null)throw new IllegalArgumentException("unsupported_model"); + entity.setId(Integer.MIN_VALUE+125);entity.setPos(minecraft.player.position()); + // Native renderers and textures are read on the client thread; only voxelization runs in the worker. + var faces=StatueModels.capture(entity);String name=type.getDescription().getString(); + busy=true;status=text("working");int token=++generation; + CompletableFuture.supplyAsync(()->VoxelSculpture.of(StatueDiscoveries.modelId(id),name,StatueVoxelizer.miniature(faces)),WORKER) + .whenComplete((sculpture,error)->minecraft.execute(()->{ + if(token!=generation||minecraft.gui.screen()!=this||minecraft.player==null||minecraft.player.containerMenu!=menu)return; + busy=false;if(error!=null){fr.koka.sanctuary.SanctuaryMod.LOGGER.warn("Unable to sculpt creature {}",id,error);status=text("animal_error");return;} + source=sculpture;pending=true;status=text("ready",name); + })); + }catch(Exception e){fr.koka.sanctuary.SanctuaryMod.LOGGER.warn("Unable to capture creature {}",id,e);status=text("animal_error");} + } @Override protected void containerTick(){ super.containerTick();rotate.active=source!=null&&!busy; var material=menu.getSlot(0).getItem();var resources=minecraft.getModelManager().getBlockStateModelSet(); @@ -94,10 +120,10 @@ public final class ClayWorkshopScreen extends AbstractContainerScreen2&&mx>=leftPos+10&&mx=topPos+148&&my mesh){} + // A break event can arrive after the block update. Keep a short, bounded snapshot in that world only. + private static final Map> REMOVED=new WeakHashMap<>(); + public static void remember(ClientLevel level,BlockPos pos){ + if(!(level.getBlockEntity(pos) instanceof StatuaryEntity e)||e.sculpture()==null)return; + var cache=REMOVED.computeIfAbsent(level,l->new LinkedHashMap<>()); + var quads=mesh(e);var sample=new ArrayList();int n=Math.min(64,quads.size()); + for(int i=0;i128)cache.remove(cache.keySet().iterator().next()); + } + private static List mesh(StatuaryEntity e){var s=e.getBlockState();return StatuaryRenderer.mesh(e.sculpture(),s.getValue(Statuary.FACING),s.getValue(Statuary.ANCHOR),s.getValue(Statuary.HEIGHT));} + public static boolean destroy(ClientLevel level,BlockPos pos,BlockState state){ + if(!state.is(Statuary.BLOCK))return false; + List quads=List.of(); + if(level.getBlockEntity(pos) instanceof StatuaryEntity e)quads=mesh(e); + var cache=REMOVED.get(level);var old=cache==null?null:cache.remove(pos); + if(quads.isEmpty()&&old!=null&&old.state==state&&level.getGameTime()-old.tick<=40)quads=old.mesh; + int count=Math.min(64,quads.size());var random=level.getRandom(); + for(int i=0;inew Vec3(x,y,q.z());case 1->new Vec3(x,y,q.z()+1/16d);case 2->new Vec3(q.x(),y,z);case 3->new Vec3(q.x()+1/16d,y,z);case 4->new Vec3(x,q.y()+1/16d,z);default->new Vec3(x,q.y(),z);}; + } + private static void spawn(ClientLevel level,BlockPos pos,Vec3 point,int color,double dx,double dy,double dz){ + Minecraft.getInstance().particleEngine.add(new Fragment(level,Vec3.atLowerCornerOf(pos).add(point),color,dx,dy,dz)); + } + static final class Fragment extends SingleQuadParticle { + Fragment(ClientLevel level,Vec3 pos,int color,double dx,double dy,double dz){ + super(level,pos.x,pos.y,pos.z,Minecraft.getInstance().getAtlasManager().getAtlasOrThrow(AtlasIds.BLOCKS).getSprite(SanctuaryMod.id("sculpture_particle"))); + setColor(((color>>16)&255)/255f,((color>>8)&255)/255f,(color&255)/255f); + quadSize=1/32f;gravity=1;friction=.96f;lifetime=12+random.nextInt(12);setParticleSpeed(dx,dy,dz); + } + @Override protected Layer getLayer(){return Layer.OPAQUE_TERRAIN;} + } + private SculptureParticles(){} +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/StatuaryRenderer.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/StatuaryRenderer.java index df48919..7b74463 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/StatuaryRenderer.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/client/StatuaryRenderer.java @@ -24,7 +24,7 @@ import org.joml.Vector3fc; /** Shared block/item render geometry: opaque visible faces, generated once per cached sculpture. */ public final class StatuaryRenderer implements BlockEntityRenderer { record Quad(float x,float y,float z,int side,int color){} - private record Placement(VoxelSculpture sculpture,Direction facing,SculptureAnchor anchor){} + private record Placement(VoxelSculpture sculpture,Direction facing,SculptureAnchor anchor,SculptureHeight height){} private static final Map> MESHES=new LinkedHashMap<>(32,.75f,true); private static final Map,VoxelSculpture> ITEMS=new LinkedHashMap<>(32,.75f,true); private static final float[][] FACES={ @@ -40,20 +40,20 @@ public final class StatuaryRenderer implements BlockEntityRenderer mesh(VoxelSculpture sculpture,Direction facing,SculptureAnchor anchor){ - if(sculpture==null)return List.of();var key=new Placement(sculpture,facing,anchor);var cached=MESHES.get(key);if(cached!=null)return cached; - var placed=SculptureGeometry.place(sculpture,facing,anchor);sculpture=placed.sculpture(); + static List mesh(VoxelSculpture sculpture,Direction facing,SculptureAnchor anchor,SculptureHeight height){ + if(sculpture==null)return List.of();var key=new Placement(sculpture,facing,anchor,height);var cached=MESHES.get(key);if(cached!=null)return cached; + var placed=SculptureGeometry.place(sculpture,facing,anchor,height);sculpture=placed.sculpture(); var occupied=new BitSet(4096);for(var cell:sculpture.cells())occupied.set(cell.key());var quads=new ArrayList(); - float ox=placed.offsetX()/16f,oz=placed.offsetZ()/16f; + float ox=placed.offsetX()/16f,oy=placed.offsetY()/16f,oz=placed.offsetZ()/16f; for(var cell:sculpture.cells())for(int side=0;side<6;side++){ var s=STEP[side];int x=cell.x()+s[0],y=cell.y()+s[1],z=cell.z()+s[2]; if(x>=0&&x<16&&y>=0&&y<16&&z>=0&&z<16&&occupied.get(x|y<<4|z<<8))continue; - quads.add(new Quad(ox+cell.x()/16f,cell.y()/16f,oz+cell.z()/16f,side,cell.color())); + quads.add(new Quad(ox+cell.x()/16f,oy+cell.y()/16f,oz+cell.z()/16f,side,cell.color())); } cached=List.copyOf(quads);MESHES.put(key,cached);if(MESHES.size()>32)MESHES.remove(MESHES.keySet().iterator().next());return cached; } @@ -69,7 +69,7 @@ public final class StatuaryRenderer implements BlockEntityRenderer64)ITEMS.remove(ITEMS.keySet().iterator().next());} - return mesh(sculpture,Direction.SOUTH,SculptureAnchor.CENTER); + return mesh(sculpture,Direction.SOUTH,SculptureAnchor.CENTER,SculptureHeight.ORIGINAL); } public void submit(List mesh,PoseStack pose,SubmitNodeCollector collector,int light,int overlay,boolean foil,int outline){draw(mesh,pose,collector,light,overlay);} public void getExtents(java.util.function.Consumer out){out.accept(new Vector3f(0,0,0));out.accept(new Vector3f(1,1,1));} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/client/SculptureParticlesMixin.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/client/SculptureParticlesMixin.java new file mode 100644 index 0000000..3343197 --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/client/SculptureParticlesMixin.java @@ -0,0 +1,26 @@ +package fr.koka.sanctuary.mixin.client; + +import fr.koka.sanctuary.client.SculptureParticles; +import fr.koka.sanctuary.plans.Statuary; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.core.*; +import net.minecraft.world.level.block.state.BlockState; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.*; +import org.spongepowered.asm.mixin.injection.callback.*; + +@Mixin(ClientLevel.class) +public abstract class SculptureParticlesMixin { + @Inject(method="setBlock",at=@At("HEAD")) + private void sanctuary$remember(BlockPos pos,BlockState state,int flags,int recursion,CallbackInfoReturnable cir){ + if(!state.is(Statuary.BLOCK))SculptureParticles.remember((ClientLevel)(Object)this,pos); + } + @Inject(method="addDestroyBlockEffect",at=@At("HEAD"),cancellable=true) + private void sanctuary$chips(BlockPos pos,BlockState state,CallbackInfo ci){ + if(SculptureParticles.destroy((ClientLevel)(Object)this,pos,state))ci.cancel(); + } + @Inject(method="addBreakingParticles",at=@At("HEAD"),cancellable=true) + private void sanctuary$chip(BlockPos pos,Direction side,BlockState state,CallbackInfo ci){ + if(SculptureParticles.hit((ClientLevel)(Object)this,pos,side,state))ci.cancel(); + } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/plans/ClayWorkshopMenu.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/plans/ClayWorkshopMenu.java index 56f46e9..5567d57 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/plans/ClayWorkshopMenu.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/plans/ClayWorkshopMenu.java @@ -20,8 +20,11 @@ import net.minecraft.world.phys.*; /** Native input/result slots. Server validates the material, applies the bounded palette and charges each take. */ public final class ClayWorkshopMenu extends AbstractContainerMenu { public static final int HOTBAR_START=2,HOTBAR_END=HOTBAR_START+Inventory.SELECTION_SIZE,HOTBAR_X=75,HOTBAR_Y=176; - public record View(BlockPos pos,UUID token){ - public static final StreamCodec CODEC=StreamCodec.of((b,p)->{b.writeBlockPos(p.pos);b.writeUUID(p.token);},b->new View(b.readBlockPos(),b.readUUID())); + public record View(BlockPos pos,UUID token,List statues){ + public View { statues=List.copyOf(statues); } + public static final StreamCodec CODEC=StreamCodec.of((b,p)->{ + b.writeBlockPos(p.pos);b.writeUUID(p.token);b.writeVarInt(p.statues.size());for(var id:p.statues)b.writeUtf(id,128); + },b->{var pos=b.readBlockPos();var token=b.readUUID();int n=b.readVarInt();if(n<0||n>1024)throw new IllegalArgumentException("statue_discoveries");var ids=new ArrayList(n);for(int i=0;i this;case CLOCKWISE_90 -> of(-z,x);case CLOCKWISE_180 -> of(-x,-z);case COUNTERCLOCKWISE_90 -> of(z,-x); + };} + public SculptureAnchor mirror(Mirror mirror){return switch(mirror){case NONE->this;case LEFT_RIGHT->of(x,-z);case FRONT_BACK->of(-x,z);};} public static SculptureAnchor of(Direction direction){return switch(direction){ case NORTH -> NORTH;case SOUTH -> SOUTH;case EAST -> EAST;case WEST -> WEST; default -> throw new IllegalArgumentException("Horizontal anchor required"); };} + private static SculptureAnchor of(int x,int z){for(var anchor:values())if(anchor.x==x&&anchor.z==z)return anchor;throw new IllegalArgumentException("Anchor outside grid");} public static SculptureAnchor placement(BlockPlaceContext context){ var pos=context.getClickedPos();var hit=context.getClickLocation(); - double x=hit.x-pos.getX()-.5,z=hit.z-pos.getZ()-.5; - // The middle 8 x 8 pixels select the existing grid-aligned center. - if(Math.abs(x)<=.25&&Math.abs(z)<=.25)return CENTER; - if(Math.abs(x)>Math.abs(z))return x<0?WEST:EAST; - return z<0?NORTH:SOUTH; + return of(band(hit.x-pos.getX()),band(hit.z-pos.getZ())); } - public int originX(int width){return this==WEST?0:this==EAST?VoxelSculpture.EDGE-width:(VoxelSculpture.EDGE-width)/2;} - public int originZ(int depth){return this==NORTH?0:this==SOUTH?VoxelSculpture.EDGE-depth:(VoxelSculpture.EDGE-depth)/2;} + // The middle eight pixels, including the boundaries, keep the grid-aligned center. + static int band(double value){return value<.25?-1:value>.75?1:0;} + public int originX(int width){return x<0?0:x>0?VoxelSculpture.EDGE-width:(VoxelSculpture.EDGE-width)/2;} + public int originZ(int depth){return z<0?0:z>0?VoxelSculpture.EDGE-depth:(VoxelSculpture.EDGE-depth)/2;} } diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/plans/SculptureGeometry.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/plans/SculptureGeometry.java index d9de4c8..6aa55a4 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/plans/SculptureGeometry.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/plans/SculptureGeometry.java @@ -6,24 +6,24 @@ import net.minecraft.world.phys.shapes.VoxelShape; /** Integer placement shared by rendering and tight, single-box selection/collision bounds. */ public final class SculptureGeometry { - public record Placement(VoxelSculpture sculpture, int offsetX, int offsetZ) {} + public record Placement(VoxelSculpture sculpture, int offsetX, int offsetY, int offsetZ) {} private SculptureGeometry() {} - public static Placement place(VoxelSculpture sculpture, Direction facing, SculptureAnchor anchor) { + public static Placement place(VoxelSculpture sculpture, Direction facing, SculptureAnchor anchor, SculptureHeight height) { int turns = switch (facing) { case WEST -> 1; case NORTH -> 2; case EAST -> 3; default -> 0; }; for (int i = 0; i < turns; i++) sculpture = sculpture.rotate(); - int minX = 16, minZ = 16, maxX = 0, maxZ = 0; + int minX = 16, minY = 16, minZ = 16, maxX = 0, maxY = 0, maxZ = 0; for (var cell : sculpture.cells()) { - minX = Math.min(minX, cell.x()); minZ = Math.min(minZ, cell.z()); - maxX = Math.max(maxX, cell.x() + 1); maxZ = Math.max(maxZ, cell.z() + 1); + minX = Math.min(minX, cell.x()); minZ = Math.min(minZ, cell.z()); minY = Math.min(minY, cell.y()); + maxX = Math.max(maxX, cell.x() + 1); maxZ = Math.max(maxZ, cell.z() + 1); maxY = Math.max(maxY, cell.y() + 1); } - return new Placement(sculpture, anchor.originX(maxX - minX) - minX, + return new Placement(sculpture, anchor.originX(maxX - minX) - minX, height.originY(minY,maxY) - minY, anchor.originZ(maxZ - minZ) - minZ); } /** Tight integer bounds, without rotating/copying cells or constructing a voxel grid. */ - public static VoxelShape shape(VoxelSculpture sculpture, Direction facing, SculptureAnchor anchor) { + public static VoxelShape shape(VoxelSculpture sculpture, Direction facing, SculptureAnchor anchor, SculptureHeight height) { int minX=16,minY=16,minZ=16,maxX=0,maxY=0,maxZ=0; for(var cell:sculpture.cells()) { minX=Math.min(minX,cell.x()); minY=Math.min(minY,cell.y()); minZ=Math.min(minZ,cell.z()); @@ -32,6 +32,7 @@ public final class SculptureGeometry { int width=maxX-minX,depth=maxZ-minZ; if(facing.getAxis()==Direction.Axis.X){int swap=width;width=depth;depth=swap;} int x=anchor.originX(width),z=anchor.originZ(depth); - return Block.box(x,minY,z,x+width,maxY,z+depth); + int y=height.originY(minY,maxY); + return Block.box(x,y,z,x+width,y+maxY-minY,z+depth); } } diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/plans/SculptureHeight.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/plans/SculptureHeight.java new file mode 100644 index 0000000..a39051e --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/plans/SculptureHeight.java @@ -0,0 +1,19 @@ +package fr.koka.sanctuary.plans; + +import java.util.Locale; +import net.minecraft.util.StringRepresentable; +import net.minecraft.world.item.context.BlockPlaceContext; + +/** ORIGINAL is the missing-property default: pre-beta.125 sculptures retain their exact Y offsets. */ +public enum SculptureHeight implements StringRepresentable { + ORIGINAL, BOTTOM, CENTER, TOP; + public String getSerializedName(){return name().toLowerCase(Locale.ROOT);} + public int originY(int minY,int maxY){return switch(this){ + case ORIGINAL -> minY;case BOTTOM -> 0;case CENTER -> (VoxelSculpture.EDGE-(maxY-minY))/2;case TOP -> VoxelSculpture.EDGE-(maxY-minY); + };} + public static SculptureHeight placement(BlockPlaceContext context){ + return switch(SculptureAnchor.band(context.getClickLocation().y-context.getClickedPos().getY())){ + case -1 -> BOTTOM;case 1 -> TOP;default -> CENTER; + }; + } +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/plans/Statuary.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/plans/Statuary.java index 19d0ebe..2e49b6a 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/plans/Statuary.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/plans/Statuary.java @@ -30,6 +30,7 @@ import net.minecraft.network.chat.Component; public final class Statuary { public static final EnumProperty FACING=BlockStateProperties.HORIZONTAL_FACING; public static final EnumProperty ANCHOR=EnumProperty.create("anchor",SculptureAnchor.class); + public static final EnumProperty HEIGHT=EnumProperty.create("vertical_anchor",SculptureHeight.class); public static final Block BLOCK=block("statuary",new SculptureBlock()); public static final Item ITEM=blockItem("statuary",BLOCK); public static final Block WORKSHOP=block("clay_workshop",new WorkshopBlock()); @@ -50,7 +51,7 @@ public final class Statuary { } public static void open(ServerPlayer p,BlockPos pos){ if(!allowed(p,p.level(),pos))return; - var view=new ClayWorkshopMenu.View(pos,UUID.randomUUID());var level=p.level(); + var view=new ClayWorkshopMenu.View(pos,UUID.randomUUID(),StatueDiscoveries.known(p));var level=p.level(); p.openMenu(new ExtendedMenuProvider(){ public ClayWorkshopMenu.View getScreenOpeningData(ServerPlayer player){return view;} public Component getDisplayName(){return WORKSHOP.getName();} @@ -69,11 +70,11 @@ public final class Statuary { if(!super.canPlace(context,state))return false; // The destination has no block entity yet: use the carried model for the native entity-overlap check. var model=StatuaryEntity.sculpture(context.getItemInHand()); - return model==null||context.getLevel().isUnobstructed(null,SculptureGeometry.shape(model,state.getValue(FACING),state.getValue(ANCHOR)).move(context.getClickedPos())); + return model==null||context.getLevel().isUnobstructed(null,SculptureGeometry.shape(model,state.getValue(FACING),state.getValue(ANCHOR),state.getValue(HEIGHT)).move(context.getClickedPos())); } } private static final class SculptureBlock extends Block implements EntityBlock { - SculptureBlock(){super(Statuary.properties("statuary").dynamicShape().isSuffocating((s,l,p)->false).isViewBlocking((s,l,p,box)->false));registerDefaultState(stateDefinition.any().setValue(FACING,Direction.SOUTH).setValue(ANCHOR,SculptureAnchor.CENTER));} + SculptureBlock(){super(Statuary.properties("statuary").dynamicShape().isSuffocating((s,l,p)->false).isViewBlocking((s,l,p,box)->false));registerDefaultState(stateDefinition.any().setValue(FACING,Direction.SOUTH).setValue(ANCHOR,SculptureAnchor.CENTER).setValue(HEIGHT,SculptureHeight.ORIGINAL));} @Override protected VoxelShape getShape(BlockState state,BlockGetter level,BlockPos pos,CollisionContext context){ return level.getBlockEntity(pos) instanceof StatuaryEntity entity?entity.shape(state):Shapes.empty(); } @@ -83,8 +84,8 @@ public final class Statuary { @Override protected boolean propagatesSkylightDown(BlockState state){return true;} @Override protected int getLightDampening(BlockState state){return 0;} @Override protected float getShadeBrightness(BlockState state,BlockGetter level,BlockPos pos){return 1;} - @Override protected void createBlockStateDefinition(StateDefinition.Builder builder){builder.add(FACING,ANCHOR);} - @Override public BlockState getStateForPlacement(BlockPlaceContext context){return defaultBlockState().setValue(FACING,context.getHorizontalDirection()).setValue(ANCHOR,SculptureAnchor.placement(context));} + @Override protected void createBlockStateDefinition(StateDefinition.Builder builder){builder.add(FACING,ANCHOR,HEIGHT);} + @Override public BlockState getStateForPlacement(BlockPlaceContext context){return defaultBlockState().setValue(FACING,context.getHorizontalDirection()).setValue(ANCHOR,SculptureAnchor.placement(context)).setValue(HEIGHT,SculptureHeight.placement(context));} @Override protected BlockState rotate(BlockState state,Rotation rotation){return state.setValue(FACING,rotation.rotate(state.getValue(FACING))).setValue(ANCHOR,state.getValue(ANCHOR).rotate(rotation));} @Override protected BlockState mirror(BlockState state,Mirror mirror){return state.setValue(FACING,mirror.mirror(state.getValue(FACING))).setValue(ANCHOR,state.getValue(ANCHOR).mirror(mirror));} public BlockEntity newBlockEntity(BlockPos pos,BlockState state){return new StatuaryEntity(pos,state);} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/plans/StatuaryEntity.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/plans/StatuaryEntity.java index 2617352..b748f32 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/plans/StatuaryEntity.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/plans/StatuaryEntity.java @@ -26,7 +26,7 @@ public final class StatuaryEntity extends BlockEntity { var model=sculpture;if(model==null)return MISSING_SHAPE; var cached=shapeCache; if(cached!=null&&cached.sculpture()==model&&cached.state()==state)return cached.shape(); - var shape=SculptureGeometry.shape(model,state.getValue(Statuary.FACING),state.getValue(Statuary.ANCHOR)); + var shape=SculptureGeometry.shape(model,state.getValue(Statuary.FACING),state.getValue(Statuary.ANCHOR),state.getValue(Statuary.HEIGHT)); shapeCache=new ShapeCache(model,state,shape);return shape; } @Override protected void saveAdditional(ValueOutput out){ diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/plans/StatueDiscoveries.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/plans/StatueDiscoveries.java index 30728a4..595d774 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/plans/StatueDiscoveries.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/plans/StatueDiscoveries.java @@ -18,5 +18,15 @@ public final class StatueDiscoveries { ||player.getStats().getValue(Stats.ENTITY_KILLED,type)>0||player.getStats().getValue(Stats.ENTITY_KILLED_BY,type)>0; }).map(type->BuiltInRegistries.ENTITY_TYPE.getKey(type).toString()).sorted().toList(); } + /** Stable identifiers share the existing sculpture schema; imported models keep their content hash. */ + public static String modelId(String entityId){ + try{return "sanctuary:model/"+HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(("sanctuary:statue/"+entityId).getBytes(java.nio.charset.StandardCharsets.UTF_8)));} + catch(java.security.NoSuchAlgorithmException impossible){throw new AssertionError(impossible);} + } + private static final class Models { + static final Map IDS=BuiltInRegistries.ENTITY_TYPE.stream().filter(t->SpawnEggItem.byId(t).isPresent()) + .map(t->BuiltInRegistries.ENTITY_TYPE.getKey(t).toString()).collect(java.util.stream.Collectors.toUnmodifiableMap(StatueDiscoveries::modelId,id->id)); + } + public static boolean allowedModel(String model,List known){var entity=Models.IDS.get(model);return entity==null||known.contains(entity);} private StatueDiscoveries(){} } diff --git a/mods/sanctuary/src/main/resources/assets/minecraft/atlases/blocks.json b/mods/sanctuary/src/main/resources/assets/minecraft/atlases/blocks.json new file mode 100644 index 0000000..af0a535 --- /dev/null +++ b/mods/sanctuary/src/main/resources/assets/minecraft/atlases/blocks.json @@ -0,0 +1,5 @@ +{ + "sources": [ + {"type": "minecraft:single", "resource": "sanctuary:misc/sculpture_white", "sprite": "sanctuary:sculpture_particle"} + ] +} diff --git a/mods/sanctuary/src/main/resources/assets/sanctuary/lang/en_us.json b/mods/sanctuary/src/main/resources/assets/sanctuary/lang/en_us.json index 4b6a4e3..6121ea3 100644 --- a/mods/sanctuary/src/main/resources/assets/sanctuary/lang/en_us.json +++ b/mods/sanctuary/src/main/resources/assets/sanctuary/lang/en_us.json @@ -2241,5 +2241,9 @@ "sanctuary.wrench.no_rotation": "This block has no vanilla orientation.", "sanctuary.wrench.linked": "This block has linked parts: separate or retract it before rotating.", "sanctuary.wrench.unsupported": "This orientation does not fit the neighbouring blocks or support.", - "sanctuary.wrench.steve_power": "Free editing of block states and textures" + "sanctuary.wrench.steve_power": "Free editing of block states and textures", + "sanctuary.statuary.animals": "Animals", + "sanctuary.statuary.imports": "Imports", + "sanctuary.statuary.no_animals": "Encounter creatures to unlock their sculptures.", + "sanctuary.statuary.animal_error": "This creature’s model could not be sculpted." } diff --git a/mods/sanctuary/src/main/resources/assets/sanctuary/lang/fr_fr.json b/mods/sanctuary/src/main/resources/assets/sanctuary/lang/fr_fr.json index 51895ac..014a99c 100644 --- a/mods/sanctuary/src/main/resources/assets/sanctuary/lang/fr_fr.json +++ b/mods/sanctuary/src/main/resources/assets/sanctuary/lang/fr_fr.json @@ -2241,5 +2241,9 @@ "sanctuary.wrench.no_rotation": "Ce bloc ne possède pas d’orientation vanilla.", "sanctuary.wrench.linked": "Bloc lié à une autre partie : dissocie-le ou rétracte-le avant de le tourner.", "sanctuary.wrench.unsupported": "Cette orientation ne convient pas aux blocs voisins ou au support.", - "sanctuary.wrench.steve_power": "Édition libre des états et des textures" + "sanctuary.wrench.steve_power": "Édition libre des états et des textures", + "sanctuary.statuary.animals": "Animaux", + "sanctuary.statuary.imports": "Imports", + "sanctuary.statuary.no_animals": "Rencontrez des créatures pour débloquer leurs sculptures.", + "sanctuary.statuary.animal_error": "Impossible de sculpter le modèle de cette créature." } diff --git a/mods/sanctuary/src/main/resources/sanctuary.mixins.json b/mods/sanctuary/src/main/resources/sanctuary.mixins.json index 1502a94..74b68ae 100644 --- a/mods/sanctuary/src/main/resources/sanctuary.mixins.json +++ b/mods/sanctuary/src/main/resources/sanctuary.mixins.json @@ -273,7 +273,8 @@ "client.SuperPistonRenderMixin", "client.SuperPistonSyncMixin", "client.SeasonalPrecipitationMixin", - "client.SpecialModelsAccess" + "client.SpecialModelsAccess", + "client.SculptureParticlesMixin" ], "injectors": { "defaultRequire": 1 diff --git a/packwiz/pack.toml b/packwiz/pack.toml index a86b392..a5d35d8 100644 --- a/packwiz/pack.toml +++ b/packwiz/pack.toml @@ -1,6 +1,6 @@ name = "Sanctuary" author = "KOKA99CAB" -version = "beta.124" +version = "beta.125" pack-format = "packwiz:1.1.0" [index]