Reorganize mission menu and separate design from logic
continuous-integration/drone/push Build is passing

This commit is contained in:
2026-10-17 18:27:31 +02:00
parent 0e71ef1fa7
commit d5464f7438
52 changed files with 2596 additions and 716 deletions
+163
View File
@@ -0,0 +1,163 @@
#!/usr/bin/env python3
"""Build native menu layouts and the runtime palette from ui/admin_menu sources."""
import argparse
import json
from pathlib import Path
import re
import sys
ROOT = Path(__file__).resolve().parents[1]
SOURCE = ROOT / 'ui/admin_menu'
LAYOUTS = Path('addons/core/UI/layouts/ERR')
PALETTE = Path('addons/core/Scripts/Game/ERR/00_Core/ERR_MenuTheme.c')
PAGES = Path('addons/core/Scripts/Game/ERR/00_Core/ERR_AdminMenuPages.c')
TOKEN = re.compile(r'\{\{([^{}]+)\}\}')
def navigation(source, tokens):
config = json.loads((source / 'navigation.json').read_text(encoding='utf-8'))
pages, groups = config['pages'], config['groups']
if [p['id'] for p in pages] != list(range(len(pages))) or not 1 <= len(groups) <= 8:
raise ValueError('Page IDs must be consecutive and main navigation supports 1-8 groups')
for field in ('key', 'widget', 'button'):
values = [p[field] for p in pages]
if len(set(values)) != len(values) or any(not re.fullmatch(r'[A-Za-z][A-Za-z_0-9]*', v) for v in values):
raise ValueError(f'Duplicate or invalid navigation {field}')
keys = {p['key']: p for p in pages}
members = [key for group in groups for key in group['pages']]
names = [g['name'] for g in groups]
if (len(members) != len(set(members)) or set(members) != keys.keys()
or len(set(names)) != len(names)
or any(not re.fullmatch(r'[A-Za-z][A-Za-z0-9]*', n) for n in names)
or any(not 1 <= len(g['pages']) <= 8 for g in groups)):
raise ValueError('Every page must belong to exactly one valid sidebar group')
for page in pages:
if not isinstance(page['label'], str) or any(c in page['label'] for c in '\n\r"\\'):
raise ValueError('Invalid navigation label')
def slot(x, y, w, h):
return f'Slot FrameWidgetSlot {{ Anchor 0 0 0 0 PositionX {x} OffsetLeft {x} PositionY {y} OffsetTop {y} SizeX {w} OffsetRight {-x-w} SizeY {h} OffsetBottom {-y-h} }}'
def button(name, label, x, y, w, selected=False):
return (f'ButtonWidgetClass : "{{75C912A1C89BE6C2}}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" {{ Name "{name}" {slot(x,y,w,42)} '
f'components {{ SCR_ButtonTextComponent "{{5548F0143AE99095}}" {{ m_sText "{label}" }} }} }}\n'
f'ImageWidgetClass {{ "Is Visible" {int(selected)} Name "Selected_{name}" {slot(x,y+44,w,3)} Color {tokens["COLOR_ACCENT"]} }}\n')
layout = ''
assignments = {}
for index, group in enumerate(groups):
name = group['name']
initial = 'MISSION' in group['pages']
layout += button('Main_' + name, name, 28 + index * 146, 106, 138, initial)
layout += f'FrameWidgetClass {{ "Is Visible" {int(initial)} Name "Sidebar_{name}" {slot(28,166,180,480)} {{\n'
for row, key in enumerate(group['pages']):
page = keys[key]
assignments[key] = index
layout += button(page['button'], page['label'], 0, row * 54, 180, key == 'MISSION')
layout += '} }\n'
def strings(method, values):
return f' static array<string> {method}() {{ return {{' + ', '.join('"' + v + '"' for v in values) + '}; }\n'
script = '// Generated by tools/build_admin_menu.py; edit ui/admin_menu/navigation.json.\n'
script += 'enum ERR_AdminPage\n{\n' + ''.join(f' {p["key"]} = {p["id"]},\n' for p in pages) + '}\n\n'
script += 'class ERR_AdminMenuPages\n{\n'
script += strings('Pages', [p['widget'] for p in pages])
script += strings('Navigation', [p['button'] for p in pages])
script += strings('MainButtons', ['Main_' + n for n in names])
script += strings('Sidebars', ['Sidebar_' + n for n in names])
script += ' static int Group(int page)\n {\n array<int> groups = {' + ', '.join(str(assignments[p['key']]) for p in pages) + '};\n if (page < 0 || page >= groups.Count()) return -1;\n return groups[page];\n }\n'
script += ' static int MainPage(int group)\n {\n array<int> pages = {' + ', '.join('ERR_AdminPage.' + g['pages'][0] for g in groups) + '};\n if (group < 0 || group >= pages.Count()) return -1;\n return pages[group];\n }\n}\n'
visibility = {'VISIBLE_' + p['widget']: str(int(p['key'] == 'MISSION')) for p in pages}
return layout, script, visibility
def source_file(source, name):
path = (source / name).resolve()
if not path.is_relative_to(source.resolve()) or not path.is_file():
raise ValueError(f'Invalid menu source: {name}')
return path
def expand(source, name, tokens, stack=()):
path = source_file(source, name)
if path in stack:
raise ValueError(f'Cyclic layout include: {name}')
def substitute(match):
key = match[1]
if key.startswith('include:'):
return expand(source, key[len('include:'):], tokens, (*stack, path)).rstrip('\n')
if key not in tokens:
raise ValueError(f'Unknown theme token {key} in {name}')
return tokens[key]
return TOKEN.sub(substitute, path.read_text(encoding='utf-8'))
def render(source=SOURCE):
theme = json.loads((source / 'theme.json').read_text(encoding='utf-8'))
tokens, colors = theme['layout'], theme['buttons']
for name, value in tokens.items():
if not re.fullmatch(r'[A-Z][A-Z_]*', name) or not isinstance(value, str):
raise ValueError(f'Invalid theme token: {name}')
if name == 'FONT':
valid = re.fullmatch(r'\{[A-Fa-f0-9]{16}\}[A-Za-z0-9_/.-]+\.fnt', value)
elif name.startswith('SIZE_'):
valid = value.isascii() and value.isdigit() and 8 <= int(value) <= 96
elif name.startswith('COLOR_'):
parts = value.split()
valid = len(parts) == 4 and all(re.fullmatch(r'\d+(?:\.\d+)?', p) and 0 <= float(p) <= 1 for p in parts)
else:
valid = False
if not valid:
raise ValueError(f'Invalid theme value: {name}')
for name, value in colors.items():
if not re.fullmatch(r'[A-Z][A-Z_]*', name) or not isinstance(value, str) or not re.fullmatch(r'0x[0-9A-F]{8}', value):
raise ValueError(f'Invalid runtime color: {name}')
required = {'NEUTRAL', 'NEUTRAL_HOVER', 'POSITIVE', 'POSITIVE_HOVER', 'NEGATIVE',
'NEGATIVE_HOVER', 'MIXED', 'READY_TEXT', 'UNAVAILABLE_TEXT'}
if not required <= colors.keys():
raise ValueError('Missing runtime palette colors')
nav_layout, nav_script, visibility = navigation(source, tokens)
tokens = {**tokens, **visibility, 'NAVIGATION': nav_layout}
manifest = json.loads((source / 'layouts.json').read_text(encoding='utf-8'))
outputs = {}
for output, template in manifest.items():
if not re.fullmatch(r'[A-Za-z][A-Za-z0-9]*\.layout', output):
raise ValueError(f'Invalid layout output: {output}')
outputs[LAYOUTS / output] = expand(source, template, tokens)
outputs[PALETTE] = ('// Generated by tools/build_admin_menu.py; edit ui/admin_menu/theme.json.\n'
'class ERR_MenuTheme\n{\n' + ''.join(
f' static const int {name} = {value};\n' for name, value in colors.items()) + '}\n')
outputs[PAGES] = nav_script
return outputs
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--check', action='store_true', help='Fail on stale output without changing files')
args = parser.parse_args()
try:
outputs = render() # Validate everything before writing any output.
stale = [path for path, content in outputs.items()
if not (ROOT / path).exists() or (ROOT / path).read_text(encoding='utf-8') != content]
if args.check:
if stale:
print('Stale generated menu files; run python3 tools/build_admin_menu.py:', file=sys.stderr)
for path in stale:
print(path, file=sys.stderr)
return 1
print('Menu layouts and palette are up to date.')
else:
for path in stale:
(ROOT / path).write_text(outputs[path], encoding='utf-8')
print(f'Updated {len(stale)} menu files.')
except (OSError, ValueError, KeyError, TypeError) as error:
print(f'Cannot build admin menu: {error}', file=sys.stderr)
return 1
return 0
if __name__ == '__main__':
sys.exit(main())