Bevy Forge 0.1.0: visual scene editor for Bevy 0.20 BSN
This commit is contained in:
@@ -0,0 +1,318 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user