explodorama / files / tools / make-pad-layouts.py
make-pad-layouts.py
92cc4ee3 2026-11-02 Pavel Safronov | 1 | #!/usr/bin/env python3 |
| 2 | """Writes assets/controller-art/*.png and src/ui/padLayouts.ts: the controller pictures of the demo's | |
| 3 | virtual controller, and where each button sits on them. | |
| 4 | ||
| 5 | The pictures are line drawings by their maker (OpenGameArt "Controller style 1/3/5", CC BY 4.0; see | |
| 6 | README Attribution and docs/art-provenance.md). This script scales each to 1200 px wide, trims the | |
| 7 | empty border (plus a small margin) and saves it, then writes where the buttons are. The positions below | |
| 8 | were measured by eye on the drawings at 800 px wide (3000 px originals / 3.75); the script converts them | |
| 9 | to the trimmed picture's own units. | |
| 10 | Needs ImageMagick (`magick`) and the downloaded originals in SRC (outside the repository). | |
| 11 | Run: python3 tools/make-pad-layouts.py | |
| 12 | """ | |
| 13 | import json, os, subprocess | |
| 14 | ||
| 15 | SRC = os.path.expanduser('~/code/controller-pack') | |
| 16 | FRAME = 800 # width the measurements below were taken at | |
| 17 | MARGIN = 8 # px of empty border kept round the drawing, at 1200 px wide | |
| 18 | ||
| 19 | # part: (x, y, r) face: top / left / right / bottom by position on the pad. l / r: (x, y, w, h) | |
| 20 | FAMILIES = { | |
| 21 | 'xbox': { # Controller style 1 (an Xbox-style pad); generic pads use it too | |
| 22 | 'file': 'controller_1_v2.png', | |
| 23 | 'dpad': (320, 283, 52), 'rstick': (481, 279, 52), 'start': (445, 184, 15), | |
| 24 | 'face': {'top': (560, 141, 21), 'left': (517, 184, 21), 'right': (603, 184, 21), 'bottom': (560, 227, 21)}, | |
| 25 | 'l': (235, 78, 50, 16), 'r': (565, 78, 50, 16), | |
| 26 | }, | |
| 27 | 'playstation': { # Controller style 5 (a DualSense-style pad) | |
| 28 | 'file': 'controller_5.png', | |
| 29 | 'dpad': (198, 172, 54), 'rstick': (504, 261, 40), 'start': (551, 100, 11), | |
| 30 | 'face': {'top': (602, 125, 22), 'left': (555, 172, 22), 'right': (650, 172, 22), 'bottom': (602, 219, 22)}, | |
| 31 | 'l': (178, 54, 40, 12), 'r': (622, 54, 40, 12), | |
| 32 | }, | |
| 33 | 'nintendo': { # Controller style 3 (a Switch Pro-style pad) | |
| 34 | 'file': 'controller_3.png', | |
| 35 | 'dpad': (305, 265, 45), 'rstick': (481, 265, 36), 'start': (477, 134, 13), | |
| 36 | 'face': {'top': (563, 139, 21), 'left': (514, 181, 21), 'right': (611, 181, 21), 'bottom': (563, 222, 21)}, | |
| 37 | 'l': (215, 80, 60, 14), 'r': (585, 80, 60, 14), | |
| 38 | }, | |
| 39 | } | |
| 40 | ||
| 41 | def run(*a): | |
| 42 | return subprocess.run(a, check=True, capture_output=True, text=True).stdout.strip() | |
| 43 | ||
| 44 | out = {} | |
| 45 | here = os.path.dirname(os.path.abspath(__file__)) | |
| 46 | for name, f in FAMILIES.items(): | |
| 47 | src = os.path.join(SRC, f['file']) | |
| 48 | k = 1200 / FRAME | |
| 49 | # the drawing's box at 1200 px wide: "WxH+X+Y" | |
| 50 | box = run('magick', src, '-resize', '1200x', '-format', '%@', 'info:') | |
| 51 | size, x, y = box.split('+')[0], int(box.split('+')[1]), int(box.split('+')[2]) | |
| 52 | bw, bh = (int(v) for v in size.split('x')) | |
| 53 | x0, y0, w, h = x - MARGIN, y - MARGIN, bw + 2 * MARGIN, bh + 2 * MARGIN | |
| 54 | dest = f'padart-{name}.png' | |
| 55 | run('magick', src, '-resize', '1200x', '-crop', f'{w}x{h}+{x0}+{y0}', '+repage', '-strip', os.path.join(here, '..', 'assets', 'controller-art', dest)) | |
| 56 | u = 1000 / w | |
| 57 | def pt(px, py): return round((px * k - x0) * u, 1), round((py * k - y0) * u, 1) | |
| 58 | def part(px, py, r): a, b = pt(px, py); return {'x': a, 'y': b, 'r': round(r * k * u, 1)} | |
| 59 | def boxp(px, py, bw2, bh2): a, b = pt(px, py); return {'x': a, 'y': b, 'w': round(bw2 * k * u, 1), 'h': round(bh2 * k * u, 1)} | |
| 60 | out[name] = { | |
| 61 | 'image': dest, 'width': 1000, 'height': round(h * u, 1), | |
| 62 | 'dpad': part(*f['dpad']), 'rstick': part(*f['rstick']), 'start': part(*f['start']), | |
| 63 | 'face': {key: part(*v) for key, v in f['face'].items()}, | |
| 64 | 'l': boxp(*f['l']), 'r': boxp(*f['r']), | |
| 65 | } | |
| 66 | ||
| 67 | ts = '''/** | |
| 68 | * Where the buttons sit on each controller family's picture (assets/controller-art/), generated by | |
| 69 | * tools/make-pad-layouts.py from measurements of the drawings; do not edit by hand. The pictures are | |
| 70 | * line drawings from OpenGameArt's "Controller style 1 / 3 / 5", CC BY 4.0 (see README Attribution). | |
| 71 | * Units: a box `width` x `height` (1000 wide) covering the picture; `x`, `y` are the centre of a | |
| 72 | * part, `r` its radius, `w` and `h` a shoulder button's size. Generic pads use the Xbox one. | |
| 73 | */ | |
| 74 | export interface PadDot { x: number; y: number; r: number } | |
| 75 | export interface PadBox { x: number; y: number; w: number; h: number } | |
| 76 | export interface PadLayout { | |
| 77 | /** The picture's file name in assets/controller-art/. */ | |
| 78 | image: string | |
| 79 | width: number | |
| 80 | height: number | |
| 81 | dpad: PadDot | |
| 82 | rstick: PadDot | |
| 83 | /** Start / Options / Menu / Plus. */ | |
| 84 | start: PadDot | |
| 85 | /** The four face buttons by their place on the pad. */ | |
| 86 | face: { top: PadDot; left: PadDot; right: PadDot; bottom: PadDot } | |
| 87 | l: PadBox | |
| 88 | r: PadBox | |
| 89 | } | |
| 90 | ||
| 91 | export const PAD_LAYOUTS: Record<'xbox' | 'playstation' | 'nintendo', PadLayout> = ''' + json.dumps(out, indent=2) + '\n' | |
| 92 | open(os.path.join(here, '..', 'src', 'ui', 'padLayouts.ts'), 'w').write(ts) | |
| 93 | print('ok', {k: (v['width'], v['height']) for k, v in out.items()}) |