| 1 | """Builds the viewer's art pack, viewer/art/, from a sparse clone of 无名杀 (libnoname/noname): the portraits and card |
| 2 | art that src/viewer/noname/art.json maps the upstream engine's generals and cards to, the woodden theme's pieces and |
| 3 | its fonts cut down to the characters the viewer draws with them. Needs Pillow (with WebP) and fonttools. |
| 4 | |
| 5 | git clone --filter=blob:none --no-checkout https://github.com/libnoname/noname <noname> |
| 6 | git -C <noname> sparse-checkout set --no-cone $(python3 src/art.py --paths) |
| 7 | git -C <noname> checkout 90b58bb9934edb1712fd187a80c8323b7aeabaf3 |
| 8 | python3 src/art.py <noname> |
| 9 | """ |
| 10 | import io, json, os, re, shutil, sys |
| 11 | from PIL import Image |
| 12 | from fontTools import subset |
| 13 | |
| 14 | SRC = os.path.dirname(os.path.abspath(__file__)) |
| 15 | GAME = os.path.dirname(SRC) |
| 16 | OUT = os.path.join(GAME, 'viewer', 'art') |
| 17 | PORTRAIT_W, PORTRAIT_KB, CARD_KB = 300, 60, 30 |
| 18 | THEME = [('theme/woodden/grid.png', 'grid.png'), ('theme/woodden/wood2.png', 'wood2.png'), ('theme/woodden/wood.png', 'wood.png'), |
| 19 | ('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)]] |
| 20 | FONTS = ('xinwei', 'huangcao') |
| 21 | |
| 22 | |
| 23 | def webp(im, path, limit_kb, q=82): |
| 24 | """Save as WebP at the best quality that fits the size limit.""" |
| 25 | while True: |
| 26 | buf = io.BytesIO() |
| 27 | im.save(buf, 'WEBP', quality=q, method=6) |
| 28 | if buf.tell() <= limit_kb * 1024 or q <= 40: |
| 29 | break |
| 30 | q -= 6 |
| 31 | with open(path, 'wb') as f: |
| 32 | f.write(buf.getvalue()) |
| 33 | return buf.tell() |
| 34 | |
| 35 | |
| 36 | def main(): |
| 37 | art = json.load(open(os.path.join(SRC, 'viewer', 'noname', 'art.json'), encoding='utf-8')) |
| 38 | if sys.argv[1] == '--paths': |
| 39 | paths = [f'image/character/{c}.jpg' for c in sorted(set(art['generals'].values()))] |
| 40 | paths += [f'image/card/{c}.png' for c in sorted(set(art['cards'].values()))] |
| 41 | paths += [src for src, _ in THEME] + ['theme/woodden/wood.jpg', 'theme/style/cardback/image/official.png'] |
| 42 | paths += [f'font/{f}.woff2' for f in (*FONTS, 'suits')] |
| 43 | print('\n'.join(f'/apps/core/{p}' for p in paths)) |
| 44 | return |
| 45 | core = os.path.join(sys.argv[1], 'apps', 'core') |
| 46 | shutil.rmtree(OUT, ignore_errors=True) |
| 47 | for d in ('general', 'card', 'theme', 'font'): |
| 48 | os.makedirs(os.path.join(OUT, d)) |
| 49 | sizes = {} |
| 50 | |
| 51 | |
| 52 | for cid in sorted(set(art['generals'].values())): |
| 53 | im = Image.open(os.path.join(core, 'image', 'character', f'{cid}.jpg')).convert('RGB') |
| 54 | im = im.resize((PORTRAIT_W, round(im.height * PORTRAIT_W / im.width)), Image.LANCZOS) |
| 55 | sizes[f'general/{cid}'] = webp(im, os.path.join(OUT, 'general', f'{cid}.webp'), PORTRAIT_KB) |
| 56 | |
| 57 | |
| 58 | for cid in sorted(set(art['cards'].values())): |
| 59 | im = Image.open(os.path.join(core, 'image', 'card', f'{cid}.png')).convert('RGBA') |
| 60 | sizes[f'card/{cid}'] = webp(im, os.path.join(OUT, 'card', f'{cid}.webp'), CARD_KB, q=88) |
| 61 | |
| 62 | |
| 63 | for src, dst in THEME: |
| 64 | shutil.copy(os.path.join(core, src), os.path.join(OUT, 'theme', dst)) |
| 65 | 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) |
| 66 | back = Image.open(os.path.join(core, 'theme/style/cardback/image/official.png')).convert('RGB').resize((240, 240), Image.LANCZOS) |
| 67 | sizes['theme/cardback'] = webp(back, os.path.join(OUT, 'theme', 'cardback.webp'), 30) |
| 68 | |
| 69 | |
| 70 | gens = json.load(open(os.path.join(GAME, 'referee', 'generals.json'), encoding='utf-8')) |
| 71 | words = list(gens) + [s for g in gens.values() for s in g['skills']] + list(art['cards']) + ['火杀', '雷杀'] |
| 72 | for root, _, files in os.walk(os.path.join(SRC, 'viewer')): |
| 73 | for f in files: |
| 74 | if f.endswith(('.tsx', '.ts', '.css')): |
| 75 | words += re.findall(r'[ -鿿-]+', open(os.path.join(root, f), encoding='utf-8').read()) |
| 76 | text = ''.join(sorted(set(''.join(words)))) + '0123456789+-/×·??' + ''.join(chr(c) for c in range(0x20, 0x7f)) |
| 77 | for name in FONTS: |
| 78 | opts = subset.Options() |
| 79 | opts.flavor = 'woff2' |
| 80 | opts.layout_features = ['*'] |
| 81 | font = subset.load_font(os.path.join(core, 'font', f'{name}.woff2'), opts) |
| 82 | s = subset.Subsetter(opts) |
| 83 | s.populate(text=text) |
| 84 | s.subset(font) |
| 85 | out = os.path.join(OUT, 'font', f'{name}.woff2') |
| 86 | subset.save_font(font, out, opts) |
| 87 | sizes[f'font/{name}'] = os.path.getsize(out) |
| 88 | shutil.copy(os.path.join(core, 'font', 'suits.woff2'), os.path.join(OUT, 'font', 'suits.woff2')) |
| 89 | |
| 90 | for f in ('art.css', 'NOTICE'): |
| 91 | shutil.copy(os.path.join(SRC, 'art', f), os.path.join(OUT, f)) |
| 92 | |
| 93 | total = sum(os.path.getsize(os.path.join(r, f)) for r, _, fs in os.walk(OUT) for f in fs) |
| 94 | big = max(sizes.items(), key=lambda kv: kv[1]) |
| 95 | print(f'{len(art["generals"])} generals ({len(set(art["generals"].values()))} portraits), {len(art["cards"])} cards, ' |
| 96 | f'{len(text)} glyphs; {total / 1e6:.1f} MB in {OUT}; largest {big[0]} {big[1] // 1024} KB') |
| 97 | |
| 98 | |
| 99 | if __name__ == '__main__': |
| 100 | main() |