From 0eb8dd5170bd73ef1e036a0ef7f33c9425ac92b0 Mon Sep 17 00:00:00 2001 From: Anuken Date: Tue, 26 May 2026 16:45:40 -0400 Subject: [PATCH] Data patch image dialog --- core/assets/bundles/bundle.properties | 22 ++ core/src/mindustry/core/UI.java | 2 +- .../editor/MapPatchImagesDialog.java | 292 ++++++++++++++++++ .../mindustry/editor/MapPatchesDialog.java | 7 +- .../mindustry/graphics/DataPatchPacker.java | 9 +- core/src/mindustry/mod/DataPatcher.java | 15 +- core/src/mindustry/ui/BorderImage.java | 26 ++ gradle.properties | 2 +- .../src/mindustry/server/ServerControl.java | 4 +- 9 files changed, 364 insertions(+), 15 deletions(-) create mode 100644 core/src/mindustry/editor/MapPatchImagesDialog.java diff --git a/core/assets/bundles/bundle.properties b/core/assets/bundles/bundle.properties index 4c8ae4d7f0..78b1df1bff 100644 --- a/core/assets/bundles/bundle.properties +++ b/core/assets/bundles/bundle.properties @@ -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 diff --git a/core/src/mindustry/core/UI.java b/core/src/mindustry/core/UI.java index 7342a8376c..e1fe54ff03 100644 --- a/core/src/mindustry/core/UI.java +++ b/core/src/mindustry/core/UI.java @@ -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); } diff --git a/core/src/mindustry/editor/MapPatchImagesDialog.java b/core/src/mindustry/editor/MapPatchImagesDialog.java new file mode 100644 index 0000000000..cbd5885759 --- /dev/null +++ b/core/src/mindustry/editor/MapPatchImagesDialog.java @@ -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> images = new Seq<>(); + var errors = new CopyOnWriteArrayList(); + + 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); + } + } +} diff --git a/core/src/mindustry/editor/MapPatchesDialog.java b/core/src/mindustry/editor/MapPatchesDialog.java index 985f52fd7e..e75287e125 100644 --- a/core/src/mindustry/editor/MapPatchesDialog.java +++ b/core/src/mindustry/editor/MapPatchesDialog.java @@ -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(); diff --git a/core/src/mindustry/graphics/DataPatchPacker.java b/core/src/mindustry/graphics/DataPatchPacker.java index 74ad218634..f29d632b14 100644 --- a/core/src/mindustry/graphics/DataPatchPacker.java +++ b/core/src/mindustry/graphics/DataPatchPacker.java @@ -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>(); + var tasks = new Seq>(); 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); diff --git a/core/src/mindustry/mod/DataPatcher.java b/core/src/mindustry/mod/DataPatcher.java index bc7482bee0..3141efed61 100644 --- a/core/src/mindustry/mod/DataPatcher.java +++ b/core/src/mindustry/mod/DataPatcher.java @@ -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 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{ /** 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{ diff --git a/core/src/mindustry/ui/BorderImage.java b/core/src/mindustry/ui/BorderImage.java index ca0a1a270b..66609d3c2d 100644 --- a/core/src/mindustry/ui/BorderImage.java +++ b/core/src/mindustry/ui/BorderImage.java @@ -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); + } } } diff --git a/gradle.properties b/gradle.properties index edacce2fd9..5bcc5de6d9 100644 --- a/gradle.properties +++ b/gradle.properties @@ -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 diff --git a/server/src/mindustry/server/ServerControl.java b/server/src/mindustry/server/ServerControl.java index dfd6dd3e39..b8f876d765 100644 --- a/server/src/mindustry/server/ServerControl.java +++ b/server/src/mindustry/server/ServerControl.java @@ -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); }