100 lines
4.4 KiB
Python
100 lines
4.4 KiB
Python
"""Shared navigation helpers (home, menu, popups, back/escape)."""
|
|
|
|
# The mailbox/cafe/shop-style header bar renders a plain light background
|
|
# here; the home screen shows character art instead.
|
|
SUBSCREEN_HEADER_PROBE = (500, 10)
|
|
SUBSCREEN_HEADER_MIN_CHANNEL = 200
|
|
|
|
# Any modal dialog dims the screen behind it to roughly this darkness.
|
|
MODAL_DIM_PROBE = (960, 200)
|
|
MODAL_DIM_MAX_CHANNEL = 150
|
|
|
|
# Shared top-left back-arrow position -- every subscreen calibrated so far
|
|
# (mailbox, cafe, shop, lesson, event) puts its own back button here
|
|
# (confirmed identical across config.py's LESSON_BACK_BUTTON/
|
|
# SHOP_BACK_BUTTON/EVENT_BACK_BUTTON). Used by return_to_home below as a
|
|
# generic recovery affordance, independent of which task got stuck.
|
|
BACK_BUTTON = (85, 55)
|
|
RETURN_HOME_MAX_ROUNDS = 6
|
|
|
|
|
|
def is_on_subscreen(driver):
|
|
r, g, b = driver.color_at(*SUBSCREEN_HEADER_PROBE)
|
|
return r > SUBSCREEN_HEADER_MIN_CHANNEL and g > SUBSCREEN_HEADER_MIN_CHANNEL and b > SUBSCREEN_HEADER_MIN_CHANNEL
|
|
|
|
|
|
def is_modal_open(driver):
|
|
r, g, b = driver.color_at(*MODAL_DIM_PROBE)
|
|
return r < MODAL_DIM_MAX_CHANNEL and g < MODAL_DIM_MAX_CHANNEL and b < MODAL_DIM_MAX_CHANNEL
|
|
|
|
|
|
def return_to_home(driver):
|
|
"""Bounded "press back until the home screen is reached" loop -- the
|
|
shared recovery path for any task that ends up on an unexpected or wrong
|
|
subscreen (e.g. event_sweep landing on a stale/finished event's page
|
|
instead of the current one, see plan.md's Event sweep phase). Generic
|
|
across tasks: it only depends on is_on_subscreen and the shared
|
|
BACK_BUTTON position above, not on any task-specific state.
|
|
|
|
Checks is_on_subscreen before every single press and stops the instant
|
|
it reads False -- never presses Escape/clicks back while already on the
|
|
home screen. This matters: CLAUDE.md documents a real hazard where a
|
|
blind Escape press on the home screen itself raises Blue Archive's own
|
|
"exit the game?" confirmation, which is exactly the failure mode a
|
|
naive fixed-count blind-press loop could cause here.
|
|
|
|
Tries Escape first each round (works for most subscreens, confirmed for
|
|
mailbox/cafe/event's own stage modal) and falls back to clicking
|
|
BACK_BUTTON if Escape didn't clear it, re-checking after each.
|
|
|
|
Returns True once home is confirmed reached, False if still not home
|
|
after RETURN_HOME_MAX_ROUNDS -- callers should treat False as "abort,
|
|
don't guess further" rather than assume home was reached.
|
|
"""
|
|
for _ in range(RETURN_HOME_MAX_ROUNDS):
|
|
if not is_on_subscreen(driver):
|
|
return True
|
|
driver.keypress("Escape")
|
|
driver.wait(1)
|
|
if not is_on_subscreen(driver):
|
|
return True
|
|
driver.click(*BACK_BUTTON)
|
|
driver.wait(1)
|
|
return not is_on_subscreen(driver)
|
|
|
|
|
|
def wait_for_state(driver, config, reactions, ends, max_iterations=30, poll_interval=1.0):
|
|
"""Generic "watch the screen, react to anything recognized, stop once a
|
|
recognized destination is reached" loop -- the local equivalent of the
|
|
reference's core/picture.py::co_detect, scoped to what this project
|
|
actually needs (a handful of named checks) rather than co_detect's full
|
|
generality (which spans the whole reference project via ~20 image
|
|
template assets this project doesn't have).
|
|
|
|
`ends`: {check_fn(driver, config) -> bool: outcome_name}. Checked first,
|
|
every iteration; the first match stops the loop and returns its name.
|
|
|
|
`reactions`: {check_fn(driver, config) -> bool: action(driver)}. Checked
|
|
if no end matched; the first match runs its action (a click, a keypress,
|
|
whatever the recognized state calls for) and the loop continues.
|
|
|
|
If neither an end nor a reaction matches, the loop just waits and retries
|
|
-- it never falls back to a blind click/keypress guess (see CLAUDE.md's
|
|
exit-game-dialog writeup for why that was a real bug elsewhere).
|
|
|
|
Returns the matched end's outcome name, or None once max_iterations is
|
|
exhausted without reaching a recognized end -- callers should treat None
|
|
as "unrecognized state, abort safely."
|
|
"""
|
|
for _ in range(max_iterations):
|
|
for check_fn, outcome_name in ends.items():
|
|
if check_fn(driver, config):
|
|
return outcome_name
|
|
for check_fn, action in reactions.items():
|
|
if check_fn(driver, config):
|
|
action(driver)
|
|
break
|
|
else:
|
|
driver.wait(poll_interval)
|
|
return None
|