Files

319 lines
10 KiB
Python

#!/usr/bin/env python3
"""Generate the pixel-art textures used by the Bevy Forge demo project.
All textures are drawn procedurally from a small palette so the demo stays
license-clean and can be regenerated on any machine with Pillow installed:
python3 examples/demo-project/tools/generate_textures.py
The output is written to ``examples/demo-project/assets/textures``.
"""
from __future__ import annotations
import random
from pathlib import Path
from PIL import Image, ImageDraw
ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "assets" / "textures"
OUT.mkdir(parents=True, exist_ok=True)
# Shared palette (deliberately small, dusk / forest leaning).
OUTLINE = (43, 32, 51, 255)
SKIN = (244, 201, 158, 255)
SKIN_SHADOW = (217, 164, 122, 255)
HAIR = (122, 78, 45, 255)
TUNIC = (54, 126, 143, 255)
TUNIC_DARK = (38, 95, 110, 255)
BELT = (91, 62, 38, 255)
BUCKLE = (242, 179, 61, 255)
PANTS = (64, 79, 105, 255)
BOOT = (74, 56, 43, 255)
CREAM = (255, 244, 214, 255)
def logical(width: int, height: int) -> tuple[Image.Image, ImageDraw.ImageDraw]:
image = Image.new("RGBA", (width, height), (0, 0, 0, 0))
return image, ImageDraw.Draw(image)
def upscale(image: Image.Image, factor: int) -> Image.Image:
return image.resize(
(image.width * factor, image.height * factor),
Image.Resampling.NEAREST,
)
def save(image: Image.Image, name: str) -> None:
image.save(OUT / name, optimize=True)
print(f"wrote {name}: {image.width}x{image.height}")
def draw_player() -> None:
image, draw = logical(16, 24)
# Boots.
draw.rectangle((3, 20, 7, 23), fill=OUTLINE)
draw.rectangle((8, 20, 12, 23), fill=OUTLINE)
draw.rectangle((4, 21, 7, 22), fill=BOOT)
draw.rectangle((8, 21, 11, 22), fill=BOOT)
# Legs.
draw.rectangle((4, 16, 11, 20), fill=OUTLINE)
draw.rectangle((5, 17, 10, 20), fill=PANTS)
draw.rectangle((7, 17, 7, 19), fill=TUNIC_DARK)
# Torso and arms.
draw.rectangle((3, 10, 12, 16), fill=OUTLINE)
draw.rectangle((4, 11, 11, 15), fill=TUNIC)
draw.rectangle((2, 11, 3, 15), fill=OUTLINE)
draw.rectangle((12, 11, 13, 15), fill=OUTLINE)
draw.rectangle((3, 12, 3, 14), fill=TUNIC)
draw.rectangle((12, 12, 12, 14), fill=TUNIC)
draw.rectangle((3, 16, 4, 16), fill=SKIN)
draw.rectangle((11, 16, 12, 16), fill=SKIN)
draw.rectangle((3, 14, 12, 15), fill=BELT)
draw.rectangle((7, 14, 8, 15), fill=BUCKLE)
draw.rectangle((4, 11, 11, 11), fill=TUNIC_DARK)
# Head with a friendly face.
draw.rectangle((4, 2, 11, 10), fill=OUTLINE)
draw.rectangle((5, 3, 10, 9), fill=SKIN)
draw.rectangle((5, 2, 10, 4), fill=HAIR)
draw.rectangle((4, 4, 4, 6), fill=HAIR)
draw.rectangle((11, 4, 11, 6), fill=HAIR)
draw.rectangle((6, 6, 6, 6), fill=OUTLINE)
draw.rectangle((9, 6, 9, 6), fill=OUTLINE)
draw.rectangle((7, 8, 8, 8), fill=SKIN_SHADOW)
draw.rectangle((5, 8, 5, 8), fill=SKIN_SHADOW)
draw.rectangle((10, 8, 10, 8), fill=SKIN_SHADOW)
save(upscale(image, 4), "player.png")
def draw_hat() -> None:
image, draw = logical(16, 8)
hat = (228, 87, 46, 255)
hat_shine = (244, 138, 54, 255)
brim = (188, 62, 34, 255)
hat_outline = (63, 35, 28, 255)
draw.rectangle((3, 1, 12, 5), fill=hat_outline)
draw.rectangle((4, 2, 11, 4), fill=hat)
draw.rectangle((5, 2, 8, 3), fill=hat_shine)
draw.rectangle((7, 0, 8, 0), fill=hat_outline)
draw.rectangle((1, 5, 14, 7), fill=hat_outline)
draw.rectangle((2, 5, 13, 6), fill=brim)
draw.rectangle((4, 4, 11, 4), fill=brim)
save(upscale(image, 4), "hat.png")
def draw_tile() -> None:
image, draw = logical(24, 24)
dirt = (122, 78, 45, 255)
dirt_dark = (92, 58, 34, 255)
dirt_light = (155, 118, 75, 255)
grass = (78, 159, 61, 255)
grass_light = (111, 207, 95, 255)
grass_dark = (46, 91, 38, 255)
draw.rectangle((0, 0, 23, 23), fill=dirt)
draw.rectangle((0, 0, 23, 1), fill=grass_light)
draw.rectangle((0, 2, 23, 5), fill=grass)
draw.rectangle((0, 6, 23, 6), fill=grass_dark)
draw.rectangle((0, 7, 23, 7), fill=dirt_dark)
# Grass dribbling over the soil edge.
for x in (2, 3, 8, 9, 15, 20, 21):
draw.rectangle((x, 7, x, 8), fill=grass)
draw.rectangle((3, 7, 6, 8), fill=grass_dark)
draw.rectangle((16, 7, 18, 9), fill=grass_dark)
# Soil speckles and stones.
rng = random.Random(11)
for _ in range(16):
x = rng.randint(1, 21)
y = rng.randint(10, 22)
draw.rectangle((x, y, x + 1, y), fill=dirt_dark)
for _ in range(8):
x = rng.randint(2, 20)
y = rng.randint(12, 21)
draw.rectangle((x, y, x, y), fill=dirt_light)
draw.rectangle((5, 14, 7, 14), fill=(155, 122, 83, 255))
draw.rectangle((17, 18, 19, 18), fill=(155, 122, 83, 255))
draw.rectangle((0, 22, 23, 23), fill=dirt_dark)
# Tiny flowers so a long ground row reads as a garden.
for x in (6, 19):
draw.rectangle((x, 1, x, 1), fill=CREAM)
draw.rectangle((x - 1, 1, x + 1, 1), fill=CREAM)
draw.rectangle((x, 0, x, 0), fill=CREAM)
draw.rectangle((x, 3, x, 3), fill=(78, 159, 61, 255))
draw.rectangle((x, 2, x, 2), fill=(242, 179, 61, 255))
save(upscale(image, 4), "tile.png")
def draw_coin() -> None:
image, draw = logical(16, 16)
draw.ellipse((1, 1, 14, 14), fill=(140, 90, 22, 255))
draw.ellipse((2, 2, 13, 13), fill=(242, 179, 61, 255))
draw.ellipse((4, 4, 11, 11), fill=(255, 217, 102, 255))
draw.rectangle((7, 5, 8, 10), fill=CREAM)
draw.rectangle((5, 7, 10, 8), fill=CREAM)
draw.rectangle((5, 4, 6, 4), fill=(255, 255, 255, 255))
draw.rectangle((4, 5, 4, 5), fill=(255, 255, 255, 255))
save(upscale(image, 4), "coin.png")
def draw_crate() -> None:
image, draw = logical(16, 16)
outline = (63, 43, 28, 255)
wood = (169, 116, 63, 255)
wood_light = (201, 143, 82, 255)
wood_dark = (122, 78, 45, 255)
draw.rectangle((0, 0, 15, 15), fill=outline)
draw.rectangle((1, 1, 14, 14), fill=wood)
draw.rectangle((1, 1, 14, 1), fill=wood_light)
draw.rectangle((1, 14, 14, 14), fill=wood_dark)
draw.rectangle((1, 1, 3, 14), fill=wood_light)
draw.rectangle((12, 1, 14, 14), fill=wood_dark)
draw.line((4, 3, 11, 12), fill=wood_dark)
draw.line((11, 3, 4, 12), fill=wood_dark)
for x, y in ((2, 2), (13, 2), (2, 13), (13, 13)):
draw.rectangle((x, y, x, y), fill=(217, 187, 130, 255))
save(upscale(image, 4), "crate.png")
def dusk_gradient(t: float) -> tuple[int, int, int]:
stops = [
(0.00, (15, 14, 34)),
(0.42, (45, 34, 70)),
(0.62, (96, 58, 78)),
(0.74, (176, 92, 70)),
(0.82, (232, 146, 84)),
(0.90, (244, 180, 110)),
(1.00, (28, 26, 48)),
]
for index in range(len(stops) - 1):
start_t, start = stops[index]
end_t, end = stops[index + 1]
if start_t <= t <= end_t:
span = max(end_t - start_t, 1e-6)
blend = (t - start_t) / span
return tuple(
round(start[channel] + (end[channel] - start[channel]) * blend)
for channel in range(3)
)
return stops[-1][1]
def draw_background() -> None:
width, height = 240, 135
image = Image.new("RGB", (width, height))
draw = ImageDraw.Draw(image)
for y in range(height):
draw.line(((0, y), (width, y)), fill=dusk_gradient(y / (height - 1)))
# Stars.
rng = random.Random(7)
for _ in range(46):
x = rng.randint(2, width - 3)
y = rng.randint(3, 62)
brightness = rng.choice([(255, 255, 255), (255, 244, 214), (210, 205, 235)])
draw.point((x, y), fill=brightness)
if rng.random() < 0.18:
draw.point((x + 1, y), fill=brightness)
# Soft evening clouds.
draw.rectangle((0, 26, width, 28), fill=(66, 52, 92))
draw.rectangle((20, 38, 190, 39), fill=(78, 60, 104))
draw.rectangle((60, 47, 230, 48), fill=(90, 66, 105))
# Sun low over the horizon.
draw.ellipse((150, 64, 191, 105), fill=(247, 190, 120))
draw.ellipse((157, 71, 184, 98), fill=(255, 226, 164))
draw.ellipse((162, 76, 179, 93), fill=(255, 244, 214))
# Far mountain range.
draw.polygon(
[
(0, 101),
(25, 82),
(48, 94),
(72, 76),
(95, 90),
(120, 80),
(150, 97),
(175, 84),
(205, 96),
(230, 88),
(240, 96),
(240, 135),
(0, 135),
],
fill=(66, 52, 96),
)
# Mid range.
draw.polygon(
[
(0, 112),
(30, 100),
(55, 108),
(80, 94),
(110, 106),
(140, 98),
(168, 110),
(196, 100),
(220, 108),
(240, 102),
(240, 135),
(0, 135),
],
fill=(47, 38, 72),
)
# Rolling hills.
draw.polygon(
[
(0, 123),
(35, 114),
(70, 121),
(110, 112),
(145, 120),
(180, 113),
(215, 121),
(240, 115),
(240, 135),
(0, 135),
],
fill=(31, 29, 52),
)
# Foreground silhouette with a few conifers.
draw.polygon(
[
(0, 129),
(30, 126),
(60, 129),
(90, 125),
(120, 129),
(150, 126),
(180, 130),
(210, 126),
(240, 129),
(240, 135),
(0, 135),
],
fill=(20, 19, 34),
)
for x, base in ((28, 129), (66, 130), (148, 127), (196, 131)):
draw.polygon([(x, base - 12), (x - 4, base), (x + 4, base)], fill=(17, 24, 30))
draw.rectangle((x - 1, base - 13, x + 1, base - 10), fill=(17, 24, 30))
draw.rectangle((x, base - 14, x, base - 13), fill=(17, 24, 30))
for x in range(4, width, 17):
draw.point((x, 132), fill=(36, 55, 42))
image = upscale(image, 4)
image = image.quantize(colors=64, method=Image.Quantize.MEDIANCUT, dither=Image.Dither.NONE)
save(image.convert("RGB"), "background.png")
def main() -> None:
draw_background()
draw_player()
draw_hat()
draw_tile()
draw_coin()
draw_crate()
total = sum(path.stat().st_size for path in OUT.glob("*.png"))
print(f"total texture size: {total} bytes")
if __name__ == "__main__":
main()