Map: block colours from real block textures; palette-limited PNG tiles
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT
This commit is contained in:
7 files changed
+561
-34
No files matched your search
@@ -35,13 +35,71 @@ public final class BlockColors {
|
||||
|
||||
private static final int[] UNKNOWN = {STONE, DIRT, WOOD, GRAY, BROWN, LIGHT_GRAY, DEEPSLATE};
|
||||
|
||||
/** 0 for blocks a map looks straight through (air, glass, rails, torches). */
|
||||
/** Water is special-cased by the renderer, which shades it by depth. */
|
||||
public static final int WATER_COLOR = 0xFF3F76E4;
|
||||
|
||||
private static final Map<String, Integer> TEXTURES = loadTextures();
|
||||
private static final Map<String, Integer> COLORS = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* The colour this block shows as on the map (opaque ARGB), or 0 for blocks a map looks straight through (air, glass, rails).
|
||||
* Vanilla blocks use the average colour of their real top texture; anything without one falls back to the map colour table.
|
||||
*/
|
||||
public static int of(String blockName) {
|
||||
return COLORS.computeIfAbsent(blockName, BlockColors::resolve);
|
||||
}
|
||||
|
||||
/** The nearest vanilla map colour id for a block (0 = see-through); also what the fallback palette uses. */
|
||||
public static int mapColor(String blockName) {
|
||||
return CACHE.computeIfAbsent(blockName, BlockColors::classify);
|
||||
}
|
||||
|
||||
private static int resolve(String full) {
|
||||
int id = mapColor(full);
|
||||
if (id == 0) return 0;
|
||||
if (id == WATER) return WATER_COLOR;
|
||||
String n = plain(full);
|
||||
Integer exact = TEXTURES.get(n);
|
||||
if (exact != null) return exact;
|
||||
String s = n;
|
||||
for (boolean changed = true; changed; ) {
|
||||
changed = false;
|
||||
for (String p : STRIP_PREFIX) if (s.startsWith(p) && s.length() > p.length()) { s = s.substring(p.length()); changed = true; }
|
||||
for (String p : STRIP_SUFFIX) if (s.endsWith(p) && s.length() > p.length()) { s = s.substring(0, s.length() - p.length()); changed = true; }
|
||||
if (!changed) break;
|
||||
for (String candidate : new String[]{s, s + "s", s + "_planks", s + "_block"}) {
|
||||
Integer hit = TEXTURES.get(candidate);
|
||||
if (hit != null) return hit;
|
||||
}
|
||||
}
|
||||
return MapColors.base(id);
|
||||
}
|
||||
|
||||
private static String plain(String full) {
|
||||
return full.startsWith("minecraft:") ? full.substring(10) : full.substring(full.indexOf(':') + 1);
|
||||
}
|
||||
|
||||
private static Map<String, Integer> loadTextures() {
|
||||
Map<String, Integer> out = new java.util.HashMap<>();
|
||||
try (java.io.InputStream in = BlockColors.class.getResourceAsStream("block_colors.csv")) {
|
||||
if (in == null) return out;
|
||||
for (String line : new String(in.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8).split("\n")) {
|
||||
int comma = line.indexOf(',');
|
||||
if (comma <= 0 || comma + 7 > line.length()) continue;
|
||||
try {
|
||||
out.put(line.substring(0, comma), 0xFF000000 | Integer.parseInt(line.substring(comma + 1, comma + 7).trim(), 16));
|
||||
} catch (NumberFormatException ignored) {
|
||||
// a malformed row just falls back to the map colour table
|
||||
}
|
||||
}
|
||||
} catch (java.io.IOException ignored) {
|
||||
// no texture table: the map colour table covers everything
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static int classify(String full) {
|
||||
String n = full.startsWith("minecraft:") ? full.substring(10) : full.substring(full.indexOf(':') + 1);
|
||||
String n = plain(full);
|
||||
int c = lookup(n);
|
||||
if (c >= 0) return c;
|
||||
// Peel shape and finish words (cut_sandstone_stairs -> sandstone) and try again.
|
||||
@@ -150,5 +208,5 @@ public final class BlockColors {
|
||||
return -1;
|
||||
}
|
||||
|
||||
public static boolean isWater(int colour) { return colour == WATER; }
|
||||
public static boolean isWater(int colour) { return colour == WATER_COLOR; }
|
||||
}
|
||||
@@ -82,7 +82,7 @@ public final class ChunkSampler {
|
||||
if (c == 0) continue;
|
||||
out.color[i] = c;
|
||||
out.height[i] = worldY;
|
||||
if (c == MapColors.WATER) out.depth[i] = waterDepth(sections, si, ly, x, z);
|
||||
if (c == BlockColors.WATER_COLOR) out.depth[i] = waterDepth(sections, si, ly, x, z);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -97,7 +97,7 @@ public final class ChunkSampler {
|
||||
for (int s = si; s < sections.size(); s++) {
|
||||
Section sec = sections.get(s);
|
||||
for (int y = s == si ? ly - 1 : 15; y >= 0; y--) {
|
||||
if (sec.colorAt(x, y, z) != MapColors.WATER) return depth;
|
||||
if (sec.colorAt(x, y, z) != BlockColors.WATER_COLOR) return depth;
|
||||
if (++depth >= 64) return depth;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,9 @@ public final class MapColors {
|
||||
/** Brightness factors for the four shades: darkest .. brightest, as a map shows low, normal, high and lowest ground. */
|
||||
private static final int[] SHADE = {180, 220, 255, 135};
|
||||
|
||||
/** The colour's base RGB (opaque ARGB), or 0 for none. */
|
||||
public static int base(int id) { return id <= 0 || id >= COUNT ? 0 : 0xFF000000 | RGB[id]; }
|
||||
|
||||
/** Opaque ARGB for a colour id at a shade (0 = low, 1 = normal, 2 = high, 3 = lowest); 0 for no colour. */
|
||||
public static int argb(int id, int shade) {
|
||||
if (id <= 0 || id >= COUNT) return 0;
|
||||
|
||||
@@ -3,6 +3,9 @@ package net.scopenet.worldmap;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -18,6 +21,7 @@ public final class Png {
|
||||
* that fits (a typical tile is a few KB instead of tens); anything with more than 256 colours falls back to RGBA.
|
||||
*/
|
||||
public static byte[] encode(int[] argb, int width, int height) {
|
||||
argb = limitColors(argb);
|
||||
Map<Integer, Integer> palette = new LinkedHashMap<>();
|
||||
for (int c : argb) {
|
||||
int key = (c >>> 24) == 0 ? 0 : c;
|
||||
@@ -65,6 +69,36 @@ public final class Png {
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps a tile within 256 colours: when it has more, the rarest colours are snapped to the nearest common one. Tiles
|
||||
* rarely get there (blocks have few colours and shading adds only a handful of steps), and the change is hard to see.
|
||||
*/
|
||||
static int[] limitColors(int[] argb) {
|
||||
Map<Integer, Integer> counts = new HashMap<>();
|
||||
for (int c : argb) if ((c >>> 24) != 0) counts.merge(c, 1, Integer::sum);
|
||||
if (counts.size() <= 255) return argb;
|
||||
List<Integer> keep = new ArrayList<>(counts.keySet());
|
||||
keep.sort((a, b) -> counts.get(b) - counts.get(a));
|
||||
List<Integer> top = new ArrayList<>(keep.subList(0, 255));
|
||||
Set<Integer> kept = new HashSet<>(top);
|
||||
Map<Integer, Integer> remap = new HashMap<>();
|
||||
int[] out = argb.clone();
|
||||
for (int i = 0; i < out.length; i++) {
|
||||
int c = out[i];
|
||||
if ((c >>> 24) == 0 || kept.contains(c)) continue;
|
||||
out[i] = remap.computeIfAbsent(c, k -> {
|
||||
int best = top.get(0), bestD = Integer.MAX_VALUE;
|
||||
for (int q : top) {
|
||||
int dr = (k >> 16 & 0xFF) - (q >> 16 & 0xFF), dg = (k >> 8 & 0xFF) - (q >> 8 & 0xFF), db = (k & 0xFF) - (q & 0xFF);
|
||||
int d = dr * dr * 2 + dg * dg * 4 + db * db * 3;
|
||||
if (d < bestD) { bestD = d; best = q; }
|
||||
}
|
||||
return best;
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static final byte[] SIGNATURE = {(byte) 0x89, 'P', 'N', 'G', '\r', '\n', 0x1A, '\n'};
|
||||
|
||||
private static byte[] deflate(byte[] raw) {
|
||||
|
||||
@@ -47,7 +47,10 @@ public final class RegionRenderer {
|
||||
return tiles;
|
||||
}
|
||||
|
||||
/** Shading as a vanilla map does it: slope against the block to the north, depth for water, plus a light dither. */
|
||||
/**
|
||||
* Shading: ground is lit by its slope against the block to the north (a gentle relief effect), water darkens with depth.
|
||||
* Colours are the blocks' own, so they stay in few steps and compress well.
|
||||
*/
|
||||
static int[] shade(int[] colors, int[] heights, int[] depths) {
|
||||
int[] out = new int[SIDE * SIDE];
|
||||
for (int z = 0; z < SIDE; z++) {
|
||||
@@ -55,18 +58,23 @@ public final class RegionRenderer {
|
||||
int i = z * SIDE + x;
|
||||
int c = colors[i];
|
||||
if (c == 0) continue;
|
||||
int shade;
|
||||
if (c == MapColors.WATER) {
|
||||
double d = depths[i] * 0.1 + ((x + z) & 1) * 0.2;
|
||||
shade = d < 0.5 ? 2 : d > 0.9 ? 0 : 1;
|
||||
int percent;
|
||||
if (c == BlockColors.WATER_COLOR) {
|
||||
percent = 108 - Math.min(depths[i], 14) * 4;
|
||||
} else {
|
||||
int north = z > 0 && colors[i - SIDE] != 0 ? heights[i - SIDE] : heights[i];
|
||||
double d = (heights[i] - north) * 4.0 / 5.0 + (((x + z) & 1) - 0.5) * 0.4;
|
||||
shade = d > 0.6 ? 2 : d < -0.6 ? 0 : 1;
|
||||
int diff = heights[i] - north;
|
||||
percent = diff >= 2 ? 120 : diff == 1 ? 109 : diff == 0 ? 100 : diff == -1 ? 90 : 80;
|
||||
}
|
||||
out[i] = MapColors.argb(c, shade);
|
||||
out[i] = scale(c, percent);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
static int scale(int argb, int percent) {
|
||||
int r = Math.min(255, (argb >> 16 & 0xFF) * percent / 100), g = Math.min(255, (argb >> 8 & 0xFF) * percent / 100),
|
||||
b = Math.min(255, (argb & 0xFF) * percent / 100);
|
||||
return 0xFF000000 | r << 16 | g << 8 | b;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user