diff --git a/src/main.ts b/src/main.ts index 11af7aa..1908e72 100644 --- a/src/main.ts +++ b/src/main.ts @@ -143,6 +143,7 @@ function showChoice(choice: Choice | undefined) { const key = choice ? JSON.stringify(choice) : ""; if (container.dataset.choice === key) return; container.dataset.choice = key; + container.dataset.kind = choice?.kind ?? ""; const button = ( label: string, pressed: boolean | undefined, @@ -294,6 +295,10 @@ function showChoice(choice: Choice | undefined) { list, button("CANCEL", undefined, () => runtime.choose(-1)), ); + // On a small screen the box scrolls; keep the highlighted entry in view. + list + .querySelector('[aria-selected="true"]') + ?.scrollIntoView({ block: "nearest" }); } } /** The naming screen as a text field and OK: an empty name keeps the @@ -627,6 +632,42 @@ function battleButtons() { ), ]; } +/** The enabled button next to `from` in the arrow's direction as drawn, so + * the cursor always follows the layout: a column moves with up and down, a + * row with left and right, and a grid with all four. */ +function adjacentButton( + buttons: HTMLButtonElement[], + from: number, + arrow: "up" | "down" | "left" | "right", +): number | undefined { + const origin = buttons[from]?.getBoundingClientRect(); + if (!origin) return undefined; + const vertical = arrow === "up" || arrow === "down"; + let best: number | undefined; + let bestScore = Infinity; + buttons.forEach((candidate, index) => { + if (index === from) return; + const box = candidate.getBoundingClientRect(); + const gap = + arrow === "up" + ? origin.top - box.bottom + : arrow === "down" + ? box.top - origin.bottom + : arrow === "left" + ? origin.left - box.right + : box.left - origin.right; + if (gap < -1) return; + const offset = vertical + ? Math.abs(box.left + box.width / 2 - (origin.left + origin.width / 2)) + : Math.abs(box.top + box.height / 2 - (origin.top + origin.height / 2)); + const score = Math.max(gap, 0) + offset * 2; + if (score < bestScore) { + bestScore = score; + best = index; + } + }); + return best; +} function highlightBattleChoice() { battleButtons().forEach((button, index) => { button.classList.toggle("battle-selected", index === battleCursor); @@ -664,9 +705,14 @@ function handleBattleInput(button: Button): boolean { buttons[battleCursor]?.click(); return true; } - if (["up", "down", "left", "right"].includes(button)) { - const delta = button === "up" || button === "left" ? -1 : 1; - battleCursor = (battleCursor + delta + buttons.length) % buttons.length; + if ( + button === "up" || + button === "down" || + button === "left" || + button === "right" + ) { + battleCursor = + adjacentButton(buttons, battleCursor, button) ?? battleCursor; if (yesNoPrompt(world.battle.prompt)) world.battle.selected = battleCursor === 0 ? 1 : 0; highlightBattleChoice(); @@ -1012,6 +1058,13 @@ function showBattle(world: GameWorld) { button("Continue ▶", () => runtime.advanceDialogue(), { wide: true }), ); } + // FIGHT/PKMN/PACK/RUN is the original's 2x2 grid; every other choice is a + // vertical list, moved with Up and Down as it is drawn. + const grid = + (prompt?.kind === "action" && !showMoves && !showPack) || + prompt?.kind === "stats"; + menu.dataset.layout = grid ? "grid" : "list"; + menu.dataset.kind = prompt?.kind ?? ""; menu.replaceChildren(...buttons); battleCursor = Math.min( battleCursor, diff --git a/src/ui/styles.css b/src/ui/styles.css index 9367102..e4af908 100644 --- a/src/ui/styles.css +++ b/src/ui/styles.css @@ -338,12 +338,18 @@ dialog h2 { gap: 6px; margin-top: 6px; } +/* The original's YES/NO box is vertical, YES above NO, moved with Up/Down. */ +.native-dialogue > .dialogue-choice[data-kind="yesno"] { + flex-direction: column; + flex-wrap: nowrap; + align-items: flex-end; +} +/* Every entry shows (the bedroom PC has six). The dialogue box grows upward + and scrolls only when the screen is too small. */ .dialogue-menu { display: flex; flex-direction: column; gap: 4px; - max-height: 9.5em; - overflow-y: auto; } .dialogue-choice .mail-lines { display: grid; @@ -452,6 +458,16 @@ dialog h2 { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; } +/* Everything but the 2x2 battle menu is a vertical list, as in the original: + YES above NO, one move or item per row. */ +.battle-menu[data-layout="list"] { + grid-template-columns: minmax(0, 1fr); +} +.battle-menu[data-kind="yesno"] button, +.battle-menu[data-kind="contestswitch"] button:not(.wide) { + justify-self: end; + min-width: 6em; +} .battle-menu button { padding: 6px 10px; text-align: left; diff --git a/tests/battle-browser.spec.ts b/tests/battle-browser.spec.ts index 71b4c9c..d440853 100644 --- a/tests/battle-browser.spec.ts +++ b/tests/battle-browser.spec.ts @@ -126,10 +126,13 @@ test("keyboard and touch D-pad navigate battle moves, cancel and run", async ({ await page.keyboard.press("KeyZ"); await expect(menu.getByRole("button", { name: "FIGHT", exact: true })).toBeVisible(); // A real pointer press on the on-screen D-pad (synthetic events cannot be captured). + // The menu is the original's 2x2 grid: FIGHT PKMN / PACK RUN. await page.locator('[data-game-button="down"]').click(); - await expect(menu.getByRole("button", { name: "PKMN", exact: true })).toHaveClass(/battle-selected/); - await page.keyboard.press("ArrowDown"); await expect(menu.getByRole("button", { name: "PACK", exact: true })).toHaveClass(/battle-selected/); + await page.keyboard.press("ArrowRight"); + await expect(menu.getByRole("button", { name: "RUN", exact: true })).toHaveClass(/battle-selected/); + await page.keyboard.press("ArrowUp"); + await expect(menu.getByRole("button", { name: "PKMN", exact: true })).toHaveClass(/battle-selected/); await page.keyboard.press("ArrowDown"); await expect(menu.getByRole("button", { name: "RUN", exact: true })).toHaveClass(/battle-selected/); await page.keyboard.press("KeyX");