Commit 17fa2913 authored by nanahira's avatar nanahira

add menu

parent 91dcb148
Pipeline #43260 failed with stages
in 83 minutes and 56 seconds
export * from './client-version-check'; export * from './client-version-check';
export * from './menu-manager';
export * from './welcome'; export * from './welcome';
export * from './random-duel'; export * from './random-duel';
export * from './reconnect'; export * from './reconnect';
......
import {
GameMode,
NetPlayerType,
YGOProCtosBase,
YGOProCtosHsToDuelist,
YGOProCtosKick,
YGOProStocHsPlayerEnter,
YGOProStocJoinGame,
YGOProStocTypeChange,
} from 'ygopro-msg-encode';
import { Context } from '../app';
import { Chnroute, Client, I18nService } from '../client';
import { DefaultHostinfo } from '../room';
import { resolvePanelPageLayout } from '../utility';
export type MenuEntry = {
title: string;
callback: (client: Client) => Promise<unknown> | unknown;
};
type MenuAction =
| {
type: 'entry';
entry: MenuEntry;
}
| {
type: 'next';
title: string;
offset: number;
}
| {
type: 'prev';
title: string;
offset: number;
};
type MenuView = {
actions: MenuAction[];
mode: GameMode;
slotCount: number;
};
export class MenuManager {
private i18n = this.ctx.get(() => I18nService);
private chnroute = this.ctx.get(() => Chnroute);
constructor(private ctx: Context) {
this.ctx.middleware(
YGOProCtosBase,
async (msg, client, next) => {
if (!client.currentMenu) {
return next();
}
if (msg instanceof YGOProCtosHsToDuelist || msg instanceof YGOProCtosKick) {
return next();
}
return undefined;
},
true,
);
this.ctx.middleware(YGOProCtosHsToDuelist, async (msg, client, next) => {
if (!client.currentMenu) {
return next();
}
await this.renderMenu(client);
return msg;
});
this.ctx.middleware(YGOProCtosKick, async (msg, client, next) => {
if (!client.currentMenu) {
return next();
}
await this.handleKick(client, Number(msg.pos));
return undefined;
});
}
async launchMenu(client: Client, menu: MenuEntry[]) {
client.currentMenu = menu;
if (client.menuOffset == null) {
client.menuOffset = 0;
}
await this.renderMenu(client);
}
clearMenu(client: Client) {
client.currentMenu = undefined;
client.menuOffset = undefined;
}
private buildMenuView(client: Client): MenuView {
const menu = client.currentMenu || [];
if (menu.length <= 2) {
return {
actions: menu.map((entry) => ({ type: 'entry', entry })),
mode: GameMode.SINGLE,
slotCount: 2,
};
}
if (menu.length <= 4) {
return {
actions: menu.map((entry) => ({ type: 'entry', entry })),
mode: GameMode.TAG,
slotCount: 4,
};
}
const layout = resolvePanelPageLayout(menu.length, client.menuOffset || 0);
client.menuOffset = layout.pageStart;
const actions: MenuAction[] = [];
if (layout.isFirstPage) {
for (const entry of menu.slice(layout.pageStart, layout.pageStart + 3)) {
actions.push({ type: 'entry', entry });
}
actions.push({
type: 'next',
title: '#{menu_next_page}',
offset: layout.pageStarts[layout.pageIndex + 1],
});
} else if (layout.isLastPage) {
actions.push({
type: 'prev',
title: '#{menu_prev_page}',
offset: layout.pageStarts[layout.pageIndex - 1],
});
for (const entry of menu.slice(layout.pageStart, layout.pageStart + 3)) {
actions.push({ type: 'entry', entry });
}
} else {
actions.push({
type: 'prev',
title: '#{menu_prev_page}',
offset: layout.pageStarts[layout.pageIndex - 1],
});
for (const entry of menu.slice(layout.pageStart, layout.pageStart + 2)) {
actions.push({ type: 'entry', entry });
}
actions.push({
type: 'next',
title: '#{menu_next_page}',
offset: layout.pageStarts[layout.pageIndex + 1],
});
}
return {
actions,
mode: GameMode.TAG,
slotCount: 4,
};
}
private async renderMenu(client: Client) {
if (!client.currentMenu) {
return;
}
const view = this.buildMenuView(client);
await client.send(
new YGOProStocJoinGame().fromPartial({
info: {
...DefaultHostinfo,
mode: view.mode,
},
}),
);
await client.send(
new YGOProStocTypeChange().fromPartial({
type: NetPlayerType.OBSERVER | 0x10,
}),
);
const locale = this.chnroute.getLocale(client.ip);
for (let i = 0; i < view.slotCount; i++) {
const action = view.actions[i];
const rawTitle =
action?.type === 'entry' ? action.entry.title : action?.title || '';
const title = rawTitle
? String(await this.i18n.translate(locale, rawTitle)).slice(0, 20)
: '';
await client.send(
new YGOProStocHsPlayerEnter().fromPartial({
name: title,
pos: i,
}),
);
}
}
private async handleKick(client: Client, index: number) {
if (!client.currentMenu) {
return;
}
const view = this.buildMenuView(client);
const selected = view.actions[index];
if (!selected) {
await this.renderMenu(client);
return;
}
if (selected.type === 'next' || selected.type === 'prev') {
client.menuOffset = selected.offset;
await this.renderMenu(client);
return;
}
const callback = selected.entry.callback;
this.clearMenu(client);
await callback(client);
}
}
declare module '../client' {
interface Client {
currentMenu?: MenuEntry[];
menuOffset?: number;
}
}
import { import { YGOProCtosJoinGame } from 'ygopro-msg-encode';
GameMode,
NetPlayerType,
YGOProCtosBase,
YGOProCtosHsToDuelist,
YGOProCtosJoinGame,
YGOProCtosKick,
YGOProStocHsPlayerEnter,
YGOProStocJoinGame,
YGOProStocTypeChange,
} from 'ygopro-msg-encode';
import { Context } from '../app'; import { Context } from '../app';
import { Chnroute, Client, I18nService } from '../client'; import { Client } from '../client';
import { Welcome } from '../feats'; import { MenuEntry, MenuManager, Welcome } from '../feats';
import { DefaultHostinfo } from '../room';
import { resolvePanelPageLayout } from '../utility';
interface PanelMenuNode { interface MenuNode {
[key: string]: string | PanelMenuNode; [key: string]: string | MenuNode;
} }
type PanelMenuAction =
| {
type: 'entry';
rawLabel: string;
value: string | PanelMenuNode;
}
| {
type: 'next';
rawLabel: string;
offset: number;
}
| {
type: 'prev';
rawLabel: string;
offset: number;
};
type PanelView = {
actions: PanelMenuAction[];
mode: GameMode;
slotCount: number;
};
export class JoinBlankPassMenu { export class JoinBlankPassMenu {
private logger = this.ctx.createLogger(this.constructor.name); private logger = this.ctx.createLogger(this.constructor.name);
private i18n = this.ctx.get(() => I18nService);
private chnroute = this.ctx.get(() => Chnroute);
private welcome = this.ctx.get(() => Welcome); private welcome = this.ctx.get(() => Welcome);
private menuManager = this.ctx.get(() => MenuManager);
private enabled = this.ctx.config.getBoolean('ENABLE_MENU'); private enabled = this.ctx.config.getBoolean('ENABLE_MENU');
private rootMenu = this.loadRootMenu(); private rootMenu = this.loadRootMenu();
...@@ -59,60 +23,23 @@ export class JoinBlankPassMenu { ...@@ -59,60 +23,23 @@ export class JoinBlankPassMenu {
return; return;
} }
this.ctx.middleware(
YGOProCtosBase,
async (msg, client, next) => {
const bypassEstablished =
msg instanceof YGOProCtosJoinGame && msg.bypassEstablished;
if (!client.isInPanel) {
return next();
}
if (bypassEstablished) {
return next();
}
if (msg instanceof YGOProCtosHsToDuelist || msg instanceof YGOProCtosKick) {
return next();
}
return undefined;
},
true,
);
this.ctx.middleware(YGOProCtosJoinGame, async (msg, client, next) => { this.ctx.middleware(YGOProCtosJoinGame, async (msg, client, next) => {
msg.pass = (msg.pass || '').trim(); msg.pass = (msg.pass || '').trim();
if (msg.pass) { if (msg.pass) {
if (client.isInPanel) { this.clearMenuContext(client);
this.exitPanel(client);
}
return next(); return next();
} }
if (client.isInPanel) { if (client.menuDispatchingJoin) {
this.exitPanel(client); this.clearMenuContext(client);
return next(); return next();
} }
this.enterPanel(client, msg); this.enterMenuContext(client, msg);
await this.welcome.sendConfigWelcome(client); await this.welcome.sendConfigWelcome(client);
await this.renderPanel(client); await this.openMenuByPath(client, client.menuPath || []);
return msg; return msg;
}); });
this.ctx.middleware(YGOProCtosHsToDuelist, async (_msg, client, next) => {
if (!client.isInPanel) {
return next();
}
await this.renderPanel(client);
return _msg;
});
this.ctx.middleware(YGOProCtosKick, async (msg, client, next) => {
if (!client.isInPanel) {
return next();
}
await this.handlePanelKick(client, Number(msg.pos));
return undefined;
});
} }
private loadRootMenu() { private loadRootMenu() {
...@@ -132,11 +59,11 @@ export class JoinBlankPassMenu { ...@@ -132,11 +59,11 @@ export class JoinBlankPassMenu {
} }
} }
private parseMenuNode(value: unknown, path: string): PanelMenuNode { private parseMenuNode(value: unknown, path: string): MenuNode {
if (!value || typeof value !== 'object' || Array.isArray(value)) { if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error(`${path} must be a JSON object`); throw new Error(`${path} must be a JSON object`);
} }
const parsed: PanelMenuNode = {}; const parsed: MenuNode = {};
for (const [label, entryValue] of Object.entries( for (const [label, entryValue] of Object.entries(
value as Record<string, unknown>, value as Record<string, unknown>,
)) { )) {
...@@ -157,28 +84,24 @@ export class JoinBlankPassMenu { ...@@ -157,28 +84,24 @@ export class JoinBlankPassMenu {
return parsed; return parsed;
} }
private enterPanel(client: Client, msg: YGOProCtosJoinGame) { private enterMenuContext(client: Client, msg: YGOProCtosJoinGame) {
client.isInPanel = true; client.menuPath = [];
client.panelMenuPath = []; client.menuJoinVersion = msg.version;
client.panelOffset = 0; client.menuJoinGameId = msg.gameid;
client.panelJoinVersion = msg.version;
client.panelJoinGameId = msg.gameid;
} }
private exitPanel(client: Client) { private clearMenuContext(client: Client) {
client.isInPanel = false; client.menuPath = undefined;
client.panelMenuPath = undefined; client.menuJoinVersion = undefined;
client.panelOffset = undefined; client.menuJoinGameId = undefined;
client.panelJoinVersion = undefined; client.menuDispatchingJoin = undefined;
client.panelJoinGameId = undefined;
} }
private resolveCurrentMenu(client: Client) { private resolveMenuNode(path: string[]) {
if (!this.rootMenu) { if (!this.rootMenu) {
return undefined; return undefined;
} }
let node = this.rootMenu; let node: MenuNode = this.rootMenu;
const path = client.panelMenuPath || [];
for (const key of path) { for (const key of path) {
const next = node[key]; const next = node[key];
if (!next || typeof next === 'string') { if (!next || typeof next === 'string') {
...@@ -189,211 +112,63 @@ export class JoinBlankPassMenu { ...@@ -189,211 +112,63 @@ export class JoinBlankPassMenu {
return node; return node;
} }
private ensureCurrentMenu(client: Client) { private buildMenuEntries(path: string[], node: MenuNode): MenuEntry[] {
let menu = this.resolveCurrentMenu(client); return Object.entries(node).map(([title, value]) => ({
if (menu) { title,
return menu; callback: async (client) => {
} if (typeof value === 'string') {
client.panelMenuPath = []; await this.dispatchJoinGameFromMenu(client, value);
client.panelOffset = 0;
menu = this.resolveCurrentMenu(client);
return menu;
}
private buildPanelView(client: Client): PanelView {
const menu = this.ensureCurrentMenu(client);
if (!menu) {
return {
actions: [],
mode: GameMode.SINGLE,
slotCount: 2,
};
}
const entries = Object.entries(menu).map(([rawLabel, value]) => ({
rawLabel,
value,
}));
if (entries.length <= 2) {
return {
actions: entries.map((entry) => ({
type: 'entry',
rawLabel: entry.rawLabel,
value: entry.value,
})),
mode: GameMode.SINGLE,
slotCount: 2,
};
}
if (entries.length <= 4) {
return {
actions: entries.map((entry) => ({
type: 'entry',
rawLabel: entry.rawLabel,
value: entry.value,
})),
mode: GameMode.TAG,
slotCount: 4,
};
}
const layout = resolvePanelPageLayout(entries.length, client.panelOffset || 0);
client.panelOffset = layout.pageStart;
const pageActions: PanelMenuAction[] = [];
if (layout.isFirstPage) {
for (const entry of entries.slice(layout.pageStart, layout.pageStart + 3)) {
pageActions.push({
type: 'entry',
rawLabel: entry.rawLabel,
value: entry.value,
});
}
pageActions.push({
type: 'next',
rawLabel: '#{menu_next_page}',
offset: layout.pageStarts[layout.pageIndex + 1],
});
} else if (layout.isLastPage) {
pageActions.push({
type: 'prev',
rawLabel: '#{menu_prev_page}',
offset: layout.pageStarts[layout.pageIndex - 1],
});
for (const entry of entries.slice(layout.pageStart, layout.pageStart + 3)) {
pageActions.push({
type: 'entry',
rawLabel: entry.rawLabel,
value: entry.value,
});
}
} else {
pageActions.push({
type: 'prev',
rawLabel: '#{menu_prev_page}',
offset: layout.pageStarts[layout.pageIndex - 1],
});
for (const entry of entries.slice(layout.pageStart, layout.pageStart + 2)) {
pageActions.push({
type: 'entry',
rawLabel: entry.rawLabel,
value: entry.value,
});
}
pageActions.push({
type: 'next',
rawLabel: '#{menu_next_page}',
offset: layout.pageStarts[layout.pageIndex + 1],
});
}
return {
actions: pageActions,
mode: GameMode.TAG,
slotCount: 4,
};
}
private async translateLabel(client: Client, label: string) {
const locale = this.chnroute.getLocale(client.ip);
return String(await this.i18n.translate(locale, label));
}
private async renderPanel(client: Client) {
const view = this.buildPanelView(client);
if (!view.actions.length) {
client.disconnect();
return; return;
} }
if (!Object.keys(value).length) {
await client.send( await this.returnToPreviousMenu(client, path);
new YGOProStocJoinGame().fromPartial({
info: {
...DefaultHostinfo,
mode: view.mode,
},
}),
);
await client.send(
new YGOProStocTypeChange().fromPartial({
type: NetPlayerType.OBSERVER | 0x10,
}),
);
for (let i = 0; i < view.slotCount; i++) {
const action = view.actions[i];
const translated = action
? await this.translateLabel(client, action.rawLabel)
: '';
await client.send(
new YGOProStocHsPlayerEnter().fromPartial({
name: translated.slice(0, 20),
pos: i,
}),
);
}
}
private async handlePanelKick(client: Client, index: number) {
const view = this.buildPanelView(client);
const selected = view.actions[index];
if (!selected) {
await this.renderPanel(client);
return; return;
} }
client.menuPath = [...path, title];
if (selected.type === 'next' || selected.type === 'prev') { client.menuOffset = 0;
client.panelOffset = selected.offset; await this.openMenuByPath(client, client.menuPath);
await this.renderPanel(client); },
return; }));
}
if (typeof selected.value === 'string') {
await this.dispatchJoinGameFromPanel(client, selected.value);
return;
} }
const nextMenuKeys = Object.keys(selected.value); private async openMenuByPath(client: Client, path: string[]) {
if (!nextMenuKeys.length) { const node = this.resolveMenuNode(path);
await this.backFromPanel(client); if (!node) {
client.disconnect();
return; return;
} }
client.menuPath = [...path];
client.panelMenuPath = [...(client.panelMenuPath || []), selected.rawLabel]; client.menuOffset = 0;
client.panelOffset = 0; const menu = this.buildMenuEntries(path, node);
await this.renderPanel(client); await this.menuManager.launchMenu(client, menu);
} }
private async backFromPanel(client: Client) { private async returnToPreviousMenu(client: Client, currentPath: string[]) {
const currentPath = [...(client.panelMenuPath || [])];
if (!currentPath.length) { if (!currentPath.length) {
client.disconnect(); client.disconnect();
return; return;
} }
currentPath.pop(); const parentPath = currentPath.slice(0, -1);
client.panelMenuPath = currentPath; await this.openMenuByPath(client, parentPath);
client.panelOffset = 0;
await this.renderPanel(client);
} }
private async dispatchJoinGameFromPanel(client: Client, pass: string) { private async dispatchJoinGameFromMenu(client: Client, pass: string) {
const joinMsg = new YGOProCtosJoinGame().fromPartial({ const joinMsg = new YGOProCtosJoinGame().fromPartial({
version: client.panelJoinVersion || this.ctx.config.getInt('YGOPRO_VERSION'), version: client.menuJoinVersion || this.ctx.config.getInt('YGOPRO_VERSION'),
gameid: client.panelJoinGameId || 0, gameid: client.menuJoinGameId || 0,
pass, pass,
}); });
joinMsg.bypassEstablished = true; joinMsg.bypassEstablished = true;
client.menuDispatchingJoin = !pass;
await this.ctx.dispatch(joinMsg, client); await this.ctx.dispatch(joinMsg, client);
} }
} }
declare module '../client' { declare module '../client' {
interface Client { interface Client {
isInPanel?: boolean; menuPath?: string[];
panelMenuPath?: string[]; menuJoinVersion?: number;
panelOffset?: number; menuJoinGameId?: number;
panelJoinVersion?: number; menuDispatchingJoin?: boolean;
panelJoinGameId?: number;
} }
} }
import { createAppContext } from 'nfkit'; import { createAppContext } from 'nfkit';
import { ContextState } from '../app'; import { ContextState } from '../app';
import { ClientVersionCheck } from '../feats'; import { ClientVersionCheck, MenuManager } from '../feats';
import { JoinWindbotAi, JoinWindbotToken } from '../feats/windbot'; import { JoinWindbotAi, JoinWindbotToken } from '../feats/windbot';
import { JoinRoom } from './join-room'; import { JoinRoom } from './join-room';
import { JoinFallback } from './fallback'; import { JoinFallback } from './fallback';
...@@ -19,6 +19,7 @@ export const JoinHandlerModule = createAppContext<ContextState>() ...@@ -19,6 +19,7 @@ export const JoinHandlerModule = createAppContext<ContextState>()
.provide(RandomDuelJoinHandler) .provide(RandomDuelJoinHandler)
.provide(JoinWindbotAi) .provide(JoinWindbotAi)
.provide(JoinRoom) .provide(JoinRoom)
.provide(MenuManager)
.provide(JoinBlankPassMenu) .provide(JoinBlankPassMenu)
.provide(JoinBlankPassRandomDuel) .provide(JoinBlankPassRandomDuel)
.provide(JoinBlankPassWindbotAi) .provide(JoinBlankPassWindbotAi)
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment