Update Healer Man server
This commit is contained in:
1 parent
cc3b2fa4d5
commit
daa417ec17
46 files changed
+1334
-172
No files matched your search
+16
-11
@@ -1,38 +1,43 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { buildBossGuideCatalog } from "../game/bossGuide";
|
||||
import { BOSS_GUIDE_OVERRIDES } from "../game/bossGuideOverrides";
|
||||
import { DUNGEON_DEFINITIONS } from "../game/dungeonRegistry";
|
||||
import { DUNGEON_DEFINITIONS, dungeonDefinitionById } from "../game/dungeonRegistry";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { useMenuController, type MenuAction } from "../input/useMenuController";
|
||||
import { ControllerButton } from "./ControllerButton";
|
||||
|
||||
const GUIDE_CATALOG = buildBossGuideCatalog(DUNGEON_DEFINITIONS, BOSS_GUIDE_OVERRIDES);
|
||||
|
||||
function BossGuideDialog() {
|
||||
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
|
||||
const sessionRevision = useGameStore((state) => state.sessionRevision);
|
||||
const bossTest = useGameStore((state) => state.bossTest);
|
||||
const openOverlay = useGameStore((state) => state.openOverlay);
|
||||
const initialDungeon = GUIDE_CATALOG.find((dungeon) => dungeon.id === (bossTest?.sourceDungeonId ?? activeDungeonId)) ?? GUIDE_CATALOG[0];
|
||||
const guideCatalog = useMemo(() => buildBossGuideCatalog(
|
||||
DUNGEON_DEFINITIONS.map((definition) => (
|
||||
definition.id === activeDungeonId ? dungeonDefinitionById(activeDungeonId) ?? definition : definition
|
||||
)),
|
||||
BOSS_GUIDE_OVERRIDES,
|
||||
), [activeDungeonId, sessionRevision]);
|
||||
const initialDungeon = guideCatalog.find((dungeon) => dungeon.id === (bossTest?.sourceDungeonId ?? activeDungeonId)) ?? guideCatalog[0];
|
||||
const [selectedDungeonId, setSelectedDungeonId] = useState(initialDungeon?.id ?? "");
|
||||
const selectedDungeon = GUIDE_CATALOG.find((dungeon) => dungeon.id === selectedDungeonId) ?? initialDungeon;
|
||||
const selectedDungeon = guideCatalog.find((dungeon) => dungeon.id === selectedDungeonId) ?? initialDungeon;
|
||||
const [selectedBossId, setSelectedBossId] = useState(selectedDungeon?.bosses.find((boss) => boss.entityId === bossTest?.entityId)?.id ?? selectedDungeon?.bosses[0]?.id ?? "");
|
||||
const selectedBoss = selectedDungeon?.bosses.find((boss) => boss.id === selectedBossId)
|
||||
?? selectedDungeon?.bosses[0];
|
||||
const close = () => openOverlay("pause");
|
||||
|
||||
const selectDungeon = (dungeonId: string) => {
|
||||
const dungeon = GUIDE_CATALOG.find((candidate) => candidate.id === dungeonId);
|
||||
const dungeon = guideCatalog.find((candidate) => candidate.id === dungeonId);
|
||||
setSelectedDungeonId(dungeonId);
|
||||
setSelectedBossId(dungeon?.bosses[0]?.id ?? "");
|
||||
};
|
||||
|
||||
const actions = useMemo<MenuAction[]>(() => {
|
||||
const dungeonActions = GUIDE_CATALOG.map((dungeon, index): MenuAction => ({
|
||||
const dungeonActions = guideCatalog.map((dungeon, index): MenuAction => ({
|
||||
id: `boss-guide-dungeon:${dungeon.id}`,
|
||||
run: () => selectDungeon(dungeon.id),
|
||||
neighbors: {
|
||||
up: `boss-guide-dungeon:${GUIDE_CATALOG[(index - 1 + GUIDE_CATALOG.length) % GUIDE_CATALOG.length].id}`,
|
||||
down: `boss-guide-dungeon:${GUIDE_CATALOG[(index + 1) % GUIDE_CATALOG.length].id}`,
|
||||
up: `boss-guide-dungeon:${guideCatalog[(index - 1 + guideCatalog.length) % guideCatalog.length].id}`,
|
||||
down: `boss-guide-dungeon:${guideCatalog[(index + 1) % guideCatalog.length].id}`,
|
||||
...(dungeon.id === selectedDungeon?.id && dungeon.bosses.length
|
||||
? { right: `boss-guide-boss:${dungeon.bosses[0].id}` }
|
||||
: {}),
|
||||
@@ -49,7 +54,7 @@ function BossGuideDialog() {
|
||||
},
|
||||
}));
|
||||
return [...dungeonActions, ...bossActions];
|
||||
}, [selectedDungeon]);
|
||||
}, [guideCatalog, selectedDungeon]);
|
||||
const controller = useMenuController(actions, {
|
||||
initialId: `boss-guide-dungeon:${selectedDungeon?.id ?? ""}`,
|
||||
onBack: close,
|
||||
@@ -74,7 +79,7 @@ function BossGuideDialog() {
|
||||
<nav className="boss-guide-panel__dungeons" aria-label="Dungeons">
|
||||
<h3>Dungeons</h3>
|
||||
<div>
|
||||
{GUIDE_CATALOG.map((dungeon) => {
|
||||
{guideCatalog.map((dungeon) => {
|
||||
const controlId = `boss-guide-dungeon:${dungeon.id}`;
|
||||
return (
|
||||
<ControllerButton
|
||||
|
||||
@@ -36,6 +36,8 @@ import { useUiLayoutStore } from "../game/uiLayoutStore";
|
||||
import { useDpsMeterStore } from "../game/dpsMeterStore";
|
||||
import { currentDisplayLayout } from "../platform/displayLayout";
|
||||
import { DungeonCompanionHud } from "./DungeonCompanionHud";
|
||||
import { LoginVirtualKeyboard } from "./LoginVirtualKeyboard";
|
||||
import { useLoginInputStore } from "../app/loginInputStore";
|
||||
|
||||
function safePercent(current: number, maximum: number): number {
|
||||
if (!Number.isFinite(current) || !Number.isFinite(maximum) || maximum <= 0) return 0;
|
||||
@@ -161,21 +163,7 @@ export function CompanionPanel({ snapshot, send, preferControllerBinds = false }
|
||||
const active = snapshot.activeCharacter ?? selected;
|
||||
|
||||
if (snapshot.phase === "login") {
|
||||
return (
|
||||
<FrontSurface bottom className="companion-panel companion-login" ariaLabel="Healer Man companion login information">
|
||||
<header><BrandMark compact /><b>SECONDARY DISPLAY</b></header>
|
||||
<main>
|
||||
<p className="companion-kicker">Ready on both screens</p>
|
||||
<h2>One roster. Two ways to play.</h2>
|
||||
<div className="companion-feature-grid">
|
||||
<article><b>01</b><span><strong>Your roster</strong><small>Choose your adventurer after signing in</small></span></article>
|
||||
<article><b>02</b><span><strong>Offline play</strong><small>No sign-in or network required</small></span></article>
|
||||
<article><b>03</b><span><strong>PC fallback</strong><small>This panel opens as an overlay</small></span></article>
|
||||
</div>
|
||||
</main>
|
||||
<footer><span>Sign in on the top screen</span><b>THOR / 3.92 INCHES</b></footer>
|
||||
</FrontSurface>
|
||||
);
|
||||
return <LoginVirtualKeyboard snapshot={snapshot.login} notice={snapshot.notice} send={send} />;
|
||||
}
|
||||
|
||||
if (snapshot.phase === "characters") {
|
||||
@@ -526,6 +514,7 @@ export function CompanionPanel({ snapshot, send, preferControllerBinds = false }
|
||||
|
||||
export function LocalCompanionPanel() {
|
||||
const shell = useShellStore();
|
||||
useLoginInputStore();
|
||||
useGameStore();
|
||||
useCombatStore();
|
||||
usePartyStore();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import catalog from "../game/generated/dungeonLeaderboardCatalog.generated.json";
|
||||
import type { DungeonRunMember } from "../game/dungeonRunTypes";
|
||||
import { bossDifficultyLabel } from "../game/dungeonDifficulty";
|
||||
import { leaderboardFinalBossIds } from "../game/dungeonLeaderboardRules";
|
||||
|
||||
const className = (id: string) => catalog.classes.find((entry) => entry.id === id)?.name ?? id;
|
||||
|
||||
@@ -12,7 +13,9 @@ export function DungeonRunDetails({ dungeonId, difficultyId, bossDifficulty, par
|
||||
readonly killedBossIds: readonly string[];
|
||||
}) {
|
||||
const dungeon = catalog.dungeons.find((entry) => entry.id === dungeonId);
|
||||
const ids = dungeon?.difficulties.find((entry) => entry.id === difficultyId)?.bossIds ?? [];
|
||||
const difficulty = dungeon?.difficulties.find((entry) => entry.id === difficultyId);
|
||||
const variant = difficulty && leaderboardFinalBossIds(difficulty).length > 1;
|
||||
const ids = variant ? killedBossIds : difficulty?.bossIds ?? [];
|
||||
const bosses = dungeon?.bosses.filter((boss) => ids.includes(boss.id)) ?? [];
|
||||
return <div className="dungeon-run-details">
|
||||
<p>{bossDifficultyLabel(bossDifficulty)} · {difficultyId === "heroic" ? "Heroic" : "Normal"}</p>
|
||||
@@ -24,7 +27,7 @@ export function DungeonRunDetails({ dungeonId, difficultyId, bossDifficulty, par
|
||||
<small>{member.role === "damage" ? "Damage" : member.role === "healer" ? "Healer" : "Tank"}</small>
|
||||
</li>)}
|
||||
</ul>
|
||||
<h4>Bosses defeated · {killedBossIds.length}/{bosses.length}</h4>
|
||||
<h4>Bosses defeated · {Math.min(killedBossIds.length, bosses.length)}/{bosses.length}</h4>
|
||||
<ul className="dungeon-run-bosses">
|
||||
{bosses.map((boss) => <li key={boss.id} className={killedBossIds.includes(boss.id) ? "is-defeated" : ""}>
|
||||
<span aria-hidden="true">{killedBossIds.includes(boss.id) ? "✓" : "—"}</span>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useMenuController } from "../input/useMenuController";
|
||||
import { ControllerButton } from "./ControllerButton";
|
||||
import { DungeonRunDetails } from "./DungeonRunDetails";
|
||||
import "./dungeonRuns.css";
|
||||
import { isLeaderboardFullClear } from "../game/dungeonLeaderboardRules";
|
||||
|
||||
export interface RunPlacementState {
|
||||
readonly scope: LeaderboardScope;
|
||||
@@ -25,8 +26,8 @@ export function DungeonRunResultsDialog({ progress, ranking, onFinish, onLoot, o
|
||||
readonly onRetry: () => void;
|
||||
}) {
|
||||
const definition = catalog.dungeons.find((dungeon) => dungeon.id === progress.dungeonId);
|
||||
const fullClear = definition?.difficulties.find((entry) => entry.id === progress.difficultyId)?.bossIds
|
||||
.every((id) => progress.killedBossIds.includes(id)) ?? false;
|
||||
const difficulty = definition?.difficulties.find((entry) => entry.id === progress.difficultyId);
|
||||
const fullClear = difficulty ? isLeaderboardFullClear(difficulty, progress.killedBossIds) : false;
|
||||
const controller = useMenuController([
|
||||
{ id: "run-loot", run: onLoot }, { id: "run-finish", run: onFinish },
|
||||
...(ranking.scope === "device" ? [{ id: "run-retry", run: onRetry }] : []),
|
||||
|
||||
@@ -36,6 +36,7 @@ import { bossDifficultyLabel } from "../game/dungeonDifficulty";
|
||||
import { useCompanionPartyPreferences } from "../game/companionPartyPreferences";
|
||||
import { CompanionPoolControls } from "./CompanionPoolControls";
|
||||
import { CompanionReadinessScreen } from "./CompanionReadinessScreen";
|
||||
import { dungeonContentPackId } from "../game/dungeonContentSource";
|
||||
|
||||
installDungeonShellRuntime();
|
||||
|
||||
@@ -172,7 +173,7 @@ export function DungeonSelectScreen() {
|
||||
setDownloading(true);
|
||||
setDownloadError("");
|
||||
try {
|
||||
await installContentPack(`dungeon:${pendingDownload.dungeonId}`);
|
||||
await installContentPack(dungeonContentPackId(pendingDownload.dungeonId));
|
||||
if (!dungeonContentIsUsable(pendingDownload.dungeonId)) {
|
||||
throw new Error("The downloaded expedition could not be verified on this device.");
|
||||
}
|
||||
|
||||
+73
-42
@@ -1,7 +1,8 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import {
|
||||
createOfflineSession,
|
||||
} from "../app/accountRepository";
|
||||
import { useLoginInputStore, type LoginKeyboardDirection } from "../app/loginInputStore";
|
||||
import {
|
||||
createOnlineAccount,
|
||||
hydrateOnlineSession,
|
||||
@@ -9,22 +10,34 @@ import {
|
||||
} from "../app/onlineAccountClient";
|
||||
import { useShellStore } from "../app/shellStore";
|
||||
import { DualDisplayFrame } from "../components/DualDisplayFrame";
|
||||
import { useMenuController, type MenuAction } from "../input/useMenuController";
|
||||
import { getControllerHeldTokens, subscribeControllerToken } from "../input/controller";
|
||||
import { INPUT_ACTIONS, controllerBindingMatches } from "../input/keybinds";
|
||||
import { getInputPreferences, isCapturingInput } from "../input/keybindRuntime";
|
||||
import { ControllerButton } from "./ControllerButton";
|
||||
import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
|
||||
import { ControllerLegend, FrontSurface } from "./FrontSurface";
|
||||
import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel";
|
||||
|
||||
export function LoginScreen() {
|
||||
const beginSession = useShellStore((state) => state.beginSession);
|
||||
const notice = useShellStore((state) => state.notice);
|
||||
const setNotice = useShellStore((state) => state.setNotice);
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [busy, setBusy] = useState<"login" | "create" | null>(null);
|
||||
const usernameRef = useRef<HTMLInputElement>(null);
|
||||
const passwordRef = useRef<HTMLInputElement>(null);
|
||||
const username = useLoginInputStore((state) => state.username);
|
||||
const password = useLoginInputStore((state) => state.password);
|
||||
const activeField = useLoginInputStore((state) => state.activeField);
|
||||
const selectedKeyId = useLoginInputStore((state) => state.selectedKeyId);
|
||||
const busy = useLoginInputStore((state) => state.busy);
|
||||
const request = useLoginInputStore((state) => state.request);
|
||||
const setUsername = useLoginInputStore((state) => state.setUsername);
|
||||
const setPassword = useLoginInputStore((state) => state.setPassword);
|
||||
const setActiveField = useLoginInputStore((state) => state.setActiveField);
|
||||
const selectKey = useLoginInputStore((state) => state.selectKey);
|
||||
const moveSelection = useLoginInputStore((state) => state.moveSelection);
|
||||
const pressSelected = useLoginInputStore((state) => state.pressSelected);
|
||||
const consumeRequest = useLoginInputStore((state) => state.consumeRequest);
|
||||
const setBusy = useLoginInputStore((state) => state.setBusy);
|
||||
const resetLoginInput = useLoginInputStore((state) => state.reset);
|
||||
|
||||
const logIn = async () => {
|
||||
const logIn = useCallback(async () => {
|
||||
if (busy) return;
|
||||
setBusy("login");
|
||||
setNotice("Contacting the online server...");
|
||||
@@ -33,15 +46,16 @@ export function LoginScreen() {
|
||||
try {
|
||||
setNotice("Synchronizing your cloud roster...");
|
||||
await hydrateOnlineSession(result.session);
|
||||
resetLoginInput();
|
||||
beginSession(result.session);
|
||||
} catch (error) {
|
||||
setNotice(error instanceof Error ? error.message : "Cloud roster could not be loaded.");
|
||||
}
|
||||
} else setNotice(result.error ?? "Login failed.");
|
||||
setBusy(null);
|
||||
};
|
||||
}, [beginSession, busy, password, resetLoginInput, setBusy, setNotice, username]);
|
||||
|
||||
const createAccount = async () => {
|
||||
const createAccount = useCallback(async () => {
|
||||
if (busy) return;
|
||||
setBusy("create");
|
||||
setNotice("Creating your online account...");
|
||||
@@ -49,47 +63,63 @@ export function LoginScreen() {
|
||||
if (result.ok && result.session) {
|
||||
try {
|
||||
await hydrateOnlineSession(result.session);
|
||||
resetLoginInput();
|
||||
beginSession(result.session);
|
||||
} catch (error) {
|
||||
setNotice(error instanceof Error ? error.message : "Cloud roster could not be initialized.");
|
||||
}
|
||||
} else setNotice(result.error ?? "Account could not be created.");
|
||||
setBusy(null);
|
||||
};
|
||||
}, [beginSession, busy, password, resetLoginInput, setBusy, setNotice, username]);
|
||||
|
||||
const playOffline = () => beginSession(createOfflineSession());
|
||||
const actions = useMemo<MenuAction[]>(() => [
|
||||
{ id: "username", run: () => usernameRef.current?.focus() },
|
||||
{ id: "password", run: () => passwordRef.current?.focus() },
|
||||
{ id: "login", run: () => { void logIn(); }, enabled: !busy },
|
||||
{ id: "create-account", run: () => { void createAccount(); }, enabled: !busy },
|
||||
{ id: "offline", run: playOffline, enabled: !busy },
|
||||
], [busy, password, username]);
|
||||
const controller = useMenuController(actions);
|
||||
const playOffline = useCallback(() => {
|
||||
resetLoginInput();
|
||||
beginSession(createOfflineSession());
|
||||
}, [beginSession, resetLoginInput]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!request || busy) return;
|
||||
consumeRequest(request.id);
|
||||
if (request.action === "login") void logIn();
|
||||
else if (request.action === "create") void createAccount();
|
||||
else playOffline();
|
||||
}, [busy, consumeRequest, createAccount, logIn, playOffline, request]);
|
||||
|
||||
useEffect(() => subscribeControllerToken(({ token, repeat, pressed }) => {
|
||||
if (pressed === false || isCapturingInput()) return;
|
||||
const bindings = getInputPreferences().controller;
|
||||
const command = INPUT_ACTIONS
|
||||
.filter((entry) => entry.context === "menu" && controllerBindingMatches(bindings[entry.id], token, getControllerHeldTokens()))
|
||||
.sort((left, right) => (bindings[right.id]?.length ?? 0) - (bindings[left.id]?.length ?? 0))[0]
|
||||
?.id.replace(/-alt$/, "");
|
||||
const direction = ["menu-up", "menu-down", "menu-left", "menu-right"].includes(command ?? "")
|
||||
? command!.slice(5) as LoginKeyboardDirection
|
||||
: null;
|
||||
if (direction) moveSelection(direction);
|
||||
else if (!repeat && command === "menu-confirm") pressSelected();
|
||||
}), [moveSelection, pressSelected]);
|
||||
|
||||
return (
|
||||
<DualDisplayFrame
|
||||
contextLabel="Roster info"
|
||||
contextLabel="Controller keyboard"
|
||||
top={
|
||||
<FrontSurface className="login-screen" ariaLabel="Healer Man login">
|
||||
<BrandMark />
|
||||
<div className="login-hero">
|
||||
<div className="login-rune" aria-hidden="true"><i /><span>+</span><i /></div>
|
||||
<p className="front-kicker">Your next expedition awaits</p>
|
||||
<h1>The party needs<br /><em>a healer.</em></h1>
|
||||
<p>Sign in to your cloud roster, create an online account, or head in completely offline.</p>
|
||||
<div className="login-pill-row"><span>Controller ready</span><span>Thor dual-screen</span><span>PC compatible</span></div>
|
||||
</div>
|
||||
<header className="login-title">
|
||||
<span className="login-title__crest" aria-hidden="true"><i>+</i></span>
|
||||
<p>Answer the call</p>
|
||||
<h1>Healer <em>Man</em></h1>
|
||||
<small>Your next expedition awaits</small>
|
||||
</header>
|
||||
<form className="login-form" onSubmit={(event) => { event.preventDefault(); void logIn(); }}>
|
||||
<header><span>Account gate</span><strong>Begin your expedition</strong><small>Online accounts synchronize characters through iwanttoheal.phenomrom.com.</small></header>
|
||||
<div className="login-form__ornament" aria-hidden="true"><i /><span>+</span><i /></div>
|
||||
<header><span>Account gateway</span><strong>Enter the realm</strong><small>Continue with your cloud roster or answer the call offline.</small></header>
|
||||
<label htmlFor="login-username">Username</label>
|
||||
<input
|
||||
ref={usernameRef}
|
||||
id="login-username"
|
||||
className={controller.isSelected("username") ? "is-controller-selected" : ""}
|
||||
className={activeField === "username" ? "is-login-field-active" : ""}
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
onFocus={() => controller.select("username")}
|
||||
onFocus={() => setActiveField("username")}
|
||||
autoComplete="username"
|
||||
inputMode="text"
|
||||
maxLength={20}
|
||||
@@ -97,30 +127,31 @@ export function LoginScreen() {
|
||||
/>
|
||||
<label htmlFor="login-password">Password</label>
|
||||
<input
|
||||
ref={passwordRef}
|
||||
id="login-password"
|
||||
className={controller.isSelected("password") ? "is-controller-selected" : ""}
|
||||
className={activeField === "password" ? "is-login-field-active" : ""}
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
onFocus={() => controller.select("password")}
|
||||
onFocus={() => setActiveField("password")}
|
||||
autoComplete="current-password"
|
||||
maxLength={128}
|
||||
placeholder="8 characters minimum"
|
||||
/>
|
||||
<div className="login-form__actions">
|
||||
<ControllerButton controlId="login" selectedId={controller.selectedId} select={controller.select} className="front-button front-button--primary" type="submit" disabled={Boolean(busy)}>
|
||||
<span>{busy === "login" ? "Checking..." : "Login"}</span><small>Load your cloud roster</small>
|
||||
<ControllerButton controlId="action-login" selectedId={selectedKeyId} select={selectKey} className="front-button front-button--primary" type="submit" disabled={Boolean(busy)}>
|
||||
<span>{busy === "login" ? "Opening gateway..." : "Enter world"}</span><small>Load your cloud roster</small>
|
||||
</ControllerButton>
|
||||
<ControllerButton controlId="create-account" selectedId={controller.selectedId} select={controller.select} className="front-button" type="button" onClick={() => { void createAccount(); }} disabled={Boolean(busy)}>
|
||||
<span>{busy === "create" ? "Creating..." : "Create account"}</span><small>Start a synchronized roster</small>
|
||||
<ControllerButton controlId="action-create" selectedId={selectedKeyId} select={selectKey} className="front-button" type="button" onClick={() => { void createAccount(); }} disabled={Boolean(busy)}>
|
||||
<span>{busy === "create" ? "Forging account..." : "Create account"}</span><small>Start a synchronized roster</small>
|
||||
</ControllerButton>
|
||||
<ControllerButton controlId="offline" selectedId={controller.selectedId} select={controller.select} className="front-button front-button--quiet" type="button" onClick={playOffline} disabled={Boolean(busy)}>
|
||||
<ControllerButton controlId="action-offline" selectedId={selectedKeyId} select={selectKey} className="front-button front-button--quiet" type="button" onClick={playOffline} disabled={Boolean(busy)}>
|
||||
<span>Play offline</span><small>Skip account login</small>
|
||||
</ControllerButton>
|
||||
</div>
|
||||
{notice && <p className="front-notice" role="status" aria-live="polite">{notice}</p>}
|
||||
<footer><span><i /> Online roster available</span><small>Secure account gateway</small></footer>
|
||||
</form>
|
||||
<p className="login-lore">Gather your party. Keep them standing.</p>
|
||||
<ControllerLegend />
|
||||
</FrontSurface>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import {
|
||||
loginKeyboardRows,
|
||||
type LoginField,
|
||||
type LoginInputSnapshot,
|
||||
type LoginKeyDefinition,
|
||||
} from "../app/loginInputStore";
|
||||
import type { CompanionCommand } from "../platform/dualScreenSync";
|
||||
import { BrandMark, FrontSurface } from "./FrontSurface";
|
||||
|
||||
function passwordPreview(length: number): string {
|
||||
if (!length) return "Password";
|
||||
return `${"•".repeat(Math.min(length, 18))}${length > 18 ? "…" : ""}`;
|
||||
}
|
||||
|
||||
function keyLabel(key: LoginKeyDefinition, activeField: LoginField): string {
|
||||
if (key.kind === "field") return activeField === "username" ? "Password" : "Username";
|
||||
return key.label;
|
||||
}
|
||||
|
||||
export function LoginVirtualKeyboard({ snapshot, notice, send }: {
|
||||
snapshot: LoginInputSnapshot;
|
||||
notice: string;
|
||||
send: (command: CompanionCommand) => void;
|
||||
}) {
|
||||
const rows = loginKeyboardRows(snapshot.keyboardMode);
|
||||
const chooseField = (field: LoginField) => send({ name: "selectLoginField", field });
|
||||
|
||||
return (
|
||||
<FrontSurface bottom className="companion-panel companion-login companion-login-keyboard" ariaLabel="Controller login keyboard">
|
||||
<header><BrandMark compact /><b>CONTROLLER KEYBOARD</b></header>
|
||||
<main>
|
||||
<div className="login-keyboard-fields" role="group" aria-label="Login fields">
|
||||
<button
|
||||
type="button"
|
||||
className={snapshot.activeField === "username" ? "is-active" : ""}
|
||||
aria-pressed={snapshot.activeField === "username"}
|
||||
onClick={() => chooseField("username")}
|
||||
>
|
||||
<small>Username</small><strong>{snapshot.username || "Select to enter"}</strong>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={snapshot.activeField === "password" ? "is-active" : ""}
|
||||
aria-pressed={snapshot.activeField === "password"}
|
||||
onClick={() => chooseField("password")}
|
||||
>
|
||||
<small>Password</small><strong>{passwordPreview(snapshot.passwordLength)}</strong>
|
||||
</button>
|
||||
</div>
|
||||
<div className="login-keyboard-grid" role="group" aria-label={`${snapshot.keyboardMode} virtual keyboard`}>
|
||||
{rows.flat().map((key) => {
|
||||
const isAction = ["login", "create", "offline"].includes(key.kind);
|
||||
const unavailableForUsername = snapshot.activeField === "username"
|
||||
&& key.kind === "character"
|
||||
&& !/^[A-Za-z0-9_]$/.test(key.value ?? "");
|
||||
return (
|
||||
<button
|
||||
key={key.id}
|
||||
type="button"
|
||||
className={`login-keyboard-key login-keyboard-key--${key.kind} ${snapshot.selectedKeyId === key.id ? "is-controller-selected" : ""} ${unavailableForUsername ? "is-unavailable" : ""}`}
|
||||
style={{ gridColumn: `span ${key.span ?? 1}` } as CSSProperties}
|
||||
aria-label={key.ariaLabel}
|
||||
aria-current={snapshot.selectedKeyId === key.id ? "true" : undefined}
|
||||
disabled={isAction && snapshot.busy !== null}
|
||||
onClick={() => send({ name: "pressLoginKey", keyId: key.id })}
|
||||
>
|
||||
{keyLabel(key, snapshot.activeField)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</main>
|
||||
<footer>
|
||||
<span><b>D-Pad</b> Move <b>A</b> Type</span>
|
||||
<strong>{snapshot.busy ? `${snapshot.busy === "create" ? "Creating account" : "Opening gateway"}…` : notice || `${snapshot.activeField === "username" ? "Username" : "Password"} selected`}</strong>
|
||||
</footer>
|
||||
</FrontSurface>
|
||||
);
|
||||
}
|
||||
+23
-3
@@ -89,15 +89,35 @@
|
||||
.companion-feature-grid article strong { font-size: 13px; }
|
||||
.companion-feature-grid article small { font-size: 11px; }
|
||||
.companion-login > footer { font-size: 11px; }
|
||||
.companion-login-keyboard.front-surface--bottom { overflow: hidden; }
|
||||
.companion-login-keyboard > main { grid-template-rows: 52px minmax(0, 1fr); gap: 6px; padding: 6px 0; overflow: hidden; }
|
||||
.login-keyboard-fields button { min-height: 52px; }
|
||||
.login-keyboard-fields small { font-size: 9px; }
|
||||
.login-keyboard-fields strong { font-size: 15px; }
|
||||
.login-keyboard-key { font-size: 14px; }
|
||||
.companion-login-keyboard > footer { min-height: 30px; font-size: 10px; }
|
||||
.companion-login-keyboard > footer strong { max-width: 52%; font-size: 9px; }
|
||||
}
|
||||
|
||||
@container (min-width: 700px) and (max-width: 1000px) {
|
||||
.front-surface--top { padding: 12px; }
|
||||
.front-surface--top :is(p, small, label, dt, dd) { font-size: max(11px, 0.7rem); }
|
||||
.front-surface--top button { min-height: 44px; }
|
||||
.login-hero h1 { font-size: 44px; }
|
||||
.login-form { padding: 20px; }
|
||||
.login-form h2 { font-size: 25px; }
|
||||
.login-title { padding-top: 0; }
|
||||
.login-title__crest { width: 34px; height: 34px; }
|
||||
.login-title p { margin-top: 7px; font-size: 6px; }
|
||||
.login-title h1 { font-size: 38px; }
|
||||
.login-title small { display: none; }
|
||||
.login-screen { grid-template-rows: minmax(92px, 1fr) auto 24px; }
|
||||
.login-form { width: min(560px, calc(100% - 28px)); padding: 16px 20px 12px; }
|
||||
.login-form header { margin-bottom: 7px; }
|
||||
.login-form header strong { font-size: 23px; }
|
||||
.login-form > label { margin-top: 5px; }
|
||||
.login-form > input { height: 38px; }
|
||||
.login-form__actions { grid-template-columns: 1.15fr 1fr 1fr; margin-top: 9px; }
|
||||
.login-form__actions .front-button--primary { grid-column: auto; }
|
||||
.login-form__actions .front-button small { display: none; }
|
||||
.login-form footer { display: none; }
|
||||
.character-select-screen { grid-template-columns: 220px minmax(0, 1fr); }
|
||||
.roster-header, .character-select-screen > .controller-legend { grid-column: 1 / 3; }
|
||||
.roster-details { display: none; }
|
||||
|
||||
Reference in new issue
Block a user