Compare commits

...
Author SHA1 Message Date
koka 00d175cb98 Deliver beta.235 as a verified reversible Pause layout experiment
Build Sanctuary / build (push) Waiting to run
2026-10-06 14:21:31 +02:00
koka a7bdfe6998 Try parallel Pause groups before Atlas and a narrow stack beside the map 2026-10-06 14:16:40 +02:00
koka f9c2ce8835 Deliver beta.234 with verified Story catalogue and local pack
Build Sanctuary / build (push) Waiting to run
2026-10-06 04:50:30 +02:00
koka 3d56d7de7f Verify Story menus under Vulkan and document story authoring 2026-10-06 04:49:56 +02:00
koka f2ac23b4f4 Add Story catalogue with search and discovery filters 2026-10-06 04:46:02 +02:00
18 changed files with 501 additions and 47 deletions
+25
View File
@@ -6,6 +6,31 @@ Créer un monde **Sanctuary** en Petit, Moyen ou Grand, puis activer les ancres
correspondantes. Le module Sanctuary Test n’est pas requis.
[Intégration et vérification du pack beta.224](docs/four-islands-base224.md).
## beta.235 — Essai réversible du menu Pause
[Disposition adaptative](docs/pause-columns-beta235.md) : avant Atlas, Aventure
à gauche et Communauté à droite, avec Partie / Game juste dessous. Avec Atlas,
une colonne étroite à gauche, Communauté → Aventure → Game, carte au centre.
Bloc centré verticalement, libellés de navigation entiers et ordre clavier
adapté. Parcours Pause et Story Vulkan FR/EN GUI 2/3/4, `check build assemblePack`
et quatre GameTests ciblés réussis.
Le commit de disposition `a7bdfe6` peut être annulé indépendamment de la
livraison. [MRpack local beta.235](build/Sanctuary-beta.235.mrpack) vérifié,
sans publication ni mise à jour personnelle. Le rendu reste à apprécier.
## beta.234 — Story, recherche et filtres
[Premier menu Story](docs/story-beta234.md), sous Progression dans Aventure :
100 histoires inconnues et désactivées, recherche instantanée et filtre
Toutes/Découvertes/Inconnues, cadre de Discovery et retour à Pause. Saisie et
défilement conservés au redimensionnement ; trois boutons Aventure centrés
avec ou sans Atlas. [Guide](docs/story/README.md) et
[modèle Markdown](docs/story/TEMPLATE.md) pour les futurs récits personnels.
Textes et déblocages restent à intégrer. `check build assemblePack`, quatre
GameTests requis et parcours Vulkan FR/EN GUI 2/3/4 réussis.
[MRpack local beta.234](build/Sanctuary-beta.234.mrpack) vérifié ; aucune
publication ni mise à jour d'installation personnelle.
## beta.233 — Navigation centrée et rédaction simplifiée
[Ajustements du menu et des formulaires](docs/pause-community-beta233.md) : même
+21
View File
@@ -1,5 +1,26 @@
# Backlog Sanctuary
## PAUSE-235 — groupes côte à côte avant Atlas — essai livré localement
Branche `codex/pause-columns-beta235`, base beta.234. Avant Atlas : Aventure à
gauche, Communauté à droite et Game juste dessous, bloc centré. Avec Atlas :
une colonne étroite à gauche dans l'ordre Communauté → Aventure → Game,
carte centrale et fils à droite. [Contrat et vérifications](pause-columns-beta235.md),
parcours Vulkan Pause et Story FR/EN GUI 2/3/4, build, quatre GameTests ciblés
et MRpack local vérifiés ; rendu à apprécier.
Commit de disposition `a7bdfe6` séparé des métadonnées pour permettre son retour.
## STORY-234 — recueil personnel avec recherche — livré localement
Branche `codex/story-beta234`, base beta.233. Story sous Progression dans
Aventure ; 100 histoires inconnues, barre de recherche et filtre de découverte.
Cadre commun avec Discovery, état de recherche et défilement conservés au
redimensionnement, accès avant et après Atlas. [Résultat et vérifications](story-beta234.md) :
build, quatre GameTests requis et parcours Vulkan FR/EN GUI 2/3/4 réussis.
[Modèle Markdown](story/TEMPLATE.md) pour les textes de l'auteur.
Les récits et leurs conditions de découverte restent des tickets suivants ;
aucun moteur de quête, format persistant ou déploiement ajouté.
## WG-RESTORE-191 — annuler les plateaux et leurs affleurements
La visite beta.190 ne valide pas le relief corrigé. [Retour exact à la base
+97
View File
@@ -0,0 +1,97 @@
# PAUSE-235 — Aventure et Communauté côte à côte
Branche d'essai `codex/pause-columns-beta235`, base beta.234, commit `f9c2ce8`,
Minecraft 26.3. Le rendu reste à apprécier par le créateur.
## Disposition et réversibilité
Avant Atlas, Aventure et Communauté occupent deux colonnes au même niveau :
Discovery, Progression et Story à gauche ; Habitant, Faction et Battle à droite.
Partie / Game suit juste dessous avec Options, World Options et la sortie
native. L'ensemble est centré verticalement dans le corps de Pause, avec de
l'espace sous Game ; Reprendre la partie reste dans le pied de page.
Sans Atlas, ce bloc est centré entre Gazette à gauche et Notice Board à droite.
Avec Atlas, une seule colonne étroite est serrée à gauche : Communauté, puis
Aventure, puis Partie / Game, selon l'ordre confirmé par le créateur. La carte
garde la région centrale et les deux fils communautaires restent à droite.
Le bloc complet reste centré verticalement. La largeur minimale des boutons
de navigation tient compte des libellés localisés et de leur marge native.
Aux petites hauteurs, boutons et espacements se compactent. Tab suit l'ordre
visuel : par rangée de gauche à droite avant Atlas, puis de haut en bas avec
Atlas. Les libellés FR/EN existants sont réutilisés.
Le changement de disposition et les assertions correspondantes sont isolés
dans le commit `a7bdfe6`, séparé de la livraison et de ses métadonnées. La branche
`codex/story-beta234` conserve la disposition précédente. Un retour s'effectue
avec `git revert a7bdfe6` ; une éventuelle nouvelle
livraison doit prendre le numéro beta suivant, sans diminuer le compteur.
L'application inverse du patch a été vérifiée avec `git apply --reverse --check`,
sans modifier les fichiers de travail.
Ce ticket ne change ni génération, sauvegarde, données communautaires, protocole,
conditions de progression ou contenus Story. Aucune installation personnelle
ou publication n'est demandée.
## Vérifications
```sh
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true -PsanctuaryPause233ClientTests=true -PsanctuaryClientGraphicsBackend=vulkan
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true -PsanctuaryStory234ClientTests=true -PsanctuaryClientGraphicsBackend=vulkan
./gradlew check build assemblePack -PsanctuaryFocusedTests=menus -PsanctuaryAtlasOnly=true
```
Le parcours Pause final réussit en 1 min 22 s (`PAUSE233_PASS`), dans un monde
plat neuf de développement, Survie sans commandes. FR/EN, GUI 2/3/4, fenêtre
1280 × 960, avant et après Atlas : six actions Sanctuary, alignement et absence
de chevauchement, largeur des libellés avec la marge native, ordre de Tab et
Game sous les groupes, avec de l'espace avant le pied. Le test attend désormais
un créneau de requête communautaire pour son amorçage et envoie le code de
raccourci natif de Tab, requis par Minecraft 26.3.
Le parcours communautaire existant passe également : publication et photo,
retours des sélecteurs, brouillons, édition d'une ancienne catégorie, inventaire
réel conservé et aucun changement du fichier communautaire pendant les lectures.
La configuration temporaire est restaurée à la fin du banc.
Log : `build/pause235-client-vulkan-final.log`. 38 captures finales copiées dans
`build/pause235-screenshots/`. Captures inspectées : sans Atlas FR/4, avec Atlas
FR/2 et EN/4. Vulkan confirmé : MoltenVK 1.4.2 sur Apple M1.
Le parcours Story final réussit en 29 s (`STORY234_PASS`), avec les mêmes
langues, GUI et états Atlas. Accès depuis les deux dispositions de Pause,
100 entrées désactivées, recherche/filtres, saisie continue et focus, molette,
redimensionnement à 1344 × 1008 et retours Échap / Terminé vérifiés. Le record
de progression serveur ne change pas. Log :
`build/pause235-story-client-vulkan-final.log` ; 21 captures copiées dans
`build/pause235-story-screenshots/`.
Aux GUI étroits, les longs libellés natifs des options/sortie et les titres des
fils gardent leur défilement ou découpage existant ; les boutons de navigation
Sanctuary montrent leur libellé entier. Les infobulles des actions Game sont
conservées. Aucun essai OpenGL, Windows ou monde personnel n'est revendiqué.
Le contrôle `check build assemblePack` réussit en 3 min 1 s : quatre GameTests
requis et contrôles purs du dépôt, dont les quatre contrôles communautaires
fichier/photos/demandes/recherche. Log : `build/pause235-check-build-pack.log`.
Le banc menus conserve sa sélection Atlas existante ; le conflit de générateur
sur le chemin natif par défaut, documenté en beta.232, reste hors de ce ticket.
## Livraison locale
`mod_version`, `pack_version` et `packwiz/pack.toml` sont alignés sur beta.235.
Le commit de livraison conserve ces métadonnées et cette documentation séparées
du commit de disposition.
Export [Sanctuary-beta.235.mrpack](../build/Sanctuary-beta.235.mrpack),
12 776 222 octets : intégrité ZIP, Minecraft 26.3 et Fabric Loader 0.19.5,
un seul JAR Sanctuary embarqué, identique au JAR construit, sans monde ni module
de test. Les 3 303 fichiers d'assets, données et resourcepacks sont identiques
à beta.234, traductions incluses. Story reste dans le JAR de production et les
classes des bancs client en sont absentes. Reçu :
`build/pause235-mrpack-receipt.json`.
- JAR : `aad94732c749161d775ee634bdd85de70de511ef875c6c00fa167d230d0d600b`.
- MRpack : `5f1bd18aa8a4145b7e708da0abfff22068365847dddbc95ae2b490b88bbb305e`.
Aucune publication distante, modification du canal packwiz, synchronisation
Prism ou ouverture d'un monde personnel effectuée.
+80
View File
@@ -0,0 +1,80 @@
# STORY-234 — recherche et filtres
Branche `codex/story-beta234`, base beta.233, commit `9c096b2`, Minecraft 26.3.
## Contrat
Story apparaît sous Progression dans Aventure, avant et après Atlas. Il reprend
le cadre de Discovery : titre, séparateurs et Terminé / Done. La recherche et
le filtre Toutes / Découvertes / Inconnues remplacent les boutons de catégories.
La liste réserve 100 histoires numérotées et inconnues, toutes désactivées.
Toutes et Inconnues montrent les entrées correspondant à la recherche, dans
l'ordre numérique ; Découvertes indique qu'aucune histoire n'est découverte.
Une recherche sans résultat dispose d'un autre message. La recherche porte sur
le numéro et le libellé visible, ignore la casse et les espaces extérieurs.
Recherche et filtre se combinent et ramènent le défilement en haut. La saisie
garde son focus et son curseur ; redimensionner garde recherche, filtre et
défilement. Terminé et Échap reviennent directement au menu Pause d'origine.
Aventure comprend maintenant Discovery, Progression et Story. Les trois groupes
de Pause conservent leur ordre avec ou sans carte. Aux petites hauteurs, les
boutons et espaces se compactent pour éviter les chevauchements.
[Guide de rédaction](story/README.md) et [modèle Markdown](story/TEMPLATE.md) :
un fichier `001.md` à `100.md`, avec numéro, titre, texte et idée de découverte
facultative. Aucun récit n'est inventé, chargé ou révélé dans ce lot. Leur
intégration et leurs déblocages seront des tickets suivants. Pas de nouveau
paquet réseau, dépendance, sauvegarde ou changement de génération.
## Vérifications
```sh
./gradlew check build assemblePack -PsanctuaryFocusedTests=menus -PsanctuaryAtlasOnly=true
./gradlew :sanctuary:runClientGameTest -PsanctuaryClientTests=true -PsanctuaryStory234ClientTests=true -PsanctuaryClientGraphicsBackend=vulkan
```
Build final réussi en 2 min 34 s : quatre GameTests requis et contrôles purs du
dépôt, dont les contrôles communautaires fichier, photos, demandes et recherche.
Log : `build/story234-check-build-pack-final.log`. Le banc menus utilise la
sélection Atlas existante ; le conflit du générateur sur le chemin par défaut,
documenté en beta.232, n'est pas modifié par ce lot.
Parcours client final réussi en 30 s (`STORY234_PASS`), sur un monde plat neuf
de développement en Survie sans commandes. Vulkan confirmé par le journal :
MoltenVK 1.4.2 sur macOS, Apple M1. Log : `build/story234-client-vulkan-final.log`.
- FR/EN, GUI 2/3/4, fenêtre 1280 × 960, avec et sans Atlas : six actions
Sanctuary dans l'ordre visuel et clavier, trois groupes sans chevauchement,
groupe Discovery/Progression/Story centré verticalement.
- 100 numéros consécutifs sans doublon, entrées désactivées, recherche du numéro
100, espaces extérieurs et recherche en majuscules dans le libellé localisé.
Recherche et filtre combinés, deux états vides distincts.
- Saisie par événements natifs, focus et curseur conservés, molette et accès à
la dernière entrée ; défilement remis en haut après recherche ou filtre.
Redimensionnement réel à 1344 × 1008 : recherche, filtre et défilement conservés.
- Terminé et Échap reviennent à la même Pause. La page reste une pause solo ;
le record de progression serveur est identique avant et après les consultations.
21 captures dans `build/story234-screenshots/`. Captures inspectées : Story FR/2
et EN/4, Pause sans Atlas FR/4 et avec Atlas EN/3, états Découvertes et recherche
vides, recherche de l'histoire 100. L'indication de recherche reste visible
lorsque le champ vide a le focus. Aucun essai OpenGL ou Windows n'est revendiqué.
## Livraison locale
`mod_version`, `pack_version` et `packwiz/pack.toml` sont alignés sur beta.234.
Export [Sanctuary-beta.234.mrpack](../build/Sanctuary-beta.234.mrpack),
12 775 502 octets : intégrité ZIP, Minecraft 26.3, Fabric Loader 0.19.5,
un seul JAR Sanctuary embarqué, identique au JAR construit, sans monde ni
module de test. Le JAR de production inclut Story et aucune classe de son banc
client. Assets et données identiques à beta.233, hors les neuf traductions
ajoutées dans chacune des langues FR/EN ; les libellés existants sont conservés.
Reçu : `build/story234-mrpack-receipt.json`.
- JAR : `25dc161468679379e642f9746761905045d1135b6a300e3bc3f58f37a4c8f9a8`.
- MRpack : `0b16514e4dec16d14061fd25c7cb42d53fdb28c73f06a1c6361d16d059977c69`.
Les trois commits couvrent l'interface, les tests/documentation et la livraison.
Aucune publication distante, modification de canal packwiz, synchronisation
Prism ou ouverture de monde personnel effectuée.
+19
View File
@@ -0,0 +1,19 @@
# Écrire les histoires de Sanctuary
Story accueille des textes personnels sur Sanctuary, son développement, Steve,
Minecraft et ce que signifie habiter un jeu. Les numéros de 1 à 100 sont des
repères durables ; ils n'imposent ni un ordre de lecture ni une chronologie.
Copier [le modèle](TEMPLATE.md) dans un fichier nommé `001.md` à `100.md`, puis
remplacer le titre et écrire librement dans la section Texte. Les paragraphes,
les hésitations et la voix de l'auteur peuvent être conservés tels quels.
La section Idée de découverte est facultative et peut rester vide.
Transmettre les fichiers au fil des séances pour préparer les prochains tickets.
Ne pas renuméroter une histoire déjà intégrée. La traduction éventuelle des récits
se décide avec l'auteur ; les interfaces du jeu disposent de leurs libellés FR/EN.
En beta.234, les 100 histoires sont encore inconnues et désactivées. Ces fichiers
sont des sources de rédaction : le mod ne les charge pas automatiquement.
L'intégration des premiers textes et leurs conditions de découverte restent à
implémenter, avec autorité serveur si elles dépendent de la partie.
+12
View File
@@ -0,0 +1,12 @@
# Titre de l'histoire
Numéro : 001
## Texte
Écrire ici le texte, avec autant de paragraphes que nécessaire.
## Idée de découverte (facultative)
Décrire éventuellement un geste, une rencontre, un lieu ou une autre idée qui
pourrait révéler cette histoire. Cette note est une intention de conception.
+2 -2
View File
@@ -9,8 +9,8 @@ loom_version=1.17.20
fabric_api_version=0.160.5+26.3
# Release counter: beta.001, .002, .003, ... (see docs/versioning.md).
mod_version=beta.233
pack_version=beta.233
mod_version=beta.235
pack_version=beta.235
resource_pack_version=beta.121
maven_group=fr.koka.sanctuary
jei_version=30.32.0-sanctuary.3
+3
View File
@@ -337,6 +337,9 @@ tasks.named('processGametestResources') {
def pause233Client = providers.gradleProperty('sanctuaryPause233ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryPause233ClientTests', pause233Client)
if (pause233Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Pause233ClientChecks') }
def story234Client = providers.gradleProperty('sanctuaryStory234ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryStory234ClientTests', story234Client)
if (story234Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Story234ClientChecks') }
def balance080Client = providers.gradleProperty('sanctuaryBalance080ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryBalance080ClientTests', balance080Client)
if (balance080Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Balance080ClientChecks') }
@@ -1,6 +1,7 @@
package fr.koka.sanctuary.client;
import com.google.gson.Gson;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.community.*;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.progression.*;
@@ -15,6 +16,7 @@ import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.screens.*;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.item.*;
import static fr.koka.sanctuary.client.Community154ClientChecks.*;
@@ -46,7 +48,8 @@ public final class Pause233ClientChecks implements FabricClientGameTest {
var server=world.getServer();
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.getAbilities().invulnerable=true;p.getInventory().setItem(6,new ItemStack(Items.OAK_LOG,17));p.getInventory().setItem(7,new ItemStack(Items.DIAMOND,3));});
String legacy=UUID.randomUUID().toString();
c.runOnClient(m->check(CommunityClient.request(null,"create",legacy,"","notice","event","Ancienne annonce","Texte historique",0,0,r->check(!r.has("error"),"Legacy-compatible notice seeded through the server")),"Seed request dispatched"));
// Wait and dispatch together so the periodic following request cannot take the slot between them.
c.waitFor(m->CommunityClient.request(null,"create",legacy,"","notice","event","Ancienne annonce","Texte historique",0,0,r->check(!r.has("error"),"Legacy-compatible notice seeded through the server")),200);
c.waitFor(m->!CommunityClient.pending(),200);c.waitTicks(24);
c.runOnClient(m->{m.getWindow().setWindowed(1280,960);m.options.guiScale().set(2);m.resizeGui();CommunityClient.open(new PauseScreen(true),"notice","",true);edit(m.gui.screen()).setValue("Du bois pour le pont");body(m.gui.screen()).setValue("Une annonce commune, avec une demande facultative.");composer(m.gui.screen(),"notice");press(m.gui.screen(),"sanctuary.community.task_configure");press(m.gui.screen(),"sanctuary.community.location_current");press(m.gui.screen(),"sanctuary.community.materials");edit(m.gui.screen()).setValue("minecraft:oak_log");});
@@ -86,7 +89,7 @@ public final class Pause233ClientChecks implements FabricClientGameTest {
}
}
server.runOnServer(s->checked(()->{check(Arrays.equals(saved,Files.readAllBytes(s.getWorldPath(net.minecraft.world.level.storage.LevelResource.ROOT).resolve("data/sanctuary-community.json"))),"Reading/resizing old and new posts never rewrites stored categories");return null;}));
System.out.println("PAUSE233_PASS: FR/EN GUI 2/3/4, identity/faction/battle at top, discovery/progression centered, options below, unified notice composer, descriptive task and photo publication, legacy category preservation");
System.out.println("PAUSE233_PASS: FR/EN GUI 2/3/4, parallel Adventure/Community before Atlas, narrow Community/Adventure stack beside Atlas, Game directly below, centered navigation block, visual Tab order, unified notice composer, descriptive task and photo publication, legacy category preservation");
}finally {intro.set(false);checked(()->{if(previous==null)Files.deleteIfExists(config);else Files.write(config,previous);Files.deleteIfExists(fixture);return null;});}
}
private FileCommunityStore store(net.minecraft.server.MinecraftServer s)throws Exception{return new FileCommunityStore(s.getWorldPath(net.minecraft.world.level.storage.LevelResource.ROOT).resolve("data/sanctuary-community.json"),serverId);}
@@ -101,23 +104,44 @@ public final class Pause233ClientChecks implements FabricClientGameTest {
if(kind.equals("notice")){var summary=widget(s,"task_summary");check(extra.getY()>body.getBottom()&&summary.getY()>extra.getBottom()&&publish.getY()>summary.getBottom(),"Text, request configuration, summary and Publish are in order");}
else {check(extra.getBottom()<edit(s).getY()&&publish.getY()>body.getBottom(),"Screenshot selector is before title, then text and Publish");widgets(s).filter(CommunityPhotoWidget.class::isInstance).map(CommunityPhotoWidget.class::cast).forEach(w->check(w.getBottom()<extra.getY(),"Photo preview is first, before selector and title"));}
}
private static void pause(Screen s,boolean unlocked){
static void pause(Screen s,boolean unlocked){
check((((PauseAtlasAccess)s).sanctuary$pauseMap()!=null)==unlocked,"Pause map appears only after Atlas purchase");
var keys=Set.of("sanctuary.progression.blocks","sanctuary.progression.progress","sanctuary.progression.identity","sanctuary.cycle.faction","sanctuary.arena.title");
var nav=s.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->keys.contains(key(b))).toList();check(nav.size()==5,"Five Sanctuary navigation actions");
int top=nav.stream().mapToInt(AbstractWidget::getY).min().orElseThrow(),bottom=nav.stream().mapToInt(AbstractWidget::getBottom).max().orElseThrow();
int options=s.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->key(b).equals("menu.options")).findFirst().orElseThrow().getY();
check(bottom+8<=options,"Navigation stays separated from Options/World Options/Quit");
var keys=Set.of("sanctuary.progression.blocks","sanctuary.progression.progress","sanctuary.story.title","sanctuary.progression.identity","sanctuary.cycle.faction","sanctuary.arena.title");
var nav=s.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->keys.contains(key(b))).toList();check(nav.size()==6,"Six Sanctuary navigation actions");
var options=s.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->key(b).equals("menu.options")).findFirst().orElseThrow();
var worldOptions=s.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->key(b).equals("options.worldOptions.button")).findFirst().orElseThrow();
var quit=s.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->Set.of("menu.returnToMenu","menu.disconnect").contains(key(b))).findFirst().orElseThrow();
var community=widget(s,"pause_group_community");var adventure=widget(s,"pause_group_adventure");var game=widget(s,"pause_group_game");
check(community.getY()==50&&community.getBottom()<nav.get(0).getY()&&adventure.getBottom()<nav.get(3).getY()&&game.getBottom()<options,"All three headings precede their buttons");
check(nav.stream().map(CommunityPausePanels::key).toList().equals(List.of("sanctuary.progression.identity","sanctuary.cycle.faction","sanctuary.arena.title","sanctuary.progression.blocks","sanctuary.progression.progress")),"Same visual and keyboard order with or without the map");
check(nav.get(0).getBottom()<nav.get(1).getY()&&nav.get(1).getBottom()<nav.get(2).getY()&&nav.get(2).getBottom()<adventure.getY()&&nav.get(3).getBottom()<nav.get(4).getY()&&nav.get(4).getBottom()<game.getY(),"Three distinct titled groups without overlap");
double center=(nav.get(3).getY()+nav.get(4).getBottom())/2.;check(Math.abs(center-s.height/2.)<=.5,"Only discovery/progression are vertically centered");
check(nav.stream().map(CommunityPausePanels::key).toList().equals(unlocked
?List.of("sanctuary.progression.identity","sanctuary.cycle.faction","sanctuary.arena.title","sanctuary.progression.blocks","sanctuary.progression.progress","sanctuary.story.title")
:List.of("sanctuary.progression.blocks","sanctuary.progression.identity","sanctuary.progression.progress","sanctuary.cycle.faction","sanctuary.story.title","sanctuary.arena.title")),"Navigation follows visual order in both layouts");
for(var button:nav)check(s.getFont().width(button.getMessage())+8<=button.getWidth(),"Navigation labels fit with native button padding");
check(nav.stream().allMatch(b->b.getBottom()<game.getY())&&game.getBottom()<options.getY()&&options.getBottom()<worldOptions.getY()&&worldOptions.getBottom()<quit.getY(),"Game follows below Sanctuary navigation");
check(options.getX()==nav.get(0).getX()&&worldOptions.getX()==options.getX()&&quit.getX()==options.getX()&&worldOptions.getWidth()==options.getWidth()&&quit.getWidth()==options.getWidth(),"Native Game actions share the navigation block width");
int bodyTop=s.height<280?46:50,bodyBottom=s.height-39;
check(Math.abs((Math.min(adventure.getY(),community.getY())+quit.getBottom())/2.-(bodyTop+bodyBottom)/2.)<=.5&&quit.getBottom()+8<=bodyBottom,"Complete block centered with clear space below Game");
// KeyEvent carries the physical key and the SDL shortcut key separately.
s.setFocused(nav.get(0));s.keyPressed(new KeyEvent(InputConstants.KEY_TAB,'\t',0));check(s.getFocused()==nav.get(1),"Tab follows the second visible action");
s.keyPressed(new KeyEvent(InputConstants.KEY_TAB,'\t',0));check(s.getFocused()==nav.get(2),"Tab follows the third visible action");
if(!unlocked){
for(var b:nav)check(Math.abs(b.getX()+b.getWidth()/2.-s.width/2.)<=.5,"Navigation centered horizontally");
check(widget(s,"article").getX()==8&&widget(s,"notice").getX()>nav.getFirst().getRight(),"Gazette remains left and Notice Board right");
check(adventure.getY()==community.getY()&&adventure.getX()==nav.get(0).getX()&&community.getX()==nav.get(1).getX()&&adventure.getBottom()<nav.get(0).getY(),"Both headings align above their columns");
for(int row=0;row<3;row++) {
var left=nav.get(2*row);var right=nav.get(2*row+1);
check(left.getY()==right.getY()&&left.getHeight()==right.getHeight()&&left.getRight()<right.getX(),"Two parallel columns without overlap");
if(row>0)check(nav.get(2*row-2).getBottom()<left.getY(),"Rows remain separated");
}
check(options.getRight()==nav.get(1).getRight(),"Game spans both columns before Atlas");
check(Math.abs((nav.get(0).getX()+nav.get(1).getRight())/2.-s.width/2.)<=.5,"Two-column block centered horizontally");
check(widget(s,"article").getX()==8&&widget(s,"article").getRight()<nav.get(0).getX()&&widget(s,"notice").getX()>nav.get(1).getRight(),"Gazette remains left and Notice Board right");
}
else {
check(community.getBottom()<nav.get(0).getY()&&nav.get(2).getBottom()<adventure.getY()&&adventure.getBottom()<nav.get(3).getY(),"Community then Adventure stacked above Game with Atlas");
for(int i=0;i<nav.size();i++) {
var button=nav.get(i);check(button.getX()==8&&button.getWidth()==options.getWidth()&&button.getWidth()<=132,"All actions use one narrow left column");
if(i>0)check(nav.get(i-1).getBottom()<button.getY(),"Stacked buttons remain separated");
}
var map=((PauseAtlasAccess)s).sanctuary$pauseMap();check(nav.get(0).getRight()<map.getX()&&map.getRight()<widget(s,"article").getX()&&widget(s,"article").getX()==widget(s,"notice").getX(),"Narrow navigation left, map center and community feeds right");
}
else check(nav.getFirst().getX()==8&&widget(s,"article").getX()==widget(s,"notice").getX(),"Unlocked map retains left navigation and community feeds on the right");
}
private void shot(ClientGameTestContext c,String name){var path=c.takeScreenshot("pause233-"+name);checked(()->{var dir=game.resolve("../../../../../build/pause233-screenshots").normalize();Files.createDirectories(dir);Files.copy(path,dir.resolve(name+".png"),StandardCopyOption.REPLACE_EXISTING);return null;});}
private interface Checked<T>{T run()throws Exception;}
@@ -49,12 +49,13 @@ public final class Solo232ClientChecks implements FabricClientGameTest {
c.runOnClient(m->{m.getWindow().setWindowed(1280,960);m.options.guiScale().set(scale);m.resizeGui();m.getLanguageManager().setSelected(language);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());m.gui.setScreen(new PauseScreen(true));});
c.waitForScreen(PauseScreen.class);c.waitTicks(3);
c.runOnClient(m->{var screen=m.gui.screen();var map=((PauseAtlasAccess)screen).sanctuary$pauseMap();check((map!=null)==unlocked,"Map widget exists only after Atlas purchase");
var buttons=screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->Set.of("sanctuary.progression.blocks","sanctuary.progression.progress","sanctuary.progression.identity","sanctuary.cycle.faction","sanctuary.arena.title","menu.options","options.worldOptions.button","menu.returnToMenu","menu.disconnect").contains(CommunityPausePanels.key(b))).toList();
check(buttons.size()==8,"All eight pause menu actions remain present");
var buttons=screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->Set.of("sanctuary.progression.blocks","sanctuary.progression.progress","sanctuary.story.title","sanctuary.progression.identity","sanctuary.cycle.faction","sanctuary.arena.title","menu.options","options.worldOptions.button","menu.returnToMenu","menu.disconnect").contains(CommunityPausePanels.key(b))).toList();
check(buttons.size()==9,"All nine pause menu actions remain present");
var article=heading(screen,"sanctuary.community.article");var notice=heading(screen,"sanctuary.community.notice");
if(!unlocked){check(article.getX()==8&&article.getY()==50&&notice.getY()==50,"Locked map uses full-height Gazette on the left and Notice Board on the right");for(var button:buttons)check(article.getRight()<button.getX()&&notice.getX()>button.getRight(),"Community columns do not overlap centered navigation");}
if(!unlocked){check(article.getX()==8&&article.getY()==(screen.height<280?46:50)&&notice.getY()==article.getY(),"Locked map uses full-height Gazette on the left and Notice Board on the right");for(var button:buttons)check(article.getRight()<button.getX()&&notice.getX()>button.getRight(),"Community columns do not overlap centered navigation");}
else check(article.getX()==notice.getX()&&article.getY()<notice.getY(),"Unlocked map retains both community feeds stacked on the right");
for(var button:buttons){check(button.getY()>=50&&button.getBottom()<=screen.height-33,"Button stays in pause body");if(!unlocked)check(Math.abs(button.getX()+button.getWidth()/2.-screen.width/2.)<=.5,"Locked-map navigation is horizontally centered");else check(button.getX()==8,"Unlocked layout retains original left column");}
for(var button:buttons)check(button.getY()>=50&&button.getBottom()<=screen.height-33,"Button stays in pause body");
Pause233ClientChecks.pause(screen,unlocked);
});
String shot="solo232-pause-"+(unlocked?"unlocked-":"locked-")+language+"-"+scale;
var image=c.takeScreenshot(shot);checked(()->{var dir=net.fabricmc.loader.api.FabricLoader.getInstance().getGameDir().resolve("../../../../../build/solo232-screenshots").normalize();java.nio.file.Files.createDirectories(dir);java.nio.file.Files.copy(image,dir.resolve(shot+".png"),java.nio.file.StandardCopyOption.REPLACE_EXISTING);});
@@ -0,0 +1,72 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import fr.koka.sanctuary.progression.*;
import java.nio.file.*;
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.fabricmc.loader.api.FabricLoader;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.screens.*;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.CharacterEvent;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.network.chat.contents.TranslatableContents;
import static fr.koka.sanctuary.client.Community154ClientChecks.*;
/** Real native events on Story and Pause, using a fresh non-operator solo world. */
public final class Story234ClientChecks implements FabricClientGameTest {
@Override public void runTest(ClientGameTestContext c) {
var intro=new AtomicBoolean(true);
fr.koka.sanctuary.testing.QuickTestWorld.enable();
c.runOnClient(m->{m.options.renderDistance().set(5);m.options.simulationDistance().set(5);m.options.pauseOnLostFocus=false;});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen screen){
IntroClient.setQuickTestIntro(true);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());
press(screen,"sanctuary.progression.enter");intro.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Story234 "+UUID.randomUUID());s.setSeed("234");s.setAllowCommands(false);s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("minecraft:flat")).findFirst().orElseThrow());}).create()) {
c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);
var server=world.getServer();
server.runOnServer(s->s.getPlayerList().getPlayers().getFirst().getAbilities().invulnerable=true);
for(boolean unlocked:new boolean[]{false,true}) {
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var r=ProgressRecord.INITIAL;if(unlocked)r=r.buy("atlas",r.cost("atlas",ProgressionService.session(s).config));p.setAttached(ProgressionService.RECORD,r.json());ProgressEffects.apply(p,r);ProgressionService.send(p,"");});
c.waitFor(m->ProgressionClient.progress.atlas()==unlocked,200);
String before=server.computeOnServer(s->s.getPlayerList().getPlayers().getFirst().getAttached(ProgressionService.RECORD));
for(String language:List.of("fr_fr","en_us"))for(int scale:List.of(2,3,4)) {
c.runOnClient(m->{m.getWindow().setWindowed(1280,960);m.options.guiScale().set(scale);m.resizeGui();m.getLanguageManager().setSelected(language);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());m.gui.setScreen(new PauseScreen(true));});
c.waitTicks(3);
c.runOnClient(m->Pause233ClientChecks.pause(m.gui.screen(),unlocked));
shot(c,"pause-"+unlocked+"-"+language+"-"+scale);
c.runOnClient(m->{var pause=m.gui.screen();press(pause,"sanctuary.story.title");check(m.gui.screen() instanceof StoryScreen,"Story opens from Pause without Atlas gating");check(m.gui.screen().isPauseScreen(),"Story retains solo pause");check(m.gui.screen().getTitle().getString().equals("Story"),"Story has its own accessible screen title");check(edit(m.gui.screen()).getValue().isEmpty()&&cycle(m.gui.screen()).getValue().toString().equals("ALL"),"Empty search and All on opening");assertEntries(m.gui.screen(),100);layout(m.gui.screen());});
shot(c,"story-"+language+"-"+scale);
c.runOnClient(m->{var s=m.gui.screen();var search=edit(s);s.setFocused(search);for(int codepoint:"100".codePoints().toArray()){check(s.charTyped(new CharacterEvent(codepoint)),"Native typing handled");check(s.getFocused()==search&&search.isFocused(),"List refresh retains search focus");}check(search.getValue().equals("100")&&search.getCursorPosition()==3,"Continuous typing retains cursor");assertEntries(s,1);check(entries(s).getFirst().getMessage().getString().equals(StoryScreen.label("unknown_entry",100).getString()),"Search selects story 100");search.setValue(" 100 ");assertEntries(s,1);cycle(s).onPress(enter());check(has(s,"sanctuary.story.no_discoveries")&&!has(s,"sanctuary.story.no_results")&&entries(s).isEmpty(),"Discovered has a distinct empty state");cycle(s).onPress(enter());check(cycle(s).getValue().toString().equals("UNKNOWN"),"Third filter is Unknown");assertEntries(s,1);search.setValue("absent-story");check(has(s,"sanctuary.story.no_results")&&!has(s,"sanctuary.story.no_discoveries"),"Search empty state is distinct");search.setValue(language.equals("fr_fr")?"INCONNUE":"UNKNOWN");assertEntries(s,100);var area=area(s);check(s.mouseScrolled(area.getX()+5,area.getY()+5,0,-5)&&area.scrollAmount()>0,"Native wheel scrolls Story");area.setScrollAmount(area.maxScrollAmount());check(entries(s).getLast().getBottom()<=area.getBottom(),"Story 100 can be reached at the end");area.setScrollAmount(100);});
c.runOnClient(m->{m.getWindow().setWindowed(1344,1008);m.resizeGui();});c.waitTicks(3);
c.runOnClient(m->{var s=m.gui.screen();check(edit(s).getValue().equals(language.equals("fr_fr")?"INCONNUE":"UNKNOWN")&&cycle(s).getValue().toString().equals("UNKNOWN"),"Resize retains query and filter");check(area(s).scrollAmount()==100,"Resize retains scroll position");layout(s);edit(s).setValue("100");check(area(s).scrollAmount()==0,"Search resets scroll");edit(s).setValue("");area(s).setScrollAmount(100);cycle(s).onPress(enter());check(cycle(s).getValue().toString().equals("ALL")&&area(s).scrollAmount()==0,"Filter resets scroll and cycles back to All");assertEntries(s,100);});
if(!unlocked&&language.equals("fr_fr")&&scale==2) {
c.runOnClient(m->cycle(m.gui.screen()).onPress(enter()));shot(c,"empty-discovered");
c.runOnClient(m->{cycle(m.gui.screen()).onPress(enter());edit(m.gui.screen()).setValue("absent-story");});shot(c,"empty-search");
c.runOnClient(m->edit(m.gui.screen()).setValue("100"));shot(c,"search-100");
}
c.runOnClient(m->{var story=(StoryScreen)m.gui.screen();var pause=story.parent;story.keyPressed(new KeyEvent(InputConstants.KEY_ESCAPE,0,0));check(m.gui.screen()==pause,"Escape returns directly to original Pause");press(pause,"sanctuary.story.title");press(m.gui.screen(),"gui.done");check(m.gui.screen()==pause,"Done returns directly to original Pause");m.gui.setScreen(null);});
c.waitTicks(2);
}
server.runOnServer(s->check(before.equals(s.getPlayerList().getPlayers().getFirst().getAttached(ProgressionService.RECORD)),"Reading and searching Story leave authoritative progression unchanged"));
}
System.out.println("STORY234_PASS: Vulkan FR/EN GUI 2/3/4, Atlas gating, 100 disabled entries, live search, three filters, distinct empty states, typing focus, wheel, resize, Escape/Done, unchanged progression");
c.runOnClient(m->m.gui.setScreen(null));
}finally{intro.set(false);}
}
private static List<Button> entries(Screen s){return widgets(s).filter(Button.class::isInstance).map(Button.class::cast).filter(b->key(b).equals("sanctuary.story.unknown_entry")).toList();}
private static String key(AbstractWidget w){return w.getMessage().getContents() instanceof TranslatableContents t?t.getKey():"";}
private static CycleButton<?> cycle(Screen s){return widgets(s).filter(CycleButton.class::isInstance).map(CycleButton.class::cast).findFirst().orElseThrow();}
private static AbstractScrollArea area(Screen s){return widgets(s).filter(AbstractScrollArea.class::isInstance).map(AbstractScrollArea.class::cast).findFirst().orElseThrow();}
private static void assertEntries(Screen s,int expected){var rows=entries(s);check(rows.size()==expected,"Expected "+expected+" entries, got "+rows.size());check(rows.stream().allMatch(b->!b.active),"Unknown entries stay disabled");if(expected==100)for(int i=0;i<100;i++)check(rows.get(i).getMessage().getString().equals(StoryScreen.label("unknown_entry",i+1).getString()),"Numbers stay ordered without gaps or duplicates");}
private static void layout(Screen s){var search=edit(s);var filter=cycle(s);var list=area(s);check(search.getBottom()<filter.getY()&&filter.getBottom()<list.getY(),"Search, filter and list do not overlap");for(var child:s.children())if(child instanceof AbstractWidget w)check(w.getX()>=0&&w.getRight()<=s.width&&w.getY()>=0&&w.getBottom()<=s.height,"Top-level widget stays inside the screen");var done=widgets(s).filter(Button.class::isInstance).map(Button.class::cast).filter(b->key(b).equals("gui.done")).findFirst().orElseThrow();check(list.getBottom()<done.getY(),"List leaves room for native footer");}
private static void shot(ClientGameTestContext c,String name){var source=c.takeScreenshot("story234-"+name);try{var dir=FabricLoader.getInstance().getGameDir().resolve("../../../../../build/story234-screenshots").normalize();Files.createDirectories(dir);Files.copy(source,dir.resolve(name+".png"),StandardCopyOption.REPLACE_EXISTING);}catch(Exception e){throw new AssertionError(e);}}
}
@@ -17,7 +17,11 @@ abstract class BlocodexFrame extends Screen {
private HeaderAndFooterLayout frame;
protected BlocodexFrame(Screen parent) {
super(ProgressionService.label("title"));
this(parent,ProgressionService.label("title"));
}
protected BlocodexFrame(Screen parent,net.minecraft.network.chat.Component pageTitle) {
super(pageTitle);
this.parent=parent;
}
@@ -23,35 +23,45 @@ public final class CommunityPausePanels implements AutoCloseable {
private boolean closed;
public CommunityPausePanels(Screen screen,Consumer<AbstractWidget> add,List<Button> nativeButtons) {
this.screen=screen;
int margin=8,gap=10,top=50,bottom=screen.height-39;
int left=Math.min(132,Math.max(76,screen.width/5)),right=Math.min(244,Math.max(104,screen.width*31/100));
int centerX=margin+left+gap,rightX=screen.width-margin-right,centerWidth=rightX-gap-centerX;
boolean compact=screen.height<280;
boolean unlocked=ProgressionClient.progress.atlas();
int menuWidth=unlocked?left:Math.max(left,Math.min(200,2*(rightX-gap)-screen.width));
int margin=8,gap=10,top=compact?46:50,bottom=screen.height-39;
String[] sections={"blocks","identity","progress","faction","story","battle"};
int columnGap=compact?4:8;
int minimumWidth=Arrays.stream(sections).map(CommunityPausePanels::sectionLabel).mapToInt(Minecraft.getInstance().font::width).max().orElse(0)+8;
int columnWidth=Math.max(minimumWidth,(Math.min(264,screen.width/3)-columnGap)/2);
int menuWidth=unlocked?Math.max(minimumWidth,Math.min(132,Math.max(76,screen.width/5))):2*columnWidth+columnGap;
int right=unlocked?Math.min(244,Math.max(104,screen.width*31/100))
:Math.min(244,(screen.width-2*margin-menuWidth-2*gap)/2);
int centerX=margin+menuWidth+gap,rightX=screen.width-margin-right,centerWidth=rightX-gap-centerX;
int menuX=unlocked?margin:(screen.width-menuWidth)/2;
int optionHeight=screen.height<280?16:20,navGap=screen.height<280?1:4,groupGap=screen.height<280?3:12,titleHeight=screen.height<280?10:12;
int optionsTop=bottom-(3*optionHeight+8);
int navHeight=Math.min(20,Math.max(11,(optionsTop-top-8-4*navGap-groupGap)/5));
// Leave room for the upper group and its headings above the centered pair.
navHeight=Math.min(navHeight,Math.max(11,(screen.height-2*top-4*titleHeight-5*navGap-2*groupGap)/8));
int communityX=unlocked?menuX:menuX+columnWidth+columnGap;
int navGap=compact?(unlocked?1:2):4,groupGap=compact?(unlocked?2:8):12,titleHeight=compact&&unlocked?10:12;
int groupCount=unlocked?3:2;
int navHeight=Math.min(compact?14:20,Math.max(11,(bottom-top-16-groupCount*titleHeight-(groupCount-1)*groupGap-2*groupCount*navGap)/(3*groupCount)));
int step=navHeight+navGap;
int upperTop=top+titleHeight;
int pairSize=2*navHeight+navGap;
int pairTop=Math.max(upperTop+2*step+navHeight+groupGap+titleHeight,Math.min((screen.height-pairSize)/2,optionsTop-titleHeight-pairSize-groupGap));
int sectionHeight=titleHeight+3*navHeight+2*navGap;
int menuHeight=groupCount*sectionHeight+(groupCount-1)*groupGap;
// Use a narrow stack beside the map, or parallel groups before Atlas.
int menuTop=top+Math.max(0,(bottom-top-menuHeight)/2);
int adventureTop=unlocked?menuTop+sectionHeight+groupGap:menuTop;
int communityTop=menuTop;
int gameTop=menuTop+(groupCount-1)*(sectionHeight+groupGap);
int optionsTop=gameTop+titleHeight;
add.accept(new Frame(screen.width,screen.height));
date=new Calendar(margin,3,left);
date=new Calendar(margin,3,Math.min(132,Math.max(76,screen.width/5)));
add.accept(date);
bulletin=new PauseTextButton(centerX,3,screen.width-margin-centerX,38,text("bulletin"),()->open(screen,"bulletin",items("bulletins").isEmpty()?"":str(items("bulletins").get(0).getAsJsonObject(),"id"),false));
add.accept(bulletin);
add.accept(new StringWidget(menuX,top,menuWidth,9,text("pause_group_community").withStyle(net.minecraft.ChatFormatting.GRAY),Minecraft.getInstance().font));
add.accept(new StringWidget(menuX,pairTop-titleHeight,menuWidth,9,text("pause_group_adventure").withStyle(net.minecraft.ChatFormatting.GRAY),Minecraft.getInstance().font));
add.accept(new StringWidget(menuX,optionsTop-titleHeight,menuWidth,9,text("pause_group_game").withStyle(net.minecraft.ChatFormatting.GRAY),Minecraft.getInstance().font));
String[] sections={"identity","faction","battle","blocks","progress"};
add.accept(new StringWidget(menuX,adventureTop,unlocked?menuWidth:columnWidth,9,text("pause_group_adventure").withStyle(net.minecraft.ChatFormatting.GRAY),Minecraft.getInstance().font));
add.accept(new StringWidget(communityX,communityTop,unlocked?menuWidth:columnWidth,9,text("pause_group_community").withStyle(net.minecraft.ChatFormatting.GRAY),Minecraft.getInstance().font));
add.accept(new StringWidget(menuX,gameTop,menuWidth,9,text("pause_group_game").withStyle(net.minecraft.ChatFormatting.GRAY),Minecraft.getInstance().font));
if(unlocked)sections=new String[]{"identity","faction","battle","blocks","progress","story"};
for(String target:sections) {
int y=switch(target){case "blocks"->pairTop;case "progress"->pairTop+step;case "identity"->upperTop;case "faction"->upperTop+step;default->upperTop+2*step;};
Component label=switch(target){case "faction"->fr.koka.sanctuary.cycle.CycleService.label("faction");case "battle"->fr.koka.sanctuary.arena.Arenas.text("title");default->fr.koka.sanctuary.progression.ProgressionService.label(target);};
add.accept(Button.builder(label,b->{switch(target){case "faction"->CycleClient.open(screen,"faction");case "battle"->ArenaClient.open(screen);default->ProgressionClient.open(screen,target);}})
.bounds(menuX,y,menuWidth,navHeight).build());
boolean adventure=target.equals("blocks")||target.equals("progress")||target.equals("story");
int y=(adventure?adventureTop:communityTop)+titleHeight+step*switch(target){case "blocks","identity"->0;case "progress","faction"->1;default->2;};
add.accept(Button.builder(sectionLabel(target),b->{switch(target){case "faction"->CycleClient.open(screen,"faction");case "battle"->ArenaClient.open(screen);default->ProgressionClient.open(screen,target);}})
.bounds(adventure?menuX:communityX,y,unlocked?menuWidth:columnWidth,navHeight).build());
}
map=unlocked?new PauseAtlasWidget(screen,centerX,top,centerWidth,Math.max(30,bottom-top)):null;
if(map!=null)add.accept(map);
@@ -60,13 +70,16 @@ public final class CommunityPausePanels implements AutoCloseable {
int feedHeight=(bottom-top-gap)/2;
articles=new PauseCommunityFeed(screen,add,unlocked?rightX:margin,top,right,unlocked?feedHeight:bottom-top,"article");
notices=new PauseCommunityFeed(screen,add,rightX,unlocked?top+feedHeight+gap:top,right,unlocked?bottom-top-feedHeight-gap:bottom-top,"notice");
place(nativeButtons,add,"menu.options",menuX,optionsTop,menuWidth,optionHeight);
place(nativeButtons,add,"options.worldOptions.button",menuX,optionsTop+optionHeight+4,menuWidth,optionHeight);
place(nativeButtons,add,"menu.options",menuX,optionsTop,menuWidth,navHeight);
place(nativeButtons,add,"options.worldOptions.button",menuX,optionsTop+step,menuWidth,navHeight);
for(var button:nativeButtons) if(key(button).equals("menu.returnToMenu") || key(button).equals("menu.disconnect")) {
button.setRectangle(menuWidth,optionHeight,menuX,optionsTop+2*(optionHeight+4));button.setTooltip(Tooltip.create(button.getMessage()));add.accept(button);
button.setRectangle(menuWidth,navHeight,menuX,optionsTop+2*step);button.setTooltip(Tooltip.create(button.getMessage()));add.accept(button);
}
updateHeader();
}
private static Component sectionLabel(String target) {
return switch(target){case "story"->StoryScreen.label("title");case "faction"->fr.koka.sanctuary.cycle.CycleService.label("faction");case "battle"->fr.koka.sanctuary.arena.Arenas.text("title");default->fr.koka.sanctuary.progression.ProgressionService.label(target);};
}
public static String key(Button button) { return button.getMessage().getContents() instanceof TranslatableContents t?t.getKey():""; }
private static void place(List<Button> buttons,Consumer<AbstractWidget> add,String key,int x,int y,int width) {
place(buttons,add,key,x,y,width,20);
@@ -30,6 +30,7 @@ public final class ProgressionClient {
public static void open(Screen parent,String section) {
var gui=Minecraft.getInstance().gui;
if (section.equals("blocks")) gui.setScreen(new SanctuaryBlocodexScreen(parent));
else if (section.equals("story") && active()) gui.setScreen(new StoryScreen(parent));
else if (active()) gui.setScreen(section.equals("history") ? new SanctuaryAtlasScreen(parent) : new ProgressionScreen(parent,section));
}
public static void register() {
@@ -0,0 +1,64 @@
package fr.koka.sanctuary.client;
import java.util.Locale;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.layouts.LinearLayout;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.network.chat.Component;
/** The first Story catalogue reserves 100 unknown stories without inventing discoveries. */
public final class StoryScreen extends BlocodexFrame {
private enum Filter {
ALL, DISCOVERED, UNKNOWN;
Component label() { return StoryScreen.label("filter_"+name().toLowerCase(Locale.ROOT)); }
}
private String query="";
private Filter filter=Filter.ALL;
private ScrollableLayout scroll;
public StoryScreen(Screen parent) { super(parent,label("title")); }
public static Component label(String key,Object... args) {
return Component.translatable("sanctuary.story."+key,args);
}
@Override protected void init() {
initFrame("story",label("title"));
var search=addRenderableWidget(new EditBox(font,contentX,contentTop,contentWidth,20,label("search")));
search.setMaxLength(128);
search.setValue(query);
// Native hints disappear on focus; a suggestion keeps the empty search identifiable.
search.setSuggestion(query.isEmpty()?label("search").getString():null);
search.setResponder(value->{query=value;search.setSuggestion(value.isEmpty()?label("search").getString():null);refreshList(0);});
addRenderableWidget(CycleButton.builder(Filter::label,filter).withValues(Filter.values())
.create(contentX,contentTop+24,contentWidth,20,label("filter"),(button,value)->{
filter=value;refreshList(0);
}));
contentTop+=50;
refreshList(scroll==null?0:scroll.getScrollAmount());
setInitialFocus(search);
}
/** Replace only list widgets: search/cursor and keyboard focus remain untouched. */
private void refreshList(double offset) {
if(scroll!=null)scroll.visitWidgets(this::removeWidget);
var contents=LinearLayout.vertical().spacing(4);
String needle=query.strip().toLowerCase(Locale.ROOT);
int count=0;
if(filter!=Filter.DISCOVERED)for(int number=1;number<=100;number++) {
Component name=label("unknown_entry",number);
if(!name.getString().toLowerCase(Locale.ROOT).contains(needle))continue;
var entry=contents.addChild(Button.builder(name,button->{}).width(contentWidth-18).build());
entry.active=false;
count++;
}
if(count==0)contents.addChild(new MultiLineTextWidget(label(filter==Filter.DISCOVERED?"no_discoveries":"no_results"),font)
.setMaxWidth(contentWidth-18));
int height=Math.max(20,contentBottom-contentTop);
scroll=new ScrollableLayout(minecraft,contents,height,ScrollableLayout.ReserveStrategy.RIGHT);
scroll.setMinHeight(height);
scroll.arrangeElements();scroll.setX(contentX);scroll.setY(contentTop);
scroll.visitWidgets(widget->{addRenderableWidget(widget);if(widget instanceof AbstractScrollArea area)area.setScrollAmount(offset);});
}
}
@@ -1,4 +1,13 @@
{
"sanctuary.story.title": "Story",
"sanctuary.story.search": "Search stories…",
"sanctuary.story.filter": "Discovery",
"sanctuary.story.filter_all": "All",
"sanctuary.story.filter_discovered": "Discovered",
"sanctuary.story.filter_unknown": "Unknown",
"sanctuary.story.unknown_entry": "Story %s — Unknown",
"sanctuary.story.no_discoveries": "No stories discovered yet.",
"sanctuary.story.no_results": "No stories match the search.",
"biome.sanctuary.diagonal_lush227": "Mangrove Lush Caves",
"biome.sanctuary.diagonal_dripstone227": "Mangrove Dripstone Caves",
"biome.sanctuary.diagonal_swamp226": "Cool Swamp",
@@ -1,4 +1,13 @@
{
"sanctuary.story.title": "Story",
"sanctuary.story.search": "Rechercher une histoire…",
"sanctuary.story.filter": "Découverte",
"sanctuary.story.filter_all": "Toutes",
"sanctuary.story.filter_discovered": "Découvertes",
"sanctuary.story.filter_unknown": "Inconnues",
"sanctuary.story.unknown_entry": "Histoire %s — Inconnue",
"sanctuary.story.no_discoveries": "Aucune histoire découverte pour le moment.",
"sanctuary.story.no_results": "Aucune histoire ne correspond à la recherche.",
"biome.sanctuary.diagonal_lush227": "Grottes luxuriantes des mangroves",
"biome.sanctuary.diagonal_dripstone227": "Grottes à concrétions des mangroves",
"biome.sanctuary.diagonal_swamp226": "Marais frais",
+1 -1
View File
@@ -1,6 +1,6 @@
name = "Sanctuary"
author = "KOKA99CAB"
version = "beta.233"
version = "beta.235"
pack-format = "packwiz:1.1.0"
[index]