Add configurable M shortcut for Atlas from game and Pause

This commit is contained in:
koka
2026-10-06 22:22:30 +02:00
parent 5ea7950f23
commit 94fefc0442
6 changed files with 96 additions and 0 deletions
@@ -10,6 +10,7 @@ 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.KeyMapping;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.screens.*;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
@@ -42,6 +43,7 @@ public final class Story234ClientChecks implements FabricClientGameTest {
});
c.waitFor(m->ProgressionClient.progress.atlas()==unlocked,200);
String before=server.computeOnServer(s->s.getPlayerList().getPlayers().getFirst().getAttached(ProgressionService.RECORD));
shortcut(c,unlocked);
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);
@@ -62,6 +64,10 @@ public final class Story234ClientChecks implements FabricClientGameTest {
check(pause.mouseClicked(new net.minecraft.client.input.MouseButtonEvent(story.getX()+story.getWidth()/2.,story.getY()+story.getHeight()/2.,new net.minecraft.client.input.MouseButtonInfo(InputConstants.MOUSE_BUTTON_LEFT,0)),false),"Native Story click handled above the map");
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);
s.keyPressed(new KeyEvent(mapKey().getDefaultKey().getValue(),InputConstants.KEYCODE_M,0));
check(s.charTyped(new CharacterEvent('m'))&&m.gui.screen()==s&&search.getValue().equals("m")&&search.isFocused(),"Typing M in Story keeps its screen, text and focus");
check(net.minecraft.network.chat.Component.translatable("sanctuary.atlas.key").getString().equals(language.equals("fr_fr")?"Ouvrir la carte":"Open world map"),"Map shortcut has a localized Controls label");search.setValue("");});
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);});
@@ -80,6 +86,35 @@ public final class Story234ClientChecks implements FabricClientGameTest {
}finally{intro.set(false);}
}
private static KeyMapping mapKey(){return KeyMapping.get("sanctuary.atlas.key");}
private static void shortcut(ClientGameTestContext c,boolean unlocked) {
var saved=c.computeOnClient(m->InputConstants.getKey(mapKey().saveString()));
int scan=c.computeOnClient(m->{var key=mapKey();check(key!=null,"Map is registered in native Controls");
int nativeScan=org.lwjgl.sdl.SDLKeyboard.SDL_GetScancodeFromKey(InputConstants.KEYCODE_M,(java.nio.ShortBuffer)null);
check(nativeScan>0&&key.getDefaultKey().getValue()==nativeScan,"Default follows the keyboard layout's M letter");
key.setKey(key.getDefaultKey());KeyMapping.resetMapping();m.gui.setScreen(null);return nativeScan;});
try {
c.getInput().pressKey(scan);c.waitTicks(5);
c.runOnClient(m->{check(unlocked?m.gui.screen() instanceof SanctuaryAtlasScreen:m.gui.screen()==null,"Game M respects Atlas unlock");});
if(unlocked){c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitTicks(3);c.runOnClient(m->check(m.gui.screen()==null,"Atlas opened from game returns to game"));}
var pause=c.computeOnClient(m->{var screen=new PauseScreen(true);m.gui.setScreen(screen);return screen;});
c.getInput().pressKey(scan);c.waitTicks(3);
c.runOnClient(m->{check(unlocked?m.gui.screen() instanceof SanctuaryAtlasScreen:m.gui.screen()==pause,"Pause M respects Atlas unlock");
if(unlocked){press(m.gui.screen(),"gui.done");check(m.gui.screen()==pause,"M from Pause keeps original parent");}m.gui.setScreen(null);});
if(unlocked) {
c.runOnClient(m->{mapKey().setKey(InputConstants.Type.KEYBOARD.getOrCreate(InputConstants.KEY_J));KeyMapping.resetMapping();});
c.getInput().pressKey(scan);c.waitTicks(3);c.runOnClient(m->check(m.gui.screen()==null,"Old M binding stops opening the map"));
c.getInput().pressKey(InputConstants.KEY_J);c.waitTicks(3);c.runOnClient(m->check(m.gui.screen() instanceof SanctuaryAtlasScreen,"Remapped J opens map from game"));
c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitTicks(3);
c.runOnClient(m->m.gui.setScreen(pause));c.getInput().pressKey(scan);c.waitTicks(3);c.runOnClient(m->check(m.gui.screen()==pause,"Pause respects removed M binding"));
c.getInput().pressKey(InputConstants.KEY_J);c.waitTicks(3);c.runOnClient(m->check(m.gui.screen() instanceof SanctuaryAtlasScreen,"Pause respects remapped J"));
c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitTicks(3);c.runOnClient(m->{check(m.gui.screen()==pause,"Remapped shortcut retains Pause parent");mapKey().setKey(InputConstants.UNKNOWN);KeyMapping.resetMapping();});
c.getInput().pressKey(scan);c.getInput().pressKey(InputConstants.KEY_J);c.waitTicks(3);c.runOnClient(m->check(m.gui.screen()==pause,"Unbound disables map shortcut"));
}
System.out.println("ATLAS236_SHORTCUT_PASS: native layout-aware M, game/Pause returns, unlock="+unlocked+", configurable key and Unbound, binding restored");
}finally{c.getInput().releaseKey(scan);c.getInput().releaseKey(InputConstants.KEY_J);c.runOnClient(m->{m.gui.setScreen(null);mapKey().setKey(saved);KeyMapping.resetMapping();});}
}
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();}
@@ -1,11 +1,20 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.atlas.AtlasPayloads;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.keymapping.v1.KeyMappingHelper;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
import net.minecraft.client.KeyMapping;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.input.KeyEvent;
/** No cross-server atlas cache: responses belong only to the currently open screen/token. */
public final class AtlasClient {
private static int sequence;
private static KeyMapping mapKey;
private AtlasClient() { }
public static void register() {
ClientPlayNetworking.registerGlobalReceiver(fr.koka.sanctuary.atlas.AtlasMarkerPayload.TYPE,(p,c) -> c.client().execute(() -> {
@@ -19,6 +28,49 @@ public final class AtlasClient {
else if (c.client().gui.screen() instanceof PauseAtlasAccess pause && pause.sanctuary$pauseMap()!=null) pause.sanctuary$pauseMap().accept(p);
}));
}
public static void registerShortcut(KeyMapping.Category category) {
var shortcut=new MapKey(category);
mapKey=KeyMappingHelper.registerKeyMapping(shortcut);
ClientLifecycleEvents.CLIENT_STARTED.register(client->shortcut.resolveLayout());
ClientTickEvents.END_CLIENT_TICK.register(client->{
while(mapKey.consumeClick())if(client.gui.screen()==null)openFromShortcut(null);
});
}
public static boolean openFromKey(Screen parent,KeyEvent event) {
return mapKey!=null && mapKey.matches(event) && openFromShortcut(parent);
}
private static boolean openFromShortcut(Screen parent) {
var client=Minecraft.getInstance();
if(client.player==null || client.level==null || !client.player.isAlive() || !ProgressionClient.active())return false;
if(!ProgressionClient.progress.atlas())client.player.sendOverlayMessage(SanctuaryAtlasScreen.label("locked"));
else ProgressionClient.open(parent,"history");
return true;
}
private static final class MapKey extends KeyMapping {
private InputConstants.Key layoutDefault;
private final boolean savedBinding;
MapKey(Category category) {
super("sanctuary.atlas.key",InputConstants.Type.KEYBOARD,InputConstants.KEY_M,category);
layoutDefault=super.getDefaultKey();
// An explicit saved binding, including Unbound, takes precedence over the layout default.
var options=net.fabricmc.loader.api.FabricLoader.getInstance().getGameDir().resolve("options.txt");
boolean saved=false;
if(java.nio.file.Files.exists(options))try(var lines=java.nio.file.Files.lines(options)) {
saved=lines.anyMatch(line->line.startsWith("key_"+getName()+":"));
}catch(java.io.IOException error){saved=true;}
savedBinding=saved;
}
void resolveLayout() {
// Resolve the letter M after native startup: KEY_M is only its QWERTY position.
boolean useDefault=!savedBinding && isDefault();
int scan=org.lwjgl.sdl.SDLKeyboard.SDL_GetScancodeFromKey(InputConstants.KEYCODE_M,(java.nio.ShortBuffer)null);
if(scan>0)layoutDefault=InputConstants.Type.KEYBOARD.getOrCreate(scan);
if(useDefault)setKey(layoutDefault);
KeyMapping.resetMapping();
}
@Override public InputConstants.Key getDefaultKey(){return layoutDefault;}
@Override public boolean isDefault(){return key.equals(layoutDefault);}
}
public static int nextToken() {sequence=sequence==Integer.MAX_VALUE ? 1 : sequence+1;return sequence;}
public static void send(AtlasPayloads.Request request) {
if (ClientPlayNetworking.canSend(AtlasPayloads.Request.TYPE)) ClientPlayNetworking.send(request);
@@ -53,6 +53,7 @@ public final class SanctuaryKnowledgeClient implements ClientModInitializer {
GraveInventoryScreen.register();
TutorialClient.register();
var category=KeyMapping.Category.register(SanctuaryMod.id("sanctuary"));
AtlasClient.registerShortcut(category);
DynamicLightClient.register(category);
ZoomClient.register(category);
LyingClient.register(category);
@@ -1,6 +1,7 @@
package fr.koka.sanctuary.mixin.client;
import fr.koka.sanctuary.client.ProgressionClient;
import fr.koka.sanctuary.client.AtlasClient;
import fr.koka.sanctuary.client.PauseAtlasAccess;
import fr.koka.sanctuary.client.PauseAtlasWidget;
import net.minecraft.client.gui.components.Button;
@@ -8,6 +9,7 @@ import fr.koka.sanctuary.client.CommunityPausePanels;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.gui.screens.PauseScreen;
import net.minecraft.network.chat.Component;
import net.minecraft.client.input.KeyEvent;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Unique;
import org.spongepowered.asm.mixin.injection.At;
@@ -30,6 +32,10 @@ public abstract class CommunityPauseMixin extends Screen implements PauseAtlasAc
}
@Inject(method="tick",at=@At("TAIL"))
private void sanctuary$communityTick(CallbackInfo callback) { if(sanctuary$community!=null)sanctuary$community.tick(); }
@Override public boolean keyPressed(KeyEvent event) {
if(sanctuary$community!=null && AtlasClient.openFromKey(this,event))return true;
return super.keyPressed(event);
}
@Override public PauseAtlasWidget sanctuary$pauseMap() {return sanctuary$community==null?null:sanctuary$community.map();}
@Inject(method="removed",at=@At("HEAD"))
private void sanctuary$communityRemoved(CallbackInfo callback) {if(sanctuary$community!=null)sanctuary$community.close();}
@@ -2452,6 +2452,7 @@
"sanctuary.community.actions_for": "Actions · %s",
"sanctuary.community.reply_hint": "Write a reply…",
"sanctuary.pause.map": "World map",
"sanctuary.atlas.key": "Open world map",
"sanctuary.pause.map_hint": "Open the map and explore",
"sanctuary.community.more": "Load more",
"sanctuary.community.browse": "All publications",
@@ -2452,6 +2452,7 @@
"sanctuary.community.actions_for": "Actions · %s",
"sanctuary.community.reply_hint": "Écrire une réponse…",
"sanctuary.pause.map": "Carte du monde",
"sanctuary.atlas.key": "Ouvrir la carte",
"sanctuary.pause.map_hint": "Ouvrir la carte et explorer",
"sanctuary.community.more": "Charger la suite",
"sanctuary.community.browse": "Toutes les publications",