mirror of
https://github.com/tangerineturtle1/NX2navi.git
synced 2026-11-15 19:19:58 -04:00
The Steam shortcut's --play runs before the window's single-instance lock and checked only whether the game was running. Started while the window rebuilt the game, installed an update or built the mods' code, it ran mods.prepare into the folder the job was reading and started the old ocarina.exe, and the job then failed at the swap with "ocarina.exe is in use" (an update's install left the new launcher on the old build); during the shaders step, make_game.py's own run of the exe made the shortcut log "already running" and do nothing visible. play_only now tries the launcher folder's lock and lets go of it at once: when another process holds it and data/job-running.json names a job (jobs.running_job, which leaves the marker in place), it logs that the launcher is busy, says which job to wait for in a message box and does not start the game. A marker that a stopped launcher left does not stop it, since its lock went with its process, and neither does the window open without a job. Not reproduced before the change in a game run: found by reading the code. Checks that passed: tests/launcher_test.py (216 tests, a new one for a held lock with the job's marker, a leftover marker with the lock free, and the window open without a job, which fails before the change) and tools/check_content.py --staged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2948 lines
147 KiB
Python
2948 lines
147 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 collections
|
|
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 (runtime/dispatch.h RT_FATAL_EXIT) 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). A lost graphics device ends it
|
|
# with 72 (runtime/dispatch.h RT_LOST_EXIT, runtime/vk_device.c vk_device_lost_check), after a FATAL line too, unless
|
|
# the report before that line hangs: then its watchdog (vk_device.c lost_wait_exit) ends the run with 72 and no FATAL
|
|
# line, so 72's text stands without one. 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',
|
|
72: 'the graphics device was lost: the GPU or its driver stopped, and the vk: lines near the end of its log say '
|
|
'what it was doing; a graphics driver update may help',
|
|
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',
|
|
}
|
|
FATAL_CODES = (70, 71, 72) # the codes that follow a FATAL line of the game's own (EXIT_CODES' comment)
|
|
|
|
|
|
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), 71 (runtime/main.c crash_handler) or 72 (runtime/vk_device.c
|
|
vk_device_lost_check) 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 ''
|
|
|
|
|
|
# The lines the game writes when it ends on its own: the window's close (runtime/gfx_present.c gfx_window_closed), the
|
|
# game's own exit (runtime/svc.c, ExitProcess), a timed run's end (runtime/boot.c), a fatal error and the crash report's
|
|
# line for a fault on the way out (runtime/main.c).
|
|
OWN_EXIT_LINES = ('gfx: window closed', 'guest ExitProcess', 'boot: frames', 'FATAL: ', 'exit: ')
|
|
# The line a log stops with once it reached its size cap (runtime/main.c log_sink, LOG_CAP): the rest of that run,
|
|
# its end lines too, went to stderr only.
|
|
LOG_CUT_LINE = 'log: the file reached 64 MiB'
|
|
|
|
|
|
def log_has_own_exit(game_dir):
|
|
"""Whether the end of logs/nx2navi.log holds a line of the game's own end (OWN_EXIT_LINES) for its latest run; None
|
|
when the log cannot be read, or when it was cut at its size cap (LOG_CUT_LINE) and so cannot tell. A line this
|
|
launcher added for an earlier run's end (note_exit_in_log) ends the search."""
|
|
if not game_dir:
|
|
return None
|
|
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 None
|
|
for line in reversed(lines):
|
|
if line.startswith('[launcher] the game ended'):
|
|
return False
|
|
body = line.split('] ', 1)[1] if line.startswith('[') and '] ' in line else line
|
|
if body.startswith(OWN_EXIT_LINES):
|
|
return True
|
|
if body.startswith(LOG_CUT_LINE):
|
|
return None
|
|
return False
|
|
|
|
|
|
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 log_is_new(game_dir, started):
|
|
"""Whether logs/nx2navi.log was written at or after started (time.time() before the game was started). A run that
|
|
ends before it moves and opens its log (runtime/main.c log_open) leaves the log of a run before it in place. FAT
|
|
and exFAT drives keep a file's time to 2 s, hence the slack."""
|
|
if not game_dir or started is None:
|
|
return False
|
|
try:
|
|
return (Path(game_dir) / 'logs' / 'nx2navi.log').stat().st_mtime >= started - 2
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
LOG_WHERE = 'Its log is logs\\nx2navi.log in the game folder.'
|
|
GameEnd = collections.namedtuple('GameEnd', 'log message note alarm')
|
|
|
|
|
|
def shown_fatal(fatal):
|
|
return fatal if len(fatal) <= FATAL_SHOWN else fatal[:FATAL_SHOWN].rstrip() + '…'
|
|
|
|
|
|
def game_end(game_dir, code, started=None, restarted=False):
|
|
"""What the launcher says about an end of the game (App.watch_game, play_only): a GameEnd of the line for
|
|
launcher.log, the sentence for the player ('' for none), the text note_exit_in_log adds to the game's log ('' for
|
|
none), and alarm: whether the end is worth a message box where no window shows the sentence (a FATAL line, a code
|
|
of FATAL_CODES or a Windows status; not 0, nor 1, which Task Manager's End task gives). code is the exit code of
|
|
the run's last process, or None when it is not known, which happens only for a copy the game's own Restart started
|
|
that could not be followed (GameRun). started is time.time() before the game was started, restarted whether it
|
|
restarted itself on the way."""
|
|
how = ' after restarting itself' if restarted else ''
|
|
if code not in (0, None):
|
|
code &= 0xFFFFFFFF
|
|
text = exit_code_text(code)
|
|
# 70, 71 and 72 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(game_dir) if code in FATAL_CODES else ''
|
|
if fatal:
|
|
return GameEnd(f'the game ended with code {code}{how}; its log says: FATAL: {fatal}',
|
|
f'The game ended with a fatal error (code {code}): {shown_fatal(fatal)}. {LOG_WHERE}',
|
|
text, True)
|
|
return GameEnd(f'the game ended with code {text}{how}', f'The game ended with code {text}. {LOG_WHERE}', text,
|
|
code in FATAL_CODES or code >= 0x80000000)
|
|
# No process of the game writes a FATAL line and exits with 0 (runtime/dispatch.c fatal_end, runtime/main.c
|
|
# crash_handler). After code 0, or a code not known, a FATAL line in a log written since the start is a copy's that
|
|
# the game's own Restart started (runtime/gfx_present.c gfx_window_closed): one that ended before the launcher saw
|
|
# it run, or one it could not follow. That copy's log is logs/nx2navi.log and holds the line already: no note.
|
|
fatal = last_fatal(game_dir) if log_is_new(game_dir, started) else ''
|
|
if fatal:
|
|
return GameEnd(f'the game ended after restarting itself; its log says: FATAL: {fatal}',
|
|
f"A copy started by the game's Restart ended with a fatal error: {shown_fatal(fatal)}. "
|
|
f'{LOG_WHERE}', '', True)
|
|
# Every end of a run the launcher started is logged, and when the game's log has no line of its own end, the end
|
|
# goes into that log: a session of 2026-11-13 stopped in play with code 0, no FATAL line and none of the game's exit
|
|
# lines, and nothing said how it had ended.
|
|
own = log_has_own_exit(game_dir)
|
|
if code is None:
|
|
return GameEnd('the game ended after restarting itself; the exit code of its last run is not known', '',
|
|
"unknown (the game had restarted itself; the log has no line of the game's own end before "
|
|
'this one)' if own is False else '', False)
|
|
if own is False:
|
|
return GameEnd(f'the game ended with code 0{how}, and its log has no line of its own end', '',
|
|
"0 (the log has no line of the game's own end before this one)", False)
|
|
return GameEnd(f'the game ended with code 0{how}', '', '', False)
|
|
|
|
|
|
class GameRun:
|
|
"""A Play of the game, followed through the copies its own Restart starts. The Restart (runtime/gfx_present.c
|
|
gfx_window_closed) starts the exe again (runtime/platform/win32.c plat_restart) and ends the process before it
|
|
with code 0, so the code the game really ended with is its last copy's: once the process followed has ended with
|
|
0 while the exe still runs, the copy is looked for (winsys.game_copy) and followed from then on. proc is the
|
|
process followed, None once a copy could not be found (it ended before the next look, or the system did not show
|
|
it); the end's code is then not known. started is time.time() before the start (game_end's log_is_new)."""
|
|
|
|
def __init__(self, proc, game_dir, started):
|
|
self.proc, self.game_dir, self.started, self.restarted = proc, game_dir, started, False
|
|
|
|
def alive(self):
|
|
"""Whether the process followed runs; nothing else is looked at. A winsys.GameCopy whose exit code could not
|
|
be read has ended all the same."""
|
|
return self.proc is not None and self.proc.poll() is None and getattr(self.proc, 'ended', False) is not True
|
|
|
|
def running(self):
|
|
"""Whether the game still runs, this launcher's process or another of the same exe (build.game_running);
|
|
asked once a second until it says False."""
|
|
if self.alive():
|
|
return True
|
|
if not (self.game_dir and saves.game_running(self.game_dir)):
|
|
return False
|
|
if self.proc is not None and self.proc.returncode == 0:
|
|
self.restarted = True
|
|
self.proc = winsys.game_copy(Path(self.game_dir) / 'ocarina.exe', skip=self.proc.pid)
|
|
return True
|
|
|
|
def end(self):
|
|
"""game_end for this run, once running() said False."""
|
|
return game_end(self.game_dir, None if self.proc is None else self.proc.returncode, self.started,
|
|
self.restarted)
|
|
|
|
|
|
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_run = None # the Play being followed (GameRun), until watch_game sees its end
|
|
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_run and self.game_run.alive()) 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_run and self.game_run.alive()) 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_run and self.game_run.alive():
|
|
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')
|
|
started = time.time()
|
|
try:
|
|
self.game_run = GameRun(winsys.start_game(exe), gd, started)
|
|
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):
|
|
"""Once a second while the game Play started runs, its Restart's copies too (GameRun.running); then the window
|
|
comes back and the end goes into launcher.log, into the game's log when that has no line of it, and on home
|
|
when there is something to tell (game_end)."""
|
|
run = self.game_run
|
|
if run and run.running():
|
|
self.root.after(1000, self.watch_game)
|
|
return
|
|
self.game_run = None
|
|
self.root.deiconify()
|
|
self.root.lift()
|
|
if run:
|
|
end = run.end()
|
|
self.ctx.log(end.log)
|
|
if end.note:
|
|
note_exit_in_log(run.game_dir, end.note)
|
|
if end.message:
|
|
self.message = end.message
|
|
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_run and self.game_run.alive())
|
|
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_run and self.game_run.alive())
|
|
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 = []
|
|
with j.locked(): # each step as one change of the worker left it, not a state with the value before it
|
|
steps = [(st.id, st.label, st.state, st.value, st.fraction) for st in j.steps]
|
|
for sid, label, state, value, fraction in steps:
|
|
if state == jobs.RUNNING:
|
|
rows.append(Row('progress', label, fraction=fraction, value=f'{int(fraction * 100)}%',
|
|
focus_value=True, desc=None))
|
|
else:
|
|
style = {jobs.DONE: 'normal', jobs.SKIPPED: 'dim', jobs.PENDING: 'dim', jobs.FAILED: 'salmon'}[state]
|
|
rows.append(Row('value', label, value, style, check=state == jobs.DONE, desc=None,
|
|
focus_value=state == jobs.FAILED))
|
|
if sid in ('romfs', 'verify') and sid != steps[-1][0]:
|
|
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.last_lines(200) 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 += j.last_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_run and self.game_run.alive()):
|
|
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
|
|
# The window or a command may run a job in the game folder meanwhile (a rebuild, an update's install, the mods'
|
|
# build): the game started now would be the build that job is replacing, which then fails to replace it, and
|
|
# mods.prepare would unpack into the folder the job reads. Such a job holds the launcher folder's lock and leaves
|
|
# jobs.MARKER. The lock is only tried, and let go at once: held, it would keep the window from opening.
|
|
try:
|
|
locks.FileLock(ctx.layout.instance_lock, 'play from Steam', where=ctx.layout.root).release()
|
|
except locks.Busy:
|
|
job = jobs.running_job(ctx)
|
|
if job:
|
|
ctx.log(f'play from Steam: not started: the launcher is busy with {job["task"]}')
|
|
tell(f'NX2navi is busy with {job["task"]}. Start the game when it is done.')
|
|
return 0
|
|
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
|
|
started = time.time()
|
|
try:
|
|
run = GameRun(winsys.start_game(exe), gd, started)
|
|
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 run.running():
|
|
time.sleep(1)
|
|
# The end is told as Play's is (App.watch_game), with no window: a box for an end worth one. The logs come first,
|
|
# since the box waits for OK and Steam may end this process meanwhile.
|
|
end = run.end()
|
|
ctx.log(f'play from Steam: {end.log}')
|
|
if end.note:
|
|
note_exit_in_log(gd, end.note)
|
|
if end.alarm:
|
|
tell(end.message)
|
|
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
|