Files
NX2navi/tools/prompt_art.py
chrg6 75b0dca907 Add Steam Deck button prompts beside the Xbox and PlayStation ones (prompts = steamdeck).
The Controls tab's Button prompts row and nx2navi.ini take steamdeck: the Deck's own labels in the game's
text, action prompt and ocarina notes, which are A B X Y where an Xbox pad has them, L1 R1 L2 R2 and L3 R3,
Menu and View. The style is drawn by tools/pad_prompts.py like the others, into romfs-patches-steamdeck
and romfs-patches-steamdeck-label, and every build with button art writes them.

Pull request #107 by loteran asked for PlayStation and Steam Deck prompts; it was closed, and the tree
took the PlayStation half in another form, drawn from button art instead of redrawn font glyphs. This is
the Steam Deck half in that form, written here: the art has no Deck style, so its entry in
tools/prompt_art.py names the Xbox pictures for the face buttons, Menu, View, the d-pad and the sticks
and the PlayStation pictures for the shoulders and stick clicks. It needs four pictures of the Xbox set
that no other style used (the white letters on a light button, for the ocarina's notes); a release made
with --prompt-art copies them with the rest.

The Windows files compile and tests/launcher_test.py passes, with the prompts step's test following the
number of folders (seven now); every picture the style names is in the art used for releases. The style
was not drawn here (that needs the game's font), and --probe-cfg, which has a new check, was not run.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 06:53:30 -05:00

157 lines
8.8 KiB
Python

"""The button art tools/pad_prompts.py draws the button prompts from: the pictures of each pad style (STYLES), the
keyboard and mouse folders (KM), and art_files, the files a release carries so the build can draw the prompts on the
player's PC (tools/make_launcher.py and tools/make_kit.py --prompt-art, tools/make_game.py's prompts step).
Standard library only, so the release tools need no imaging packages.
The art is 128x128 PNGs in the folders of button prompt art packs: XGamepad/Default and /Light, P5Gamepad/Default and
/Light, Keyboard_Mouse/Dark, /White and /Blanks. It never enters the repository: a release copies it from the folders
--prompt-art names into its prompt-art folder, which make_game.py passes to pad_prompts.py as --art.
"""
import shutil
import sys
from pathlib import Path
# The art folders, each named as in the art packs; --art holds all three.
ART_SETS = ('XGamepad', 'P5Gamepad', 'Keyboard_Mouse')
# The folder of a release that holds the art (app/<version>/prompt-art, the build kit's prompt-art).
FOLDER = 'prompt-art'
def _sticks(folder, stick, press, rotate):
"""The stick pictures of one style: <stick>_<direction> for L and R."""
out = {}
for side, name in (('L', stick[0]), ('R', stick[1])):
base = f'{folder}{name}'
out.update({f'{side}STICK': f'{base}.png', f'{side}STICK_UP': f'{base}_UP.png',
f'{side}STICK_DOWN': f'{base}_Down.png', f'{side}STICK_LEFT': f'{base}_Left.png',
f'{side}STICK_RIGHT': f'{base}_Right.png', f'{side}STICK_UPDOWN': f'{base}_Y.png',
f'{side}STICK_LEFTRIGHT': f'{base}_X.png', f'{side}STICK_PRESS': f'{folder}{press[side == "R"]}'})
out['RSTICK_ROTATE'] = f'{folder}{rotate}'
return out
def _dpad(folder, name, up):
return {'DPAD': f'{folder}{name}.png', 'DPAD_UP': f'{folder}{name}_{up}.png', 'DPAD_DOWN': f'{folder}{name}_Down.png',
'DPAD_LEFT': f'{folder}{name}_Left.png', 'DPAD_RIGHT': f'{folder}{name}_Right.png',
'DPAD_UPDOWN': f'{folder}{name}_Y.png', 'DPAD_LEFTRIGHT': f'{folder}{name}_X.png'}
# Per style: glyph, the dark pictures the button font and the action prompt's letter are drawn from (by Switch button,
# the face buttons by their place on the pad); note, the light ones the ocarina's note buttons are drawn from. A
# value of OPTIONS is drawn by pad_prompts.py (options_art) from SHARE: the art shows PlayStation's Options button as
# its word over a small pill, which is unreadable at text size, so the prompt is the Create button's pill with three
# bars, as on the pad.
X, XL, P, PL = 'XGamepad/Default/T_X_', 'XGamepad/Light/T_X_', 'P5Gamepad/Default/T_P5_', 'P5Gamepad/Light/T_P5_'
OPTIONS = 'options'
SHARE = 'XGamepad/Default/T_X_Share-1.png'
STYLES = {
'xbox': {
'glyph': {'south': f'{X}A_White.png', 'east': f'{X}B_White.png', 'west': f'{X}X_White.png',
'north': f'{X}Y_White.png', 'L': f'{X}LB.png', 'R': f'{X}RB.png', 'ZL': f'{X}LT.png',
'ZR': f'{X}RT.png', 'PLUS': f'{X}X.png', 'MINUS': f'{X}Share.png', # Menu (three bars), View
**_dpad(X, 'Dpad', 'Up'), **_sticks(X, ('L', 'R'), ('Left_Stick_Click.png', 'Right_Stick_Click.png'),
'R_2D.png')},
'note': {'south': f'{XL}A_Color_Light.png', 'east': f'{XL}B_Color_Light.png', 'west': f'{XL}X_Color_Light.png',
'north': f'{XL}Y_Color_Light.png', 'L': f'{XL}LB_Light.png', 'R': f'{XL}RB_Light.png'},
},
'playstation': {
'glyph': {'south': f'{P}Cross.png', 'east': f'{P}Circle.png', 'west': f'{P}Square.png',
'north': f'{P}Triangle.png', 'L': f'{P}L1.png', 'R': f'{P}R1.png', 'ZL': f'{P}L2.png',
'ZR': f'{P}R2.png', 'PLUS': OPTIONS, 'MINUS': SHARE, # a share pill
**_dpad(P, 'Dpad', 'UP'), **_sticks(P, ('L', 'R'), ('L3.png', 'R3.png'), 'R_2D.png')},
'note': {'south': f'{PL}Cross_Color_Light.png', 'east': f'{PL}Circle_Color_Light.png',
'west': f'{PL}Square_Color_Light.png', 'north': f'{PL}Triangle_Color_Light.png',
'L': f'{PL}L1_Light.png', 'R': f'{PL}R1_Light.png'},
},
# The Steam Deck's own labels, from the two pads' pictures (the packs have no Deck style): A B X Y where an Xbox pad
# has them, in white as the Deck prints them; L1 R1 L2 R2 and L3 R3 as a PlayStation pad names them; Menu (three
# bars) and View.
'steamdeck': {
'glyph': {'south': f'{X}A_White.png', 'east': f'{X}B_White.png', 'west': f'{X}X_White.png',
'north': f'{X}Y_White.png', 'L': f'{P}L1.png', 'R': f'{P}R1.png', 'ZL': f'{P}L2.png',
'ZR': f'{P}R2.png', 'PLUS': f'{X}X.png', 'MINUS': f'{X}Share.png',
**_dpad(X, 'Dpad', 'Up'), **_sticks(X, ('L', 'R'), ('Left_Stick_Click.png', 'Right_Stick_Click.png'),
'R_2D.png'),
'LSTICK_PRESS': f'{P}L3.png', 'RSTICK_PRESS': f'{P}R3.png'},
'note': {'south': f'{XL}A_White_Light.png', 'east': f'{XL}B_White_Light.png', 'west': f'{XL}X_White_Light.png',
'north': f'{XL}Y_White_Light.png', 'L': f'{PL}L1_Light.png', 'R': f'{PL}R1_Light.png'},
},
}
# The keyboard and mouse: a key in each shade (Dark: a dark key with a white label, the button font and the action
# prompt; White: the other way round, the ocarina's notes), and the blank key a key the art has no picture of gets its
# name written on (pad_prompts.py label_art).
KM = 'Keyboard_Mouse/'
SHADES = ('Dark', 'White')
BLANKS = {'Dark': f'{KM}Blanks/T_Blank_Black_Normal.png', 'White': f'{KM}Blanks/T_Blank_White_Normal.png'}
def overlay_folder(style, layout=None):
"""The game folder's overlay folder of a style ('pc' for the keyboard and mouse) and pad layout ('position' or
'label'), as runtime/input_map.c input_prompts_folder names it."""
return f'romfs-patches-{style}' + ('-label' if layout == 'label' else '')
# Every folder pad_prompts.py writes without --style and --layout.
OVERLAYS = tuple(overlay_folder(s, layout) for s in sorted(STYLES) for layout in ('label', 'position')) + \
(overlay_folder('pc'),)
def art_roots(folders):
"""ART_SETS name -> the folder that holds it, from folders that each hold some of ART_SETS or are one of them
(for art spread over several packs). A set named itself wins over one found in a folder that holds it, so a pack's
Source folder and another pack's Keyboard_Mouse take the latter's keys. Exits naming a set that is missing, or
found twice at the same rank."""
ranked = ({}, {}) # sets named themselves, sets found inside a folder
for f in map(Path, folders):
if not f.is_dir():
sys.exit(f'{f}: no such folder')
rank, found = (0, [(f.name, f.parent)]) if f.name in ART_SETS else \
(1, [(n, f) for n in ART_SETS if (f / n).is_dir()])
if not found:
sys.exit(f'{f} holds none of {", ".join(ART_SETS)}')
for name, root in found:
seen = ranked[rank].get(name)
if seen and seen.resolve() != root.resolve():
sys.exit(f'{name} is in both {seen} and {root}: name the one to use')
ranked[rank][name] = root
roots = {**ranked[1], **ranked[0]}
missing = [n for n in ART_SETS if n not in roots]
if missing:
sys.exit(f'no {", ".join(missing)} folder in {", ".join(map(str, folders))}')
return roots
def art_files(folders):
"""Relative path (as pad_prompts.py --art reads it) -> source file, for every picture pad_prompts.py can read: the
pictures of STYLES, SHARE, the blank keys, and every PNG of Keyboard_Mouse/Dark and /White, as a player may bind
any key. Exits naming the pictures that are missing."""
roots = art_roots(folders)
wanted = {rel for style in STYLES.values() for part in style.values() for rel in part.values() if rel != OPTIONS}
wanted |= {SHARE, *BLANKS.values()}
files, missing = {}, []
for rel in sorted(wanted):
src = roots[rel.split('/')[0]] / rel
if src.is_file():
files[rel] = src
else:
missing.append(str(src))
for shade in SHADES:
keys = sorted((roots['Keyboard_Mouse'] / KM / shade).glob('*.png'))
if not keys:
missing.append(str(roots['Keyboard_Mouse'] / KM / shade / '*.png'))
files.update({f'{KM}{shade}/{p.name}': p for p in keys})
if missing:
sys.exit('button art missing:\n ' + '\n '.join(missing[:20]))
return files
def copy_art(folders, dest):
"""Copies art_files(folders) into dest (a release's prompt-art folder, emptied first); returns (files, bytes)."""
files = art_files(folders)
shutil.rmtree(dest, ignore_errors=True)
for rel, src in files.items():
(dest / rel).parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dest / rel)
return len(files), sum(p.stat().st_size for p in files.values())