feat: upgrade combat and item icons
This commit is contained in:
@@ -9,39 +9,183 @@ from PIL import Image, ImageDraw, ImageEnhance, ImageFilter
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
OUT = ROOT / "src" / "assets" / "images" / "ui" / "battle-ui-icons.png"
|
||||
SEED_ATLAS = ROOT / "docs" / "battle-ui-icons-hq-batch1-atlas.png"
|
||||
MICRO_OUT = ROOT / "src" / "assets" / "images" / "ui" / "battle-ui-icons-micro.png"
|
||||
PROOF_OUT = ROOT / "docs" / "battle-ui-icons-v2-action-item-contact.png"
|
||||
SEED_ATLAS = ROOT / "docs" / "battle-ui-icons-hq-gapfix-v1-atlas.png"
|
||||
TERRAIN_SOURCE = ROOT / "src" / "assets" / "images" / "battle" / "first-battle-map.webp"
|
||||
SOURCE_DIR = ROOT / "src" / "assets" / "images" / "ui" / "icon-sources" / "battle-ui-v2"
|
||||
BASE_FRAME = 48
|
||||
FRAME = 128
|
||||
MICRO_FRAME = 32
|
||||
SCALE = 4
|
||||
COLS = 5
|
||||
ROWS = 6
|
||||
SOURCE_FRAMES = {
|
||||
4: ("attack", "attack-v2.png"),
|
||||
16: ("hit", "hit-v2.png"),
|
||||
17: ("critical", "critical-v2.png"),
|
||||
19: ("counter", "counter-v2.png"),
|
||||
24: ("fire", "fire-v2.png"),
|
||||
27: ("bean", "bean-v2.png"),
|
||||
28: ("salve", "salve-v2.png"),
|
||||
29: ("wine", "wine-v2.png"),
|
||||
}
|
||||
REPLACEMENT_FRAMES: dict[int, tuple[str, Callable[[Image.Image], Image.Image]]] = {
|
||||
4: ("attack", lambda atlas: compose_attack_icon(atlas)),
|
||||
10: ("axe", lambda atlas: compose_axe_icon(atlas)),
|
||||
18: ("terrain", lambda atlas: compose_terrain_icon()),
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
OUT.parent.mkdir(parents=True, exist_ok=True)
|
||||
PROOF_OUT.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
seed_path = SEED_ATLAS if SEED_ATLAS.exists() else OUT
|
||||
if not seed_path.exists():
|
||||
raise FileNotFoundError(f"missing seed atlas: {seed_path}")
|
||||
|
||||
atlas = Image.open(seed_path).convert("RGBA")
|
||||
seed_atlas = Image.open(seed_path).convert("RGBA")
|
||||
expected_size = (COLS * FRAME, ROWS * FRAME)
|
||||
if atlas.size != expected_size:
|
||||
raise ValueError(f"seed atlas must be {expected_size}, got {atlas.size}: {seed_path}")
|
||||
if seed_atlas.size != expected_size:
|
||||
raise ValueError(f"seed atlas must be {expected_size}, got {seed_atlas.size}: {seed_path}")
|
||||
|
||||
atlas = seed_atlas.copy()
|
||||
|
||||
for index, (name, filename) in SOURCE_FRAMES.items():
|
||||
tile = source_icon(SOURCE_DIR / filename)
|
||||
replace_frame(atlas, index, tile)
|
||||
print(f"Applied frame {index:02d} {name} from {filename}")
|
||||
|
||||
for index, (_name, build_icon) in REPLACEMENT_FRAMES.items():
|
||||
tile = build_icon(atlas)
|
||||
frame_box = ((index % COLS) * FRAME, (index // COLS) * FRAME)
|
||||
atlas.paste(Image.new("RGBA", (FRAME, FRAME), (0, 0, 0, 0)), frame_box)
|
||||
atlas.alpha_composite(tile, frame_box)
|
||||
replace_frame(atlas, index, tile)
|
||||
validate_source_frames(atlas)
|
||||
atlas.save(OUT, optimize=True)
|
||||
micro_atlas = build_micro_atlas(atlas)
|
||||
validate_micro_frames(micro_atlas)
|
||||
micro_atlas.save(MICRO_OUT, optimize=True)
|
||||
write_contact_sheet(seed_atlas, atlas, micro_atlas)
|
||||
print(f"Wrote {OUT}")
|
||||
print(f"Wrote {MICRO_OUT}")
|
||||
print(f"Wrote {PROOF_OUT}")
|
||||
|
||||
|
||||
def replace_frame(atlas: Image.Image, index: int, tile: Image.Image) -> None:
|
||||
frame_box = ((index % COLS) * FRAME, (index // COLS) * FRAME)
|
||||
atlas.paste(Image.new("RGBA", (FRAME, FRAME), (0, 0, 0, 0)), frame_box)
|
||||
atlas.alpha_composite(tile.convert("RGBA"), frame_box)
|
||||
|
||||
|
||||
def source_icon(path: Path) -> Image.Image:
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"missing V2 icon source: {path}")
|
||||
|
||||
image = Image.open(path).convert("RGBA")
|
||||
alpha = image.getchannel("A")
|
||||
corner_alpha = [
|
||||
alpha.getpixel((0, 0)),
|
||||
alpha.getpixel((image.width - 1, 0)),
|
||||
alpha.getpixel((0, image.height - 1)),
|
||||
alpha.getpixel((image.width - 1, image.height - 1)),
|
||||
]
|
||||
if max(corner_alpha) > 8:
|
||||
raise ValueError(f"V2 icon source must have transparent corners: {path}")
|
||||
bounds = alpha.getbbox()
|
||||
if bounds is None:
|
||||
raise ValueError(f"V2 icon source is fully transparent: {path}")
|
||||
|
||||
cropped = image.crop(bounds)
|
||||
content_size = 104
|
||||
scale = min(content_size / cropped.width, content_size / cropped.height)
|
||||
width = max(1, round(cropped.width * scale))
|
||||
height = max(1, round(cropped.height * scale))
|
||||
resized = cropped.resize((width, height), Image.Resampling.LANCZOS)
|
||||
resized = resized.filter(ImageFilter.UnsharpMask(radius=0.7, percent=85, threshold=2))
|
||||
|
||||
tile = Image.new("RGBA", (FRAME, FRAME), (0, 0, 0, 0))
|
||||
tile.alpha_composite(resized, ((FRAME - width) // 2, (FRAME - height) // 2))
|
||||
return tile
|
||||
|
||||
|
||||
def build_micro_atlas(atlas: Image.Image) -> Image.Image:
|
||||
micro = Image.new("RGBA", (COLS * MICRO_FRAME, ROWS * MICRO_FRAME), (0, 0, 0, 0))
|
||||
for index in range(COLS * ROWS):
|
||||
micro_content_size = MICRO_FRAME - 2
|
||||
frame = atlas_frame(atlas, index).resize((micro_content_size, micro_content_size), Image.Resampling.LANCZOS)
|
||||
alpha = frame.getchannel("A")
|
||||
frame = frame.filter(ImageFilter.UnsharpMask(radius=0.45, percent=120, threshold=1))
|
||||
frame.putalpha(alpha)
|
||||
micro.alpha_composite(frame, ((index % COLS) * MICRO_FRAME + 1, (index // COLS) * MICRO_FRAME + 1))
|
||||
return micro
|
||||
|
||||
|
||||
def validate_source_frames(atlas: Image.Image) -> None:
|
||||
for index, (name, _filename) in SOURCE_FRAMES.items():
|
||||
bounds = atlas_frame(atlas, index).getchannel("A").getbbox()
|
||||
if bounds is None:
|
||||
raise ValueError(f"V2 atlas frame {index:02d} {name} is fully transparent")
|
||||
width = bounds[2] - bounds[0]
|
||||
height = bounds[3] - bounds[1]
|
||||
if min(width, height) < 48:
|
||||
raise ValueError(f"V2 atlas frame {index:02d} {name} is too narrow for runtime readability: {bounds}")
|
||||
if bounds[0] < 8 or bounds[1] < 8 or bounds[2] > FRAME - 8 or bounds[3] > FRAME - 8:
|
||||
raise ValueError(f"V2 atlas frame {index:02d} {name} violates the 8px safe edge: {bounds}")
|
||||
|
||||
|
||||
def validate_micro_frames(atlas: Image.Image) -> None:
|
||||
for index, (name, _filename) in SOURCE_FRAMES.items():
|
||||
bounds = micro_atlas_frame(atlas, index).getchannel("A").getbbox()
|
||||
if bounds is None:
|
||||
raise ValueError(f"micro atlas frame {index:02d} {name} is fully transparent")
|
||||
if bounds[0] == 0 or bounds[1] == 0 or bounds[2] == MICRO_FRAME or bounds[3] == MICRO_FRAME:
|
||||
raise ValueError(f"micro atlas frame {index:02d} {name} touches an outer edge: {bounds}")
|
||||
|
||||
|
||||
def micro_atlas_frame(atlas: Image.Image, index: int) -> Image.Image:
|
||||
x = (index % COLS) * MICRO_FRAME
|
||||
y = (index // COLS) * MICRO_FRAME
|
||||
return atlas.crop((x, y, x + MICRO_FRAME, y + MICRO_FRAME)).convert("RGBA")
|
||||
|
||||
|
||||
def write_contact_sheet(seed_atlas: Image.Image, atlas: Image.Image, micro_atlas: Image.Image) -> None:
|
||||
card_width = 500
|
||||
card_height = 220
|
||||
sheet = Image.new("RGB", (card_width * 4, 40 + card_height * 2), (8, 14, 20))
|
||||
draw = ImageDraw.Draw(sheet)
|
||||
draw.text((14, 13), "Battle UI V2 action/item icons - before, HQ, runtime sizes", fill=(232, 220, 183))
|
||||
labels = list(SOURCE_FRAMES.items())
|
||||
runtime_sizes = (14, 20, 30, 34, 52, 70)
|
||||
|
||||
for position, (index, (name, _filename)) in enumerate(labels):
|
||||
column = position % 4
|
||||
row = position // 4
|
||||
left = column * card_width
|
||||
top = 40 + row * card_height
|
||||
draw.rounded_rectangle((left + 6, top + 6, left + card_width - 6, top + card_height - 6), radius=8, fill=(15, 25, 34), outline=(64, 83, 98), width=1)
|
||||
draw.text((left + 16, top + 14), f"{index:02d} {name}", fill=(236, 221, 176))
|
||||
|
||||
before = atlas_frame(seed_atlas, index).resize((72, 72), Image.Resampling.LANCZOS)
|
||||
after = atlas_frame(atlas, index).resize((72, 72), Image.Resampling.LANCZOS)
|
||||
sheet.paste(before, (left + 16, top + 46), before)
|
||||
sheet.paste(after, (left + 104, top + 46), after)
|
||||
draw.text((left + 29, top + 124), "before", fill=(125, 145, 159))
|
||||
draw.text((left + 121, top + 124), "HQ", fill=(125, 145, 159))
|
||||
|
||||
cursor_x = left + 194
|
||||
micro_frame = micro_atlas_frame(micro_atlas, index)
|
||||
hq_frame = atlas_frame(atlas, index)
|
||||
for size in runtime_sizes:
|
||||
source = micro_frame if size <= MICRO_FRAME else hq_frame
|
||||
sample = source.resize((size, size), Image.Resampling.LANCZOS)
|
||||
sample_y = top + 78 - size // 2
|
||||
sheet.paste(sample, (cursor_x, sample_y), sample)
|
||||
draw.text((cursor_x, top + 124), str(size), fill=(125, 145, 159))
|
||||
cursor_x += max(size, 28) + 8
|
||||
|
||||
bounds = atlas_frame(atlas, index).getchannel("A").getbbox()
|
||||
occupancy = 0 if bounds is None else round(100 * max(bounds[2] - bounds[0], bounds[3] - bounds[1]) / FRAME)
|
||||
draw.text((left + 16, top + 164), f"frame {FRAME}px / micro {MICRO_FRAME}px / max span {occupancy}%", fill=(143, 164, 176))
|
||||
|
||||
sheet.save(PROOF_OUT, optimize=True)
|
||||
|
||||
|
||||
def make_icon(draw_icon: Callable[[ImageDraw.ImageDraw], None]) -> Image.Image:
|
||||
|
||||
@@ -8,13 +8,26 @@ const server = await createServer({
|
||||
});
|
||||
|
||||
const minimumReadableIconFrameSize = 96;
|
||||
const expectedV2IconFrames = {
|
||||
attack: 4,
|
||||
hit: 16,
|
||||
critical: 17,
|
||||
counter: 19,
|
||||
fire: 24,
|
||||
bean: 27,
|
||||
salve: 28,
|
||||
wine: 29
|
||||
};
|
||||
|
||||
try {
|
||||
const { battleUiIconManifest } = await server.ssrLoadModule('/src/game/data/battleUiIcons.ts');
|
||||
const { battleUiIconManifest, battleUiIconTextureForSize } = await server.ssrLoadModule('/src/game/data/battleUiIcons.ts');
|
||||
const { usableCatalog } = await server.ssrLoadModule('/src/game/data/battleUsables.ts');
|
||||
const { itemCatalog, equipmentSlots } = await server.ssrLoadModule('/src/game/data/battleItems.ts');
|
||||
const errors = [];
|
||||
|
||||
validateBattleUiIconAtlas(errors, battleUiIconManifest);
|
||||
validateBattleUiIconTextureRouting(errors, battleUiIconManifest, battleUiIconTextureForSize);
|
||||
validateBattleUsableIcons(errors, usableCatalog, battleUiIconManifest.frames);
|
||||
validateItemCatalog(errors, itemCatalog, equipmentSlots);
|
||||
validateGeneratedEquipmentIcons(errors, itemCatalog);
|
||||
|
||||
@@ -54,6 +67,11 @@ function validateBattleUiIconAtlas(errors, manifest) {
|
||||
);
|
||||
assertPositiveInteger(errors, manifest.columns, 'battleUiIconManifest.columns');
|
||||
assertPositiveInteger(errors, manifest.rows, 'battleUiIconManifest.rows');
|
||||
assertNonEmptyString(errors, manifest.microSource, 'battleUiIconManifest.microSource');
|
||||
assertNonEmptyString(errors, manifest.microTextureKey, 'battleUiIconManifest.microTextureKey');
|
||||
assertPositiveInteger(errors, manifest.microFrameWidth, 'battleUiIconManifest.microFrameWidth');
|
||||
assertPositiveInteger(errors, manifest.microFrameHeight, 'battleUiIconManifest.microFrameHeight');
|
||||
assertPositiveInteger(errors, manifest.microMaxDisplaySize, 'battleUiIconManifest.microMaxDisplaySize');
|
||||
|
||||
let dimensions;
|
||||
try {
|
||||
@@ -72,6 +90,26 @@ function validateBattleUiIconAtlas(errors, manifest) {
|
||||
errors.push(`battle UI icon atlas must be 8-bit RGBA PNG color type 6, got bitDepth=${dimensions.bitDepth} colorType=${dimensions.colorType}`);
|
||||
}
|
||||
|
||||
let microDimensions;
|
||||
try {
|
||||
microDimensions = readPngDimensions(manifest.microSource);
|
||||
} catch (error) {
|
||||
errors.push(`battle UI micro icon atlas cannot be read at "${manifest.microSource}": ${error.message}`);
|
||||
return;
|
||||
}
|
||||
const expectedMicroWidth = manifest.columns * manifest.microFrameWidth;
|
||||
const expectedMicroHeight = manifest.rows * manifest.microFrameHeight;
|
||||
if (microDimensions.width !== expectedMicroWidth || microDimensions.height !== expectedMicroHeight) {
|
||||
errors.push(
|
||||
`battle UI micro icon atlas is ${microDimensions.width}x${microDimensions.height}, expected ${expectedMicroWidth}x${expectedMicroHeight}`
|
||||
);
|
||||
}
|
||||
if (microDimensions.bitDepth !== 8 || microDimensions.colorType !== 6) {
|
||||
errors.push(
|
||||
`battle UI micro icon atlas must be 8-bit RGBA PNG color type 6, got bitDepth=${microDimensions.bitDepth} colorType=${microDimensions.colorType}`
|
||||
);
|
||||
}
|
||||
|
||||
const frameLimit = manifest.columns * manifest.rows;
|
||||
Object.entries(manifest.frames).forEach(([key, frame]) => {
|
||||
assertNonEmptyString(errors, key, 'battle UI icon key');
|
||||
@@ -79,6 +117,79 @@ function validateBattleUiIconAtlas(errors, manifest) {
|
||||
errors.push(`battle UI icon "${key}" frame ${frame} is outside atlas frame range 0..${frameLimit - 1}`);
|
||||
}
|
||||
});
|
||||
const coveredFrames = new Set(Object.values(manifest.frames));
|
||||
for (let frame = 0; frame < frameLimit; frame += 1) {
|
||||
if (!coveredFrames.has(frame)) {
|
||||
errors.push(`battle UI icon atlas frame ${frame} has no manifest key`);
|
||||
}
|
||||
}
|
||||
|
||||
validateBattleUiIconSources(errors, manifest);
|
||||
}
|
||||
|
||||
function validateBattleUiIconSources(errors, manifest) {
|
||||
const sources = manifest.v2Sources;
|
||||
if (!sources || typeof sources !== 'object' || Array.isArray(sources)) {
|
||||
errors.push('battleUiIconManifest.v2Sources must be an object');
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = Object.entries(sources);
|
||||
if (entries.length !== 8) {
|
||||
errors.push(`battleUiIconManifest.v2Sources must contain 8 source icons, got ${entries.length}`);
|
||||
}
|
||||
|
||||
const sourceFrames = new Set();
|
||||
entries.forEach(([key, source]) => {
|
||||
const context = `battleUiIconManifest.v2Sources.${key}`;
|
||||
if (expectedV2IconFrames[key] !== source.frame) {
|
||||
errors.push(`${context}.frame must remain ${expectedV2IconFrames[key]}, got ${source.frame}`);
|
||||
}
|
||||
if (manifest.frames[key] !== source.frame) {
|
||||
errors.push(`${context}.frame ${source.frame} does not match battleUiIconManifest.frames.${key} ${manifest.frames[key]}`);
|
||||
}
|
||||
if (sourceFrames.has(source.frame)) {
|
||||
errors.push(`${context}.frame duplicates frame ${source.frame}`);
|
||||
}
|
||||
sourceFrames.add(source.frame);
|
||||
assertNonEmptyString(errors, source.source, `${context}.source`);
|
||||
|
||||
let dimensions;
|
||||
try {
|
||||
dimensions = readPngDimensions(source.source);
|
||||
} catch (error) {
|
||||
errors.push(`${context}.source cannot be read at "${source.source}": ${error.message}`);
|
||||
return;
|
||||
}
|
||||
if (dimensions.width !== dimensions.height || dimensions.width < 512) {
|
||||
errors.push(`${context}.source must be a square master of at least 512px, got ${dimensions.width}x${dimensions.height}`);
|
||||
}
|
||||
if (dimensions.bitDepth !== 8 || dimensions.colorType !== 6) {
|
||||
errors.push(`${context}.source must be an 8-bit RGBA PNG, got bitDepth=${dimensions.bitDepth} colorType=${dimensions.colorType}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function validateBattleUiIconTextureRouting(errors, manifest, textureForSize) {
|
||||
if (textureForSize(14) !== manifest.microTextureKey || textureForSize(manifest.microMaxDisplaySize) !== manifest.microTextureKey) {
|
||||
errors.push('battle UI icons at or below microMaxDisplaySize must use the micro atlas');
|
||||
}
|
||||
if (textureForSize(manifest.microMaxDisplaySize + 1) !== manifest.textureKey || textureForSize(70) !== manifest.textureKey) {
|
||||
errors.push('battle UI icons above microMaxDisplaySize must use the full atlas');
|
||||
}
|
||||
}
|
||||
|
||||
function validateBattleUsableIcons(errors, usableCatalog, frames) {
|
||||
Object.entries(usableCatalog).forEach(([id, usable]) => {
|
||||
const context = `usableCatalog.${id}.iconKey`;
|
||||
assertNonEmptyString(errors, usable.iconKey, context);
|
||||
if (!(usable.iconKey in frames)) {
|
||||
errors.push(`${context} references unknown battle UI icon "${usable.iconKey}"`);
|
||||
}
|
||||
if (usable.command === 'item' && usable.iconKey !== id) {
|
||||
errors.push(`${context} must use the item's dedicated icon key "${id}", got "${usable.iconKey}"`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function validateItemCatalog(errors, itemCatalog, equipmentSlots) {
|
||||
|
||||
Reference in New Issue
Block a user