import { create } from "zustand"; export type LoginField = "username" | "password"; export type LoginKeyboardMode = "lower" | "upper" | "symbols"; export type LoginRequestAction = "login" | "create" | "offline"; export type LoginKeyboardDirection = "up" | "down" | "left" | "right"; export interface LoginKeyDefinition { id: string; label: string; ariaLabel: string; kind: "character" | "mode" | "field" | "space" | "backspace" | "clear" | "done" | LoginRequestAction; value?: string; span?: number; } export interface LoginInputSnapshot { username: string; passwordLength: number; activeField: LoginField; selectedKeyId: string; keyboardMode: LoginKeyboardMode; busy: LoginRequestAction | null; } interface LoginRequest { id: number; action: LoginRequestAction; } interface LoginInputState { username: string; password: string; activeField: LoginField; selectedKeyId: string; keyboardMode: LoginKeyboardMode; busy: LoginRequestAction | null; request: LoginRequest | null; requestId: number; setUsername: (value: string) => void; setPassword: (value: string) => void; setActiveField: (field: LoginField) => void; selectKey: (keyId: string) => void; moveSelection: (direction: LoginKeyboardDirection) => void; pressKey: (keyId: string) => void; pressSelected: () => void; consumeRequest: (id: number) => void; setBusy: (busy: LoginRequestAction | null) => void; reset: () => void; } export const DEFAULT_LOGIN_KEY_ID = "char-1-0"; const characterLayouts: Record = { lower: [ ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"], ["q", "w", "e", "r", "t", "y", "u", "i", "o", "p"], ["a", "s", "d", "f", "g", "h", "j", "k", "l", "_"], ["z", "x", "c", "v", "b", "n", "m", ".", "-"], ], upper: [ ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"], ["Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P"], ["A", "S", "D", "F", "G", "H", "J", "K", "L", "_"], ["Z", "X", "C", "V", "B", "N", "M", ".", "-"], ], symbols: [ ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"], ["!", "@", "#", "$", "%", "^", "&", "*", "(", ")"], ["{", "}", "[", "]", "<", ">", "=", "+", "/", "\\"], ["~", "`", ";", ":", "'", "\"", ",", ".", "?"], ], }; function characterKey(value: string, row: number, column: number): LoginKeyDefinition { return { id: `char-${row}-${column}`, label: value, ariaLabel: value === " " ? "Space" : value, kind: "character", value, }; } export function loginKeyboardRows(mode: LoginKeyboardMode): readonly (readonly LoginKeyDefinition[])[] { const rows = characterLayouts[mode]; return [ rows[0].map((value, column) => characterKey(value, 0, column)), rows[1].map((value, column) => characterKey(value, 1, column)), rows[2].map((value, column) => characterKey(value, 2, column)), [ { id: "control-mode", label: mode === "lower" ? "⇧" : mode === "upper" ? "#+=" : "abc", ariaLabel: "Change keyboard mode", kind: "mode" }, ...rows[3].map((value, column) => characterKey(value, 3, column + 1)), ], [ { id: "control-field", label: "Field", ariaLabel: "Switch login field", kind: "field", span: 2 }, { id: "control-space", label: "Space", ariaLabel: "Space", kind: "space", span: 3 }, { id: "control-backspace", label: "⌫", ariaLabel: "Backspace", kind: "backspace", span: 2 }, { id: "control-clear", label: "Clear", ariaLabel: "Clear active field", kind: "clear", span: 2 }, { id: "control-done", label: "Done", ariaLabel: "Finish active field", kind: "done" }, ], [ { id: "action-login", label: "Enter world", ariaLabel: "Log in and enter world", kind: "login", span: 4 }, { id: "action-create", label: "Create", ariaLabel: "Create account", kind: "create", span: 3 }, { id: "action-offline", label: "Offline", ariaLabel: "Play offline", kind: "offline", span: 3 }, ], ]; } function keyPosition(rows: readonly (readonly LoginKeyDefinition[])[], keyId: string) { for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) { let column = 0; for (let keyIndex = 0; keyIndex < rows[rowIndex].length; keyIndex += 1) { const key = rows[rowIndex][keyIndex]; const span = key.span ?? 1; if (key.id === keyId) return { rowIndex, keyIndex, center: column + span / 2 }; column += span; } } return null; } export function nextLoginKeyId( mode: LoginKeyboardMode, selectedKeyId: string, direction: LoginKeyboardDirection, ): string { const rows = loginKeyboardRows(mode); const position = keyPosition(rows, selectedKeyId) ?? keyPosition(rows, DEFAULT_LOGIN_KEY_ID)!; const row = rows[position.rowIndex]; if (direction === "left" || direction === "right") { const delta = direction === "left" ? -1 : 1; return row[(position.keyIndex + delta + row.length) % row.length].id; } const rowDelta = direction === "up" ? -1 : 1; const targetRowIndex = (position.rowIndex + rowDelta + rows.length) % rows.length; const targetRow = rows[targetRowIndex]; let column = 0; return targetRow.reduce((closest, key) => { const center = column + (key.span ?? 1) / 2; column += key.span ?? 1; return Math.abs(center - position.center) < Math.abs(closest.center - position.center) ? { id: key.id, center } : closest; }, { id: targetRow[0].id, center: (targetRow[0].span ?? 1) / 2 }).id; } function findLoginKey(mode: LoginKeyboardMode, keyId: string): LoginKeyDefinition | null { return loginKeyboardRows(mode).flat().find((key) => key.id === keyId) ?? null; } function nextMode(mode: LoginKeyboardMode): LoginKeyboardMode { if (mode === "lower") return "upper"; if (mode === "upper") return "symbols"; return "lower"; } const initialState = { username: "", password: "", activeField: "username" as LoginField, selectedKeyId: DEFAULT_LOGIN_KEY_ID, keyboardMode: "lower" as LoginKeyboardMode, busy: null, request: null, requestId: 0, }; export const useLoginInputStore = create((set, get) => ({ ...initialState, setUsername: (username) => set({ username: username.slice(0, 20), activeField: "username" }), setPassword: (password) => set({ password: password.slice(0, 128), activeField: "password" }), setActiveField: (activeField) => set({ activeField }), selectKey: (selectedKeyId) => { if (findLoginKey(get().keyboardMode, selectedKeyId)) set({ selectedKeyId }); }, moveSelection: (direction) => set((state) => ({ selectedKeyId: nextLoginKeyId(state.keyboardMode, state.selectedKeyId, direction), })), pressKey: (keyId) => { const state = get(); const key = findLoginKey(state.keyboardMode, keyId); if (!key) return; set({ selectedKeyId: key.id }); if (key.kind === "character" && key.value) { if (state.activeField === "username") { if (/^[A-Za-z0-9_]$/.test(key.value) && state.username.length < 20) set({ username: state.username + key.value }); } else if (state.password.length < 128) set({ password: state.password + key.value }); return; } if (key.kind === "mode") set({ keyboardMode: nextMode(state.keyboardMode) }); else if (key.kind === "field") set({ activeField: state.activeField === "username" ? "password" : "username" }); else if (key.kind === "space" && state.activeField === "password" && state.password.length < 128) set({ password: `${state.password} ` }); else if (key.kind === "backspace") { if (state.activeField === "username") set({ username: state.username.slice(0, -1) }); else set({ password: state.password.slice(0, -1) }); } else if (key.kind === "clear") { if (state.activeField === "username") set({ username: "" }); else set({ password: "" }); } else if (key.kind === "done") { if (state.activeField === "username") set({ activeField: "password" }); else set({ selectedKeyId: "action-login" }); } else if (["login", "create", "offline"].includes(key.kind) && !state.busy) { const requestId = state.requestId + 1; set({ requestId, request: { id: requestId, action: key.kind as LoginRequestAction } }); } }, pressSelected: () => get().pressKey(get().selectedKeyId), consumeRequest: (id) => set((state) => state.request?.id === id ? { request: null } : {}), setBusy: (busy) => set({ busy }), reset: () => set(initialState), })); export function currentLoginInputSnapshot(): LoginInputSnapshot { const state = useLoginInputStore.getState(); return { username: state.username, passwordLength: state.password.length, activeField: state.activeField, selectedKeyId: state.selectedKeyId, keyboardMode: state.keyboardMode, busy: state.busy, }; }