Compare commits

..
2 Commits
Author SHA1 Message Date
koka edc7fc3dfb Match sculpture collisions to voxels and fix light occlusion (beta.122)
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 15:02:05 +02:00
koka 9b0104d27a Record beta.121 publication and preserved Prism sync
Build Sanctuary / build (push) Canceled after 0s
2026-09-17 14:11:39 +02:00
12 changed files with 367 additions and 24 deletions
+16 -8
View File
@@ -20,11 +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.121** reprend la nouvelle texture de clé dorée et ajoute
**Personnaliser Sanctuary** à l'écran des packs de ressources. Le bouton crée
une copie complète, modifiable, que les mises à jour ne réécrivent jamais.
Le [ticket de livraison](docs/resourcepack-template-beta121.md) détaille le
fonctionnement et les vérifications client et archives réussies.
La version **beta.122** adapte la sélection et les collisions des sculptures
à leurs voxels réels et supprime leurs ombres de blocs pleins. Les modèles
restent alignés au centre ou au bord choisi, dans les quatre orientations.
Le [ticket de livraison](docs/sculpture-shape-light-beta122.md) détaille la
forme physique, les règles de lumière et les vérifications réussies.
La [livraison précédente](docs/clay-workshop-integration-beta110.md) réunit déjà
latelier, les œufs et la neige saisonnière dans linstance **Sanctuary Beta**.
Les [synchronisations antérieures](docs/git-sync-beta092.md) restent documentées.
@@ -35,10 +35,18 @@ La [carte native beta.005](docs/atlas-beta005.md) prend le relais après
l’étude de Xaero et des alternatives libres.
## beta.122 — Sculptures : forme et lumière
Collisions et sélection sur les voxels réels, creux traversables, lumière sans
occlusion cubique. [Contrat et vérifications](docs/sculpture-shape-light-beta122.md).
## beta.121 — Texture de clé et pack personnel
Copie complète des ressources, création depuis le jeu et guide FR/EN inclus.
[Contrat et vérifications](docs/resourcepack-template-beta121.md).
[Contrat et vérifications](docs/resourcepack-template-beta121.md) ·
[Template personnel](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.121/Sanctuary-Template-beta.121.zip) ·
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.121/Sanctuary-beta.121.mrpack) ·
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.121/Sanctuary-Test-beta.121.mrpack).
## beta.120 — Clé dorée et états de blocs
@@ -2138,7 +2146,7 @@ Depuis beta.090, la cible est **Minecraft 26.3 finale**, publiée le 15 septembr
| Fabric API | 0.160.5+26.3 |
| Fabric Loom | 1.17.20 |
| Gradle Wrapper | 9.5.1, distribution vérifiée par SHA-256 |
| Sanctuary / pack | beta.121 |
| Sanctuary / pack | beta.122 |
Java 25 et Python 3.11 ou plus récent sont nécessaires. Le script pack utilise
uniquement la bibliothèque standard et repère aussi une installation Python
@@ -2167,7 +2175,7 @@ décrits dans [Validation](docs/testing.md).
Résultats :
- `mods/sanctuary/build/libs/sanctuary-beta.121.jar` : mod à installer avec
- `mods/sanctuary/build/libs/sanctuary-beta.122.jar` : mod à installer avec
Fabric API sur la version Minecraft indiquée.
- `build/packwiz/` : pack de développement complet, avec le mod construit et
l'index vérifié. Voir [Installation packwiz](packwiz/README.md).
+12
View File
@@ -14,6 +14,18 @@ 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.121 — texture de clé et pack personnel
PNG du créateur et copie complète modifiable depuis l'écran des packs.
[Contrat et vérifications](resourcepack-template-beta121.md).
[Release beta.121](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.121)
publiée ; canal `e3e6572da9288fbbcfa36bf6b7dd3aa0d1c54f5e`. 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.121/`. Le template personnel est créé par le
joueur, jamais géré ou réécrit par packwiz.
## beta.120 — clé dorée et états de blocs
Gestes du debug stick, rotation des textures et assemblages prioritaires.
+17 -1
View File
@@ -106,4 +106,20 @@ JAR Sanctuary : SHA-256
## Publication et installation
Artefacts prêts ; publication du canal et synchronisation Prism à effectuer.
La [release beta.121](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.121)
est publiée depuis `d9b3dd6bedf32448f8c6e5bfa4da10cb69968bc7`. Tag et artefacts immuables,
téléchargements publics vérifiés. Canal packwiz :
`e3e6572da9288fbbcfa36bf6b7dd3aa0d1c54f5e`.
Deux synchronisations isolées puis deux dans **Sanctuary Beta** réussissent.
Un seul JAR Sanctuary beta.121 est actif ; le second passage est identique.
Les **923 fichiers personnels et réglages suivis** restent inchangés,
y compris les packs de ressources. Aucune copie personnelle n'est créée par
la mise à jour : chaque joueur utilise le bouton quand il le souhaite.
Aucun monde personnel ouvert. Copie préalable dans
`sanctuary-backups/before-beta.121/` de l'instance existante ; reçus
`build/template121-isolated.json` et `build/template121-prism.json`.
- [Template personnel complet](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.121/Sanctuary-Template-beta.121.zip).
- [Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.121/Sanctuary-beta.121.mrpack).
- [Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.121/Sanctuary-Test-beta.121.mrpack).
+90
View File
@@ -0,0 +1,90 @@
# SCULPT-09 — Forme physique et lumière — beta.122
Contrat du 17 septembre 2026, branche `codex/sculpture-shape-light-beta122`.
Le créateur demande des bounding boxes adaptées aux sculptures et la suppression
des ombres cubiques visibles lorsqu'on en pose plusieurs côte à côte.
La sélection, le contact avec les entités et les collisions suivent les voxels
occupés de chaque sculpture, y compris les creux. Le rendu et la forme physique
partagent leur placement entier sur la grille 1/16, après orientation et ancrage.
Le centre, les quatre bords et les dimensions impaires gardent leur alignement.
La pose vérifie aussi les voxels portés avant que lentité de bloc existe :
elle refuse une intersection avec le joueur et autorise les espaces libres.
La forme est construite directement sur une grille native 16³ et mise en cache
par entité de bloc, modèle et état ; aucun calcul complet à chaque interrogation.
Les sculptures transmettent la lumière et ne sont plus utilisées comme cubes
pleins par l'occlusion ambiante. Leurs faces restent éclairées par le monde,
sans émission de lumière et sans rendu à luminosité maximale. Cette règle est
commune aux sculptures : l'éclairage natif stocké par bloc ne permet pas une
ombre distincte pour chaque petit voxel. L'atelier d'argile reste inchangé.
Aucun identifiant, blockstate, format d'item ou schéma sauvegardé ne change.
Les formes sont recalculées à partir des voxels existants ; aucune migration,
aucun parcours de monde ni régénération de chunk. Les données inconnues gardent
leur conservation opaque et une petite base sélectionnable pour récupérer l'item.
Le travail de documentation déjà présent dans l'autre branche reste intact.
## Vérifications et livraison
Le parcours `Sculpture122ClientChecks` passe en **37 s** sur Minecraft 26.3,
client macOS et serveur intégré, dans un monde plat jetable de graine 122 :
- Pose par la commande native d'utilisation ; sélection du pilier et visée
à travers le trou d'une arche jusqu'au bloc derrière, captures relues.
- Requêtes natives de collision côté serveur et client : voxels occupés solides,
espace vide libre, aucune profondeur artificielle d'un bloc entier.
- Pose refusée dans le joueur, sans consommer l'objet ; petite sculpture acceptée
dans la partie libre du même bloc, sans intersection avec le joueur.
- **80 cas** confrontent le volume et les faces de collision au maillage réellement
émis : arche creuse, marges vides, cube de 4 096 voxels, voxel unique, quatre
orientations et cinq ancrages. Chaque interrogation répétée réutilise la forme.
- Régression de **5 120 placements** du rendu existant : dimensions paires/impaires,
orientations et ancrages, plus marqueurs de direction et modèles avec marges.
- Changement d'orientation/ancrage puis changement des voxels sur la même entité :
cache recalculé et forme client synchronisée.
- **75 sculptures en trois couches** : skylight natif conservé à 15 partout.
Un toit de pierre témoin produit toujours une ombre. La lumière d'un bloc
traverse une sculpture avec l'atténuation de distance native (13 à deux blocs).
- Le rendu reçoit la lumière réelle du monde : 15 au ciel, **0 dans une pièce
fermée et obscure**. Aucun rendu artificiellement lumineux.
- Sauvegarde/reconnexion : modèle, collisions et lumière conservés sur les
deux côtés, sans fichier 3D source.
```sh
./gradlew :sanctuary:runClientGameTest \
-PsanctuaryClientTests=true -PsanctuarySculpture122ClientTests=true \
-PsanctuaryClientNoVsync=true -PsanctuaryQuickTests=true
```
Marqueurs `SCULPTURE122_PASS`, `SCULPTURE122_GEOMETRY_PASS`,
`SCULPTURE122_PLACEMENT_PASS`, `SCULPTURE122_LIGHT_PASS` et
`SCULPTURE113_GEOMETRY_PASS` dans `build/sculpture122-client.log` ; captures
conservées dans `build/sculpture122-evidence/`. Le test prépare explicitement
son sol à Y=0 : le preset plat nu commence à Y=-1.
Aucun monde personnel ouvert ; pas d'essai Windows ni depuis deux ordinateurs,
ni de garantie sur les ombres ajoutées par des shaders tiers.
Le GameTest serveur dédié reste exclu conformément au refus antérieur de son EULA.
`./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest`
passe en **2 min 13 s**, 126 tâches. Les sources JAR correspondent aux sources
Java du dépôt. Par rapport à beta.121, les différences de production se limitent
aux classes des sculptures et à la nouvelle géométrie partagée (14 entrées,
y compris le déplacement du sélecteur synthétique d'orientation). Toutes les
ressources client, données serveur et configurations de mixins sont identiques.
Les packs normal/Test contiennent le même JAR, sans sauvegarde ni fixture.
Le template beta.122 contient les mêmes ressources que beta.121, octet pour octet.
Reçu local : `build/sculpture122-artifact.json`.
- `Sanctuary-beta.122.mrpack` : 10267459 octets, SHA-256
`b6e24d65797eb7f16670d2f3a591b5f2476820608f72241ecd37fb78a486008e`.
- `Sanctuary-Test-beta.122.mrpack` : 10286380 octets, SHA-256
`90487ee3ad4298fccb8c24e8781af4cbf043d88dc0379fba5c21d99733dac92d`.
JAR Sanctuary : SHA-256
`ff8ebf89f39a64809fae6912253386793e9b5e66ec0adb9fa7521729893d406e`.
## Publication et installation
Artefacts prêts ; publication et synchronisation à effectuer.
+2 -2
View File
@@ -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.121
pack_version=beta.121
mod_version=beta.122
pack_version=beta.122
resource_pack_version=beta.121
maven_group=fr.koka.sanctuary
jei_version=30.32.0-sanctuary.3
+4
View File
@@ -93,6 +93,10 @@ tasks.named('runGameTest') {
}
tasks.named('processGametestResources') {
def sculpture122Client = providers.gradleProperty('sanctuarySculpture122ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuarySculpture122ClientTests', sculpture122Client)
if (sculpture122Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Sculpture122ClientChecks') }
def template121Client = providers.gradleProperty('sanctuaryResourceTemplate121ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryResourceTemplate121ClientTests', template121Client)
if (template121Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.ResourceTemplate121ClientChecks') }
@@ -0,0 +1,150 @@
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.Minecraft;
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.nbt.CompoundTag;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.util.ProblemReporter;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.item.BlockItem;
import net.minecraft.world.item.context.BlockPlaceContext;
import net.minecraft.world.level.*;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.storage.TagValueInput;
import net.minecraft.world.phys.*;
import net.minecraft.world.phys.shapes.*;
/** Native picking/collision and light propagation, compared with the actual emitted sculpture mesh. */
public final class Sculpture122ClientChecks implements FabricClientGameTest {
private static final String MODEL="sanctuary:model/"+"1".repeat(64);
private static final BlockPos TARGET=new BlockPos(0,1,0),SKY=new BlockPos(10,2,2),CONTROL=new BlockPos(18,2,2);
private static final VoxelSculpture ARCH=arch();
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);
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(new KeyEvent(InputConstants.KEY_RETURN,0,0));
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(new KeyEvent(InputConstants.KEY_RETURN,0,0));intro.set(false);
}});
net.fabricmc.fabric.api.client.gametest.v1.world.TestWorldSave saved;
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Sculpture122 "+UUID.randomUUID());s.setSeed("122");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()){
saved=world.getWorldSave();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=-14;x<=21;x++)for(int z=-6;z<=9;z++)l.setBlockAndUpdate(new BlockPos(x,0,z),Blocks.STONE.defaultBlockState());p.teleportTo(.5,1,3.2);p.setYRot(0);p.getInventory().setItem(p.getInventory().getSelectedSlot(),StatuaryEntity.item(ARCH));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");
});
c.waitFor(m->m.player.distanceToSqr(.5,1,3.2)<.1&&m.player.getMainHandItem().is(Statuary.ITEM)&&m.level.getBlockState(TARGET.below()).is(Blocks.STONE),150);
c.runOnClient(m->m.player.lookAt(net.minecraft.commands.arguments.EntityAnchorArgument.Anchor.EYES,new Vec3(.5,.999,.5)));c.waitTicks(3);
c.runOnClient(m->check(m.hitResult instanceof BlockHitResult hit&&hit.getBlockPos().equals(TARGET.below())&&hit.getDirection()==Direction.UP,"Cursor aims at placement support"));
c.getInput().holdKey(o->o.keyUse);c.waitTicks(1);c.getInput().releaseKey(o->o.keyUse);c.waitTicks(3);
server.runOnServer(s->System.out.println("SCULPTURE122_PLACED "+player(s).level().getBlockState(TARGET)+" "+player(s).level().getBlockEntity(TARGET)));
c.waitFor(m->m.level.getBlockEntity(TARGET) instanceof StatuaryEntity e&&ARCH.equals(e.sculpture()),150);
server.runOnServer(s->{physical(player(s).level(),TARGET);placementOverlap(player(s));});
c.runOnClient(m->{physical(m.level,TARGET);geometry(m);Sculpture113ClientChecks.geometry(m,MODEL);});
c.runOnClient(m->m.player.lookAt(net.minecraft.commands.arguments.EntityAnchorArgument.Anchor.EYES,new Vec3(.0625,1.5,.5)));c.waitTicks(5);
c.runOnClient(m->check(m.hitResult instanceof BlockHitResult hit&&hit.getBlockPos().equals(TARGET),"Native cursor selects an occupied pillar"));
c.takeScreenshot("sculpture122-voxel-outline");
server.runOnServer(s->player(s).level().setBlockAndUpdate(TARGET.north(),Blocks.REDSTONE_BLOCK.defaultBlockState()));
c.waitFor(m->m.level.getBlockState(TARGET.north()).is(Blocks.REDSTONE_BLOCK),100);
c.runOnClient(m->m.player.lookAt(net.minecraft.commands.arguments.EntityAnchorArgument.Anchor.EYES,new Vec3(.5,1.5,.5)));c.waitTicks(5);
c.runOnClient(m->check(m.hitResult instanceof BlockHitResult hit&&hit.getBlockPos().equals(TARGET.north()),"Native cursor passes through the sculpture hole to the block behind"));
c.takeScreenshot("sculpture122-pick-through-hole");
// A state edit and fresh entity payload must invalidate geometry on both sides.
server.runOnServer(s->{var l=player(s).level();l.setBlockAndUpdate(TARGET,l.getBlockState(TARGET).setValue(Statuary.FACING,Direction.WEST).setValue(Statuary.ANCHOR,SculptureAnchor.EAST));});
c.waitFor(m->m.level.getBlockState(TARGET).getValue(Statuary.FACING)==Direction.WEST,100);
server.runOnServer(s->{var l=player(s).level();var bounds=l.getBlockState(TARGET).getShape(l,TARGET).bounds();check(bounds.maxX==1&&bounds.getXsize()==.25,"Server shape rotates and moves to aimed edge");});
c.runOnClient(m->{var bounds=m.level.getBlockState(TARGET).getShape(m.level,TARGET).bounds();check(bounds.maxX==1&&bounds.getXsize()==.25,"Client shape follows changed blockstate");});
var small=new VoxelSculpture(MODEL,"Small",1,1,1,List.of(new VoxelSculpture.Cell(0,0,0,-1)));
server.runOnServer(s->place(player(s).level(),TARGET,small,Direction.WEST,SculptureAnchor.EAST));
c.waitFor(m->m.level.getBlockEntity(TARGET) instanceof StatuaryEntity e&&small.equals(e.sculpture()),100);
c.runOnClient(m->check(m.level.getBlockState(TARGET).getShape(m.level,TARGET).bounds().getYsize()==1/16d,"Changed block entity payload invalidates cached shape"));
server.runOnServer(s->{var l=player(s).level();place(l,TARGET,ARCH,Direction.SOUTH,SculptureAnchor.CENTER);l.setBlockAndUpdate(TARGET.north(),Blocks.AIR.defaultBlockState());
for(int x=8;x<=12;x++)for(int z=0;z<=4;z++)for(int y=3;y<=5;y++)place(l,new BlockPos(x,y,z),ARCH,Direction.SOUTH,SculptureAnchor.CENTER);
for(int x=16;x<=20;x++)for(int z=0;z<=4;z++)l.setBlockAndUpdate(new BlockPos(x,3,z),Blocks.STONE.defaultBlockState());
l.setBlockAndUpdate(new BlockPos(-8,2,0),Blocks.GLOWSTONE.defaultBlockState());place(l,new BlockPos(-7,2,0),ARCH,Direction.SOUTH,SculptureAnchor.CENTER);
});
server.waitFor(s->{var l=player(s).level();return l.getBrightness(LightLayer.SKY,SKY)==15&&l.getBrightness(LightLayer.SKY,CONTROL)<15&&l.getBrightness(LightLayer.BLOCK,new BlockPos(-6,2,0))==13;},200);
c.waitFor(m->m.level.getBrightness(LightLayer.SKY,SKY)==15&&m.level.getBrightness(LightLayer.SKY,CONTROL)<15&&m.level.getBrightness(LightLayer.BLOCK,new BlockPos(-6,2,0))==13,200);
server.runOnServer(s->{var l=player(s).level();for(int x=8;x<=12;x++)for(int z=0;z<=4;z++)for(int y=3;y<=5;y++)check(l.getBrightness(LightLayer.SKY,new BlockPos(x,y,z))==15,"All 75 sculptures keep full native skylight");});
c.runOnClient(m->{var e=(StatuaryEntity)m.level.getBlockEntity(new BlockPos(10,4,2));var renderer=new StatuaryRenderer();var state=renderer.createRenderState();renderer.extractRenderState(e,state,0,Vec3.ZERO,null);check(net.minecraft.util.LightCoordsUtil.sky(state.lightCoords)==15,"Actual renderer receives full skylight inside a group");});
// Visual crowd at ground level: no whole-block AO squares between the models.
server.runOnServer(s->{var p=player(s);for(int x=-3;x<=3;x++)for(int z=-5;z<=-2;z++)place(p.level(),new BlockPos(x,1,z),ARCH,Direction.from2DDataValue(Math.floorMod(x+z,4)),SculptureAnchor.CENTER);p.teleportTo(.5,3,5.5);p.getAbilities().flying=true;p.onUpdateAbilities();});
c.waitFor(m->m.player.distanceToSqr(.5,3,5.5)<.1,100);c.runOnClient(m->m.player.lookAt(net.minecraft.commands.arguments.EntityAnchorArgument.Anchor.EYES,new Vec3(.5,1,-3)));c.waitTicks(12);c.takeScreenshot("sculpture122-crowd-light");
// Transparent-to-light is not emissive: in a closed room sculpture faces stay dark.
server.runOnServer(s->{var l=player(s).level();for(int x=-13;x<=-9;x++)for(int y=1;y<=5;y++)for(int z=5;z<=9;z++)l.setBlockAndUpdate(new BlockPos(x,y,z),x==-13||x==-9||y==1||y==5||z==5||z==9?Blocks.STONE.defaultBlockState():Blocks.AIR.defaultBlockState());place(l,new BlockPos(-11,2,7),ARCH,Direction.SOUTH,SculptureAnchor.CENTER);});
c.waitFor(m->m.level.getBlockEntity(new BlockPos(-11,2,7)) instanceof StatuaryEntity&&m.level.getBrightness(LightLayer.SKY,new BlockPos(-11,2,7))==0&&m.level.getBrightness(LightLayer.BLOCK,new BlockPos(-11,2,7))==0,200);
c.runOnClient(m->{var renderer=new StatuaryRenderer();var state=renderer.createRenderState();renderer.extractRenderState((StatuaryEntity)m.level.getBlockEntity(new BlockPos(-11,2,7)),state,0,Vec3.ZERO,null);check(state.lightCoords==0,"No fullbright workaround: a dark room produces dark faces");});
System.out.println("SCULPTURE122_LIGHT_PASS: 75 stacked models sky=15; opaque control shadow; block light=13 across sculpture; dark room=0; shade brightness=1");
}
try(var world=saved.open()){
c.waitFor(m->m.player!=null&&m.gui.screen()==null&&m.level.getBlockEntity(TARGET) instanceof StatuaryEntity e&&ARCH.equals(e.sculpture()),1200);
world.getServer().runOnServer(s->physical(player(s).level(),TARGET));c.runOnClient(m->physical(m.level,TARGET));
c.waitFor(m->m.level.getBrightness(LightLayer.SKY,SKY)==15,200);
System.out.println("SCULPTURE122_PASS: native placement, voxel outline and picking through holes, authoritative collisions, shared render geometry, cache refresh, light propagation and save/reconnect");
}
}
private static void placementOverlap(ServerPlayer player){
var pos=new BlockPos(3,1,0);var level=player.level();player.teleportTo(3.75,1,.5);player.setYRot(0);
var filled=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++)filled.add(new VoxelSculpture.Cell(x,y,z,-1));
var dense=new VoxelSculpture(MODEL,"Solid",16,16,16,filled);var stack=StatuaryEntity.item(dense);player.getInventory().setItem(player.getInventory().getSelectedSlot(),stack);
var hit=new BlockHitResult(new Vec3(3.01,1,.5),Direction.UP,pos.below(),false);
var blocked=((BlockItem)Statuary.ITEM).place(new BlockPlaceContext(player,InteractionHand.MAIN_HAND,stack,hit));
check(!blocked.consumesAction()&&level.getBlockState(pos).isAir()&&stack.getCount()==1,"Native placement refuses occupied voxels inside player without consuming sculpture");
var postCells=new ArrayList<VoxelSculpture.Cell>();for(int y=0;y<16;y++)for(int x=0;x<2;x++)for(int z=0;z<2;z++)postCells.add(new VoxelSculpture.Cell(x,y,z,-1));
var post=new VoxelSculpture(MODEL,"Post",2,16,2,postCells);stack=StatuaryEntity.item(post);player.getInventory().setItem(player.getInventory().getSelectedSlot(),stack);
var allowed=((BlockItem)Statuary.ITEM).place(new BlockPlaceContext(player,InteractionHand.MAIN_HAND,stack,hit));
check(allowed.consumesAction()&&level.getBlockState(pos).is(Statuary.BLOCK),"Native placement allows empty part of block beside player");
check(level.noCollision(player,player.getBoundingBox()),"Accepted sculpture does not intersect player");
level.setBlockAndUpdate(pos,Blocks.AIR.defaultBlockState());player.teleportTo(.5,1,3.2);player.getInventory().setItem(player.getInventory().getSelectedSlot(),StatuaryEntity.item(ARCH));player.inventoryMenu.broadcastFullState();
System.out.println("SCULPTURE122_PLACEMENT_PASS native item rejects occupied voxels, allows empty space, preserves stack on rejection");
}
private static void physical(Level level,BlockPos pos){
var state=level.getBlockState(pos);var shape=state.getShape(level,pos);var collision=state.getCollisionShape(level,pos);
check(shape==collision,"Selection and native collision share cached shape");check(shape.bounds().getZsize()==.25,"No invisible full-block depth");
check(level.clip(new ClipContext(new Vec3(.5,1.5,2),new Vec3(.5,1.5,-.1),ClipContext.Block.OUTLINE,ClipContext.Fluid.NONE,CollisionContext.empty())).getType()==HitResult.Type.MISS,"Outline ray passes through empty arch center");
check(level.clip(new ClipContext(new Vec3(.0625,1.5,2),new Vec3(.0625,1.5,-.1),ClipContext.Block.COLLIDER,ClipContext.Fluid.NONE,CollisionContext.empty())).getType()==HitResult.Type.BLOCK,"Collision ray hits pillar");
check(level.noCollision(new AABB(.35,1.2,.35,.65,1.7,.65)),"Native collision query leaves hole empty");
check(!level.noCollision(new AABB(.02,1.2,.4,.1,1.7,.6)),"Native collision query blocks occupied pillar");
check(!state.isCollisionShapeFullBlock(level,pos)&&!state.isSolidRender()&&!state.canOcclude(),"Sculpture is not a solid occluding cube");
check(state.getOcclusionShape().isEmpty()&&state.propagatesSkylightDown()&&state.getLightDampening()==0&&state.getShadeBrightness(level,pos)==1,"Light metadata never reintroduces full-block occlusion");
check(!state.isSuffocating(level,pos)&&state.getLightEmission()==0,"Decorative shape neither suffocates nor emits light");
}
private static void geometry(Minecraft m){
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);
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(volume==model.cells().size()/4096d,"Shape contains exactly the occupied volume");
var renderer=new StatuaryRenderer();var render=renderer.createRenderState();renderer.extractRenderState(entity,render,0,Vec3.ZERO,null);
for(var q:render.mesh){var center=new Vec3(q.x()+1/32d,q.y()+1/32d,q.z()+1/32d);check(boxes.stream().anyMatch(b->b.contains(center)),"Every rendered cell is physically present");
var normal=switch(q.side()){case 0->Direction.NORTH;case 1->Direction.SOUTH;case 2->Direction.WEST;case 3->Direction.EAST;case 4->Direction.UP;default->Direction.DOWN;};var neighbor=center.add(normal.getStepX()/16d,normal.getStepY()/16d,normal.getStepZ()/16d);check(boxes.stream().noneMatch(b->b.contains(neighbor)),"Rendered exposed face is also exposed physically");}
cases++;
}
System.out.println("SCULPTURE122_GEOMETRY_PASS "+cases+" native voxel shapes agree with emitted meshes, including holes, padding, dense 4096 cells, single voxel, 4 directions and 5 anchors");
}
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);
}
private static void load(StatuaryEntity entity,VoxelSculpture model,Level level){var tag=new CompoundTag();tag.put("sculpture",StatuaryEntity.encode(model));entity.loadWithComponents(TagValueInput.create(ProblemReporter.DISCARDING,level.registryAccess(),tag));}
private static VoxelSculpture arch(){var cells=new ArrayList<VoxelSculpture.Cell>();for(int x=0;x<16;x++)for(int y=0;y<16;y++)for(int z=0;z<4;z++)if(x<2||x>=14||y>=14)cells.add(new VoxelSculpture.Cell(x,y,z,0xffc99679));return new VoxelSculpture(MODEL,"Arch",16,16,4,cells);}
private static ServerPlayer player(MinecraftServer server){return server.getPlayerList().getPlayers().getFirst();}
private static void check(boolean value,String reason){if(!value)throw new AssertionError(reason);}
}
@@ -47,16 +47,9 @@ public final class StatuaryRenderer implements BlockEntityRenderer<StatuaryEntit
}
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;
// Rotate integer cells before anchoring: odd dimensions never introduce half-pixel offsets.
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();
var placed=SculptureGeometry.place(sculpture,facing,anchor);sculpture=placed.sculpture();
var occupied=new BitSet(4096);for(var cell:sculpture.cells())occupied.set(cell.key());var quads=new ArrayList<Quad>();
int minX=16,minZ=16,maxX=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);
}
float ox=(anchor.originX(maxX-minX)-minX)/16f,oz=(anchor.originZ(maxZ-minZ)-minZ)/16f;
float ox=placed.offsetX()/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;
@@ -0,0 +1,35 @@
package fr.koka.sanctuary.plans;
import net.minecraft.core.Direction;
import net.minecraft.world.phys.shapes.BitSetDiscreteVoxelShape;
import net.minecraft.world.phys.shapes.CubeVoxelShape;
import net.minecraft.world.phys.shapes.VoxelShape;
/** One integer placement shared by rendering, picking and server-side collisions. */
public final class SculptureGeometry {
public record Placement(VoxelSculpture sculpture, int offsetX, int offsetZ) {}
private SculptureGeometry() {}
public static Placement place(VoxelSculpture sculpture, Direction facing, SculptureAnchor anchor) {
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;
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);
}
return new Placement(sculpture, anchor.originX(maxX - minX) - minX,
anchor.originZ(maxZ - minZ) - minZ);
}
public static VoxelShape shape(VoxelSculpture sculpture, Direction facing, SculptureAnchor anchor) {
var placed = place(sculpture, facing, anchor);
var grid = new BitSetDiscreteVoxelShape(16, 16, 16);
for (var cell : placed.sculpture().cells()) {
grid.fill(cell.x() + placed.offsetX(), cell.y(), cell.z() + placed.offsetZ());
}
// Build the bounded native voxel grid directly, without thousands of box unions.
return new CubeVoxelShape(grid);
}
}
@@ -23,6 +23,7 @@ import net.minecraft.world.level.material.PushReaction;
import net.minecraft.world.level.storage.loot.LootParams;
import net.minecraft.world.level.storage.loot.parameters.LootContextParams;
import net.minecraft.world.phys.*;
import net.minecraft.world.phys.shapes.*;
import net.minecraft.network.chat.Component;
/** The clay workshop crafts portable sculptures; a placed sculpture is only a decoration. */
@@ -57,10 +58,31 @@ public final class Statuary {
});
}
private static Block block(String name,Block block){return Registry.register(BuiltInRegistries.BLOCK,SanctuaryMod.id(name),block);}
private static Item blockItem(String name,Block block){return Registry.register(BuiltInRegistries.ITEM,SanctuaryMod.id(name),new BlockItem(block,new Item.Properties().setId(ResourceKey.create(Registries.ITEM,SanctuaryMod.id(name))).useBlockDescriptionPrefix()));}
private static Item blockItem(String name,Block block){
var properties=new Item.Properties().setId(ResourceKey.create(Registries.ITEM,SanctuaryMod.id(name))).useBlockDescriptionPrefix();
return Registry.register(BuiltInRegistries.ITEM,SanctuaryMod.id(name),block==BLOCK?new SculptureItem(block,properties):new BlockItem(block,properties));
}
private static BlockBehaviour.Properties properties(String id){return BlockBehaviour.Properties.of().strength(1.5f).noOcclusion().noLootTable().pushReaction(PushReaction.IMMOVEABLE).setId(ResourceKey.create(Registries.BLOCK,SanctuaryMod.id(id)));}
private static final class SculptureItem extends BlockItem {
SculptureItem(Block block,Item.Properties properties){super(block,properties);}
@Override protected boolean canPlace(BlockPlaceContext context,BlockState state){
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()));
}
}
private static final class SculptureBlock extends Block implements EntityBlock {
SculptureBlock(){super(Statuary.properties("statuary"));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));}
@Override protected VoxelShape getShape(BlockState state,BlockGetter level,BlockPos pos,CollisionContext context){
return level.getBlockEntity(pos) instanceof StatuaryEntity entity?entity.shape(state):Shapes.empty();
}
// Lighting metadata is cached per blockstate, so it cannot describe each block entity's voxels.
// Decorative sculptures transmit light; their faces still receive ordinary world lighting.
@Override protected VoxelShape getOcclusionShape(BlockState state){return Shapes.empty();}
@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 BlockState rotate(BlockState state,Rotation rotation){return state.setValue(FACING,rotation.rotate(state.getValue(FACING))).setValue(ANCHOR,state.getValue(ANCHOR).rotate(rotation));}
@@ -9,13 +9,26 @@ import net.minecraft.world.item.component.TypedEntityData;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.storage.*;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.phys.shapes.VoxelShape;
/** New, additive block data only. No ticking or mutation of pre-existing world formats. */
public final class StatuaryEntity extends BlockEntity {
private VoxelSculpture sculpture;
private CompoundTag opaque;
private record ShapeCache(VoxelSculpture sculpture,BlockState state,VoxelShape shape){}
private volatile ShapeCache shapeCache;
// Keep unknown/future data recoverable by picking the small base, without a phantom full cube.
private static final VoxelShape MISSING_SHAPE=Block.box(6,0,6,10,4,10);
public StatuaryEntity(BlockPos pos,BlockState state){super(Statuary.TYPE,pos,state);}
public VoxelSculpture sculpture(){return sculpture;}
public VoxelShape shape(BlockState state){
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));
shapeCache=new ShapeCache(model,state,shape);return shape;
}
@Override protected void saveAdditional(ValueOutput out){
super.saveAdditional(out);
if(opaque!=null){out.store("sculpture",CompoundTag.CODEC,opaque);return;}
@@ -23,7 +36,7 @@ public final class StatuaryEntity extends BlockEntity {
out.store("sculpture",CompoundTag.CODEC,encode(sculpture));
}
@Override protected void loadAdditional(ValueInput in){
super.loadAdditional(in);sculpture=null;opaque=null;
super.loadAdditional(in);sculpture=null;opaque=null;shapeCache=null;
var data=in.read("sculpture",CompoundTag.CODEC).orElse(null);if(data==null)return;
try{
if(data.getIntOr("schema",-1)!=1)throw new IllegalArgumentException("schema");
+1 -1
View File
@@ -1,6 +1,6 @@
name = "Sanctuary"
author = "KOKA99CAB"
version = "beta.121"
version = "beta.122"
pack-format = "packwiz:1.1.0"
[index]