"""Builds the viewer's art pack, viewer/art/, from a sparse clone of 无名杀 (libnoname/noname): the portraits and card art that src/viewer/noname/art.json maps the upstream engine's generals and cards to, the woodden theme's pieces and its fonts cut down to the characters the viewer draws with them. Needs Pillow (with WebP) and fonttools. git clone --filter=blob:none --no-checkout https://github.com/libnoname/noname git -C sparse-checkout set --no-cone $(python3 src/art.py --paths) git -C checkout 90b58bb9934edb1712fd187a80c8323b7aeabaf3 python3 src/art.py """ import io, json, os, re, shutil, sys from PIL import Image from fontTools import subset SRC = os.path.dirname(os.path.abspath(__file__)) GAME = os.path.dirname(SRC) OUT = os.path.join(GAME, 'viewer', 'art') PORTRAIT_W, PORTRAIT_KB, CARD_KB = 300, 60, 30 THEME = [('theme/woodden/grid.png', 'grid.png'), ('theme/woodden/wood2.png', 'wood2.png'), ('theme/woodden/wood.png', 'wood.png'), ('theme/woodden/wood2.jpg', 'wood2.jpg'), ('image/card/handcard.png', 'handcard.png'), *[(f'theme/style/hp/image/glass{i}.png', f'hp{i}.png') for i in range(1, 5)]] FONTS = ('xinwei', 'huangcao') def webp(im, path, limit_kb, q=82): """Save as WebP at the best quality that fits the size limit.""" while True: buf = io.BytesIO() im.save(buf, 'WEBP', quality=q, method=6) if buf.tell() <= limit_kb * 1024 or q <= 40: break q -= 6 with open(path, 'wb') as f: f.write(buf.getvalue()) return buf.tell() def main(): art = json.load(open(os.path.join(SRC, 'viewer', 'noname', 'art.json'), encoding='utf-8')) if sys.argv[1] == '--paths': # what the sparse clone needs paths = [f'image/character/{c}.jpg' for c in sorted(set(art['generals'].values()))] paths += [f'image/card/{c}.png' for c in sorted(set(art['cards'].values()))] paths += [src for src, _ in THEME] + ['theme/woodden/wood.jpg', 'theme/style/cardback/image/official.png'] paths += [f'font/{f}.woff2' for f in (*FONTS, 'suits')] print('\n'.join(f'/apps/core/{p}' for p in paths)) return core = os.path.join(sys.argv[1], 'apps', 'core') shutil.rmtree(OUT, ignore_errors=True) for d in ('general', 'card', 'theme', 'font'): os.makedirs(os.path.join(OUT, d)) sizes = {} # Portraits: 无名杀's are 350x464; kept whole (the seat card crops with CSS), 300 wide. for cid in sorted(set(art['generals'].values())): im = Image.open(os.path.join(core, 'image', 'character', f'{cid}.jpg')).convert('RGB') im = im.resize((PORTRAIT_W, round(im.height * PORTRAIT_W / im.width)), Image.LANCZOS) sizes[f'general/{cid}'] = webp(im, os.path.join(OUT, 'general', f'{cid}.webp'), PORTRAIT_KB) # Card art: 120x120 with alpha, drawn on the theme's card face. for cid in sorted(set(art['cards'].values())): im = Image.open(os.path.join(core, 'image', 'card', f'{cid}.png')).convert('RGBA') sizes[f'card/{cid}'] = webp(im, os.path.join(OUT, 'card', f'{cid}.webp'), CARD_KB, q=88) # The woodden theme (its grid and wood), the glass 勾玉 and the card back. for src, dst in THEME: shutil.copy(os.path.join(core, src), os.path.join(OUT, 'theme', dst)) sizes['theme/wood'] = webp(Image.open(os.path.join(core, 'theme/woodden/wood.jpg')).convert('RGB'), os.path.join(OUT, 'theme', 'wood.webp'), 40, q=84) back = Image.open(os.path.join(core, 'theme/style/cardback/image/official.png')).convert('RGB').resize((240, 240), Image.LANCZOS) sizes['theme/cardback'] = webp(back, os.path.join(OUT, 'theme', 'cardback.webp'), 30) # Fonts, cut to what is drawn in them: names of generals, skills and cards, and the viewer's own labels. gens = json.load(open(os.path.join(GAME, 'referee', 'generals.json'), encoding='utf-8')) words = list(gens) + [s for g in gens.values() for s in g['skills']] + list(art['cards']) + ['火杀', '雷杀'] for root, _, files in os.walk(os.path.join(SRC, 'viewer')): for f in files: if f.endswith(('.tsx', '.ts', '.css')): words += re.findall(r'[ -鿿＀-￯]+', open(os.path.join(root, f), encoding='utf-8').read()) text = ''.join(sorted(set(''.join(words)))) + '0123456789+-/×·??' + ''.join(chr(c) for c in range(0x20, 0x7f)) for name in FONTS: opts = subset.Options() opts.flavor = 'woff2' opts.layout_features = ['*'] font = subset.load_font(os.path.join(core, 'font', f'{name}.woff2'), opts) s = subset.Subsetter(opts) s.populate(text=text) s.subset(font) out = os.path.join(OUT, 'font', f'{name}.woff2') subset.save_font(font, out, opts) sizes[f'font/{name}'] = os.path.getsize(out) shutil.copy(os.path.join(core, 'font', 'suits.woff2'), os.path.join(OUT, 'font', 'suits.woff2')) for f in ('art.css', 'NOTICE'): shutil.copy(os.path.join(SRC, 'art', f), os.path.join(OUT, f)) total = sum(os.path.getsize(os.path.join(r, f)) for r, _, fs in os.walk(OUT) for f in fs) big = max(sizes.items(), key=lambda kv: kv[1]) print(f'{len(art["generals"])} generals ({len(set(art["generals"].values()))} portraits), {len(art["cards"])} cards, ' f'{len(text)} glyphs; {total / 1e6:.1f} MB in {OUT}; largest {big[0]} {big[1] // 1024} KB') if __name__ == '__main__': main()