Refine Princess Lima for v2 presentation
All checks were successful
Build Org Website / build (push) Successful in 1m2s
All checks were successful
Build Org Website / build (push) Successful in 1m2s
This commit is contained in:
260
tools/generate-princess-lima-maps.mjs
Normal file
260
tools/generate-princess-lima-maps.mjs
Normal file
@@ -0,0 +1,260 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Generate committed Tiled-compatible maps from compact, deliberately authored
|
||||
* region specifications. The generated JSON is runtime data, while this file
|
||||
* remains the editable source of truth.
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const OUT = path.join(ROOT, "assets", "maps", "princess-lima");
|
||||
const TILE = 32;
|
||||
const W = 30;
|
||||
const H = 22;
|
||||
|
||||
const regions = {
|
||||
village: {
|
||||
name: "Broken Village", theme: 0, ground: 0, music: "village-theme",
|
||||
paths: [[13, 0, 5, 22], [3, 9, 24, 5]],
|
||||
solids: [[1, 1, 8, 5, "Damaged home"], [20, 1, 8, 5, "Healer"], [1, 16, 8, 5, "Blacksmith"], [21, 16, 7, 5, "Burnt farm"], [1, 7, 4, 2, "Cart"], [24, 8, 4, 2, "Barricade"]],
|
||||
props: [[15, 10, "Village well"], [11, 7, "Abandoned belongings"], [19, 14, "Memorial tree"], [6, 13, "Supply chest"]],
|
||||
spawns: { start: [15, 19], square: [15, 12], forestRoad: [15, 2] },
|
||||
npcs: [["elder", 15, 10], ["bram", 9, 16], ["nia", 21, 7]],
|
||||
enemies: [["raider", 11, 13], ["raider", 18, 14], ["shield", 15, 16]],
|
||||
transitions: [["forest-road", 14, 0, 3, 1, "forest", "villagePath", "village_defended"]],
|
||||
interactions: [["well", 15, 10, "The old well reflects a sky bruised by smoke."], ["memorial", 19, 14, "Fresh ribbons promise that the village will endure."], ["chest-village", 6, 13, "chest"]],
|
||||
camera: [[10, 7, 10, 9, "square-reveal", 1.08]],
|
||||
secrets: [["cellar", 3, 15, "A loose foundation stone hides a moon coin."]]
|
||||
},
|
||||
forest: {
|
||||
name: "Whispering Woods", theme: 1, ground: 0, music: "forest-theme",
|
||||
paths: [[0, 10, 30, 4], [22, 2, 4, 20], [5, 4, 20, 3]],
|
||||
water: [[14, 0, 3, 10], [14, 14, 3, 8]],
|
||||
solids: [[1, 1, 5, 8, "Canopy"], [7, 1, 6, 3, "Canopy"], [18, 1, 4, 3, "Canopy"], [26, 1, 3, 9, "Canopy"], [1, 14, 8, 7, "Canopy"], [18, 15, 4, 6, "Canopy"], [26, 14, 3, 7, "Canopy"]],
|
||||
props: [[8, 11, "Ancient stone"], [13, 12, "Bridge"], [20, 6, "Flower shrine"], [24, 18, "Briar den"]],
|
||||
spawns: { villagePath: [2, 12], ruinsPath: [24, 20], mountainPath: [24, 3] },
|
||||
npcs: [["tovin", 20, 7]],
|
||||
enemies: [["wolf", 8, 7], ["ambusher", 20, 12], ["archer", 25, 16], ["briar_wolf", 24, 18]],
|
||||
transitions: [["village", 0, 10, 1, 4, "village", "forestRoad", ""], ["ruins", 23, 21, 3, 1, "ruins", "forestDoor", "guide_found"], ["mountain", 23, 0, 3, 1, "mountain", "forestTrail", "briar_defeated"]],
|
||||
interactions: [["stone-young", 8, 11, "puzzle"], ["stone-old", 20, 6, "puzzle"], ["tracks", 18, 12, "Claw marks turn toward the briar clearing."]],
|
||||
camera: [[20, 14, 8, 7, "briar-clearing", 1.12]],
|
||||
secrets: [["hollow", 6, 5, "Inside the hollow: a Forest Charm fragment."]]
|
||||
},
|
||||
ruins: {
|
||||
name: "Ancient Ruins", theme: 2, ground: 2, music: "forest-theme",
|
||||
paths: [[2, 9, 26, 4], [13, 2, 4, 18]],
|
||||
water: [[1, 1, 7, 7], [22, 1, 7, 7], [1, 15, 7, 6], [22, 15, 7, 6]],
|
||||
solids: [[8, 1, 2, 8, "Collapsed wall"], [20, 1, 2, 8, "Collapsed wall"], [8, 13, 2, 8, "Collapsed wall"], [20, 13, 2, 8, "Collapsed wall"], [11, 5, 8, 2, "Mosaic gate"]],
|
||||
props: [[11, 10, "Dawn brazier"], [15, 6, "Noon brazier"], [19, 10, "Dusk brazier"], [15, 17, "Night brazier"], [15, 11, "Sun mosaic"]],
|
||||
spawns: { forestDoor: [15, 20] },
|
||||
npcs: [],
|
||||
enemies: [["flying", 11, 8], ["caster", 19, 8], ["ambusher", 15, 15]],
|
||||
transitions: [["forest", 14, 21, 3, 1, "forest", "ruinsPath", ""]],
|
||||
interactions: [["dawn", 11, 10, "puzzle"], ["noon", 15, 6, "puzzle"], ["dusk", 19, 10, "puzzle"], ["night", 15, 17, "puzzle"], ["mural", 15, 3, "The mural shows dawn, noon, dusk, then night."]],
|
||||
camera: [[9, 4, 12, 14, "mosaic-hall", 1.1]],
|
||||
secrets: [["hidden-room", 23, 18, "A concealed chamber holds the Sun Crystal."]]
|
||||
},
|
||||
mountain: {
|
||||
name: "Frostpeak Mountain", theme: 3, ground: 0, music: "mountain-theme",
|
||||
paths: [[1, 16, 28, 4], [4, 10, 22, 4], [22, 3, 4, 17]],
|
||||
water: [[0, 0, 10, 8], [10, 0, 10, 5], [26, 0, 4, 16]],
|
||||
solids: [[0, 8, 4, 8, "Cliff"], [8, 4, 12, 5, "Cliff"], [26, 20, 4, 2, "Cliff"], [12, 13, 7, 3, "Broken bridge"]],
|
||||
props: [[10, 12, "West winch"], [20, 12, "East winch"], [23, 6, "Sheltered camp"], [15, 17, "Rope bridge"]],
|
||||
spawns: { forestTrail: [2, 18], campRoad: [27, 18] },
|
||||
npcs: [["bram", 23, 7]],
|
||||
enemies: [["flying", 7, 12], ["shield", 22, 16], ["stone_guardian", 24, 11]],
|
||||
transitions: [["forest", 0, 16, 1, 4, "forest", "mountainPath", ""], ["camp", 29, 16, 1, 4, "camp", "mountainRoad", "guardian_defeated"]],
|
||||
interactions: [["west-winch", 10, 12, "puzzle"], ["east-winch", 20, 12, "puzzle"], ["campfire", 23, 6, "The sheltered flame steadies your breath."]],
|
||||
camera: [[19, 7, 9, 8, "guardian-arena", 1.12]],
|
||||
secrets: [["ice-cave", 5, 9, "Behind the frozen fall lies a Royal Draught."]]
|
||||
},
|
||||
camp: {
|
||||
name: "Resistance Camp", theme: 4, ground: 0, music: "village-theme",
|
||||
paths: [[0, 9, 30, 5], [13, 2, 5, 20]],
|
||||
solids: [[2, 2, 7, 5, "Supply tent"], [21, 2, 7, 5, "Infirmary"], [2, 16, 7, 5, "Barracks"], [21, 16, 7, 5, "Scout post"], [10, 15, 10, 2, "Barricade"]],
|
||||
props: [[15, 11, "Command fire"], [7, 10, "Training dummies"], [23, 11, "Supply table"], [15, 18, "Mobilisation map"]],
|
||||
spawns: { mountainRoad: [2, 12], fortressRoad: [27, 12] },
|
||||
npcs: [["elowen", 15, 9], ["nia", 23, 8], ["elder", 14, 18]],
|
||||
enemies: [["shield", 11, 12], ["archer", 19, 12], ["captain", 18, 18]],
|
||||
transitions: [["mountain", 0, 10, 1, 4, "mountain", "campRoad", ""], ["fortress", 29, 10, 1, 4, "fortressExterior", "campGate", "emblem_found"]],
|
||||
interactions: [["map", 15, 18, "route-choice"], ["supplies", 23, 11, "A ledger records medicine sent to the wounded."], ["dummies", 7, 10, "block-tutorial"]],
|
||||
camera: [[10, 7, 10, 9, "command-area", 1.08]],
|
||||
secrets: [["scout-cache", 27, 17, "A scout cache contains smoke bombs."]]
|
||||
},
|
||||
fortressExterior: {
|
||||
name: "Fortress Exterior", theme: 5, ground: 0, music: "fortress-theme",
|
||||
paths: [[1, 16, 28, 4], [13, 3, 5, 17], [3, 9, 24, 4]],
|
||||
solids: [[0, 0, 30, 3, "Fortress wall"], [0, 3, 4, 13, "Cliff wall"], [26, 3, 4, 13, "Cliff wall"], [5, 4, 5, 5, "West tower"], [20, 4, 5, 5, "East tower"], [11, 2, 8, 4, "Gatehouse"]],
|
||||
props: [[7, 12, "Ballista"], [22, 12, "Detection ward"], [15, 8, "Portcullis"], [4, 18, "Siege breach"]],
|
||||
spawns: { campGate: [2, 18], innerGate: [15, 7] },
|
||||
npcs: [["elowen", 5, 18]],
|
||||
enemies: [["shield", 9, 18], ["archer", 7, 11], ["archer", 22, 11], ["caster", 21, 15], ["support", 15, 12]],
|
||||
transitions: [["camp", 0, 16, 1, 4, "camp", "fortressRoad", ""], ["interior", 14, 5, 3, 1, "fortressInterior", "frontHall", "emblem_found"]],
|
||||
interactions: [["ballista", 7, 12, "disable-defence"], ["ward", 22, 12, "disable-defence"], ["breach", 4, 18, "side-route"]],
|
||||
camera: [[4, 2, 22, 14, "fortress-reveal", 1.0]],
|
||||
secrets: [["drain", 25, 19, "A half-flooded drain bypasses the main courtyard."]]
|
||||
},
|
||||
fortressInterior: {
|
||||
name: "Fortress Interior", theme: 6, ground: 2, music: "fortress-theme",
|
||||
paths: [[13, 0, 5, 22], [2, 9, 26, 4], [4, 3, 22, 4], [4, 16, 22, 4]],
|
||||
solids: [[0, 0, 4, 22, "Outer wall"], [26, 0, 4, 22, "Outer wall"], [4, 0, 9, 3, "Library"], [18, 0, 8, 3, "Chapel"], [4, 7, 7, 2, "Prison wall"], [19, 7, 7, 2, "Armoury wall"], [4, 13, 7, 3, "Kitchen"], [19, 13, 7, 3, "Barracks"]],
|
||||
props: [[7, 11, "Prison cells"], [22, 11, "Armoury"], [7, 5, "War table"], [22, 5, "Ritual altar"], [15, 18, "Servant passage"]],
|
||||
spawns: { frontHall: [15, 20], throneDoor: [15, 2] },
|
||||
npcs: [["prisoner", 7, 11], ["elowen", 8, 11]],
|
||||
enemies: [["shield", 15, 16], ["archer", 22, 11], ["caster", 22, 5], ["support", 7, 5], ["elite", 15, 7]],
|
||||
transitions: [["outside", 14, 21, 3, 1, "fortressExterior", "innerGate", ""], ["throne", 14, 0, 3, 1, "bossArena", "entrance", "wards_broken"]],
|
||||
interactions: [["cell-west", 6, 11, "free-prisoner"], ["cell-east", 8, 11, "free-prisoner"], ["ward-moon", 7, 5, "puzzle"], ["ward-crown", 15, 7, "puzzle"], ["ward-flame", 22, 5, "puzzle"], ["passage", 15, 18, "shortcut"]],
|
||||
camera: [[4, 3, 22, 16, "great-hall", 1.04]],
|
||||
secrets: [["library-cache", 5, 2, "Malrec's notes reveal the throne's weakness."]]
|
||||
},
|
||||
bossArena: {
|
||||
name: "Throne of Night", theme: 7, ground: 2, music: "boss-theme",
|
||||
paths: [[5, 2, 20, 18]],
|
||||
solids: [[0, 0, 5, 22, "Void"], [25, 0, 5, 22, "Void"], [5, 0, 20, 2, "Throne wall"], [5, 20, 20, 2, "Arena wall"], [7, 4, 2, 2, "Pillar"], [21, 4, 2, 2, "Pillar"], [7, 16, 2, 2, "Pillar"], [21, 16, 2, 2, "Pillar"]],
|
||||
props: [[15, 4, "Malrec's throne"], [10, 11, "Sun pedestal"], [20, 11, "Moon pedestal"], [15, 11, "Shadow seal"]],
|
||||
spawns: { entrance: [15, 18] },
|
||||
npcs: [["malrec", 15, 5]],
|
||||
enemies: [["malrec", 15, 8]],
|
||||
transitions: [["chamber", 14, 0, 3, 2, "chamber", "door", "malrec_defeated"]],
|
||||
interactions: [["sun-pedestal", 10, 11, "boss-mechanic"], ["moon-pedestal", 20, 11, "boss-mechanic"]],
|
||||
camera: [[5, 2, 20, 18, "throne-arena", 1.0]],
|
||||
secrets: []
|
||||
},
|
||||
chamber: {
|
||||
name: "Lima's Chamber", theme: 8, ground: 2, music: "victory-theme",
|
||||
paths: [[4, 2, 22, 18]],
|
||||
solids: [[0, 0, 4, 22, "Outer wall"], [26, 0, 4, 22, "Outer wall"], [4, 0, 22, 2, "Balcony"], [4, 20, 22, 2, "Outer wall"], [6, 4, 7, 5, "Bed"], [19, 4, 5, 4, "Writing desk"]],
|
||||
props: [[15, 7, "Dawn window"], [21, 7, "Lima's journal"], [9, 10, "Broken chain"], [15, 14, "Royal crest"]],
|
||||
spawns: { door: [15, 18] },
|
||||
npcs: [["lima", 15, 9]],
|
||||
enemies: [],
|
||||
transitions: [],
|
||||
interactions: [["journal", 21, 7, "Lima wrote plans for rebuilding every village Malrec harmed."], ["chain", 9, 10, "The broken shackle is scored where Lima worked at it night after night."]],
|
||||
camera: [[7, 3, 16, 14, "rescue-framing", 1.14]],
|
||||
secrets: [["keepsake", 23, 17, "A small carved bird bears the traveller's name."]]
|
||||
}
|
||||
};
|
||||
|
||||
function tileLayer(name, data, visible = true, opacity = 1) {
|
||||
return { id: 0, name, type: "tilelayer", width: W, height: H, x: 0, y: 0, visible, opacity, data };
|
||||
}
|
||||
|
||||
function objectLayer(name, objects) {
|
||||
return { id: 0, name, type: "objectgroup", visible: true, opacity: 1, draworder: "topdown", objects };
|
||||
}
|
||||
|
||||
function propList(values) {
|
||||
return Object.entries(values).map(([name, value]) => ({
|
||||
name, type: typeof value === "number" ? "float" : "string", value
|
||||
}));
|
||||
}
|
||||
|
||||
function pointObject(name, type, tx, ty, properties = {}) {
|
||||
return { id: 0, name, type, x: tx * TILE + TILE / 2, y: ty * TILE + TILE / 2, width: 0, height: 0, point: true, visible: true, rotation: 0, properties: propList(properties) };
|
||||
}
|
||||
|
||||
function rectObject(name, type, tx, ty, tw, th, properties = {}) {
|
||||
return { id: 0, name, type, x: tx * TILE, y: ty * TILE, width: tw * TILE, height: th * TILE, visible: true, rotation: 0, properties: propList(properties) };
|
||||
}
|
||||
|
||||
function paint(data, x, y, w, h, tile) {
|
||||
for (let row = Math.max(0, y); row < Math.min(H, y + h); row += 1) {
|
||||
for (let col = Math.max(0, x); col < Math.min(W, x + w); col += 1) data[row * W + col] = tile;
|
||||
}
|
||||
}
|
||||
|
||||
function build(id, spec) {
|
||||
const first = spec.theme * 4 + 1;
|
||||
const base = Array(W * H).fill(first + spec.ground);
|
||||
const variation = Array(W * H).fill(0);
|
||||
const paths = Array(W * H).fill(0);
|
||||
const water = Array(W * H).fill(0);
|
||||
const cliffs = Array(W * H).fill(0);
|
||||
const props = Array(W * H).fill(0);
|
||||
const shadows = Array(W * H).fill(0);
|
||||
const lighting = Array(W * H).fill(0);
|
||||
spec.paths.forEach(([x, y, w, h]) => paint(paths, x, y, w, h, first + 2));
|
||||
(spec.water || []).forEach(([x, y, w, h]) => paint(water, x, y, w, h, first + 3));
|
||||
spec.solids.forEach(([x, y, w, h]) => {
|
||||
paint(cliffs, x, y, w, h, first + 1);
|
||||
paint(shadows, x, y + h - 1, w, 1, first + 3);
|
||||
});
|
||||
spec.props.forEach(([x, y]) => { props[y * W + x] = first + 1; });
|
||||
for (let y = 1; y < H - 1; y += 1) for (let x = 1; x < W - 1; x += 1) {
|
||||
if ((x * 13 + y * 7 + spec.theme * 11) % 41 === 0) variation[y * W + x] = first + 1;
|
||||
}
|
||||
|
||||
const layers = [
|
||||
tileLayer("Base terrain", base),
|
||||
tileLayer("Terrain variation", variation),
|
||||
tileLayer("Paths", paths),
|
||||
tileLayer("Water", water),
|
||||
tileLayer("Cliffs and buildings", cliffs),
|
||||
tileLayer("Props", props),
|
||||
tileLayer("Objects behind actors", Array(W * H).fill(0)),
|
||||
tileLayer("Actor layer", Array(W * H).fill(0)),
|
||||
tileLayer("Objects above actors", Array(W * H).fill(0)),
|
||||
tileLayer("Shadows", shadows, true, 0.45),
|
||||
tileLayer("Lighting", lighting, true, 0.3),
|
||||
objectLayer("Collision", spec.solids.map((v, i) => rectObject(`solid-${i}`, "collision", v[0], v[1], v[2], v[3], { material: v[4] }))),
|
||||
objectLayer("Interaction zones", spec.interactions.map((v) => pointObject(v[0], "interaction", v[1], v[2], { action: v[3] }))),
|
||||
objectLayer("Dialogue triggers", spec.npcs.map((v) => pointObject(v[0], "npc", v[1], v[2], { dialogue: v[0] }))),
|
||||
objectLayer("Quest triggers", spec.interactions.filter((v) => ["puzzle", "route-choice", "disable-defence", "free-prisoner", "boss-mechanic"].includes(v[3])).map((v) => pointObject(v[0], "quest", v[1], v[2], { action: v[3] }))),
|
||||
objectLayer("Enemy zones", spec.enemies.map((v, i) => pointObject(`${v[0]}-${i}`, "enemy", v[1], v[2], { enemy: v[0], leash: v[0].includes("malrec") ? 420 : 190 }))),
|
||||
objectLayer("Camera zones", spec.camera.map((v) => rectObject(v[4], "camera", v[0], v[1], v[2], v[3], { zoom: v[5] }))),
|
||||
objectLayer("Scene transitions", spec.transitions.map((v) => rectObject(v[0], "transition", v[1], v[2], v[3], v[4], { target: v[5], spawn: v[6], requirement: v[7] }))),
|
||||
objectLayer("Named safe spawns", Object.entries(spec.spawns).map(([name, v]) => pointObject(name, "spawn", v[0], v[1]))),
|
||||
objectLayer("Audio zones", [rectObject(`${id}-ambience`, "audio", 0, 0, W, H, { music: spec.music })]),
|
||||
objectLayer("Optional secrets", spec.secrets.map((v) => pointObject(v[0], "secret", v[1], v[2], { observation: v[3] })))
|
||||
];
|
||||
|
||||
let objectId = 1;
|
||||
let layerId = 1;
|
||||
layers.forEach((layer) => {
|
||||
layer.id = layerId++;
|
||||
if (layer.objects) layer.objects.forEach((object) => { object.id = objectId++; });
|
||||
});
|
||||
return {
|
||||
compressionlevel: -1,
|
||||
height: H,
|
||||
width: W,
|
||||
infinite: false,
|
||||
layers,
|
||||
nextlayerid: layerId,
|
||||
nextobjectid: objectId,
|
||||
orientation: "orthogonal",
|
||||
renderorder: "right-down",
|
||||
tiledversion: "1.11.2",
|
||||
tileheight: TILE,
|
||||
tilewidth: TILE,
|
||||
type: "map",
|
||||
version: "1.10",
|
||||
properties: propList({ id, displayName: spec.name, schemaVersion: 2, resolvedFlag: `${id}_resolved` }),
|
||||
tilesets: [{
|
||||
firstgid: 1,
|
||||
columns: 4,
|
||||
image: "../../images/play/princess-lima/world-tiles-v2.png",
|
||||
imageheight: 288,
|
||||
imagewidth: 128,
|
||||
margin: 0,
|
||||
name: "Princess Lima World",
|
||||
spacing: 0,
|
||||
tilecount: 36,
|
||||
tileheight: TILE,
|
||||
tilewidth: TILE
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
fs.mkdirSync(OUT, { recursive: true });
|
||||
for (const [id, spec] of Object.entries(regions)) {
|
||||
fs.writeFileSync(path.join(OUT, `${id}.json`), `${JSON.stringify(build(id, spec), null, 2)}\n`);
|
||||
}
|
||||
fs.writeFileSync(path.join(OUT, "world-tiles.tsj"), `${JSON.stringify({
|
||||
columns: 4, image: "../../images/play/princess-lima/world-tiles-v2.png", imageheight: 288, imagewidth: 128,
|
||||
margin: 0, name: "Princess Lima World", spacing: 0, tilecount: 36, tileheight: TILE, tilewidth: TILE,
|
||||
tiledversion: "1.11.2", type: "tileset", version: "1.10"
|
||||
}, null, 2)}\n`);
|
||||
console.log(`Generated ${Object.keys(regions).length} authored Tiled maps in ${OUT}`);
|
||||
126
tools/generate-princess-lima-v2-assets.py
Normal file
126
tools/generate-princess-lima-v2-assets.py
Normal file
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Normalize existing original artwork into gameplay-scale v2 atlases."""
|
||||
|
||||
from pathlib import Path
|
||||
import random
|
||||
from PIL import Image, ImageDraw, ImageEnhance
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
ASSETS = ROOT / "assets/images/play/princess-lima"
|
||||
FRAME_W, FRAME_H = 48, 64
|
||||
SOURCE_CELL = 314
|
||||
|
||||
|
||||
def crop_subject(source: Image.Image, index: int) -> Image.Image:
|
||||
col, row = index % 4, index // 4
|
||||
cell = source.crop((col * SOURCE_CELL, row * SOURCE_CELL,
|
||||
(col + 1) * SOURCE_CELL, (row + 1) * SOURCE_CELL))
|
||||
box = cell.getbbox()
|
||||
return cell.crop(box) if box else cell
|
||||
|
||||
|
||||
def fitted(subject: Image.Image, max_w: int = 43, max_h: int = 57) -> Image.Image:
|
||||
copy = subject.copy()
|
||||
resampling = getattr(Image, "Resampling", Image)
|
||||
copy.thumbnail((max_w, max_h), resampling.LANCZOS)
|
||||
copy = ImageEnhance.Contrast(copy).enhance(1.08)
|
||||
return copy
|
||||
|
||||
|
||||
def make_actor_atlas() -> None:
|
||||
source = Image.open(ASSETS / "cast-atlas.png").convert("RGBA")
|
||||
# Stable actor row contract used by v2-data.js.
|
||||
source_frames = [0, 4, 5, 6, 7, 11, 8, 9, 10, 11, 12, 13, 14, 13, 7, 4]
|
||||
atlas = Image.new("RGBA", (FRAME_W * 16, FRAME_H * len(source_frames)))
|
||||
for actor_row, source_index in enumerate(source_frames):
|
||||
subject = fitted(crop_subject(source, source_index))
|
||||
for direction in range(4):
|
||||
for phase in range(4):
|
||||
frame = Image.new("RGBA", (FRAME_W, FRAME_H))
|
||||
bob = (0, -1, 0, 1)[phase]
|
||||
stride = (-1, 0, 1, 0)[phase]
|
||||
x = (FRAME_W - subject.width) // 2 + (stride if direction in (1, 3) else 0)
|
||||
y = FRAME_H - subject.height - 3 + bob
|
||||
transpose = getattr(Image, "Transpose", Image)
|
||||
image = subject.transpose(transpose.FLIP_LEFT_RIGHT) if direction == 3 else subject
|
||||
frame.alpha_composite(image, (x, y))
|
||||
atlas.alpha_composite(frame, ((direction * 4 + phase) * FRAME_W, actor_row * FRAME_H))
|
||||
atlas.save(ASSETS / "actor-atlas-v2.png", optimize=True)
|
||||
|
||||
|
||||
def make_effect_atlas() -> None:
|
||||
size = 32
|
||||
atlas = Image.new("RGBA", (size * 8, size * 2))
|
||||
palette = [
|
||||
(255, 226, 132), (243, 132, 74), (118, 205, 255), (179, 118, 255),
|
||||
(144, 222, 136), (235, 238, 246), (255, 107, 119), (89, 65, 120)
|
||||
]
|
||||
for index, color in enumerate(palette):
|
||||
draw = ImageDraw.Draw(atlas)
|
||||
x = index * size
|
||||
draw.ellipse((x + 12, 12, x + 20, 20), fill=color + (245,))
|
||||
draw.polygon([(x + 16, 2), (x + 19, 12), (x + 16, 10), (x + 13, 12)], fill=color + (210,))
|
||||
draw.polygon([(x + 16, 30), (x + 19, 20), (x + 16, 22), (x + 13, 20)], fill=color + (150,))
|
||||
draw.polygon([(x + 2, 16), (x + 12, 13), (x + 10, 16), (x + 12, 19)], fill=color + (180,))
|
||||
draw.polygon([(x + 30, 16), (x + 20, 13), (x + 22, 16), (x + 20, 19)], fill=color + (180,))
|
||||
y = size
|
||||
draw.arc((x + 2, y + 4, x + 30, y + 30), 205, 335, fill=color + (235,), width=5)
|
||||
draw.arc((x + 6, y + 8, x + 26, y + 26), 205, 335, fill=(255, 247, 210, 220), width=2)
|
||||
atlas.save(ASSETS / "effects-atlas-v2.png", optimize=True)
|
||||
|
||||
|
||||
def make_world_tiles() -> None:
|
||||
themes = [
|
||||
("#526c3d", "#354932", "#ad8b58", "#516776"),
|
||||
("#24553b", "#15392c", "#8b704a", "#2b6771"),
|
||||
("#526f70", "#2c474f", "#85806d", "#217885"),
|
||||
("#a6bac9", "#60778b", "#e0e8ec", "#58788f"),
|
||||
("#62523a", "#3d3329", "#9a7950", "#6c4430"),
|
||||
("#43374d", "#241e2d", "#706179", "#4e264e"),
|
||||
("#403845", "#211d27", "#817068", "#782f35"),
|
||||
("#382b42", "#18131e", "#74527e", "#873d72"),
|
||||
("#c9bd95", "#746d67", "#dccb91", "#537399"),
|
||||
]
|
||||
atlas = Image.new("RGBA", (128, 288))
|
||||
for row, colors in enumerate(themes):
|
||||
for kind, base in enumerate(colors):
|
||||
tile = Image.new("RGBA", (32, 32), base)
|
||||
draw = ImageDraw.Draw(tile)
|
||||
rng = random.Random(row * 97 + kind * 29)
|
||||
rgb = tuple(int(base[index:index + 2], 16) for index in (1, 3, 5))
|
||||
dark = tuple(max(0, int(value * .68)) for value in rgb) + (255,)
|
||||
light = tuple(min(255, int(value * 1.22)) for value in rgb) + (255,)
|
||||
if kind == 0:
|
||||
for _ in range(30):
|
||||
x, y = rng.randrange(2, 30), rng.randrange(2, 30)
|
||||
draw.point((x, y), fill=light if rng.random() > .72 else dark)
|
||||
for _ in range(3):
|
||||
x, y = rng.randrange(4, 28), rng.randrange(4, 28)
|
||||
draw.line((x - 2, y, x + 2, y - 1), fill=dark)
|
||||
elif kind == 1:
|
||||
for y in range(0, 32, 8):
|
||||
draw.line((0, y, 32, y), fill=dark, width=2)
|
||||
offset = 5 if y % 16 else 0
|
||||
for x in range(-offset, 32, 11):
|
||||
draw.line((x, y, x + 4, y + 8), fill=dark)
|
||||
draw.line((x + 1, y + 2, x + 4, y + 2), fill=light)
|
||||
elif kind == 2:
|
||||
tile.paste(base, (0, 0, 32, 32))
|
||||
for y in range(2, 32, 9):
|
||||
offset = 5 if y % 18 else 0
|
||||
for x in range(-offset, 32, 10):
|
||||
draw.rounded_rectangle((x, y, x + 8, y + 6), radius=2, fill=light, outline=dark)
|
||||
draw.line((0, 31, 32, 31), fill=dark)
|
||||
else:
|
||||
for y in range(4, 32, 7):
|
||||
draw.arc((-7, y - 4, 13, y + 5), 195, 345, fill=light, width=2)
|
||||
draw.arc((11, y - 4, 31, y + 5), 195, 345, fill=dark, width=2)
|
||||
atlas.alpha_composite(tile, (kind * 32, row * 32))
|
||||
atlas.save(ASSETS / "world-tiles-v2.png", optimize=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
make_actor_atlas()
|
||||
make_effect_atlas()
|
||||
make_world_tiles()
|
||||
print("Generated normalized actor and effects atlases.")
|
||||
Reference in New Issue
Block a user