Support for dynamically-generated rounded/angled corners in UI

This commit is contained in:
Anuken
2026-09-13 20:23:30 -04:00
parent 5c7cacaafe
commit fb4bddd163
4 changed files with 361 additions and 2 deletions

View File

@@ -0,0 +1,190 @@
package mindustry.graphics;
import arc.*;
import arc.graphics.*;
import arc.graphics.g2d.*;
import arc.graphics.g2d.PixmapPacker.*;
import arc.math.*;
import arc.math.geom.*;
import arc.scene.style.*;
import arc.scene.ui.layout.*;
import mindustry.core.*;
public class CornerGenerator{
/** Sub-samples per axis, per pixel, used to antialias the diagonal edge of the sharp/beveled corners. 8 -> 64 samples/pixel. */
private static final int supersample = 8;
private CornerGenerator(){}
/**
* Generates a NinePatchDrawable with antialiased angled corners.
* @param unscaledSize size of the corner in unscaled pixels; this is scaled by Scl.scl.
* */
public static NinePatchDrawable generateAngled(int unscaledSize){
int size = Math.max((int)Scl.scl(unscaledSize), 1);
TextureRegion[] tiles = renderCorner("corner-" + size, size, (x, y) -> coverage(x, y, size));
return ninePatch(tiles);
}
/**
* Generates an {@link OutlinedCornerDrawable}: an antialiased, beveled-corner outline in the same visual style as
* {@link #generateAngled(int)}, but drawing only the outline (stroke) of the shape rather than filling it.
* @param unscaledSize size of the corner in unscaled pixels; this is scaled by Scl.scl, same as {@link #generateAngled(int)}.
* @param unscaledStroke width of the outline, perpendicular to the line, in unscaled pixels; this is scaled by Scl.scl.
*/
public static OutlinedCornerDrawable generateAngledOutline(int unscaledSize, float unscaledStroke){
int size = Math.max((int)Scl.scl(unscaledSize), 1);
float stroke = Scl.scl(unscaledStroke);
String name = "corner-outline-" + size + "-" + Float.floatToIntBits(stroke);
TextureRegion[] tiles = renderCorner(name, size, (x, y) -> outlineCoverage(x, y, stroke));
return outlinedDrawable(tiles, size, stroke);
}
/**
* Generates a NinePatchDrawable with antialiased rounded corners.
* @param unscaledSize size of the corner in unscaled pixels; this is scaled by Scl.scl, same as {@link #generateAngled(int)}.
*/
public static NinePatchDrawable generateRound(int unscaledSize){
int size = Math.max((int)Scl.scl(unscaledSize), 1);
String name = "corner-round-" + size;
TextureRegion[] tiles = renderCorner(name, size, (x, y) -> roundCoverage(x, y, size));
return ninePatch(tiles);
}
/**
* Generates an outlined drawable with antialiased rounded corners.
* <p>
* @param unscaledSize size of the corner in unscaled pixels; this is scaled by Scl.scl.
* @param unscaledStroke width of the outline, in unscaled pixels; this is scaled by Scl.scl.
*/
public static OutlinedCornerDrawable generateRoundOutline(int unscaledSize, float unscaledStroke){
int size = Math.max((int)Scl.scl(unscaledSize), 1);
float stroke = Scl.scl(unscaledStroke);
String name = "corner-round-outline-" + size + "-" + Float.floatToIntBits(stroke);
TextureRegion[] tiles = renderCorner(name, size, (x, y) -> outlineRoundCoverage(x, y, size, stroke));
return outlinedDrawable(tiles, size, stroke);
}
/**
* Packs a {@code size} x {@code size} tile into the UI packer, filling it pixel-by-pixel via {@code fn}, and
* returns the four flipped corner regions derived from it.
* @return {topLeft, topRight, bottomLeft, bottomRight}, matching the flips used by both {@link #generateAngled(int)}
* and {@link #generateAngledOutline(int, float)}.
*/
private static TextureRegion[] renderCorner(String name, int size, CoverageFn fn){
PixmapPacker packer = UI.packer;
Rect rect = packer.pack(name, size, size);
Page page = packer.getPage(name);
Pixmap pix = page.image;
int baseX = (int)rect.x, baseY = (int)rect.y;
//draw the antialiased shape directly into the packer page's pixmap
for(int y = 0; y < size; y++){
for(int x = 0; x < size; x++){
float coverage = fn.get(x, y);
pix.setRaw(baseX + x, baseY + y, Color.rgba8888(1f, 1f, 1f, coverage));
}
}
page.setDirty(true);
Texture texture = page.texture;
//top-right corner tile, exactly as generated
TextureRegion topRight = new TextureRegion(texture, baseX, baseY, size, size);
TextureRegion topLeft = new TextureRegion(topRight);
topLeft.flip(true, false);
TextureRegion bottomRight = new TextureRegion(topRight);
bottomRight.flip(false, true);
TextureRegion bottomLeft = new TextureRegion(topRight);
bottomLeft.flip(true, true);
return new TextureRegion[]{topLeft, topRight, bottomLeft, bottomRight};
}
/** Builds a filled nine-patch drawable out of the four corner tiles returned by {@link #renderCorner}. */
private static NinePatchDrawable ninePatch(TextureRegion[] corners){
TextureRegion topLeft = corners[0], topRight = corners[1], bottomLeft = corners[2], bottomRight = corners[3];
int size = topRight.height;
//note: Core.atlas uses an array texture, and in this case it is guaranteed to be the same, assuming the packer is set up to target it
TextureRegion white = Core.atlas.white();
TextureRegion top = new TextureRegion(white);
top.height = size;
TextureRegion bottom = new TextureRegion(white);
bottom.height = size;
TextureRegion left = new TextureRegion(white);
left.width = size;
TextureRegion right = new TextureRegion(white);
right.width = size;
TextureRegion center = new TextureRegion(white);
NinePatch patch = new NinePatch(
topLeft, top, topRight,
left, center, right,
bottomLeft, bottom, bottomRight
);
return new NinePatchDrawable(patch);
}
/** Builds an {@link OutlinedCornerDrawable} out of the four corner tiles returned by {@link #renderCorner}. */
private static OutlinedCornerDrawable outlinedDrawable(TextureRegion[] corners, int size, float stroke){
TextureRegion topLeft = corners[0], topRight = corners[1], bottomLeft = corners[2], bottomRight = corners[3];
//note: Core.atlas uses an array texture, and in this case it is guaranteed to be the same, assuming the packer is set up to target it
return new OutlinedCornerDrawable(topLeft, topRight, bottomLeft, bottomRight, Core.atlas.white(), size, stroke);
}
/** @return the antialiased coverage (0-1) of pixel (x, y) with an angled edge. */
private static float coverage(int x, int y, int size){
int inside = 0;
for(int sy = 0; sy < supersample; sy++){
float fy = y + (sy + 0.5f) / supersample;
for(int sx = 0; sx < supersample; sx++){
float fx = x + (sx + 0.5f) / supersample;
if(fy > fx) inside++;
}
}
return inside / (float)(supersample * supersample);
}
/** @return the antialiased coverage (0-1) of pixel (x, y) with an angled outlined edge. */
private static float outlineCoverage(int x, int y, float stroke){
int inside = 0;
float band = stroke;
float extrude = stroke * ((float)Math.sqrt(2.0) - 1f);
for(int sy = 0; sy < supersample; sy++){
float fy = y + (sy + 0.5f) / supersample;
for(int sx = 0; sx < supersample; sx++){
float fx = x + (sx + 0.5f) / supersample;
float diff = fy - fx;
//on the filled side of the diagonal, extruded a bit toward the top-right, and within the band
if(diff >= -extrude && diff < band) inside++;
}
}
return inside / (float)(supersample * supersample);
}
private static float cornerDistance(float fx, float fy, int size){
float qy = Math.max(size - fy, 0f);
return (float)Math.sqrt(fx * fx + qy * qy) - size;
}
private static float roundCoverage(int x, int y, int size){
float d = cornerDistance(x + 0.5f, y + 0.5f, size);
return Mathf.clamp(0.5f - d);
}
private static float outlineRoundCoverage(int x, int y, int size, float stroke){
float d = cornerDistance(x + 0.5f, y + 0.5f, size);
return Mathf.clamp(0.5f - d) - Mathf.clamp(0.5f - d - stroke);
}
/** (x, y) pixel coordinate -> coverage (0-1). */
private interface CoverageFn{
float get(int x, int y);
}
}

View File

@@ -0,0 +1,169 @@
package mindustry.graphics;
import arc.graphics.*;
import arc.graphics.g2d.*;
import arc.math.*;
import arc.scene.style.*;
/**
* Drawable for an antialiased, beveled-corner <i>outline</i>, as generated by {@link CornerGenerator#generateAngledOutline(int, float)}.
* <p>
* This looks similar to a {@link NinePatchDrawable} but it isn't a true nine-patch; the 4 corners are generated, the center is not drawn, and the 4 sides are drawn as lines.
*/
public class OutlinedCornerDrawable extends BaseDrawable implements TransformDrawable{
private static final Color tmpColor = new Color();
//quad slots within the vertex array; there is deliberately no "center" slot, as it is never drawn
private static final int topLeft = 0, topRight = 1, bottomLeft = 2, bottomRight = 3,
top = 4, bottom = 5, left = 6, right = 7;
private static final int quads = 8;
private final Color color = new Color(Color.white);
private final float[] vertices = new float[quads * SpriteBatch.spriteSize];
private Texture texture;
private float cornerSize, strokeWidth;
/**
* @param topLeftRegion generated corner tile for the top-left corner (flipped horizontally from the base tile)
* @param topRightRegion generated corner tile for the top-right corner (the base tile, unflipped)
* @param bottomLeftRegion generated corner tile for the bottom-left corner (flipped both ways)
* @param bottomRightRegion generated corner tile for the bottom-right corner (flipped vertically)
* @param white a plain white region, used to draw the four straight sides; must share a texture with the corners
* @param cornerSize size of each (square) corner region, in draw-time pixels
* @param strokeWidth thickness of the outline, in draw-time pixels
*/
public OutlinedCornerDrawable(TextureRegion topLeftRegion, TextureRegion topRightRegion, TextureRegion bottomLeftRegion, TextureRegion bottomRightRegion, TextureRegion white, float cornerSize, float strokeWidth){
this.texture = white.texture;
this.cornerSize = cornerSize;
this.strokeWidth = strokeWidth;
setUv(topLeft, topLeftRegion);
setUv(topRight, topRightRegion);
setUv(bottomLeft, bottomLeftRegion);
setUv(bottomRight, bottomRightRegion);
setUv(top, white);
setUv(bottom, white);
setUv(left, white);
setUv(right, white);
setMinWidth(cornerSize * 2f);
setMinHeight(cornerSize * 2f);
setTopHeight(cornerSize);
setBottomHeight(cornerSize);
setLeftWidth(cornerSize);
setRightWidth(cornerSize);
}
private void setUv(int quad, TextureRegion region){
int idx = quad * SpriteBatch.spriteSize;
float u = region.u, v = region.v2, u2 = region.u2, v2 = region.v;
float depth = region.getDepth();
float[] vs = vertices;
vs[idx + 2] = u; vs[idx + 3] = v; vs[idx + 4] = depth;
vs[idx + 9] = u; vs[idx + 10] = v2; vs[idx + 11] = depth;
vs[idx + 16] = u2; vs[idx + 17] = v2; vs[idx + 18] = depth;
vs[idx + 23] = u2; vs[idx + 24] = v; vs[idx + 25] = depth;
}
/** Sets the position and color of one of the eight quads. */
private void setQuad(int quad, float x, float y, float w, float h, float c){
int idx = quad * SpriteBatch.spriteSize;
float x2 = x + w, y2 = y + h;
float[] vs = vertices;
vs[idx] = x; vs[idx + 1] = y; vs[idx + 5] = c;
vs[idx + 7] = x; vs[idx + 8] = y2; vs[idx + 12] = c;
vs[idx + 14] = x2; vs[idx + 15] = y2; vs[idx + 19] = c;
vs[idx + 21] = x2; vs[idx + 22] = y; vs[idx + 26] = c;
}
private void prepareVertices(float x, float y, float width, float height){
float c = tmpColor.set(color).mul(Draw.getColor()).toFloatBits();
float innerX = x + cornerSize, innerY = y + cornerSize;
float rightX = x + width - cornerSize, topY = y + height - cornerSize;
//corners: full generated tiles, unscaled, sitting exactly in the four corners
setQuad(bottomLeft, x, y, cornerSize, cornerSize, c);
setQuad(bottomRight, rightX, y, cornerSize, cornerSize, c);
setQuad(topLeft, x, topY, cornerSize, cornerSize, c);
setQuad(topRight, rightX, topY, cornerSize, cornerSize, c);
//sides: plain white, only as thick as the stroke, spanning the gap between corners - not the corner size
float sideWidth = Math.max(0f, width - cornerSize * 2f);
float sideHeight = Math.max(0f, height - cornerSize * 2f);
setQuad(bottom, innerX, y, sideWidth, strokeWidth, c);
setQuad(top, innerX, y + height - strokeWidth, sideWidth, strokeWidth, c);
setQuad(left, x, innerY, strokeWidth, sideHeight, c);
setQuad(right, x + width - strokeWidth, innerY, strokeWidth, sideHeight, c);
}
@Override
public void draw(float x, float y, float width, float height){
prepareVertices(x, y, width, height);
Draw.vert(texture, vertices, 0, vertices.length);
}
@Override
public void draw(float x, float y, float originX, float originY, float width, float height, float scaleX, float scaleY, float rotation){
prepareVertices(x, y, width, height);
float worldOriginX = x + originX, worldOriginY = y + originY;
float[] vs = vertices;
int n = vs.length;
if(rotation != 0){
float cos = Mathf.cosDeg(rotation), sin = Mathf.sinDeg(rotation);
for(int i = 0; i < n; i += SpriteBatch.vertexSize){
float vx = (vs[i] - worldOriginX) * scaleX, vy = (vs[i + 1] - worldOriginY) * scaleY;
vs[i] = cos * vx - sin * vy + worldOriginX;
vs[i + 1] = sin * vx + cos * vy + worldOriginY;
}
}else if(scaleX != 1f || scaleY != 1f){
for(int i = 0; i < n; i += SpriteBatch.vertexSize){
vs[i] = (vs[i] - worldOriginX) * scaleX + worldOriginX;
vs[i + 1] = (vs[i + 1] - worldOriginY) * scaleY + worldOriginY;
}
}
Draw.vert(texture, vs, 0, n);
}
public float getCornerSize(){
return cornerSize;
}
public float getStrokeWidth(){
return strokeWidth;
}
public Color getColor(){
return color;
}
/** Copy given color. Blended with the batch color, then combined with the texture colors at draw time. */
public void setColor(Color color){
this.color.set(color);
}
/** Creates a new drawable that renders the same as this drawable tinted the specified color. */
public OutlinedCornerDrawable tint(Color tint){
OutlinedCornerDrawable drawable = new OutlinedCornerDrawable(this);
drawable.color.set(tint);
return drawable;
}
/** Copy constructor; shares the same texture/UV data (via {@link #vertices}) as the source drawable. */
private OutlinedCornerDrawable(OutlinedCornerDrawable other){
super(other);
this.texture = other.texture;
this.cornerSize = other.cornerSize;
this.strokeWidth = other.strokeWidth;
this.color.set(other.color);
System.arraycopy(other.vertices, 0, this.vertices, 0, other.vertices.length);
}
}

View File

@@ -558,7 +558,7 @@ public abstract class SaveVersion extends SaveFileReader{
//the requiredPlanets filter needs this, since the rules aren't read yet
if(headless){
try{
Planet planet = content.planet(new JsonReader().parse(saveState.ruleString).getString("planet", Planets.serpulo.name));
Planet planet = content.planet(Jval.read(saveState.ruleString).getString("planet", Planets.serpulo.name));
state.rules.planet = planet == null ? Planets.serpulo : planet;
}catch(Exception ignored){}
}

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=b63432df12
archash=c082cc548b