Five freely combinable variants for floor, earth, rock and ore textures

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-07 22:02:55 +01:00
1 parent ad668ec123
commit 362f4e7670
59 files changed
+101 -40

No files matched your search

+16
View File
@@ -85,3 +85,19 @@ Keep this exact seamless tileable earth texture (same colours, same soil, same s
Gold has four variants (`--size 128` and `--size 256` like the others). Silver, ruby and diamond Gold has four variants (`--size 128` and `--size 256` like the others). Silver, ruby and diamond
have two variants: one seamless tile for both (`--size 64 --colors 32 --band 8` and have two variants: one seamless tile for both (`--size 64 --colors 32 --band 8` and
`--size 128 --colors 48 --band 14`), copied to `assets/textures/<name>.png` and `<name>-128.png`. `--size 128 --colors 48 --band 14`), copied to `assets/textures/<name>.png` and `<name>-128.png`.
## Five variants per kind (2026-11-07)
Floor, earth, rock and the ores (gold, silver, ruby, diamond) now have five tiles each that fit
together in any order (`HD_SETS` in `src/gfx.rs`; a tile picks one by its position). No new
pictures: `tools/make_tile.py --variants 5` takes the seamless sheet of a raw picture, makes one
quarter of it seamless on its own as the shared border and fills the middles of the other tiles
from other windows of the sheet:
```bash
python3 tools/make_tile.py <name>-raw.png <name> --size 256 --colors 48 --band 28 --variants 5 # 128x128 tiles
python3 tools/make_tile.py <name>-raw.png <name> --size 128 --colors 32 --band 14 --variants 5 # 64x64 tiles
```
`<name>-v64.png` / `<name>-v128.png` are copied to `assets/textures/`; `<name>-v128-preview.png`
shows a floor laid out at random. Water and lava keep their 2x2 blocks (they flow).
Binary file not shown.

After

Width:  |  Height:  |  Size: 360 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 311 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

+42 -38
View File
@@ -6,7 +6,7 @@ use macroquad::prelude::*;
pub const CELL: f32 = 16.0; pub const CELL: f32 = 16.0;
pub const ATLAS_COLS: u32 = 16; pub const ATLAS_COLS: u32 = 16;
pub const ATLAS_ROWS: u32 = 16; // rows 14-15: cells only the high-res atlases fill (see HD_FLOORS) pub const ATLAS_ROWS: u32 = 18; // rows 14-17: cells only the high-res atlases fill (see HD_SETS)
// atlas indices (4 variants each) // atlas indices (4 variants each)
pub const T_ROCK: u32 = 0; pub const T_ROCK: u32 = 0;
@@ -337,39 +337,42 @@ fn earth_px(x: i32, y: i32, v: u32) -> Color {
// tiles and has no seams), for kinds with two variants one seamless tile for both. // tiles and has no seams), for kinds with two variants one seamless tile for both.
pub const HD_TILE: u32 = 64; pub const HD_TILE: u32 = 64;
pub const HD_TILE2: u32 = 128; pub const HD_TILE2: u32 = 128;
// (first cell, variants, 64x64 level, 128x128 level) // the liquids: (first cell, variants, 64x64 level, 128x128 level); a seamless 2x2 block of
const HD_TILES: [(u32, u32, &[u8], &[u8]); 9] = [ // tiles each (variant k = quarter k % 2, k / 2), which the shader lays out by position while
(T_FLOOR, 4, include_bytes!("../assets/textures/floor.png"), include_bytes!("../assets/textures/floor-256.png")), // the texture flows
(T_EARTH, 4, include_bytes!("../assets/textures/earth.png"), include_bytes!("../assets/textures/earth-256.png")), const HD_TILES: [(u32, u32, &[u8], &[u8]); 2] = [
(T_ROCK, 4, include_bytes!("../assets/textures/rock.png"), include_bytes!("../assets/textures/rock-256.png")),
(T_GOLD, 4, include_bytes!("../assets/textures/gold.png"), include_bytes!("../assets/textures/gold-256.png")),
(T_WATER, 4, include_bytes!("../assets/textures/water.png"), include_bytes!("../assets/textures/water-256.png")), (T_WATER, 4, include_bytes!("../assets/textures/water.png"), include_bytes!("../assets/textures/water-256.png")),
(T_LAVA, 4, include_bytes!("../assets/textures/lava.png"), include_bytes!("../assets/textures/lava-256.png")), (T_LAVA, 4, include_bytes!("../assets/textures/lava.png"), include_bytes!("../assets/textures/lava-256.png")),
(T_SILVER, 2, include_bytes!("../assets/textures/silver.png"), include_bytes!("../assets/textures/silver-128.png")),
(T_RUBY, 2, include_bytes!("../assets/textures/ruby.png"), include_bytes!("../assets/textures/ruby-128.png")),
(T_DIAMOND, 2, include_bytes!("../assets/textures/diamond.png"), include_bytes!("../assets/textures/diamond-128.png")),
]; ];
// The masters' claimed floors in the high-res style: the same grey, worn-smooth castle // Sets of five tiles that fit together in any order, so a floor or a wall shows no pattern:
// flagstones for everybody (cut so they fit in any order), every tile with a polished inlay in // the ground kinds (all five tiles share one seamless border, their middles differ; see
// its master's colour and an engraving of their own (the player: a demon skull, Mossgrim: a // tools/make_tile.py --variants) and the masters' claimed floors (the same grey, worn-smooth
// mushroom, Lady Nightwell: a lily under the moon, Saffrax: an alchemical sun). Five tiles each: // castle flagstones for everybody, every tile with a polished inlay in its master's colour and
// plain, cracked, chipped, broken, scratched. They go into cells of their own (from T_HDFLOOR on, // an engraving of their own: the player a demon skull, Mossgrim a mushroom, Lady Nightwell a lily
// five per master); a tile picks one by its position, weighted (plain ones most often). // under the moon, Saffrax an alchemical sun; plain, cracked, chipped, broken, scratched). They go
// (the claimed floor's cells, the first own cell, 64x64 tiles side by side, 128x128 ones, weights) // into cells of their own from T_HDSET on, five per set; a tile picks one by its position,
pub const T_HDFLOOR: u32 = 224; // weighted. (the kind's cells: first and count, the first own cell, 64x64 tiles side by side,
const HD_FLOORS: [(u32, u32, &[u8], &[u8], [u32; 5]); 4] = [ // 128x128 ones, weights)
(T_CLAIM, T_HDFLOOR, include_bytes!("../assets/textures/floor-player.png"), include_bytes!("../assets/textures/floor-player-128.png"), [4, 2, 2, 1, 3]), pub const T_HDSET: u32 = 224;
(T_RIVAL1, T_HDFLOOR + 5, include_bytes!("../assets/textures/floor-mossgrim.png"), include_bytes!("../assets/textures/floor-mossgrim-128.png"), [4, 2, 2, 1, 3]), const HD_SETS: [(u32, u32, u32, &[u8], &[u8], [u32; 5]); 11] = [
(T_RIVAL2, T_HDFLOOR + 10, include_bytes!("../assets/textures/floor-nightwell.png"), include_bytes!("../assets/textures/floor-nightwell-128.png"), [4, 2, 2, 1, 3]), (T_CLAIM, 4, T_HDSET, include_bytes!("../assets/textures/floor-player.png"), include_bytes!("../assets/textures/floor-player-128.png"), [4, 2, 2, 1, 3]),
(T_RIVAL3, T_HDFLOOR + 15, include_bytes!("../assets/textures/floor-saffrax.png"), include_bytes!("../assets/textures/floor-saffrax-128.png"), [4, 2, 2, 1, 3]), (T_RIVAL1, 4, T_HDSET + 5, include_bytes!("../assets/textures/floor-mossgrim.png"), include_bytes!("../assets/textures/floor-mossgrim-128.png"), [4, 2, 2, 1, 3]),
(T_RIVAL2, 4, T_HDSET + 10, include_bytes!("../assets/textures/floor-nightwell.png"), include_bytes!("../assets/textures/floor-nightwell-128.png"), [4, 2, 2, 1, 3]),
(T_RIVAL3, 4, T_HDSET + 15, include_bytes!("../assets/textures/floor-saffrax.png"), include_bytes!("../assets/textures/floor-saffrax-128.png"), [4, 2, 2, 1, 3]),
(T_FLOOR, 4, T_HDSET + 20, include_bytes!("../assets/textures/floor-v64.png"), include_bytes!("../assets/textures/floor-v128.png"), [1; 5]),
(T_EARTH, 4, T_HDSET + 25, include_bytes!("../assets/textures/earth-v64.png"), include_bytes!("../assets/textures/earth-v128.png"), [1; 5]),
(T_ROCK, 4, T_HDSET + 30, include_bytes!("../assets/textures/rock-v64.png"), include_bytes!("../assets/textures/rock-v128.png"), [1; 5]),
(T_GOLD, 4, T_HDSET + 35, include_bytes!("../assets/textures/gold-v64.png"), include_bytes!("../assets/textures/gold-v128.png"), [1; 5]),
(T_SILVER, 2, T_HDSET + 40, include_bytes!("../assets/textures/silver-v64.png"), include_bytes!("../assets/textures/silver-v128.png"), [1; 5]),
(T_RUBY, 2, T_HDSET + 45, include_bytes!("../assets/textures/ruby-v64.png"), include_bytes!("../assets/textures/ruby-v128.png"), [1; 5]),
(T_DIAMOND, 2, T_HDSET + 50, include_bytes!("../assets/textures/diamond-v64.png"), include_bytes!("../assets/textures/diamond-v128.png"), [1; 5]),
]; ];
// the cell a tile at (x, z) shows in the high-res style: generated textures with four variants // the cell a tile at (x, z) shows in the high-res style: the kinds with a set pick one of its
// are laid out by the position (see HD_TILES), the masters' floors pick one of their five slabs // five tiles (HD_SETS), the liquids are laid out by the position (HD_TILES), everything else stays
// (HD_FLOORS), everything else stays
pub fn hd_tile(id: u32, x: i32, z: i32) -> u32 { pub fn hd_tile(id: u32, x: i32, z: i32) -> u32 {
if let Some(&(_, own, _, _, w)) = HD_FLOORS.iter().find(|f| (f.0..f.0 + 4).contains(&id)) { if let Some(&(_, _, own, _, _, w)) = HD_SETS.iter().find(|f| (f.0..f.0 + f.1).contains(&id)) {
let h = (x as u32).wrapping_mul(73_856_093) ^ (z as u32).wrapping_mul(19_349_663); let h = (x as u32).wrapping_mul(73_856_093) ^ (z as u32).wrapping_mul(19_349_663);
let h = (h ^ (h >> 13)).wrapping_mul(0x5bd1_e995); let h = (h ^ (h >> 13)).wrapping_mul(0x5bd1_e995);
let mut r = (h >> 8) % w.iter().sum::<u32>(); let mut r = (h >> 8) % w.iter().sum::<u32>();
@@ -410,8 +413,8 @@ fn gen_hd_atlas(tile: u32) -> Image {
} }
} }
} }
for &(_, own, png, png2, _) in HD_FLOORS.iter() { for &(_, _, own, png, png2, _) in HD_SETS.iter() {
let src = Image::from_file_with_format(if tile == HD_TILE2 { png2 } else { png }, Some(ImageFormat::Png)).expect("hd floor"); let src = Image::from_file_with_format(if tile == HD_TILE2 { png2 } else { png }, Some(ImageFormat::Png)).expect("hd set");
for v in 0..5 { for v in 0..5 {
let id = own + v; let id = own + v;
let (ox, oy) = ((id % ATLAS_COLS) * tile, (id / ATLAS_COLS) * tile); let (ox, oy) = ((id % ATLAS_COLS) * tile, (id / ATLAS_COLS) * tile);
@@ -2768,8 +2771,8 @@ mod hd_atlas_tests {
assert_eq!(px(&hd, ox + 7, oy + 40), px(&src, sx + 7, sy + 40)); assert_eq!(px(&hd, ox + 7, oy + 40), px(&src, sx + 7, sy + 40));
} }
} }
// the masters' floors: five slabs each in their own cells (inside the atlas) // the sets: five tiles each in their own cells (inside the atlas)
for &(_, own, png, png2, _) in HD_FLOORS.iter() { for &(_, _, own, png, png2, _) in HD_SETS.iter() {
let src = Image::from_file_with_format(if tile == HD_TILE2 { png2 } else { png }, Some(ImageFormat::Png)).unwrap(); let src = Image::from_file_with_format(if tile == HD_TILE2 { png2 } else { png }, Some(ImageFormat::Png)).unwrap();
assert_eq!((src.width as u32, src.height as u32), (5 * tile, tile)); assert_eq!((src.width as u32, src.height as u32), (5 * tile, tile));
assert!(own + 5 <= ATLAS_COLS * ATLAS_ROWS); assert!(own + 5 <= ATLAS_COLS * ATLAS_ROWS);
@@ -2785,17 +2788,18 @@ mod hd_atlas_tests {
for z in 0..60 { for z in 0..60 {
for x in 0..60 { for x in 0..60 {
let id = hd_tile(T_CLAIM + (x as u32 % 4), x, z); let id = hd_tile(T_CLAIM + (x as u32 % 4), x, z);
assert!((T_HDFLOOR..T_HDFLOOR + 5).contains(&id)); assert!((T_HDSET..T_HDSET + 5).contains(&id));
n[(id - T_HDFLOOR) as usize] += 1; n[(id - T_HDSET) as usize] += 1;
} }
} }
assert!(n.iter().all(|&k| k > 100), "{n:?}"); assert!(n.iter().all(|&k| k > 100), "{n:?}");
assert!(n[0] > n[3] * 2, "{n:?}"); assert!(n[0] > n[3] * 2, "{n:?}");
assert!((T_HDFLOOR + 15..T_HDFLOOR + 20).contains(&hd_tile(T_RIVAL3 + 2, 3, 7))); assert!((T_HDSET + 15..T_HDSET + 20).contains(&hd_tile(T_RIVAL3 + 2, 3, 7)));
// four variants by position, two variants keep theirs, others stay assert!((T_HDSET + 50..T_HDSET + 55).contains(&hd_tile(T_DIAMOND + 1, 8, 2)));
assert_eq!(hd_tile(T_FLOOR + 3, 0, 0), T_FLOOR); assert!((T_HDSET + 25..T_HDSET + 30).contains(&hd_tile(T_EARTH + 3, 5, 5)));
assert_eq!(hd_tile(T_FLOOR, 1, 1), T_FLOOR + 3); // the liquids: four variants by position; others stay
assert_eq!(hd_tile(T_RUBY + 1, 1, 0), T_RUBY + 1); assert_eq!(hd_tile(T_WATER + 3, 0, 0), T_WATER);
assert_eq!(hd_tile(T_LAVA, 1, 1), T_LAVA + 3);
assert_eq!(hd_tile(T_TREASURY + 2, 1, 0), T_TREASURY + 2); assert_eq!(hd_tile(T_TREASURY + 2, 1, 0), T_TREASURY + 2);
} }
} }
+43 -2
View File
@@ -2,7 +2,7 @@
"""Turn a generated texture into a seamless pixel-art tile sheet for the high-res atlas. """Turn a generated texture into a seamless pixel-art tile sheet for the high-res atlas.
Usage: Usage:
make_tile.py raw.png out_prefix [--size 128] [--colors 32] [--band 14] make_tile.py raw.png out_prefix [--size 128] [--colors 32] [--band 14] [--variants N]
The picture is scaled down to size x size (default 128: a 2x2 block of 64x64 tiles; the game The picture is scaled down to size x size (default 128: a 2x2 block of 64x64 tiles; the game
lays the four quarters out by the tiles' positions, so the floor repeats only every two tiles), lays the four quarters out by the tiles' positions, so the floor repeats only every two tiles),
@@ -10,9 +10,15 @@ made seamless (near the edges it fades into a copy shifted by half the size, who
around without a seam; `--band` pixels wide) and reduced to a palette without dithering. around without a seam; `--band` pixels wide) and reduced to a palette without dithering.
Writes out_prefix-<size>.png and out_prefix-<size>-preview.png (3x3 copies scaled up 3x, to Writes out_prefix-<size>.png and out_prefix-<size>-preview.png (3x3 copies scaled up 3x, to
check the seams). Needs Pillow. check the seams).
--variants N: instead N single tiles of size/2 that fit together in any order: all share the
same seamless border (a quarter of the sheet made seamless on its own), their middles come from
different windows of the sheet. Writes out_prefix-v<tile>.png (the tiles side by side) and
out_prefix-v<tile>-preview.png (a floor of them laid out at random). Needs Pillow.
""" """
import argparse import argparse
import random
from PIL import Image, ImageChops from PIL import Image, ImageChops
@@ -29,6 +35,38 @@ def seamless(img, band):
return Image.composite(img, shifted, mask) return Image.composite(img, shifted, mask)
def window(img, x, y, n):
# an n x n window of a seamless picture, wrapping around
return ImageChops.offset(img, -x, -y).crop((0, 0, n, n))
def variants(sheet, a):
t = a.size // 2
base = seamless(sheet.crop((0, 0, t, t)), a.band)
band = max(4, t * 18 // 100)
mask = Image.new("L", (t, t))
for y in range(t):
for x in range(t):
d = min(x, t - 1 - x, y, t - 1 - y)
mask.putpixel((x, y), int(255 * max(0.0, min(1.0, (d - band * 0.4) / (band * 0.6)))))
tiles = [base]
for k in range(1, a.variants):
w = window(sheet, (k * t * 37 // 100 + t // 2 * (k % 2)) % a.size, (k * t * 61 // 100) % a.size, t)
tiles.append(Image.composite(w, base, mask))
strip = Image.new("RGB", (t * len(tiles), t))
for k, tile in enumerate(tiles):
strip.paste(tile, (k * t, 0))
strip = strip.quantize(colors=a.colors, method=Image.Quantize.MEDIANCUT, dither=Image.Dither.NONE).convert("RGB")
strip.save(f"{a.out}-v{t}.png")
rnd = random.Random(3)
prev = Image.new("RGB", (t * 6, t * 4))
for k in range(24):
v = rnd.randrange(len(tiles))
prev.paste(strip.crop((v * t, 0, (v + 1) * t, t)), ((k % 6) * t, (k // 6) * t))
prev.resize((prev.width * 2, prev.height * 2), Image.NEAREST).save(f"{a.out}-v{t}-preview.png")
print(f"{a.out}-v{t}.png: {len(tiles)} tiles")
def main(): def main():
ap = argparse.ArgumentParser() ap = argparse.ArgumentParser()
ap.add_argument("raw") ap.add_argument("raw")
@@ -36,6 +74,7 @@ def main():
ap.add_argument("--size", type=int, default=128) ap.add_argument("--size", type=int, default=128)
ap.add_argument("--colors", type=int, default=32) ap.add_argument("--colors", type=int, default=32)
ap.add_argument("--band", type=int, default=14) ap.add_argument("--band", type=int, default=14)
ap.add_argument("--variants", type=int, default=0)
a = ap.parse_args() a = ap.parse_args()
img = Image.open(a.raw).convert("RGB") img = Image.open(a.raw).convert("RGB")
@@ -43,6 +82,8 @@ def main():
img = img.crop(((img.width - s) // 2, (img.height - s) // 2, (img.width + s) // 2, (img.height + s) // 2)) img = img.crop(((img.width - s) // 2, (img.height - s) // 2, (img.width + s) // 2, (img.height + s) // 2))
img = img.resize((a.size, a.size), Image.LANCZOS) img = img.resize((a.size, a.size), Image.LANCZOS)
img = seamless(img, a.band) img = seamless(img, a.band)
if a.variants:
return variants(img, a)
img = img.quantize(colors=a.colors, method=Image.Quantize.MEDIANCUT, dither=Image.Dither.NONE).convert("RGB") img = img.quantize(colors=a.colors, method=Image.Quantize.MEDIANCUT, dither=Image.Dither.NONE).convert("RGB")
img.save(f"{a.out}-{a.size}.png") img.save(f"{a.out}-{a.size}.png")
prev = Image.new("RGB", (a.size * 3, a.size * 3)) prev = Image.new("RGB", (a.size * 3, a.size * 3))