Data patch image dialog

This commit is contained in:
Anuken
2026-05-26 16:45:40 -04:00
parent 3b85f6a499
commit 0eb8dd5170
9 changed files with 364 additions and 15 deletions

View File

@@ -483,6 +483,28 @@ editor.objectives = Objectives
editor.locales = Locale Bundles
editor.patches.guide = Patch Guide
editor.patches = Data Patches
editor.patches.images = Data Patch Images
editor.patches.image.imports = Import Results
editor.patches.image.imported = [accent]{0} images imported.
editor.patches.image.error = [red]The following errors were encountered:[]
editor.patches.image.importzip = Import Zip
editor.patches.image.exportzip = Export Zip
editor.patches.image.clearall = Clear All
editor.patches.image.clearall.confirm = Are you certain that you wish to clear all images?
editor.patches.image.info = Image Info
editor.patches.image.toolarge = Image ([lightgray]{0}x{1}[]) is too large. Maximum size is {2}x{3}.
editor.patches.image.exists = An image with that path/name already exists:\n[lightgray]{0}
editor.patches.image.delete.confirm = Are you certain that you wish to delete this image?
editor.patches.image.name = [accent]File Name: [lightgray]{0}
editor.patches.image.region.name = [accent]Region Name: [lightgray]{0}
editor.patches.image.path = [accent]Path: [lightgray]{0}
editor.patches.image.env = [accent]Uses Environment Atlas: [lightgray]{0}
editor.patches.image.size = [accent]Size: [lightgray]{0}x{1}
editor.patches.image.filesize = [accent]File Size: [lightgray]{0}
editor.patches.image.packfailed = [red]⚠ Failed to pack sprite![]
editor.patches.image.packfailed.env = This is likely caused by a lack of space in the environment atlas.\nNote that environment sprites are limited to a 512x512 total area.
editor.images = Images
editor.patches.path = Path:
editor.patch = Patchset: {0}
editor.patches.none = [lightgray]No patchsets loaded.
editor.patches.errors = Patchset Errors

View File

@@ -130,7 +130,7 @@ public class UI implements ApplicationListener, Loadable{
Dialog.setHideAction(() -> sequence(fadeOut(0.1f)));
Tooltips.getInstance().animations = false;
Tooltips.getInstance().textProvider = text -> new Tooltip(t -> t.background(Styles.black6).margin(4f).add(text));
Tooltips.getInstance().textProvider = text -> new Tooltip(t -> t.background(Styles.black8).margin(4f).add(text));
if(mobile){
Tooltips.getInstance().offsetY += Scl.scl(60f);
}

View File

@@ -0,0 +1,292 @@
package mindustry.editor;
import arc.*;
import arc.files.*;
import arc.graphics.*;
import arc.graphics.g2d.TextureAtlas.*;
import arc.graphics.g2d.*;
import arc.scene.ui.*;
import arc.scene.ui.TextButton.*;
import arc.scene.ui.layout.*;
import arc.struct.*;
import arc.util.*;
import mindustry.*;
import mindustry.gen.*;
import mindustry.mod.*;
import mindustry.mod.DataPatcher.*;
import mindustry.ui.*;
import mindustry.ui.dialogs.*;
import java.io.*;
import java.util.concurrent.*;
import java.util.zip.*;
import static mindustry.Vars.*;
import static mindustry.graphics.DataPatchPacker.*;
public class MapPatchImagesDialog extends BaseDialog{
TextField searchField;
@Nullable String searchString;
Table inner;
public MapPatchImagesDialog(){
super("@editor.patches.images");
cont.table(search -> {
search.image(Icon.zoom);
searchField = search.field("", t -> {
searchString = t.length() > 0 ? t.toLowerCase() : null;
rebuild();
}).growX().get();
searchField.setMessageText("@search");
}).growX().row();
cont.pane(t -> inner = t).grow();
addCloseButton();
buttons.button("@add", Icon.add, () -> {
platform.showFileChooser(true, "png", result -> {
try{
Pixmap pix = PixmapIO.readPNG(result);
int width = pix.width;
int height = pix.height;
Pixmaps.bleed(pix);
byte[] bytes = PixmapIO.writePngBytes(pix);
pix.dispose();
if(width > DataPatcher.maxImageSize || height > DataPatcher.maxImageSize){
ui.showErrorMessage(Core.bundle.format("editor.patches.image.toolarge", width, height, DataPatcher.maxImageSize, DataPatcher.maxImageSize));
return;
}
//path and name are the same here; there's no path context.
String name = result.nameWithoutExtension();
String path = name;
var other = state.patcher.images.find(p -> (p.path.equalsIgnoreCase(path) || p.name.equalsIgnoreCase(name)));
if(other != null){
ui.showErrorMessage(Core.bundle.format("editor.patches.image.exists", other.name + " (" + other.path + ")"));
return;
}
state.patcher.images.add(new PatchImage(path, width, height, bytes));
state.patcher.images.sort();
state.patcher.applyImages(state.patcher.images);
rebuild();
}catch(Exception e){
ui.showException(e);
}
});
}).size(190f, 64f);
buttons.button("@edit", Icon.menu, () -> {
BaseDialog dialog = new BaseDialog("@waves.edit");
dialog.cont.pane(p -> {
p.margin(10f);
p.table(Tex.button, t -> {
TextButtonStyle style = Styles.flatt;
t.defaults().size(280f, 60f).left();
t.button("@editor.patches.image.importzip", Icon.download, style, () -> platform.showFileChooser(true, "zip", file -> {
dialog.hide();
ui.loadAnd(() -> {
try{
Seq<Future<PatchImage>> images = new Seq<>();
var errors = new CopyOnWriteArrayList<String>();
Fi zipped = new ZipFi(file);
zipped.walk(ifile -> {
if(ifile.extEquals("png")){
images.add(mainExecutor.submit(() -> {
try{
byte[] bytes = ifile.readBytes();
Pixmap pix = PixmapIO.readPNG(bytes);
int width = pix.width;
int height = pix.height;
Pixmaps.bleed(pix);
bytes = PixmapIO.writePngBytes(pix);
pix.dispose();
return new PatchImage(ifile.pathWithoutExtension(), width, height, bytes);
}catch(Throwable error){
errors.add("[accent]" + ifile.path() + "[white]: " + Strings.getSimpleMessage(error));
return null;
}
}));
}
});
Threads.awaitAll(images);
zipped.delete(); //closes the zip file
int imported = 0;
for(var future : images){
var image = future.get();
if(image != null){
if(image.width > DataPatcher.maxImageSize || image.height > DataPatcher.maxImageSize){
errors.add("[accent]" + image.path + "[white]: " +Core.bundle.format("editor.patches.image.toolarge", width, height, DataPatcher.maxImageSize, DataPatcher.maxImageSize));
continue;
}
var other = state.patcher.images.find(op -> (op.path.equalsIgnoreCase(image.path) || op.name.equalsIgnoreCase(image.name)));
if(other != null){
errors.add("[accent]" + image.path + "[white]: " +Core.bundle.format("editor.patches.image.exists", other.name + " (" + other.path + ")").replace("\n", " "));
continue;
}
state.patcher.images.add(image);
imported ++;
}
}
state.patcher.images.sort();
state.patcher.applyImages(state.patcher.images);
rebuild();
var idiag = new BaseDialog("@editor.patches.image.imports");
if(imported > 0) idiag.cont.add(Core.bundle.format("editor.patches.image.imported", imported)).row();
if(!errors.isEmpty()){
idiag.cont.add("@editor.patches.image.error").padBottom(10f).row();
idiag.cont.pane(ep -> {
ep.add(Seq.with(errors).toString("\n", s -> "[gray]- []" + s)).labelAlign(Align.left, Align.left).grow();
});
}
idiag.buttons.button("@ok", Icon.ok, idiag::hide).size(200f, 64f);
idiag.show();
}catch(Throwable e){
ui.showException(e);
}
});
})).marginLeft(12f).row();
t.button("@editor.patches.image.exportzip", Icon.upload, style, () -> platform.showFileChooser(false, "zip", file -> {
dialog.hide();
try{
try(OutputStream fos = file.write(false, 4096); ZipOutputStream zos = new ZipOutputStream(fos)){
for(var image : state.patcher.images){
zos.putNextEntry(new ZipEntry(image.path + ".png"));
zos.write(image.data);
zos.closeEntry();
}
}
}catch(Throwable e){
ui.showException(e);
}
})).marginLeft(12f).row();
t.button("@editor.patches.image.clearall", Icon.trash, style, () -> {
dialog.hide();
ui.showConfirm("@editor.patches.image.clearall.confirm", () -> {
state.patcher.images.clear();
state.patcher.applyImages(state.patcher.images);
rebuild();
});
}).marginLeft(12f).row();
});
});
dialog.addCloseButton();
dialog.show();
});
shown(() -> {
searchString = null;
searchField.setText("");
rebuild();
});
makeButtonOverlay();
}
void rebuild(){
inner.clearChildren();
inner.top().left();
float size = 200f;
int cols = (int)Math.max(1, Core.graphics.getWidth() / Scl.scl(size + 12f));
int i = 0;
for(var image : Vars.state.patcher.images){
if(searchString != null && !image.path.toLowerCase().contains(searchString)) continue;
TextureRegion region = Core.atlas.find(regionPrefix + image.name, "nomap");
boolean found = Core.atlas.has(regionPrefix + image.name);
inner.table(Styles.grayPanel, t -> {
t.margin(5f);
t.top();
t.add(new BorderImage(region, 4f)).scaling(Scaling.fit).with(b -> b.drawAlpha = true).size(size - 10f).row();
t.add((found ? "" : "[red]⚠[] ") + image.name).tooltip(regionPrefix + image.name + "\n[lightgray]" + image.path).ellipsis(true).left().width(size - 10f).growX().row();
t.add(image.width + "x" + image.height).tooltip(String.format("%,d", image.data.length) + "[lightgray]b").color(Color.lightGray).left().growX().row();
t.table(b -> {
b.left();
b.defaults().size((size - 10f) / 4f);
var istyle = Styles.emptyi;
b.button(Icon.pencil, istyle, () -> {
ui.showTextInput("@save.rename", "@editor.patches.path", image.path, res -> {
Fi fi = new Fi(res);
String name = fi.nameWithoutExtension();
String path = fi.pathWithoutExtension();
var other = state.patcher.images.find(p -> p != image && (p.path.equalsIgnoreCase(path) || p.name.equalsIgnoreCase(name)));
if(other != null){
ui.showErrorMessage(Core.bundle.format("editor.patches.image.exists", other.name + " (" + other.path + ")"));
}else{
//move and rename associated atlas region
if(region instanceof AtlasRegion at && at.name.equals(regionPrefix + image.name)){
var map = Core.atlas.getRegionMap();
map.remove(regionPrefix + image.name);
map.put(regionPrefix + name, at);
at.name = regionPrefix + name;
}
image.path = path;
image.name = name;
rebuild();
}
});
});
b.button(Icon.info, istyle, () -> {
BaseDialog d = new BaseDialog("@editor.patches.image.info");
float maxSize = Math.min(Core.graphics.getHeight(), Core.graphics.getHeight()) / Scl.scl(1f) * 0.8f - Scl.scl(64f + 60f);
d.cont.top();
d.cont.add(new BorderImage(region, 4f)).scaling(Scaling.fit).with(bi -> bi.drawAlpha = bi.forceNearest = true).size(maxSize).row();
d.cont.row().defaults().left();
boolean env = image.path.contains("blocks/environment/");
if(!found){
d.cont.add(Core.bundle.get("editor.patches.image.packfailed") + (env ? "\n" + Core.bundle.get("editor.patches.image.packfailed.env") : "")).row();
}
d.cont.add(Core.bundle.format("editor.patches.image.name", image.name)).row();
d.cont.add(Core.bundle.format("editor.patches.image.region.name", regionPrefix + image.name)).row();
d.cont.add(Core.bundle.format("editor.patches.image.path", image.name)).row();
d.cont.add(Core.bundle.format("editor.patches.image.env", env)).row();
d.cont.add(Core.bundle.format("editor.patches.image.size", image.width, image.height)).row();
d.cont.add(Core.bundle.format("editor.patches.image.filesize", String.format("%,d", image.data.length) + "[darkgray]b"));
d.addCloseButton();
d.show();
});
b.button(Icon.export, istyle, () -> {
platform.showFileChooser(false, "png", out -> {
try{
out.writeBytes(image.data);
}catch(Throwable e){
ui.showException(e);
}
});
});
b.button(Icon.trash, istyle, () -> {
ui.showConfirm("@editor.patches.image.delete.confirm", () -> {
Core.atlas.getRegionMap().remove(regionPrefix + image.name);
state.patcher.images.remove(image);
rebuild();
});
}).padTop(2f);
}).growX();
}).pad(4f).width(size).uniformY();
if(++i % cols == 0){
inner.row();
}
}
if(inner.getChildren().isEmpty()){
inner.center().add("@none.found").pad(10f);
}
}
}

View File

@@ -16,6 +16,7 @@ import static mindustry.Vars.*;
public class MapPatchesDialog extends BaseDialog{
private Table list;
private MapPatchImagesDialog images = new MapPatchImagesDialog();
public MapPatchesDialog(){
super("@editor.patches");
@@ -23,9 +24,11 @@ public class MapPatchesDialog extends BaseDialog{
shown(this::setup);
addCloseButton();
buttons.button("@editor.patches.guide", Icon.link, () -> Core.app.openURI(patchesGuideURL)).size(200, 64f);
buttons.button("@editor.patches.guide", Icon.link, () -> Core.app.openURI(patchesGuideURL)).size(190f, 64f);
buttons.button("@add", Icon.add, () -> showImport(this::addPatch)).size(200f, 64f);
buttons.button("@editor.images", Icon.image, () -> images.show()).size(190f, 64f);
buttons.button("@add", Icon.add, () -> showImport(this::addPatch)).size(190f, 64f);
cont.top();
getCell(cont).grow();

View File

@@ -16,7 +16,7 @@ import java.util.concurrent.*;
/** Manages data patch images. */
public class DataPatchPacker{
private static final String regionPrefix = "dp-";
public static final String regionPrefix = "dp-";
private @Nullable TextureAtlas patchAtlas;
@@ -46,7 +46,7 @@ public class DataPatchPacker{
TextureRegion envReserveRegion = Core.atlas.find("data-patch-reserved-env");
PixmapPacker envPacker = anyEnv ? new PixmapPacker(envReserveRegion.width, envReserveRegion.height, 2, true) : null;
var tasks = new Seq<Future<PackResult>>();
var tasks = new Seq<Future<?>>();
for(var image : images){
tasks.add(Vars.mainExecutor.submit(() -> {
try{
@@ -58,16 +58,13 @@ public class DataPatchPacker{
}else{
packer.pack(name, pixmap);
}
return new PackResult(name, pixmap);
}catch(Throwable e){
Log.err("Invalid patch image: " + image.path, e);
return null;
}
}));
}
Threads.awaitAll(tasks.as());
Threads.awaitAll(tasks);
TextureFilter filter = Core.settings.getBool("linear", !Vars.mobile) ? TextureFilter.linear : TextureFilter.nearest;
patchAtlas = packer.generateTextureAtlas(filter, filter, false);

View File

@@ -29,7 +29,8 @@ import java.util.*;
/** The current implementation is awful. Consider it a proof of concept. */
@SuppressWarnings("unchecked")
public class DataPatcher{
private static final int maxImageSize = 1024;
public static final int maxImageSize = 1024;
private static final Object root = new Object();
private static final ObjectMap<String, ContentType> nameToType = new ObjectMap<>();
private static ContentParser parser = createParser();
@@ -683,7 +684,7 @@ public class DataPatcher{
}
}
public static class PatchImage{
public static class PatchImage implements Comparable<PatchImage>{
/** Image name without extension; does not contain packing prefix. */
public String name;
/** Image path, excluding extension. */
@@ -694,8 +695,9 @@ public class DataPatcher{
public byte[] data;
public PatchImage(String path, int width, int height, byte[] data){
this.name = new Fi(path).nameWithoutExtension();
this.path = path;
Fi file = new Fi(path);
this.name = file.nameWithoutExtension();
this.path = file.pathWithoutExtension();
this.width = width;
this.height = height;
this.data = data;
@@ -720,6 +722,11 @@ public class DataPatcher{
}
return new PatchImage(relativePath, width, height, data);
}
@Override
public int compareTo(PatchImage patchImage){
return path.compareTo(patchImage.path);
}
}
private static class PatchRecord{

View File

@@ -1,15 +1,22 @@
package mindustry.ui;
import arc.graphics.*;
import arc.graphics.Texture.*;
import arc.graphics.g2d.*;
import arc.math.*;
import arc.math.geom.*;
import arc.scene.style.*;
import arc.scene.ui.*;
import arc.scene.ui.layout.*;
import arc.util.*;
import mindustry.gen.*;
import mindustry.graphics.*;
public class BorderImage extends Image{
public float thickness = 4f, pad = 0f;
public Color borderColor = Pal.gray;
public boolean forceNearest = false, drawAlpha = false;
public Color alphaColor = Color.gray.cpy();
public BorderImage(){
@@ -40,6 +47,21 @@ public class BorderImage extends Image{
@Override
public void draw(){
TextureFilter prev = TextureFilter.linear;
if(forceNearest && getDrawable() instanceof TextureRegionDrawable draw){
prev = draw.getRegion().texture.getMinFilter();
draw.getRegion().texture.setFilter(TextureFilter.nearest);
}
if(drawAlpha){
Draw.color(alphaColor, parentAlpha);
Vec2 v = scaling.apply(imageWidth, imageHeight, width, height).scl(1f / width, 1f / height);
TextureRegion region = ((TextureRegionDrawable)Tex.alphaBg).getRegion();
Tmp.tr1.set(region.texture);
Tmp.tr1.set(region.u, region.v, Mathf.lerp(region.u, region.u2, v.x), Mathf.lerp(region.v, region.v2, v.y));
Draw.rect(Tmp.tr1, x + imageX + imageWidth * scaleX/2f, y + imageY + imageHeight * scaleY/2f, imageWidth * scaleX, imageHeight * scaleY);
}
super.draw();
Draw.color(borderColor);
@@ -47,5 +69,9 @@ public class BorderImage extends Image{
Lines.stroke(Scl.scl(thickness));
Lines.rect(x + imageX - pad, y + imageY - pad, imageWidth * scaleX + pad*2, imageHeight * scaleY + pad*2);
Draw.reset();
if(forceNearest && getDrawable() instanceof TextureRegionDrawable draw){
draw.getRegion().texture.setFilter(prev);
}
}
}

View File

@@ -26,4 +26,4 @@ org.gradle.caching=true
org.gradle.internal.http.socketTimeout=100000
org.gradle.internal.http.connectionTimeout=100000
android.enableR8.fullMode=false
archash=6a42fef5da
archash=8186d87882

View File

@@ -206,7 +206,7 @@ public class ServerControl implements ApplicationListener{
patchDirectory = dataDirectory.child("patches");
patchDirectory.mkdirs();
patchImageDirectory = patchDirectory.child("images");
patchImageDirectory = patchDirectory.child("sprites");
patchImageDirectory.mkdirs();
loadPatchFiles();
@@ -366,6 +366,8 @@ public class ServerControl implements ApplicationListener{
}
}
contentPatchImages.sort();
if(contentPatches.size > 0){
Log.info("Loaded @ content patch files.", contentPatches.size);
}