Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ec5b0d028 | ||
|
|
67e6e7c4f2 |
@@ -20,12 +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 publication
|
||||
et la synchronisation de l’instance sont préparées.
|
||||
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.
|
||||
@@ -36,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.
|
||||
|
||||
@@ -14,6 +14,19 @@ JAR Sanctuary sont des pièces jointes de releases Gitea. Aucun JAR de mod ou
|
||||
dépendance téléchargée n'entre dans l'historique Git. Le canal Beta est distinct
|
||||
de l'ancien pack 26.2.
|
||||
|
||||
## beta.124 — une boîte par sculpture
|
||||
|
||||
Une boîte ajustée commune à la sélection et aux collisions ; détails physiques
|
||||
simplifiés, orientation/ancrage et éclairage conservés.
|
||||
[Contrat et vérifications](sculpture-bounds-beta124.md).
|
||||
[Release beta.124](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.124)
|
||||
publiée ; canal `67ebcc7d07f8b75e8988f6099e3d6e5704f123fb`. Deux
|
||||
synchronisations isolées puis deux dans la même instance **Sanctuary Beta**
|
||||
réussies. Les 923 fichiers personnels et réglages suivis sont inchangés ;
|
||||
sauvegardes restées fermées. Copie préalable des fichiers remplacés dans
|
||||
`sanctuary-backups/before-beta.124/`. Packs normal/Test et template également
|
||||
copiés et vérifiés dans le dossier habituel `sanctuary-beta/build/`.
|
||||
|
||||
## beta.123 — clé dorée et clé de Steve
|
||||
|
||||
La clé dorée se limite aux orientations cohérentes ; la clé de Steve reprend
|
||||
|
||||
@@ -77,3 +77,17 @@ habituel `sanctuary-beta/build/`.
|
||||
|
||||
Le GameTest dédié reste exclu conformément au refus antérieur de son EULA.
|
||||
Aucun monde personnel n'est ouvert par les essais.
|
||||
|
||||
## Publication et installation
|
||||
|
||||
La [release beta.124](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.124)
|
||||
est publiée depuis `00179e30ef0b45ef98378bc164a7810552dd1bdd`.
|
||||
Le tag exact et les artefacts sont immuables, leurs téléchargements publics
|
||||
vérifiés. Canal packwiz : `67ebcc7d07f8b75e8988f6099e3d6e5704f123fb`.
|
||||
|
||||
Deux synchronisations isolées puis deux dans **Sanctuary Beta** réussissent.
|
||||
Un seul JAR Sanctuary beta.124 est actif, conforme au hash vérifié ; le second
|
||||
passage ne change rien. Les **923 fichiers personnels et réglages suivis**
|
||||
restent inchangés, sans ouverture de monde personnel. Copie préalable dans
|
||||
`sanctuary-backups/before-beta.124/` de l'instance existante.
|
||||
Reçus : `build/sculpture124-isolated.json` et `build/sculpture124-prism.json`.
|
||||
|
||||
@@ -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/`.
|
||||
+2
-2
@@ -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
|
||||
|
||||
@@ -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') }
|
||||
|
||||
+7
-5
@@ -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);}
|
||||
}
|
||||
|
||||
+3
-3
@@ -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<VoxelSculpture.Cell>();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);
|
||||
|
||||
+118
@@ -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<BlockPos,BlockState>();
|
||||
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<VoxelSculpture.Cell>();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);}
|
||||
}
|
||||
+36
@@ -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<Integer> 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<Particle> queue,List<StatuaryRenderer.Quad> mesh,Set<Integer> colors,BlockPos pos){
|
||||
check(!queue.isEmpty()&&queue.size()<=64,"Bounded destruction particles");var observed=new HashSet<Integer>();
|
||||
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<Particle> queue(Minecraft mc){try{var f=ParticleEngine.class.getDeclaredField("particlesToAdd");f.setAccessible(true);return (Queue<Particle>)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);}
|
||||
}
|
||||
+53
@@ -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);}
|
||||
}
|
||||
@@ -21,6 +21,8 @@ public final class ClayWorkshopScreen extends AbstractContainerScreen<ClayWorksh
|
||||
private static final Identifier SLOT=Identifier.withDefaultNamespace("container/slot");
|
||||
private static final Identifier SELECTION=fr.koka.sanctuary.SanctuaryMod.id("hud/hotbar_selection");
|
||||
private static final ExecutorService WORKER=Executors.newSingleThreadExecutor(r->{var t=new Thread(r,"Sanctuary clay models");t.setDaemon(true);return t;});
|
||||
private boolean imported;
|
||||
private List<net.minecraft.world.entity.EntityType<?>> animals=List.of();
|
||||
private List<Path> 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<ClayWorksh
|
||||
@Override protected void init(){
|
||||
super.init();
|
||||
try{files=CreationLibrary.files(CreationLibrary.Kind.MODEL);}catch(Exception e){status=ModelImportErrors.message(e);}
|
||||
page=Math.min(page,Math.max(0,(files.size()-1)/4));
|
||||
for(int i=0;i<4;i++){int at=page*4+i;if(at>=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)
|
||||
.<net.minecraft.world.entity.EntityType<?>>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<files.size();
|
||||
addRenderableWidget(Button.builder(Component.literal("→"),b->{page++;rebuildWidgets();}).bounds(leftPos+116,topPos+106,22,18).build()).active=(page+1)*4<count;
|
||||
addRenderableWidget(Button.builder(text("refresh"),b->rebuildWidgets()).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<ClayWorksh
|
||||
source=sculpture;pending=true;status=text("ready",sculpture.name());
|
||||
}));
|
||||
}
|
||||
public void load(net.minecraft.world.entity.EntityType<?> 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 AbstractContainerScreen<ClayWorksh
|
||||
g.text(font,Component.literal("→"),leftPos+254,topPos+111,0xFF404040,false);
|
||||
var detail=font.split(status,290);for(int i=0;i<Math.min(2,detail.size());i++)g.text(font,detail.get(i),leftPos+10,topPos+150+i*font.lineHeight,0xFF404040,false);
|
||||
if(detail.size()>2&&mx>=leftPos+10&&mx<leftPos+300&&my>=topPos+148&&my<topPos+174)g.setTooltipForNextFrame(font,status,mx,my);
|
||||
if(files.isEmpty())g.text(font,text("empty"),leftPos+10,topPos+32,0xFF404040,false);
|
||||
if(imported?files.isEmpty():animals.isEmpty()){var lines=font.split(text(imported?"empty":"no_animals"),128);for(int i=0;i<Math.min(6,lines.size());i++)g.text(font,lines.get(i),leftPos+10,topPos+38+i*font.lineHeight,0xFF404040,false);}
|
||||
}
|
||||
@Override protected void extractLabels(GuiGraphicsExtractor g,int mx,int my){
|
||||
g.text(font,title,10,8,0xFF303030,false);g.text(font,text("models"),10,22,0xFF404040,false);
|
||||
g.text(font,title,10,8,0xFF303030,false);
|
||||
g.text(font,text("input"),230-font.width(text("input"))/2,95,0xFF303030,false);g.text(font,text("output"),imageWidth-10-font.width(text("output")),95,0xFF303030,false);
|
||||
}
|
||||
ItemStack preview(){return preview;}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package fr.koka.sanctuary.client;
|
||||
|
||||
import fr.koka.sanctuary.SanctuaryMod;
|
||||
import fr.koka.sanctuary.plans.*;
|
||||
import java.util.*;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.multiplayer.ClientLevel;
|
||||
import net.minecraft.client.particle.SingleQuadParticle;
|
||||
import net.minecraft.core.*;
|
||||
import net.minecraft.data.AtlasIds;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.phys.*;
|
||||
|
||||
/** Chips use the already-colored surface voxels, not the technical block's clay particle material. */
|
||||
public final class SculptureParticles {
|
||||
private record Removed(long tick,BlockState state,List<StatuaryRenderer.Quad> mesh){}
|
||||
// A break event can arrive after the block update. Keep a short, bounded snapshot in that world only.
|
||||
private static final Map<ClientLevel,LinkedHashMap<BlockPos,Removed>> 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<StatuaryRenderer.Quad>();int n=Math.min(64,quads.size());
|
||||
for(int i=0;i<n;i++)sample.add(quads.get((int)((i+.5)*quads.size()/n)));
|
||||
cache.put(pos.immutable(),new Removed(level.getGameTime(),e.getBlockState(),List.copyOf(sample)));
|
||||
while(cache.size()>128)cache.remove(cache.keySet().iterator().next());
|
||||
}
|
||||
private static List<StatuaryRenderer.Quad> 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<StatuaryRenderer.Quad> 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;i<count;i++){
|
||||
// Stratified surface sampling covers colors across the sculpture without 4096 particles.
|
||||
int start=i*quads.size()/count,end=(i+1)*quads.size()/count;
|
||||
var q=quads.get(start+random.nextInt(end-start));var point=center(q);
|
||||
spawn(level,pos,point,q.color(),(random.nextDouble()-.5)*.12,random.nextDouble()*.12,(random.nextDouble()-.5)*.12);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
public static boolean hit(ClientLevel level,BlockPos pos,Direction side,BlockState state){
|
||||
if(!state.is(Statuary.BLOCK))return false;
|
||||
if(!(level.getBlockEntity(pos) instanceof StatuaryEntity entity))return true;
|
||||
var quads=mesh(entity);if(quads.isEmpty())return true;
|
||||
var mc=Minecraft.getInstance();Vec3 aim=mc.hitResult instanceof BlockHitResult h&&h.getBlockPos().equals(pos)?h.getLocation().subtract(Vec3.atLowerCornerOf(pos)):new Vec3(.5,.5,.5);
|
||||
StatuaryRenderer.Quad nearest=null;double distance=Double.POSITIVE_INFINITY;
|
||||
// The selection box can cover a hole: use the nearest actual surface, never empty space.
|
||||
for(var q:quads){double d=center(q).distanceToSqr(aim);if(d<distance){distance=d;nearest=q;}}
|
||||
if(nearest!=null)spawn(level,pos,center(nearest),nearest.color(),side.getStepX()*.035,.03+side.getStepY()*.035,side.getStepZ()*.035);
|
||||
return true;
|
||||
}
|
||||
static Vec3 center(StatuaryRenderer.Quad q){
|
||||
double x=q.x()+1/32d,y=q.y()+1/32d,z=q.z()+1/32d;
|
||||
return switch(q.side()){case 0->new 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(){}
|
||||
}
|
||||
@@ -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<StatuaryEntity,StatuaryRenderer.State> {
|
||||
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<Placement,List<Quad>> MESHES=new LinkedHashMap<>(32,.75f,true);
|
||||
private static final Map<TypedEntityData<?>,VoxelSculpture> ITEMS=new LinkedHashMap<>(32,.75f,true);
|
||||
private static final float[][] FACES={
|
||||
@@ -40,20 +40,20 @@ public final class StatuaryRenderer implements BlockEntityRenderer<StatuaryEntit
|
||||
public State createRenderState(){return new State();}
|
||||
@Override public void extractRenderState(StatuaryEntity entity,State state,float delta,Vec3 camera,ModelFeatureRenderer.CrumblingOverlay breakProgress){
|
||||
BlockEntityRenderer.super.extractRenderState(entity,state,delta,camera,breakProgress);
|
||||
state.mesh=mesh(entity.sculpture(),entity.getBlockState().getValue(Statuary.FACING),entity.getBlockState().getValue(Statuary.ANCHOR));
|
||||
state.mesh=mesh(entity.sculpture(),entity.getBlockState().getValue(Statuary.FACING),entity.getBlockState().getValue(Statuary.ANCHOR),entity.getBlockState().getValue(Statuary.HEIGHT));
|
||||
}
|
||||
public void submit(State state,PoseStack pose,SubmitNodeCollector collector,CameraRenderState camera){
|
||||
draw(state.mesh,pose,collector,state.lightCoords,OverlayTexture.NO_OVERLAY);
|
||||
}
|
||||
private static List<Quad> 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<Quad> 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<Quad>();
|
||||
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 BlockEntityRenderer<StatuaryEntit
|
||||
var data=stack.get(DataComponents.BLOCK_ENTITY_DATA);if(data==null)return List.of();
|
||||
var sculpture=ITEMS.get(data);
|
||||
if(sculpture==null){sculpture=StatuaryEntity.sculpture(stack);if(sculpture==null)return List.of();ITEMS.put(data,sculpture);if(ITEMS.size()>64)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<Quad> 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<Vector3fc> out){out.accept(new Vector3f(0,0,0));out.accept(new Vector3f(1,1,1));}
|
||||
|
||||
+26
@@ -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<Boolean> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<RegistryFriendlyByteBuf,View> 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<String> statues){
|
||||
public View { statues=List.copyOf(statues); }
|
||||
public static final StreamCodec<RegistryFriendlyByteBuf,View> 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<String>(n);for(int i=0;i<n;i++)ids.add(b.readUtf(128));return new View(pos,token,ids);});
|
||||
}
|
||||
/** At most 24 KiB of cells plus 256 palette colors in one atomic, bounded selection, scoped to one open menu. */
|
||||
public record Select(int menu,UUID token,VoxelSculpture sculpture,Item material,SculpturePalette palette) implements CustomPacketPayload {
|
||||
@@ -73,7 +76,7 @@ public final class ClayWorkshopMenu extends AbstractContainerMenu {
|
||||
var hit=new BlockHitResult(Vec3.atCenterOf(view.pos),Direction.UP,view.pos,false);
|
||||
if(UseBlockCallback.EVENT.invoker().interact(p,level,InteractionHand.MAIN_HAND,hit)!=InteractionResult.PASS||!stillValid(p))return;
|
||||
lastSelection=level.getGameTime();
|
||||
if(sculpture!=null&&(!material(input.getItem(0))||!input.getItem(0).is(material)||palette==null))return;
|
||||
if(sculpture!=null&&(!StatueDiscoveries.allowedModel(sculpture.modelId(),view.statues())||!material(input.getItem(0))||!input.getItem(0).is(material)||palette==null))return;
|
||||
if(!Objects.equals(selected,sculpture)||!Objects.equals(selectedPalette,palette))
|
||||
selectedResult=sculpture==null||palette==null?null:palette.apply(sculpture);
|
||||
selected=sculpture;selectedMaterial=material;selectedPalette=palette;refresh();broadcastChanges();
|
||||
|
||||
@@ -7,27 +7,29 @@ import net.minecraft.world.item.context.BlockPlaceContext;
|
||||
import net.minecraft.world.level.block.Mirror;
|
||||
import net.minecraft.world.level.block.Rotation;
|
||||
|
||||
/** World-space anchor of the occupied block, always on the whole-voxel grid. */
|
||||
/** World-space horizontal anchor; old names remain valid in saved blockstates. */
|
||||
public enum SculptureAnchor implements StringRepresentable {
|
||||
CENTER(null), NORTH(Direction.NORTH), SOUTH(Direction.SOUTH), EAST(Direction.EAST), WEST(Direction.WEST);
|
||||
CENTER(0,0), NORTH(0,-1), SOUTH(0,1), EAST(1,0), WEST(-1,0),
|
||||
NORTH_WEST(-1,-1), NORTH_EAST(1,-1), SOUTH_WEST(-1,1), SOUTH_EAST(1,1);
|
||||
|
||||
private final Direction direction;
|
||||
SculptureAnchor(Direction direction){this.direction=direction;}
|
||||
private final int x,z;
|
||||
SculptureAnchor(int x,int z){this.x=x;this.z=z;}
|
||||
public String getSerializedName(){return name().toLowerCase(Locale.ROOT);}
|
||||
public SculptureAnchor rotate(Rotation rotation){return direction==null?this:of(rotation.rotate(direction));}
|
||||
public SculptureAnchor mirror(Mirror mirror){return direction==null?this:of(mirror.mirror(direction));}
|
||||
public SculptureAnchor rotate(Rotation rotation){return switch(rotation){
|
||||
case NONE -> 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;}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import net.minecraft.network.chat.Component;
|
||||
public final class Statuary {
|
||||
public static final EnumProperty<Direction> FACING=BlockStateProperties.HORIZONTAL_FACING;
|
||||
public static final EnumProperty<SculptureAnchor> ANCHOR=EnumProperty.create("anchor",SculptureAnchor.class);
|
||||
public static final EnumProperty<SculptureHeight> 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<ClayWorkshopMenu.View>(){
|
||||
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<Block,BlockState> 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<Block,BlockState> 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);}
|
||||
|
||||
@@ -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){
|
||||
|
||||
@@ -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<String,String> 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<String> known){var entity=Models.IDS.get(model);return entity==null||known.contains(entity);}
|
||||
private StatueDiscoveries(){}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"sources": [
|
||||
{"type": "minecraft:single", "resource": "sanctuary:misc/sculpture_white", "sprite": "sanctuary:sculpture_particle"}
|
||||
]
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -273,7 +273,8 @@
|
||||
"client.SuperPistonRenderMixin",
|
||||
"client.SuperPistonSyncMixin",
|
||||
"client.SeasonalPrecipitationMixin",
|
||||
"client.SpecialModelsAccess"
|
||||
"client.SpecialModelsAccess",
|
||||
"client.SculptureParticlesMixin"
|
||||
],
|
||||
"injectors": {
|
||||
"defaultRequire": 1
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
name = "Sanctuary"
|
||||
author = "KOKA99CAB"
|
||||
version = "beta.124"
|
||||
version = "beta.125"
|
||||
pack-format = "packwiz:1.1.0"
|
||||
|
||||
[index]
|
||||
|
||||
Reference in New Issue
Block a user