mirror of
https://github.com/tangerineturtle1/NX2navi.git
synced 2026-11-15 19:19:58 -04:00
Windows and the build tools stop at 260 characters for a file's whole path, and a built game needs up to 128 below its folder. A deeper folder made the build stop in the middle with a file "not found"; Begin Installation and the install job now refuse it first and name both lengths. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2793 lines
138 KiB
Python
2793 lines
138 KiB
Python
"""The launcher's window, laid out like the game's options menu (docs/launcher.md, "Look"): a wooden title band with
|
|
Navi and the name in the game's Cetra Display across the whole top (the window's own title bar: drag it to move the
|
|
window, minimise and close at its right end), a list of rows on the left (a bronze label cell and a dark value cell each), a white
|
|
glowing cursor on the selected row, filigree dividers between groups and a description box on the right that
|
|
explains the selected row.
|
|
|
|
The window is one Tk canvas at 1000 x 640 logical pixels, scaled to the display. The pictures come from art.py; text,
|
|
flat cells and the orange choices are canvas items. Screens:
|
|
|
|
first_run the dump, the install folder, three choices and Begin Installation
|
|
home Play, Updates, the game folder, Saves, Rebuild the Game, Customize Launcher, Settings
|
|
customize the colour scheme, the player's own background picture, animations and sounds; Add to Steam
|
|
color the Custom scheme's colour picker: a colour square, a hue bar and the hex code
|
|
saves Export Save (a zip for a bug report or another player), Import a Save (such a zip), a save folder
|
|
job the steps of an install, update or rebuild, with their progress, and Cancel; after a failure Try Again
|
|
and Copy Report
|
|
settings the launcher's choices, the folders, the logs and Uninstall
|
|
uninstall what to remove, then Uninstall or Cancel
|
|
|
|
Mouse, keyboard (arrows, Enter or Space, Esc, F5) and an XInput gamepad (d-pad or stick, A, B, Y) all move the cursor.
|
|
|
|
The look is the player's (Customize Launcher): art.set_theme fills the palette every picture is drawn from, a picture
|
|
of the player's own can replace the backdrop (picture.py), and unless Animations is off the cursor glides between
|
|
rows, a new screen slides in, sparks drift over the backdrop and Navi bobs in the title band (animate). sound.py
|
|
plays the game's own decide and cancel sounds, read from the player's game, for a choice and for going back.
|
|
"""
|
|
import argparse
|
|
import colorsys
|
|
import math
|
|
import os
|
|
import random
|
|
import shutil
|
|
import sys
|
|
import time
|
|
import tkinter as tk
|
|
import tkinter.font as tkfont
|
|
from pathlib import Path
|
|
from tkinter import filedialog
|
|
|
|
from .. import NAME, build, feed, gameini, gamefiles, jobs, locks, mods, saves, steam, versions, winsys
|
|
from ..context import Context
|
|
from ..paths import long_path_problem
|
|
from . import art, picture, sound
|
|
|
|
W, H = 1000, 640
|
|
BAND = (0, 1000, 56) # x0, x1, height: the whole top of the window, which is also its title bar
|
|
PIC_TOP = 60 # the player's background picture starts under the band and its two lines
|
|
TITLE_BUTTONS = (('min', 908, 954), ('close', 954, 1000)) # x ranges on the band
|
|
PANEL = (112, 60, 580) # x0, top, x1
|
|
ROWS = (140, 552, 96) # x0, x1, top
|
|
LABEL_W = 222 # 54 % of the row
|
|
GAP = 12
|
|
DESC = (614, 336) # x, width
|
|
P = art.PALETTE # the colour scheme in use: art.set_theme changes it in place
|
|
FRAME_MS = 15 # between two steps of the animations; they move by the time that really passed
|
|
SPARKS = 22 # the dust drifting over the backdrop
|
|
DEPTH = {'first_run': 0, 'home': 0, 'job': 1, 'saves': 1, 'settings': 1, 'customize': 1, 'uninstall': 2,
|
|
'color': 2, 'mods': 1, 'mod_order': 2}
|
|
MODS_PAGE = 7 # mods on a page of the Mods screen, beside its other rows
|
|
ORDER_PAGE = 10 # ... of the Load Order screen
|
|
# the Custom scheme's picker (draw_picker), logical pixels: its box (x, top, width, height), the colour square and
|
|
# the hue bar (x0, y0, x1, y1)
|
|
PICKER_BOX = (614, 100, 336, 452)
|
|
SQUARE = (667, 160, 897, 390)
|
|
BAR = (667, 408, 897, 430)
|
|
CUSTOM_LABELS = {'background': 'Background Color', 'panel': 'Panel Color', 'buttons': 'Button Color'}
|
|
CUSTOM_DESC = {'background': 'The color behind the menus.', 'panel': 'The color of the panels and the top bar.',
|
|
'buttons': 'The color of the buttons.'}
|
|
DIMS = (0, 20, 40, 60, 80) # Background Dimming, in percent
|
|
SOUND_LEVELS = (('off', 'Off'), ('low', 'Low'), ('medium', 'Medium'), ('high', 'High'))
|
|
|
|
|
|
class Row:
|
|
def __init__(self, kind, label='', value='', style='normal', big=False, icon=None, badge=False, desc=None,
|
|
on=None, left=None, right=None, get=None, options=None, fraction=0.0, actions=None, check=False,
|
|
focus_value=False, height=None, enabled=True):
|
|
self.kind, self.label, self.value, self.style = kind, label, value, style
|
|
self.big, self.icon, self.badge, self.desc, self.check = big, icon, badge, desc, check
|
|
self.on, self.left, self.right, self.get, self.options = on, left, right, get, options
|
|
self.fraction, self.actions, self.focus_value, self.enabled = fraction, actions or [], focus_value, enabled
|
|
default_h = {'divider': 26, 'action': 54, 'pair': 44}.get(kind, 64 if big else 40)
|
|
self.h = height or default_h
|
|
self.rect = self.value_rect = None
|
|
self.parts = [] # pair: [(rect, label, callback)]
|
|
|
|
@property
|
|
def focusable(self):
|
|
return self.kind != 'divider'
|
|
|
|
|
|
def fit(font, text, width):
|
|
"""text shortened in the middle with an ellipsis so it fits width pixels."""
|
|
if font.measure(text) <= width:
|
|
return text
|
|
lo, hi = 1, len(text)
|
|
while lo < hi:
|
|
mid = (lo + hi + 1) // 2
|
|
keep = mid // 2
|
|
cand = text[:keep] + '…' + text[len(text) - (mid - keep):]
|
|
if font.measure(cand) <= width:
|
|
lo = mid
|
|
else:
|
|
hi = mid - 1
|
|
keep = lo // 2
|
|
return text[:keep] + '…' + text[len(text) - (lo - keep):]
|
|
|
|
|
|
def free_gb(path):
|
|
p = Path(path)
|
|
while not p.exists() and p.parent != p:
|
|
p = p.parent
|
|
try:
|
|
return shutil.disk_usage(p).free / 1e9
|
|
except OSError:
|
|
return 0.0
|
|
|
|
|
|
# Exit codes that end the game, and what they mean. The game exits with 70 after a FATAL line of its own: an error it
|
|
# cannot go on from, most often an allocation that failed because the memory ran out (runtime/dispatch.c rt_fatal), or
|
|
# a failed re-run of itself (runtime/platform/win32.c plat_reexec). Its crash report exits with 71 after writing its
|
|
# FATAL lines (runtime/main.c crash_handler). The others come from Windows when something ends the process before or
|
|
# around that report.
|
|
EXIT_CODES = {
|
|
70: 'a fatal error: the FATAL line near the end of its log says which, often memory that ran out',
|
|
71: 'the crash report ended the game',
|
|
0xC0000005: 'access violation',
|
|
0xC00000FD: 'stack overflow',
|
|
0xC0000142: 'a DLL failed to start',
|
|
0xC0000374: 'heap corruption',
|
|
0xC0000409: 'fail-fast: a security check or abort ended the process without an exception',
|
|
0xC0000417: 'invalid parameter to a C runtime function',
|
|
0xC000041D: 'a fault inside a Windows callback',
|
|
0xE06D7363: 'a C++ exception no one caught',
|
|
}
|
|
|
|
|
|
def exit_code_text(code):
|
|
"""The exit code as the game's log and Windows show it: hex for a Windows status, with its meaning when known."""
|
|
code &= 0xFFFFFFFF
|
|
meaning = EXIT_CODES.get(code)
|
|
shown = f'0x{code:08X}' if code >= 0x80000000 else str(code)
|
|
return f'{shown} ({meaning})' if meaning else shown
|
|
|
|
|
|
FATAL_TAIL = 256 << 10 # bytes of the game's log last_fatal reads from its end
|
|
FATAL_SHOWN = 240 # characters of a FATAL line the home screen shows; the launcher's log has all of it
|
|
|
|
|
|
def last_fatal(game_dir):
|
|
"""The last FATAL line of the game's latest run, without its "FATAL: ", or ''. The game writes one before it
|
|
exits with 70 (runtime/dispatch.c rt_fatal) or 71 (runtime/main.c crash_handler) into logs/nx2navi.log, which
|
|
each run starts anew (runtime/main.c log_open). A line this launcher added for an earlier run's end
|
|
(note_exit_in_log) ends the search: what stands before it is not this run's."""
|
|
if not game_dir:
|
|
return ''
|
|
try:
|
|
with open(Path(game_dir) / 'logs' / 'nx2navi.log', 'rb') as f:
|
|
f.seek(max(0, f.seek(0, 2) - FATAL_TAIL))
|
|
lines = f.read().decode('utf-8', 'replace').splitlines()
|
|
except OSError:
|
|
return ''
|
|
for line in reversed(lines):
|
|
if line.startswith('[launcher] the game ended'):
|
|
return ''
|
|
if line.startswith('FATAL: '):
|
|
return line[len('FATAL: '):].strip()
|
|
return ''
|
|
|
|
|
|
def note_exit_in_log(game_dir, text):
|
|
"""Adds the exit code to the end of the game's log, so a log a player sends shows how the run ended even when the
|
|
process died before it could write a line itself."""
|
|
if not game_dir:
|
|
return
|
|
log = Path(game_dir) / 'logs' / 'nx2navi.log'
|
|
try:
|
|
with open(log, 'a', encoding='utf-8') as f:
|
|
f.write(f'[launcher] the game ended with code {text} at {time.strftime("%Y-%m-%d %H:%M:%S")}\n')
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def partial_image_gb(game_dir):
|
|
"""The size of what an install that was cut short left of the RomFS image (romfs/romfs.bin.part, up to 26 GB).
|
|
The next install writes the image anew (tools/make_game.py step_romfs), so that space is the install's to use:
|
|
the first-run screen counts it as free."""
|
|
try:
|
|
return (Path(game_dir) / 'romfs' / 'romfs.bin.part').stat().st_size / 1e9
|
|
except OSError:
|
|
return 0.0
|
|
|
|
|
|
def drop_partial_image(game_dir):
|
|
"""Removes that file (partial_image_gb), so that it does not count against the free space an install asks
|
|
for."""
|
|
try:
|
|
(Path(game_dir) / 'romfs' / 'romfs.bin.part').unlink(missing_ok=True)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
# Seconds after an update check before the Updates row or F5 starts another. Each check is a request to GitHub,
|
|
# which allows 60 an hour for an address: a player who selected the row 25 times in 90 seconds used the allowance
|
|
# up, and every check of the next hour was refused.
|
|
FORCED_CHECK_PAUSE = 60
|
|
|
|
# How long an update's rebuild takes, for the Updates row. No single figure holds: a version that changed little
|
|
# compiles a few files and links, minutes; one that changed the lifter translates and compiles the game's code again,
|
|
# and one that changed what the shaders are built with builds all of them into the graphics driver's cache again
|
|
# (tools/make_game.py warm_driver_cache). Players' updates took 24, 34 and 45 minutes where the text said "a few
|
|
# minutes".
|
|
UPDATE_TAKES = ('That takes a few minutes when little changed, and half an hour or more when the game\'s code or '
|
|
'its shaders are built again.')
|
|
|
|
|
|
def drive_of(path):
|
|
return Path(path).anchor.rstrip('\\/') or str(Path(path).parent)
|
|
|
|
|
|
class App:
|
|
def __init__(self, root, ctx, args):
|
|
self.root, self.ctx, self.args = root, ctx, args
|
|
self.layout, self.settings = ctx.layout, ctx.settings
|
|
self.s = self.compute_scale()
|
|
self.sprites = {}
|
|
self.photos = {}
|
|
self.rows, self.focus = [], 0
|
|
self.screen = None
|
|
self.job = None
|
|
self.job_rev = -1
|
|
self.job_return = 'home'
|
|
self.check = None
|
|
self.release = None
|
|
self.check_error = ''
|
|
self.checked_at = 0.0
|
|
self.check_started = 0.0
|
|
# the update fetched in the background and installed when the game is not running; a checkout and the
|
|
# selftest have nothing to switch to
|
|
self.bg = jobs.BackgroundUpdate(ctx, lambda a: winsys.restart(self.layout, a),
|
|
enabled=not self.layout.dev and not args.selftest)
|
|
self.row_gap = GAP
|
|
self.message = ''
|
|
self.look_message = '' # why the last background picture was not taken, in Customize Launcher's descriptions
|
|
self.save_message = '' # the last import's result, shown in the import rows' descriptions
|
|
self.export_message = '' # the last Export Save result, shown in its description
|
|
self.steam_message = '' # the last Add to Steam result, shown in its description
|
|
self.mods_message = '' # what the last change on the Mods screen did, shown in its descriptions
|
|
self.mods_page = 0 # the page of the Mods and Load Order screens
|
|
self.mods_failed = [] # the mods whose code a build before Play could not take in: Play no longer waits
|
|
self.steam_busy = False # Add to Steam is making the pictures or writing Steam's files
|
|
self.pick = None # the colour picker: {'slot', 'h', 's', 'v'} (HSV in 0..1) of the colour being picked
|
|
self.pick_drag = None # 'square' or 'bar' while the mouse drags on the picker
|
|
self.hex_entry = None # the box the hex code is typed into
|
|
self.look_after = None # the redraw a picked colour waits for (look_soon)
|
|
self.dumps = None
|
|
self.dump_search = None
|
|
self.uninstall_game = False
|
|
self.game_proc = None
|
|
self.cursor_frames, self.cursor_k = [], 0
|
|
self.last_second = 0
|
|
self.gamepad = winsys.Gamepad()
|
|
self.tb_hover = None
|
|
self.drag = None
|
|
self.ico = None
|
|
# the look: the colour scheme, the player's background picture at the window's size (None: the scheme's own
|
|
# backdrop), the sounds (none while the selftest draws the screens)
|
|
art.set_theme(self.settings.get('theme'), self.custom_colors())
|
|
self.bg_store = self.layout.data / 'background.npy'
|
|
self.picture = None
|
|
self.sounds = sound.Sounds(self.layout.cache, 'off' if args.selftest else self.settings.get('sounds'), ctx.log)
|
|
# the animations' state (animate): the clock and the last step's time, the cursor's place on its way to the
|
|
# focused row, how far the screen still stands from its place after a change, the sparks, Navi and its halo
|
|
self.anim_t = 0.0
|
|
self.anim_last = time.perf_counter()
|
|
self.cur_xy = self.cur_target = None
|
|
self.cur_screen = None
|
|
self.slide = 0.0
|
|
self.slide_applied = 0
|
|
self.sparks = []
|
|
self.navi_xy = None
|
|
self.halo = []
|
|
|
|
root.title(NAME)
|
|
root.resizable(False, False)
|
|
root.configure(background=P['window'])
|
|
self.canvas = tk.Canvas(root, width=self.X(W), height=self.X(H), highlightthickness=0, bd=0,
|
|
background=P['window'], cursor='arrow')
|
|
self.canvas.pack()
|
|
self.set_icon()
|
|
self.load_fonts()
|
|
self.load_picture()
|
|
self.draw_static()
|
|
self.bind_input()
|
|
self.start()
|
|
self.root.after(80, self.animate)
|
|
self.root.after(150, self.tick)
|
|
self.root.after(50, self.poll_gamepad)
|
|
self.root.after(60000, self.minute)
|
|
self.center()
|
|
self.framed = not winsys.frameless(root) # Windows: the band is the title bar
|
|
self.root.after(60, self.native_window)
|
|
|
|
# ---------------------------------------------------------------- scale, icon, fonts
|
|
|
|
def compute_scale(self):
|
|
try:
|
|
s = self.root.winfo_fpixels('1i') / 96.0
|
|
except tk.TclError:
|
|
s = 1.0
|
|
sw, sh = self.root.winfo_screenwidth(), self.root.winfo_screenheight()
|
|
s = min(max(s, 1.0), sw * 0.94 / W, sh * 0.88 / H)
|
|
return max(0.75, round(s * 4) / 4)
|
|
|
|
def X(self, v):
|
|
return int(round(v * self.s))
|
|
|
|
def center(self):
|
|
self.root.update_idletasks()
|
|
w, h = self.X(W), self.X(H)
|
|
x = max(0, (self.root.winfo_screenwidth() - w) // 2)
|
|
y = max(0, (self.root.winfo_screenheight() - h) // 3)
|
|
self.root.geometry(f'{w}x{h}+{x}+{y}')
|
|
|
|
def set_icon(self):
|
|
"""Navi as the window's icon: Tk's photo icon everywhere, and on Windows the .ico set on the window itself, so
|
|
the taskbar button shows Navi rather than Python's icon."""
|
|
try:
|
|
self.photos['icon'] = art.photo(self.root, art.navi_icon(64))
|
|
self.root.iconphoto(True, self.photos['icon'])
|
|
if os.name == 'nt':
|
|
data = art.navi_ico()
|
|
self.ico = self.layout.cache / 'nx2navi.ico'
|
|
if not self.ico.is_file() or self.ico.read_bytes() != data:
|
|
self.ico.write_bytes(data)
|
|
except (tk.TclError, OSError) as e:
|
|
self.ctx.log(f'icon: {e}')
|
|
|
|
def native_window(self):
|
|
if self.ico:
|
|
winsys.set_window_icon(self.root, self.ico)
|
|
try:
|
|
self.root.focus_force()
|
|
except tk.TclError:
|
|
pass
|
|
|
|
def load_fonts(self):
|
|
"""The game's Cetra Display when this PC has decoded it from the player's game (data/cache/fonts), else the
|
|
system's interface font."""
|
|
fams = {}
|
|
for role, (path, family) in gamefiles.cached_fonts(self.layout).items():
|
|
if winsys.register_font(path):
|
|
fams[role] = family
|
|
available = set(tkfont.families(self.root))
|
|
fallback = next((f for f in ('Segoe UI', 'Source Sans 3', 'Noto Sans', 'DejaVu Sans', 'Helvetica')
|
|
if f in available), 'TkDefaultFont')
|
|
# Every text is in the game's Cetra Display; only the build log keeps a monospaced face so its lines align.
|
|
text = title = fams.get('title') if fams.get('title') in available else fallback
|
|
mono = next((f for f in ('Consolas', 'Cascadia Mono', 'DejaVu Sans Mono', 'Courier New') if f in available),
|
|
'TkFixedFont')
|
|
self.game_fonts = bool(fams.get('title') and fams['title'] in available)
|
|
self.ctx.log(f'fonts: {text}' + (' (the game\'s, from data/cache/fonts)' if self.game_fonts else
|
|
' (the game\'s Cetra Display is not decoded yet)'))
|
|
|
|
self.fspec = {}
|
|
|
|
def F(family, px, weight='normal'):
|
|
font = tkfont.Font(root=self.root, family=family, size=-max(8, int(round(px * self.s))), weight=weight)
|
|
self.fspec[id(font)] = (family, max(8, int(round(px * self.s))), weight)
|
|
return font
|
|
self.fcache = {}
|
|
self.f = {'band': F(title, 25), 'big': F(text, 23), 'big_small': F(text, 21), 'label': F(text, 17),
|
|
'value': F(text, 16), 'desc': F(text, 17), 'small': F(text, 15), 'heading': F(title, 21),
|
|
'sub': F(title, 17), 'action': F(text, 18), 'status': F(text, 15), 'mono': F(mono, 11.5),
|
|
'badge': F(text, 15, 'bold'), 'action_big': F(text, 21)}
|
|
|
|
def fit_font(self, font, text, width, smallest=0.72):
|
|
"""font, or the same face a few pixels smaller so text fits width (down to smallest of its size)."""
|
|
if font.measure(text) <= width:
|
|
return font
|
|
family, px, weight = self.fspec[id(font)]
|
|
for size in range(px - 1, max(7, int(px * smallest)) - 1, -1):
|
|
key = (family, size, weight)
|
|
if key not in self.fcache:
|
|
self.fcache[key] = tkfont.Font(root=self.root, family=family, size=-size, weight=weight)
|
|
if self.fcache[key].measure(text) <= width:
|
|
return self.fcache[key]
|
|
return self.fcache[(family, max(7, int(px * smallest)), weight)]
|
|
|
|
def fonts_from_game(self, game_dir='', dump=''):
|
|
"""Decode the game's fonts and its interface sounds in the background, redraw with the fonts and take up
|
|
the sounds."""
|
|
fonts, sounds = not self.game_fonts, not self.sounds.game
|
|
if not fonts and not sounds:
|
|
return
|
|
|
|
def work():
|
|
got = heard = {}
|
|
try:
|
|
if fonts:
|
|
# The cached files are registered (load_fonts) and were not usable, or this would not run:
|
|
# registered, Windows keeps them open, and decoding them again failed with "Permission denied"
|
|
# at every start, so a cache file that had gone bad was never replaced.
|
|
for path, _family in gamefiles.cached_fonts(self.layout).values():
|
|
winsys.unregister_font(path)
|
|
got = gamefiles.extract_fonts(self.layout, game_dir, dump)
|
|
except Exception as e:
|
|
self.ctx.log(f'fonts: {e}')
|
|
try:
|
|
if sounds:
|
|
heard = gamefiles.extract_sounds(self.layout, game_dir, dump)
|
|
self.ctx.log(f'sounds: {len(heard)} of {len(gamefiles.SOUNDS)} interface sounds from the game')
|
|
except Exception as e:
|
|
self.ctx.log(f'sounds: {e}')
|
|
if got:
|
|
self.root.after(0, self.reload_fonts)
|
|
if heard:
|
|
self.root.after(0, self.sounds.load_game)
|
|
import threading
|
|
threading.Thread(target=work, daemon=True).start()
|
|
|
|
def reload_fonts(self):
|
|
self.load_fonts()
|
|
self.draw_static()
|
|
self.render()
|
|
|
|
# ---------------------------------------------------------------- pictures
|
|
|
|
def sprite(self, key, make):
|
|
if key not in self.sprites:
|
|
self.sprites[key] = art.photo(self.root, make())
|
|
return self.sprites[key]
|
|
|
|
def animated(self):
|
|
"""Animations on (Customize Launcher); never while the selftest draws the screens, whose pictures must not
|
|
depend on the moment they are taken."""
|
|
return bool(self.settings.get('animations')) and not self.args.selftest
|
|
|
|
def load_picture(self):
|
|
"""The player's background at the size of the window below the title band, when one is set and its kept copy
|
|
is readable. A copy an older launcher kept for the whole window is made again from the picture when the file
|
|
is still there, so no more of it is cut off than the new shape needs."""
|
|
self.picture = None
|
|
src = self.settings.get('background')
|
|
if src:
|
|
if picture.kept_shape(self.bg_store) not in (None, (picture.KEEP_H, picture.KEEP_W)) \
|
|
and Path(src).is_file():
|
|
try:
|
|
picture.keep(src, self.bg_store, self.root)
|
|
except (picture.PictureError, OSError, MemoryError, ValueError) as e:
|
|
self.ctx.log(f'background: {src}: {e!r}; the copy kept before stays')
|
|
self.picture = picture.kept(self.bg_store, self.X(W), self.X(H) - self.X(PIC_TOP))
|
|
if self.picture is None:
|
|
self.ctx.log(f'background: {self.bg_store} is missing or unreadable; the colour scheme\'s backdrop')
|
|
|
|
def see_through(self):
|
|
"""How solid the panel and the description box are: a little see-through over the player's own picture."""
|
|
return 0.8 if self.picture is not None else 1.0
|
|
|
|
def draw_static(self):
|
|
c, s = self.canvas, self.s
|
|
c.delete('static')
|
|
dim = max(0, min(100, self.settings.get('background_dim', 40))) / 100.0
|
|
c.create_image(0, 0, anchor='nw', tags='static',
|
|
image=self.sprite('backdrop', lambda: art.backdrop(self.X(W), self.X(H), s, self.picture, dim,
|
|
self.X(PIC_TOP))))
|
|
self.make_sparks() # over the backdrop, under the panel and the band
|
|
bx0, bx1, bh = BAND
|
|
px0, ptop, px1 = PANEL
|
|
c.create_image(self.X(px0), self.X(ptop), anchor='nw', tags='static',
|
|
image=self.sprite('panel', lambda: art.panel(self.X(px1) - self.X(px0), self.X(H - ptop), s,
|
|
self.see_through())))
|
|
c.create_image(self.X(bx0), 0, anchor='nw', tags='static',
|
|
image=self.sprite('band', lambda: art.band(self.X(bx1) - self.X(bx0), self.X(bh), s)))
|
|
k = max(1, int(round(2 * s))) # Navi in whole pixels: two screen pixels per artwork pixel at 100 %
|
|
emblem = self.sprite(('navi', k), lambda: art.navi(k))
|
|
ew = emblem.width()
|
|
title_w = self.f['band'].measure(NAME)
|
|
gap = self.X(12)
|
|
x = self.X(W / 2) - (ew + gap + title_w) // 2
|
|
# Navi's halo, behind her: a soft light that pulses while the animations run
|
|
size = self.X(bh) - 2
|
|
if ('halo', size) not in self.sprites:
|
|
self.sprites[('halo', size)] = [art.photo(self.root, f) for f in art.halo_frames(size)]
|
|
self.halo = self.sprites[('halo', size)]
|
|
c.create_image(x + ew // 2, self.X(bh / 2), image=self.halo[0], tags=('static', 'halo'))
|
|
self.navi_xy = (x, self.X(bh / 2))
|
|
c.create_image(x, self.X(bh / 2), anchor='w', image=emblem, tags=('static', 'navi'))
|
|
tx = x + ew + gap
|
|
c.create_text(tx + 1, self.X(bh / 2) + self.X(2), text=NAME, anchor='w', font=self.f['band'],
|
|
fill=P['band_shadow'], tags='static')
|
|
c.create_text(tx, self.X(bh / 2), text=NAME, anchor='w', font=self.f['band'], fill=P['band_text'],
|
|
tags='static')
|
|
c.create_rectangle(0, 0, self.X(W) - 1, self.X(H) - 1, outline=P['frame'], tags='static')
|
|
c.tag_lower('static')
|
|
self.draw_title_buttons()
|
|
|
|
def make_sparks(self):
|
|
"""The dust over the backdrop: SPARKS soft dots of three sizes, each with its own speed, sway and twinkle, at
|
|
places of their own from the first frame on. None while Animations is off."""
|
|
self.sparks = []
|
|
if not self.animated():
|
|
return
|
|
rnd = random.Random(7)
|
|
for i in range(SPARKS):
|
|
size = self.X((5, 8, 12)[i % 3])
|
|
pics = [self.sprite(('spark', size, a), lambda sz=size, al=a: art.spark(sz, al)) for a in (0.9, 0.55, 0.25)]
|
|
# logical pixels: its speed upwards a second, how far it sways to each side and how fast
|
|
sp = {'x': rnd.uniform(0, W), 'y': rnd.uniform(BAND[2], H), 'vy': rnd.uniform(4, 14),
|
|
'sway': rnd.uniform(4, 16), 'rate': rnd.uniform(0.4, 1.1), 'phase': rnd.uniform(0, 6.28),
|
|
'pics': pics, 'shown': 0}
|
|
sp['id'] = self.canvas.create_image(self.X(sp['x']), self.X(sp['y']), image=pics[0], tags=('static', 'spark'))
|
|
self.sparks.append(sp)
|
|
|
|
def apply_look(self):
|
|
"""Draw everything again after the colour scheme, the background or Animations changed."""
|
|
art.set_theme(self.settings.get('theme'), self.custom_colors())
|
|
self.sprites.clear()
|
|
self.root.configure(background=P['window'])
|
|
self.canvas.configure(background=P['window'])
|
|
self.load_picture()
|
|
self.draw_static()
|
|
self.render()
|
|
|
|
def draw_title_buttons(self):
|
|
"""Minimise and close at the band's right end, lit when the mouse is over them."""
|
|
c, X = self.canvas, self.X
|
|
c.delete('tb')
|
|
bh = X(BAND[2])
|
|
for kind, x0, x1 in TITLE_BUTTONS:
|
|
if self.tb_hover == kind:
|
|
c.create_rectangle(X(x0), 0, X(x1), bh - 1, outline='', tags='tb',
|
|
fill=P['close_hover'] if kind == 'close' else P['button_hover'])
|
|
size = X(12)
|
|
glyph = self.sprite(('glyph', kind, size), lambda kd=kind: art.title_glyph(kd, size, self.s))
|
|
c.create_image((X(x0) + X(x1)) // 2, bh // 2, image=glyph, tags='tb')
|
|
|
|
def title_button_at(self, x, y):
|
|
if y >= self.X(BAND[2]):
|
|
return None
|
|
for kind, x0, x1 in TITLE_BUTTONS:
|
|
if self.X(x0) <= x < self.X(x1):
|
|
return kind
|
|
return None
|
|
|
|
# ---------------------------------------------------------------- input
|
|
|
|
def bind_input(self):
|
|
r = self.root
|
|
for key, fn in (('<Up>', lambda e: self.move(-1)), ('<Down>', lambda e: self.move(1)),
|
|
('<Left>', lambda e: self.side(-1)), ('<Right>', lambda e: self.side(1)),
|
|
('<Return>', lambda e: self.activate()), ('<KP_Enter>', lambda e: self.activate()),
|
|
('<space>', lambda e: self.activate()), ('<Escape>', lambda e: self.back()),
|
|
('<F5>', lambda e: self.check_now(force=True))):
|
|
r.bind(key, fn)
|
|
self.canvas.bind('<Motion>', self.on_motion)
|
|
self.canvas.bind('<Button-1>', self.on_click)
|
|
self.canvas.bind('<B1-Motion>', self.on_drag)
|
|
self.canvas.bind('<ButtonRelease-1>', self.on_release)
|
|
self.canvas.bind('<Leave>', lambda e: self.set_tb_hover(None))
|
|
r.bind('<Alt-F4>', lambda e: self.on_close())
|
|
r.protocol('WM_DELETE_WINDOW', self.on_close)
|
|
|
|
def poll_gamepad(self):
|
|
try:
|
|
for b in self.gamepad.poll(winsys.in_front(self.root)):
|
|
{'up': lambda: self.move(-1), 'down': lambda: self.move(1), 'left': lambda: self.side(-1),
|
|
'right': lambda: self.side(1), 'a': self.activate, 'b': self.back,
|
|
'y': lambda: self.check_now(force=True)}.get(b, lambda: None)()
|
|
except Exception as e:
|
|
self.ctx.log(f'gamepad: {e}')
|
|
self.gamepad.dll = None
|
|
self.root.after(50, self.poll_gamepad)
|
|
|
|
def focusables(self):
|
|
return [i for i, r in enumerate(self.rows) if r.focusable]
|
|
|
|
def move(self, d):
|
|
idx = self.focusables()
|
|
if not idx:
|
|
return
|
|
pos = idx.index(self.focus) if self.focus in idx else 0
|
|
self.focus = idx[max(0, min(len(idx) - 1, pos + d))]
|
|
self.render()
|
|
|
|
def focused(self):
|
|
return self.rows[self.focus] if 0 <= self.focus < len(self.rows) else None
|
|
|
|
def side(self, d):
|
|
r = self.focused()
|
|
if not r:
|
|
return
|
|
if r.kind == 'toggle':
|
|
r.on(d < 0) # left = On, right = Off
|
|
elif r.kind == 'enum':
|
|
(r.left if d < 0 else r.right)()
|
|
elif r.kind == 'pair':
|
|
r.pick = max(0, min(len(r.actions) - 1, getattr(r, 'pick', 0) + d))
|
|
else:
|
|
return
|
|
if r.kind != 'pair': # a switch or a choice changed
|
|
self.sounds.play('select')
|
|
self.render()
|
|
|
|
def activate(self):
|
|
r = self.focused()
|
|
if not r or not r.enabled:
|
|
return
|
|
if r.kind == 'toggle':
|
|
self.sounds.play('select')
|
|
r.on(not r.get())
|
|
elif r.kind == 'enum':
|
|
self.sounds.play('select')
|
|
r.right()
|
|
elif r.kind == 'pair':
|
|
self.sounds.play('select')
|
|
r.actions[getattr(r, 'pick', 0)][1]()
|
|
return
|
|
elif r.on:
|
|
self.sounds.play('back' if r.kind == 'action' and r.label == 'Back' else 'select')
|
|
r.on()
|
|
return
|
|
self.render()
|
|
|
|
def home_row(self, label):
|
|
"""The index of the home screen's row with this label, for coming back to it."""
|
|
return next((i for i, r in enumerate(self.rows_home()) if r.label == label), None)
|
|
|
|
def back(self):
|
|
if self.screen in ('settings', 'uninstall', 'saves', 'customize', 'job', 'color', 'mods', 'mod_order'):
|
|
self.sounds.play('back')
|
|
if self.screen == 'color':
|
|
self.pick_done()
|
|
elif self.screen == 'settings':
|
|
self.show('home', self.home_row('Settings'))
|
|
elif self.screen == 'uninstall':
|
|
self.show('settings')
|
|
elif self.screen == 'saves':
|
|
self.show('home', self.home_row('Saves'))
|
|
elif self.screen == 'mods':
|
|
self.show('home', self.home_row('Mods'))
|
|
elif self.screen == 'mod_order':
|
|
self.show('mods', self.mods_back_focus())
|
|
elif self.screen == 'customize':
|
|
self.show('home', self.home_row('Customize Launcher'))
|
|
elif self.screen == 'job' and self.job and self.job.result is None:
|
|
self.cancel_job()
|
|
elif self.screen == 'job':
|
|
self.leave_job()
|
|
|
|
def hit(self, x, y):
|
|
for i, r in enumerate(self.rows):
|
|
if r.rect and r.focusable:
|
|
x0, y0, x1, y1 = r.rect
|
|
if x0 <= x <= x1 and y0 - self.X(self.row_gap / 2) <= y <= y1 + self.X(self.row_gap / 2):
|
|
return i
|
|
return None
|
|
|
|
def set_tb_hover(self, kind):
|
|
if kind != self.tb_hover:
|
|
self.tb_hover = kind
|
|
self.draw_title_buttons()
|
|
|
|
def on_drag(self, e):
|
|
if self.pick_drag:
|
|
self.picker_at(e.x, e.y, self.pick_drag)
|
|
return
|
|
if self.drag: # moving the window without the system's help (Linux)
|
|
dx, dy = self.drag
|
|
self.root.geometry(f'+{e.x_root - dx}+{e.y_root - dy}')
|
|
|
|
def on_motion(self, e):
|
|
self.set_tb_hover(self.title_button_at(e.x, e.y))
|
|
i = self.hit(e.x, e.y)
|
|
if i is not None and i != self.focus:
|
|
self.focus = i
|
|
r = self.rows[i]
|
|
if r.kind == 'pair':
|
|
r.pick = 0 if e.x < (r.rect[0] + r.rect[2]) / 2 else 1
|
|
self.render()
|
|
elif i is not None and self.rows[i].kind == 'pair':
|
|
r = self.rows[i]
|
|
pick = 0 if e.x < (r.rect[0] + r.rect[2]) / 2 else 1
|
|
if pick != getattr(r, 'pick', 0):
|
|
r.pick = pick
|
|
self.render()
|
|
|
|
def on_click(self, e):
|
|
try:
|
|
self.root.focus_force()
|
|
except tk.TclError:
|
|
pass
|
|
kind = self.title_button_at(e.x, e.y)
|
|
if kind == 'close':
|
|
self.on_close()
|
|
return
|
|
if kind == 'min':
|
|
self.set_tb_hover(None)
|
|
winsys.minimize(self.root)
|
|
return
|
|
if e.y < self.X(BAND[2]): # the band moves the window, like a title bar
|
|
if not winsys.start_drag(self.root):
|
|
self.drag = (e.x_root - self.root.winfo_x(), e.y_root - self.root.winfo_y())
|
|
return
|
|
if self.screen == 'color':
|
|
part = self.picker_at(e.x, e.y)
|
|
if part:
|
|
self.pick_drag = part
|
|
return
|
|
i = self.hit(e.x, e.y)
|
|
if i is None:
|
|
return
|
|
self.focus = i
|
|
r = self.rows[i]
|
|
if not r.enabled:
|
|
self.render()
|
|
return
|
|
if r.kind == 'toggle' and r.value_rect:
|
|
x0, _, x1, _ = r.value_rect
|
|
self.sounds.play('select')
|
|
r.on(e.x < (x0 + x1) / 2)
|
|
self.render()
|
|
elif r.kind == 'enum' and r.value_rect:
|
|
x0, _, x1, _ = r.value_rect
|
|
self.sounds.play('select')
|
|
(r.left if e.x < x0 + (x1 - x0) / 3 else r.right)()
|
|
self.render()
|
|
elif r.kind == 'pair':
|
|
r.pick = 0 if e.x < (r.rect[0] + r.rect[2]) / 2 else 1
|
|
self.sounds.play('select')
|
|
r.actions[r.pick][1]()
|
|
else:
|
|
self.activate()
|
|
|
|
def on_release(self, e):
|
|
self.drag = None
|
|
if self.pick_drag:
|
|
self.pick_drag = None
|
|
self.pick_changed(final=True)
|
|
|
|
def on_close(self):
|
|
if self.job and self.job.result is None:
|
|
self.job.cancel()
|
|
if self.bg.staging(): # what it downloaded is resumed, and its unfinished folder replaced, next time
|
|
self.bg.job.cancel()
|
|
self.sounds.stop()
|
|
self.root.destroy()
|
|
|
|
# ---------------------------------------------------------------- screens
|
|
|
|
def start(self):
|
|
a = self.args
|
|
gd = self.settings.get('game_dir')
|
|
if a.rolled_back:
|
|
self.ctx.log(f'the stub went back from {a.rolled_back} to {self.layout.version}')
|
|
self.settings['rolled_back'] = versions.display(a.rolled_back) # never installed again by itself
|
|
self.settings.save()
|
|
if self.ctx.zstd_missing: # told at once, not when Begin Installation stops at the build tools
|
|
self.message = self.ctx.zstd_missing
|
|
left = None if a.selftest else jobs.interrupted(self.ctx) # before any job of this start leaves its own
|
|
if left:
|
|
self.ctx.log(f'the launcher was stopped during {left["task"]} (started '
|
|
f'{time.strftime("%Y-%m-%d %H:%M", time.localtime(left.get("started") or 0))}, game folder '
|
|
f'{left.get("game_dir") or "none"})')
|
|
for old in self.layout.root.glob('python.old*'): # the Python an update replaced (launcher/native/nx2navi.c)
|
|
shutil.rmtree(old, ignore_errors=True)
|
|
if gd or self.settings.get('dump'):
|
|
self.fonts_from_game(gd, self.settings.get('dump'))
|
|
if a.uninstall:
|
|
self.show('uninstall')
|
|
elif a.continue_update is not None and not a.rolled_back:
|
|
# after a rollback the stub passes the failed version's --continue-update on to this one: the game was
|
|
# never rebuilt for the new version, so there is nothing to finish, and home tells why the version is back
|
|
self.run_job(jobs.ContinueUpdateJob(self.ctx, gd if build.installed(gd) else '',
|
|
a.continue_update, self.settings.get('dump')), 'home')
|
|
elif build.installed(gd):
|
|
# also a game whose last update stopped in the compile (build.installed): home, where Play starts the
|
|
# build from before the update and Rebuild the Game finishes it, not the first-run screen and its dump
|
|
self.show('home')
|
|
self.after_update_notice()
|
|
if left and not self.message:
|
|
# make_game.py keeps the old exe until the new one is linked and records each finished step, so the
|
|
# game folder is playable and a rebuild goes on from there; the player is told that it is due
|
|
self.message = (f'NX2navi was closed or stopped during {left["task"]}, which did not finish. Play '
|
|
'starts the last finished build; Rebuild the Game finishes what was left.')
|
|
self.render()
|
|
if self.install_staged('a staged update waited for the launcher to start'):
|
|
return
|
|
if a.play: # Play from Steam with a mod's code to build in first (play_only)
|
|
self.play()
|
|
return
|
|
if self.settings.get('auto_check'):
|
|
self.check_now()
|
|
else:
|
|
if left and not self.message:
|
|
self.message = (f'NX2navi was closed or stopped during {left["task"]}, which did not finish. Begin '
|
|
'Installation goes on from the steps that were done.')
|
|
self.show('first_run')
|
|
self.find_dumps()
|
|
|
|
def after_update_notice(self):
|
|
if self.args.rolled_back:
|
|
self.message = (f'Version {self.args.rolled_back} did not start, so NX2navi went back to '
|
|
f'{versions.display(self.layout.version)}.')
|
|
|
|
def show(self, screen, focus=None):
|
|
if screen != self.screen and self.screen is not None and self.animated():
|
|
# another screen slides into place: from the right when it lies deeper, from the left on the way back
|
|
deeper = DEPTH.get(screen, 1) >= DEPTH.get(self.screen, 0)
|
|
self.slide = float(self.X(44) if deeper else -self.X(44))
|
|
self.close_hex()
|
|
self.screen = screen
|
|
self.rows = getattr(self, 'rows_' + screen)()
|
|
idx = self.focusables()
|
|
self.focus = focus if focus is not None and focus in idx else (idx[0] if idx else 0)
|
|
if screen == 'job' and focus is None:
|
|
self.focus = self.job_focus()
|
|
self.render()
|
|
|
|
# ---- first run
|
|
|
|
def find_dumps(self):
|
|
import threading
|
|
extra = [self.args.source] if self.args.source else []
|
|
extra.append(str(self.layout.root.parent))
|
|
|
|
def work():
|
|
try:
|
|
found = gamefiles.find_dumps(self.layout, extra)
|
|
except Exception as e:
|
|
self.ctx.log(f'dump search: {e}')
|
|
found = []
|
|
self.root.after(0, lambda: self.dumps_found(found))
|
|
self.dump_search = threading.Thread(target=work, daemon=True)
|
|
self.dump_search.start()
|
|
|
|
def dumps_found(self, found):
|
|
self.dumps = found
|
|
if found and not self.settings.get('dump'):
|
|
self.settings['dump'] = str(found[0])
|
|
self.settings.save()
|
|
self.fonts_from_game('', str(found[0]))
|
|
if self.screen == 'first_run':
|
|
self.show('first_run', self.focus)
|
|
|
|
def game_dir(self):
|
|
return self.settings.get('game_dir') or str(self.layout.default_game_dir())
|
|
|
|
def rows_first_run(self):
|
|
dump = self.settings.get('dump')
|
|
if dump:
|
|
dump_value, dump_style, check = Path(dump).name, 'normal', True
|
|
where = str(Path(dump).parent)
|
|
dump_desc = (f'Your dump: {Path(dump).name}, in {where}. Select this row to choose another .nup file.',)
|
|
elif self.dumps is None:
|
|
dump_value, dump_style, check = 'Looking for your dump…', 'dim', False
|
|
dump_desc = ('Looking for your dump of Ocarina of Time in Downloads, Desktop, Documents and the folder '
|
|
'the setup ran from.',)
|
|
else:
|
|
dump_value, dump_style, check = 'Choose your .nup file', 'salmon', False
|
|
dump_desc = ('Choose your own dump of Ocarina of Time with the 1.1.1 update, as one .nup file. None was '
|
|
'found in Downloads, Desktop or Documents.',)
|
|
gd = self.game_dir()
|
|
need = 34
|
|
free = free_gb(gd) + partial_image_gb(gd) # Begin Installation removes the partial image (begin_install)
|
|
folder_desc = (f'The game is built into this folder. {free:.0f} GB free on {drive_of(gd)}; the game needs '
|
|
f'about {need} GB. Select this row to choose another folder.',)
|
|
ready = bool(dump)
|
|
begin_desc = (f'Build Ocarina of Time on this PC from your own dump. It takes 20 to 45 minutes, once, and '
|
|
f'downloads 170 MB of build tools. The game needs about {need} GB on {drive_of(gd)} '
|
|
f'({free:.0f} GB free).',) if ready else \
|
|
('Choose your dump first: select Game Dump above.',)
|
|
return [
|
|
Row('value', 'Game Dump', dump_value, dump_style, big=True, check=check, on=self.choose_dump,
|
|
desc=dump_desc),
|
|
Row('value', 'Install Folder', gd, big=True, on=self.choose_folder, desc=folder_desc),
|
|
Row('divider'),
|
|
self.toggle('Desktop Shortcut', 'desktop_shortcut',
|
|
'Put an NX2navi shortcut on the desktop. The Start menu always has one.'),
|
|
self.toggle('Check for Updates at Start', 'auto_check',
|
|
'Look for a new version of NX2navi when the launcher opens, and every hour while it stays '
|
|
'open. A new version downloads in the background and installs when you are not playing; '
|
|
'Settings can make it wait until you choose Updates.'),
|
|
self.channel_row(),
|
|
Row('divider'),
|
|
Row('action', 'Begin Installation', on=self.begin_install, desc=begin_desc, enabled=True),
|
|
]
|
|
|
|
def toggle(self, label, key, desc):
|
|
def on(v):
|
|
self.settings[key] = bool(v)
|
|
self.settings.save()
|
|
if key == 'desktop_shortcut':
|
|
winsys.desktop_shortcut(self.layout, bool(v))
|
|
return Row('toggle', label, on=on, get=lambda: bool(self.settings.get(key)), desc=(desc,))
|
|
|
|
def channel_row(self):
|
|
def flip():
|
|
self.settings['channel'] = 'preview' if self.settings.get('channel') == 'stable' else 'stable'
|
|
self.settings.save()
|
|
self.release, self.checked_at = None, 0
|
|
return Row('enum', 'Update Channel', options=('Stable', 'Preview'),
|
|
get=lambda: 'Preview' if self.settings.get('channel') == 'preview' else 'Stable',
|
|
left=flip, right=flip,
|
|
desc=('Stable gets each finished release. Preview also gets test builds before they are '
|
|
'released, which may have new problems.',))
|
|
|
|
def discord_row(self, game_dir):
|
|
"""The game's discord_rpc setting (runtime/discord_rpc.c), kept in the game's nx2navi.ini as the game's own
|
|
menu keeps it; the game reads it when it starts. On unless the file says 0, as the game reads it (cfg_bool,
|
|
default on)."""
|
|
def on(v):
|
|
# Not while the game runs, as the mods' switches (switch_mod): the game writes the same file when a menu
|
|
# setting changes. Each side reads the file, changes its own lines and puts a new file in place, so of
|
|
# two changes in the same moment the second drops the first. The game reads this setting only when it
|
|
# starts, and its own menu switches it meanwhile.
|
|
if self.game_busy(game_dir):
|
|
self.message = ('Quit the game first, or switch Discord Status in the game\'s menu (Game tab): the '
|
|
'game writes its settings file while it runs.')
|
|
return
|
|
try:
|
|
gameini.put(game_dir, 'discord', 'discord_rpc', '1' if v else '0')
|
|
self.message = ''
|
|
except OSError as e:
|
|
self.message = f'The game\'s settings could not be written: {e}'
|
|
|
|
def get():
|
|
try:
|
|
return gameini.get(game_dir, 'discord_rpc') != '0'
|
|
except OSError: # nx2navi.ini there but unreadable: the game's default
|
|
return True
|
|
return Row('toggle', 'Discord Status', on=on, get=get,
|
|
desc=('Show "Playing NX2navi" with your play time in your Discord status while the game runs. '
|
|
'The game\'s menu switches it too (Game tab).',))
|
|
|
|
def choose_dump(self):
|
|
start = str(Path(self.settings.get('dump')).parent) if self.settings.get('dump') else str(Path.home())
|
|
path = filedialog.askopenfilename(parent=self.root, title='Choose your Ocarina of Time dump',
|
|
initialdir=start, filetypes=[('Game dump', '*.nup'), ('All files', '*.*')])
|
|
if not path:
|
|
return
|
|
if not gamefiles.is_game_nup(self.layout, path):
|
|
self.message = f'{Path(path).name} is not a dump of Ocarina of Time.'
|
|
self.render()
|
|
return
|
|
self.settings['dump'] = path
|
|
self.settings.save()
|
|
self.message = ''
|
|
self.fonts_from_game('', path)
|
|
self.show(self.screen, self.focus)
|
|
|
|
def choose_folder(self):
|
|
path = filedialog.askdirectory(parent=self.root, title='Choose the folder for the game',
|
|
initialdir=str(Path(self.game_dir()).parent))
|
|
if not path:
|
|
return
|
|
p = Path(path)
|
|
if p.exists() and any(p.iterdir()) and not (p / 'build-cache').is_dir() and p.name.lower() != 'nx2navi':
|
|
p = p / 'NX2navi'
|
|
self.settings['game_dir'] = str(p)
|
|
self.settings.save()
|
|
self.show(self.screen, self.focus)
|
|
|
|
def begin_install(self):
|
|
dump = self.settings.get('dump')
|
|
if not dump:
|
|
self.focus = 0
|
|
self.choose_dump()
|
|
return
|
|
gd = self.game_dir()
|
|
too_long = long_path_problem(gd) # told here, not as a missing file halfway through the build
|
|
if too_long:
|
|
self.message = App.friendly(too_long)
|
|
self.render()
|
|
return
|
|
drop_partial_image(gd) # before the free space is measured
|
|
if free_gb(gd) < 34 and not (Path(gd) / 'romfs' / 'romfs.bin').is_file():
|
|
self.message = f'{drive_of(gd)} has {free_gb(gd):.0f} GB free; the game needs about 34 GB.'
|
|
self.render()
|
|
return
|
|
self.settings['game_dir'] = gd
|
|
self.settings.save()
|
|
if self.settings.get('desktop_shortcut'):
|
|
winsys.desktop_shortcut(self.layout, True)
|
|
self.run_job(jobs.InstallJob(self.ctx, dump, gd), 'home', back='first_run')
|
|
|
|
# ---- home
|
|
|
|
def rows_home(self):
|
|
gd = self.settings.get('game_dir')
|
|
built = build.built_version(gd)
|
|
mine = versions.display(self.layout.version)
|
|
play_value = 'Ocarina of Time'
|
|
staged = self.bg.ready(self.release)
|
|
if staged:
|
|
upd = Row('value', 'Updates', f'Ready · {staged["version"]}', 'orange_arrow', big=True, icon='refresh',
|
|
badge=True, on=self.start_update, focus_value=True, desc=lambda: self.staged_desc(staged))
|
|
elif self.bg.staging():
|
|
upd = Row('value', 'Updates', f'Downloading {self.bg.job.ver}', 'orange_arrow', big=True,
|
|
icon='refresh', on=self.start_update, focus_value=True, desc=self.staging_desc)
|
|
elif self.release and jobs.update_available(self.ctx, self.release):
|
|
nv = versions.display(self.release.version)
|
|
upd = Row('value', 'Updates', f'Version {nv}', 'orange_arrow', big=True, icon='refresh', badge=True,
|
|
on=self.start_update, focus_value=True, desc=self.update_desc)
|
|
elif self.check and self.check.result is None:
|
|
upd = Row('value', 'Updates', 'Checking…', 'dim', big=True, icon='refresh', on=lambda: None,
|
|
desc=('Looking for a new version of NX2navi.',))
|
|
elif self.check_error:
|
|
upd = Row('value', 'Updates', 'Could not check', 'dim', big=True, icon='refresh',
|
|
on=lambda: self.check_now(force=True),
|
|
desc=(f'The update check failed: {self.check_error}', 'Select this row to try again.'))
|
|
elif self.checked_at:
|
|
upd = Row('value', 'Updates', f'Up to Date · {mine}', 'normal', big=True, icon='refresh', check=True,
|
|
on=lambda: self.check_now(force=True),
|
|
desc=(f'{mine} is the newest version. Checked {self.ago(self.checked_at)}.',
|
|
'Select this row to check again.'))
|
|
else:
|
|
upd = Row('value', 'Updates', f'Version {mine}', 'normal', big=True, icon='refresh',
|
|
on=lambda: self.check_now(force=True),
|
|
desc=(f'You have NX2navi {mine}. Select this row to look for a new version.',))
|
|
# an update that stopped in the compile left no finished build on record, but the exe of the build before it
|
|
unfinished = not built and build.installed(gd)
|
|
rebuild_value = (f'Built with {versions.display(built)}' if built else
|
|
'Not Finished' if unfinished else 'Not built')
|
|
rebuild_style = 'normal' if versions.display(built) == mine else 'salmon'
|
|
play_desc = ('Start Ocarina of Time. The launcher steps aside while you play and comes back when you quit.',)
|
|
if built and versions.display(built) != mine:
|
|
play_desc += (f'The game was built with {versions.display(built)}. Rebuild the Game brings it to {mine}.',)
|
|
elif unfinished:
|
|
play_desc += (f'The last rebuild of the game did not finish, so Play starts the build from before it. '
|
|
f'Rebuild the Game finishes it and brings the game to {mine}; your dump is not needed.',)
|
|
if staged and self.settings.get('auto_install'):
|
|
play_desc += (f'NX2navi {staged["version"]} is downloaded and installs after you quit the game.',)
|
|
if self.message:
|
|
play_desc = (self.message,) + play_desc
|
|
return [
|
|
Row('value', 'Play', play_value, big=True, icon='play', on=self.play, desc=play_desc),
|
|
upd,
|
|
Row('divider'),
|
|
Row('value', 'Game Folder', gd, on=lambda: self.open(gd),
|
|
desc=('Open the game folder. The game keeps your save in its profile folder, its settings in '
|
|
'nx2navi.ini and its logs in logs. Its saves folder holds saves to import and backups.',)),
|
|
self.saves_row(gd),
|
|
self.mods_row(gd),
|
|
Row('value', 'Rebuild the Game', rebuild_value, rebuild_style, on=self.start_rebuild,
|
|
desc=('Build the game again from the files of the last build. Only what changed is compiled. Use it '
|
|
'after changing build tools, or if the game stopped starting.',)),
|
|
Row('value', 'Customize Launcher', art.THEMES[art.theme]['label'], 'arrow',
|
|
on=lambda: self.show('customize'),
|
|
desc=('Colour scheme, background picture, animations and sounds.',)),
|
|
Row('value', 'Settings', '', 'arrow', on=lambda: self.show('settings'),
|
|
desc=('Updates, the game folder, the logs and uninstalling.',)),
|
|
]
|
|
|
|
# ---- customize
|
|
|
|
def rows_customize(self):
|
|
names = list(art.THEMES)
|
|
custom = bool(self.settings.get('background'))
|
|
|
|
def scheme(d):
|
|
self.settings['theme'] = names[(names.index(art.theme) + d) % len(names)]
|
|
self.settings.save()
|
|
# the rows again: Background Color and Button Color are there only while the scheme is Custom; the
|
|
# cursor stays on Color Scheme, the first row
|
|
art.set_theme(self.settings['theme'], self.custom_colors())
|
|
self.rows = self.rows_customize()
|
|
self.apply_look()
|
|
|
|
def dim(d):
|
|
now = self.settings.get('background_dim', 40)
|
|
at = min(range(len(DIMS)), key=lambda i: abs(DIMS[i] - now))
|
|
self.settings['background_dim'] = DIMS[max(0, min(len(DIMS) - 1, at + d))]
|
|
self.settings.save()
|
|
self.apply_look()
|
|
|
|
def animations(v):
|
|
self.settings['animations'] = bool(v)
|
|
self.settings.save()
|
|
self.cur_xy = None
|
|
self.slide = 0.0
|
|
self.apply_look()
|
|
|
|
def sounds(d):
|
|
keys = [k for k, _ in SOUND_LEVELS]
|
|
now = self.settings.get('sounds')
|
|
at = keys.index(now if now in keys else sound.DEFAULT_LEVEL)
|
|
self.settings['sounds'] = keys[max(0, min(len(keys) - 1, at + d))]
|
|
self.settings.save()
|
|
self.sounds.set_level(self.settings['sounds'])
|
|
self.sounds.play('select') # the new loudness, heard at once
|
|
|
|
bg_desc = ('Use a picture of your own as the background.',)
|
|
if self.look_message:
|
|
bg_desc = (self.look_message,) + bg_desc
|
|
rows = [
|
|
Row('enum', 'Color Scheme', get=lambda: art.THEMES[art.theme]['label'],
|
|
left=lambda: scheme(-1), right=lambda: scheme(1),
|
|
desc=('The launcher\'s colours. Custom lets you pick your own.',)),
|
|
]
|
|
if art.theme == 'custom':
|
|
rows += [Row('value', CUSTOM_LABELS[slot], art.CUSTOM[slot], 'swatch',
|
|
on=lambda sl=slot: self.open_picker(sl), desc=(CUSTOM_DESC[slot],))
|
|
for slot in art.CUSTOM_SLOTS]
|
|
rows += [
|
|
Row('value', 'Background', Path(self.settings['background']).name if custom else 'Choose a Picture',
|
|
'arrow', on=self.choose_background, desc=bg_desc),
|
|
]
|
|
if custom:
|
|
rows += [
|
|
Row('enum', 'Background Dimming', get=lambda: f'{self.settings.get("background_dim", 40)} %',
|
|
left=lambda: dim(-1), right=lambda: dim(1),
|
|
desc=('Darkens your picture so the text stays easy to read.',)),
|
|
Row('value', 'Remove Background', '', 'arrow', on=self.clear_background,
|
|
desc=('Go back to the colour scheme\'s own background.',)),
|
|
]
|
|
rows += [
|
|
Row('divider'),
|
|
Row('toggle', 'Animations', on=animations, get=lambda: bool(self.settings.get('animations')),
|
|
desc=('A gliding cursor, sliding screens and drifting sparks.',)),
|
|
Row('enum', 'Sounds', get=lambda: dict(SOUND_LEVELS).get(self.settings.get('sounds'),
|
|
dict(SOUND_LEVELS)[sound.DEFAULT_LEVEL]),
|
|
left=lambda: sounds(-1), right=lambda: sounds(1),
|
|
desc=('The game\'s select and back sounds. Low by default.',)),
|
|
Row('divider'),
|
|
]
|
|
if os.name == 'nt':
|
|
rows += [self.steam_row(), Row('divider')]
|
|
rows += [
|
|
Row('action', 'Back', on=lambda: self.show('home', self.home_row('Customize Launcher')), height=40,
|
|
desc=('Back to the main menu.',)),
|
|
]
|
|
return rows
|
|
|
|
# ---- the Custom scheme's colour picker
|
|
|
|
def custom_colors(self):
|
|
return {slot: self.settings.get('custom_' + slot, '') for slot in art.CUSTOM_SLOTS}
|
|
|
|
def open_picker(self, slot):
|
|
h = art.CUSTOM.get(slot) or art.CUSTOM_DEFAULT[slot]
|
|
hh, ss, vv = colorsys.rgb_to_hsv(*(int(h[i:i + 2], 16) / 255.0 for i in (1, 3, 5)))
|
|
self.pick = {'slot': slot, 'h': hh, 's': ss, 'v': vv}
|
|
self.show('color', 0)
|
|
|
|
def pick_hex(self):
|
|
p = self.pick
|
|
return art.hexcode(*colorsys.hsv_to_rgb(p['h'], p['s'], p['v']))
|
|
|
|
def pick_step(self, key, d):
|
|
"""Hue by 5 degrees along the hue bar, saturation and brightness by 5 % (the arrows and the gamepad)."""
|
|
p = self.pick
|
|
if key == 'h':
|
|
p['h'] = (round(p['h'] * 72) + d) % 72 / 72.0
|
|
else:
|
|
p[key] = max(0.0, min(1.0, (round(p[key] * 20) + d) / 20.0))
|
|
self.pick_changed(final=True)
|
|
|
|
def pick_changed(self, final):
|
|
"""The picked colour into the settings and onto the screen: the picker at once, the launcher's colours a
|
|
moment later (look_soon), so dragging over the square stays smooth. final: saved to launcher.json."""
|
|
if not self.pick:
|
|
return
|
|
self.settings['custom_' + self.pick['slot']] = self.pick_hex()
|
|
if final:
|
|
self.settings.save()
|
|
if self.screen == 'color':
|
|
self.rows = self.rows_color()
|
|
self.render()
|
|
self.look_soon(0 if final else 120)
|
|
|
|
def look_soon(self, ms):
|
|
if self.look_after:
|
|
self.root.after_cancel(self.look_after)
|
|
self.look_after = self.root.after(ms, self.look_now)
|
|
|
|
def look_now(self):
|
|
self.look_after = None
|
|
self.apply_look()
|
|
|
|
def pick_reset(self):
|
|
slot = self.pick['slot']
|
|
self.settings['custom_' + slot] = ''
|
|
self.settings.save()
|
|
art.CUSTOM[slot] = art.CUSTOM_DEFAULT[slot]
|
|
self.open_picker(slot)
|
|
self.focus = next(i for i, r in enumerate(self.rows) if r.label == 'Reset')
|
|
self.apply_look()
|
|
|
|
def pick_done(self):
|
|
slot = self.pick['slot'] if self.pick else 'background'
|
|
if self.look_after:
|
|
self.root.after_cancel(self.look_after)
|
|
self.look_after = None
|
|
self.apply_look()
|
|
rows = self.rows_customize()
|
|
self.show('customize', next((i for i, r in enumerate(rows) if r.label == CUSTOM_LABELS[slot]), 0))
|
|
|
|
def rows_color(self):
|
|
p = self.pick
|
|
return [
|
|
Row('enum', 'Hue', get=lambda: f'{round(p["h"] * 360) % 360}°',
|
|
left=lambda: self.pick_step('h', -1), right=lambda: self.pick_step('h', 1)),
|
|
Row('enum', 'Saturation', get=lambda: f'{round(p["s"] * 100)} %',
|
|
left=lambda: self.pick_step('s', -1), right=lambda: self.pick_step('s', 1)),
|
|
Row('enum', 'Brightness', get=lambda: f'{round(p["v"] * 100)} %',
|
|
left=lambda: self.pick_step('v', -1), right=lambda: self.pick_step('v', 1)),
|
|
Row('value', 'Hex Code', self.pick_hex().upper(), 'arrow', on=self.type_hex),
|
|
Row('divider'),
|
|
Row('value', 'Reset', '', 'arrow', on=self.pick_reset),
|
|
Row('divider'),
|
|
Row('action', 'Done', on=self.pick_done, height=40),
|
|
]
|
|
|
|
def picker_at(self, x, y, part=None):
|
|
"""The colour under the mouse at (x, y) on the square or the bar into the picker; which of the two it was
|
|
(part: the one being dragged, which takes the mouse also outside it), or None."""
|
|
X, p = self.X, self.pick
|
|
|
|
def inside(box, m=0):
|
|
x0, y0, x1, y1 = box
|
|
return X(x0) - X(m) <= x <= X(x1) + X(m) and X(y0) - X(m) <= y <= X(y1) + X(m)
|
|
|
|
def frac(v, a, b):
|
|
return max(0.0, min(1.0, (v - X(a)) / max(1, X(b) - X(a))))
|
|
sx0, sy0, sx1, sy1 = SQUARE
|
|
bx0, _, bx1, _ = BAR
|
|
if part == 'square' or (part is None and inside(SQUARE, 4)):
|
|
p['s'] = frac(x, sx0, sx1)
|
|
p['v'] = 1 - frac(y, sy0, sy1)
|
|
part = 'square'
|
|
elif part == 'bar' or (part is None and inside(BAR, 8)):
|
|
p['h'] = min(frac(x, bx0, bx1), 0.9999)
|
|
part = 'bar'
|
|
else:
|
|
return None
|
|
self.pick_changed(final=False)
|
|
return part
|
|
|
|
def type_hex(self):
|
|
"""A box over the Hex Code row to type a colour into, like #3A7BD5; Enter takes it, Esc leaves it."""
|
|
r = self.focused()
|
|
if self.hex_entry or not r or not r.value_rect:
|
|
return
|
|
x0, y0, x1, y1 = r.value_rect
|
|
X = self.X
|
|
e = tk.Entry(self.canvas, font=self.f['label'], bg=P['value'], fg=P['value_text'],
|
|
insertbackground=P['value_text'], selectbackground=P['orange'], selectforeground=P['on_text'],
|
|
relief='flat', justify='center', highlightthickness=1, highlightcolor=P['orange'],
|
|
highlightbackground=P['value_border'])
|
|
e.insert(0, self.pick_hex().upper())
|
|
e.select_range(0, 'end')
|
|
self.hex_entry = e
|
|
self.canvas.create_window(x0 + X(3), y0 + X(3), anchor='nw', window=e, width=x1 - x0 - X(6),
|
|
height=y1 - y0 - X(6), tags='hexentry')
|
|
e.focus_set()
|
|
|
|
def take(ev):
|
|
self.hex_typed(e.get())
|
|
return 'break'
|
|
|
|
def leave(ev):
|
|
self.close_hex()
|
|
return 'break'
|
|
for key in ('<Return>', '<KP_Enter>'):
|
|
e.bind(key, take)
|
|
e.bind('<Escape>', leave)
|
|
for key in ('<Up>', '<Down>', '<space>'):
|
|
e.bind(key, lambda ev: 'break')
|
|
e.bind('<FocusOut>', lambda ev: self.close_hex())
|
|
|
|
@staticmethod
|
|
def parse_hex(text):
|
|
"""(r, g, b) in 0..255 of a typed hex code (#3A7BD5, 3A7BD5 or #37D), or None."""
|
|
t = text.strip().lstrip('#').lower()
|
|
if len(t) == 3:
|
|
t = ''.join(ch * 2 for ch in t)
|
|
return tuple(int(t[i:i + 2], 16) for i in (0, 2, 4)) if art.HEX.match('#' + t) else None
|
|
|
|
def hex_typed(self, text):
|
|
got = self.parse_hex(text)
|
|
if got is None:
|
|
self.sounds.play('back')
|
|
self.hex_entry.select_range(0, 'end')
|
|
return
|
|
self.sounds.play('select')
|
|
hh, ss, vv = colorsys.rgb_to_hsv(*(v / 255.0 for v in got))
|
|
self.pick.update(h=hh, s=ss, v=vv)
|
|
self.close_hex()
|
|
self.pick_changed(final=True)
|
|
|
|
def close_hex(self):
|
|
if self.hex_entry:
|
|
e, self.hex_entry = self.hex_entry, None
|
|
self.canvas.delete('hexentry')
|
|
e.destroy()
|
|
try:
|
|
self.root.focus_force()
|
|
except tk.TclError:
|
|
pass
|
|
|
|
def draw_picker(self):
|
|
"""The colour picker in the description box's place: the slot's name, the colour square of the picked hue
|
|
with a ring on the picked colour, the hue bar, and the colour itself with its hex code."""
|
|
c, X, s, p = self.canvas, self.X, self.s, self.pick
|
|
bx, by, bw, bh = PICKER_BOX
|
|
key = ('desc', X(bw), X(bh) // 4)
|
|
if key not in self.sprites:
|
|
img, pad = art.desc_box(X(bw), X(bh), s, self.see_through())
|
|
self.sprites[key] = (art.photo(self.root, img), pad)
|
|
pic, pad = self.sprites[key]
|
|
c.create_image(X(bx) - pad, X(by) - pad, anchor='nw', image=pic, tags='dyn')
|
|
self.text(X(bx + 26), X(by + 34), CUSTOM_LABELS[p['slot']], self.f['heading'], P['text_bright'])
|
|
sx0, sy0, sx1, sy1 = SQUARE
|
|
sw, sh = X(sx1) - X(sx0), X(sy1) - X(sy0)
|
|
hue = round(p['h'], 3)
|
|
square = self.sprite(('square', sw, sh, hue), lambda: art.color_square(sw, sh, hue))
|
|
c.create_rectangle(X(sx0) - 1, X(sy0) - 1, X(sx0) + sw, X(sy0) + sh, outline=P['value_border'], tags='dyn')
|
|
c.create_image(X(sx0), X(sy0), anchor='nw', image=square, tags='dyn')
|
|
size = X(18)
|
|
mark = self.sprite(('mark', size), lambda: art.picker_mark(size, s))
|
|
c.create_image(X(sx0) + round(p['s'] * sw), X(sy0) + round((1 - p['v']) * sh), image=mark, tags='dyn')
|
|
bx0, by0, bx1, by1 = BAR
|
|
bw_, bh_ = X(bx1) - X(bx0), X(by1) - X(by0)
|
|
bar = self.sprite(('hues', bw_, bh_), lambda: art.hue_bar(bw_, bh_))
|
|
c.create_rectangle(X(bx0) - 1, X(by0) - 1, X(bx0) + bw_, X(by0) + bh_, outline=P['value_border'], tags='dyn')
|
|
c.create_image(X(bx0), X(by0), anchor='nw', image=bar, tags='dyn')
|
|
c.create_image(X(bx0) + round(p['h'] * bw_), X(by0) + bh_ // 2, image=mark, tags='dyn')
|
|
hexc = self.pick_hex()
|
|
c.create_rectangle(X(bx0), X(452), X(bx0 + 64), X(500), fill=hexc, outline=P['value_border'], tags='dyn')
|
|
self.text(X(bx0 + 80), X(476), hexc.upper(), self.f['label'], P['text_bright'])
|
|
self.text(X(bx + 26), X(526), 'Click or drag the square and the bar.', self.f['small'], P['muted'])
|
|
|
|
def steam_row(self):
|
|
"""Add to Steam: the game in the Steam library, started from there without the launcher (launcher/steam.py)."""
|
|
found = steam.find_steam()
|
|
desc = ('Adds the game to your Steam library with its own icon and artwork, so you can start it from Steam.',
|
|
'Steam restarts while it is added.')
|
|
if self.steam_message:
|
|
desc = (self.steam_message,) + desc
|
|
if self.steam_busy:
|
|
value, style = 'Adding…', 'dim'
|
|
elif not found:
|
|
value, style = 'Steam not found', 'dim'
|
|
else:
|
|
value, style = ('Added' if steam.added(found[0]) else ''), 'arrow'
|
|
return Row('value', 'Add to Steam', value, style, on=self.add_to_steam, desc=desc)
|
|
|
|
def add_to_steam(self):
|
|
import threading
|
|
if self.steam_busy:
|
|
return
|
|
gd = self.settings.get('game_dir')
|
|
if not steam.find_steam():
|
|
self.steam_message = 'Steam is not installed on this PC.'
|
|
self.sounds.play('back')
|
|
self.show('customize', self.focus)
|
|
return
|
|
if not gd or not (Path(gd) / 'ocarina.exe').is_file():
|
|
self.steam_message = 'The game is not built yet.'
|
|
self.sounds.play('back')
|
|
self.show('customize', self.focus)
|
|
return
|
|
self.steam_busy = True
|
|
self.steam_message = 'Adding to Steam…'
|
|
self.sounds.play('select')
|
|
self.show('customize', self.focus)
|
|
dump = self.settings.get('dump')
|
|
|
|
def work():
|
|
try:
|
|
text, ok = steam.add(self.layout, gd, dump, self.ctx.log), True
|
|
except steam.SteamError as e:
|
|
text, ok = str(e), False
|
|
except Exception as e: # a Steam folder in a state nobody expected: say so instead of dying
|
|
import traceback
|
|
self.ctx.log('steam: ' + traceback.format_exc())
|
|
text, ok = f'Adding to Steam failed: {e}', False
|
|
self.ctx.log(f'steam: {text}')
|
|
self.root.after(0, lambda: self.steam_done(text, ok))
|
|
threading.Thread(target=work, daemon=True).start()
|
|
|
|
def steam_done(self, text, ok):
|
|
self.steam_busy = False
|
|
self.steam_message = text
|
|
if not ok:
|
|
self.sounds.play('back')
|
|
if self.screen == 'customize':
|
|
self.show('customize', self.focus)
|
|
|
|
def choose_background(self):
|
|
start = self.settings.get('background')
|
|
start = str(Path(start).parent) if start and Path(start).parent.is_dir() else \
|
|
str(winsys.known_folder('pictures') or Path.home())
|
|
pattern = ' '.join('*' + t for t in picture.TYPES)
|
|
path = filedialog.askopenfilename(parent=self.root, title='Choose a background picture', initialdir=start,
|
|
filetypes=[('Pictures', pattern), ('All files', '*.*')])
|
|
if not path:
|
|
return
|
|
try:
|
|
picture.keep(path, self.bg_store, self.root)
|
|
except (picture.PictureError, OSError, MemoryError, ValueError) as e:
|
|
self.ctx.log(f'background: {path}: {e!r}')
|
|
self.look_message = str(e) if isinstance(e, picture.PictureError) else \
|
|
f'{Path(path).name} could not be used as the background: {e}'
|
|
self.sounds.play('back')
|
|
self.show('customize', self.focus)
|
|
return
|
|
self.look_message = ''
|
|
self.settings['background'] = str(Path(path))
|
|
self.settings.save()
|
|
self.apply_look()
|
|
self.show('customize', self.focus)
|
|
|
|
def clear_background(self):
|
|
self.settings['background'] = ''
|
|
self.settings.save()
|
|
try:
|
|
self.bg_store.unlink(missing_ok=True)
|
|
except OSError as e:
|
|
self.ctx.log(f'background: {e}')
|
|
self.look_message = ''
|
|
self.apply_look()
|
|
self.show('customize', 1)
|
|
|
|
def save_waiting(self, gd):
|
|
"""A save put into the game folder's saves folder (a shared zip, a save folder), waiting to be imported."""
|
|
if gd and Path(gd).is_dir():
|
|
try:
|
|
saves.ensure_saves_dir(gd)
|
|
return saves.waiting(gd)[0] is not None
|
|
except OSError:
|
|
pass
|
|
return False
|
|
|
|
def saves_row(self, gd):
|
|
"""Saves: the screen that exports and imports saves; orange while a save waits in the saves folder."""
|
|
ready = self.save_waiting(gd)
|
|
desc = ('Export your save into one zip, to attach to a bug report or give to another player, or import a '
|
|
'save someone shared, a Switch save backup or an emulator\'s save.',)
|
|
if ready:
|
|
desc += ('A save waits in the saves folder of the game folder: select this row, then Import a Save.',)
|
|
return Row('value', 'Saves', 'Import Waiting' if ready else '', 'orange_arrow' if ready else 'arrow',
|
|
on=lambda: self.show('saves'), desc=desc)
|
|
|
|
# ---- saves
|
|
|
|
def export_dir(self):
|
|
d = self.settings.get('save_export_dir')
|
|
return Path(d) if d and Path(d).is_dir() else saves.default_export_dir()
|
|
|
|
def rows_saves(self):
|
|
gd = self.settings.get('game_dir')
|
|
ready = self.save_waiting(gd)
|
|
out = self.export_dir()
|
|
export_desc = ('Write your save into one zip file, to attach to a bug report or give to another player: every '
|
|
'save slot and a note of the NX2navi and game versions, never your keys or game files. The '
|
|
'folder opens with the zip selected.',)
|
|
if self.export_message:
|
|
export_desc = (self.export_message,) + export_desc
|
|
import_desc = ('Make a save someone shared (an NX2navi-save zip) your save: every slot is replaced. Put the zip '
|
|
'into the saves folder of the game folder and select this row, or select this row and choose '
|
|
'the zip.', 'Your current save is copied to the saves folder first.')
|
|
if gd and saves.pending_import(gd):
|
|
import_desc += ('A save imported in the game\'s menu waits for the game\'s next start; an import here '
|
|
'replaces it.',)
|
|
folder_desc = ('Bring in a save folder: a Switch save backup, an emulator\'s save, another NX2navi install, or '
|
|
'one save slot (a folder named slot_00 to slot_05: only that slot is replaced).',
|
|
'Your current save is copied to the saves folder first.')
|
|
if self.save_message:
|
|
import_desc, folder_desc = (self.save_message,) + import_desc, (self.save_message,) + folder_desc
|
|
|
|
def report(v):
|
|
self.settings['export_with_log'] = bool(v)
|
|
self.settings.save()
|
|
return [
|
|
Row('value', 'Export Save', out.name or str(out), on=self.export_save, desc=export_desc),
|
|
Row('toggle', 'Add Log and Settings', on=report, get=lambda: bool(self.settings.get('export_with_log')),
|
|
desc=('On: the zip also holds the game\'s latest log (logs\\nx2navi.log) and its settings '
|
|
'(nx2navi.ini), which show what happened and with which settings. Keep it on for a bug report: '
|
|
'while the game has no save yet, Export Save then writes these two alone.',
|
|
'Off: the save alone, to give to another player.')),
|
|
Row('value', 'Export To', str(out), on=self.choose_export_dir,
|
|
desc=(f'Export Save writes into {out}.', 'Select this row to choose another folder.')),
|
|
Row('divider'),
|
|
Row('value', 'Import a Save', 'From the Saves Folder' if ready else 'Choose a Zip',
|
|
'orange_arrow' if ready else 'arrow', on=lambda: self.import_saves(folder=False), desc=import_desc),
|
|
Row('value', 'Import a Save Folder', 'Choose a Folder', 'arrow',
|
|
on=lambda: self.import_saves(folder=True), desc=folder_desc),
|
|
Row('value', 'Open Saves Folder', '', 'arrow',
|
|
on=lambda: gd and Path(gd).is_dir() and self.open(saves.ensure_saves_dir(gd)),
|
|
desc=('The saves folder of the game folder: saves to import, and a backup of each save an import '
|
|
'replaced.',)),
|
|
Row('action', 'Back', on=lambda: self.show('home'), height=40, desc=('Back to the main menu.',)),
|
|
]
|
|
|
|
def choose_export_dir(self):
|
|
d = filedialog.askdirectory(parent=self.root, title='Choose where Export Save writes its zip',
|
|
initialdir=str(self.export_dir()), mustexist=True)
|
|
if d:
|
|
self.settings['save_export_dir'] = str(Path(d))
|
|
self.settings.save()
|
|
self.show('saves', self.focus)
|
|
|
|
def export_save(self):
|
|
gd = self.settings.get('game_dir')
|
|
if not gd or not Path(gd).is_dir():
|
|
self.export_message = 'Install the game first.'
|
|
else:
|
|
try:
|
|
r = saves.export_save(gd, self.export_dir(), bug_report=bool(self.settings.get('export_with_log')),
|
|
version=versions.display(build.built_version(gd) or self.layout.version),
|
|
log=self.ctx.log)
|
|
except (saves.SaveError, OSError) as e:
|
|
self.export_message = f'The save was not exported: {e}'
|
|
else:
|
|
extra = ' with the game\'s log and settings' if r['extras'] else ''
|
|
self.export_message = f'Exported {r["files"]} files{extra} to {r["path"]}.'
|
|
if not r['files']: # a game that stopped before its first save: the report files alone
|
|
self.export_message = (f'The game has no save yet: exported its log and settings alone to '
|
|
f'{r["path"]}.')
|
|
try:
|
|
winsys.reveal(r['path'])
|
|
except OSError:
|
|
pass
|
|
self.show('saves', self.focus)
|
|
|
|
def import_saves(self, folder):
|
|
"""Import a Save (folder False): what waits in the saves folder, else a zip the player picks. Import a Save
|
|
Folder (folder True): a folder the player picks; the saves folder itself takes what waits there."""
|
|
gd = self.settings.get('game_dir')
|
|
if not gd or not Path(gd).is_dir():
|
|
self.save_message = 'Install the game first.'
|
|
elif (self.game_proc and self.game_proc.poll() is None) or saves.game_running(gd):
|
|
self.save_message = 'Quit the game first: it keeps its save open while it runs.'
|
|
else:
|
|
try:
|
|
src = None
|
|
if folder:
|
|
src = filedialog.askdirectory(parent=self.root, title='Choose the folder with the save',
|
|
initialdir=str(saves.ensure_saves_dir(gd)), mustexist=True)
|
|
if not src:
|
|
return
|
|
elif saves.waiting(gd)[0] is None:
|
|
src = filedialog.askopenfilename(parent=self.root, title='Choose the save to import',
|
|
initialdir=str(self.export_dir()),
|
|
filetypes=[('NX2navi save', '*.zip'), ('All files', '*.*')])
|
|
if not src:
|
|
return
|
|
r = saves.import_save(gd, src, log=self.ctx.log)
|
|
except (saves.SaveError, OSError) as e:
|
|
self.save_message = f'The save was not imported: {e}'
|
|
else:
|
|
self.save_message = self.imported_text(r)
|
|
self.show('saves', self.focus)
|
|
|
|
@staticmethod
|
|
def imported_text(r):
|
|
"""What an import did, for the import rows' descriptions."""
|
|
m = r.get('manifest')
|
|
if m:
|
|
text = (f'Save imported from {r["source"].name} (exported {str(m.get("exported", ""))[:10]} with NX2navi '
|
|
f'{m.get("nx2navi") or "?"}).')
|
|
elif r['slot']:
|
|
text = f'Save imported into {r["slot"]}.'
|
|
else:
|
|
text = f'Save imported: {r["files"]} files from {r["source"].name}.'
|
|
return text + (f' Your old save is in saves\\{r["backup"].name}.' if r['backup'] else '')
|
|
|
|
# ---- mods
|
|
|
|
def mod_list(self, gd, details=True):
|
|
"""The game folder's mods in the game's load order ([] before the game is installed or when the folder cannot
|
|
be read)."""
|
|
if not gd or not Path(gd).is_dir():
|
|
return []
|
|
try:
|
|
return mods.scan(gd, details=details)
|
|
except OSError as e:
|
|
self.ctx.log(f'mods: {e}')
|
|
return []
|
|
|
|
def mods_row(self, gd):
|
|
"""Mods: the screen of the game's mods; orange while a mod's code waits to be built into the game."""
|
|
found = self.mod_list(gd, details=False)
|
|
try:
|
|
waiting = mods.code_waiting(gd) if found else []
|
|
except OSError:
|
|
waiting = []
|
|
on = sum(1 for m in found if m.enabled)
|
|
desc = ('Mods change the game: its pictures, models, sounds, text or code. Put a mod you downloaded into the '
|
|
'mods folder, its .zip, .7z or .rar file as it is or its folder, or select Mods and then Add a Mod: '
|
|
'Play takes it in. Switch each mod on or off, and choose which wins where two change the same file.',)
|
|
if waiting:
|
|
desc += (f'{", ".join(waiting)} changes the game\'s code: Play first builds the game again with it, which '
|
|
'takes a few minutes.',)
|
|
return Row('value', 'Mods', 'Build Waiting' if waiting else f'{on} On' if found else 'None',
|
|
'orange_arrow' if waiting else 'arrow', on=lambda: self.show('mods'), desc=desc)
|
|
|
|
def mods_page_count(self, n, per):
|
|
return max(1, -(-n // per))
|
|
|
|
def rows_mods(self):
|
|
gd = self.settings.get('game_dir')
|
|
folder = mods.mods_dir(gd) if gd else Path('mods')
|
|
found = self.mod_list(gd)
|
|
over = mods.overlaps(found)
|
|
pages = self.mods_page_count(len(found), MODS_PAGE)
|
|
self.mods_page = max(0, min(pages - 1, self.mods_page))
|
|
msg = (self.mods_message,) if self.mods_message else ()
|
|
rows = [
|
|
Row('value', 'Open Mods Folder', str(folder), on=self.open_mods,
|
|
desc=msg + ('Open the mods folder: one folder for each mod. A mod you downloaded goes in as it came, '
|
|
'an archive (.zip, .7z or .rar) or a folder; Play unpacks archives into folders of their '
|
|
'own and moves a mod\'s files to where the game looks for them.',
|
|
'To remove a mod, delete its folder and its archive, or switch it off here.')),
|
|
Row('value', 'Add a Mod', 'Choose a File', 'arrow', on=lambda: self.add_mod(folder=False),
|
|
desc=msg + ('Choose a mod you downloaded, a .zip, .7z or .rar file: it is unpacked into the mods '
|
|
'folder, ready for the game\'s next start.',)),
|
|
Row('value', 'Add a Mod Folder', 'Choose a Folder', 'arrow', on=lambda: self.add_mod(folder=True),
|
|
desc=msg + ('Choose the folder of a mod (one with a romfs, exefs or cheats folder in it, or an '
|
|
'atmosphere folder): it is copied into the mods folder.',)),
|
|
]
|
|
if found:
|
|
rows.append(Row('divider'))
|
|
for m in found[self.mods_page * MODS_PAGE:(self.mods_page + 1) * MODS_PAGE]:
|
|
label = fit(self.f['label'], m.name, self.X(LABEL_W - 30))
|
|
if m.usable:
|
|
rows.append(Row('toggle', label, on=lambda v, m=m: self.switch_mod(m, v), get=lambda m=m: m.enabled,
|
|
desc=lambda m=m: self.mod_desc(m, over)))
|
|
else:
|
|
rows.append(Row('value', label, 'Not Usable', 'salmon', on=lambda: None,
|
|
desc=lambda m=m: self.mod_desc(m, over)))
|
|
if pages > 1:
|
|
rows.append(Row('enum', 'More Mods', get=lambda: f'Page {self.mods_page + 1} of {pages}',
|
|
left=lambda: self.mods_turn(-1, 'mods'), right=lambda: self.mods_turn(1, 'mods'),
|
|
desc=(f'{len(found)} mods, {MODS_PAGE} to a page: left and right turn the pages.',)))
|
|
if len(found) > 1:
|
|
rows.append(Row('value', 'Load Order', '', 'arrow', on=lambda: self.show('mod_order'),
|
|
desc=('Where two mods change the same file, the one higher in the load order wins. Set '
|
|
'the order here.',)))
|
|
rows.append(Row('action', 'Back', on=lambda: self.show('home', self.home_row('Mods')), height=40,
|
|
desc=('Back to the main menu.',)))
|
|
return rows
|
|
|
|
def mods_turn(self, d, screen):
|
|
self.mods_page += d
|
|
self.show(screen, next((i for i, r in enumerate(getattr(self, 'rows_' + screen)()) if r.label == 'More Mods'),
|
|
None))
|
|
|
|
def mod_desc(self, m, over):
|
|
lines = [self.mods_message] if self.mods_message else []
|
|
if not m.usable:
|
|
lines.append(m.problem)
|
|
what = []
|
|
if m.files:
|
|
what.append(f'replaces {len(m.files):,} of the game\'s files' if len(m.files) > 1 else
|
|
'replaces one of the game\'s files')
|
|
if m.patches:
|
|
what.append(f'patches the game\'s code ({m.patches} patch file{"s" if m.patches > 1 else ""})')
|
|
if m.cheat_file:
|
|
what.append('brings cheats, which the Cheats tab of the game\'s menu switches on and off')
|
|
if what:
|
|
lines.append('It ' + (', '.join(what[:-1]) + ' and ' + what[-1] if len(what) > 1 else what[0]) + '.')
|
|
if m.name == mods.STAND_INS:
|
|
lines.append('The launcher made it: stand-ins for game files its check found damaged, so that the game '
|
|
'can load what needs them.')
|
|
if m.other_version:
|
|
lines.append(f'{m.other_version} of its code patch files are for another version of the game and are '
|
|
'left out.')
|
|
for other, n, wins in over.get(m.name, [])[:3]:
|
|
lines.append(f'{n:,} of its files are in {other} too: {"this mod" if wins else other} wins, being higher '
|
|
'in the load order.')
|
|
if m.waiting and m.enabled:
|
|
lines.append('Its code is not in the game yet: Play first builds the game again with it, which takes a '
|
|
'few minutes.')
|
|
if m.usable:
|
|
lines.append('On: the game loads it at its next start. Off: it stays in the mods folder, unused.')
|
|
return {'title': m.name, 'text': lines}
|
|
|
|
def game_busy(self, gd):
|
|
return bool((self.game_proc and self.game_proc.poll() is None) or (gd and saves.game_running(gd)))
|
|
|
|
def switch_mod(self, m, on):
|
|
gd = self.settings.get('game_dir')
|
|
if self.game_busy(gd):
|
|
self.mods_message = 'Quit the game first: it reads the mods when it starts.'
|
|
return
|
|
try:
|
|
mods.set_enabled(gd, m.name, on)
|
|
except OSError as e:
|
|
self.mods_message = f'The game\'s settings file could not be written: {e}'
|
|
return
|
|
m.enabled, self.mods_message = bool(on), ''
|
|
self.ctx.log(f'mods: {m.name} {"on" if on else "off"}')
|
|
|
|
def open_mods(self):
|
|
gd = self.settings.get('game_dir')
|
|
if not gd or not Path(gd).is_dir():
|
|
self.mods_message = 'Install the game first.'
|
|
self.show('mods', self.focus)
|
|
return
|
|
try:
|
|
folder = mods.ensure_mods_dir(gd)
|
|
except OSError as e:
|
|
self.mods_message = f'The mods folder could not be made: {e}'
|
|
self.show('mods', self.focus)
|
|
return
|
|
self.open(folder)
|
|
|
|
def add_mod(self, folder):
|
|
gd = self.settings.get('game_dir')
|
|
if not gd or not Path(gd).is_dir():
|
|
self.mods_message = 'Install the game first.'
|
|
else:
|
|
if folder:
|
|
src = filedialog.askdirectory(parent=self.root, title='Choose the folder of the mod',
|
|
initialdir=str(Path.home() / 'Downloads'), mustexist=True)
|
|
else:
|
|
src = filedialog.askopenfilename(parent=self.root, title='Choose the mod',
|
|
initialdir=str(Path.home() / 'Downloads'),
|
|
filetypes=[('Mod', '*.zip *.7z *.rar'), ('All files', '*.*')])
|
|
if not src:
|
|
return
|
|
try:
|
|
target = mods.add(gd, src, log=self.ctx.log)
|
|
except (mods.ModError, OSError) as e:
|
|
self.mods_message = f'{Path(src).name} was not added: {e}.'
|
|
else:
|
|
added = next((m for m in self.mod_list(gd) if m.name == target.name), None)
|
|
self.mods_message = f'Added {target.name}.' + (f' {added.problem}' if added and not added.usable else
|
|
' It is on: the game loads it at its next start.')
|
|
self.show('mods', self.focus)
|
|
|
|
def rows_mod_order(self):
|
|
gd = self.settings.get('game_dir')
|
|
found = self.mod_list(gd, details=False)
|
|
pages = self.mods_page_count(len(found), ORDER_PAGE)
|
|
self.mods_page = max(0, min(pages - 1, self.mods_page))
|
|
rows = []
|
|
first = self.mods_page * ORDER_PAGE
|
|
for k, m in enumerate(found[first:first + ORDER_PAGE], first + 1):
|
|
rows.append(Row('enum', fit(self.f['label'], m.name, self.X(LABEL_W - 30)), get=lambda k=k: f'{k}',
|
|
left=lambda m=m: self.order_move(m.name, -1), right=lambda m=m: self.order_move(m.name, 1),
|
|
desc=lambda m=m, k=k: {'title': m.name, 'text': [
|
|
f'Number {k} of {len(found)} in the load order{" (off)" if not m.enabled else ""}. '
|
|
'Left moves it up: it wins over the mods below it where two change the same file. '
|
|
'Right moves it down.'] + ([self.mods_message] if self.mods_message else [])}))
|
|
if pages > 1:
|
|
rows.append(Row('enum', 'More Mods', get=lambda: f'Page {self.mods_page + 1} of {pages}',
|
|
left=lambda: self.mods_turn(-1, 'mod_order'), right=lambda: self.mods_turn(1, 'mod_order'),
|
|
desc=(f'{len(found)} mods, {ORDER_PAGE} to a page: left and right turn the pages.',)))
|
|
rows.append(Row('action', 'Back', on=lambda: self.show('mods', self.mods_back_focus()), height=40,
|
|
desc=('Back to the mods.',)))
|
|
return rows
|
|
|
|
def mods_back_focus(self):
|
|
return next((i for i, r in enumerate(self.rows_mods()) if r.label == 'Load Order'), None)
|
|
|
|
def order_move(self, name, d):
|
|
gd = self.settings.get('game_dir')
|
|
if self.game_busy(gd):
|
|
self.mods_message = 'Quit the game first: it reads the mods when it starts.'
|
|
return
|
|
try:
|
|
mods.move(gd, name, d)
|
|
except OSError as e:
|
|
self.mods_message = f'The game\'s settings file could not be written: {e}'
|
|
return
|
|
self.mods_message = ''
|
|
names = [m.name for m in self.mod_list(gd, details=False)]
|
|
at = names.index(name) if name in names else 0
|
|
self.mods_page = at // ORDER_PAGE
|
|
self.show('mod_order', at % ORDER_PAGE)
|
|
|
|
def mods_before_play(self, gd):
|
|
"""Put the mods folder in order (mods.prepare) and say whether the game must be built again for a mod's code
|
|
first: the mods that wait for it, [] when none do."""
|
|
try:
|
|
for line in mods.prepare(gd, log=self.ctx.log):
|
|
self.mods_message = line
|
|
if 'was not unpacked' in line: # on the home screen too, when the launcher comes back
|
|
self.message = line
|
|
except OSError as e:
|
|
self.ctx.log(f'mods: the mods folder could not be put in order: {e}')
|
|
try:
|
|
return mods.code_waiting(gd) if build.can_rebuild(gd) else []
|
|
except OSError as e:
|
|
self.ctx.log(f'mods: {e}')
|
|
return []
|
|
|
|
def update_desc(self):
|
|
r = self.release
|
|
nv, mine = versions.display(r.version), versions.display(self.layout.version)
|
|
notes = feed.plain_notes(r.notes, 6)
|
|
return {'title': f'Version {nv} Is Ready',
|
|
'text': [f'You have {mine}. Updating downloads the new version and rebuilds the game. {UPDATE_TAKES} '
|
|
f'Your saves and settings stay as they are.'],
|
|
'divider': bool(notes), 'subtitle': "What's New" if notes else '', 'bullets': notes}
|
|
|
|
def staged_desc(self, m):
|
|
"""The Updates row of a release downloaded in the background (BackgroundUpdate.ready)."""
|
|
nv, mine = m['version'], versions.display(self.layout.version)
|
|
notes = feed.plain_notes(m.get('notes', ''), 4)
|
|
if self.settings.get('auto_install'):
|
|
text = (f'You have {mine}. Version {nv} is downloaded and installs when you are not playing: after you '
|
|
'quit the game, or when NX2navi next opens. Select this row to install it now.')
|
|
else:
|
|
text = f'You have {mine}. Version {nv} is downloaded. Select this row to install it.'
|
|
return {'title': f'Version {nv} Is Ready', 'text': [text],
|
|
'divider': bool(notes), 'subtitle': "What's New" if notes else '', 'bullets': notes,
|
|
'note': f'Installing restarts the launcher and rebuilds the game. {UPDATE_TAKES} Your saves and '
|
|
'settings stay as they are.'}
|
|
|
|
def staging_desc(self):
|
|
nv, mine = self.bg.job.ver, versions.display(self.layout.version)
|
|
if self.bg.asked:
|
|
then = 'It installs as soon as the download is done.'
|
|
elif self.settings.get('auto_install'):
|
|
then = 'It installs when you are not playing. Select this row to install it as soon as it is downloaded.'
|
|
else:
|
|
then = 'Select this row to install it as soon as it is downloaded.'
|
|
return {'title': f'Downloading Version {nv}',
|
|
'text': [f'You have {mine}. Version {nv} is downloading in the background; you can play meanwhile. '
|
|
f'{then}']}
|
|
|
|
def ago(self, t):
|
|
d = time.time() - t
|
|
if d < 90:
|
|
return 'just now'
|
|
if d < 3600:
|
|
return f'{int(d // 60)} minutes ago'
|
|
return time.strftime('at %H:%M', time.localtime(t))
|
|
|
|
def check_now(self, force=False):
|
|
if self.check and self.check.result is None:
|
|
return
|
|
if not force and time.time() - self.settings.get('last_check', 0) < 600 and self.release:
|
|
return
|
|
if force and time.time() - self.check_started < FORCED_CHECK_PAUSE:
|
|
self.message = 'NX2navi looked for updates less than a minute ago.'
|
|
if self.screen == 'home':
|
|
self.show('home', self.focus)
|
|
return
|
|
self.check_error = ''
|
|
self.check_started = time.time()
|
|
self.check = jobs.CheckJob(self.ctx).start()
|
|
if self.screen == 'home':
|
|
self.show('home', self.focus)
|
|
|
|
def check_done(self):
|
|
c = self.check
|
|
self.bg.checked() # the release found is staged at the next tick, also one whose staging failed before
|
|
if c.result == 'ok':
|
|
self.release = c.release
|
|
self.checked_at = time.time()
|
|
self.settings['last_check'] = self.checked_at
|
|
if self.release:
|
|
self.settings['last_seen'] = self.release.version
|
|
self.settings.save()
|
|
self.ctx.log(f'update check: {self.release.version if self.release else "no release"}')
|
|
else:
|
|
self.check_error = c.error or 'cancelled'
|
|
self.ctx.log(f'update check: {self.check_error}')
|
|
if self.screen == 'home':
|
|
self.show('home', self.focus)
|
|
|
|
def play(self, built=False):
|
|
"""built: the build for the mods' code (ModsRebuildJob) just ended, so the game starts without asking again."""
|
|
gd = self.settings.get('game_dir')
|
|
exe = Path(gd) / 'ocarina.exe'
|
|
if not exe.is_file():
|
|
self.message = 'The game is not built yet.'
|
|
self.render()
|
|
return
|
|
if self.game_proc and self.game_proc.poll() is None:
|
|
return
|
|
if saves.game_running(gd): # a copy this launcher did not start (another launcher, a shortcut, a test run)
|
|
self.message = 'The game is already running.'
|
|
self.render()
|
|
return
|
|
waiting = [] if built else self.mods_before_play(gd)
|
|
if waiting and waiting != self.mods_failed:
|
|
# a mod's code cheats or ExeFS patches the game was not built with: built in first (tools/make_game.py
|
|
# lift), then the game starts (job_finished). After a build that failed for the same mods the game
|
|
# starts without them, so that a build that cannot finish never keeps the player from the game.
|
|
self.ctx.log(f'play: the game is built again first for the code of {", ".join(waiting)}')
|
|
job = jobs.ModsRebuildJob(self.ctx, gd)
|
|
job.waiting = waiting
|
|
self.run_job(job, 'home')
|
|
self.message = f'Building the game with the code of {", ".join(waiting)}; it starts after.'
|
|
return
|
|
if waiting:
|
|
self.ctx.log(f'play: without the code of {", ".join(waiting)}, whose build did not finish')
|
|
try:
|
|
self.game_proc = winsys.start_game(exe)
|
|
except OSError as e:
|
|
self.message = f'The game could not start: {e}'
|
|
self.render()
|
|
return
|
|
self.ctx.log(f'play: {exe}')
|
|
self.sounds.play('select')
|
|
if self.settings.get('hide_while_playing'):
|
|
self.root.withdraw()
|
|
self.root.after(1000, self.watch_game)
|
|
|
|
def watch_game(self):
|
|
# The game's own Restart ends the process started here and starts another (gfx_present.c gfx_window_closed)
|
|
gd = self.settings.get('game_dir')
|
|
if (self.game_proc and self.game_proc.poll() is None) or (gd and saves.game_running(gd)):
|
|
self.root.after(1000, self.watch_game)
|
|
return
|
|
code = self.game_proc.returncode if self.game_proc else 0
|
|
self.game_proc = None
|
|
self.root.deiconify()
|
|
self.root.lift()
|
|
if code not in (0, None):
|
|
text = exit_code_text(code)
|
|
# 70 and 71 follow a FATAL line of the game's own, which names the error: the player gets that line
|
|
# and the launcher's log too, where the exit code alone said only that there was one
|
|
fatal = last_fatal(self.game_dir()) if code & 0xFFFFFFFF in (70, 71) else ''
|
|
if fatal:
|
|
shown = fatal if len(fatal) <= FATAL_SHOWN else fatal[:FATAL_SHOWN].rstrip() + '…'
|
|
self.ctx.log(f'the game ended with code {code & 0xFFFFFFFF}; its log says: FATAL: {fatal}')
|
|
self.message = (f'The game ended with a fatal error (code {code & 0xFFFFFFFF}): {shown}. Its log is '
|
|
'logs\\nx2navi.log in the game folder.')
|
|
else:
|
|
self.ctx.log(f'the game ended with code {text}')
|
|
self.message = f'The game ended with code {text}. Its log is logs\\nx2navi.log in the game folder.'
|
|
note_exit_in_log(self.game_dir(), text)
|
|
if self.install_staged('the game exited'):
|
|
return
|
|
self.show('home', self.focus)
|
|
|
|
def open(self, path):
|
|
try:
|
|
winsys.open_path(path)
|
|
except OSError as e:
|
|
self.message = str(e)
|
|
self.render()
|
|
|
|
def start_update(self):
|
|
"""The Updates row: install the release staged in the background, have the one downloading install once it
|
|
is staged, or else download it here with its steps on screen (as when the background download failed)."""
|
|
gd = self.settings.get('game_dir')
|
|
if gd and build.game_running(gd):
|
|
self.message = 'Close the game first: the update rebuilds it.'
|
|
self.show('home', self.focus)
|
|
return
|
|
if self.bg.staging():
|
|
self.bg.asked = True
|
|
self.message = f'Version {self.bg.job.ver} installs as soon as it is downloaded.'
|
|
self.show('home', self.focus)
|
|
return
|
|
if self.bg.ready(self.release):
|
|
if not self.install_staged('selected', asked=True):
|
|
self.message = f'The update could not be installed: {self.bg.error or "see the launcher log"}.'
|
|
self.show('home', self.focus)
|
|
return
|
|
if not self.release:
|
|
return
|
|
self.run_job(jobs.UpdateDownloadJob(self.ctx, self.release), 'home')
|
|
|
|
def install_staged(self, why, asked=False):
|
|
"""Install the release staged in the background when nothing stops it (BackgroundUpdate.install) and close
|
|
for the restart into it; True when it was installed."""
|
|
if self.job and self.job.result is None:
|
|
return False
|
|
running = bool(self.game_proc and self.game_proc.poll() is None)
|
|
ver = self.bg.install(self.settings.get('game_dir'), self.release, why, asked=asked, running=running)
|
|
if not ver:
|
|
return False
|
|
if self.bg.restarting:
|
|
self.message = f'Installing NX2navi {ver}…'
|
|
self.root.after(200, self.root.destroy)
|
|
else:
|
|
self.message = f'NX2navi {ver} is installed: start NX2navi again to finish it.'
|
|
if self.screen == 'home':
|
|
self.show('home', self.focus)
|
|
return True
|
|
|
|
def background_event(self, ev):
|
|
"""A background staging started or ended (BackgroundUpdate.poll)."""
|
|
if ev in ('staged', 'failed') and (self.bg.asked or self.bg.waiting):
|
|
if self.install_staged('the download finished'):
|
|
return
|
|
if ev == 'failed' and self.bg.asked:
|
|
# the player chose to update: download it here instead, where its error and Try Again show
|
|
self.bg.asked = False
|
|
gd = self.settings.get('game_dir')
|
|
if (self.screen == 'home' and self.release and jobs.update_available(self.ctx, self.release)
|
|
and not (self.game_proc and self.game_proc.poll() is None)
|
|
and not (gd and build.game_running(gd))):
|
|
self.run_job(jobs.UpdateDownloadJob(self.ctx, self.release), 'home')
|
|
return
|
|
if self.screen == 'home':
|
|
self.show('home', self.focus)
|
|
|
|
def start_rebuild(self):
|
|
gd = self.settings.get('game_dir')
|
|
self.run_job(jobs.RebuildJob(self.ctx, gd, self.settings.get('dump')), 'home')
|
|
|
|
# ---- settings
|
|
|
|
def rows_settings(self):
|
|
gd = self.settings.get('game_dir')
|
|
dump = self.settings.get('dump')
|
|
return [
|
|
self.toggle('Check for Updates at Start', 'auto_check',
|
|
'Look for a new version of NX2navi when the launcher opens, and every hour while it stays '
|
|
'open (also while you play). A new version downloads in the background.'),
|
|
self.toggle('Install Updates Automatically', 'auto_install',
|
|
'Install a downloaded update by itself when you are not playing: after you quit the game, '
|
|
'or when NX2navi opens. Off: it waits until you select Updates. Updates download in the '
|
|
'background either way.'),
|
|
self.toggle('Hide Launcher While Playing', 'hide_while_playing',
|
|
'Hide this window while the game runs; it comes back when you quit the game.'),
|
|
self.toggle('Keep Build Cache', 'keep_cache',
|
|
'Keep the translated code and compiled files (about 1.5 GB) in the game folder, so an update '
|
|
'compiles only what changed. Off: they are deleted after each build.'),
|
|
self.channel_row(),
|
|
*([self.discord_row(gd)] if gd and Path(gd).is_dir() else []),
|
|
Row('divider'),
|
|
Row('value', 'Game Folder', gd or 'Not chosen', on=lambda: gd and self.open(gd),
|
|
desc=('Open the game folder: saves in profile, the game\'s settings in nx2navi.ini, its logs in '
|
|
'logs.',)),
|
|
Row('value', 'Game Dump', Path(dump).name if dump else 'Not chosen', on=self.choose_dump,
|
|
desc=(f'The dump the game was built from: {dump}.' if dump else 'No dump chosen yet.',
|
|
'Select this row to choose another .nup file.')),
|
|
Row('divider'),
|
|
Row('value', 'Open Logs Folder', '', 'arrow', on=lambda: self.open(self.layout.logs),
|
|
desc=('The launcher\'s logs. Attach launcher.log when you report a problem with installing or '
|
|
'updating; never send game files.',)),
|
|
Row('value', 'Uninstall NX2navi', 'Keeps your saves', 'salmon', on=lambda: self.show('uninstall'),
|
|
desc=('Remove the launcher and its build tools. You choose on the next screen whether the built '
|
|
'game goes too.',)),
|
|
Row('action', 'Back', on=lambda: self.show('home'), height=40, desc=('Back to the main menu.',)),
|
|
]
|
|
|
|
# ---- uninstall
|
|
|
|
def rows_uninstall(self):
|
|
def on(v):
|
|
self.uninstall_game = bool(v)
|
|
gd = self.settings.get('game_dir')
|
|
return [
|
|
Row('toggle', 'Remove the Built Game', on=on, get=lambda: self.uninstall_game,
|
|
desc=(f'On: the game folder ({gd or "none"}) is deleted too, saves included.',
|
|
'Off: the game folder and your saves stay where they are.')),
|
|
Row('divider'),
|
|
Row('pair', actions=[('Uninstall', self.do_uninstall), ('Cancel', lambda: self.show('settings'))],
|
|
desc=('Uninstall removes NX2navi, its build tools and its shortcuts. Your dump is never touched.',)),
|
|
]
|
|
|
|
def do_uninstall(self):
|
|
gd = self.settings.get('game_dir') if self.uninstall_game else ''
|
|
if gd and build.game_running(gd):
|
|
self.message = 'Close the game first.'
|
|
self.render()
|
|
return
|
|
self.ctx.log(f'uninstall (game folder too: {bool(gd)})')
|
|
winsys.uninstall(self.layout, gd, self.ctx.log)
|
|
self.root.destroy()
|
|
|
|
# ---- jobs
|
|
|
|
def run_job(self, job, return_to, back=None):
|
|
self.job, self.job_return, self.job_back = job, return_to, back or return_to
|
|
self.job_rev = -1
|
|
self.message = ''
|
|
winsys.keep_awake(True)
|
|
job.start()
|
|
self.show('job')
|
|
|
|
def rows_job(self):
|
|
j = self.job
|
|
rows = []
|
|
for st in j.steps:
|
|
if st.state == jobs.RUNNING:
|
|
rows.append(Row('progress', st.label, fraction=st.fraction, value=f'{int(st.fraction * 100)}%',
|
|
focus_value=True, desc=None))
|
|
else:
|
|
style = {jobs.DONE: 'normal', jobs.SKIPPED: 'dim', jobs.PENDING: 'dim', jobs.FAILED: 'salmon'}[st.state]
|
|
rows.append(Row('value', st.label, st.value, style, check=st.state == jobs.DONE, desc=None,
|
|
focus_value=st.state == jobs.FAILED))
|
|
if st.id in ('romfs', 'verify') and st is not j.steps[-1]:
|
|
rows.append(Row('divider'))
|
|
if j.result is None:
|
|
label = {'install': 'Cancel Installation', 'update': 'Cancel Update'}.get(j.kind, 'Cancel')
|
|
rows.append(Row('action', label, on=self.cancel_job, height=44))
|
|
elif j.result == 'failed':
|
|
rows.append(Row('pair', actions=[('Try Again', self.retry_job), ('Copy Report', self.copy_report)]))
|
|
for r in rows:
|
|
r.desc = self.job_desc
|
|
return rows
|
|
|
|
def job_focus(self):
|
|
"""The cursor sits on the running step, or on Try Again after a failure."""
|
|
for i, r in enumerate(self.rows):
|
|
if self.job.result == 'failed' and r.kind == 'pair':
|
|
return i
|
|
if r.kind == 'progress':
|
|
return i
|
|
return self.focusables()[-1] if self.focusables() else 0
|
|
|
|
def job_desc(self):
|
|
j = self.job
|
|
cur = j.current()
|
|
lines = [ln for ln in j.lines if ln.strip() and not ln.startswith('@@')][-6:]
|
|
if j.result == 'failed':
|
|
return {'title': 'The Build Stopped' if j.kind != 'update' or cur.id not in ('download', 'verify')
|
|
else 'The Update Stopped',
|
|
'text': [self.friendly(j.error), 'Nothing in your game folder was changed.' if cur.id not in
|
|
('download', 'verify', 'tools', 'tools_update') else ''],
|
|
'log': lines, 'note': 'Try again first. If it stops again, use Copy Report and send it with your '
|
|
'issue. Never send game files.'}
|
|
if j.result == 'cancelled':
|
|
return {'title': 'Cancelled', 'text': ['Nothing was changed that a new start cannot finish.']}
|
|
if j.result == 'ok':
|
|
return {'title': 'Done', 'text': [f'Finished in {jobs.mmss(j.elapsed())}.']}
|
|
text = [cur.desc + (f' {cur.detail}.' if cur.detail else '')]
|
|
eta = j.eta()
|
|
return {'title': self.step_title(cur), 'text': text, 'progress': j.overall(),
|
|
'progress_label': ('Overall', self.eta_text(eta)),
|
|
'log': lines,
|
|
'note': 'Keep using your PC if you like. It stays awake until the build is done.'
|
|
if j.kind != 'update' or cur.id not in ('download', 'verify') else
|
|
'Your saves and settings stay as they are.'}
|
|
|
|
@staticmethod
|
|
def eta_text(eta):
|
|
if not eta:
|
|
return ''
|
|
minutes = max(1, int(eta // 60 + 1))
|
|
return 'about 1 minute left' if minutes == 1 else f'about {minutes} minutes left'
|
|
|
|
def step_title(self, st):
|
|
return {'tools': 'Gathering Build Tools', 'tools_update': 'Updating Build Tools', 'check': 'Inspecting Your Game',
|
|
'exefs': 'Unpacking the Program', 'romfs': 'Packing the Game Data', 'lift': 'Translating the Code',
|
|
'compile': 'Compiling', 'compile_changed': 'Compiling Changed Files', 'shaders': 'Preparing the Shaders',
|
|
'prompts': 'Drawing the Button Prompts', 'scan': 'Checking the Game Files', 'ready': 'Ready to Play',
|
|
'download': 'Downloading', 'verify': 'Verifying', 'swap': 'Swapping In the New Build'}.get(st.id,
|
|
st.label)
|
|
|
|
@staticmethod
|
|
def friendly(err):
|
|
e = (err or 'unknown error').strip()
|
|
return e[0].upper() + e[1:] + ('' if e.endswith('.') else '.')
|
|
|
|
def cancel_job(self):
|
|
if self.job and self.job.result is None:
|
|
self.job.cancel()
|
|
|
|
def retry_job(self):
|
|
j = self.job
|
|
if isinstance(j, jobs.InstallJob):
|
|
self.run_job(jobs.InstallJob(self.ctx, j.dump, j.game_dir), self.job_return, self.job_back)
|
|
elif isinstance(j, jobs.ContinueUpdateJob):
|
|
self.run_job(jobs.ContinueUpdateJob(self.ctx, j.game_dir, j.old, j.dump), self.job_return)
|
|
elif isinstance(j, jobs.RebuildJob):
|
|
self.run_job(jobs.RebuildJob(self.ctx, j.game_dir, j.dump), self.job_return)
|
|
elif isinstance(j, jobs.UpdateDownloadJob):
|
|
self.run_job(jobs.UpdateDownloadJob(self.ctx, j.release), self.job_return)
|
|
|
|
def copy_report(self):
|
|
j = self.job
|
|
report = [f'NX2navi {self.layout.version} ({"checkout" if self.layout.dev else "release"}), {sys.platform}',
|
|
f'Job: {j.kind}, step: {j.current().label}', f'Error: {j.error}', '', 'Last lines:']
|
|
report += [ln for ln in list(j.lines)[-60:]]
|
|
self.root.clipboard_clear()
|
|
self.root.clipboard_append('\n'.join(report))
|
|
self.message = 'Report copied.'
|
|
self.render()
|
|
|
|
def leave_job(self):
|
|
j = self.job
|
|
if j.result == 'ok':
|
|
self.show(self.job_return)
|
|
elif j.result == 'cancelled':
|
|
self.show(self.job_back)
|
|
else:
|
|
self.show(self.job_back)
|
|
|
|
def job_finished(self):
|
|
j = self.job
|
|
winsys.keep_awake(False)
|
|
self.ctx.log(f'job {j.kind}: {j.result} {j.error}')
|
|
if j.result in ('ok', 'failed'):
|
|
self.sounds.play('select' if j.result == 'ok' else 'back')
|
|
if j.result == 'ok':
|
|
if j.after == 'restart':
|
|
if winsys.restart(self.layout, ['--continue-update', versions.display(self.layout.version)]):
|
|
self.root.after(200, self.root.destroy)
|
|
return
|
|
self.message = 'The update is ready: start NX2navi again to finish it.'
|
|
elif j.after == 'staged':
|
|
self.message = 'The update was downloaded; a checkout keeps running from the repository.'
|
|
elif j.after == 'play': # the mods' code is built in (play): the game starts
|
|
if not self.settings.get('keep_cache'):
|
|
self.drop_build_cache()
|
|
self.message, self.mods_failed = '', []
|
|
self.show('home', self.home_row('Play'))
|
|
self.play(built=True)
|
|
return
|
|
self.mods_failed = []
|
|
if not self.settings.get('keep_cache') and j.kind in ('install', 'update', 'rebuild'):
|
|
self.drop_build_cache()
|
|
if j.kind == 'install':
|
|
self.message = 'The game is built. Select Play.'
|
|
elif j.kind in ('update', 'rebuild'):
|
|
self.message = 'The game is rebuilt and up to date.' if j.kind == 'rebuild' else \
|
|
f'NX2navi is now {versions.display(self.layout.version)}.'
|
|
self.release = None
|
|
note = getattr(j, 'note', '') # what the file check found (jobs.Job.note_scan)
|
|
if note and j.after is None:
|
|
self.message = f'{self.message} {note}'.strip()
|
|
self.reload_fonts_if_new()
|
|
self.show(self.job_return)
|
|
if j.kind == 'install' and self.settings.get('auto_check'):
|
|
self.check_now()
|
|
elif j.result == 'cancelled':
|
|
self.mods_failed = getattr(j, 'waiting', self.mods_failed)
|
|
self.show(self.job_back)
|
|
else:
|
|
self.mods_failed = getattr(j, 'waiting', self.mods_failed)
|
|
self.show('job')
|
|
|
|
def drop_build_cache(self):
|
|
"""Keep Build Cache off: delete the lifted C and the objects (about 1.5 GB) but keep state.json, which an
|
|
update needs to rebuild without the dump; the next build then lifts and compiles everything again."""
|
|
gd = self.settings.get('game_dir')
|
|
if not gd:
|
|
return
|
|
cache = Path(gd) / 'build-cache'
|
|
for name in ('lift', 'obj', 'gen'):
|
|
shutil.rmtree(cache / name, ignore_errors=True)
|
|
for name in ('link.rsp', 'link.key'):
|
|
(cache / name).unlink(missing_ok=True)
|
|
|
|
def reload_fonts_if_new(self):
|
|
if not self.game_fonts and gamefiles.cached_fonts(self.layout):
|
|
self.reload_fonts()
|
|
|
|
# ---------------------------------------------------------------- timers
|
|
|
|
def tick(self):
|
|
try:
|
|
if self.check and self.check.result is not None and not getattr(self.check, 'seen', False):
|
|
self.check.seen = True
|
|
self.check_done()
|
|
ev = self.bg.poll(self.release, busy=bool(self.job and self.job.result is None))
|
|
if ev:
|
|
self.background_event(ev)
|
|
if self.screen == 'job' and self.job:
|
|
if self.job.rev != self.job_rev:
|
|
self.job_rev = self.job.rev
|
|
if self.job.result is not None:
|
|
self.job_finished()
|
|
else:
|
|
self.rows = self.rows_job()
|
|
self.focus = self.job_focus()
|
|
self.render()
|
|
elif int(time.time()) != self.last_second and self.job.result is None:
|
|
self.render()
|
|
self.last_second = int(time.time())
|
|
except Exception as e: # a drawing error must not stop the timer
|
|
self.ctx.log(f'tick: {e!r}')
|
|
self.root.after(150, self.tick)
|
|
|
|
def minute(self):
|
|
"""Once a minute, also while the window is hidden during play: look for a new version an hour after the last
|
|
look (a new one then downloads in the background), and install a staged release that waited for a copy of
|
|
the game the launcher did not start to close."""
|
|
try:
|
|
if not self.args.selftest and self.screen in ('home', 'settings'):
|
|
last = max(self.settings.get('last_check', 0.0), self.check_started)
|
|
if self.settings.get('auto_check') and time.time() - last >= jobs.RECHECK:
|
|
self.check_now()
|
|
if self.bg.waiting and not (self.game_proc and self.game_proc.poll() is None):
|
|
self.install_staged('the game closed')
|
|
except Exception as e:
|
|
self.ctx.log(f'minute: {e!r}')
|
|
self.root.after(60000, self.minute)
|
|
|
|
def animate(self):
|
|
"""One step of everything that moves: the cursor on its way to the focused row and its breath, the screen
|
|
sliding into place, the sparks and Navi. Each moves by the time since the last step (the timer comes when
|
|
Tk gets to it, 16 to 50 ms apart), at most a tenth of a second's worth. With Animations off, while the
|
|
window is hidden or minimised, and while another window is in front, nothing moves and the timer runs slowly.
|
|
The last covers a launcher left open behind the game (Hide Launcher While Playing off): drawing every 15 ms
|
|
took about 0.6 of a CPU core from the game for a window nobody saw."""
|
|
moving = False
|
|
try:
|
|
now = time.perf_counter()
|
|
dt = min(0.1, max(0.0, now - self.anim_last))
|
|
self.anim_last = now
|
|
moving = self.animated() and self.root.state() == 'normal' and winsys.in_front(self.root)
|
|
if moving:
|
|
self.anim_t += dt
|
|
self.step_slide(dt)
|
|
self.step_cursor(dt)
|
|
self.step_backdrop(dt)
|
|
except Exception as e: # a drawing error must not stop the timer
|
|
self.ctx.log(f'animate: {e!r}')
|
|
self.root.after(FRAME_MS if moving else 300, self.animate)
|
|
|
|
def step_slide(self, dt):
|
|
if not self.slide:
|
|
return
|
|
left = self.slide * math.exp(-dt * 16) # most of the way in a fifth of a second
|
|
if abs(left) < 1:
|
|
left = 0.0
|
|
self.canvas.move('dyn', int(left) - self.slide_applied, 0)
|
|
self.slide, self.slide_applied = left, int(left)
|
|
|
|
def step_cursor(self, dt):
|
|
c = self.canvas
|
|
if self.cursor_frames: # one breath in 1.6 s
|
|
k = int(self.anim_t / 1.6 * len(self.cursor_frames)) % len(self.cursor_frames)
|
|
if k != self.cursor_k:
|
|
self.cursor_k = k
|
|
c.itemconfigure('cursor', image=self.cursor_frames[k])
|
|
if self.cur_xy and self.cur_target and self.cur_xy != self.cur_target:
|
|
(x, y), (tx, ty) = self.cur_xy, self.cur_target
|
|
f = 1 - math.exp(-dt * 22) # there in about a tenth of a second
|
|
x, y = x + (tx - x) * f, y + (ty - y) * f
|
|
if abs(tx - x) < 0.6 and abs(ty - y) < 0.6:
|
|
x, y = tx, ty
|
|
self.cur_xy = (x, y)
|
|
c.coords('cursor', round(x) + self.slide_applied, round(y))
|
|
|
|
def step_backdrop(self, dt):
|
|
c, t = self.canvas, self.anim_t
|
|
for sp in self.sparks: # up, swaying, and back in at the bottom once under the title band
|
|
sp['y'] -= sp['vy'] * dt
|
|
if sp['y'] < BAND[2] - 8:
|
|
sp['y'] = H + 8
|
|
c.coords(sp['id'], self.X(sp['x'] + math.sin(t * sp['rate'] + sp['phase']) * sp['sway']), self.X(sp['y']))
|
|
k = int((math.sin(t * sp['rate'] * 2.3 + sp['phase'] * 3) + 1) * 1.49) # 0, 1 or 2: its twinkle
|
|
if k != sp['shown']:
|
|
sp['shown'] = k
|
|
c.itemconfigure(sp['id'], image=sp['pics'][k])
|
|
if self.navi_xy: # Navi rises and sinks two pixels every 2.4 s, her halo pulses with her
|
|
x, y = self.navi_xy
|
|
c.coords('navi', x, y + round(math.sin(t * 2.6) * self.X(2)))
|
|
if self.halo:
|
|
c.itemconfigure('halo', image=self.halo[int(t / 2.4 * len(self.halo)) % len(self.halo)])
|
|
|
|
# ---------------------------------------------------------------- drawing
|
|
|
|
def render(self):
|
|
c = self.canvas
|
|
c.delete('dyn')
|
|
if self.screen == 'job' and self.job:
|
|
if self.job.result is None or self.rows == []:
|
|
self.rows = self.rows_job()
|
|
if not (0 <= self.focus < len(self.rows)) or not self.rows[self.focus].focusable:
|
|
self.focus = self.job_focus()
|
|
x0, x1, y = ROWS
|
|
heights, self.row_gap = self.fit_rows(y, H - 46 if self.screen == 'job' else H - 12)
|
|
for r, h in zip(self.rows, heights):
|
|
self.draw_row(r, self.X(x0), self.X(y), self.X(x1), self.X(y + h))
|
|
y += h + self.row_gap
|
|
self.draw_status()
|
|
self.draw_cursor()
|
|
if self.screen == 'color' and self.pick:
|
|
self.draw_picker()
|
|
else:
|
|
self.draw_desc()
|
|
self.slide_applied = int(self.slide)
|
|
if self.slide_applied: # drawn in place, then put where the slide has got to (step_slide)
|
|
c.move('dyn', self.slide_applied, 0)
|
|
|
|
def fit_rows(self, top, bottom):
|
|
"""The height of each row and the gap between rows (logical pixels) that end the rows above bottom. A screen
|
|
with more rows than the window holds (Settings with every row, a job with every step) narrows its gaps
|
|
first, then its dividers, and last makes its rows a little lower."""
|
|
rows = self.rows
|
|
hs = [r.h for r in rows]
|
|
gaps = max(1, len(rows) - 1)
|
|
room = bottom - top
|
|
for stage in ('gaps', 'dividers', 'rows'):
|
|
if stage == 'dividers':
|
|
hs = [min(h, 14) if r.kind == 'divider' else h for r, h in zip(rows, hs)]
|
|
elif stage == 'rows':
|
|
over = sum(hs) + 6 * gaps - room
|
|
cut = -(-over // max(1, sum(1 for r in rows if r.kind != 'divider')))
|
|
hs = [h if r.kind == 'divider' else max(30, h - cut) for r, h in zip(rows, hs)]
|
|
gap = min(GAP, (room - sum(hs)) // gaps)
|
|
if gap >= 6:
|
|
return hs, gap
|
|
return hs, 6
|
|
|
|
def draw_status(self):
|
|
if self.screen != 'job' or not self.job:
|
|
return
|
|
j = self.job
|
|
steps = [s for s in j.steps]
|
|
cur = j.current()
|
|
n = steps.index(cur) + 1 if cur in steps else len(steps)
|
|
left = f'Stopped at step {n} of {len(steps)}' if j.result == 'failed' else f'Step {n} of {len(steps)}'
|
|
if self.message:
|
|
left = self.message
|
|
y = self.X(H - 24)
|
|
self.canvas.create_text(self.X(PANEL[0] + 30), y, text=left, anchor='sw', font=self.f['status'],
|
|
fill=P['status'], tags='dyn')
|
|
self.canvas.create_text(self.X(PANEL[2] - 30), y, text=f'Elapsed {jobs.mmss(j.elapsed())}', anchor='se',
|
|
font=self.f['status'], fill=P['status'], tags='dyn')
|
|
|
|
def text(self, x, y, text, font, fill, anchor='w', shadow=True, width=0):
|
|
c = self.canvas
|
|
if shadow:
|
|
c.create_text(x + 1, y + 1, text=text, font=font, fill=P['text_shadow'], anchor=anchor, tags='dyn',
|
|
width=width)
|
|
return c.create_text(x, y, text=text, font=font, fill=fill, anchor=anchor, tags='dyn', width=width)
|
|
|
|
def draw_row(self, r, x0, y0, x1, y1):
|
|
c, s, X = self.canvas, self.s, self.X
|
|
r.rect = (x0, y0, x1, y1)
|
|
if r.kind == 'divider':
|
|
img = self.sprite(('filigree', x1 - x0), lambda: art.filigree(x1 - x0, s)[0])
|
|
c.create_image(x0, (y0 + y1) // 2, anchor='w', image=img, tags='dyn')
|
|
return
|
|
if r.kind in ('action', 'pair'):
|
|
parts = [(r.label, r.on)] if r.kind == 'action' else r.actions
|
|
gap = X(GAP)
|
|
w = (x1 - x0 - gap * (len(parts) - 1)) // len(parts)
|
|
r.parts = []
|
|
for k, (label, cb) in enumerate(parts):
|
|
px0 = x0 + k * (w + gap)
|
|
px1 = px0 + w if k < len(parts) - 1 else x1
|
|
lit = r is self.focused() and (r.kind == 'action' or getattr(r, 'pick', 0) == k)
|
|
img = self.sprite(('label', px1 - px0, y1 - y0, lit),
|
|
lambda a=px1 - px0, b=y1 - y0: art.label_cell(a, b, s, lit))
|
|
ring = max(1, int(round(s)))
|
|
c.create_image(px0 - ring, y0 - ring, anchor='nw', image=img, tags='dyn')
|
|
font = self.fit_font(self.f['action_big'] if r.h >= 50 else self.f['action'], label,
|
|
px1 - px0 - X(16))
|
|
self.text((px0 + px1) // 2, (y0 + y1) // 2, label, font, P['text_bright'], anchor='center')
|
|
r.parts.append(((px0, y0, px1, y1), label, cb))
|
|
r.value_rect = None
|
|
return
|
|
lw = X(LABEL_W)
|
|
lx1 = x0 + lw
|
|
lit = r is self.focused()
|
|
img = self.sprite(('label', lw, y1 - y0, lit), lambda: art.label_cell(lw, y1 - y0, s, lit))
|
|
ring = max(1, int(round(s)))
|
|
c.create_image(x0 - ring, y0 - ring, anchor='nw', image=img, tags='dyn')
|
|
tx = x0 + X(16)
|
|
if r.icon:
|
|
size = X(32)
|
|
ic = self.sprite(('icon', r.icon, size), lambda: art.framed_icon(size, s, r.icon))
|
|
c.create_image(tx, (y0 + y1) // 2, anchor='w', image=ic, tags='dyn')
|
|
tx += size + X(14)
|
|
font = (self.f['big'] if r.icon else self.f['big_small']) if r.big else self.f['label']
|
|
font = self.fit_font(font, r.label, lx1 - tx - X(10) - (X(32) if r.badge else 0))
|
|
tid = self.text(tx, (y0 + y1) // 2, r.label, font, P['text_bright'] if r.big else P['text'])
|
|
if r.badge:
|
|
bx = self.canvas.bbox(tid)[2] + X(10)
|
|
size = X(22)
|
|
b = self.sprite(('badge', size), lambda: art.badge(size, s))
|
|
c.create_image(bx, (y0 + y1) // 2, anchor='w', image=b, tags='dyn')
|
|
c.create_text(bx + (size + 4) // 2, (y0 + y1) // 2, text='!', font=self.f['badge'],
|
|
fill=P['badge_text'], tags='dyn')
|
|
vx0 = lx1
|
|
r.value_rect = (vx0, y0, x1, y1)
|
|
c.create_rectangle(vx0, y0, x1 - 1, y1 - 1, fill=P['value'], outline=P['value_border'], width=1, tags='dyn')
|
|
pad = X(3) if not r.big else X(4)
|
|
ix0, iy0, ix1, iy1 = vx0 + pad, y0 + pad, x1 - pad, y1 - pad
|
|
cy = (y0 + y1) // 2
|
|
cx = (vx0 + x1) // 2
|
|
vfont = self.f['label'] if r.kind in ('toggle', 'enum', 'progress') or r.style == 'orange_arrow' \
|
|
else self.f['value']
|
|
if r.kind == 'toggle':
|
|
on = bool(r.get())
|
|
mid = (ix0 + ix1) // 2
|
|
for k, (label, sel) in enumerate((('On', on), ('Off', not on))):
|
|
sx0, sx1 = (ix0, mid) if k == 0 else (mid, ix1)
|
|
if sel:
|
|
self.orange(sx0, iy0, sx1, iy1)
|
|
self.canvas.create_text((sx0 + sx1) // 2, cy, text=label, font=vfont,
|
|
fill=P['on_text'] if sel else P['off'], tags='dyn')
|
|
elif r.kind == 'enum':
|
|
self.orange(ix0, iy0, ix1, iy1)
|
|
cw, ch = X(9), X(12)
|
|
for left in (True, False):
|
|
img = self.sprite(('chev', cw, ch, left, 'on'), lambda lf=left: art.chevron(cw, ch, P['on_text'], lf))
|
|
c.create_image(ix0 + X(9) if left else ix1 - X(9), cy, anchor='w' if left else 'e', image=img,
|
|
tags='dyn')
|
|
self.canvas.create_text(cx, cy, text=r.get(), font=vfont, fill=P['on_text'], tags='dyn')
|
|
elif r.kind == 'progress':
|
|
fw = int((ix1 - ix0) * max(0.0, min(1.0, r.fraction)))
|
|
if fw > 2:
|
|
self.orange(ix0, iy0, ix0 + fw, iy1)
|
|
self.canvas.create_text(cx + 1, cy + 1, text=r.value, font=vfont, fill=P['band_shadow'], tags='dyn')
|
|
self.canvas.create_text(cx, cy, text=r.value, font=vfont, fill=P['on_text'], tags='dyn')
|
|
elif r.style == 'orange_arrow':
|
|
self.orange(ix0, iy0, ix1, iy1)
|
|
cw, ch = X(9), X(12)
|
|
vfont = self.fit_font(vfont, r.value, (ix1 - ix0) - X(30) - cw, 0.8)
|
|
label_w = vfont.measure(r.value)
|
|
total = label_w + X(10) + cw
|
|
sx = cx - total // 2
|
|
self.canvas.create_text(sx, cy, text=r.value, font=vfont, fill=P['on_text'], anchor='w', tags='dyn')
|
|
img = self.sprite(('chev', cw, ch, False, 'on'), lambda: art.chevron(cw, ch, P['on_text']))
|
|
c.create_image(sx + label_w + X(10), cy, anchor='w', image=img, tags='dyn')
|
|
elif r.style == 'swatch': # a colour of the Custom scheme: a patch of it and its hex code
|
|
cw, ch = X(9), X(12)
|
|
img = self.sprite(('chev', cw, ch, False, 'dim'), lambda: art.chevron(cw, ch, P['chevron_dim']))
|
|
c.create_image(x1 - X(16), cy, anchor='e', image=img, tags='dyn')
|
|
tw = vfont.measure(r.value.upper())
|
|
sw = X(26)
|
|
sx = (vx0 + x1 - X(16) - cw - X(10)) // 2 - (sw + X(10) + tw) // 2
|
|
c.create_rectangle(sx, cy - X(9), sx + sw, cy + X(9), fill=r.value, outline=P['value_border'], tags='dyn')
|
|
c.create_text(sx + sw + X(10), cy, text=r.value.upper(), font=vfont, fill=P['value_text'], anchor='w',
|
|
tags='dyn')
|
|
elif r.style == 'arrow':
|
|
cw, ch = X(9), X(12)
|
|
img = self.sprite(('chev', cw, ch, False, 'dim'), lambda: art.chevron(cw, ch, P['chevron_dim']))
|
|
c.create_image(x1 - X(16), cy, anchor='e', image=img, tags='dyn')
|
|
if r.value: # a value, centred in the cell's room left of the arrow
|
|
right = x1 - X(16) - cw - X(10)
|
|
avail = right - vx0 - X(16)
|
|
vfont = self.fit_font(vfont, r.value, avail, 0.8)
|
|
c.create_text((vx0 + right) // 2, cy, text=fit(vfont, r.value, avail), font=vfont,
|
|
fill=P['value_text'], tags='dyn')
|
|
else:
|
|
color = {'normal': P['value_text'], 'dim': P['dim'], 'salmon': P['salmon']}.get(r.style, P['value_text'])
|
|
avail = (x1 - vx0) - X(20) - (X(21) if r.check else 0)
|
|
vfont = self.fit_font(vfont, r.value, avail, 0.8)
|
|
label = fit(vfont, r.value, avail)
|
|
tw = vfont.measure(label)
|
|
sx = cx - (tw + (X(21) if r.check else 0)) // 2
|
|
if r.check:
|
|
size = X(14)
|
|
ck = self.sprite(('check', size), lambda: art.check_mark(size, s))
|
|
c.create_image(sx, cy, anchor='w', image=ck, tags='dyn')
|
|
sx += X(21)
|
|
self.canvas.create_text(sx, cy, text=label, font=vfont, fill=color, anchor='w', tags='dyn')
|
|
|
|
def orange(self, x0, y0, x1, y1, tags=('dyn',)):
|
|
c = self.canvas
|
|
c.create_rectangle(x0, y0, x1 - 1, y1 - 1, fill=P['orange'], outline=P['orange_dark'], width=1, tags=tags)
|
|
if x1 - x0 > 4:
|
|
c.create_rectangle(x0 + 1, y0 + 1, x1 - 2, y1 - 2, outline=P['orange_light'], width=1, tags=tags)
|
|
|
|
def draw_cursor(self):
|
|
r = self.focused()
|
|
self.cursor_frames = []
|
|
if not r or not r.rect:
|
|
self.cur_xy = self.cur_target = None
|
|
return
|
|
if r.kind == 'pair' and r.parts:
|
|
box = r.parts[getattr(r, 'pick', 0)][0]
|
|
elif (r.kind in ('toggle', 'enum', 'progress') or r.focus_value) and r.value_rect:
|
|
box = r.value_rect
|
|
else:
|
|
box = r.rect
|
|
x0, y0, x1, y1 = box
|
|
frames, mg = self.glow(x1 - x0, y1 - y0)
|
|
self.cursor_frames = frames
|
|
self.cursor_k %= len(frames)
|
|
# The cursor is drawn where it has got to and animate takes it on to the row (step_cursor); on a new screen,
|
|
# while one slides in and with Animations off it stands on the row at once.
|
|
target = (float(x0 - mg), float(y0 - mg))
|
|
glide = self.animated() and self.cur_xy and self.cur_screen == self.screen and not self.slide
|
|
self.cur_xy = self.cur_xy if glide else target
|
|
self.cur_target, self.cur_screen = target, self.screen
|
|
self.canvas.create_image(round(self.cur_xy[0]), round(self.cur_xy[1]), anchor='nw',
|
|
image=frames[self.cursor_k], tags=('dyn', 'cursor'))
|
|
|
|
def glow(self, w, h):
|
|
key = ('glow', w, h)
|
|
if key not in self.sprites:
|
|
frames, mg = art.glow_frames(w, h, self.s)
|
|
self.sprites[key] = ([art.photo(self.root, f) for f in frames], mg)
|
|
return self.sprites[key]
|
|
|
|
def draw_desc(self):
|
|
r = self.focused()
|
|
d = r.desc if r else None
|
|
if callable(d):
|
|
d = d()
|
|
if not d:
|
|
if self.message and self.screen != 'job':
|
|
d = (self.message,)
|
|
else:
|
|
return
|
|
if isinstance(d, tuple):
|
|
d = {'text': [t for t in d if t]}
|
|
elif self.message and self.screen in ('first_run', 'settings', 'uninstall'):
|
|
d = dict(d)
|
|
d['text'] = [self.message] + list(d.get('text', []))
|
|
c, X = self.canvas, self.X
|
|
dx, dw = DESC
|
|
x0 = X(dx)
|
|
w = X(dw)
|
|
padx, pady = X(26), X(22)
|
|
inner = w - 2 * padx
|
|
items = []
|
|
y = 0
|
|
|
|
def add(kind, *args):
|
|
items.append((kind, args))
|
|
|
|
if d.get('title'):
|
|
add('text', d['title'], self.f['heading'], P['text_bright'], X(14))
|
|
for t in d.get('text', []):
|
|
if t:
|
|
add('text', t, self.f['desc'], P['desc_text'], X(12))
|
|
if d.get('progress') is not None:
|
|
add('progress', d['progress'], d.get('progress_label', ('', '')))
|
|
if d.get('divider'):
|
|
add('filigree')
|
|
if d.get('subtitle'):
|
|
add('text', d['subtitle'], self.f['sub'], P['gold'], X(8))
|
|
for b in d.get('bullets', []):
|
|
add('bullet', b)
|
|
if d.get('log'):
|
|
add('log', d['log'])
|
|
if d.get('note'):
|
|
add('text', d['note'], self.f['small'], P['muted'], X(4))
|
|
# measure: draw at a provisional top, then move everything to the final top
|
|
top0 = X(112)
|
|
y = top0 + pady
|
|
made = []
|
|
for kind, args in items:
|
|
if kind == 'text':
|
|
text, font, fill, after = args
|
|
tid = c.create_text(x0 + padx, y, text=text, font=font, fill=fill, anchor='nw', width=inner,
|
|
tags=('dyn', 'desc'))
|
|
bb = c.bbox(tid)
|
|
y = bb[3] + after
|
|
made.append(tid)
|
|
elif kind == 'progress':
|
|
frac, (left, right) = args
|
|
c.create_text(x0 + padx, y, text=left, font=self.f['small'], fill=P['muted'], anchor='nw',
|
|
tags=('dyn', 'desc'))
|
|
c.create_text(x0 + w - padx, y, text=right, font=self.f['small'], fill=P['muted'], anchor='ne',
|
|
tags=('dyn', 'desc'))
|
|
y += self.f['small'].metrics('linespace') + X(6)
|
|
h = X(22)
|
|
c.create_rectangle(x0 + padx, y, x0 + w - padx - 1, y + h - 1, fill=P['value'],
|
|
outline=P['value_border'], tags=('dyn', 'desc'))
|
|
fw = int((inner - X(6)) * frac)
|
|
if fw > 2:
|
|
self.orange(x0 + padx + X(3), y + X(3), x0 + padx + X(3) + fw, y + h - X(3), ('dyn', 'desc'))
|
|
y += h + X(14)
|
|
elif kind == 'filigree':
|
|
img = self.sprite(('filigree', inner), lambda: art.filigree(inner, self.s)[0])
|
|
c.create_image(x0 + padx, y + X(9), anchor='w', image=img, tags=('dyn', 'desc'))
|
|
y += X(22)
|
|
elif kind == 'bullet':
|
|
(text,) = args
|
|
c.create_rectangle(x0 + padx + X(1), y + X(8), x0 + padx + X(6), y + X(13), fill=P['bullet'],
|
|
outline='', tags=('dyn', 'desc'))
|
|
tid = c.create_text(x0 + padx + X(16), y, text=text, font=self.f['value'], fill=P['desc_text'],
|
|
anchor='nw', width=inner - X(16), tags=('dyn', 'desc'))
|
|
y = c.bbox(tid)[3] + X(8)
|
|
elif kind == 'log':
|
|
(lines,) = args
|
|
lh = self.f['mono'].metrics('linespace')
|
|
shown = [fit(self.f['mono'], ln, inner - X(24)) for ln in lines[-6:]]
|
|
h = lh * max(len(shown), 1) + X(20)
|
|
c.create_rectangle(x0 + padx, y, x0 + w - padx - 1, y + h - 1, fill=P['value'],
|
|
outline=P['value_border'], tags=('dyn', 'desc'))
|
|
ty = y + X(10)
|
|
for ln in shown:
|
|
color = P['salmon'] if ln.lower().startswith(('error', 'stopped')) else P['log_text']
|
|
c.create_text(x0 + padx + X(12), ty, text=ln, font=self.f['mono'], fill=color, anchor='nw',
|
|
tags=('dyn', 'desc'))
|
|
ty += lh
|
|
y += h + X(14)
|
|
height = y - top0 + pady - X(4)
|
|
top = X(236) if height < X(200) else X(112)
|
|
top = min(top, X(H - 30) - height) if top + height > X(H - 30) else top
|
|
dy = top - top0
|
|
if dy:
|
|
c.move('desc', 0, dy)
|
|
hb = max(X(60), height)
|
|
key = ('desc', w, hb // 4)
|
|
if key not in self.sprites:
|
|
img, pad = art.desc_box(w, hb, self.s, self.see_through())
|
|
self.sprites[key] = (art.photo(self.root, img), pad)
|
|
pic, pad = self.sprites[key]
|
|
bid = c.create_image(x0 - pad, top - pad, anchor='nw', image=pic, tags='dyn')
|
|
c.tag_lower(bid, 'desc')
|
|
|
|
|
|
def parse_args(argv):
|
|
ap = argparse.ArgumentParser(prog='NX2navi')
|
|
ap.add_argument('--from', dest='source', help='a folder to look in for the dump first (the setup\'s folder)')
|
|
ap.add_argument('--continue-update', metavar='OLD', help='finish an update after the restart (from OLD)')
|
|
ap.add_argument('--rolled-back', metavar='VERSION', help='the stub went back from VERSION')
|
|
ap.add_argument('--uninstall', action='store_true')
|
|
ap.add_argument('--root', help='the launcher folder (tests)')
|
|
ap.add_argument('--selftest', action='store_true', help='draw every screen and close')
|
|
ap.add_argument('--shots', help='with --selftest: save a PNG of each screen into this folder')
|
|
ap.add_argument('--play', action='store_true',
|
|
help='start the game without the window and wait for it (the Steam shortcut, launcher/steam.py)')
|
|
a, _unknown = ap.parse_known_args(argv)
|
|
return a
|
|
|
|
|
|
def single_instance(layout, wait):
|
|
"""The launcher folder's lock (launcher/locks.py) for this window's life. locks.Busy, naming the holder, when
|
|
another launcher or a command of launcher/cli.py still holds it after wait seconds (the launcher an update
|
|
restart replaces may still be closing)."""
|
|
return locks.FileLock(layout.instance_lock, 'the launcher window', wait=wait, where=layout.root)
|
|
|
|
|
|
def tell(text):
|
|
"""A sentence for the player before the window is up: a message box that waits for OK."""
|
|
try:
|
|
if os.name == 'nt':
|
|
import ctypes
|
|
ctypes.windll.user32.MessageBoxW(None, text, NAME, 0x40)
|
|
else:
|
|
from tkinter import messagebox
|
|
root = tk.Tk()
|
|
root.withdraw()
|
|
messagebox.showinfo(NAME, text, parent=root)
|
|
root.destroy()
|
|
except Exception: # no display, no toolkit: the terminal that started it
|
|
print(text, file=sys.stderr)
|
|
|
|
|
|
def play_only(ctx):
|
|
"""--play, from the Steam shortcut (launcher/steam.py): the game starts from its folder with its console hidden, as
|
|
Play starts it, and this process waits until it and the copies its own Restart starts have ended, so Steam shows
|
|
the game as running all that time. None when there is no built game: the launcher's window opens instead."""
|
|
gd = ctx.settings.get('game_dir')
|
|
exe = Path(gd) / 'ocarina.exe' if gd else None
|
|
if not exe or not exe.is_file():
|
|
return None
|
|
if saves.game_running(gd):
|
|
ctx.log('play from Steam: the game is already running')
|
|
return 0
|
|
try:
|
|
for line in mods.prepare(gd, log=ctx.log):
|
|
pass
|
|
waiting = mods.code_waiting(gd) if build.can_rebuild(gd) else []
|
|
except OSError as e:
|
|
ctx.log(f'play from Steam: mods: {e}')
|
|
waiting = []
|
|
if waiting: # the window builds the mods' code in, then starts the game (App.start with --play)
|
|
ctx.log(f'play from Steam: the game is built again first for the code of {", ".join(waiting)}')
|
|
return None
|
|
try:
|
|
proc = winsys.start_game(exe)
|
|
except OSError as e:
|
|
ctx.log(f'play from Steam: {exe}: {e}')
|
|
return None
|
|
ctx.log(f'play from Steam: {exe}')
|
|
time.sleep(2)
|
|
while proc.poll() is None or saves.game_running(gd):
|
|
time.sleep(1)
|
|
ctx.log(f'play from Steam: the game ended (code {proc.returncode})')
|
|
return 0
|
|
|
|
|
|
def main(argv=None):
|
|
a = parse_args(sys.argv[1:] if argv is None else argv)
|
|
winsys.dpi_aware()
|
|
winsys.app_id()
|
|
from ..paths import Layout
|
|
ctx = Context(Layout(root=a.root) if a.root else None)
|
|
if a.play and not a.selftest:
|
|
code = play_only(ctx)
|
|
if code is not None:
|
|
return code
|
|
lock = None
|
|
if not a.selftest:
|
|
try:
|
|
lock = single_instance(ctx.layout, 20 if (a.continue_update or a.rolled_back) else 2)
|
|
except locks.Busy as e:
|
|
# A second window on the same folder would run its own jobs into the same game folder and settings. The
|
|
# first one may be out of sight (hidden while the game runs), so the player is told who holds the folder
|
|
ctx.log(f'not started: {e}')
|
|
tell(f'NX2navi is already running: {e}.\n\nIts window may be hidden while the game runs; it comes back '
|
|
'when you quit the game.')
|
|
return 0
|
|
try:
|
|
root = tk.Tk(className='NX2navi')
|
|
app = App(root, ctx, a)
|
|
if a.selftest:
|
|
from .selftest import run
|
|
root.after(300, lambda: run(app, a.shots))
|
|
root.mainloop()
|
|
finally:
|
|
if lock:
|
|
lock.release()
|
|
return 0
|