Release v0.1.4 with terrain rescanning and owner-published map updates
All checks were successful
Deploy MineGeo to Dokploy / deploy (push) Successful in 6m26s
All checks were successful
Deploy MineGeo to Dokploy / deploy (push) Successful in 6m26s
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
allprojects { group = 'dev.minegeo'; version = '0.1.3'; repositories { mavenCentral() } }
|
||||
allprojects { group = 'dev.minegeo'; version = '0.1.4'; repositories { mavenCentral() } }
|
||||
if (System.getenv('MINEGEO_JAVA_BUILD_ROOT')) {
|
||||
subprojects { layout.buildDirectory = new File(System.getenv('MINEGEO_JAVA_BUILD_ROOT'), project.name) }
|
||||
}
|
||||
|
||||
@@ -8,13 +8,15 @@ public final class CaptureScreen extends Screen {
|
||||
private EditBox name,target;
|
||||
public CaptureScreen(){super(Component.literal("MineGeo Capture"));}
|
||||
@Override protected void init(){var mod=MineGeoClient.INSTANCE;int x=width/2-130;
|
||||
addRenderableWidget(Button.builder(Component.literal("Log in"),b->net.minecraft.util.Util.getPlatform().openUri(MineGeoClient.website+"/log-in")).bounds(x+180,42,80,20).build());
|
||||
name=new EditBox(font,x,92,260,20,Component.literal("Map name"));name.setMaxLength(80);name.setValue(mod.dataset==null?"My Minecraft map":mod.dataset.name);addRenderableWidget(name);
|
||||
target=new EditBox(font,x,126,260,20,Component.literal("Add to map ID (optional)"));target.setMaxLength(36);target.setValue(mod.targetMap);addRenderableWidget(target);
|
||||
addRenderableWidget(Button.builder(Component.literal("Capture panorama"),b->{mod.targetMap=target.getValue().trim();mod.capture();}).bounds(x,154,126,20).build());
|
||||
addRenderableWidget(Button.builder(Component.literal("Preview files"),b->{if(mod.dataset!=null)net.minecraft.util.Util.getPlatform().openPath(mod.dataset.directory);}).bounds(x+134,154,126,20).build());
|
||||
addRenderableWidget(Button.builder(Component.literal("Link account"),b->{mod.targetMap=target.getValue().trim();mod.pair();}).bounds(x,182,126,20).build());
|
||||
addRenderableWidget(Button.builder(Component.literal("Upload / resume"),b->{mod.targetMap=target.getValue().trim();mod.upload(name.getValue());}).bounds(x+134,182,126,20).build());
|
||||
addRenderableWidget(Button.builder(Component.literal("New map at this location"),b->{mod.newMap();resize(width,height);}).bounds(x,210,260,20).build());
|
||||
addRenderableWidget(Button.builder(Component.literal("Rescan terrain"),b->mod.rescanTerrain()).bounds(x,210,126,20).build());
|
||||
addRenderableWidget(Button.builder(Component.literal("New map here"),b->{mod.newMap();resize(width,height);}).bounds(x+134,210,126,20).build());
|
||||
addRenderableWidget(Button.builder(Component.literal("Close"),b->onClose()).bounds(x,238,260,20).build());
|
||||
}
|
||||
@Override public void extractRenderState(GuiGraphicsExtractor context,int mouseX,int mouseY,float delta){super.extractRenderState(context,mouseX,mouseY,delta);int x=width/2-130;context.centeredText(font,title,width/2,20,0xff8cdf6b);context.text(font,"MineGeo service",x,38,0xffffffff);context.text(font,MineGeoClient.website,x,54,0xff8cdf6b);context.text(font,"Map name",x,80,0xffffffff);context.text(font,"Add to map ID (blank = new map)",x,114,0xffffffff);var mod=MineGeoClient.INSTANCE;String counts=mod.dataset==null?"Join an Overworld to begin":mod.dataset.points.size()+" panoramas · "+mod.dataset.coverage.size()+" explored chunks";context.centeredText(font,Component.literal(counts),width/2,270,0xffffffff);int y=292;for(var line:font.split(Component.literal(mod.status),260)){context.text(font,line,x,y,0xffd5d5d5);y+=10;}}
|
||||
|
||||
@@ -32,6 +32,7 @@ public final class MineGeoClient implements ClientModInitializer {
|
||||
public void capture(){try{if(uploading||Capture.active())throw new IllegalStateException("Finish the current operation first");if(dataset==null)throw new IllegalStateException("Join an Overworld and explore first");var c=Minecraft.getInstance();if(!dataset.contains(c.player.getX(),c.player.getZ()))throw new IllegalStateException("Outside this map's 512-block boundary. Start a new map here.");int limit=24;if(dataset.points.size()>=limit)throw new IllegalStateException("This capture already contains "+limit+" locations");if(dataset.hasLocation(c.player.getX(),c.player.getEyeY(),c.player.getZ()))throw new IllegalStateException("This location is already captured. Move to another block first.");pending=new SurfaceDataset.Point("p"+java.util.UUID.randomUUID().toString().replace("-","").substring(0,12),c.player.getX(),c.player.getEyeY(),c.player.getZ());ClientCompat.screen(c,null);Capture.start(dataset.directory.resolve(pending.id()));message("Capturing six views. Stand still; press G to cancel.");}catch(Exception e){message(e.getMessage());}}
|
||||
public void savedCapture(Path path){try{if(pending==null||!path.getFileName().toString().equals(pending.id()))return;dataset.points.add(pending);dataset.save();message("Panorama saved locally ("+dataset.points.size()+" total). Press G for preview or upload.");}catch(Exception e){message("Saving capture failed: "+e.getMessage());}finally{pending=null;}}
|
||||
public void newMap(){try{if(uploading||Capture.active())throw new IllegalStateException("Finish the current operation first");dataset=SurfaceDataset.create(server());message("New 512 × 512 map started. Older maps remain in .minecraft/minegeo.");}catch(Exception e){message(e.getMessage());}}
|
||||
public void rescanTerrain(){try{if(uploading||Capture.active()||dataset==null)throw new IllegalStateException("Join a world and finish the current operation first");dataset.rescan();message("Terrain rescan started. Revisit the area before uploading; only loaded chunks are captured.");}catch(Exception e){message(e.getMessage());}}
|
||||
public void pair(){background.execute(()->{try{saveConfig();var uploader=new UploadClient("");var pair=uploader.beginPairing();String code=pair.get("deviceCode").getAsString();String link=pair.get("verificationUri").getAsString();Minecraft.getInstance().execute(()->{message("Approve code "+pair.get("userCode").getAsString()+" at "+link);net.minecraft.util.Util.getPlatform().openUri(link);});for(int i=0;i<120;i++){Thread.sleep(5000);var response=uploader.pollPairing(code);if(response.get("status").getAsString().equals("approved")){token=response.get("token").getAsString();saveConfig();Minecraft.getInstance().execute(()->message("Account linked. You can upload maps now."));return;}}throw new IllegalStateException("Pairing expired. Try linking again.");}catch(Exception e){Minecraft.getInstance().execute(()->message("Pairing: "+e.getMessage()));}});}
|
||||
public void upload(String name){if(uploading)return;final SurfaceDataset selected=dataset;final String target=targetMap.trim();if(!target.isBlank()&&(Minecraft.getInstance().getCurrentServer()==null||selected==null||!java.util.Objects.equals(dev.minegeo.plugin.ServerAddress.normalize(server()),dev.minegeo.plugin.ServerAddress.normalize(selected.server)))){message("Join the server this capture belongs to before contributing.");return;}int minimum=target.isBlank()?5:1;if(selected==null||selected.points.size()<minimum){message("Capture at least "+minimum+" locations before uploading.");return;}uploading=true;background.execute(()->{try{selected.name=name;selected.save();saveConfig();status="Uploading assets. Interrupted transfers resume when you retry.";new UploadClient(token).upload(selected.directory,target.isBlank()?null:target, progress -> status=progress, () -> false);Minecraft.getInstance().execute(()->message("Upload received. Preview your selection and submit it in your dashboard."));}catch(Exception e){Minecraft.getInstance().execute(()->message("Upload paused: "+e.getMessage()));}finally{uploading=false;}});}
|
||||
}
|
||||
|
||||
@@ -14,22 +14,24 @@ import javax.imageio.ImageIO;
|
||||
public final class SurfaceDataset {
|
||||
public record Point(String id,double x,double y,double z){}
|
||||
public final Path directory;public final String server;public String name="My Minecraft map";
|
||||
public final List<Point> points=new ArrayList<>();public final Set<String> coverage=new HashSet<>();
|
||||
public final List<Point> points=new ArrayList<>();public final Set<String> coverage=new HashSet<>();private final Set<String> refreshPending=new HashSet<>();
|
||||
private final BufferedImage image=new BufferedImage(512,512,BufferedImage.TYPE_INT_ARGB);
|
||||
private int minX,minZ,cursor;private String scanning;private int chunkX,chunkZ;
|
||||
private SurfaceDataset(Path directory,String server,int x,int z){this.directory=directory;this.server=server;minX=x;minZ=z;for(int iz=0;iz<512;iz++)for(int ix=0;ix<512;ix++)image.setRGB(ix,iz,((ix/16+iz/16)%2==0)?0xff4c493d:0xff3b3932);}
|
||||
private int minX,minZ,cursor;private String scanning;private int chunkX,chunkZ;private final dev.minegeo.plugin.SurfaceTerrain terrain;
|
||||
private SurfaceDataset(Path directory,String server,int x,int z){this.directory=directory;this.server=server;terrain=new dev.minegeo.plugin.SurfaceTerrain(directory);minX=x;minZ=z;for(int iz=0;iz<512;iz++)for(int ix=0;ix<512;ix++)image.setRGB(ix,iz,((ix/16+iz/16)%2==0)?0xff4c493d:0xff3b3932);}
|
||||
public static SurfaceDataset create(String server)throws Exception{var c=Minecraft.getInstance();var root=c.gameDirectory.toPath().resolve("minegeo");Files.createDirectories(root);var dataset=new SurfaceDataset(root.resolve(UUID.randomUUID().toString()),server,(Math.floorDiv(c.player.getBlockX(),16)-16)*16,(Math.floorDiv(c.player.getBlockZ(),16)-16)*16);Files.createDirectories(dataset.directory);dataset.saveLocal();return dataset;}
|
||||
public static SurfaceDataset open(String server)throws Exception{Path root=Minecraft.getInstance().gameDirectory.toPath().resolve("minegeo");if(Files.exists(root)){try(var dirs=Files.list(root)){for(Path path:dirs.sorted(Comparator.reverseOrder()).toList()){Path state=path.resolve("local.json");if(!Files.exists(state))continue;var j=JsonParser.parseString(Files.readString(state)).getAsJsonObject();if(!j.get("server").getAsString().equals(server))continue;var d=new SurfaceDataset(path,server,j.get("minX").getAsInt(),j.get("minZ").getAsInt());d.name=j.get("name").getAsString();for(var p:j.getAsJsonArray("viewpoints")){var v=p.getAsJsonObject();d.points.add(new Point(v.get("id").getAsString(),v.get("x").getAsDouble(),v.get("y").getAsDouble(),v.get("z").getAsDouble()));}for(var k:j.getAsJsonArray("coverage"))d.coverage.add(k.getAsString());if(Files.exists(path.resolve("map.png"))){var saved=ImageIO.read(path.resolve("map.png").toFile());d.image.getGraphics().drawImage(saved,0,0,null);}return d;}}}return create(server);}
|
||||
public static SurfaceDataset open(String server)throws Exception{Path root=Minecraft.getInstance().gameDirectory.toPath().resolve("minegeo");if(Files.exists(root)){try(var dirs=Files.list(root)){for(Path path:dirs.sorted(Comparator.comparingLong((Path p)->p.resolve("local.json").toFile().lastModified()).thenComparing(Path::toString).reversed()).toList()){Path state=path.resolve("local.json");if(!Files.exists(state))continue;var j=JsonParser.parseString(Files.readString(state)).getAsJsonObject();if(!j.get("server").getAsString().equals(server))continue;var d=new SurfaceDataset(path,server,j.get("minX").getAsInt(),j.get("minZ").getAsInt());d.name=j.get("name").getAsString();for(var p:j.getAsJsonArray("viewpoints")){var v=p.getAsJsonObject();d.points.add(new Point(v.get("id").getAsString(),v.get("x").getAsDouble(),v.get("y").getAsDouble(),v.get("z").getAsDouble()));}for(var k:j.getAsJsonArray("coverage"))if(d.terrain.has(k.getAsString()))d.coverage.add(k.getAsString());if(Files.exists(path.resolve("map.png"))){var saved=ImageIO.read(path.resolve("map.png").toFile());d.image.getGraphics().drawImage(saved,0,0,null);}return d;}}}return create(server);}
|
||||
public boolean hasLocation(double x,double y,double z){return points.stream().anyMatch(p->Math.floor(p.x())==Math.floor(x)&&Math.floor(p.y())==Math.floor(y)&&Math.floor(p.z())==Math.floor(z));}
|
||||
public void rescan(){refreshPending.clear();refreshPending.addAll(coverage);scanning=null;}
|
||||
public boolean contains(double x,double z){return x>=minX&&x<minX+512&&z>=minZ&&z<minZ+512;}
|
||||
public void scan(Minecraft c){
|
||||
if(scanning==null){int px=Math.floorDiv(c.player.getBlockX(),16),pz=Math.floorDiv(c.player.getBlockZ(),16);outer:for(int radius=0;radius<=12;radius++)for(int dz=-radius;dz<=radius;dz++)for(int dx=-radius;dx<=radius;dx++){if(Math.max(Math.abs(dx),Math.abs(dz))!=radius)continue;int cx=px+dx,cz=pz+dz;String key=cx+","+cz;if(coverage.contains(key)||!contains(cx*16,cz*16))continue;if(c.level.getChunk(cx,cz,ChunkStatus.FULL,false)==null)continue;scanning=key;chunkX=cx;chunkZ=cz;cursor=0;break outer;}}
|
||||
if(scanning==null){int px=Math.floorDiv(c.player.getBlockX(),16),pz=Math.floorDiv(c.player.getBlockZ(),16);outer:for(int radius=0;radius<=12;radius++)for(int dz=-radius;dz<=radius;dz++)for(int dx=-radius;dx<=radius;dx++){if(Math.max(Math.abs(dx),Math.abs(dz))!=radius)continue;int cx=px+dx,cz=pz+dz;String key=cx+","+cz;if((coverage.contains(key)&&!refreshPending.contains(key))||!contains(cx*16,cz*16))continue;if(c.level.getChunk(cx,cz,ChunkStatus.FULL,false)==null)continue;scanning=key;chunkX=cx;chunkZ=cz;cursor=0;break outer;}}
|
||||
if(scanning==null)return;if(c.level.getChunk(chunkX,chunkZ,ChunkStatus.FULL,false)==null){scanning=null;return;}
|
||||
var pos=new BlockPos.MutableBlockPos();for(int n=0;n<64&&cursor<256;n++,cursor++){int x=chunkX*16+(cursor%16),z=chunkZ*16+(cursor/16),y=c.level.getHeight(Heightmap.Types.WORLD_SURFACE,x,z)-1;pos.set(x,y,z);int color=c.level.getBlockState(pos).getMapColor(c.level,pos).col;image.setRGB(x-minX,z-minZ,0xff000000|color);}
|
||||
if(cursor==256){coverage.add(scanning);scanning=null;}
|
||||
if(cursor==0)terrain.begin();
|
||||
var pos=new BlockPos.MutableBlockPos();for(int n=0;n<64&&cursor<256;n++,cursor++){int x=chunkX*16+(cursor%16),z=chunkZ*16+(cursor/16),y=c.level.getHeight(Heightmap.Types.WORLD_SURFACE,x,z)-1;pos.set(x,y,z);int color=c.level.getBlockState(pos).getMapColor(c.level,pos).col;image.setRGB(x-minX,z-minZ,0xff000000|color);for(int dy=0;dy<4&&y-dy>=c.level.getMinY();dy++){pos.set(x,y-dy,z);var state=c.level.getBlockState(pos);if(!state.isAir())terrain.add(x,y-dy,z,state.toString());}}
|
||||
if(cursor==256){try{terrain.finish(scanning);coverage.add(scanning);refreshPending.remove(scanning);}catch(Exception e){MineGeoClient.INSTANCE.message("Terrain capture: "+e.getMessage());}scanning=null;}
|
||||
}
|
||||
private JsonObject state(){var g=MineGeoClient.INSTANCE.gson;var j=new JsonObject();j.addProperty("server",server);j.addProperty("name",name);j.addProperty("minX",minX);j.addProperty("minZ",minZ);j.add("viewpoints",g.toJsonTree(points));j.add("coverage",g.toJsonTree(coverage));return j;}
|
||||
private void saveLocal()throws Exception{Files.createDirectories(directory);Files.writeString(directory.resolve("local.json"),MineGeoClient.INSTANCE.gson.toJson(state()));ImageIO.write(image,"png",directory.resolve("map.png").toFile());}
|
||||
public void save()throws Exception{saveLocal();var j=new JsonObject();j.addProperty("format","minegeo-dataset/v1");j.addProperty("minecraftVersion",net.fabricmc.loader.api.FabricLoader.getInstance().getModContainer("minecraft").orElseThrow().getMetadata().getVersion().getFriendlyString());j.addProperty("dimension","minecraft:overworld");j.addProperty("north","-z");j.addProperty("source","fabric");j.addProperty("name",name);j.addProperty("description","Player-captured surface map. Checkerboard areas have not been explored.");j.addProperty("serverName",server);if(!server.equals("Singleplayer"))j.addProperty("serverAddress",dev.minegeo.plugin.ServerAddress.normalize(server));var b=new JsonObject();b.addProperty("minX",minX);b.addProperty("maxX",minX+512);b.addProperty("minZ",minZ);b.addProperty("maxZ",minZ+512);j.add("bounds",b);j.add("viewpoints",MineGeoClient.INSTANCE.gson.toJsonTree(points));var chunks=new JsonArray();for(String key:coverage){var v=new JsonArray();for(String number:key.split(","))v.add(Integer.parseInt(number));chunks.add(v);}j.add("coverage",chunks);var assets=new JsonArray();addAsset(assets,"map.png");for(Point p:points)for(String face:Capture.FACES)addAsset(assets,p.id()+"/"+face+".png");j.add("assets",assets);Files.writeString(directory.resolve("manifest.json"),MineGeoClient.INSTANCE.gson.toJson(j));}
|
||||
public void save()throws Exception{saveLocal();var j=new JsonObject();j.addProperty("format","minegeo-dataset/v1");j.addProperty("minecraftVersion",net.fabricmc.loader.api.FabricLoader.getInstance().getModContainer("minecraft").orElseThrow().getMetadata().getVersion().getFriendlyString());j.addProperty("dimension","minecraft:overworld");j.addProperty("north","-z");j.addProperty("source","fabric");j.addProperty("name",name);j.addProperty("description","Player-captured surface map. Checkerboard areas have not been explored.");j.addProperty("serverName",server);if(!server.equals("Singleplayer"))j.addProperty("serverAddress",dev.minegeo.plugin.ServerAddress.normalize(server));var b=new JsonObject();b.addProperty("minX",minX);b.addProperty("maxX",minX+512);b.addProperty("minZ",minZ);b.addProperty("maxZ",minZ+512);j.add("bounds",b);j.add("viewpoints",MineGeoClient.INSTANCE.gson.toJsonTree(points));var chunks=new JsonArray();for(String key:coverage){var v=new JsonArray();for(String number:key.split(","))v.add(Integer.parseInt(number));chunks.add(v);}j.add("coverage",chunks);var assets=new JsonArray();addAsset(assets,"map.png");if(terrain.export(coverage,minX,minZ))addAsset(assets,"terrain.json.gz");for(Point p:points)for(String face:Capture.FACES)addAsset(assets,p.id()+"/"+face+".png");j.add("assets",assets);Files.writeString(directory.resolve("manifest.json"),MineGeoClient.INSTANCE.gson.toJson(j));}
|
||||
private void addAsset(JsonArray array,String file)throws Exception{Path p=directory.resolve(file);var j=new JsonObject();j.addProperty("path",file);j.addProperty("bytes",Files.size(p));j.addProperty("sha256",HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(p))));array.add(j);}
|
||||
}
|
||||
|
||||
@@ -8,13 +8,15 @@ public final class CaptureScreen extends Screen {
|
||||
private TextFieldWidget name,target;
|
||||
public CaptureScreen(){super(Text.literal("MineGeo Capture"));}
|
||||
@Override protected void init(){var mod=MineGeoClient.INSTANCE;int x=width/2-130;
|
||||
addDrawableChild(ButtonWidget.builder(Text.literal("Log in"),b->net.minecraft.util.Util.getOperatingSystem().open(MineGeoClient.website+"/log-in")).dimensions(x+180,42,80,20).build());
|
||||
name=new TextFieldWidget(textRenderer,x,92,260,20,Text.literal("Map name"));name.setMaxLength(80);name.setText(mod.dataset==null?"My Minecraft map":mod.dataset.name);addDrawableChild(name);
|
||||
target=new TextFieldWidget(textRenderer,x,126,260,20,Text.literal("Add to map ID (optional)"));target.setMaxLength(36);target.setText(mod.targetMap);addDrawableChild(target);
|
||||
addDrawableChild(ButtonWidget.builder(Text.literal("Capture panorama"),b->{mod.targetMap=target.getText().trim();mod.capture();}).dimensions(x,154,126,20).build());
|
||||
addDrawableChild(ButtonWidget.builder(Text.literal("Preview files"),b->{if(mod.dataset!=null)net.minecraft.util.Util.getOperatingSystem().open(mod.dataset.directory.toFile());}).dimensions(x+134,154,126,20).build());
|
||||
addDrawableChild(ButtonWidget.builder(Text.literal("Link account"),b->{mod.targetMap=target.getText().trim();mod.pair();}).dimensions(x,182,126,20).build());
|
||||
addDrawableChild(ButtonWidget.builder(Text.literal("Upload / resume"),b->{mod.targetMap=target.getText().trim();mod.upload(name.getText());}).dimensions(x+134,182,126,20).build());
|
||||
addDrawableChild(ButtonWidget.builder(Text.literal("New map at this location"),b->{mod.newMap();init(width,height);}).dimensions(x,210,260,20).build());
|
||||
addDrawableChild(ButtonWidget.builder(Text.literal("Rescan terrain"),b->mod.rescanTerrain()).dimensions(x,210,126,20).build());
|
||||
addDrawableChild(ButtonWidget.builder(Text.literal("New map here"),b->{mod.newMap();init(width,height);}).dimensions(x+134,210,126,20).build());
|
||||
addDrawableChild(ButtonWidget.builder(Text.literal("Close"),b->close()).dimensions(x,238,260,20).build());
|
||||
}
|
||||
@Override public void render(DrawContext context,int mouseX,int mouseY,float delta){super.render(context,mouseX,mouseY,delta);int x=width/2-130;context.drawCenteredTextWithShadow(textRenderer,title,width/2,20,0xff8cdf6b);context.drawTextWithShadow(textRenderer,"MineGeo service",x,38,0xffffffff);context.drawTextWithShadow(textRenderer,MineGeoClient.website,x,54,0xff8cdf6b);context.drawTextWithShadow(textRenderer,"Map name",x,80,0xffffffff);context.drawTextWithShadow(textRenderer,"Add to map ID (blank = new map)",x,114,0xffffffff);var mod=MineGeoClient.INSTANCE;String counts=mod.dataset==null?"Join an Overworld to begin":mod.dataset.points.size()+" panoramas · "+mod.dataset.coverage.size()+" explored chunks";context.drawCenteredTextWithShadow(textRenderer,Text.literal(counts),width/2,270,0xffffffff);int y=292;for(var line:textRenderer.wrapLines(Text.literal(mod.status),260)){context.drawTextWithShadow(textRenderer,line,x,y,0xffd5d5d5);y+=10;}}
|
||||
|
||||
@@ -32,6 +32,7 @@ public final class MineGeoClient implements ClientModInitializer {
|
||||
public void capture(){try{if(uploading||Capture.active())throw new IllegalStateException("Finish the current operation first");if(dataset==null)throw new IllegalStateException("Join an Overworld and explore first");var c=MinecraftClient.getInstance();if(!dataset.contains(c.player.getX(),c.player.getZ()))throw new IllegalStateException("Outside this map's 512-block boundary. Start a new map here.");int limit=24;if(dataset.points.size()>=limit)throw new IllegalStateException("This capture already contains "+limit+" locations");if(dataset.hasLocation(c.player.getX(),c.player.getEyeY(),c.player.getZ()))throw new IllegalStateException("This location is already captured. Move to another block first.");pending=new SurfaceDataset.Point("p"+java.util.UUID.randomUUID().toString().replace("-","").substring(0,12),c.player.getX(),c.player.getEyeY(),c.player.getZ());c.setScreen(null);Capture.start(dataset.directory.resolve(pending.id()));message("Capturing six views. Stand still; press G to cancel.");}catch(Exception e){message(e.getMessage());}}
|
||||
public void savedCapture(Path path){try{if(pending==null||!path.getFileName().toString().equals(pending.id()))return;dataset.points.add(pending);dataset.save();message("Panorama saved locally ("+dataset.points.size()+" total). Press G for preview or upload.");}catch(Exception e){message("Saving capture failed: "+e.getMessage());}finally{pending=null;}}
|
||||
public void newMap(){try{if(uploading||Capture.active())throw new IllegalStateException("Finish the current operation first");dataset=SurfaceDataset.create(server());message("New 512 × 512 map started. Older maps remain in .minecraft/minegeo.");}catch(Exception e){message(e.getMessage());}}
|
||||
public void rescanTerrain(){try{if(uploading||Capture.active()||dataset==null)throw new IllegalStateException("Join a world and finish the current operation first");dataset.rescan();message("Terrain rescan started. Revisit the area before uploading; only loaded chunks are captured.");}catch(Exception e){message(e.getMessage());}}
|
||||
public void pair(){background.execute(()->{try{saveConfig();var uploader=new UploadClient("");var pair=uploader.beginPairing();String code=pair.get("deviceCode").getAsString();String link=pair.get("verificationUri").getAsString();MinecraftClient.getInstance().execute(()->{message("Approve code "+pair.get("userCode").getAsString()+" at "+link);net.minecraft.util.Util.getOperatingSystem().open(link);});for(int i=0;i<120;i++){Thread.sleep(5000);var response=uploader.pollPairing(code);if(response.get("status").getAsString().equals("approved")){token=response.get("token").getAsString();saveConfig();MinecraftClient.getInstance().execute(()->message("Account linked. You can upload maps now."));return;}}throw new IllegalStateException("Pairing expired. Try linking again.");}catch(Exception e){MinecraftClient.getInstance().execute(()->message("Pairing: "+e.getMessage()));}});}
|
||||
public void upload(String name){if(uploading)return;final SurfaceDataset selected=dataset;final String target=targetMap.trim();if(!target.isBlank()&&(MinecraftClient.getInstance().getCurrentServerEntry()==null||selected==null||!java.util.Objects.equals(dev.minegeo.plugin.ServerAddress.normalize(server()),dev.minegeo.plugin.ServerAddress.normalize(selected.server)))){message("Join the server this capture belongs to before contributing.");return;}int minimum=target.isBlank()?5:1;if(selected==null||selected.points.size()<minimum){message("Capture at least "+minimum+" locations before uploading.");return;}uploading=true;background.execute(()->{try{selected.name=name;selected.save();saveConfig();status="Uploading assets. Interrupted transfers resume when you retry.";new UploadClient(token).upload(selected.directory,target.isBlank()?null:target, progress -> status=progress, () -> false);MinecraftClient.getInstance().execute(()->message("Upload received. Preview your selection and submit it in your dashboard."));}catch(Exception e){MinecraftClient.getInstance().execute(()->message("Upload paused: "+e.getMessage()));}finally{uploading=false;}});}
|
||||
}
|
||||
|
||||
@@ -14,22 +14,24 @@ import javax.imageio.ImageIO;
|
||||
public final class SurfaceDataset {
|
||||
public record Point(String id,double x,double y,double z){}
|
||||
public final Path directory;public final String server;public String name="My Minecraft map";
|
||||
public final List<Point> points=new ArrayList<>();public final Set<String> coverage=new HashSet<>();
|
||||
public final List<Point> points=new ArrayList<>();public final Set<String> coverage=new HashSet<>();private final Set<String> refreshPending=new HashSet<>();
|
||||
private final BufferedImage image=new BufferedImage(512,512,BufferedImage.TYPE_INT_ARGB);
|
||||
private int minX,minZ,cursor;private String scanning;private int chunkX,chunkZ;
|
||||
private SurfaceDataset(Path directory,String server,int x,int z){this.directory=directory;this.server=server;minX=x;minZ=z;for(int iz=0;iz<512;iz++)for(int ix=0;ix<512;ix++)image.setRGB(ix,iz,((ix/16+iz/16)%2==0)?0xff4c493d:0xff3b3932);}
|
||||
private int minX,minZ,cursor;private String scanning;private int chunkX,chunkZ;private final dev.minegeo.plugin.SurfaceTerrain terrain;
|
||||
private SurfaceDataset(Path directory,String server,int x,int z){this.directory=directory;this.server=server;terrain=new dev.minegeo.plugin.SurfaceTerrain(directory);minX=x;minZ=z;for(int iz=0;iz<512;iz++)for(int ix=0;ix<512;ix++)image.setRGB(ix,iz,((ix/16+iz/16)%2==0)?0xff4c493d:0xff3b3932);}
|
||||
public static SurfaceDataset create(String server)throws Exception{var c=MinecraftClient.getInstance();var root=c.runDirectory.toPath().resolve("minegeo");Files.createDirectories(root);var dataset=new SurfaceDataset(root.resolve(UUID.randomUUID().toString()),server,(Math.floorDiv(c.player.getBlockX(),16)-16)*16,(Math.floorDiv(c.player.getBlockZ(),16)-16)*16);Files.createDirectories(dataset.directory);dataset.saveLocal();return dataset;}
|
||||
public static SurfaceDataset open(String server)throws Exception{Path root=MinecraftClient.getInstance().runDirectory.toPath().resolve("minegeo");if(Files.exists(root)){try(var dirs=Files.list(root)){for(Path path:dirs.sorted(Comparator.reverseOrder()).toList()){Path state=path.resolve("local.json");if(!Files.exists(state))continue;var j=JsonParser.parseString(Files.readString(state)).getAsJsonObject();if(!j.get("server").getAsString().equals(server))continue;var d=new SurfaceDataset(path,server,j.get("minX").getAsInt(),j.get("minZ").getAsInt());d.name=j.get("name").getAsString();for(var p:j.getAsJsonArray("viewpoints")){var v=p.getAsJsonObject();d.points.add(new Point(v.get("id").getAsString(),v.get("x").getAsDouble(),v.get("y").getAsDouble(),v.get("z").getAsDouble()));}for(var k:j.getAsJsonArray("coverage"))d.coverage.add(k.getAsString());if(Files.exists(path.resolve("map.png"))){var saved=ImageIO.read(path.resolve("map.png").toFile());d.image.getGraphics().drawImage(saved,0,0,null);}return d;}}}return create(server);}
|
||||
public static SurfaceDataset open(String server)throws Exception{Path root=MinecraftClient.getInstance().runDirectory.toPath().resolve("minegeo");if(Files.exists(root)){try(var dirs=Files.list(root)){for(Path path:dirs.sorted(Comparator.comparingLong((Path p)->p.resolve("local.json").toFile().lastModified()).thenComparing(Path::toString).reversed()).toList()){Path state=path.resolve("local.json");if(!Files.exists(state))continue;var j=JsonParser.parseString(Files.readString(state)).getAsJsonObject();if(!j.get("server").getAsString().equals(server))continue;var d=new SurfaceDataset(path,server,j.get("minX").getAsInt(),j.get("minZ").getAsInt());d.name=j.get("name").getAsString();for(var p:j.getAsJsonArray("viewpoints")){var v=p.getAsJsonObject();d.points.add(new Point(v.get("id").getAsString(),v.get("x").getAsDouble(),v.get("y").getAsDouble(),v.get("z").getAsDouble()));}for(var k:j.getAsJsonArray("coverage"))if(d.terrain.has(k.getAsString()))d.coverage.add(k.getAsString());if(Files.exists(path.resolve("map.png"))){var saved=ImageIO.read(path.resolve("map.png").toFile());d.image.getGraphics().drawImage(saved,0,0,null);}return d;}}}return create(server);}
|
||||
public boolean hasLocation(double x,double y,double z){return points.stream().anyMatch(p->Math.floor(p.x())==Math.floor(x)&&Math.floor(p.y())==Math.floor(y)&&Math.floor(p.z())==Math.floor(z));}
|
||||
public void rescan(){refreshPending.clear();refreshPending.addAll(coverage);scanning=null;}
|
||||
public boolean contains(double x,double z){return x>=minX&&x<minX+512&&z>=minZ&&z<minZ+512;}
|
||||
public void scan(MinecraftClient c){
|
||||
if(scanning==null){int px=Math.floorDiv(c.player.getBlockX(),16),pz=Math.floorDiv(c.player.getBlockZ(),16);outer:for(int radius=0;radius<=12;radius++)for(int dz=-radius;dz<=radius;dz++)for(int dx=-radius;dx<=radius;dx++){if(Math.max(Math.abs(dx),Math.abs(dz))!=radius)continue;int cx=px+dx,cz=pz+dz;String key=cx+","+cz;if(coverage.contains(key)||!contains(cx*16,cz*16))continue;if(c.world.getChunk(cx,cz,ChunkStatus.FULL,false)==null)continue;scanning=key;chunkX=cx;chunkZ=cz;cursor=0;break outer;}}
|
||||
if(scanning==null){int px=Math.floorDiv(c.player.getBlockX(),16),pz=Math.floorDiv(c.player.getBlockZ(),16);outer:for(int radius=0;radius<=12;radius++)for(int dz=-radius;dz<=radius;dz++)for(int dx=-radius;dx<=radius;dx++){if(Math.max(Math.abs(dx),Math.abs(dz))!=radius)continue;int cx=px+dx,cz=pz+dz;String key=cx+","+cz;if((coverage.contains(key)&&!refreshPending.contains(key))||!contains(cx*16,cz*16))continue;if(c.world.getChunk(cx,cz,ChunkStatus.FULL,false)==null)continue;scanning=key;chunkX=cx;chunkZ=cz;cursor=0;break outer;}}
|
||||
if(scanning==null)return;if(c.world.getChunk(chunkX,chunkZ,ChunkStatus.FULL,false)==null){scanning=null;return;}
|
||||
var pos=new BlockPos.Mutable();for(int n=0;n<64&&cursor<256;n++,cursor++){int x=chunkX*16+(cursor%16),z=chunkZ*16+(cursor/16),y=c.world.getTopY(Heightmap.Type.WORLD_SURFACE,x,z)-1;pos.set(x,y,z);int color=c.world.getBlockState(pos).getMapColor(c.world,pos).color;image.setRGB(x-minX,z-minZ,0xff000000|color);}
|
||||
if(cursor==256){coverage.add(scanning);scanning=null;}
|
||||
if(cursor==0)terrain.begin();
|
||||
var pos=new BlockPos.Mutable();for(int n=0;n<64&&cursor<256;n++,cursor++){int x=chunkX*16+(cursor%16),z=chunkZ*16+(cursor/16),y=c.world.getTopY(Heightmap.Type.WORLD_SURFACE,x,z)-1;pos.set(x,y,z);int color=c.world.getBlockState(pos).getMapColor(c.world,pos).color;image.setRGB(x-minX,z-minZ,0xff000000|color);for(int dy=0;dy<4&&y-dy>=c.world.getBottomY();dy++){pos.set(x,y-dy,z);var state=c.world.getBlockState(pos);if(!state.isAir())terrain.add(x,y-dy,z,state.toString());}}
|
||||
if(cursor==256){try{terrain.finish(scanning);coverage.add(scanning);refreshPending.remove(scanning);}catch(Exception e){MineGeoClient.INSTANCE.message("Terrain capture: "+e.getMessage());}scanning=null;}
|
||||
}
|
||||
private JsonObject state(){var g=MineGeoClient.INSTANCE.gson;var j=new JsonObject();j.addProperty("server",server);j.addProperty("name",name);j.addProperty("minX",minX);j.addProperty("minZ",minZ);j.add("viewpoints",g.toJsonTree(points));j.add("coverage",g.toJsonTree(coverage));return j;}
|
||||
private void saveLocal()throws Exception{Files.createDirectories(directory);Files.writeString(directory.resolve("local.json"),MineGeoClient.INSTANCE.gson.toJson(state()));ImageIO.write(image,"png",directory.resolve("map.png").toFile());}
|
||||
public void save()throws Exception{saveLocal();var j=new JsonObject();j.addProperty("format","minegeo-dataset/v1");j.addProperty("minecraftVersion",net.fabricmc.loader.api.FabricLoader.getInstance().getModContainer("minecraft").orElseThrow().getMetadata().getVersion().getFriendlyString());j.addProperty("dimension","minecraft:overworld");j.addProperty("north","-z");j.addProperty("source","fabric");j.addProperty("name",name);j.addProperty("description","Player-captured surface map. Checkerboard areas have not been explored.");j.addProperty("serverName",server);if(!server.equals("Singleplayer"))j.addProperty("serverAddress",dev.minegeo.plugin.ServerAddress.normalize(server));var b=new JsonObject();b.addProperty("minX",minX);b.addProperty("maxX",minX+512);b.addProperty("minZ",minZ);b.addProperty("maxZ",minZ+512);j.add("bounds",b);j.add("viewpoints",MineGeoClient.INSTANCE.gson.toJsonTree(points));var chunks=new JsonArray();for(String key:coverage){var v=new JsonArray();for(String number:key.split(","))v.add(Integer.parseInt(number));chunks.add(v);}j.add("coverage",chunks);var assets=new JsonArray();addAsset(assets,"map.png");for(Point p:points)for(String face:Capture.FACES)addAsset(assets,p.id()+"/"+face+".png");j.add("assets",assets);Files.writeString(directory.resolve("manifest.json"),MineGeoClient.INSTANCE.gson.toJson(j));}
|
||||
public void save()throws Exception{saveLocal();var j=new JsonObject();j.addProperty("format","minegeo-dataset/v1");j.addProperty("minecraftVersion",net.fabricmc.loader.api.FabricLoader.getInstance().getModContainer("minecraft").orElseThrow().getMetadata().getVersion().getFriendlyString());j.addProperty("dimension","minecraft:overworld");j.addProperty("north","-z");j.addProperty("source","fabric");j.addProperty("name",name);j.addProperty("description","Player-captured surface map. Checkerboard areas have not been explored.");j.addProperty("serverName",server);if(!server.equals("Singleplayer"))j.addProperty("serverAddress",dev.minegeo.plugin.ServerAddress.normalize(server));var b=new JsonObject();b.addProperty("minX",minX);b.addProperty("maxX",minX+512);b.addProperty("minZ",minZ);b.addProperty("maxZ",minZ+512);j.add("bounds",b);j.add("viewpoints",MineGeoClient.INSTANCE.gson.toJsonTree(points));var chunks=new JsonArray();for(String key:coverage){var v=new JsonArray();for(String number:key.split(","))v.add(Integer.parseInt(number));chunks.add(v);}j.add("coverage",chunks);var assets=new JsonArray();addAsset(assets,"map.png");if(terrain.export(coverage,minX,minZ))addAsset(assets,"terrain.json.gz");for(Point p:points)for(String face:Capture.FACES)addAsset(assets,p.id()+"/"+face+".png");j.add("assets",assets);Files.writeString(directory.resolve("manifest.json"),MineGeoClient.INSTANCE.gson.toJson(j));}
|
||||
private void addAsset(JsonArray array,String file)throws Exception{Path p=directory.resolve(file);var j=new JsonObject();j.addProperty("path",file);j.addProperty("bytes",Files.size(p));j.addProperty("sha256",HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(p))));array.add(j);}
|
||||
}
|
||||
|
||||
@@ -25,9 +25,9 @@ public final class ServerAddress {
|
||||
return host+(port==25565?"":":"+port);
|
||||
}
|
||||
public static void requireMatch(String expected,String captured) throws IOException {
|
||||
String server=normalize(expected);
|
||||
if(server==null)return;
|
||||
if(expected==null||expected.isBlank())return;
|
||||
String actual=normalize(captured);
|
||||
if(!server.equals(actual))throw new IOException("Wrong capture server: this map requires "+server+", but the export records "+(actual==null?"no server address":actual)+". Join the correct server and capture again. Plugin admins: configure server-address before exporting. The map owner can correct or clear its server address in settings.");
|
||||
for(String address:expected.split(","))if(java.util.Objects.equals(normalize(address),actual)&&actual!=null)return;
|
||||
throw new IOException("Wrong capture server: this map requires one of "+expected+", but the export records "+(actual==null?"no server address":actual)+". Join the correct server and capture again.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package dev.minegeo.plugin;
|
||||
|
||||
import com.google.gson.*;
|
||||
import com.google.gson.stream.JsonWriter;
|
||||
import java.io.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import java.util.zip.*;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** Persists bounded, client-visible surface samples in the plugin terrain format. */
|
||||
public final class SurfaceTerrain {
|
||||
private static final Pattern BLOCK_STATE=Pattern.compile("minecraft:[a-z0-9_]+(\\[[a-z0-9_=,]+\\])?");
|
||||
private final Path directory;
|
||||
private final Gson gson=new Gson();
|
||||
private JsonArray current;
|
||||
public SurfaceTerrain(Path directory){this.directory=directory;}
|
||||
private Path chunk(String key){return directory.resolve("terrain-chunks").resolve(key.replace(',','_')+".json.gz");}
|
||||
public boolean has(String key){return Files.isRegularFile(chunk(key));}
|
||||
public void begin(){current=new JsonArray();}
|
||||
public void add(int x,int y,int z,String state){
|
||||
if(current==null)return;
|
||||
state=state.replace("Block{","").replace("}","");
|
||||
if(!BLOCK_STATE.matcher(state).matches())return;
|
||||
JsonArray block=new JsonArray();block.add(x);block.add(y);block.add(z);block.add(state);current.add(block);
|
||||
}
|
||||
public void finish(String key) throws IOException {
|
||||
Path file=chunk(key);Files.createDirectories(file.getParent());Path tmp=file.resolveSibling(file.getFileName()+".tmp");
|
||||
try(Writer out=new OutputStreamWriter(new GZIPOutputStream(Files.newOutputStream(tmp)),StandardCharsets.UTF_8)){gson.toJson(current,out);}
|
||||
Files.move(tmp,file,StandardCopyOption.REPLACE_EXISTING);current=null;
|
||||
}
|
||||
public boolean export(Set<String> coverage,int minX,int minZ) throws IOException {
|
||||
List<String> keys=coverage.stream().filter(this::has).sorted().toList();if(keys.isEmpty())return false;
|
||||
Path target=directory.resolve("terrain.json.gz"),tmp=directory.resolve("terrain.json.gz.tmp");int blocks=0;
|
||||
try(JsonWriter writer=new JsonWriter(new OutputStreamWriter(new GZIPOutputStream(Files.newOutputStream(tmp)),StandardCharsets.UTF_8))){
|
||||
writer.beginObject().name("bounds").beginObject().name("minX").value(minX).name("minZ").value(minZ).name("maxX").value(minX+512).name("maxZ").value(minZ+512).endObject();
|
||||
writer.name("blocks").beginArray();
|
||||
for(String key:keys)try(Reader in=new InputStreamReader(new GZIPInputStream(Files.newInputStream(chunk(key))),StandardCharsets.UTF_8)){
|
||||
for(JsonElement block:JsonParser.parseReader(in).getAsJsonArray()){gson.toJson(block,writer);blocks++;}
|
||||
}
|
||||
writer.endArray().name("coverage").beginArray();for(String key:keys){String[] xy=key.split(",");writer.beginArray().value(Integer.parseInt(xy[0])).value(Integer.parseInt(xy[1])).endArray();}writer.endArray().endObject();
|
||||
}
|
||||
if(blocks==0){Files.deleteIfExists(tmp);return false;}
|
||||
if(Files.size(tmp)>50L*1024*1024){Files.deleteIfExists(tmp);throw new IOException("Terrain snapshot exceeds 50 MiB");}
|
||||
Files.move(tmp,target,StandardCopyOption.REPLACE_EXISTING);return true;
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,8 @@ public final class UploadClient {
|
||||
JsonObject manifest=JsonParser.parseString(Files.readString(manifestFile)).getAsJsonObject();
|
||||
if(targetMap!=null){
|
||||
JsonObject serverInfo=api("/contributions/"+targetMap+"/server",null);
|
||||
ServerAddress.requireMatch(serverInfo.has("serverAddress")&&!serverInfo.get("serverAddress").isJsonNull()?serverInfo.get("serverAddress").getAsString():null,manifest.has("serverAddress")?manifest.get("serverAddress").getAsString():null);
|
||||
String expected=serverInfo.has("serverAddresses")?java.util.stream.StreamSupport.stream(serverInfo.getAsJsonArray("serverAddresses").spliterator(),false).map(e->e.getAsString()).collect(java.util.stream.Collectors.joining(",")):serverInfo.has("serverAddress")&&!serverInfo.get("serverAddress").isJsonNull()?serverInfo.get("serverAddress").getAsString():null;
|
||||
ServerAddress.requireMatch(expected,manifest.has("serverAddress")?manifest.get("serverAddress").getAsString():null);
|
||||
String originalHash=hash((website+"\n"+hash(token.getBytes(StandardCharsets.UTF_8))+"\n"+gson.toJson(manifest)).getBytes(StandardCharsets.UTF_8));
|
||||
Path selectionFile=directory.resolve(".contribution-"+targetMap+".json");
|
||||
JsonObject cached=Files.exists(selectionFile)?JsonParser.parseString(Files.readString(selectionFile)).getAsJsonObject():new JsonObject();
|
||||
@@ -64,7 +65,7 @@ public final class UploadClient {
|
||||
manifest.add("viewpoints",subset);
|
||||
if(manifest.get("source").getAsString().equals("fabric")){
|
||||
Set<String> ids=new HashSet<>();for(JsonElement p:subset)ids.add(p.getAsJsonObject().get("id").getAsString());
|
||||
JsonArray assets=new JsonArray();for(JsonElement a:manifest.getAsJsonArray("assets")){String path=a.getAsJsonObject().get("path").getAsString();if(path.equals("map.png")||ids.contains(path.split("/")[0]))assets.add(a);}manifest.add("assets",assets);
|
||||
JsonArray assets=new JsonArray();for(JsonElement a:manifest.getAsJsonArray("assets")){String path=a.getAsJsonObject().get("path").getAsString();if(path.equals("map.png")||path.equals("terrain.json.gz")||ids.contains(path.split("/")[0]))assets.add(a);}manifest.add("assets",assets);
|
||||
}
|
||||
cached=new JsonObject();cached.addProperty("hash",originalHash);cached.add("manifest",manifest);save(selectionFile,cached);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: MineGeo
|
||||
version: 0.1.3
|
||||
version: 0.1.4
|
||||
main: dev.minegeo.plugin.MineGeoPlugin
|
||||
api-version: '1.21'
|
||||
description: Bounded terrain export for MineGeo panorama guessing
|
||||
|
||||
Reference in New Issue
Block a user