Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4e96c0d99 | ||
|
|
7c63865157 | ||
|
|
00be1eb0d0 | ||
|
|
adde24aa7e | ||
|
|
99860af312 |
@@ -20,13 +20,12 @@ 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.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 release est
|
||||
publiée et l’instance **Sanctuary Beta** synchronisée, sauvegardes et réglages
|
||||
conservés. Packs normal/Test et template sont aussi dans le dossier `build/`.
|
||||
La version **beta.128** stabilise les ombres pixélisées, atténue le soleil au
|
||||
zénith et ajoute les ombres légères de la lune et le feuillage irrégulier.
|
||||
**Options → Shaders → Sanctuary · Vanilla Light** : les réglages de finesse et
|
||||
de portée sont conservés. Le [ticket de livraison](docs/shader-beta128.md)
|
||||
décrit le résultat et ses vérifications. Les gemmes, sculptures et découvertes
|
||||
de beta.126 restent inchangées.
|
||||
La [livraison précédente](docs/clay-workshop-integration-beta110.md) réunit déjà
|
||||
l’atelier, les œufs et la neige saisonnière dans l’instance **Sanctuary Beta**.
|
||||
Les [synchronisations antérieures](docs/git-sync-beta092.md) restent documentées.
|
||||
@@ -37,6 +36,28 @@ La [carte native beta.005](docs/atlas-beta005.md) prend le relais après
|
||||
l’étude de Xaero et des alternatives libres.
|
||||
|
||||
|
||||
## beta.128 — Ombres stables, lune et feuillage
|
||||
|
||||
Grille de réception stabilisée, contours nets des blocs, atténuation à midi,
|
||||
ombre lunaire légère et feuillage nuancé selon son épaisseur.
|
||||
[Contrat et vérifications](docs/shader-beta128.md).
|
||||
|
||||
## beta.127 — Shader : ombres portées pixélisées
|
||||
|
||||
Ombres solaires natives, finesse 8/16/32 pixels par bloc et portée 16/32/64 blocs.
|
||||
Option désactivée par défaut, réglable sans rechargement des ressources.
|
||||
[Contrat et vérifications](docs/shader-beta127.md) ·
|
||||
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.127/Sanctuary-beta.127.mrpack) ·
|
||||
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.127/Sanctuary-Test-beta.127.mrpack).
|
||||
|
||||
## beta.126 — Gemmes, argile et découvertes
|
||||
|
||||
Rubis et saphir avec filons configurables, couleurs originales avec l'argile,
|
||||
commandes de découverte globales ou ciblées.
|
||||
[Contrat et vérifications](docs/gems-beta126.md) ·
|
||||
[Pack normal](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.126/Sanctuary-beta.126.mrpack) ·
|
||||
[Pack de test](https://git.botsu.net/koka/sanctuary-beta/releases/download/beta.126/Sanctuary-Test-beta.126.mrpack).
|
||||
|
||||
## beta.125 — Sculptures : placement, éclats et animaux
|
||||
|
||||
Coins et trois hauteurs sur la grille 1/16 ; particules issues des voxels ;
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
# GEM-01 — Gemmes, argile et découvertes — beta.126
|
||||
|
||||
Contrat du 17 septembre 2026, branche `codex/gems-beta126`, Minecraft 26.3.
|
||||
|
||||
Deux gemmes natives complètent l'émeraude : `sanctuary:ruby` et
|
||||
`sanctuary:sapphire`. Chaque famille comprend un bloc de stockage, un minerai
|
||||
ordinaire et un minerai des abîmes. Les huit PNG du créateur sont copiés à
|
||||
l'identique ; leurs empreintes figurent dans `tools/gems-textures-beta126.json`.
|
||||
Les noms des fichiers français `saphir` sont associés aux identifiants anglais
|
||||
stables `sapphire`, sans modifier les pixels. Les deux items utilisent les fichiers de remplacement
|
||||
`Desktop/ruby.png` et `Desktop/saphir.png` fournis en fin de livraison.
|
||||
|
||||
L'ordre créatif est émeraude, rubis, saphir : ingrédients, blocs de stockage,
|
||||
minerais ordinaires et minerais des abîmes dans leurs onglets natifs respectifs.
|
||||
Minage à la pioche de fer ou mieux, résistance et expérience comme l'émeraude,
|
||||
Fortune, Toucher de soie, cuisson/four à fusion et conversion 9 gemmes ↔ 1 bloc.
|
||||
Les blocs peuvent former un socle de balise et les gemmes servir de paiement.
|
||||
Les recettes rejoignent la collection actuelle de l'émeraude, sans ajouter de
|
||||
transactions de villageois. Libellés français et anglais, tags communs `c:`.
|
||||
|
||||
## Génération procédurale et contrat de compatibilité
|
||||
|
||||
Profil initial **gemmes 1 / beta.126** : quatre tentatives par chunk et par
|
||||
gemme, filons natifs de taille 3, hauteur uniforme Y0–256, probabilité de rejet
|
||||
au contact de l'air de 0,25. Ces nombres sont un réglage initial à équilibrer
|
||||
lors de la future refonte ; aucun quota de gemmes par chunk n'est garanti.
|
||||
Les variantes ordinaires remplacent les roches du tag vanilla
|
||||
`stone_ore_replaceables`, les variantes des abîmes celles de
|
||||
`deepslate_ore_replaceables`.
|
||||
|
||||
Les fichiers `worldgen/feature/ore_<gemme>.json`,
|
||||
`worldgen/placed_feature/ore_<gemme>.json` et les tags de biomes
|
||||
`has_ore/<gemme>` séparent les cibles, la forme, la fréquence, la hauteur et
|
||||
les régions. Un datapack peut les remplacer sans recompiler le mod. Le code
|
||||
réutilise `OreFeature` et le hasard natif issu de la graine ; il limite ces
|
||||
filons aux mondes utilisant `SanctuaryChunkGenerator`. Les biomes vanilla
|
||||
partagés avec Sanctuary restent compatibles sans ajouter les gemmes aux
|
||||
mondes vanilla, au Nether ou à l'End.
|
||||
|
||||
Cette évolution additive est autorisée pour les **nouveaux chunks uniquement**.
|
||||
Aucun remplissage rétrospectif, aucune régénération, aucun remplacement de chunk
|
||||
sauvegardé ni activation d'expansion. Les codecs du relief, les dimensions et
|
||||
le format des sauvegardes restent inchangés. Les filons suivent les protections
|
||||
existantes de la décoration native. Les anciens profils expérimentaux précédant
|
||||
le générateur Sanctuary unifié ne sont pas étendus.
|
||||
|
||||
## Argile spéciale dans le Clay Workshop
|
||||
|
||||
Le bloc d'argile vanilla conserve les couleurs échantillonnées sur le modèle
|
||||
original, animal découvert ou import GLB. Les argiles colorées et les autres
|
||||
blocs continuent à transférer leur palette de texture. Une même règle est
|
||||
appliquée à l'aperçu client et au résultat serveur. Le changement de matériau
|
||||
invalide le résultat même si les deux palettes sont identiques. Le texte de
|
||||
l'atelier explique l'exception en français et en anglais.
|
||||
|
||||
Une sculpture coûte toujours un bloc ; aucune recette, découverte, donnée de
|
||||
sculpture déjà fabriquée ni règle de placement n'est modifiée.
|
||||
|
||||
## Commande de découverte commune
|
||||
|
||||
`/sanctuary discovery all` découvre tous les blocs, objets et espèces enregistrés.
|
||||
Les variantes `blocks all`, `items all` et `mobs all` ciblent une catégorie ;
|
||||
`block <identifiant>`, `item <identifiant>` et `mob <identifiant>` ciblent une
|
||||
entrée avec la complétion native. Un sélecteur de joueurs en fin de commande
|
||||
est facultatif ; sans cible, elle s'applique au joueur qui l'exécute.
|
||||
|
||||
Exemples :
|
||||
|
||||
```mcfunction
|
||||
/sanctuary discovery all
|
||||
/sanctuary discovery mobs all
|
||||
/sanctuary discovery block minecraft:stone
|
||||
/sanctuary discovery item minecraft:diamond
|
||||
/sanctuary discovery mob minecraft:cow
|
||||
/sanctuary discovery all @a
|
||||
```
|
||||
|
||||
La permission suit « Autoriser les commandes » en solo et les droits opérateur
|
||||
sur serveur. La console doit préciser la cible. Les identifiants invalides,
|
||||
l'air et les types d'entités sans espèce découvrable sont refusés.
|
||||
|
||||
Les blocs passent par le registre de découvertes et son événement existant ;
|
||||
les objets passent par `RecipeService.discover`. Les collections et recettes
|
||||
sont ensuite recalculées normalement. Les espèces rejoignent `seen_mobs`,
|
||||
la même donnée que l'observation naturelle, consommée par les familiers,
|
||||
le Métabli et le Clay Workshop. Il n'existe aucun drapeau de déblocage parallèle.
|
||||
Les catalogues déjà ouverts sont à rouvrir après la commande.
|
||||
|
||||
La commande conserve les quantités possédées et les compteurs de combat,
|
||||
minage, pose et fabrication. Elle ne donne pas d'objets, n'achète pas les skills
|
||||
et n'attribue pas d'advancements. Les recettes réservées aux opérateurs gardent
|
||||
leurs règles. Les historiques personnels sont enrichis sans changer leur
|
||||
schéma ; la commande refuse les données illisibles plutôt que les remplacer.
|
||||
|
||||
## Vérifications
|
||||
|
||||
Client natif Minecraft 26.3 / Java 25, `Gems126ClientChecks`, réussi en
|
||||
1 min 08 s. Les mondes sont jetables, sans sauvegarde personnelle ouverte :
|
||||
|
||||
- Les huit items et six blocs sont présents, avec les huit textures exactes.
|
||||
Quatre groupes créatifs respectent émeraude → rubis → saphir, sans doublons.
|
||||
- Douze recettes natives, découverte du bloc de stockage, outil fer minimum,
|
||||
Fortune III, Toucher de soie et balises passent les contrôles serveur.
|
||||
- Graine **126**, profil gemmes 1, 81 chunks autour de l'origine : 7 rubis dans
|
||||
la pierre et 8 dans la roche des abîmes ; 15 saphirs dans la pierre et 10 dans
|
||||
la roche des abîmes. La génération est refusée dans le monde plat vanilla.
|
||||
Les minerais persistent à la reconnexion, et un minerai retiré ne réapparaît pas.
|
||||
- L'argile conserve exactement les couleurs des voxels d'une vache et d'un GLB
|
||||
texturé. Bois et argile colorée transfèrent toujours leur palette. Le retour
|
||||
à l'argile, l'égalité aperçu/résultat et le coût d'un bloc en survie sont vérifiés.
|
||||
- La commande native respecte « Autoriser les commandes », accepte une cible
|
||||
et la console, refuse les identifiants invalides et permet une espèce, un
|
||||
bloc, un objet, toutes les espèces ou tout le contenu. Les recettes liées
|
||||
sont recalculées. La répétition est idempotente et les découvertes survivent
|
||||
à la reconnexion sans inventer de compteurs de combat ou de possession.
|
||||
|
||||
Logs et captures dans `build/gems126-client.log` et
|
||||
`mods/sanctuary/build/run/clientGameTest/screenshots/`. Le GameTest dédié
|
||||
reste exclu conformément au refus antérieur de son EULA. Le build complet et
|
||||
les archives sont vérifiés séparément avant publication.
|
||||
|
||||
|
||||
Build final `./gradlew check build assemblePack assembleTestPack
|
||||
-x :sanctuary:runGameTest` réussi en **2 min 10 s**, 126 tâches. Les contrôles
|
||||
natifs de gameplay précèdent uniquement le remplacement final des deux PNG
|
||||
d'items ; les huit fichiers livrés sont vérifiés à l'octet près contre les
|
||||
sources, tous en 16 × 16. Les JAR de sources correspondent aux sources courantes.
|
||||
L'audit entre les binaires beta.125 et beta.126 ne trouve que les 112 entrées
|
||||
de production prévues. Les archives normal/Test contiennent le même mod et
|
||||
le template reproduit exactement ses ressources exportables.
|
||||
|
||||
| Artefact | SHA-256 |
|
||||
| --- | --- |
|
||||
| `Sanctuary-beta.126.mrpack` | `716221ce24549781ab91f5437ad2310497b198a4b61c43c40c67a27984365ae5` |
|
||||
| `Sanctuary-Test-beta.126.mrpack` | `bbf22d7dd72e6746538b4ec28686f699225d4a59372304e5db42140a80e88bd1` |
|
||||
| `sanctuary-beta.126.jar` | `85dbe1b41d98751029bb62e87d259522f8609758896c797b1e02fa7656ec0342` |
|
||||
|
||||
## Publication et synchronisation
|
||||
|
||||
[Release beta.126](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.126)
|
||||
publiée depuis `99860af312a1737d938812f3515fcc4acb4291b2` ; artefacts
|
||||
immuables retéléchargés et vérifiés. Canal packwiz : `31749b95d21ce5bced6b9e5759499da6f8a67e6e`.
|
||||
Deux synchronisations isolées, puis deux synchronisations de l'instance existante
|
||||
**Sanctuary Beta** ont réussi. Un seul JAR Sanctuary beta.126, correspondant
|
||||
à l'empreinte ci-dessus. Les **923 fichiers personnels** suivis sont conservés,
|
||||
sauvegardes restées fermées ; copie préalable dans
|
||||
`sanctuary-backups/before-beta.126/`.
|
||||
|
||||
Packs normal/Test et template copiés et vérifiés dans
|
||||
`sanctuary-beta/build/`. Reçus locaux : `build/gems126-artifact.json`,
|
||||
`build/gems126-isolated.json`, `build/gems126-prism.json` et
|
||||
`build/gems126-publication.log`.
|
||||
@@ -14,6 +14,29 @@ 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.127 — shader et ombres pixélisées
|
||||
|
||||
Ombres solaires optionnelles intégrées à Sanctuary · Vanilla Light, avec finesse
|
||||
et portée réglables. Socle beta.126 conservé. [Contrat et vérifications](shader-beta127.md).
|
||||
[Release beta.127](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.127)
|
||||
publiée ; canal `7fd24a43e01ee497a23311e3c78e1dd3dddd877a`. Deux synchronisations
|
||||
isolées puis deux dans la même instance **Sanctuary Beta** réussies.
|
||||
Les 923 fichiers personnels suivis, réglages de shaders compris, sont conservés.
|
||||
Aucun monde personnel ouvert ; copie préalable dans `sanctuary-backups/before-beta.127/`.
|
||||
Packs normal/Test et template copiés et vérifiés dans `sanctuary-beta/build/`.
|
||||
|
||||
## beta.126 — gemmes, argile et découvertes
|
||||
|
||||
Rubis et saphir, nouvelles textures d'items, argile conservant les couleurs
|
||||
originales et commandes de découverte communes aux recettes et catalogues.
|
||||
[Contrat et vérifications](gems-beta126.md).
|
||||
[Release beta.126](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.126)
|
||||
publiée ; canal `31749b95d21ce5bced6b9e5759499da6f8a67e6e`. 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 dans `sanctuary-backups/before-beta.126/`.
|
||||
Packs normal/Test et template copiés et vérifiés dans `sanctuary-beta/build/`.
|
||||
|
||||
## beta.125 — placement, éclats et animaux sculptés
|
||||
|
||||
Coins et trois hauteurs, particules de voxels et espèces découvertes au Clay
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
# Préparation locale des ombres portées pixélisées (ancien numéro beta.122)
|
||||
|
||||
Ticket du 17 septembre 2026, branche locale `codex/pixel-shadows-beta122`.
|
||||
Cette préparation n'a jamais été publiée sous le numéro beta.122, déjà utilisé
|
||||
par une autre livraison. Elle est intégrée au socle beta.126 et livrée en
|
||||
[beta.127](shader-beta127.md). Les chemins et mesures ci-dessous décrivent
|
||||
les essais historiques dans le dossier de préparation.
|
||||
Demande : ajouter aux réglages de Sanctuary · Vanilla Light des ombres portées
|
||||
pixélisées, avec une finesse et une distance réglables.
|
||||
|
||||
## Utilisation
|
||||
|
||||
Dans **Options → Shaders**, activer **Sanctuary · Vanilla Light**, puis
|
||||
**Ombres portées pixélisées**. L'option est désactivée par défaut pour conserver
|
||||
le coût et le rendu des installations existantes. Les choix sont :
|
||||
|
||||
- finesse de **8, 16 ou 32 pixels par bloc**, défaut 16 ;
|
||||
- distance de **16, 32 ou 64 blocs**, défaut 32.
|
||||
|
||||
Ces préférences sont conservées dans `config/sanctuary-shaders.json`, avec les
|
||||
anciens champs de couleur. Leurs valeurs numériques sont ramenées aux choix
|
||||
admis au chargement. Les libellés et explications sont disponibles en FR/EN.
|
||||
Les réglages s'appliquent sans recharger les ressources.
|
||||
|
||||
## Rendu et périmètre
|
||||
|
||||
Une carte de profondeur rend les formes d'occultation des blocs solides chargés
|
||||
autour de la caméra, indépendamment de leur présence à l'écran. Les dalles et
|
||||
escaliers conservent leurs formes. Le cache est invalidé par les changements de
|
||||
blocs, les paquets de chunks et les reconstructions du moteur natif.
|
||||
La direction solaire vient du ciel natif extrait par Minecraft, et respecte
|
||||
ainsi l'angle solaire de Sanctuary. L'effet s'atténue sous la pluie, à l'horizon,
|
||||
dans la brume et à la limite de sa portée. Il s'arrête la nuit, dans les dimensions
|
||||
sans soleil, dans les fluides et lorsqu'un moteur externe suspend Vanilla Light.
|
||||
|
||||
La profondeur du monde est lue directement par le GPU après le rendu du monde,
|
||||
avant la main et l'interface. L'inverse de la projection réelle inclut les effets
|
||||
de caméra. Les points recevant l'ombre sont arrondis sur une grille ancrée dans
|
||||
le monde ; le plan de la face est conservé pour limiter les auto-ombres.
|
||||
La carte solaire utilise également une origine alignée sur ses texels.
|
||||
Le HUD, la main, les étoiles et les captures panoramiques techniques gardent leur
|
||||
chemin de rendu. Aucun niveau de lumière de gameplay, règle serveur, sauvegarde
|
||||
ou générateur n'est changé. Aucune dépendance de rendu n'est ajoutée.
|
||||
|
||||
Cette première version couvre les **formes d'occultation des blocs** : les entités
|
||||
conservent leurs ombres natives ; les feuillages ajourés, modèles non occultants,
|
||||
block entities animés, eau et verre ne projettent pas de nouvelles silhouettes.
|
||||
Les transparences et particules déjà composées peuvent recevoir l'assombrissement
|
||||
du terrain derrière elles. Il n'y a pas d'ombres de lune ou de torches, ni de
|
||||
séparation de l'éclairage local émissif dans cette passe d'assombrissement.
|
||||
|
||||
## Coût et cycle de vie
|
||||
|
||||
Les maillages sont mis en cache par section de 16³, avec au plus quatre sections
|
||||
commencées par image et un budget souple de 3 ms pour leur construction. Les
|
||||
ombres apparaissent progressivement pendant la préparation après activation ou
|
||||
déplacement. Le volume des obstacles part de la portée choisie avec une marge de huit blocs,
|
||||
puis s’étend vers le soleil sur deux fois cette portée, arrondi aux sections.
|
||||
Les obstacles au-delà de ce volume ou dans des chunks non chargés ne sont pas
|
||||
représentés. Les mises à jour de lumière seule ne reconstruisent pas les formes.
|
||||
Le cache de sommets est plafonné à 96 Mio et omet les formes pathologiques qui
|
||||
dépassent ses limites. Les textures de profondeur font entre 512 et 4 096 pixels
|
||||
de côté selon finesse et portée. Le coût exact dépend de la scène et du GPU.
|
||||
|
||||
Les ressources sont libérées à la désactivation, au changement de monde, à la
|
||||
reconstruction des ressources et à l'arrêt du client. Cette livraison est locale :
|
||||
aucun avancement du canal packwiz ni installation personnelle ne fait partie du
|
||||
ticket.
|
||||
|
||||
## Vérification
|
||||
|
||||
Validation native sur **Minecraft 26.3, Java 25, macOS / Apple M1, OpenGL 4.1**,
|
||||
dans un nouveau monde plat de développement, graine **122** :
|
||||
|
||||
- Comparaison GPU activation/désactivation sur une plateforme blanche avec
|
||||
pilier, dalle et escalier : **920 échantillons assombris**, 63 412 inchangés,
|
||||
aucun éclairci sur les 64 332 échantillons de sol retenus.
|
||||
- Pilier entièrement hors du frustum : **42 048 échantillons** restent dans
|
||||
son ombre. Le retrait du pilier restitue la lumière sur **42 048 / 42 048**
|
||||
échantillons et préserve les 23 616 échantillons déjà éclairés. Sa repose
|
||||
restitue la même ombre, sans désactiver le cache entre ces manipulations.
|
||||
- Les trois finesses et les trois portées sont appliquées en direct ; les cartes
|
||||
GPU sont redimensionnées aux dimensions attendues. Désactiver l'option libère
|
||||
la texture et ne soumet plus de passe d'ombres.
|
||||
- À minuit, activer ou désactiver l'option donne la même image et aucune passe
|
||||
solaire n'est soumise. Au retour du jour, le rendu reprend.
|
||||
- Le rechargement des ressources restitue la même image. Les préférences
|
||||
persistent à la relecture du fichier et leurs valeurs extrêmes sont bornées.
|
||||
- Menus FR/EN inspectés en capture ; les libellés tiennent dans les contrôles
|
||||
natifs à la taille de test. Les vues de finesse 8/16/32 sont conservées.
|
||||
|
||||
```sh
|
||||
./gradlew :sanctuary:runClientGameTest \
|
||||
-PsanctuaryClientTests=true -PsanctuaryPixelShadows122ClientTests=true \
|
||||
-PsanctuaryClientNoVsync=true -PsanctuaryQuickTests=true
|
||||
```
|
||||
|
||||
Succès en **1 min 17 s**, marqueur `PIXEL_SHADOWS122_CLIENT_PASS` dans
|
||||
`build/pixel-shadows122-client.log`. Captures inspectées dans
|
||||
`build/pixel-shadows122-evidence/final/`. Les pixels sont relus uniquement dans
|
||||
le test ; le code de rendu de production n'effectue aucune lecture CPU de GPU.
|
||||
Les premiers démarrages ont permis de corriger les identifiants de pipelines,
|
||||
la directive GLSL `#include` attendue par 26.3 et deux libellés répétés du menu.
|
||||
|
||||
La commande de livraison est :
|
||||
|
||||
```sh
|
||||
./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest
|
||||
```
|
||||
|
||||
Le serveur GameTest dédié est exclu, conformément au refus de son EULA déjà
|
||||
consigné dans les livraisons précédentes. Le test graphique utilise le serveur
|
||||
intégré de son monde neuf. Aucun monde personnel n'est ouvert.
|
||||
La construction finale **réussit en 2 min 18 s** (126 tâches), journal
|
||||
`build/pixel-shadows122-build-final.log`. Le contrôle des archives confirme
|
||||
**868 sources Java** et **1 869 classes** identiques aux sorties construites,
|
||||
les ressources GLSL/FR/EN exactes dans le JAR et le template, et le même JAR
|
||||
dans les profils packwiz normal et Test. Aucune fixture cliente n'est embarquée.
|
||||
Reçu : `build/pixel-shadows122-artifact.json`.
|
||||
|
||||
Artefacts locaux : `mods/sanctuary/build/libs/sanctuary-beta.122.jar`,
|
||||
`build/packwiz/`, `build/packwiz-test/` et `build/Sanctuary-Template-beta.122.zip`.
|
||||
SHA-256 du JAR :
|
||||
`66987681cede1d2adc1dda449078e8fa85310eb2c3fecd7a905d772ce75739c9`.
|
||||
|
||||
Pas de mesure de FPS, d'essai Windows/Vulkan, de pack Iris ni de silhouettes
|
||||
animées revendiqués. Le shader reste un assombrissement graphique appliqué au
|
||||
rendu opaque et déjà composé, avec les limites de transparence décrites plus haut.
|
||||
@@ -0,0 +1,88 @@
|
||||
# SHADER-127 — intégration des ombres portées pixélisées
|
||||
|
||||
Demande du 17 septembre 2026 : livrer le shader dans **beta.127**.
|
||||
Branche `codex/shader-beta127`, socle publié beta.126, Minecraft 26.3 / Java 25.
|
||||
|
||||
La préparation locale [des ombres pixélisées](pixel-shadows-source122.md) est
|
||||
intégrée sans écraser les changements non publiés du dossier d'origine.
|
||||
Les gemmes, dernières textures de rubis/saphir, l'argile spéciale et les
|
||||
commandes de découverte de beta.126 sont conservées.
|
||||
|
||||
## Utilisation
|
||||
|
||||
**Options → Shaders → Sanctuary · Vanilla Light → Ombres portées pixélisées**.
|
||||
L'option est désactivée par défaut ; son activation reste un choix du joueur.
|
||||
Finesse : 8/16/32 pixels par bloc ; portée : 16/32/64 blocs. Défauts : 16 et 32.
|
||||
Les paramètres FR/EN s'appliquent immédiatement et persistent dans
|
||||
`config/sanctuary-shaders.json`. La mise à jour préserve les réglages personnels.
|
||||
Le rendu est natif au mod : aucun pack Iris, téléchargement supplémentaire ou
|
||||
nouvelle dépendance de rendu.
|
||||
|
||||
## Périmètre
|
||||
|
||||
Ombres solaires des formes d'occultation des blocs chargés, incluant dalles,
|
||||
escaliers et obstacles hors écran. Carte de profondeur GPU, grille ancrée dans
|
||||
le monde, atténuation selon soleil, pluie et brume. L'effet s'arrête la nuit,
|
||||
dans les dimensions sans soleil, dans les fluides ou avec un moteur externe.
|
||||
Le HUD et la main ne passent pas dans cet effet. La lumière de gameplay,
|
||||
les règles serveur, les sauvegardes et la génération ne sont pas modifiées.
|
||||
|
||||
La préparation limite le cache à 96 Mio et sa construction à quatre sections
|
||||
par image dans un budget souple de 3 ms. L'intégration supprime seulement une
|
||||
invalidation de bord exécutée deux fois à l'identique dans le cache de terrain.
|
||||
Les formes non occultantes, feuillages ajourés, eau/verre et entités animées ne
|
||||
projettent pas de nouvelles silhouettes. Les transparences déjà composées
|
||||
peuvent recevoir l'assombrissement du terrain situé derrière elles. Pas
|
||||
d'ombres de torches/lune ni de mesure de FPS revendiquée.
|
||||
|
||||
## Vérifications et livraison
|
||||
|
||||
Client natif Minecraft 26.3 / Java 25 sur macOS, monde neuf plat, graine 122 :
|
||||
`PixelShadows122ClientChecks` réussit sur la beta.127 en **1 min 26 s**.
|
||||
Le nom de cette fixture historique est conservé pour suivre la même scène.
|
||||
|
||||
- Activation : 920 échantillons de sol assombris, 63 412 inchangés, aucun éclairci.
|
||||
- Pilier hors écran : 42 048 échantillons restent ombrés ; le retrait du pilier
|
||||
restitue la lumière sur les 42 048 et conserve les 23 616 déjà éclairés.
|
||||
La repose rétablit la même ombre sans désactiver le cache.
|
||||
- Trois finesses et trois portées, redimensionnement GPU, désactivation/libération,
|
||||
arrêt nocturne, reprise diurne et rechargement des ressources vérifiés.
|
||||
- Préférences persistantes et bornées ; captures FR/EN inspectées, sans texte
|
||||
tronqué. La scène pilier/dalle/escalier est également inspectée visuellement.
|
||||
|
||||
Logs : `build/shader127-client.log`. Captures :
|
||||
`mods/sanctuary/build/run/clientGameTest/screenshots/`.
|
||||
Le serveur GameTest dédié reste exclu conformément au refus antérieur de son
|
||||
EULA. Aucun monde personnel n'est ouvert. Aucun essai Windows/Vulkan ou FPS
|
||||
n'est revendiqué ; la validation graphique est celle du client natif local.
|
||||
|
||||
|
||||
Build `./gradlew check build assemblePack assembleTestPack
|
||||
-x :sanctuary:runGameTest` réussi en **2 min 25 s**, 126 tâches. Les sources
|
||||
Java dans les archives correspondent aux sources du ticket. La comparaison
|
||||
avec le JAR beta.126 ne trouve que **21 entrées de production modifiées**,
|
||||
toutes liées au shader, à son initialisation, ses mixins et ses libellés.
|
||||
Les règles de jeu, données, textures de gemmes, sculptures et découvertes
|
||||
sont identiques. Normal et Test contiennent le même JAR ; le template exporte
|
||||
exactement les ressources du mod. Aucune fixture de test ni monde embarqué.
|
||||
|
||||
| Artefact | SHA-256 |
|
||||
| --- | --- |
|
||||
| `Sanctuary-beta.127.mrpack` | `23fa6ca0c5d1f1033e13e5370d669491b398d5a99e13f8bd1e983f998a24a20a` |
|
||||
| `Sanctuary-Test-beta.127.mrpack` | `f185f3a743ba23cd6c024d1c1e46587706bf4f3edb5e14d7e5dee522a1ebf2c2` |
|
||||
| `sanctuary-beta.127.jar` | `3ef3fab67f4f703864a1acc9ae2395c636d206f3128d3399140eaf045a46d5f3` |
|
||||
|
||||
## Publication et synchronisation
|
||||
|
||||
[Release beta.127](https://git.botsu.net/koka/sanctuary-beta/releases/tag/beta.127)
|
||||
publiée depuis `00be1eb0d0bfe042442d90255385033a7765092f` ; artefacts immuables retéléchargés
|
||||
et vérifiés. Canal packwiz : `7fd24a43e01ee497a23311e3c78e1dd3dddd877a`.
|
||||
Deux synchronisations isolées puis deux dans l'instance existante
|
||||
**Sanctuary Beta** ont réussi. Les **923 fichiers personnels** suivis,
|
||||
y compris les préférences de shaders, sont conservés. Aucun monde personnel
|
||||
ouvert ; copie préalable dans `sanctuary-backups/before-beta.127/`.
|
||||
|
||||
Les packs normal/Test et le template sont aussi copiés et vérifiés dans
|
||||
`sanctuary-beta/build/`. Reçus locaux : `build/shader127-artifact.json`,
|
||||
`build/shader127-isolated.json`, `build/shader127-prism.json` et journal de
|
||||
publication `build/shader127-publication.log`.
|
||||
@@ -0,0 +1,92 @@
|
||||
# SHADER-128 — stabilité, lune et feuillage
|
||||
|
||||
Demandes du 17 septembre 2026 : réduire le jitter des bords et à midi, conserver
|
||||
des ombres de blocs nettes et un feuillage nuancé, ajouter une ombre lunaire
|
||||
fine et une transmission
|
||||
irrégulière du feuillage selon son épaisseur. Branche `codex/moon-foliage-beta128`,
|
||||
socle publié beta.127, Minecraft 26.3 / Java 25.
|
||||
|
||||
## Résultat
|
||||
|
||||
Les faces de voxels reçoivent l'ombre sur une grille fixe ; leur plan est
|
||||
stabilisé au 1/16 de bloc et les faces inclinées conservent leur normale. La
|
||||
carte de profondeur utilise un ancrage par 16 blocs avec alignement de ses
|
||||
texels. **Les ombres des blocs restent nettes** : le créateur a écarté leur
|
||||
adoucissement après l'essai visuel. Le filtrage est réservé au feuillage.
|
||||
Le soleil continue de se déplacer : ce mouvement naturel peut toujours faire
|
||||
avancer une ombre d'une case. L'objectif est de supprimer les oscillations
|
||||
liées à la caméra, pas de figer le temps.
|
||||
|
||||
Entre une élévation solaire de 0,92 et le zénith, l'intensité diminue doucement
|
||||
jusqu'à 25 % de sa valeur habituelle : alpha maximal 0,095 au lieu de 0,38.
|
||||
La lune réutilise la même carte et son angle natif ; alpha maximal 0,10 selon
|
||||
sa phase, nul à la nouvelle lune. Horizon, pluie, portée et brume atténuent
|
||||
les deux lumières. Pas de seconde passe de carte lunaire simultanée.
|
||||
|
||||
Les blocs du tag vanilla `leaves` projettent une couverture probabiliste fixe
|
||||
sur une grille de quart de bloc. Les faces internes restent présentes : plus
|
||||
le rayon traverse de couches, moins il a de chances de passer. Il s'agit d'une
|
||||
approximation statistique de l'épaisseur, pas d'un comptage des feuilles ni
|
||||
d'une lecture des trous de leur texture. Quatre échantillons bilinéaires sur
|
||||
un quart de bloc mélangent cette couverture
|
||||
en nuances, évaluées sur la grille pixélisée. Un masque R8 distingue feuilles
|
||||
et blocs dans la même passe de profondeur, pour garder les silhouettes opaques
|
||||
nettes. Les comparaisons suivent le plan récepteur pour éviter l'auto-ombrage
|
||||
sur les surfaces obliques. Le motif ne dépend ni du temps ni de la caméra et
|
||||
ne demande aucune lecture GPU côté CPU en production.
|
||||
Les budgets du cache restent 96 Mio, quatre sections et 3 ms souples par image.
|
||||
Le feuillage ajoute des faces ; son filtrage utilise seize comparaisons de
|
||||
profondeur et, selon le cas, autant de lectures du masque. Les ombres opaques
|
||||
pleines utilisent le chemin court. Le masque prend 4 Mio à la définition
|
||||
par défaut, 16 Mio au maximum ; aucune promesse de FPS n'est faite.
|
||||
|
||||
Réglages existants et libellés FR/EN conservés/actualisés. Les ombres restent
|
||||
optionnelles et désactivées par défaut. Règles, éclairage de gameplay, mondes
|
||||
et génération inchangés. Les limitations de beta.127 concernant les entités,
|
||||
fluides, verre et autres formes non occultantes subsistent.
|
||||
|
||||
## Vérifications
|
||||
|
||||
`PixelShadows122ClientChecks`, fixture historique étendue, réussit en **1 min
|
||||
44 s** dans un monde neuf plat, graine 122, client natif macOS/OpenGL.
|
||||
|
||||
- Soleil : 920 échantillons de sol ombrés et 63 412 inchangés ; retrait/repose
|
||||
d'un pilier hors écran restitue puis rétablit son ombre.
|
||||
- Lune : ombre localisée mesurée ; arrêt des passes à la nouvelle lune.
|
||||
- Midi : ombre atténuée et **zéro échantillon de transition adoucie sur 65 664**
|
||||
dans la scène de contrôle du contour opaque.
|
||||
- Caméra : petits déplacements autour de l'ancien ancrage entier et du nouvel
|
||||
ancrage par 16 blocs, retour au point initial, petites rotations en vue
|
||||
oblique, instants avant/après midi et passage de 5999 à 6001 ticks.
|
||||
Comparaisons d'images avec tolérance de trois niveaux par canal et moins
|
||||
de 1 % d'échantillons changés pour ces petits mouvements.
|
||||
- Vue oblique : 19 637 échantillons ombrés, 46 027 inchangés sur le sol blanc.
|
||||
- Feuillage : assombrissement moyen mesuré d'environ **18,6 %** pour une couche,
|
||||
**31,7 %** pour quatre, contre **36,8 %** sous une couverture solide.
|
||||
Motif fixe dans le temps ; modification des couches invalide le cache actif.
|
||||
- Trois finesses/trois portées, désactivation/libération GPU, préférences
|
||||
persistantes, rechargement des ressources, textes FR/EN et captures inspectés.
|
||||
|
||||
Commande native : `./gradlew :sanctuary:runClientGameTest
|
||||
-PsanctuaryClientTests=true -PsanctuaryPixelShadows122ClientTests=true
|
||||
-PsanctuaryClientNoVsync=true -PsanctuaryQuickTests=true` sous Java 25.
|
||||
Logs : `build/shader128-client.log` ; captures sous
|
||||
`mods/sanctuary/build/run/clientGameTest/screenshots/`.
|
||||
|
||||
`./gradlew check build assemblePack assembleTestPack
|
||||
-x :sanctuary:runGameTest` réussit en **2 min 24 s**, 126 tâches.
|
||||
Les sources archivées correspondent au ticket. **13 entrées de production**
|
||||
diffèrent de beta.127, limitées aux classes du shader, GLSL et libellés FR/EN.
|
||||
Gemmes, sculptures, découvertes, données et règles de jeu restent identiques.
|
||||
Normal et Test embarquent le même JAR ; le template correspond exactement aux
|
||||
ressources exportables. Aucun monde ni fixture de test embarqué.
|
||||
Le serveur GameTest dédié reste exclu conformément au refus antérieur de son
|
||||
EULA. Aucun monde personnel utilisé. Aucun essai Windows/Vulkan revendiqué.
|
||||
|
||||
## Artefacts vérifiés
|
||||
|
||||
| Artefact | SHA-256 |
|
||||
| --- | --- |
|
||||
| `Sanctuary-beta.128.mrpack` | `84125b18366afde52c8cba4bc7007257b1c3d8b79d413338abe64d0e0ae8feaf` |
|
||||
| `Sanctuary-Test-beta.128.mrpack` | `6bfb10d8d63875bfb84e813d4f0a9faf5db30902cc0400deda0993c08b86e597` |
|
||||
| `sanctuary-beta.128.jar` | `c54cbae7efaa7059c8fd4d28801087983379958fe34eedff51682d106d649c6a` |
|
||||
@@ -102,3 +102,8 @@ client natif : témoin `SHADER040_CLIENT_PASS` dans
|
||||
`build/operator040-client-final.log`. Captures dans
|
||||
`build/operator040-evidence/final/`, reçu dans `build/operator040-artifact.json`.
|
||||
Les contrôles ne revendiquent pas un essai avec Iris ou un autre moteur externe.
|
||||
|
||||
|
||||
Les ombres portées pixélisées sont livrées dans [beta.127](shader-beta127.md),
|
||||
intégrées sur la beta.126. Leur préparation locale portait un numéro beta.122
|
||||
non publié ; la release beta.122 existante n'est pas remplacée.
|
||||
|
||||
@@ -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.125
|
||||
pack_version=beta.125
|
||||
mod_version=beta.128
|
||||
pack_version=beta.128
|
||||
resource_pack_version=beta.121
|
||||
maven_group=fr.koka.sanctuary
|
||||
jei_version=30.32.0-sanctuary.3
|
||||
|
||||
@@ -93,6 +93,14 @@ tasks.named('runGameTest') {
|
||||
}
|
||||
|
||||
tasks.named('processGametestResources') {
|
||||
def shadows122Client = providers.gradleProperty('sanctuaryPixelShadows122ClientTests').map { it.toBoolean() }.getOrElse(false)
|
||||
inputs.property('sanctuaryPixelShadows122ClientTests', shadows122Client)
|
||||
if (shadows122Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.shader.PixelShadows122ClientChecks') }
|
||||
|
||||
def gems126Client = providers.gradleProperty('sanctuaryGems126ClientTests').map { it.toBoolean() }.getOrElse(false)
|
||||
inputs.property('sanctuaryGems126ClientTests', gems126Client)
|
||||
if (gems126Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Gems126ClientChecks') }
|
||||
|
||||
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') }
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
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.core.BlockPos;
|
||||
import net.minecraft.world.entity.EntityTypes;
|
||||
import net.minecraft.world.inventory.ContainerInput;
|
||||
import net.minecraft.world.item.*;
|
||||
import net.minecraft.world.level.GameType;
|
||||
|
||||
/** Native preview, server result and survival payment for the special plain-clay material. */
|
||||
final class Clay126Checks {
|
||||
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","cow"));Statuary.open(p,pos);p.containerMenu.getSlot(0).set(new ItemStack(Items.CLAY,2));p.containerMenu.broadcastChanges();});
|
||||
c.waitForScreen(ClayWorkshopScreen.class);c.runOnClient(m->{m.options.guiScale().set(2);m.getWindow().setWindowed(1280,800);m.resizeGui();((ClayWorkshopScreen)m.gui.screen()).load(EntityTypes.COW);});
|
||||
c.waitFor(m->{var screen=(ClayWorkshopScreen)m.gui.screen();return !screen.busy()&&screen.selected()!=null&&screen.selected().equals(StatuaryEntity.sculpture(m.player.containerMenu.getSlot(1).getItem()));},250);
|
||||
var cow=c.computeOnClient(m->((ClayWorkshopScreen)m.gui.screen()).selected());check(cow.cells().stream().map(VoxelSculpture.Cell::color).distinct().count()>3,"Cow source retains texture colors");
|
||||
for(var material:List.of(Items.OAK_PLANKS,fr.koka.sanctuary.building.ColoredBricks.families().getFirst().clay().asItem(),Items.CLAY)){
|
||||
server.runOnServer(s->{var menu=s.getPlayerList().getPlayers().getFirst().containerMenu;menu.getSlot(0).set(new ItemStack(material,2));menu.broadcastChanges();});
|
||||
var expected=c.computeOnClient(m->ClayWorkshopMenu.appearance(cow,material,SculptureMaterialSampler.sample(new ItemStack(material))));
|
||||
check(material==Items.CLAY?expected.equals(cow):!expected.equals(cow),"Only plain clay retains original colors");
|
||||
c.waitFor(m->expected.equals(StatuaryEntity.sculpture(m.player.containerMenu.getSlot(1).getItem())),180);
|
||||
c.runOnClient(m->check(ItemStack.matches(((ClayWorkshopScreen)m.gui.screen()).preview(),m.player.containerMenu.getSlot(1).getItem()),"Preview matches authoritative material switch"));
|
||||
}
|
||||
c.takeScreenshot("clay126-original-cow");
|
||||
c.runOnClient(m->m.gameMode.handleContainerInput(m.player.containerMenu.containerId,1,0,ContainerInput.PICKUP,m.player));
|
||||
server.waitFor(s->{var menu=s.getPlayerList().getPlayers().getFirst().containerMenu;return cow.equals(StatuaryEntity.sculpture(menu.getCarried()))&&menu.getSlot(0).getItem().getCount()==1;},100);
|
||||
c.waitFor(m->cow.equals(StatuaryEntity.sculpture(m.player.containerMenu.getCarried()))&&m.player.containerMenu.getSlot(0).getItem().getCount()==1,100);
|
||||
Path file=PlanClient.directory().resolve("clay126-"+UUID.randomUUID()+".glb");
|
||||
try{Files.createDirectories(file.getParent());Files.write(file,Statuary106ClientChecks.fixture());var id=GlbModel.load(file).id();c.runOnClient(m->((ClayWorkshopScreen)m.gui.screen()).load(file));
|
||||
c.waitFor(m->{var screen=(ClayWorkshopScreen)m.gui.screen();return !screen.busy()&&screen.selected()!=null&&screen.selected().modelId().equals(id)&&screen.selected().equals(StatuaryEntity.sculpture(m.player.containerMenu.getSlot(1).getItem()));},250);
|
||||
c.runOnClient(m->{var screen=(ClayWorkshopScreen)m.gui.screen();check(screen.selected().cells().stream().map(VoxelSculpture.Cell::color).distinct().count()>1,"Imported textured GLB retains its original colors");check(ItemStack.matches(screen.preview(),m.player.containerMenu.getSlot(1).getItem()),"Imported preview equals native result");});
|
||||
}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("CLAY126_PASS original animal and GLB colors, oak and colored clay palette transfer, switch back, matching previews and one clay paid in survival");
|
||||
}
|
||||
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package fr.koka.sanctuary.client;
|
||||
|
||||
import fr.koka.sanctuary.companion.*;
|
||||
import fr.koka.sanctuary.knowledge.*;
|
||||
import fr.koka.sanctuary.plans.*;
|
||||
import fr.koka.sanctuary.recipes.*;
|
||||
import java.util.*;
|
||||
import net.fabricmc.fabric.api.client.gametest.v1.context.*;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.stats.Stats;
|
||||
import net.minecraft.world.entity.EntityTypes;
|
||||
import net.minecraft.world.item.*;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
|
||||
final class Discovery126Checks {
|
||||
static void exercise(ClientGameTestContext c,TestServerContext server){
|
||||
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var dispatcher=s.getCommands().getDispatcher();
|
||||
var source=p.createCommandSourceStack();var root=dispatcher.getRoot().getChild("sanctuary").getChild("discovery");
|
||||
s.getWorldData().setAllowCommands(false);check(!root.canUse(source),"Discovery commands respect native Allow Commands");
|
||||
try{dispatcher.execute("sanctuary discovery all",source);throw new AssertionError("Denied command executed");}catch(com.mojang.brigadier.exceptions.CommandSyntaxException expected){}
|
||||
s.getWorldData().setAllowCommands(true);
|
||||
var before=CompanionService.record(p);var items=new TreeSet<>(RecipeService.knowledge(p).items);int kills=p.getStats().getValue(Stats.ENTITY_KILLED,EntityTypes.WITHER);
|
||||
command(p,"mob minecraft:wither");check(StatueDiscoveries.known(p).contains("minecraft:wither"),"Precise species unlocks sculpture catalogue");check(CompanionService.record(p).notes().get("seen_mobs").contains("wither"),"Same actual seen_mobs record");check(RecipeService.knowledge(p).items.equals(items),"Mob discovery invents no unrelated item discovery");check(p.getStats().getValue(Stats.ENTITY_KILLED,EntityTypes.WITHER)==kills,"No fake kill stat");
|
||||
command(p,"block minecraft:pink_glazed_terracotta");var block=BlockKnowledgeService.knowledge(p,Blocks.GLAZED_TERRACOTTA.pick(DyeColor.PINK));check(block.seen()&&!block.possessed()&&block.mined()==0&&block.placed()==0,"Precise block is seen without fabricated possession or stats");check(RecipeService.knowledge(p).items.contains("minecraft:pink_glazed_terracotta")&&RecipeService.knowledge(p).collections.contains("sanctuary:terracotta"),"Block event unlocks its normal recipe collection");
|
||||
command(p,"item minecraft:amethyst_shard");check(RecipeService.knowledge(p).items.contains("minecraft:amethyst_shard")&&RecipeService.knowledge(p).recipes.contains("minecraft:spyglass"),"Precise item unlocks linked recipes");
|
||||
String old=CompanionService.record(p).json();check(run(p,"mob minecraft:item")==0,"Nonmob type refused");check(CompanionService.record(p).json().equals(old),"Rejected request does not change discoveries");
|
||||
try{dispatcher.execute("sanctuary discovery item sanctuary:missing_item",source);throw new AssertionError("Missing ID accepted");}catch(com.mojang.brigadier.exceptions.CommandSyntaxException expected){}
|
||||
command(p,"mobs all");allSpecies(p);var pos=new BlockPos(4,1,4);Statuary.open(p,pos);check(((ClayWorkshopMenu)p.containerMenu).view.statues().equals(StatueDiscoveries.known(p)),"Workshop sees the shared full catalogue");p.closeContainer();
|
||||
command(p,"all");all(p);String species=CompanionService.record(p).json(),recipes=RecipeService.knowledge(p).json();var blocks=BlockKnowledgeService.snapshot(p);command(p,"all @s");check(species.equals(CompanionService.record(p).json())&&recipes.equals(RecipeService.knowledge(p).json())&&blocks.equals(BlockKnowledgeService.snapshot(p)),"Repeated all with target selector is idempotent");
|
||||
check(CompanionService.record(p).paid()==before.paid()&&CompanionService.record(p).unlocked()==before.unlocked(),"Discoveries do not fabricate paid skills");
|
||||
try{check(dispatcher.execute("sanctuary discovery mob minecraft:cow "+p.getGameProfile().name(),s.createCommandSourceStack())==1,"Console can address an online player");}catch(Exception e){throw new AssertionError(e);}
|
||||
System.out.println("DISCOVERY126_PASS native permissions, precise block/item/species, linked recipes, shared catalogue, all three categories, all, selector, console, idempotence and unchanged combat/ownership counters");
|
||||
});
|
||||
c.waitFor(m->m.gui.screen()==null,100);
|
||||
}
|
||||
static void all(ServerPlayer p){
|
||||
allSpecies(p);check(BlockKnowledgeService.snapshot(p).stream().allMatch(BlockKnowledge::known),"All block discoveries");
|
||||
var knowledge=RecipeService.knowledge(p);for(var item:BuiltInRegistries.ITEM)if(item!=Items.AIR)check(knowledge.items.contains(BuiltInRegistries.ITEM.getKey(item).toString()),"All registered items discovered");
|
||||
check(knowledge.recipes.contains("minecraft:spyglass")&&knowledge.recipes.contains("sanctuary:ruby_block"),"Vanilla and Sanctuary recipes are derived from discoveries");
|
||||
}
|
||||
static void allSpecies(ServerPlayer p){check(StatueDiscoveries.known(p).size()==BuiltInRegistries.ENTITY_TYPE.stream().filter(t->SpawnEggItem.byId(t).isPresent()).count(),"All registered species discovered");}
|
||||
static void command(ServerPlayer p,String args){check(run(p,args)==1,"Discovery command succeeds: "+args);}
|
||||
private static int run(ServerPlayer p,String args){try{return p.level().getServer().getCommands().getDispatcher().execute("sanctuary discovery "+args,p.createCommandSourceStack());}catch(Exception e){throw new AssertionError(e);}}
|
||||
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package fr.koka.sanctuary.client;
|
||||
|
||||
import com.mojang.blaze3d.platform.InputConstants;
|
||||
import fr.koka.sanctuary.SanctuaryMod;
|
||||
import fr.koka.sanctuary.gems.*;
|
||||
import fr.koka.sanctuary.gametest.Starter039TestSupport;
|
||||
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.*;
|
||||
import net.minecraft.network.chat.contents.TranslatableContents;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.tags.*;
|
||||
import net.minecraft.util.RandomSource;
|
||||
import net.minecraft.world.item.*;
|
||||
import net.minecraft.world.item.crafting.*;
|
||||
import net.minecraft.world.item.enchantment.Enchantments;
|
||||
import net.minecraft.world.level.block.*;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.levelgen.GenerationStep;
|
||||
|
||||
/** Actual native registration, recipes, drops, tab order and naturally decorated Sanctuary chunks. */
|
||||
public final class Gems126ClientChecks implements FabricClientGameTest {
|
||||
public void runTest(ClientGameTestContext c){
|
||||
var intro=new AtomicBoolean(true);
|
||||
c.runOnClient(m->{m.options.renderDistance().set(5);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){
|
||||
IntroClient.setQuickTestIntro(true);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);
|
||||
}});
|
||||
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Gems126 registry "+UUID.randomUUID());s.setSeed("126");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);
|
||||
world.getServer().runOnServer(s->{
|
||||
var p=s.getPlayerList().getPlayers().getFirst();var l=p.level();var pos=new BlockPos(0,4,0);var iron=new ItemStack(Items.IRON_PICKAXE);var silk=iron.copy();silk.enchant(l.registryAccess().lookupOrThrow(Registries.ENCHANTMENT).getOrThrow(Enchantments.SILK_TOUCH),1);var fortune=iron.copy();fortune.enchant(l.registryAccess().lookupOrThrow(Registries.ENCHANTMENT).getOrThrow(Enchantments.FORTUNE),3);
|
||||
for(var f:Gems.FAMILIES){
|
||||
for(var b:List.of(f.block(),f.ore(),f.deepslateOre())){var state=b.defaultBlockState();check(state.is(BlockTags.MINEABLE_WITH_PICKAXE)&&state.is(BlockTags.NEEDS_IRON_TOOL),"Native mining tags "+b);check(iron.isCorrectToolForDrops(state)&&!new ItemStack(Items.STONE_PICKAXE).isCorrectToolForDrops(state),"Iron tier enforced "+b);}
|
||||
drop(Block.getDrops(f.block().defaultBlockState(),l,pos,null,p,iron),f.block().asItem(),1);
|
||||
for(var ore:List.of(f.ore(),f.deepslateOre())){
|
||||
drop(Block.getDrops(ore.defaultBlockState(),l,pos,null,p,iron),f.gem(),1);drop(Block.getDrops(ore.defaultBlockState(),l,pos,null,p,silk),ore.asItem(),1);
|
||||
int max=0;for(int i=0;i<48;i++){var drops=Block.getDrops(ore.defaultBlockState(),l,pos,null,p,fortune);check(drops.size()==1&&drops.getFirst().is(f.gem()),"Fortune gem type");int n=drops.getFirst().getCount();check(n>=1&&n<=4,"Native Fortune III range");max=Math.max(max,n);}check(max>1,"Fortune increases gem yield");
|
||||
var input=new SingleRecipeInput(new ItemStack(ore));for(var type:List.of(RecipeType.SMELTING,RecipeType.BLASTING)){var recipe=s.getRecipeManager().getRecipeFor(type,input,l).orElseThrow().value();check(recipe.assemble(input).is(f.gem()),"Native furnace recipe");}
|
||||
}
|
||||
var input=CraftingInput.of(3,3,Collections.nCopies(9,new ItemStack(f.gem())));drop(List.of(s.getRecipeManager().getRecipeFor(RecipeType.CRAFTING,input,l).orElseThrow().value().assemble(input)),f.block().asItem(),1);
|
||||
input=CraftingInput.of(1,1,List.of(new ItemStack(f.block())));drop(List.of(s.getRecipeManager().getRecipeFor(RecipeType.CRAFTING,input,l).orElseThrow().value().assemble(input)),f.gem(),9);
|
||||
check(f.block().defaultBlockState().is(BlockTags.BEACON_BASE_BLOCKS)&&new ItemStack(f.gem()).is(ItemTags.BEACON_PAYMENT_ITEMS),"Native beacon compatibility");
|
||||
var feature=l.registryAccess().lookupOrThrow(Registries.FEATURE).getOrThrow(ResourceKey.create(Registries.FEATURE,SanctuaryMod.id("ore_"+f.id()))).value();check(feature instanceof GemOreFeature,"Data feature decoded");
|
||||
check(!feature.place(l,l.getChunkSource().getGenerator(),RandomSource.create(126),pos),"Gem veins do not run in ordinary flat/vanilla worlds");
|
||||
fr.koka.sanctuary.recipes.RecipeService.discover(p,f.gem());fr.koka.sanctuary.recipes.RecipeService.refresh(p);check(fr.koka.sanctuary.recipes.RecipeService.knowledge(p).recipes.contains("sanctuary:"+f.id()+"_block"),"Native discovery unlocks storage recipe");
|
||||
}
|
||||
int x=0;for(var block:List.of(Blocks.EMERALD_BLOCK,Gems.RUBY.block(),Gems.SAPPHIRE.block(),Blocks.EMERALD_ORE,Gems.RUBY.ore(),Gems.SAPPHIRE.ore(),Blocks.DEEPSLATE_EMERALD_ORE,Gems.RUBY.deepslateOre(),Gems.SAPPHIRE.deepslateOre())){l.setBlockAndUpdate(new BlockPos(x%3,1,x/3),block.defaultBlockState());x++;}
|
||||
p.teleportTo(1.5,3,7);p.getAbilities().flying=true;p.onUpdateAbilities();p.getInventory().setItem(0,new ItemStack(Items.EMERALD));p.getInventory().setItem(1,new ItemStack(Gems.RUBY.gem()));p.getInventory().setItem(2,new ItemStack(Gems.SAPPHIRE.gem()));p.inventoryMenu.broadcastFullState();
|
||||
System.out.println("GEMS126_SERVER_PASS eight registered items, six blocks, native loot/Fortune/Silk Touch, twelve recipes, mining tiers, beacons, discoveries and vanilla generation exclusion");
|
||||
});
|
||||
c.runOnClient(m->{CreativeModeTabs.tryRebuildTabContents(m.level.enabledFeatures(),true,m.level.registryAccess());order(CreativeModeTabs.INGREDIENTS,Items.EMERALD,Gems.RUBY.gem(),Gems.SAPPHIRE.gem());order(CreativeModeTabs.BUILDING_BLOCKS,Items.EMERALD_BLOCK,Gems.RUBY.block().asItem(),Gems.SAPPHIRE.block().asItem());order(CreativeModeTabs.NATURAL_BLOCKS,Items.EMERALD_ORE,Gems.RUBY.ore().asItem(),Gems.SAPPHIRE.ore().asItem());order(CreativeModeTabs.NATURAL_BLOCKS,Items.DEEPSLATE_EMERALD_ORE,Gems.RUBY.deepslateOre().asItem(),Gems.SAPPHIRE.deepslateOre().asItem());
|
||||
for(var f:Gems.FAMILIES)for(var block:List.of(f.block(),f.ore(),f.deepslateOre())){var parts=new ArrayList<net.minecraft.client.renderer.block.dispatch.BlockStateModelPart>();m.getModelManager().getBlockStateModelSet().get(block.defaultBlockState()).collectParts(RandomSource.create(0),parts);check(!parts.isEmpty(),"Baked gem block model");for(var part:parts)for(var face:Direction.values())for(var quad:part.getQuads(face))check(!quad.materialInfo().sprite().contents().name().toString().contains("missingno"),"Native texture atlas resolves supplied gem PNG");}
|
||||
System.out.println("GEMS126_CREATIVE_PASS emerald, ruby, sapphire adjacency in four groups; all six baked block models");
|
||||
});
|
||||
c.waitTicks(10);c.runOnClient(m->m.player.lookAt(net.minecraft.commands.arguments.EntityAnchorArgument.Anchor.EYES,new net.minecraft.world.phys.Vec3(1.5,1.5,1.5)));c.waitTicks(4);c.takeScreenshot("gems126-gallery");
|
||||
Clay126Checks.exercise(c,world.getServer());
|
||||
Discovery126Checks.exercise(c,world.getServer());
|
||||
}
|
||||
intro.set(true);net.fabricmc.fabric.api.client.gametest.v1.world.TestWorldSave saved;var found=new HashMap<BlockPos,BlockState>();
|
||||
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Gems126 natural "+UUID.randomUUID());s.setSeed("126");s.setAllowCommands(true);s.setGenerateStructures(false);s.setGameMode(WorldCreationUiState.SelectedGameMode.CREATIVE);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary:sanctuary")).findFirst().orElseThrow());}).create()){
|
||||
c.waitFor(m->m.player!=null&&m.gui.screen()==null&&ProgressionClient.active()&&!IntroSpawnFlash.active(),2400);
|
||||
world.getServer().runOnServer(s->{var l=s.overworld();check(l.getChunkSource().getGenerator() instanceof fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator,"Actual Sanctuary generator");
|
||||
for(int cx=-4;cx<=4;cx++)for(int cz=-4;cz<=4;cz++){var chunk=l.getChunk(cx,cz);var cursor=new BlockPos.MutableBlockPos();for(int x=0;x<16;x++)for(int z=0;z<16;z++)for(int y=0;y<=256;y++){cursor.set(cx*16+x,y,cz*16+z);var state=chunk.getBlockState(cursor);if(state.is(Gems.RUBY.ore())||state.is(Gems.RUBY.deepslateOre())||state.is(Gems.SAPPHIRE.ore())||state.is(Gems.SAPPHIRE.deepslateOre()))found.put(cursor.immutable(),state);}}
|
||||
for(var f:Gems.FAMILIES){long ordinary=found.values().stream().filter(b->b.is(f.ore())).count(),deep=found.values().stream().filter(b->b.is(f.deepslateOre())).count();System.out.println("GEMS126_NATURAL "+f.id()+" stone="+ordinary+" deepslate="+deep+" seed=126 chunks=81");check(ordinary+deep>0,"Both gems generate naturally without structure placement: "+f.id());}
|
||||
var biome=l.getBiome(found.keySet().iterator().next()).value();var features=biome.getGenerationSettings().features().get(GenerationStep.Decoration.UNDERGROUND_ORES.ordinal());for(var f:Gems.FAMILIES)check(features.stream().anyMatch(h->h.is(ResourceKey.create(Registries.PLACED_FEATURE,SanctuaryMod.id("ore_"+f.id())))),"Native biome includes configurable gem placement");
|
||||
Discovery126Checks.command(s.getPlayerList().getPlayers().getFirst(),"all");
|
||||
var mined=found.keySet().iterator().next();l.setBlockAndUpdate(mined,Blocks.AIR.defaultBlockState());found.put(mined,Blocks.AIR.defaultBlockState());
|
||||
});saved=world.getWorldSave();
|
||||
}
|
||||
try(var world=saved.open()){
|
||||
c.waitFor(m->m.player!=null&&m.gui.screen()==null,2400);world.getServer().runOnServer(s->{Discovery126Checks.all(s.getPlayerList().getPlayers().getFirst());for(var e:found.entrySet())check(s.overworld().getBlockState(e.getKey())==e.getValue(),"Generated ores persist on reload without repopulation");});
|
||||
}
|
||||
System.out.println("GEMS126_PASS registry, recipes, drops, creative order, native models, natural Sanctuary generation seed 126 and save/reconnect");
|
||||
}
|
||||
private static void order(ResourceKey<CreativeModeTab> tab,Item anchor,Item ruby,Item sapphire){var items=new ArrayList<>(BuiltInRegistries.CREATIVE_MODE_TAB.getOrThrow(tab).value().getDisplayItems());int i=0;while(i<items.size()&&!items.get(i).is(anchor))i++;check(i+2<items.size()&&items.get(i+1).is(ruby)&&items.get(i+2).is(sapphire),"Emerald/ruby/sapphire order in "+tab);check(items.stream().filter(v->v.is(ruby)||v.is(sapphire)).count()==2,"No duplicate creative entries");}
|
||||
private static void drop(List<ItemStack> drops,Item item,int n){check(drops.size()==1&&drops.getFirst().is(item)&&drops.getFirst().getCount()==n,"Native loot/craft expected "+n+" "+item+", got "+drops);}
|
||||
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
|
||||
}
|
||||
@@ -48,7 +48,7 @@ public final class Sculpture116ClientChecks implements FabricClientGameTest {
|
||||
var rendered=new HashSet<VoxelSculpture>();
|
||||
for(var item:List.of(Items.STONE,Items.OAK_PLANKS,Items.GOLD_BLOCK,Items.WOOL.pick(DyeColor.RED),Items.GLASS,Items.OAK_LEAVES,Items.CLAY,Items.DEEPSLATE)){
|
||||
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.getInventory().clearContent();var menu=(ClayWorkshopMenu)p.containerMenu;menu.getSlot(0).set(new ItemStack(item,2));check(menu.getSlot(1).getItem().isEmpty(),"Previous material result invalidates immediately");menu.broadcastChanges();});
|
||||
var expected=c.computeOnClient(m->SculptureMaterialSampler.sample(new ItemStack(item)).apply(source));rendered.add(expected);
|
||||
var expected=c.computeOnClient(m->ClayWorkshopMenu.appearance(source,item,SculptureMaterialSampler.sample(new ItemStack(item))));rendered.add(expected);
|
||||
c.waitFor(m->expected.equals(StatuaryEntity.sculpture(m.player.containerMenu.getSlot(1).getItem())),180);
|
||||
c.runOnClient(m->check(ItemStack.matches(((ClayWorkshopScreen)m.gui.screen()).preview(),m.player.containerMenu.getSlot(1).getItem()),"Preview equals server-owned result"));
|
||||
check(StatuaryEntity.decode(StatuaryEntity.encode(expected)).equals(expected),"Palette colors persist in unchanged schema 1");
|
||||
|
||||
@@ -62,7 +62,7 @@ public final class Statuary106ClientChecks implements FabricClientGameTest {
|
||||
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Statuary106 "+UUID.randomUUID());s.setSeed("106");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 source=c.computeOnClient(m->SculptureMaterialSampler.sample(new ItemStack(Items.CLAY)).apply(originalSource));
|
||||
var source=originalSource;
|
||||
var server=world.getServer();
|
||||
server.runOnServer(s->{var p=player(s);p.teleportTo(.5,1,.5);p.getInventory().clearContent();p.setGameMode(GameType.SURVIVAL);for(int x=-7;x<=9;x++)for(int z=-5;z<=9;z++)p.level().setBlockAndUpdate(new BlockPos(x,0,z),Blocks.STONE.defaultBlockState());p.level().setBlockAndUpdate(WORKSHOP,Statuary.WORKSHOP.defaultBlockState());
|
||||
var legacy=net.minecraft.nbt.NbtUtils.writeBlockState(Statuary.BLOCK.defaultBlockState());legacy.remove("properties");
|
||||
|
||||
@@ -0,0 +1,634 @@
|
||||
package fr.koka.sanctuary.client.shader;
|
||||
|
||||
import com.mojang.blaze3d.platform.InputConstants;
|
||||
import fr.koka.sanctuary.client.HelloWorldScreen;
|
||||
import fr.koka.sanctuary.client.IntroClient;
|
||||
import fr.koka.sanctuary.client.IntroSpawnFlash;
|
||||
import fr.koka.sanctuary.client.ProgressionClient;
|
||||
import fr.koka.sanctuary.gametest.Starter039TestSupport;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
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.fabricmc.fabric.api.client.gametest.v1.context.TestServerContext;
|
||||
import net.fabricmc.loader.api.FabricLoader;
|
||||
import net.minecraft.client.CameraType;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.Screenshot;
|
||||
import net.minecraft.client.gui.components.Button;
|
||||
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
|
||||
import net.minecraft.client.input.KeyEvent;
|
||||
import net.minecraft.client.renderer.culling.Frustum;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.network.chat.contents.TranslatableContents;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.StairBlock;
|
||||
import net.minecraft.world.level.gamerules.GameRules;
|
||||
import net.minecraft.world.phys.AABB;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
|
||||
/** Real terrain/depth-map checks on a new flat seed 122 world; GPU readback is test-only. */
|
||||
public final class PixelShadows122ClientChecks implements FabricClientGameTest {
|
||||
private static final int FLOOR = 64;
|
||||
private static final AABB PILLAR = new AABB(0, FLOOR + 1, 0, 2, FLOOR + 9, 2);
|
||||
private static final BlockPos SLAB = new BlockPos(5, FLOOR + 3, -5);
|
||||
private static final BlockPos STAIR = new BlockPos(5, FLOOR + 3, 5);
|
||||
private static final int DARK_DELTA = 8;
|
||||
|
||||
@Override
|
||||
public void runTest(ClientGameTestContext c) {
|
||||
var path = FabricLoader.getInstance().getConfigDir().resolve("sanctuary-shaders.json");
|
||||
final byte[] originalPreferences;
|
||||
try {
|
||||
originalPreferences = Files.exists(path) ? Files.readAllBytes(path) : null;
|
||||
} catch (IOException e) {
|
||||
throw new AssertionError("Cannot back up shader preferences", e);
|
||||
}
|
||||
var originalOptions = c.computeOnClient(Options::capture);
|
||||
var onboarding = new AtomicBoolean(true);
|
||||
ClientTickEvents.END_CLIENT_TICK.register(m -> {
|
||||
if (onboarding.get() && m.gui.screen() instanceof HelloWorldScreen screen) {
|
||||
// Public test support works across packages; enter through the native widgets.
|
||||
IntroClient.setQuickTestIntro(true);
|
||||
if (!screen.starterChoices().isEmpty()) Starter039TestSupport.selectFirst(screen);
|
||||
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());
|
||||
onboarding.set(false);
|
||||
}
|
||||
});
|
||||
try {
|
||||
c.runOnClient(m -> {
|
||||
m.options.renderDistance().set(6);
|
||||
m.options.simulationDistance().set(5);
|
||||
m.options.bobView().set(false);
|
||||
m.options.pauseOnLostFocus = false;
|
||||
m.options.setCameraType(CameraType.FIRST_PERSON);
|
||||
checkPreferences();
|
||||
});
|
||||
try (var world = c.worldBuilder().adjustSettings(s -> {
|
||||
s.setName("PixelShadows122 " + 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()) {
|
||||
world.getConnection().waitForChunksDownload(2400);
|
||||
c.waitFor(m -> m.player != null && ProgressionClient.active()
|
||||
&& m.gui.screen() == null && !IntroSpawnFlash.active(), 1200);
|
||||
c.getInput().pressKey(InputConstants.KEY_F1);
|
||||
var server = world.getServer();
|
||||
server.runOnServer(s -> {
|
||||
command(s, "sanctuary time vanilla");
|
||||
command(s, "sanctuary weather vanilla");
|
||||
command(s, "weather clear 100000");
|
||||
if (s.getGlobalGameRules().get(GameRules.ADVANCE_TIME))
|
||||
command(s, "gamerule " + GameRules.ADVANCE_TIME.id() + " false");
|
||||
check(!s.getGlobalGameRules().get(GameRules.ADVANCE_TIME), "Native daylight rule is frozen");
|
||||
command(s, "time set 3000");
|
||||
command(s, "gamemode spectator @a");
|
||||
if (s.getGlobalGameRules().get(GameRules.SPAWN_MOBS))
|
||||
command(s, "gamerule " + GameRules.SPAWN_MOBS.id() + " false");
|
||||
var level = s.overworld();
|
||||
var entities = new java.util.ArrayList<net.minecraft.world.entity.Entity>();
|
||||
level.getAllEntities().forEach(entities::add);
|
||||
entities.stream().filter(e -> !(e instanceof net.minecraft.server.level.ServerPlayer))
|
||||
.forEach(net.minecraft.world.entity.Entity::discard);
|
||||
// The elevated platform isolates the samples from grass, mobs and flat-world lighting.
|
||||
for (int x = -24; x <= 24; x++) for (int z = -24; z <= 24; z++) {
|
||||
level.setBlockAndUpdate(new BlockPos(x, FLOOR, z), Blocks.CONCRETE.white().defaultBlockState());
|
||||
}
|
||||
pillar(s, true);
|
||||
// Coloured casters cannot be mistaken for neutral white receiving ground.
|
||||
level.setBlockAndUpdate(SLAB, Blocks.RED_SANDSTONE_SLAB.defaultBlockState());
|
||||
level.setBlockAndUpdate(STAIR, Blocks.RED_SANDSTONE_STAIRS.defaultBlockState()
|
||||
.setValue(StairBlock.FACING, Direction.WEST));
|
||||
});
|
||||
camera(c, server, -2, FLOOR + 18, 1, 70);
|
||||
c.waitFor(m -> m.level.getBlockState(new BlockPos(0, FLOOR + 8, 0)).is(Blocks.CONCRETE.red())
|
||||
&& m.level.getBlockState(SLAB).is(Blocks.RED_SANDSTONE_SLAB)
|
||||
&& m.level.getBlockState(STAIR).is(Blocks.RED_SANDSTONE_STAIRS), 200);
|
||||
c.waitTicks(40);
|
||||
c.runOnClient(m -> check(VanillaLight.active(), "Native Sanctuary shader is active without an external engine"));
|
||||
float morning = c.computeOnClient(m -> m.gameRenderer.gameRenderState().levelRenderState.skyRenderState.sunAngle);
|
||||
|
||||
// Keep VanillaLight enabled for both frames: changing its fog/grade would invalidate this oracle.
|
||||
shadows(c, false);
|
||||
var off = capture(c, "day-off");
|
||||
c.waitTicks(6);
|
||||
unchanged(off, capture(c, null), "Fixed camera/daylight OFF control");
|
||||
shadows(c, true);
|
||||
var on = capture(c, "day-on-pillar-slab-stair");
|
||||
var day = compare(off, on);
|
||||
localShadow(day, "Pillar/slab/stair daylight ground", .70);
|
||||
c.runOnClient(m -> {
|
||||
check(Float.compare(morning, m.gameRenderer.gameRenderState().levelRenderState.skyRenderState.sunAngle) == 0,
|
||||
"Native rule actually freezes the rendered sun angle");
|
||||
check(PixelShadows.shadowMap() != null, "Daylight allocates a real shadow target");
|
||||
check(PixelShadows.shadowMap().width == PixelShadows.mapSize(32, 16), "Shadow map follows selected quality");
|
||||
});
|
||||
for (int pixels : new int[]{8, 32, 16}) {
|
||||
c.runOnClient(m -> VanillaLight.setShadowPixels(pixels));
|
||||
activeFrames(c);
|
||||
c.runOnClient(m -> check(PixelShadows.shadowMap().width == PixelShadows.mapSize(32, pixels),
|
||||
"Live pixel density reallocates the correct depth map"));
|
||||
capture(c, "precision-" + pixels);
|
||||
}
|
||||
for (int distance : new int[]{16, 64, 32}) {
|
||||
c.runOnClient(m -> VanillaLight.setShadowDistance(distance));
|
||||
activeFrames(c);
|
||||
c.runOnClient(m -> check(PixelShadows.shadowMap().width == PixelShadows.mapSize(distance, 16),
|
||||
"Live distance reallocates the correct depth map"));
|
||||
}
|
||||
shadows(c, false);
|
||||
unchanged(off, capture(c, "day-off-restored"), "Disabling removes the shadow without residual grading");
|
||||
|
||||
// Aim at the shadow's far end, with its entire caster outside the actual camera frustum.
|
||||
shadows(c, true);
|
||||
double shadowX = c.computeOnClient(m -> {
|
||||
var sun = PixelShadows.sunDirection(m.gameRenderer.gameRenderState().levelRenderState.skyRenderState.sunAngle);
|
||||
check(sun.y > .2f && Math.abs(sun.x / sun.y) > .5f, "Morning has an oblique sun for offscreen casting");
|
||||
return 1.0 - 7.0 * sun.x / sun.y;
|
||||
});
|
||||
camera(c, server, shadowX, FLOOR + 5, 1, 30);
|
||||
c.runOnClient(m -> {
|
||||
var state = m.gameRenderer.gameRenderState().levelRenderState.cameraRenderState;
|
||||
var frustum = new Frustum(state.viewRotationMatrix, state.projectionMatrix);
|
||||
frustum.prepare(state.pos.x, state.pos.y, state.pos.z);
|
||||
check(!frustum.isVisible(PILLAR), "Whole pillar is outside the receiver camera frustum");
|
||||
check(!frustum.isVisible(new AABB(SLAB)) && !frustum.isVisible(new AABB(STAIR)),
|
||||
"Slab and stair are also outside the offscreen capture");
|
||||
});
|
||||
shadows(c, false);
|
||||
var offscreenOff = capture(c, "offscreen-off");
|
||||
shadows(c, true);
|
||||
var offscreenOn = capture(c, "offscreen-on");
|
||||
localShadow(compare(offscreenOff, offscreenOn), "Offscreen pillar still casts onto visible ground", .20);
|
||||
|
||||
// Do not toggle/release/rebuild the renderer here: real server edits must invalidate its live cache.
|
||||
server.runOnServer(s -> pillar(s, false));
|
||||
c.waitFor(m -> m.level.getBlockState(new BlockPos(0, FLOOR + 8, 0)).isAir(), 200);
|
||||
activeFrames(c);
|
||||
var removed = capture(c, "offscreen-caster-removed");
|
||||
restoredGround(offscreenOff, offscreenOn, removed);
|
||||
server.runOnServer(s -> pillar(s, true));
|
||||
c.waitFor(m -> m.level.getBlockState(new BlockPos(0, FLOOR + 8, 0)).is(Blocks.CONCRETE.red()), 200);
|
||||
activeFrames(c);
|
||||
var replaced = capture(c, "offscreen-caster-replaced");
|
||||
localShadow(compare(offscreenOff, replaced), "Replacing caster restores its shadow", .20);
|
||||
unchanged(offscreenOn, replaced, "Same caster restores the same ground shadow");
|
||||
|
||||
// beta.128: the moon now casts a weaker shadow using its native celestial angle.
|
||||
server.runOnServer(s -> command(s, "time set 15000"));
|
||||
c.waitFor(m -> PixelShadows.sunDirection(m.gameRenderer.gameRenderState().levelRenderState.skyRenderState.sunAngle).y < 0, 200);
|
||||
camera(c, server, -2, FLOOR + 18, 1, 70);
|
||||
shadows(c, false);
|
||||
var moonOff = capture(c, "moon-off");
|
||||
shadows(c, true);
|
||||
var moonOn = capture(c, "moon-on");
|
||||
var moonStats = opacity(moonOff, moonOn);
|
||||
check(moonStats.dark > 100 && moonStats.maximum > .025 && moonStats.maximum < .14,
|
||||
"Moon creates localized subtle shadows: " + moonStats);
|
||||
unchanged(moonOn, capture(c, null), "Frozen moon shadow is stable");
|
||||
server.runOnServer(s -> command(s, "time set 111000"));
|
||||
c.waitFor(m -> m.gameRenderer.gameRenderState().levelRenderState.skyRenderState.moonPhase.index() == 4
|
||||
&& !PixelShadows.applied(), 200);
|
||||
c.waitTicks(6);
|
||||
int newMoonFrames = c.computeOnClient(m -> PixelShadows.renderedFrames());
|
||||
c.waitTicks(6);
|
||||
c.runOnClient(m -> check(PixelShadows.renderedFrames() == newMoonFrames, "New moon submits no shadow pass"));
|
||||
noonAndFoliage(c, server);
|
||||
|
||||
c.waitFor(m -> PixelShadows.sunDirection(m.gameRenderer.gameRenderState().levelRenderState.skyRenderState.sunAngle).y > .2f,
|
||||
200);
|
||||
shadows(c, true);
|
||||
camera(c, server, -2, FLOOR + 18, 1, 70);
|
||||
c.waitTicks(30);
|
||||
var beforeReload = capture(c, "before-reload");
|
||||
var reload = c.computeOnClient(Minecraft::reloadResourcePacks);
|
||||
c.waitFor(m -> reload.isDone() && m.gui.overlay() == null, 1200);
|
||||
reload.join();
|
||||
c.runOnClient(m -> {
|
||||
forgetPreferences();
|
||||
check(VanillaLight.enabled() && VanillaLight.pixelShadows()
|
||||
&& VanillaLight.shadowPixels() == 16 && VanillaLight.shadowDistance() == 32,
|
||||
"Resource reload and disk reread retain enabled shadow preferences");
|
||||
});
|
||||
activeFrames(c);
|
||||
unchanged(beforeReload, capture(c, "after-reload"), "Resource reload preserves the rendered ground shadow");
|
||||
c.getInput().pressKey(InputConstants.KEY_F1);
|
||||
for (String locale : new String[]{"fr_fr", "en_us"}) {
|
||||
c.runOnClient(m -> {
|
||||
m.getLanguageManager().setSelected(locale);
|
||||
m.getLanguageManager().onResourceManagerReload(m.getResourceManager());
|
||||
for (String key : new String[]{"pixel_shadows", "shadow_pixels", "shadow_distance"}) {
|
||||
String fullKey = "sanctuary.shaders." + key;
|
||||
check(!Component.translatable(fullKey).getString().equals(fullKey), "Translated " + locale + " " + key);
|
||||
}
|
||||
m.gui.setScreen(new ShaderOptionsScreen(null));
|
||||
widgets(m.gui.screen()).filter(net.minecraft.client.gui.components.CycleButton.class::isInstance)
|
||||
.map(net.minecraft.client.gui.components.AbstractWidget.class::cast).forEach(button -> {
|
||||
check(m.font.width(button.getMessage()) < button.getWidth() - 8,
|
||||
"Localized shader cycle fits: " + button.getMessage().getString());
|
||||
});
|
||||
});
|
||||
c.waitTicks(3);
|
||||
c.takeScreenshot("sanctuary-beta128-shadows-options-" + locale);
|
||||
}
|
||||
c.runOnClient(m -> m.gui.setScreen(null));
|
||||
}
|
||||
System.out.println("PIXEL_SHADOWS122_CLIENT_PASS: bounded saved preferences; native GPU ON/OFF ground; "
|
||||
+ "offscreen caster; live removal/replacement; subtle moon; noon attenuation; foliage density; camera stability; resource reload; FR/EN screenshots");
|
||||
} finally {
|
||||
onboarding.set(false);
|
||||
c.runOnClient(m -> {
|
||||
PixelShadows.release();
|
||||
originalOptions.restore(m);
|
||||
IntroClient.setQuickTestIntro(false);
|
||||
try {
|
||||
if (originalPreferences == null) Files.deleteIfExists(path);
|
||||
else Files.write(path, originalPreferences);
|
||||
} catch (IOException e) {
|
||||
throw new AssertionError("Cannot restore shader preferences", e);
|
||||
} finally {
|
||||
forgetPreferences();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static void noonAndFoliage(ClientGameTestContext c, TestServerContext server) {
|
||||
// Overhead caster keeps a visible noon shadow while the camera sits below it.
|
||||
server.runOnServer(s -> {
|
||||
pillar(s, false);
|
||||
s.overworld().setBlockAndUpdate(SLAB, Blocks.AIR.defaultBlockState());
|
||||
s.overworld().setBlockAndUpdate(STAIR, Blocks.AIR.defaultBlockState());
|
||||
canopy(s, 1, false, 3);
|
||||
command(s, "time set 6000");
|
||||
});
|
||||
camera(c, server, .99999, FLOOR + 5, 1, 70);
|
||||
c.waitTicks(30);
|
||||
shadows(c, false);
|
||||
var noonOff = capture(c, "noon-off");
|
||||
shadows(c, true);
|
||||
var noonOn = capture(c, "noon-on");
|
||||
var noonStats = opacity(noonOff, noonOn);
|
||||
crispEdge(noonOff, noonOn);
|
||||
check(noonStats.dark > 100 && noonStats.maximum > .04 && noonStats.maximum < .14,
|
||||
"Noon shadow is present but attenuated: " + noonStats);
|
||||
camera(c, server, 1.00001, FLOOR + 5, 1, 70);
|
||||
unchanged(noonOn, capture(c, "noon-camera-subpixel"), "Noon tiny motion across former integer anchor");
|
||||
camera(c, server, .99999, FLOOR + 5, 1, 70);
|
||||
unchanged(noonOn, capture(c, null), "Noon camera return");
|
||||
look(c, server, 35, 60);
|
||||
shadows(c, false);
|
||||
var obliqueOff = capture(c, "noon-oblique-off");
|
||||
shadows(c, true);
|
||||
var obliqueNoon = capture(c, "noon-oblique");
|
||||
localShadow(compare(obliqueOff, obliqueNoon), "Oblique noon camera sees actual white receiving ground", .10);
|
||||
look(c, server, 35.00002f, 60.00002f);
|
||||
unchanged(obliqueNoon, capture(c, null), "Subpixel rotation with an oblique receiver");
|
||||
// Both sides of noon, frozen individually: no frame-to-frame edge flicker.
|
||||
for (int time : new int[]{5990, 6010}) {
|
||||
server.runOnServer(s -> command(s, "time set " + time));
|
||||
c.waitTicks(10);
|
||||
var stable = capture(c, "noon-" + time);
|
||||
c.waitTicks(10);
|
||||
unchanged(stable, capture(c, null), "Frozen near-noon " + time);
|
||||
}
|
||||
server.runOnServer(s -> command(s, "time set 5999"));
|
||||
c.waitTicks(10);
|
||||
var beforeNoon = capture(c, null);
|
||||
server.runOnServer(s -> command(s, "time set 6001"));
|
||||
c.waitTicks(10);
|
||||
unchanged(beforeNoon, capture(c, "noon-crossing"), "Continuous passage through noon has no large flip");
|
||||
server.runOnServer(s -> {
|
||||
canopy(s, 1, true, 16);
|
||||
command(s, "time set 3000");
|
||||
});
|
||||
camera(c, server, 0, FLOOR + 5, 0, 60);
|
||||
c.waitTicks(30);
|
||||
shadows(c, false);
|
||||
var thinOff = capture(c, "leaves-thin-off");
|
||||
shadows(c, true);
|
||||
var thinOn = capture(c, "leaves-thin-on");
|
||||
var thin = opacity(thinOff, thinOn);
|
||||
c.waitTicks(8);
|
||||
unchanged(thinOn, capture(c, null), "Leaf noise is independent of frame/time");
|
||||
// Keep the live cache: adding inner leaf layers must invalidate it too.
|
||||
server.runOnServer(s -> canopy(s, 4, true, 16));
|
||||
c.waitTicks(30);
|
||||
activeFrames(c);
|
||||
var thickOn = capture(c, "leaves-thick-on");
|
||||
shadows(c, false);
|
||||
var thickOff = capture(c, "leaves-thick-off");
|
||||
var thick = opacity(thickOff, thickOn);
|
||||
server.runOnServer(s -> canopy(s, 4, false, 16));
|
||||
c.waitTicks(30);
|
||||
var solidOff = capture(c, "canopy-solid-off");
|
||||
shadows(c, true);
|
||||
var solid = opacity(solidOff, capture(c, "canopy-solid-on"));
|
||||
check(thin.mean > .02 && thick.mean > thin.mean + .025 && solid.mean > thick.mean + .01,
|
||||
"Depth increases foliage opacity, bounded by solid: thin=" + thin + " thick=" + thick + " solid=" + solid);
|
||||
check(thin.dark > 100 && thin.lit > 100, "Thin foliage has both light holes and shadows: " + thin);
|
||||
server.runOnServer(s -> {
|
||||
for (int x=-16;x<=16;x++) for(int z=-16;z<=16;z++) for(int y=FLOOR+10;y<FLOOR+13;y++)
|
||||
s.overworld().setBlockAndUpdate(new BlockPos(x,y,z), Blocks.AIR.defaultBlockState());
|
||||
canopy(s, 1, true, 16);
|
||||
});
|
||||
c.waitTicks(30);
|
||||
// Crossing a 16-block origin boundary must preserve the same world shadow.
|
||||
camera(c, server, 7.99999, FLOOR + 5, 0, 60);
|
||||
activeFrames(c);
|
||||
var boundary = capture(c, null);
|
||||
camera(c, server, 8.00001, FLOOR + 5, 0, 60);
|
||||
activeFrames(c);
|
||||
unchanged(boundary, capture(c, "anchor-boundary"), "Subpixel motion across 16-block anchor");
|
||||
System.out.println("SHADOWS128_PASS moon/noon plus stable anchors; foliage thin=" + thin + " thick=" + thick + " solid=" + solid);
|
||||
server.runOnServer(s -> {
|
||||
for (int x=-16;x<=16;x++) for(int z=-16;z<=16;z++) for(int y=FLOOR+9;y<FLOOR+13;y++)
|
||||
s.overworld().setBlockAndUpdate(new BlockPos(x,y,z), Blocks.AIR.defaultBlockState());
|
||||
pillar(s, true);
|
||||
});
|
||||
}
|
||||
|
||||
private static void canopy(MinecraftServer s, int layers, boolean leaves, int radius) {
|
||||
var state = leaves ? Blocks.OAK_LEAVES.defaultBlockState().setValue(net.minecraft.world.level.block.LeavesBlock.PERSISTENT, true)
|
||||
: Blocks.CONCRETE.red().defaultBlockState();
|
||||
for (int x=-radius;x<=radius;x++) for(int z=-radius;z<=radius;z++) for(int y=FLOOR+9;y<FLOOR+9+layers;y++)
|
||||
s.overworld().setBlockAndUpdate(new BlockPos(x,y,z), state);
|
||||
}
|
||||
|
||||
private static void crispEdge(Frame off, Frame on) {
|
||||
int samples = 0, intermediate = 0;
|
||||
for (int y=off.height/10;y<off.height*9/10;y+=2) for (int x=off.width/10;x<off.width*9/10;x+=2) {
|
||||
int i=y*off.width+x;
|
||||
if (!whiteGround(off.pixels[i])) continue;
|
||||
samples++;
|
||||
double ratio=(brightness(off.pixels[i])-brightness(on.pixels[i]))/(double)brightness(off.pixels[i]);
|
||||
if (ratio > .015 && ratio < .070) intermediate++;
|
||||
}
|
||||
check(samples > 1000 && intermediate < samples / 200,
|
||||
"Opaque noon edge stays crisp, no foliage smoothing: " + intermediate + "/" + samples);
|
||||
System.out.println("SHADOWS128_CRISP intermediate=" + intermediate + "/" + samples);
|
||||
}
|
||||
|
||||
private record Opacity(int samples, int dark, int lit, double mean, double maximum) { }
|
||||
private static Opacity opacity(Frame off, Frame on) {
|
||||
sameSize(off,on);
|
||||
int samples=0,dark=0,lit=0;double sum=0,maximum=0;
|
||||
for(int y=off.height/10;y<off.height*9/10;y+=2) for(int x=off.width/10;x<off.width*9/10;x+=2) {
|
||||
int i=y*off.width+x,a=off.pixels[i],b=on.pixels[i];
|
||||
int r=a>>16&255,g=a>>8&255,blue=a&255;
|
||||
if(Math.min(r,Math.min(g,blue))<24 || Math.max(r,Math.max(g,blue))-Math.min(r,Math.min(g,blue))>64)continue;
|
||||
double ratio=(brightness(a)-brightness(b))/(double)brightness(a);
|
||||
samples++;sum+=ratio;maximum=Math.max(maximum,ratio);
|
||||
if(ratio>.02)dark++;else if(Math.abs(ratio)<.01)lit++;
|
||||
}
|
||||
check(samples>1000,"White concrete (including blue moonlight) available for shadow opacity");
|
||||
var result=new Opacity(samples,dark,lit,sum/samples,maximum);
|
||||
System.out.println("SHADOWS128_OPACITY " + result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static java.util.stream.Stream<net.minecraft.client.gui.components.events.GuiEventListener> widgets(
|
||||
net.minecraft.client.gui.components.events.ContainerEventHandler root) {
|
||||
return root.children().stream().flatMap(widget -> widget instanceof net.minecraft.client.gui.components.events.ContainerEventHandler child
|
||||
? java.util.stream.Stream.concat(java.util.stream.Stream.of(widget), widgets(child)) : java.util.stream.Stream.of(widget));
|
||||
}
|
||||
|
||||
private static void checkPreferences() {
|
||||
VanillaLight.setEnabled(true);
|
||||
VanillaLight.setSaturation(100);
|
||||
for (int[] pair : new int[][]{{Integer.MIN_VALUE, 8}, {11, 8}, {12, 16}, {16, 16}, {23, 16}, {24, 32}, {Integer.MAX_VALUE, 32}}) {
|
||||
VanillaLight.setShadowPixels(pair[0]);
|
||||
forgetPreferences();
|
||||
check(VanillaLight.shadowPixels() == pair[1], "Persisted pixel density rounds/clamps " + pair[0]);
|
||||
}
|
||||
for (int[] pair : new int[][]{{Integer.MIN_VALUE, 16}, {23, 16}, {24, 32}, {32, 32}, {47, 32}, {48, 64}, {Integer.MAX_VALUE, 64}}) {
|
||||
VanillaLight.setShadowDistance(pair[0]);
|
||||
forgetPreferences();
|
||||
check(VanillaLight.shadowDistance() == pair[1], "Persisted shadow distance rounds/clamps " + pair[0]);
|
||||
}
|
||||
for (boolean enabled : new boolean[]{true, false}) {
|
||||
VanillaLight.setPixelShadows(enabled);
|
||||
forgetPreferences();
|
||||
check(VanillaLight.pixelShadows() == enabled && VanillaLight.enabled(), "Saved shadow toggle is independent of main shader");
|
||||
}
|
||||
VanillaLight.setShadowPixels(16);
|
||||
VanillaLight.setShadowDistance(32);
|
||||
}
|
||||
|
||||
private static void forgetPreferences() {
|
||||
try {
|
||||
var field = VanillaLight.class.getDeclaredField("preferences");
|
||||
field.setAccessible(true);
|
||||
field.set(null, null);
|
||||
} catch (ReflectiveOperationException e) {
|
||||
throw new AssertionError("Cannot reread actual shader preferences", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void camera(ClientGameTestContext c, TestServerContext server, double x, double y, double z, int fov) {
|
||||
server.runOnServer(s -> {
|
||||
var p = s.getPlayerList().getPlayers().getFirst();
|
||||
command(s, "tp @a " + x + " " + y + " " + z + " 0 90");
|
||||
p.setDeltaMovement(Vec3.ZERO);
|
||||
});
|
||||
c.waitFor(m -> m.player.position().distanceToSqr(new Vec3(x, y, z)) < .0001
|
||||
&& Math.abs(m.player.getXRot() - 90) < .001, 200);
|
||||
c.runOnClient(m -> {
|
||||
m.options.fov().set(fov);
|
||||
m.player.setYRot(0);
|
||||
m.player.yRotO = 0;
|
||||
m.player.setXRot(90);
|
||||
m.player.xRotO = 90;
|
||||
m.player.setDeltaMovement(Vec3.ZERO);
|
||||
});
|
||||
c.waitTicks(15);
|
||||
}
|
||||
|
||||
private static void look(ClientGameTestContext c, TestServerContext server, float yaw, float pitch) {
|
||||
server.runOnServer(s -> {
|
||||
var p = s.getPlayerList().getPlayers().getFirst();
|
||||
command(s, "tp @a " + p.getX() + " " + p.getY() + " " + p.getZ() + " " + yaw + " " + pitch);
|
||||
});
|
||||
c.waitFor(m -> Math.abs(m.player.getXRot() - pitch) < .00001
|
||||
&& Math.abs(m.player.getYRot() - yaw) < .00001, 200);
|
||||
c.waitTicks(15);
|
||||
}
|
||||
|
||||
private static void pillar(MinecraftServer s, boolean present) {
|
||||
for (int x = 0; x < 2; x++) for (int z = 0; z < 2; z++) for (int y = FLOOR + 1; y <= FLOOR + 8; y++) {
|
||||
s.overworld().setBlockAndUpdate(new BlockPos(x, y, z),
|
||||
(present ? Blocks.CONCRETE.red() : Blocks.AIR).defaultBlockState());
|
||||
}
|
||||
}
|
||||
|
||||
private static void command(MinecraftServer s, String command) {
|
||||
try {
|
||||
// Dispatcher exceptions fail the fixture instead of leaving an ignored command error in chat.
|
||||
s.getCommands().getDispatcher().execute(command, s.createCommandSourceStack());
|
||||
} catch (com.mojang.brigadier.exceptions.CommandSyntaxException e) {
|
||||
throw new AssertionError("Fixture command failed: " + command, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void shadows(ClientGameTestContext c, boolean enabled) {
|
||||
c.runOnClient(m -> VanillaLight.setPixelShadows(enabled));
|
||||
if (enabled) activeFrames(c);
|
||||
else {
|
||||
c.waitTicks(6);
|
||||
int frames = c.computeOnClient(m -> {
|
||||
check(!PixelShadows.applied() && PixelShadows.shadowMap() == null, "OFF releases the GPU shadow target");
|
||||
return PixelShadows.renderedFrames();
|
||||
});
|
||||
c.waitTicks(3);
|
||||
c.runOnClient(m -> check(PixelShadows.renderedFrames() == frames, "OFF submits no shadow frames"));
|
||||
}
|
||||
}
|
||||
|
||||
private static void activeFrames(ClientGameTestContext c) {
|
||||
int before = c.computeOnClient(m -> PixelShadows.renderedFrames());
|
||||
c.waitFor(m -> PixelShadows.applied() && PixelShadows.ready() && PixelShadows.renderedFrames() > before + 12, 200);
|
||||
// Allow the bounded terrain cache and native light/chunk uploads to settle before reading pixels.
|
||||
c.waitTicks(30);
|
||||
}
|
||||
|
||||
private static Frame capture(ClientGameTestContext c, String name) {
|
||||
if (name != null) c.takeScreenshot("sanctuary-beta128-shadows-" + name);
|
||||
var result = new CompletableFuture<Frame>();
|
||||
c.runOnClient(m -> Screenshot.takeScreenshot(m.gameRenderer.mainRenderTarget(), image -> {
|
||||
try (image) {
|
||||
result.complete(new Frame(image.getWidth(), image.getHeight(), image.getPixels()));
|
||||
} catch (Throwable e) {
|
||||
result.completeExceptionally(e);
|
||||
}
|
||||
}));
|
||||
c.waitFor(m -> result.isDone(), 200);
|
||||
return result.join();
|
||||
}
|
||||
|
||||
private record Frame(int width, int height, int[] pixels) { }
|
||||
private record Difference(int sampled, int ground, int dark, int stable, int brighter) { }
|
||||
|
||||
private static Difference compare(Frame before, Frame after) {
|
||||
sameSize(before, after);
|
||||
int sampled = 0, ground = 0, dark = 0, stable = 0, brighter = 0;
|
||||
// Skip borders and sample every other pixel; no PNG decoding, image libraries or golden screenshot dependency.
|
||||
for (int y = before.height / 10; y < before.height * 9 / 10; y += 2) {
|
||||
for (int x = before.width / 10; x < before.width * 9 / 10; x += 2) {
|
||||
sampled++;
|
||||
int a = before.pixels[y * before.width + x], b = after.pixels[y * after.width + x];
|
||||
if (!whiteGround(a)) continue;
|
||||
ground++;
|
||||
int delta = brightness(a) - brightness(b);
|
||||
if (delta >= DARK_DELTA) dark++;
|
||||
if (delta <= -DARK_DELTA) brighter++;
|
||||
if (channelDifference(a, b) <= 3) stable++;
|
||||
}
|
||||
}
|
||||
return new Difference(sampled, ground, dark, stable, brighter);
|
||||
}
|
||||
|
||||
private static void localShadow(Difference d, String label, double minimumStable) {
|
||||
System.out.println("PIXEL_SHADOWS122_PIXELS " + label + ": " + d);
|
||||
check(d.ground > d.sampled * .50, label + ": a majority of samples must be actual white receiving ground: " + d);
|
||||
check(d.dark >= Math.max(24, d.ground / 1000), label + ": measurable cast-shadow darkening: " + d);
|
||||
check(d.stable > d.ground * minimumStable, label + ": unshadowed ground stays unchanged; reject a global tint: " + d);
|
||||
check(d.brighter < Math.max(12, d.ground / 100), label + ": shadows must not brighten ground: " + d);
|
||||
}
|
||||
|
||||
private static void unchanged(Frame before, Frame after, String label) {
|
||||
sameSize(before, after);
|
||||
int sampled = 0, changed = 0;
|
||||
for (int y = before.height / 10; y < before.height * 9 / 10; y += 2) {
|
||||
for (int x = before.width / 10; x < before.width * 9 / 10; x += 2) {
|
||||
sampled++;
|
||||
int i = y * before.width + x;
|
||||
if (channelDifference(before.pixels[i], after.pixels[i]) > 3) changed++;
|
||||
}
|
||||
}
|
||||
check(sampled > 0 && changed <= sampled / 100, label + ": changed=" + changed + "/" + sampled);
|
||||
}
|
||||
|
||||
private static void restoredGround(Frame off, Frame on, Frame removed) {
|
||||
sameSize(off, on);
|
||||
sameSize(off, removed);
|
||||
int shadow = 0, restored = 0, lit = 0, stable = 0;
|
||||
for (int y = off.height / 10; y < off.height * 9 / 10; y += 2) {
|
||||
for (int x = off.width / 10; x < off.width * 9 / 10; x += 2) {
|
||||
int i = y * off.width + x;
|
||||
if (!whiteGround(off.pixels[i])) continue;
|
||||
if (brightness(off.pixels[i]) - brightness(on.pixels[i]) >= DARK_DELTA) {
|
||||
shadow++;
|
||||
if (brightness(removed.pixels[i]) - brightness(on.pixels[i]) >= DARK_DELTA
|
||||
&& channelDifference(off.pixels[i], removed.pixels[i]) <= 4) restored++;
|
||||
} else if (channelDifference(off.pixels[i], on.pixels[i]) <= 3) {
|
||||
lit++;
|
||||
if (channelDifference(on.pixels[i], removed.pixels[i]) <= 3) stable++;
|
||||
}
|
||||
}
|
||||
}
|
||||
check(shadow >= 24 && restored > shadow * .85,
|
||||
"Removing offscreen caster relights its former shadow: " + restored + "/" + shadow);
|
||||
check(lit >= 24 && stable > lit * .98,
|
||||
"Removing caster preserves already sunlit ground: " + stable + "/" + lit);
|
||||
System.out.println("PIXEL_SHADOWS122_EDIT restored=" + restored + "/" + shadow + " litStable=" + stable + "/" + lit);
|
||||
}
|
||||
|
||||
private static boolean whiteGround(int color) {
|
||||
int r = color >> 16 & 255, g = color >> 8 & 255, b = color & 255;
|
||||
return Math.min(r, Math.min(g, b)) > 100 && Math.max(r, Math.max(g, b)) - Math.min(r, Math.min(g, b)) < 24;
|
||||
}
|
||||
|
||||
private static int brightness(int color) {
|
||||
return ((color >> 16 & 255) + (color >> 8 & 255) + (color & 255)) / 3;
|
||||
}
|
||||
|
||||
private static int channelDifference(int a, int b) {
|
||||
return Math.max(Math.abs((a >> 16 & 255) - (b >> 16 & 255)),
|
||||
Math.max(Math.abs((a >> 8 & 255) - (b >> 8 & 255)), Math.abs((a & 255) - (b & 255))));
|
||||
}
|
||||
|
||||
private static void sameSize(Frame a, Frame b) {
|
||||
check(a.width == b.width && a.height == b.height && a.pixels.length == b.pixels.length,
|
||||
"GPU comparison requires the same framebuffer dimensions");
|
||||
}
|
||||
|
||||
private record Options(int distance, int simulationDistance, int fov, boolean bob,
|
||||
boolean pause, CameraType camera, String language) {
|
||||
static Options capture(Minecraft m) {
|
||||
return new Options(m.options.renderDistance().get(), m.options.simulationDistance().get(), m.options.fov().get(),
|
||||
m.options.bobView().get(), m.options.pauseOnLostFocus,
|
||||
m.options.getCameraType(), m.getLanguageManager().getSelected());
|
||||
}
|
||||
|
||||
void restore(Minecraft m) {
|
||||
m.options.renderDistance().set(distance);
|
||||
m.options.simulationDistance().set(simulationDistance);
|
||||
m.options.fov().set(fov);
|
||||
m.options.bobView().set(bob);
|
||||
m.options.pauseOnLostFocus = pause;
|
||||
m.options.setCameraType(camera);
|
||||
m.getLanguageManager().setSelected(language);
|
||||
m.getLanguageManager().onResourceManagerReload(m.getResourceManager());
|
||||
}
|
||||
}
|
||||
|
||||
private static KeyEvent enter() { return new KeyEvent(InputConstants.KEY_RETURN, 0, 0); }
|
||||
private static void check(boolean ok, String message) { if (!ok) throw new AssertionError(message); }
|
||||
}
|
||||
@@ -44,6 +44,7 @@ public final class SanctuaryMod implements ModInitializer {
|
||||
@Override
|
||||
public void onInitialize() {
|
||||
fr.koka.sanctuary.building.ColoredBricks.register();
|
||||
fr.koka.sanctuary.gems.Gems.register();
|
||||
fr.koka.sanctuary.sky.VortexClouds.register();
|
||||
fr.koka.sanctuary.loading.PreloadProgress.register();
|
||||
fr.koka.sanctuary.progression.SanctuaryGameplay.register();
|
||||
|
||||
@@ -14,6 +14,7 @@ import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.world.entity.player.Inventory;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.item.Items;
|
||||
|
||||
/** File selection is local; the two ordinary inventory slots and every crafted item are server-owned. */
|
||||
public final class ClayWorkshopScreen extends AbstractContainerScreen<ClayWorkshopMenu> {
|
||||
@@ -91,9 +92,9 @@ public final class ClayWorkshopScreen extends AbstractContainerScreen<ClayWorksh
|
||||
if(previewSource!=source||changedMaterial){
|
||||
previewSource=source;lastMaterial=material.copy();sampledResources=resources;
|
||||
if(changedMaterial)palette=SculptureMaterialSampler.sample(material);
|
||||
var colored=source==null?null:palette==null?source:palette.apply(source);
|
||||
var colored=source==null?null:palette==null?source:ClayWorkshopMenu.appearance(source,material.getItem(),palette);
|
||||
preview=colored==null?ItemStack.EMPTY:StatuaryEntity.item(colored);pending=true;
|
||||
if(source!=null&&!busy)status=palette==null?text("choose_material"):text("material_ready",material.getHoverName());
|
||||
if(source!=null&&!busy)status=palette==null?text("choose_material"):material.is(Items.CLAY)?text("clay_ready"):text("material_ready",material.getHoverName());
|
||||
}
|
||||
// Retry a selection that raced the input packet or server rate limit. The result is the
|
||||
// acknowledgement; never predict consumption or manufacture an item on the client.
|
||||
|
||||
@@ -19,6 +19,7 @@ public final class SanctuaryKnowledgeClient implements ClientModInitializer {
|
||||
public static void registerClient() {
|
||||
SanctuaryResourcePack.register();
|
||||
VortexCloudRenderer.register();
|
||||
fr.koka.sanctuary.client.shader.PixelShadows.register();
|
||||
ProgressionClient.register();
|
||||
StarClient.register();
|
||||
WeatherClient.register();
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
package fr.koka.sanctuary.client.shader;
|
||||
|
||||
import com.mojang.blaze3d.buffers.Std140Builder;
|
||||
import com.mojang.blaze3d.pipeline.TextureTarget;
|
||||
import com.mojang.blaze3d.systems.RenderSystem;
|
||||
import com.mojang.blaze3d.vertex.DefaultVertexFormat;
|
||||
import com.mojang.renderpearl.api.GpuFormat;
|
||||
import com.mojang.renderpearl.api.buffers.GpuBuffer;
|
||||
import com.mojang.renderpearl.api.buffers.GpuBufferSlice;
|
||||
import com.mojang.renderpearl.api.pipeline.*;
|
||||
import com.mojang.renderpearl.api.textures.FilterMode;
|
||||
import fr.koka.sanctuary.client.panorama.PanoramaCapture;
|
||||
import java.util.*;
|
||||
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents;
|
||||
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.client.renderer.BindGroupLayouts;
|
||||
import net.minecraft.client.renderer.MappableRingBuffer;
|
||||
import net.minecraft.client.renderer.RenderPipelines;
|
||||
import net.minecraft.client.renderer.state.level.CameraRenderState;
|
||||
import net.minecraft.world.level.dimension.DimensionType;
|
||||
import net.minecraft.world.level.material.FogType;
|
||||
import org.joml.Matrix4f;
|
||||
import org.joml.Vector3f;
|
||||
import org.joml.Vector4f;
|
||||
|
||||
/** Native sun/moon depth map and world-grid receivers. Never reads GPU pixels or changes gameplay light. */
|
||||
public final class PixelShadows {
|
||||
private static final RenderPipeline DEPTH = RenderPipelines.register(RenderPipeline.builder()
|
||||
.withLocation(Identifier.parse("sanctuary:pipeline/pixel_shadow_depth"))
|
||||
.withVertexShader(Identifier.parse("sanctuary:core/pixel_shadow_depth"))
|
||||
.withFragmentShader(Identifier.parse("sanctuary:core/pixel_shadow_depth"))
|
||||
.withBindGroupLayout(BindGroupLayouts.DYNAMIC_TRANSFORMS)
|
||||
.withVertexBinding(0, DefaultVertexFormat.POSITION_COLOR)
|
||||
.withPrimitiveTopology(PrimitiveTopology.QUADS).withCull(false)
|
||||
.withColorTargetState(new ColorTargetState(Optional.empty(), GpuFormat.R8_UNORM, ColorTargetState.WRITE_COLOR))
|
||||
.withDepthStencilState(new DepthStencilState(CompareOp.LESS_THAN, true)).build());
|
||||
private static final RenderPipeline APPLY = RenderPipelines.register(RenderPipeline.builder()
|
||||
.withLocation(Identifier.parse("sanctuary:pipeline/pixel_shadows"))
|
||||
.withVertexShader("core/screenquad")
|
||||
.withFragmentShader(Identifier.parse("sanctuary:core/pixel_shadows"))
|
||||
.withBindGroupLayout(BindGroupLayout.builder()
|
||||
.withUniform("PixelShadows", UniformType.UNIFORM_BUFFER)
|
||||
.withUniform("WorldDepth", UniformType.COMBINED_IMAGE_SAMPLER)
|
||||
.withUniform("SunDepth", UniformType.COMBINED_IMAGE_SAMPLER)
|
||||
.withUniform("SunMaterial", UniformType.COMBINED_IMAGE_SAMPLER).build())
|
||||
.withPrimitiveTopology(PrimitiveTopology.TRIANGLES).withCull(false)
|
||||
.withColorTargetState(new ColorTargetState(Optional.of(BlendFunction.TRANSLUCENT), GpuFormat.RGBA8_UNORM, ColorTargetState.WRITE_COLOR))
|
||||
.withDepthStencilState(Optional.empty()).build());
|
||||
private static final ShadowTerrain TERRAIN = new ShadowTerrain();
|
||||
private static final Matrix4f projection = new Matrix4f();
|
||||
private static TextureTarget shadowMap;
|
||||
private static MappableRingBuffer uniforms;
|
||||
private static int renderedFrames;
|
||||
private static boolean applied;
|
||||
|
||||
private PixelShadows() { }
|
||||
public static void register() {
|
||||
ClientPlayConnectionEvents.DISCONNECT.register((handler, client) -> release());
|
||||
ClientLifecycleEvents.CLIENT_STOPPING.register(client -> release());
|
||||
}
|
||||
public static void captureProjection(Matrix4f matrix) { projection.set(matrix); }
|
||||
public static void dirtyBlock(BlockPos pos) {
|
||||
for (int x = (pos.getX()-1)>>4; x <= (pos.getX()+1)>>4; x++)
|
||||
for (int y = (pos.getY()-1)>>4; y <= (pos.getY()+1)>>4; y++)
|
||||
for (int z = (pos.getZ()-1)>>4; z <= (pos.getZ()+1)>>4; z++) TERRAIN.dirtySection(x,y,z);
|
||||
}
|
||||
public static void dirtyChunk(int x, int z) { TERRAIN.dirtyChunk(x, z); }
|
||||
|
||||
/** Called after the world's depth is resolved and before the first-person hand and GUI. */
|
||||
public static void render() {
|
||||
applied = false;
|
||||
var client = Minecraft.getInstance();
|
||||
if (!VanillaLight.active() || !VanillaLight.pixelShadows() || client.level == null) {
|
||||
release();
|
||||
return;
|
||||
}
|
||||
if (PanoramaCapture.rendering()) return;
|
||||
var state = client.gameRenderer.gameRenderState().levelRenderState;
|
||||
var sky = state.skyRenderState;
|
||||
CameraRenderState camera = state.cameraRenderState;
|
||||
var sun = sunDirection(sky.sunAngle);
|
||||
boolean lunar = sun.y <= .08f;
|
||||
if (lunar) sun = sunDirection(sky.moonAngle);
|
||||
if (sky.skybox != DimensionType.Skybox.OVERWORLD || camera.fogType != FogType.NONE || sun.y <= .08f) return;
|
||||
float moon = sky.moonPhase == null ? 1 : (float)(.5 + .5 * Math.cos(sky.moonPhase.index() * Math.PI / 4));
|
||||
float strength = (lunar ? .10f * moon : .38f * zenithFade(sun.y))
|
||||
* Math.clamp((sun.y - .08f) / .16f, 0, 1) * sky.rainBrightness;
|
||||
if (strength <= .001f) return;
|
||||
int distance = VanillaLight.shadowDistance(), pixels = VanillaLight.shadowPixels();
|
||||
TERRAIN.update(client.level, camera.pos, distance, sun);
|
||||
int size = mapSize(distance, pixels);
|
||||
if (shadowMap == null || shadowMap.width != size) {
|
||||
if (shadowMap != null) shadowMap.destroyBuffers();
|
||||
shadowMap = new TextureTarget("Sanctuary celestial shadows", size, size, GpuFormat.R8_UNORM, GpuFormat.D32_FLOAT);
|
||||
}
|
||||
if (uniforms == null) uniforms = new MappableRingBuffer(() -> "Sanctuary pixel shadows",
|
||||
GpuBuffer.USAGE_UNIFORM | GpuBuffer.USAGE_MAP_WRITE, 192);
|
||||
double ox = anchor(camera.pos.x), oy = anchor(camera.pos.y), oz = anchor(camera.pos.z);
|
||||
boolean zeroToOne = RenderSystem.getDevice().getDeviceInfo().isZZeroToOne();
|
||||
var light = lightMatrix(sun, distance, size, ox, oy, oz, zeroToOne);
|
||||
var inverse = new Matrix4f(projection).mul(camera.viewRotationMatrix).invert();
|
||||
uniforms.rotate();
|
||||
var fog = camera.fogData;
|
||||
try (var mapped = uniforms.currentBuffer().map(false, true)) {
|
||||
Std140Builder.intoBuffer(mapped.data()).putMat4f(inverse).putMat4f(light)
|
||||
.putVec4((float)(camera.pos.x - ox), (float)(camera.pos.y - oy), (float)(camera.pos.z - oz), pixels)
|
||||
.putVec4(sun.x, sun.y, sun.z, strength)
|
||||
.putVec4(distance, zeroToOne ? 1 : 0, fog.renderDistanceStart, fog.renderDistanceEnd)
|
||||
.putVec4(fog.environmentalStart, fog.environmentalEnd, fog.color.w, 0);
|
||||
}
|
||||
var entries = TERRAIN.entries();
|
||||
var transforms = new ArrayList<GpuBufferSlice>(entries.size());
|
||||
int maxIndices = 0;
|
||||
for (var entry : entries) {
|
||||
transforms.add(RenderSystem.getDynamicUniforms().writeTransform(new Matrix4f(light)
|
||||
.translate((float)(entry.x() - ox), (float)(entry.y() - oy), (float)(entry.z() - oz)),
|
||||
new Vector4f(1, 1, 1, 1),
|
||||
// Bounded world coordinates keep foliage noise precise far from spawn.
|
||||
new Vector3f(Math.floorMod(entry.x(), 4096), Math.floorMod(entry.y(), 4096),
|
||||
Math.floorMod(entry.z(), 4096)), new Matrix4f()));
|
||||
maxIndices = Math.max(maxIndices, entry.indexCount());
|
||||
}
|
||||
var indices = RenderSystem.getSequentialBuffer(PrimitiveTopology.QUADS);
|
||||
indices.requestIndexCount(maxIndices);
|
||||
var encoder = RenderSystem.getDevice().createCommandEncoder();
|
||||
try (var pass = encoder.createRenderPass(com.mojang.renderpearl.api.commands.RenderPassDescriptor.builder(() -> "Sanctuary celestial depth")
|
||||
.withColorAttachment(shadowMap.getColorTextureView(), Optional.of(new Vector4f(0, 0, 0, 0)))
|
||||
.withDepthAttachment(shadowMap.getDepthTextureView(), OptionalDouble.of(1)).build())) {
|
||||
pass.setPipeline(RenderSystem.getCompiledPipeline(DEPTH));
|
||||
if (maxIndices > 0) pass.setIndexBuffer(indices.getBuffer(), indices.type());
|
||||
for (int i = 0; i < entries.size(); i++) {
|
||||
var entry = entries.get(i);
|
||||
pass.setUniform("DynamicTransforms", transforms.get(i));
|
||||
pass.setVertexBuffer(0, entry.vertices().slice());
|
||||
pass.drawIndexed(entry.indexCount(), 1, 0, 0, 0);
|
||||
}
|
||||
}
|
||||
var main = client.gameRenderer.mainRenderTarget();
|
||||
var nearest = RenderSystem.getSamplerCache().getClampToEdge(FilterMode.NEAREST);
|
||||
try (var pass = encoder.createRenderPass(() -> "Sanctuary pixel shadows", main.getColorTextureView(), Optional.empty())) {
|
||||
pass.setPipeline(RenderSystem.getCompiledPipeline(APPLY));
|
||||
pass.setUniform("PixelShadows", uniforms.currentBuffer());
|
||||
pass.setUniform("WorldDepth", main.getDepthTextureView(), nearest);
|
||||
pass.setUniform("SunDepth", shadowMap.getDepthTextureView(), nearest);
|
||||
pass.setUniform("SunMaterial", shadowMap.getColorTextureView(), nearest);
|
||||
pass.draw(3, 1, 0, 0);
|
||||
}
|
||||
renderedFrames++;
|
||||
applied = true;
|
||||
}
|
||||
|
||||
/** Smoothly soften overhead sunlight, without a noon on/off discontinuity. */
|
||||
static float zenithFade(float elevation) {
|
||||
float t = Math.clamp((elevation - .92f) / .08f, 0, 1);
|
||||
return 1 - .75f * t * t * (3 - 2 * t);
|
||||
}
|
||||
|
||||
static double anchor(double coordinate) { return Math.floor(coordinate / 16.0 + .5) * 16.0; }
|
||||
static Vector3f sunDirection(float angle) { return new Vector3f(-(float)Math.sin(angle), (float)Math.cos(angle), 0); }
|
||||
static int mapSize(int distance, int pixels) {
|
||||
int size = 512;
|
||||
while (size < 2 * (distance + 16) * pixels && size < 4096) size *= 2;
|
||||
return size;
|
||||
}
|
||||
static Matrix4f lightMatrix(Vector3f sun, int distance, int size, double x, double y, double z, boolean zeroToOne) {
|
||||
float extent = distance + 16, reach = 4 * (distance + 16);
|
||||
// A fixed up axis is nonsingular even at noon. Snap the light-space origin to texels.
|
||||
var view = new Matrix4f().lookAt(sun.x * reach, sun.y * reach, sun.z * reach, 0, 0, 0, 0, 0, 1);
|
||||
double texel = 2.0 * extent / size;
|
||||
double sx = view.m00() * x + view.m10() * y + view.m20() * z;
|
||||
double sy = view.m01() * x + view.m11() * y + view.m21() * z;
|
||||
var result = new Matrix4f().setOrtho(-extent, extent, -extent, extent, .1f, reach * 2, zeroToOne).mul(view);
|
||||
result.m30(result.m30() + (float)((sx - Math.rint(sx / texel) * texel) / extent));
|
||||
result.m31(result.m31() + (float)((sy - Math.rint(sy / texel) * texel) / extent));
|
||||
return result;
|
||||
}
|
||||
public static void release() {
|
||||
TERRAIN.close();
|
||||
if (shadowMap != null) { shadowMap.destroyBuffers(); shadowMap = null; }
|
||||
if (uniforms != null) { uniforms.close(); uniforms = null; }
|
||||
applied = false;
|
||||
}
|
||||
static int renderedFrames() { return renderedFrames; }
|
||||
static boolean applied() { return applied; }
|
||||
static boolean ready() { return TERRAIN.settled(); }
|
||||
static TextureTarget shadowMap() { return shadowMap; }
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package fr.koka.sanctuary.client.shader;
|
||||
|
||||
import com.mojang.serialization.Codec;
|
||||
import java.util.List;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.OptionInstance;
|
||||
import net.minecraft.client.gui.screens.Screen;
|
||||
@@ -15,5 +17,14 @@ public final class ShaderOptionsScreen extends OptionsSubScreen {
|
||||
list.addBig(new OptionInstance<Integer>("sanctuary.shaders.saturation",OptionInstance.cachedConstantTooltip(
|
||||
Component.translatable("sanctuary.shaders.saturation_help")),(caption,value)->Component.translatable("sanctuary.shaders.saturation_value",value),
|
||||
new OptionInstance.IntRange(0,200),VanillaLight.saturation(),VanillaLight::setSaturation));
|
||||
list.addBig(OptionInstance.createBoolean("sanctuary.shaders.pixel_shadows",OptionInstance.cachedConstantTooltip(
|
||||
Component.translatable("sanctuary.shaders.pixel_shadows_help")),
|
||||
VanillaLight.pixelShadows(),VanillaLight::setPixelShadows));
|
||||
list.addBig(new OptionInstance<Integer>("sanctuary.shaders.shadow_pixels",OptionInstance.cachedConstantTooltip(
|
||||
Component.translatable("sanctuary.shaders.shadow_pixels_help")),(caption,value)->Component.translatable("sanctuary.shaders.shadow_pixels_value",value),
|
||||
new OptionInstance.Enum<>(List.of(8,16,32),Codec.INT),VanillaLight.shadowPixels(),VanillaLight::setShadowPixels));
|
||||
list.addBig(new OptionInstance<Integer>("sanctuary.shaders.shadow_distance",OptionInstance.cachedConstantTooltip(
|
||||
Component.translatable("sanctuary.shaders.shadow_distance_help")),(caption,value)->Component.translatable("sanctuary.shaders.shadow_distance_value",value),
|
||||
new OptionInstance.Enum<>(List.of(16,32,64),Codec.INT),VanillaLight.shadowDistance(),VanillaLight::setShadowDistance));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,517 @@
|
||||
package fr.koka.sanctuary.client.shader;
|
||||
|
||||
import com.mojang.blaze3d.systems.RenderSystem;
|
||||
import com.mojang.blaze3d.vertex.BufferBuilder;
|
||||
import com.mojang.blaze3d.vertex.ByteBufferBuilder;
|
||||
import com.mojang.blaze3d.vertex.DefaultVertexFormat;
|
||||
import com.mojang.renderpearl.api.buffers.GpuBuffer;
|
||||
import com.mojang.renderpearl.api.pipeline.PrimitiveTopology;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.tags.BlockTags;
|
||||
import net.minecraft.core.SectionPos;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.client.multiplayer.ClientLevel;
|
||||
import net.minecraft.world.level.block.Block;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.chunk.LevelChunk;
|
||||
import net.minecraft.world.level.chunk.LevelChunkSection;
|
||||
import net.minecraft.world.level.chunk.status.ChunkStatus;
|
||||
import net.minecraft.world.phys.AABB;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
import org.joml.Vector3fc;
|
||||
|
||||
/**
|
||||
* Render-thread-only, read-only terrain casters. Call update BEFORE opening any render pass.
|
||||
* Positions use POSITION_COLOR (three floats plus a foliage flag), relative to the entry's block
|
||||
* origin; add that origin in the draw transform. Indices belong to RenderSystem's shared
|
||||
* QUADS sequential buffer, which update grows before drawing. This cache never owns it.
|
||||
*
|
||||
* <p>Call dirtySection for edits (including affected neighbors), or dirtyChunk for a chunk
|
||||
* packet that mutates an existing chunk. Identity changes/unloads are detected automatically.
|
||||
* Call close on disable/disconnect/resource teardown; it is idempotent and the cache is reusable.
|
||||
* A different ClientLevel, or update(null, ...), also releases all owned resources.
|
||||
*/
|
||||
public final class ShadowTerrain implements AutoCloseable {
|
||||
/** Bounds metadata/work as well as geometry; current shader options use 16, 32 and 64. */
|
||||
public static final int MAX_DISTANCE = 128;
|
||||
private static final int MAX_SECTIONS_PER_UPDATE = 4;
|
||||
private static final long BUILD_NANOS = 3_000_000L;
|
||||
private static final long MAX_GPU_BYTES = 96L * 1024 * 1024;
|
||||
private static final int MAX_SECTION_QUADS = 65_536;
|
||||
private static final int MAX_SHAPES = 1024;
|
||||
private static final int MAX_BOXES = 256;
|
||||
private static final Direction[] DIRECTIONS = Direction.values();
|
||||
|
||||
/** Borrowed buffer: owned exclusively by this cache, valid until its next update/close. */
|
||||
public record Entry(int x, int y, int z, GpuBuffer vertices, int indexCount) { }
|
||||
|
||||
private final Map<Long, LevelChunk> chunks = new HashMap<>();
|
||||
private final Map<Long, Section> sections = new HashMap<>();
|
||||
private final Map<BlockState, Shape> shapes = new IdentityHashMap<>();
|
||||
private ClientLevel level;
|
||||
private Bounds bounds;
|
||||
private Build building;
|
||||
private long gpuBytes;
|
||||
private List<Entry> entries = List.of();
|
||||
private boolean entriesChanged;
|
||||
private int cameraSectionX, cameraSectionY, cameraSectionZ;
|
||||
|
||||
/**
|
||||
* Refreshes loaded section metadata, then meshes at most four sections within a soft 3 ms
|
||||
* budget. A large section resumes next call; a single shape lookup or GPU upload cannot be
|
||||
* preempted. distance is in blocks, 0..MAX_DISTANCE. The receiver volume plus an eight-block guard
|
||||
* is extruded by twice that distance toward the sun, rounded to sections and clipped
|
||||
* to the level's build height. This includes loaded casters outside the camera frustum.
|
||||
*
|
||||
* <p>The 96 MiB vertex budget favors nearer sections. Evicted/deferred meshes are retried
|
||||
* when room becomes available or the camera enters another section. Pathological geometry
|
||||
* exceeding 256 boxes per state or 65,536 quads per section is omitted, never approximated
|
||||
* by a full cube or uploaded partially. It can be retried after invalidation.
|
||||
*/
|
||||
public List<Entry> update(ClientLevel level, Vec3 camera, int distance, Vector3fc sun) {
|
||||
RenderSystem.assertOnRenderThread();
|
||||
if (level == null) {
|
||||
close();
|
||||
return entries;
|
||||
}
|
||||
Objects.requireNonNull(camera, "camera");
|
||||
if (distance < 0 || distance > MAX_DISTANCE || !Double.isFinite(camera.x)
|
||||
|| !Double.isFinite(camera.y) || !Double.isFinite(camera.z)) {
|
||||
throw new IllegalArgumentException("Finite camera and shadow distance 0.." + MAX_DISTANCE + " required");
|
||||
}
|
||||
if (this.level != level) {
|
||||
close();
|
||||
this.level = level;
|
||||
}
|
||||
int radius = distance + 8;
|
||||
double dx = sun.x() * distance * 2, dy = sun.y() * distance * 2, dz = sun.z() * distance * 2;
|
||||
Bounds next = new Bounds(section(camera.x - radius + Math.min(0, dx)), section(camera.x + radius + Math.max(0, dx)),
|
||||
Math.max(level.getMinSectionY(), section(camera.y - radius + Math.min(0, dy))),
|
||||
Math.min(level.getMaxSectionY() - 1, section(camera.y + radius + Math.max(0, dy))),
|
||||
section(camera.z - radius + Math.min(0, dz)), section(camera.z + radius + Math.max(0, dz)));
|
||||
int cx = section(camera.x), cy = section(camera.y), cz = section(camera.z);
|
||||
boolean moved = !next.equals(bounds) || cx != cameraSectionX || cy != cameraSectionY || cz != cameraSectionZ;
|
||||
if (bounds != null && !next.equals(bounds)) {
|
||||
// Changing the caster boundary changes which neighboring faces may be culled.
|
||||
for (Section section : sections.values()) {
|
||||
if (bounds.onBoundary(section) || next.onBoundary(section)) dirty(section);
|
||||
}
|
||||
}
|
||||
bounds = next;
|
||||
cameraSectionX = cx;
|
||||
cameraSectionY = cy;
|
||||
cameraSectionZ = cz;
|
||||
reconcile();
|
||||
if (moved) {
|
||||
retryDeferred();
|
||||
}
|
||||
|
||||
long deadline = System.nanoTime() + BUILD_NANOS;
|
||||
int started = building == null ? 0 : 1;
|
||||
try {
|
||||
while (System.nanoTime() < deadline) {
|
||||
if (building == null) {
|
||||
if (started == MAX_SECTIONS_PER_UPDATE) break;
|
||||
Section nearest = nearestDirty(camera);
|
||||
if (nearest == null) break;
|
||||
building = new Build(nearest);
|
||||
started++;
|
||||
}
|
||||
if (!advance(building, deadline)) break;
|
||||
Build finished = building;
|
||||
building = null;
|
||||
try (finished) {
|
||||
upload(finished, camera);
|
||||
}
|
||||
}
|
||||
} catch (RuntimeException | Error failure) {
|
||||
cancelBuild();
|
||||
throw failure;
|
||||
}
|
||||
return entries();
|
||||
}
|
||||
|
||||
/** Immutable snapshot; consume after update and before another update or close. */
|
||||
public List<Entry> entries() {
|
||||
RenderSystem.assertOnRenderThread();
|
||||
if (entriesChanged) {
|
||||
var result = new ArrayList<Entry>();
|
||||
for (Section section : sections.values()) {
|
||||
if (section.entry != null) result.add(section.entry);
|
||||
}
|
||||
entries = List.copyOf(result);
|
||||
entriesChanged = false;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** Outstanding rebuilds, including the in-progress section; excludes memory-deferred work. */
|
||||
public int pendingSections() {
|
||||
RenderSystem.assertOnRenderThread();
|
||||
int pending = 0;
|
||||
for (Section section : sections.values()) {
|
||||
if (section.dirty && !section.deferred) pending++;
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
|
||||
/** No runnable mesh work after the last update; may still omit memory/complexity-limited sections. */
|
||||
public boolean settled() {
|
||||
return pendingSections() == 0;
|
||||
}
|
||||
|
||||
/** Coordinates are section coordinates, not block origins. Caller also invalidates neighbors. */
|
||||
public void dirtySection(int sx, int sy, int sz) {
|
||||
RenderSystem.assertOnRenderThread();
|
||||
Section section = sections.get(SectionPos.asLong(sx, sy, sz));
|
||||
if (section != null) dirty(section);
|
||||
}
|
||||
|
||||
/** Coordinates are chunk coordinates. Caller also invalidates adjacent chunks as needed. */
|
||||
public void dirtyChunk(int cx, int cz) {
|
||||
RenderSystem.assertOnRenderThread();
|
||||
for (Section section : sections.values()) {
|
||||
if (section.x == cx && section.z == cz) dirty(section);
|
||||
}
|
||||
}
|
||||
|
||||
private void dirty(Section section) {
|
||||
section.dirty = true;
|
||||
section.deferred = false;
|
||||
if (building != null && building.section == section) cancelBuild();
|
||||
}
|
||||
|
||||
/** Poll only chunk identities and section emptiness; never scan unchanged block storage. */
|
||||
private void reconcile() {
|
||||
var loaded = new HashMap<Long, LevelChunk>();
|
||||
if (bounds.minY <= bounds.maxY) {
|
||||
for (int x = bounds.minX; x <= bounds.maxX; x++) {
|
||||
for (int z = bounds.minZ; z <= bounds.maxZ; z++) {
|
||||
LevelChunk chunk = level.getChunkSource().getChunk(x, z, ChunkStatus.FULL, false);
|
||||
long key = ChunkPos.pack(x, z);
|
||||
if (chunk != null) loaded.put(key, chunk);
|
||||
if (chunks.get(key) != chunk) dirtyChunkAndNeighbors(x, z);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (long key : chunks.keySet()) {
|
||||
if (!loaded.containsKey(key)) dirtyChunkAndNeighbors(ChunkPos.getX(key), ChunkPos.getZ(key));
|
||||
}
|
||||
chunks.clear();
|
||||
chunks.putAll(loaded);
|
||||
|
||||
boolean freed = false;
|
||||
for (Iterator<Section> iterator = sections.values().iterator(); iterator.hasNext();) {
|
||||
Section section = iterator.next();
|
||||
LevelChunk chunk = chunks.get(ChunkPos.pack(section.x, section.z));
|
||||
if (!bounds.contains(section.x, section.y, section.z) || chunk != section.chunk
|
||||
|| chunk.getSection(level.getSectionIndexFromSectionY(section.y)) != section.storage
|
||||
|| section.storage.hasOnlyAir()) {
|
||||
if (building != null && building.section == section) cancelBuild();
|
||||
freed |= section.entry != null;
|
||||
release(section);
|
||||
iterator.remove();
|
||||
}
|
||||
}
|
||||
for (LevelChunk chunk : chunks.values()) {
|
||||
int x = chunk.getPos().x(), z = chunk.getPos().z();
|
||||
for (int y = bounds.minY; y <= bounds.maxY; y++) {
|
||||
LevelChunkSection storage = chunk.getSection(level.getSectionIndexFromSectionY(y));
|
||||
long key = SectionPos.asLong(x, y, z);
|
||||
if (!storage.hasOnlyAir() && !sections.containsKey(key)) {
|
||||
sections.put(key, new Section(x, y, z, chunk, storage));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (freed) retryDeferred();
|
||||
}
|
||||
|
||||
private void dirtyChunkAndNeighbors(int x, int z) {
|
||||
dirtyChunk(x, z);
|
||||
dirtyChunk(x - 1, z);
|
||||
dirtyChunk(x + 1, z);
|
||||
dirtyChunk(x, z - 1);
|
||||
dirtyChunk(x, z + 1);
|
||||
}
|
||||
|
||||
private void retryDeferred() {
|
||||
for (Section section : sections.values()) {
|
||||
if (section.deferred) {
|
||||
section.deferred = false;
|
||||
section.dirty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Section nearestDirty(Vec3 camera) {
|
||||
Section nearest = null;
|
||||
double best = Double.POSITIVE_INFINITY;
|
||||
for (Section section : sections.values()) {
|
||||
if (section.dirty && !section.deferred) {
|
||||
double distance = section.distanceSquared(camera);
|
||||
if (distance < best) {
|
||||
best = distance;
|
||||
nearest = section;
|
||||
}
|
||||
}
|
||||
}
|
||||
return nearest;
|
||||
}
|
||||
|
||||
private boolean advance(Build build, long deadline) {
|
||||
while (build.cursor < 4096) {
|
||||
int index = build.cursor++;
|
||||
int x = index & 15, z = (index >> 4) & 15, y = index >> 8;
|
||||
var state = build.section.storage.getBlockState(x, y, z);
|
||||
boolean foliage = state.is(BlockTags.LEAVES);
|
||||
Shape shape = foliage ? LEAVES : shape(state);
|
||||
if (shape != null) {
|
||||
if (shape.boxes == null) {
|
||||
build.oversized = true;
|
||||
return true;
|
||||
}
|
||||
int hidden = 0;
|
||||
for (Direction direction : DIRECTIONS) {
|
||||
if (foliage) break; // Retain internal leaf faces: each layer contributes transmission.
|
||||
BlockState neighbor = neighbor(build.section, x + direction.getStepX(),
|
||||
y + direction.getStepY(), z + direction.getStepZ());
|
||||
Shape other = neighbor == null ? null : shape(neighbor);
|
||||
if (other != null && (other.fullFaces & (1 << direction.getOpposite().ordinal())) != 0) {
|
||||
hidden |= 1 << direction.ordinal();
|
||||
}
|
||||
}
|
||||
for (AABB box : shape.boxes) {
|
||||
for (Direction direction : DIRECTIONS) {
|
||||
if ((hidden & (1 << direction.ordinal())) != 0 && atBoundary(box, direction)) continue;
|
||||
if (build.quads == MAX_SECTION_QUADS) {
|
||||
build.oversized = true;
|
||||
return true;
|
||||
}
|
||||
face(build.vertices, box, x, y, z, direction, foliage ? 0xFFFFFFFF : 0xFF000000);
|
||||
build.quads++;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ((build.cursor & 31) == 0 && System.nanoTime() >= deadline) return build.cursor == 4096;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private BlockState neighbor(Section section, int x, int y, int z) {
|
||||
if ((x | y | z) >= 0 && x < 16 && y < 16 && z < 16) {
|
||||
return section.storage.getBlockState(x, y, z);
|
||||
}
|
||||
int sx = section.x + (x >> 4), sy = section.y + (y >> 4), sz = section.z + (z >> 4);
|
||||
// Keep the exterior faces of the caster volume; no dependency on chunks outside it.
|
||||
if (!bounds.contains(sx, sy, sz)) return null;
|
||||
LevelChunk chunk = chunks.get(ChunkPos.pack(sx, sz));
|
||||
if (chunk == null) return null;
|
||||
return chunk.getSection(level.getSectionIndexFromSectionY(sy)).getBlockState(x & 15, y & 15, z & 15);
|
||||
}
|
||||
|
||||
private Shape shape(BlockState state) {
|
||||
// Retain the solid part of waterlogged slabs/stairs, but never mesh the fluid itself.
|
||||
if (state.isAir() || state.is(BlockTags.LEAVES) || !state.canOcclude() || state.liquid()) return null;
|
||||
Shape cached = shapes.get(state);
|
||||
if (cached != null) return cached;
|
||||
// In 26.3 occlusion shapes are cached on BlockState and take no level/position argument.
|
||||
var voxel = state.getOcclusionShape();
|
||||
var boxes = new ArrayList<AABB>();
|
||||
voxel.forAllBoxes((x0, y0, z0, x1, y1, z1) -> {
|
||||
if (boxes.size() <= MAX_BOXES) boxes.add(new AABB(x0, y0, z0, x1, y1, z1));
|
||||
});
|
||||
int fullFaces = 0;
|
||||
for (Direction direction : DIRECTIONS) {
|
||||
if (Block.isFaceFull(voxel, direction)) fullFaces |= 1 << direction.ordinal();
|
||||
}
|
||||
// An omitted shape must not hide the faces of the surrounding geometry.
|
||||
Shape result = boxes.size() > MAX_BOXES ? new Shape(null, 0) : new Shape(boxes.toArray(AABB[]::new), fullFaces);
|
||||
if (shapes.size() == MAX_SHAPES) shapes.clear();
|
||||
shapes.put(state, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private void upload(Build build, Vec3 camera) {
|
||||
Section section = build.section;
|
||||
if (build.oversized || build.quads == 0) {
|
||||
release(section);
|
||||
section.dirty = false;
|
||||
retryDeferred();
|
||||
return;
|
||||
}
|
||||
try (var mesh = build.vertices.buildOrThrow()) {
|
||||
long bytes = mesh.vertexBuffer().remaining();
|
||||
long previous = section.entry == null ? 0 : section.entry.vertices.size();
|
||||
// Release the old buffer before replacement, so even transient owned GPU memory is bounded.
|
||||
release(section);
|
||||
while (gpuBytes + bytes > MAX_GPU_BYTES) {
|
||||
Section farthest = null;
|
||||
double far = section.distanceSquared(camera);
|
||||
for (Section candidate : sections.values()) {
|
||||
double distance = candidate.distanceSquared(camera);
|
||||
if (candidate.entry != null && distance > far) {
|
||||
farthest = candidate;
|
||||
far = distance;
|
||||
}
|
||||
}
|
||||
if (farthest == null) {
|
||||
section.deferred = true;
|
||||
return;
|
||||
}
|
||||
release(farthest);
|
||||
farthest.dirty = true;
|
||||
farthest.deferred = true;
|
||||
}
|
||||
int indexCount = mesh.drawState().indexCount();
|
||||
RenderSystem.getSequentialBuffer(PrimitiveTopology.QUADS).getBuffer(indexCount);
|
||||
GpuBuffer buffer = RenderSystem.getDevice().createBuffer(() -> "Sanctuary shadow terrain "
|
||||
+ section.x + "," + section.y + "," + section.z,
|
||||
GpuBuffer.USAGE_VERTEX | GpuBuffer.USAGE_COPY_DST, mesh.vertexBuffer());
|
||||
section.entry = new Entry(section.x << 4, section.y << 4, section.z << 4, buffer, indexCount);
|
||||
gpuBytes += buffer.size();
|
||||
section.dirty = false;
|
||||
entriesChanged = true;
|
||||
if (bytes < previous) retryDeferred();
|
||||
}
|
||||
}
|
||||
|
||||
private void release(Section section) {
|
||||
if (section.entry != null) {
|
||||
GpuBuffer buffer = section.entry.vertices;
|
||||
gpuBytes -= buffer.size();
|
||||
buffer.close();
|
||||
section.entry = null;
|
||||
entriesChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void cancelBuild() {
|
||||
if (building != null) {
|
||||
building.close();
|
||||
building = null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
RenderSystem.assertOnRenderThread();
|
||||
cancelBuild();
|
||||
for (Section section : sections.values()) release(section);
|
||||
sections.clear();
|
||||
chunks.clear();
|
||||
shapes.clear();
|
||||
entries = List.of();
|
||||
entriesChanged = false;
|
||||
gpuBytes = 0;
|
||||
bounds = null;
|
||||
level = null;
|
||||
}
|
||||
|
||||
private static int section(double coordinate) {
|
||||
return (int) Math.floor(coordinate / 16.0);
|
||||
}
|
||||
|
||||
private static boolean atBoundary(AABB box, Direction direction) {
|
||||
return switch (direction) {
|
||||
case DOWN -> box.minY == 0;
|
||||
case UP -> box.maxY == 1;
|
||||
case NORTH -> box.minZ == 0;
|
||||
case SOUTH -> box.maxZ == 1;
|
||||
case WEST -> box.minX == 0;
|
||||
case EAST -> box.maxX == 1;
|
||||
};
|
||||
}
|
||||
|
||||
/** Counterclockwise exterior faces, matching the native sequential QUADS index order. */
|
||||
private static void face(BufferBuilder out, AABB box, int x, int y, int z, Direction direction, int color) {
|
||||
float x0 = x + (float) box.minX, x1 = x + (float) box.maxX;
|
||||
float y0 = y + (float) box.minY, y1 = y + (float) box.maxY;
|
||||
float z0 = z + (float) box.minZ, z1 = z + (float) box.maxZ;
|
||||
switch (direction) {
|
||||
case DOWN -> {
|
||||
out.addVertex(x0, y0, z0).setColor(color); out.addVertex(x1, y0, z0).setColor(color);
|
||||
out.addVertex(x1, y0, z1).setColor(color); out.addVertex(x0, y0, z1).setColor(color);
|
||||
}
|
||||
case UP -> {
|
||||
out.addVertex(x0, y1, z0).setColor(color); out.addVertex(x0, y1, z1).setColor(color);
|
||||
out.addVertex(x1, y1, z1).setColor(color); out.addVertex(x1, y1, z0).setColor(color);
|
||||
}
|
||||
case NORTH -> {
|
||||
out.addVertex(x0, y0, z0).setColor(color); out.addVertex(x0, y1, z0).setColor(color);
|
||||
out.addVertex(x1, y1, z0).setColor(color); out.addVertex(x1, y0, z0).setColor(color);
|
||||
}
|
||||
case SOUTH -> {
|
||||
out.addVertex(x0, y0, z1).setColor(color); out.addVertex(x1, y0, z1).setColor(color);
|
||||
out.addVertex(x1, y1, z1).setColor(color); out.addVertex(x0, y1, z1).setColor(color);
|
||||
}
|
||||
case WEST -> {
|
||||
out.addVertex(x0, y0, z0).setColor(color); out.addVertex(x0, y0, z1).setColor(color);
|
||||
out.addVertex(x0, y1, z1).setColor(color); out.addVertex(x0, y1, z0).setColor(color);
|
||||
}
|
||||
case EAST -> {
|
||||
out.addVertex(x1, y0, z0).setColor(color); out.addVertex(x1, y1, z0).setColor(color);
|
||||
out.addVertex(x1, y1, z1).setColor(color); out.addVertex(x1, y0, z1).setColor(color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private record Bounds(int minX, int maxX, int minY, int maxY, int minZ, int maxZ) {
|
||||
boolean contains(int x, int y, int z) {
|
||||
return x >= minX && x <= maxX && y >= minY && y <= maxY && z >= minZ && z <= maxZ;
|
||||
}
|
||||
|
||||
boolean onBoundary(Section section) {
|
||||
return section.x == minX || section.x == maxX || section.y == minY || section.y == maxY
|
||||
|| section.z == minZ || section.z == maxZ;
|
||||
}
|
||||
}
|
||||
|
||||
private record Shape(AABB[] boxes, int fullFaces) { }
|
||||
private static final Shape LEAVES = new Shape(new AABB[]{new AABB(0, 0, 0, 1, 1, 1)}, 0);
|
||||
|
||||
private static final class Section {
|
||||
final int x, y, z;
|
||||
final LevelChunk chunk;
|
||||
final LevelChunkSection storage;
|
||||
Entry entry;
|
||||
boolean dirty = true;
|
||||
boolean deferred;
|
||||
|
||||
Section(int x, int y, int z, LevelChunk chunk, LevelChunkSection storage) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.z = z;
|
||||
this.chunk = chunk;
|
||||
this.storage = storage;
|
||||
}
|
||||
|
||||
double distanceSquared(Vec3 camera) {
|
||||
double dx = x * 16.0 + 8 - camera.x, dy = y * 16.0 + 8 - camera.y, dz = z * 16.0 + 8 - camera.z;
|
||||
return dx * dx + dy * dy + dz * dz;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Build implements AutoCloseable {
|
||||
final Section section;
|
||||
final ByteBufferBuilder bytes = new ByteBufferBuilder(64 * 1024);
|
||||
final BufferBuilder vertices = new BufferBuilder(bytes, PrimitiveTopology.QUADS, DefaultVertexFormat.POSITION_COLOR);
|
||||
int cursor, quads;
|
||||
boolean oversized;
|
||||
|
||||
Build(Section section) {
|
||||
this.section = section;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
bytes.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,13 @@ public final class VanillaLight {
|
||||
||FabricLoader.getInstance().isModLoaded("oculus")||FabricLoader.getInstance().isModLoaded("canvas");
|
||||
private static Preferences preferences;
|
||||
private VanillaLight() { }
|
||||
private static final class Preferences { boolean enabled=true;int saturation=142; }
|
||||
private static final class Preferences {
|
||||
boolean enabled=true;
|
||||
int saturation=142;
|
||||
boolean pixelShadows=false;
|
||||
int shadowPixels=16;
|
||||
int shadowDistance=32;
|
||||
}
|
||||
private static Path path(){return FabricLoader.getInstance().getConfigDir().resolve("sanctuary-shaders.json");}
|
||||
public static boolean enabled(){
|
||||
if(preferences==null){
|
||||
@@ -22,17 +28,30 @@ public final class VanillaLight {
|
||||
catch(Exception e){SanctuaryMod.LOGGER.warn("Cannot read Sanctuary shader preference",e);}
|
||||
if(preferences==null)preferences=new Preferences();
|
||||
preferences.saturation=Math.clamp(preferences.saturation,0,200);
|
||||
preferences.shadowPixels=normalizeShadowValue(preferences.shadowPixels,8,16,32);
|
||||
preferences.shadowDistance=normalizeShadowValue(preferences.shadowDistance,16,32,64);
|
||||
}
|
||||
return preferences.enabled;
|
||||
}
|
||||
public static boolean externalEngine(){return EXTERNAL_ENGINE;}
|
||||
public static boolean active(){return ProgressionClient.active()&&!EXTERNAL_ENGINE&&enabled();}
|
||||
public static int saturation(){enabled();return preferences.saturation;}
|
||||
public static boolean pixelShadows(){enabled();return preferences.pixelShadows;}
|
||||
public static int shadowPixels(){enabled();return preferences.shadowPixels;}
|
||||
public static int shadowDistance(){enabled();return preferences.shadowDistance;}
|
||||
public static void setSaturation(int value){enabled();preferences.saturation=Math.clamp(value,0,200);save();}
|
||||
public static void setPixelShadows(boolean value){enabled();preferences.pixelShadows=value;save();}
|
||||
public static void setShadowPixels(int value){enabled();preferences.shadowPixels=normalizeShadowValue(value,8,16,32);save();}
|
||||
public static void setShadowDistance(int value){enabled();preferences.shadowDistance=normalizeShadowValue(value,16,32,64);save();}
|
||||
public static void setEnabled(boolean value){
|
||||
enabled();preferences.enabled=value;
|
||||
save();
|
||||
}
|
||||
/** Nearest supported value, clamped at either end; midpoint ties round up. */
|
||||
private static int normalizeShadowValue(int value,int low,int middle,int high){
|
||||
if(value<(low+middle)/2)return low;
|
||||
return value<(middle+high)/2?middle:high;
|
||||
}
|
||||
private static void save(){
|
||||
try{
|
||||
Files.createDirectories(path().getParent());var temporary=path().resolveSibling(path().getFileName()+".tmp");
|
||||
|
||||
@@ -34,13 +34,17 @@ public final class CompanionQueries {
|
||||
&&familiar.egg().getItem() instanceof net.minecraft.world.item.SpawnEggItem?net.minecraft.world.item.SpawnEggItem.getType(familiar.egg()):entity.getType();
|
||||
var id=BuiltInRegistries.ENTITY_TYPE.getKey(type).getPath();
|
||||
if(CompanionType.find(id)==null)return;
|
||||
var seen=new TreeSet<>(Arrays.asList(record.notes().getOrDefault("seen_mobs","").split(",")));seen.remove("");
|
||||
if(seen.add(id))record=record.note("seen_mobs",String.join(",",seen));
|
||||
record=discoverSpecies(record,List.of(id));
|
||||
if(entity instanceof net.minecraft.world.entity.animal.fish.TropicalFish fish){
|
||||
var variants=fishVariants(record);variants.set(fishVariant(fish));record=record.note("seen_fish_v1",Base64.getEncoder().encodeToString(variants.toByteArray()));
|
||||
}
|
||||
if(!record.equals(CompanionService.record(p)))CompanionService.save(p,record);
|
||||
}
|
||||
/** The same encounter record is used by natural observation and administrative discoveries. */
|
||||
public static CompanionRecord discoverSpecies(CompanionRecord record,Collection<String> ids){
|
||||
var seen=new TreeSet<>(Arrays.asList(record.notes().getOrDefault("seen_mobs","").split(",")));seen.remove("");
|
||||
return seen.addAll(ids)?record.note("seen_mobs",String.join(",",seen)):record;
|
||||
}
|
||||
private static int fishVariant(net.minecraft.world.entity.animal.fish.TropicalFish fish){
|
||||
int packed=fish.getPattern().getPackedId();return ((packed>>>8)*2+(packed&1))*256+fish.getBaseColor().getId()*16+fish.getPatternColor().getId();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package fr.koka.sanctuary.gems;
|
||||
|
||||
import com.mojang.serialization.MapCodec;
|
||||
import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.util.RandomSource;
|
||||
import net.minecraft.world.level.WorldGenLevel;
|
||||
import net.minecraft.world.level.chunk.ChunkGenerator;
|
||||
import net.minecraft.world.level.levelgen.feature.*;
|
||||
|
||||
/** Native seeded ore veins, scoped to Sanctuary even when its biomes are shared with vanilla. */
|
||||
public record GemOreFeature(OreFeature ore) implements Feature {
|
||||
public static final MapCodec<GemOreFeature> CODEC=OreFeature.CODEC.xmap(GemOreFeature::new,GemOreFeature::ore);
|
||||
@Override public MapCodec<GemOreFeature> codec(){return CODEC;}
|
||||
@Override public boolean place(WorldGenLevel level,ChunkGenerator generator,RandomSource random,BlockPos origin){
|
||||
if(!(level.getLevel().getChunkSource().getGenerator() instanceof SanctuaryChunkGenerator))return false;
|
||||
return ore.place(level,generator,random,origin);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package fr.koka.sanctuary.gems;
|
||||
|
||||
import fr.koka.sanctuary.SanctuaryMod;
|
||||
import java.util.List;
|
||||
import net.fabricmc.fabric.api.biome.v1.BiomeModifications;
|
||||
import net.fabricmc.fabric.api.creativetab.v1.CreativeModeTabEvents;
|
||||
import net.minecraft.core.Registry;
|
||||
import net.minecraft.core.registries.*;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.tags.TagKey;
|
||||
import net.minecraft.util.valueproviders.UniformInt;
|
||||
import net.minecraft.world.item.*;
|
||||
import net.minecraft.world.level.block.*;
|
||||
import net.minecraft.world.level.block.state.BlockBehaviour;
|
||||
import net.minecraft.world.level.levelgen.GenerationStep;
|
||||
|
||||
/** Ordinary gem items and blocks; their distribution lives in data, not this registry. */
|
||||
public final class Gems {
|
||||
public record Family(String id,Item gem,Block block,Block ore,Block deepslateOre){}
|
||||
public static final Family RUBY=family("ruby",DyeColor.RED);
|
||||
public static final Family SAPPHIRE=family("sapphire",DyeColor.BLUE);
|
||||
public static final List<Family> FAMILIES=List.of(RUBY,SAPPHIRE);
|
||||
public static void register(){
|
||||
Registry.register(BuiltInRegistries.FEATURE_TYPE,SanctuaryMod.id("gem_ore"),GemOreFeature.CODEC);
|
||||
for(var f:FAMILIES){
|
||||
var biomes=TagKey.create(Registries.BIOME,SanctuaryMod.id("has_ore/"+f.id));
|
||||
BiomeModifications.addFeature(c->c.hasTag(biomes),GenerationStep.Decoration.UNDERGROUND_ORES,
|
||||
ResourceKey.create(Registries.PLACED_FEATURE,SanctuaryMod.id("ore_"+f.id)));
|
||||
}
|
||||
CreativeModeTabEvents.modifyOutputEvent(CreativeModeTabs.INGREDIENTS).register(out->out.insertAfter(Items.EMERALD,RUBY.gem,SAPPHIRE.gem));
|
||||
CreativeModeTabEvents.modifyOutputEvent(CreativeModeTabs.BUILDING_BLOCKS).register(out->out.insertAfter(Blocks.EMERALD_BLOCK,RUBY.block,SAPPHIRE.block));
|
||||
CreativeModeTabEvents.modifyOutputEvent(CreativeModeTabs.NATURAL_BLOCKS).register(out->{
|
||||
out.insertAfter(Blocks.EMERALD_ORE,RUBY.ore,SAPPHIRE.ore);
|
||||
out.insertAfter(Blocks.DEEPSLATE_EMERALD_ORE,RUBY.deepslateOre,SAPPHIRE.deepslateOre);
|
||||
});
|
||||
}
|
||||
private static Family family(String id,DyeColor color){
|
||||
var key=SanctuaryMod.id(id);var item=Registry.register(BuiltInRegistries.ITEM,key,new Item(new Item.Properties().setId(ResourceKey.create(Registries.ITEM,key))));
|
||||
var block=block(id+"_block",new Block(properties(id+"_block",Blocks.EMERALD_BLOCK).mapColor(color.getMapColor())));
|
||||
var ore=block(id+"_ore",new DropExperienceBlock(UniformInt.of(3,7),properties(id+"_ore",Blocks.EMERALD_ORE)));
|
||||
var deep=block("deepslate_"+id+"_ore",new DropExperienceBlock(UniformInt.of(3,7),properties("deepslate_"+id+"_ore",Blocks.DEEPSLATE_EMERALD_ORE)));
|
||||
return new Family(id,item,block,ore,deep);
|
||||
}
|
||||
private static BlockBehaviour.Properties properties(String id,Block source){return BlockBehaviour.Properties.ofFullCopy(source).setId(ResourceKey.create(Registries.BLOCK,SanctuaryMod.id(id)));}
|
||||
private static Block block(String id,Block block){
|
||||
var key=SanctuaryMod.id(id);Registry.register(BuiltInRegistries.BLOCK,key,block);
|
||||
Registry.register(BuiltInRegistries.ITEM,key,new BlockItem(block,new Item.Properties().setId(ResourceKey.create(Registries.ITEM,key)).useBlockDescriptionPrefix()));return block;
|
||||
}
|
||||
private Gems(){}
|
||||
}
|
||||
@@ -47,6 +47,7 @@ public final class BlockKnowledgeService {
|
||||
private BlockKnowledgeService() {}
|
||||
|
||||
public static void register() {
|
||||
DiscoveryCommands.register();
|
||||
ServerPlayConnectionEvents.JOIN.register((handler, sender, server) -> observeInventory(handler.player));
|
||||
// Fabric also reaches DISCONNECT through Netty's channelInactive callback.
|
||||
ServerPlayConnectionEvents.DISCONNECT.register((handler, server) -> server.execute(() -> {
|
||||
@@ -97,6 +98,12 @@ public final class BlockKnowledgeService {
|
||||
|
||||
public static void recordSeen(ServerPlayer player, Block block) {
|
||||
if (block.defaultBlockState().isAir() || player.isSpectator()) return;
|
||||
discover(player,block);
|
||||
}
|
||||
|
||||
/** Explicit discoveries use the same record and events, including for a spectator command target. */
|
||||
public static void discover(ServerPlayer player,Block block) {
|
||||
if(block.defaultBlockState().isAir())return;
|
||||
Session session = session(player);
|
||||
if (session.failed || !session.memory.recordSeen(id(block))) return;
|
||||
session.dirty = true;
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package fr.koka.sanctuary.knowledge;
|
||||
|
||||
import com.mojang.brigadier.builder.ArgumentBuilder;
|
||||
import com.mojang.brigadier.context.CommandContext;
|
||||
import com.mojang.brigadier.exceptions.CommandSyntaxException;
|
||||
import fr.koka.sanctuary.companion.*;
|
||||
import fr.koka.sanctuary.operator.OperatorService;
|
||||
import fr.koka.sanctuary.progression.ProgressEffects;
|
||||
import fr.koka.sanctuary.recipes.*;
|
||||
import java.util.*;
|
||||
import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback;
|
||||
import net.minecraft.commands.*;
|
||||
import net.minecraft.commands.arguments.*;
|
||||
import net.minecraft.core.registries.*;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.entity.EntityType;
|
||||
import net.minecraft.world.item.*;
|
||||
import net.minecraft.world.level.block.Block;
|
||||
|
||||
/** One command routes through the existing discovery services and their dependent recipe/catalogue rules. */
|
||||
public final class DiscoveryCommands {
|
||||
private record Selection(List<Block> blocks,List<Item> items,List<EntityType<?>> mobs){}
|
||||
@FunctionalInterface private interface SelectionArgument {Selection get(CommandContext<CommandSourceStack> context)throws CommandSyntaxException;}
|
||||
public static void register(){
|
||||
CommandRegistrationCallback.EVENT.register((dispatcher,registries,environment)->{
|
||||
var root=Commands.literal("discovery").requires(OperatorService::canCommand);
|
||||
root.then(targets(Commands.literal("all"),c->all(true,true,true)));
|
||||
root.then(Commands.literal("blocks").then(targets(Commands.literal("all"),c->all(true,false,false))));
|
||||
root.then(Commands.literal("items").then(targets(Commands.literal("all"),c->all(false,true,false))));
|
||||
root.then(Commands.literal("mobs").then(targets(Commands.literal("all"),c->all(false,false,true))));
|
||||
root.then(Commands.literal("block").then(targets(Commands.argument("block",ResourceArgument.resource(registries,Registries.BLOCK)),c->new Selection(List.of(ResourceArgument.getResource(c,"block",Registries.BLOCK).value()),List.of(),List.of()))));
|
||||
root.then(Commands.literal("item").then(targets(Commands.argument("item",ResourceArgument.resource(registries,Registries.ITEM)),c->new Selection(List.of(),List.of(ResourceArgument.getResource(c,"item",Registries.ITEM).value()),List.of()))));
|
||||
root.then(Commands.literal("mob").then(targets(Commands.argument("mob",ResourceArgument.resource(registries,Registries.ENTITY_TYPE)),c->new Selection(List.of(),List.of(),List.of(ResourceArgument.getResource(c,"mob",Registries.ENTITY_TYPE).value())))));
|
||||
dispatcher.register(Commands.literal("sanctuary").then(root));
|
||||
});
|
||||
}
|
||||
private static <T extends ArgumentBuilder<CommandSourceStack,T>> T targets(T node,SelectionArgument selection){
|
||||
return node.executes(c->discover(c,selection.get(c),List.of(c.getSource().getPlayerOrException())))
|
||||
.then(Commands.argument("players",EntityArgument.players()).executes(c->discover(c,selection.get(c),EntityArgument.getPlayers(c,"players"))));
|
||||
}
|
||||
private static Selection all(boolean blocks,boolean items,boolean mobs){
|
||||
return new Selection(blocks?BuiltInRegistries.BLOCK.stream().filter(b->!b.defaultBlockState().isAir()).toList():List.of(),
|
||||
items?BuiltInRegistries.ITEM.stream().filter(i->i!=Items.AIR).toList():List.of(),
|
||||
mobs?BuiltInRegistries.ENTITY_TYPE.stream().filter(t->SpawnEggItem.byId(t).isPresent()).toList():List.of());
|
||||
}
|
||||
private static int discover(CommandContext<CommandSourceStack> context,Selection selection,Collection<ServerPlayer> players){
|
||||
var source=context.getSource();
|
||||
if(selection.blocks.stream().anyMatch(b->b.defaultBlockState().isAir())||selection.items.contains(Items.AIR)||selection.mobs.stream().anyMatch(t->SpawnEggItem.byId(t).isEmpty())){
|
||||
source.sendFailure(Component.translatable("sanctuary.discovery.invalid"));return 0;
|
||||
}
|
||||
var species=selection.mobs.stream().map(BuiltInRegistries.ENTITY_TYPE::getKey).map(id->id.getNamespace().equals("minecraft")?id.getPath():id.toString()).toList();
|
||||
int changed=0;
|
||||
for(var player:players){
|
||||
var record=CompanionService.record(player);
|
||||
if(ProgressEffects.get(player)==null||record==null||!BlockKnowledgeService.healthy(player)||!RecipeService.healthy(player)){
|
||||
source.sendFailure(Component.translatable("sanctuary.discovery.unavailable",player.getDisplayName()));continue;
|
||||
}
|
||||
// Validate bounded records before any write; no new save schema or unlock flag.
|
||||
CompanionRecord next;
|
||||
var discoveredItems=new HashSet<String>(RecipeService.knowledge(player).items);
|
||||
for(var item:selection.items)discoveredItems.add(BuiltInRegistries.ITEM.getKey(item).toString());
|
||||
for(var block:selection.blocks)if(block.asItem()!=Items.AIR)discoveredItems.add(BuiltInRegistries.ITEM.getKey(block.asItem()).toString());
|
||||
try{next=CompanionQueries.discoverSpecies(record,species);if(discoveredItems.size()>RecipeKnowledge.MAX_IDS)throw new IllegalArgumentException("Discovery capacity");}
|
||||
catch(IllegalArgumentException invalid){source.sendFailure(Component.translatable("sanctuary.discovery.unavailable",player.getDisplayName()));continue;}
|
||||
CompanionService.save(player,next);
|
||||
for(var block:selection.blocks)BlockKnowledgeService.discover(player,block);
|
||||
for(var item:selection.items){if(item instanceof BlockItem b)BlockKnowledgeService.discover(player,b.getBlock());RecipeService.discover(player,item);}
|
||||
RecipeService.refresh(player);
|
||||
source.sendSuccess(()->Component.translatable("sanctuary.discovery.granted",player.getDisplayName(),selection.blocks.size(),selection.items.size(),selection.mobs.size()),true);changed++;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
private DiscoveryCommands(){}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package fr.koka.sanctuary.mixin.client;
|
||||
|
||||
import fr.koka.sanctuary.client.shader.PixelShadows;
|
||||
import net.minecraft.client.renderer.extract.LevelExtractor;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Inject;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
|
||||
/** Geometry changes only: lighting-only section invalidations do not rebuild shadow casters. */
|
||||
@Mixin(LevelExtractor.class)
|
||||
abstract class PixelShadowDirtyMixin {
|
||||
@Inject(method="blockChanged", at=@At("HEAD"))
|
||||
private void sanctuary$dirty(BlockPos pos, int flags, CallbackInfo ci) {
|
||||
PixelShadows.dirtyBlock(pos);
|
||||
}
|
||||
@Inject(method="allChanged", at=@At("HEAD"))
|
||||
private void sanctuary$reload(CallbackInfo ci) { PixelShadows.release(); }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package fr.koka.sanctuary.mixin.client;
|
||||
|
||||
import fr.koka.sanctuary.client.shader.PixelShadows;
|
||||
import net.minecraft.client.multiplayer.ClientLevel;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.chunk.LevelChunk;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Inject;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
|
||||
@Mixin(ClientLevel.class)
|
||||
abstract class PixelShadowLevelMixin {
|
||||
@Inject(method="setBlocksDirty", at=@At("HEAD"))
|
||||
private void sanctuary$block(BlockPos pos, BlockState before, BlockState after, CallbackInfo ci) {
|
||||
if (before != after) PixelShadows.dirtyBlock(pos);
|
||||
}
|
||||
|
||||
@Inject(method="onChunkLoaded", at=@At("TAIL"))
|
||||
private void sanctuary$loaded(ChunkPos pos, CallbackInfo ci) { PixelShadows.dirtyChunk(pos.x(), pos.z()); }
|
||||
@Inject(method="unload", at=@At("HEAD"))
|
||||
private void sanctuary$unload(LevelChunk chunk, CallbackInfo ci) { PixelShadows.dirtyChunk(chunk.getPos().x(), chunk.getPos().z()); }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package fr.koka.sanctuary.mixin.client;
|
||||
|
||||
import com.llamalad7.mixinextras.injector.wrapoperation.Operation;
|
||||
import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation;
|
||||
import com.mojang.renderpearl.api.buffers.GpuBufferSlice;
|
||||
import fr.koka.sanctuary.client.shader.PixelShadows;
|
||||
import net.minecraft.client.renderer.GameRenderer;
|
||||
import net.minecraft.client.renderer.ProjectionMatrixBuffer;
|
||||
import org.joml.Matrix4f;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Inject;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
|
||||
@Mixin(GameRenderer.class)
|
||||
abstract class PixelShadowRendererMixin {
|
||||
@WrapOperation(method="renderLevel", at=@At(value="INVOKE", target="Lnet/minecraft/client/renderer/ProjectionMatrixBuffer;getBuffer(Lorg/joml/Matrix4f;)Lcom/mojang/renderpearl/api/buffers/GpuBufferSlice;"))
|
||||
private GpuBufferSlice sanctuary$projection(ProjectionMatrixBuffer buffer, Matrix4f matrix, Operation<GpuBufferSlice> original) {
|
||||
PixelShadows.captureProjection(matrix);
|
||||
return original.call(buffer, matrix);
|
||||
}
|
||||
@Inject(method="renderLevel", at=@At(value="INVOKE", target="Lnet/minecraft/client/renderer/GameRenderer;render3dHud(Lnet/minecraft/client/renderer/state/level/CameraRenderState;Lnet/minecraft/client/renderer/state/level/PlayerRenderState;Lnet/minecraft/client/renderer/state/OptionsRenderState;Z)V"))
|
||||
private void sanctuary$shadows(CallbackInfo ci) { PixelShadows.render(); }
|
||||
@Inject(method={"close", "setLevel"}, at=@At("HEAD"))
|
||||
private void sanctuary$close(CallbackInfo ci) { PixelShadows.release(); }
|
||||
}
|
||||
@@ -71,14 +71,18 @@ public final class ClayWorkshopMenu extends AbstractContainerMenu {
|
||||
var contents=item.get(DataComponents.CONTAINER);
|
||||
return contents==null||!contents.nonEmptyItems().iterator().hasNext();
|
||||
}
|
||||
/** Plain clay retains the model's sampled colors; other blocks transfer their texture palette. */
|
||||
public static VoxelSculpture appearance(VoxelSculpture source,Item material,SculpturePalette palette){
|
||||
return material==Items.CLAY?source:palette.apply(source);
|
||||
}
|
||||
public void select(ServerPlayer p,VoxelSculpture sculpture,Item material,SculpturePalette palette){
|
||||
if(level==null||!stillValid(p)||level.getGameTime()-lastSelection<4)return;
|
||||
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&&(!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);
|
||||
if(!Objects.equals(selected,sculpture)||selectedMaterial!=material||!Objects.equals(selectedPalette,palette))
|
||||
selectedResult=sculpture==null||palette==null?null:appearance(sculpture,material,palette);
|
||||
selected=sculpture;selectedMaterial=material;selectedPalette=palette;refresh();broadcastChanges();
|
||||
}
|
||||
private void refresh(){
|
||||
|
||||
@@ -35,6 +35,7 @@ public final class RecipeService {
|
||||
}
|
||||
private static Session session(ServerPlayer p){return SESSIONS.computeIfAbsent(p,Session::new);}
|
||||
public static RecipeKnowledge knowledge(ServerPlayer p){return session(p).data;}
|
||||
public static boolean healthy(ServerPlayer p){return !session(p).invalid;}
|
||||
public static void discover(ServerPlayer p,Item item){
|
||||
if(ProgressEffects.get(p)==null||item==Items.AIR)return;var s=session(p);if(s.invalid)return;
|
||||
s.dirty|=RecipeKnowledge.add(s.data.items,BuiltInRegistries.ITEM.getKey(item).toString());
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"variants": {
|
||||
"": {
|
||||
"model": "sanctuary:block/deepslate_ruby_ore"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"variants": {
|
||||
"": {
|
||||
"model": "sanctuary:block/deepslate_sapphire_ore"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"variants": {
|
||||
"": {
|
||||
"model": "sanctuary:block/ruby_block"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"variants": {
|
||||
"": {
|
||||
"model": "sanctuary:block/ruby_ore"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"variants": {
|
||||
"": {
|
||||
"model": "sanctuary:block/sapphire_block"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"variants": {
|
||||
"": {
|
||||
"model": "sanctuary:block/sapphire_ore"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"model": {
|
||||
"type": "minecraft:model",
|
||||
"model": "sanctuary:block/deepslate_ruby_ore"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"model": {
|
||||
"type": "minecraft:model",
|
||||
"model": "sanctuary:block/deepslate_sapphire_ore"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"model": {
|
||||
"type": "minecraft:model",
|
||||
"model": "sanctuary:item/ruby"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"model": {
|
||||
"type": "minecraft:model",
|
||||
"model": "sanctuary:block/ruby_block"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"model": {
|
||||
"type": "minecraft:model",
|
||||
"model": "sanctuary:block/ruby_ore"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"model": {
|
||||
"type": "minecraft:model",
|
||||
"model": "sanctuary:item/sapphire"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"model": {
|
||||
"type": "minecraft:model",
|
||||
"model": "sanctuary:block/sapphire_block"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"model": {
|
||||
"type": "minecraft:model",
|
||||
"model": "sanctuary:block/sapphire_ore"
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"sanctuary.statuary.clay_ready": "Clay: original model colors.",
|
||||
"sanctuary.weather.selected": "Real Time weather: %s for %s (%s). The daily forecast resumes at midnight.",
|
||||
"sanctuary.weather.overcast": "Overcast",
|
||||
"sanctuary.weather.fog": "Fog",
|
||||
@@ -1284,7 +1285,7 @@
|
||||
"sanctuary.shaders.saturation": "Saturation",
|
||||
"sanctuary.shaders.saturation_value": "Saturation: %s%%",
|
||||
"sanctuary.shaders.saturation_help": "0%: grayscale. 100%: neutral. 142%: default setting. 200%: more vivid colours. Requires Vanilla Light; interface colours stay unchanged.",
|
||||
"sanctuary.shaders.help": "Subtle colour grading and progressive fog from your viewpoint to the visible limit. No extra shadows or reflections.",
|
||||
"sanctuary.shaders.help": "Subtle colour grading and progressive fog from your viewpoint to the visible limit. Optional pixel sun shadows, with no extra reflections.",
|
||||
"sanctuary.shaders.external": "Sanctuary Vanilla Light is suspended while another shader engine is installed. Your preference is kept.",
|
||||
"sanctuary.cosmetic.bed_other": "Decorative bed: you cannot sleep in a worn bed.",
|
||||
"block.sanctuary.slime_springboard": "Temporary slime",
|
||||
@@ -1972,7 +1973,7 @@
|
||||
"sanctuary.discovery.item_filter": "Show carried items or every item already discovered.",
|
||||
"sanctuary.discovery.catalogue_required": "The Catalogue ability lets you browse discovered items you no longer carry.",
|
||||
"sanctuary.discovery.loading": "Loading statistics…",
|
||||
"sanctuary.discovery.unavailable": "Statistics unavailable. Refresh to try again.",
|
||||
"sanctuary.discovery.unavailable": "Unable to update discoveries for %s; no changes made.",
|
||||
"sanctuary.discovery.mobs_hint": "Mobs you have killed or that have killed you. Sightings alone are not recorded yet.",
|
||||
"sanctuary.discovery.count": "%s entries",
|
||||
"sanctuary.discovery.empty": "No entries match this filter.",
|
||||
@@ -2245,5 +2246,23 @@
|
||||
"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."
|
||||
"sanctuary.statuary.animal_error": "This creature’s model could not be sculpted.",
|
||||
"item.sanctuary.ruby": "Ruby",
|
||||
"block.sanctuary.ruby_block": "Block of Ruby",
|
||||
"block.sanctuary.ruby_ore": "Ruby Ore",
|
||||
"block.sanctuary.deepslate_ruby_ore": "Deepslate Ruby Ore",
|
||||
"item.sanctuary.sapphire": "Sapphire",
|
||||
"block.sanctuary.sapphire_block": "Block of Sapphire",
|
||||
"block.sanctuary.sapphire_ore": "Sapphire Ore",
|
||||
"block.sanctuary.deepslate_sapphire_ore": "Deepslate Sapphire Ore",
|
||||
"sanctuary.discovery.granted": "Discoveries added for %s: %s blocks, %s items, %s species. Linked recipes updated; reopen catalogues.",
|
||||
"sanctuary.discovery.invalid": "Choose a discoverable block, item or species.",
|
||||
"sanctuary.shaders.pixel_shadows": "Pixel shadows",
|
||||
"sanctuary.shaders.pixel_shadows_help": "Sun shadows and subtle moon shadows on a pixel grid. Dappled foliage and softer shadows at noon. Disabled by default; requires Vanilla Light.",
|
||||
"sanctuary.shaders.shadow_pixels": "Shadow precision",
|
||||
"sanctuary.shaders.shadow_pixels_value": "%s pixels/block",
|
||||
"sanctuary.shaders.shadow_pixels_help": "Grid precision: 8, 16 or 32 pixels per block. Higher values make shadow pixels finer. Requires Vanilla Light and pixel shadows.",
|
||||
"sanctuary.shaders.shadow_distance": "Shadow distance",
|
||||
"sanctuary.shaders.shadow_distance_value": "%s blocks",
|
||||
"sanctuary.shaders.shadow_distance_help": "Shadow range: 16, 32 or 64 blocks. Higher distances cost more performance. Requires Vanilla Light and pixel shadows."
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"sanctuary.statuary.clay_ready": "Argile : couleurs originales du modèle.",
|
||||
"sanctuary.weather.selected": "Météo Real Time : %s pour la journée du %s (%s). Le tirage quotidien reprend à minuit.",
|
||||
"sanctuary.weather.overcast": "Ciel gris",
|
||||
"sanctuary.weather.fog": "Brouillard",
|
||||
@@ -1284,7 +1285,7 @@
|
||||
"sanctuary.shaders.saturation": "Saturation",
|
||||
"sanctuary.shaders.saturation_value": "Saturation : %s %%",
|
||||
"sanctuary.shaders.saturation_help": "0 % : noir et blanc. 100 % : neutre. 142 % : réglage par défaut. 200 % : couleurs plus vives. Avec Vanilla Light activé ; l’interface garde ses couleurs.",
|
||||
"sanctuary.shaders.help": "Couleurs légèrement retravaillées et brume progressive depuis ton point de vue jusqu’à la limite visible. Sans ombres ni reflets supplémentaires.",
|
||||
"sanctuary.shaders.help": "Couleurs légèrement retravaillées et brume progressive depuis ton point de vue jusqu’à la limite visible. Ombres pixelisées du soleil en option, sans reflets supplémentaires.",
|
||||
"sanctuary.shaders.external": "Sanctuary Vanilla Light est suspendu lorsqu’un autre moteur de shaders est installé. Votre préférence est conservée.",
|
||||
"sanctuary.cosmetic.bed_other": "Lit décoratif : impossible de dormir dans un lit porté.",
|
||||
"block.sanctuary.slime_springboard": "Slime éphémère",
|
||||
@@ -1972,7 +1973,7 @@
|
||||
"sanctuary.discovery.item_filter": "Afficher les objets portés ou tous les objets déjà découverts.",
|
||||
"sanctuary.discovery.catalogue_required": "L’aptitude Catalogue permet de consulter les objets découverts que tu ne portes plus.",
|
||||
"sanctuary.discovery.loading": "Chargement des statistiques…",
|
||||
"sanctuary.discovery.unavailable": "Statistiques indisponibles. Actualise pour réessayer.",
|
||||
"sanctuary.discovery.unavailable": "Impossible de mettre à jour les découvertes de %s ; aucune modification effectuée.",
|
||||
"sanctuary.discovery.mobs_hint": "Créatures déjà tuées ou responsables de ta mort. Les simples observations ne sont pas encore enregistrées.",
|
||||
"sanctuary.discovery.count": "%s entrées",
|
||||
"sanctuary.discovery.empty": "Aucune entrée pour ce filtre.",
|
||||
@@ -2245,5 +2246,23 @@
|
||||
"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."
|
||||
"sanctuary.statuary.animal_error": "Impossible de sculpter le modèle de cette créature.",
|
||||
"item.sanctuary.ruby": "Rubis",
|
||||
"block.sanctuary.ruby_block": "Bloc de rubis",
|
||||
"block.sanctuary.ruby_ore": "Minerai de rubis",
|
||||
"block.sanctuary.deepslate_ruby_ore": "Minerai de rubis des abîmes",
|
||||
"item.sanctuary.sapphire": "Saphir",
|
||||
"block.sanctuary.sapphire_block": "Bloc de saphir",
|
||||
"block.sanctuary.sapphire_ore": "Minerai de saphir",
|
||||
"block.sanctuary.deepslate_sapphire_ore": "Minerai de saphir des abîmes",
|
||||
"sanctuary.discovery.granted": "Découvertes ajoutées pour %s : %s blocs, %s objets, %s espèces. Recettes liées actualisées ; rouvre les catalogues.",
|
||||
"sanctuary.discovery.invalid": "Choisis un bloc, un objet ou une espèce découvrable.",
|
||||
"sanctuary.shaders.pixel_shadows": "Ombres pixelisées",
|
||||
"sanctuary.shaders.pixel_shadows_help": "Ombres du soleil et ombres légères de la lune, sur une grille de pixels. Feuillage irrégulier et ombres atténuées à midi. Désactivées par défaut ; nécessite Vanilla Light.",
|
||||
"sanctuary.shaders.shadow_pixels": "Précision des ombres",
|
||||
"sanctuary.shaders.shadow_pixels_value": "%s pixels/bloc",
|
||||
"sanctuary.shaders.shadow_pixels_help": "Précision de la grille : 8, 16 ou 32 pixels par bloc. Plus la valeur est élevée, plus les pixels des ombres sont fins. Nécessite Vanilla Light et les ombres pixelisées.",
|
||||
"sanctuary.shaders.shadow_distance": "Distance des ombres",
|
||||
"sanctuary.shaders.shadow_distance_value": "%s blocs",
|
||||
"sanctuary.shaders.shadow_distance_help": "Portée des ombres : 16, 32 ou 64 blocs. Une distance plus élevée coûte davantage en performances. Nécessite Vanilla Light et les ombres pixelisées."
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"parent": "minecraft:block/cube_all",
|
||||
"textures": {
|
||||
"all": "sanctuary:block/deepslate_ruby_ore"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"parent": "minecraft:block/cube_all",
|
||||
"textures": {
|
||||
"all": "sanctuary:block/deepslate_sapphire_ore"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"parent": "minecraft:block/cube_all",
|
||||
"textures": {
|
||||
"all": "sanctuary:block/ruby_block"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"parent": "minecraft:block/cube_all",
|
||||
"textures": {
|
||||
"all": "sanctuary:block/ruby_ore"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"parent": "minecraft:block/cube_all",
|
||||
"textures": {
|
||||
"all": "sanctuary:block/sapphire_block"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"parent": "minecraft:block/cube_all",
|
||||
"textures": {
|
||||
"all": "sanctuary:block/sapphire_ore"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"parent": "minecraft:item/generated",
|
||||
"textures": {
|
||||
"layer0": "sanctuary:item/ruby"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"parent": "minecraft:item/generated",
|
||||
"textures": {
|
||||
"layer0": "sanctuary:item/sapphire"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
#version 330
|
||||
#extension GL_ARB_separate_shader_objects : require
|
||||
layout(location = 0) in vec3 foliagePosition;
|
||||
layout(location = 1) flat in float foliage;
|
||||
layout(location = 0) out vec4 material;
|
||||
uint mixBits(uint h) {
|
||||
h ^= h >> 16; h *= 0x7feb352du; h ^= h >> 15; h *= 0x846ca68bu; return h ^ (h >> 16);
|
||||
}
|
||||
void main() {
|
||||
// Fixed quarter-block cells: no frame/time/camera seed, texture-alpha sampling or CPU ray march.
|
||||
// Each crossed leaf surface transmits 78%; independent layers converge towards solid shade.
|
||||
if (foliage > 0.5) {
|
||||
uvec3 p = uvec3(ivec3(floor((foliagePosition + 0.03125) * 4.0)));
|
||||
uint h = mixBits(p.x ^ mixBits(p.y + 0x9e3779b9u) ^ mixBits(p.z + 0x85ebca6bu));
|
||||
if (float(h & 65535u) / 65535.0 >= 0.22) discard;
|
||||
}
|
||||
material = vec4(foliage, 0.0, 0.0, 1.0);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
#version 330
|
||||
#extension GL_ARB_separate_shader_objects : require
|
||||
#include <minecraft:dynamictransforms.glsl>
|
||||
layout(location = 0) in vec3 Position;
|
||||
layout(location = 1) in vec4 Color;
|
||||
layout(location = 0) out vec3 foliagePosition;
|
||||
layout(location = 1) flat out float foliage;
|
||||
void main() {
|
||||
gl_Position = ModelViewMat * vec4(Position, 1.0);
|
||||
foliagePosition = Position + ModelOffset;
|
||||
foliage = Color.r;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
#version 330
|
||||
#extension GL_ARB_separate_shader_objects : require
|
||||
uniform sampler2D WorldDepth;
|
||||
uniform sampler2D SunDepth;
|
||||
uniform sampler2D SunMaterial;
|
||||
layout(std140) uniform PixelShadows {
|
||||
mat4 InverseViewProjection;
|
||||
mat4 LightMatrix;
|
||||
vec4 CameraFractionPixels;
|
||||
vec4 SunStrength;
|
||||
vec4 DistanceClipFog;
|
||||
vec4 EnvironmentFog;
|
||||
};
|
||||
layout(location = 0) in vec2 texCoord;
|
||||
layout(location = 0) out vec4 fragColor;
|
||||
float linearFog(float d, float start, float end) {
|
||||
return clamp((d - start) / max(end - start, 0.001), 0.0, 1.0);
|
||||
}
|
||||
float shadowAt(vec2 uv, vec3 receiver, vec2 planeSlope, bool includeOpaque) {
|
||||
if (!includeOpaque && texture(SunMaterial, uv).r < 0.5) return 0.0;
|
||||
// Compare each tap on the same receiving plane; an oblique floor must not shadow itself.
|
||||
float depth = receiver.z + dot(uv - receiver.xy, planeSlope);
|
||||
return depth > texture(SunDepth, uv).r + 0.00015 ? 1.0 : 0.0;
|
||||
}
|
||||
float bilinearShadow(vec2 uv, vec3 receiver, vec2 planeSlope, bool includeOpaque) {
|
||||
vec2 size = vec2(textureSize(SunDepth, 0));
|
||||
vec2 at = uv * size - 0.5;
|
||||
vec2 base = (floor(at) + 0.5) / size;
|
||||
vec2 t = fract(at), stepSize = 1.0 / size;
|
||||
return mix(mix(shadowAt(base,receiver,planeSlope,includeOpaque),
|
||||
shadowAt(base+vec2(stepSize.x,0.0),receiver,planeSlope,includeOpaque),t.x),
|
||||
mix(shadowAt(base+vec2(0.0,stepSize.y),receiver,planeSlope,includeOpaque),
|
||||
shadowAt(base+stepSize,receiver,planeSlope,includeOpaque),t.x),t.y);
|
||||
}
|
||||
float filteredShadow(vec3 coord, vec2 planeSlope, bool includeOpaque) {
|
||||
// Four fixed bilinear taps span a quarter block, averaging leaf coverage into softer shades.
|
||||
// The result is still evaluated once per world-grid cell, with no temporal/random jitter.
|
||||
float r = 0.125 / (2.0 * (DistanceClipFog.x + 16.0));
|
||||
return 0.25 * (bilinearShadow(coord.xy+vec2(-r,-r),coord,planeSlope,includeOpaque)
|
||||
+ bilinearShadow(coord.xy+vec2( r,-r),coord,planeSlope,includeOpaque)
|
||||
+ bilinearShadow(coord.xy+vec2(-r, r),coord,planeSlope,includeOpaque)
|
||||
+ bilinearShadow(coord.xy+vec2( r, r),coord,planeSlope,includeOpaque));
|
||||
}
|
||||
void main() {
|
||||
float depth = texture(WorldDepth, texCoord).r;
|
||||
float clipDepth = DistanceClipFog.y > 0.5 ? depth : depth * 2.0 - 1.0;
|
||||
vec4 projected = InverseViewProjection * vec4(texCoord * 2.0 - 1.0, clipDepth, 1.0);
|
||||
vec3 relative = projected.xyz / projected.w;
|
||||
// Derivatives before divergent branches; keep the receiving plane instead of snapping into a block.
|
||||
vec3 normal = normalize(cross(dFdx(relative), dFdy(relative)));
|
||||
if (dot(normal, -relative) < 0.0) normal = -normal;
|
||||
if (depth <= 0.0 || length(relative) >= DistanceClipFog.x) discard;
|
||||
// Depth derivatives vary with the camera. Use a stable axis for voxel faces.
|
||||
vec3 absoluteNormal = abs(normal);
|
||||
vec3 axis = absoluteNormal.x > absoluteNormal.y && absoluteNormal.x > absoluteNormal.z ? vec3(1,0,0)
|
||||
: absoluteNormal.y > absoluteNormal.z ? vec3(0,1,0) : vec3(0,0,1);
|
||||
bool voxelFace = max(max(absoluteNormal.x, absoluteNormal.y), absoluteNormal.z) > 0.995;
|
||||
if (voxelFace) normal = axis * sign(dot(normal,axis));
|
||||
vec3 p = relative + CameraFractionPixels.xyz;
|
||||
float pixels = CameraFractionPixels.w;
|
||||
vec3 snapped = (floor(p * pixels + 0.0001) + 0.5) / pixels;
|
||||
// Vanilla voxel faces live on the 1/16 grid. Quantize their plane as well as the two face axes.
|
||||
float plane = floor(dot(p,axis) * 16.0 + 0.5) / 16.0;
|
||||
vec3 q = voxelFace ? snapped + axis * (plane - dot(snapped,axis))
|
||||
: snapped + normal * dot(p - snapped, normal);
|
||||
q += normal * 0.04 + SunStrength.xyz * 0.015;
|
||||
vec4 light = LightMatrix * vec4(q, 1.0);
|
||||
vec3 coord = light.xyz / light.w;
|
||||
coord.xy = coord.xy * 0.5 + 0.5;
|
||||
if (DistanceClipFog.y < 0.5) coord.z = coord.z * 0.5 + 0.5;
|
||||
if (any(lessThan(coord, vec3(0.0))) || any(greaterThan(coord, vec3(1.0)))) discard;
|
||||
// Cofactors transform the receiver normal without a per-fragment matrix inverse.
|
||||
vec3 a = LightMatrix[0].xyz, b = LightMatrix[1].xyz, c = LightMatrix[2].xyz;
|
||||
vec3 lightNormal = normal.x * cross(b,c) + normal.y * cross(c,a) + normal.z * cross(a,b);
|
||||
vec2 planeSlope = abs(lightNormal.z) > 0.00000001
|
||||
? -lightNormal.xy / lightNormal.z * (DistanceClipFog.y > 0.5 ? 2.0 : 1.0) : vec2(0.0);
|
||||
bool leaf = texture(SunMaterial, coord.xy).r > 0.5;
|
||||
float hardShadow = shadowAt(coord.xy, coord, planeSlope, true);
|
||||
// Keep opaque-block edges crisp. Only foliage coverage receives the soft kernel.
|
||||
// Leaf taps may include opaque occluders behind them, preserving shade under roofs.
|
||||
float shade = !leaf && hardShadow > 0.5 ? 1.0 : filteredShadow(coord, planeSlope, leaf);
|
||||
float fade = 1.0 - smoothstep(DistanceClipFog.x * 0.8, DistanceClipFog.x, length(relative));
|
||||
float fog = max(linearFog(length(relative), EnvironmentFog.x, EnvironmentFog.y),
|
||||
linearFog(max(length(relative.xz), abs(relative.y)), DistanceClipFog.z, DistanceClipFog.w));
|
||||
fragColor = vec4(0.0, 0.0, 0.0, shade * SunStrength.w * fade * (1.0 - fog * EnvironmentFog.z));
|
||||
}
|
||||
|
After Width: | Height: | Size: 596 B |
|
After Width: | Height: | Size: 624 B |
|
After Width: | Height: | Size: 202 B |
|
After Width: | Height: | Size: 573 B |
|
After Width: | Height: | Size: 391 B |
|
After Width: | Height: | Size: 605 B |
|
After Width: | Height: | Size: 211 B |
|
After Width: | Height: | Size: 208 B |
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"replace": false,
|
||||
"values": [
|
||||
"#c:ores/ruby",
|
||||
"#c:ores/sapphire"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"replace": false,
|
||||
"values": [
|
||||
"#sanctuary:ruby_ores"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"replace": false,
|
||||
"values": [
|
||||
"#sanctuary:sapphire_ores"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"replace": false,
|
||||
"values": [
|
||||
"sanctuary:deepslate_ruby_ore",
|
||||
"sanctuary:deepslate_sapphire_ore"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"replace": false,
|
||||
"values": [
|
||||
"sanctuary:ruby_ore",
|
||||
"sanctuary:sapphire_ore"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"replace": false,
|
||||
"values": [
|
||||
"#c:storage_blocks/ruby",
|
||||
"#c:storage_blocks/sapphire"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"replace": false,
|
||||
"values": [
|
||||
"sanctuary:ruby_block"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"replace": false,
|
||||
"values": [
|
||||
"sanctuary:sapphire_block"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"replace": false,
|
||||
"values": [
|
||||
"#c:gems/ruby",
|
||||
"#c:gems/sapphire"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"replace": false,
|
||||
"values": [
|
||||
"sanctuary:ruby"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"replace": false,
|
||||
"values": [
|
||||
"sanctuary:sapphire"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"replace": false,
|
||||
"values": [
|
||||
"#c:ores/ruby",
|
||||
"#c:ores/sapphire"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"replace": false,
|
||||
"values": [
|
||||
"#sanctuary:ruby_ores"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"replace": false,
|
||||
"values": [
|
||||
"#sanctuary:sapphire_ores"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"replace": false,
|
||||
"values": [
|
||||
"sanctuary:deepslate_ruby_ore",
|
||||
"sanctuary:deepslate_sapphire_ore"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"replace": false,
|
||||
"values": [
|
||||
"sanctuary:ruby_ore",
|
||||
"sanctuary:sapphire_ore"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"replace": false,
|
||||
"values": [
|
||||
"#c:storage_blocks/ruby",
|
||||
"#c:storage_blocks/sapphire"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"replace": false,
|
||||
"values": [
|
||||
"sanctuary:ruby_block"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"replace": false,
|
||||
"values": [
|
||||
"sanctuary:sapphire_block"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"replace": false,
|
||||
"values": [
|
||||
"sanctuary:ruby_block",
|
||||
"sanctuary:sapphire_block"
|
||||
]
|
||||
}
|
||||
@@ -64,6 +64,12 @@
|
||||
"sanctuary:brown_brick_wall",
|
||||
"sanctuary:green_brick_wall",
|
||||
"sanctuary:red_brick_wall",
|
||||
"sanctuary:black_brick_wall"
|
||||
"sanctuary:black_brick_wall",
|
||||
"sanctuary:ruby_block",
|
||||
"sanctuary:ruby_ore",
|
||||
"sanctuary:deepslate_ruby_ore",
|
||||
"sanctuary:sapphire_block",
|
||||
"sanctuary:sapphire_ore",
|
||||
"sanctuary:deepslate_sapphire_ore"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"replace": false,
|
||||
"values": [
|
||||
"sanctuary:ruby_block",
|
||||
"sanctuary:ruby_ore",
|
||||
"sanctuary:deepslate_ruby_ore",
|
||||
"sanctuary:sapphire_block",
|
||||
"sanctuary:sapphire_ore",
|
||||
"sanctuary:deepslate_sapphire_ore"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"replace": false,
|
||||
"values": [
|
||||
"sanctuary:ruby",
|
||||
"sanctuary:sapphire"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"parent": "minecraft:recipes/root",
|
||||
"criteria": {
|
||||
"has_emerald": {
|
||||
"conditions": {
|
||||
"items": [
|
||||
{
|
||||
"items": "sanctuary:ruby"
|
||||
}
|
||||
]
|
||||
},
|
||||
"trigger": "minecraft:inventory_changed"
|
||||
},
|
||||
"has_the_recipe": {
|
||||
"conditions": {
|
||||
"recipes": "sanctuary:ruby_block"
|
||||
},
|
||||
"trigger": "minecraft:recipe_unlocked"
|
||||
}
|
||||
},
|
||||
"requirements": [
|
||||
[
|
||||
"has_the_recipe",
|
||||
"has_emerald"
|
||||
]
|
||||
],
|
||||
"rewards": {
|
||||
"recipes": [
|
||||
"sanctuary:ruby_block"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"parent": "minecraft:recipes/root",
|
||||
"criteria": {
|
||||
"has_emerald": {
|
||||
"conditions": {
|
||||
"items": [
|
||||
{
|
||||
"items": "sanctuary:sapphire"
|
||||
}
|
||||
]
|
||||
},
|
||||
"trigger": "minecraft:inventory_changed"
|
||||
},
|
||||
"has_the_recipe": {
|
||||
"conditions": {
|
||||
"recipes": "sanctuary:sapphire_block"
|
||||
},
|
||||
"trigger": "minecraft:recipe_unlocked"
|
||||
}
|
||||
},
|
||||
"requirements": [
|
||||
[
|
||||
"has_the_recipe",
|
||||
"has_emerald"
|
||||
]
|
||||
],
|
||||
"rewards": {
|
||||
"recipes": [
|
||||
"sanctuary:sapphire_block"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"parent": "minecraft:recipes/root",
|
||||
"criteria": {
|
||||
"has_emerald_block": {
|
||||
"conditions": {
|
||||
"items": [
|
||||
{
|
||||
"items": "sanctuary:ruby_block"
|
||||
}
|
||||
]
|
||||
},
|
||||
"trigger": "minecraft:inventory_changed"
|
||||
},
|
||||
"has_the_recipe": {
|
||||
"conditions": {
|
||||
"recipes": "sanctuary:ruby"
|
||||
},
|
||||
"trigger": "minecraft:recipe_unlocked"
|
||||
}
|
||||
},
|
||||
"requirements": [
|
||||
[
|
||||
"has_the_recipe",
|
||||
"has_emerald_block"
|
||||
]
|
||||
],
|
||||
"rewards": {
|
||||
"recipes": [
|
||||
"sanctuary:ruby"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"parent": "minecraft:recipes/root",
|
||||
"criteria": {
|
||||
"has_deepslate_ruby_ore": {
|
||||
"conditions": {
|
||||
"items": [
|
||||
{
|
||||
"items": "sanctuary:deepslate_ruby_ore"
|
||||
}
|
||||
]
|
||||
},
|
||||
"trigger": "minecraft:inventory_changed"
|
||||
},
|
||||
"has_the_recipe": {
|
||||
"conditions": {
|
||||
"recipes": "sanctuary:ruby_from_blasting_deepslate_ruby_ore"
|
||||
},
|
||||
"trigger": "minecraft:recipe_unlocked"
|
||||
}
|
||||
},
|
||||
"requirements": [
|
||||
[
|
||||
"has_the_recipe",
|
||||
"has_deepslate_ruby_ore"
|
||||
]
|
||||
],
|
||||
"rewards": {
|
||||
"recipes": [
|
||||
"sanctuary:ruby_from_blasting_deepslate_ruby_ore"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"parent": "minecraft:recipes/root",
|
||||
"criteria": {
|
||||
"has_ruby_ore": {
|
||||
"conditions": {
|
||||
"items": [
|
||||
{
|
||||
"items": "sanctuary:ruby_ore"
|
||||
}
|
||||
]
|
||||
},
|
||||
"trigger": "minecraft:inventory_changed"
|
||||
},
|
||||
"has_the_recipe": {
|
||||
"conditions": {
|
||||
"recipes": "sanctuary:ruby_from_blasting_ruby_ore"
|
||||
},
|
||||
"trigger": "minecraft:recipe_unlocked"
|
||||
}
|
||||
},
|
||||
"requirements": [
|
||||
[
|
||||
"has_the_recipe",
|
||||
"has_ruby_ore"
|
||||
]
|
||||
],
|
||||
"rewards": {
|
||||
"recipes": [
|
||||
"sanctuary:ruby_from_blasting_ruby_ore"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"parent": "minecraft:recipes/root",
|
||||
"criteria": {
|
||||
"has_deepslate_ruby_ore": {
|
||||
"conditions": {
|
||||
"items": [
|
||||
{
|
||||
"items": "sanctuary:deepslate_ruby_ore"
|
||||
}
|
||||
]
|
||||
},
|
||||
"trigger": "minecraft:inventory_changed"
|
||||
},
|
||||
"has_the_recipe": {
|
||||
"conditions": {
|
||||
"recipes": "sanctuary:ruby_from_smelting_deepslate_ruby_ore"
|
||||
},
|
||||
"trigger": "minecraft:recipe_unlocked"
|
||||
}
|
||||
},
|
||||
"requirements": [
|
||||
[
|
||||
"has_the_recipe",
|
||||
"has_deepslate_ruby_ore"
|
||||
]
|
||||
],
|
||||
"rewards": {
|
||||
"recipes": [
|
||||
"sanctuary:ruby_from_smelting_deepslate_ruby_ore"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"parent": "minecraft:recipes/root",
|
||||
"criteria": {
|
||||
"has_ruby_ore": {
|
||||
"conditions": {
|
||||
"items": [
|
||||
{
|
||||
"items": "sanctuary:ruby_ore"
|
||||
}
|
||||
]
|
||||
},
|
||||
"trigger": "minecraft:inventory_changed"
|
||||
},
|
||||
"has_the_recipe": {
|
||||
"conditions": {
|
||||
"recipes": "sanctuary:ruby_from_smelting_ruby_ore"
|
||||
},
|
||||
"trigger": "minecraft:recipe_unlocked"
|
||||
}
|
||||
},
|
||||
"requirements": [
|
||||
[
|
||||
"has_the_recipe",
|
||||
"has_ruby_ore"
|
||||
]
|
||||
],
|
||||
"rewards": {
|
||||
"recipes": [
|
||||
"sanctuary:ruby_from_smelting_ruby_ore"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"parent": "minecraft:recipes/root",
|
||||
"criteria": {
|
||||
"has_emerald_block": {
|
||||
"conditions": {
|
||||
"items": [
|
||||
{
|
||||
"items": "sanctuary:sapphire_block"
|
||||
}
|
||||
]
|
||||
},
|
||||
"trigger": "minecraft:inventory_changed"
|
||||
},
|
||||
"has_the_recipe": {
|
||||
"conditions": {
|
||||
"recipes": "sanctuary:sapphire"
|
||||
},
|
||||
"trigger": "minecraft:recipe_unlocked"
|
||||
}
|
||||
},
|
||||
"requirements": [
|
||||
[
|
||||
"has_the_recipe",
|
||||
"has_emerald_block"
|
||||
]
|
||||
],
|
||||
"rewards": {
|
||||
"recipes": [
|
||||
"sanctuary:sapphire"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"parent": "minecraft:recipes/root",
|
||||
"criteria": {
|
||||
"has_deepslate_sapphire_ore": {
|
||||
"conditions": {
|
||||
"items": [
|
||||
{
|
||||
"items": "sanctuary:deepslate_sapphire_ore"
|
||||
}
|
||||
]
|
||||
},
|
||||
"trigger": "minecraft:inventory_changed"
|
||||
},
|
||||
"has_the_recipe": {
|
||||
"conditions": {
|
||||
"recipes": "sanctuary:sapphire_from_blasting_deepslate_sapphire_ore"
|
||||
},
|
||||
"trigger": "minecraft:recipe_unlocked"
|
||||
}
|
||||
},
|
||||
"requirements": [
|
||||
[
|
||||
"has_the_recipe",
|
||||
"has_deepslate_sapphire_ore"
|
||||
]
|
||||
],
|
||||
"rewards": {
|
||||
"recipes": [
|
||||
"sanctuary:sapphire_from_blasting_deepslate_sapphire_ore"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"parent": "minecraft:recipes/root",
|
||||
"criteria": {
|
||||
"has_sapphire_ore": {
|
||||
"conditions": {
|
||||
"items": [
|
||||
{
|
||||
"items": "sanctuary:sapphire_ore"
|
||||
}
|
||||
]
|
||||
},
|
||||
"trigger": "minecraft:inventory_changed"
|
||||
},
|
||||
"has_the_recipe": {
|
||||
"conditions": {
|
||||
"recipes": "sanctuary:sapphire_from_blasting_sapphire_ore"
|
||||
},
|
||||
"trigger": "minecraft:recipe_unlocked"
|
||||
}
|
||||
},
|
||||
"requirements": [
|
||||
[
|
||||
"has_the_recipe",
|
||||
"has_sapphire_ore"
|
||||
]
|
||||
],
|
||||
"rewards": {
|
||||
"recipes": [
|
||||
"sanctuary:sapphire_from_blasting_sapphire_ore"
|
||||
]
|
||||
}
|
||||
}
|
||||