diff --git a/packages/webapp/src/configure.ts b/packages/webapp/src/configure.ts index 375d1536..644fa78e 100644 --- a/packages/webapp/src/configure.ts +++ b/packages/webapp/src/configure.ts @@ -109,6 +109,7 @@ const defaultUISettings = { }; export type Cheat = { code: string; enabled: boolean; toggleKey: string; comment: string }; +export type Combo = { code: string; enabled: boolean; binding: string; comment: string }; export type Settings = { keybinding: typeof defaultKeybinding; @@ -117,6 +118,7 @@ export type Settings = { video: typeof defaultVideoSettings; ui: typeof defaultUISettings; cheat: Record; + combo: Record; tourIndex: number; }; @@ -140,6 +142,7 @@ export const parseAccount = (account: GetAccountQuery['account']): User => { video: mergeSettings(defaultVideoSettings, settings.video), ui: mergeSettings(defaultUISettings, settings.ui), cheat: settings.cheat || {}, + combo: settings.combo || {}, tourIndex: settings.tourIndex || 0, }, }; diff --git a/packages/webapp/src/locales/en/basic.json b/packages/webapp/src/locales/en/basic.json index 953ebef6..684049c2 100644 --- a/packages/webapp/src/locales/en/basic.json +++ b/packages/webapp/src/locales/en/basic.json @@ -120,10 +120,13 @@ "settings.cheat.enable": "Enable", "settings.cheat.key": "Toggle Key", "settings.cheat.title": "Cheat Settings ($1)", + "settings.combo.code": "Combo Code", + "settings.combo.title": "Combo ($1)", "settings.keybinding.title": "Joypad", "settings.shortcut.global": "Global", "settings.shortcut.inGame": "In Game", "settings.shortcut.openCheat": "Cheat Settings", + "settings.shortcut.openCombo": "Combo", "settings.shortcut.openHelp": "Help", "settings.shortcut.openRam": "RAM Viewer", "settings.shortcut.readMsg": "Read Message", diff --git a/packages/webapp/src/locales/ja/basic.json b/packages/webapp/src/locales/ja/basic.json index e2ff83a7..66d68b27 100644 --- a/packages/webapp/src/locales/ja/basic.json +++ b/packages/webapp/src/locales/ja/basic.json @@ -118,10 +118,13 @@ "settings.cheat.enable": "開いてください", "settings.cheat.key": "キーバインディング", "settings.cheat.title": "Cheat Settings ($1)", + "settings.combo.code": "コンボコード", + "settings.combo.title": "コンボ($1)", "settings.keybinding.title": "ジョイパッド", "settings.shortcut.global": "グローバル", "settings.shortcut.inGame": "ゲームで", "settings.shortcut.openCheat": "チート設定", + "settings.shortcut.openCombo": "コンボ", "settings.shortcut.openHelp": "ヘルプ", "settings.shortcut.openRam": "RAM ビューア", "settings.shortcut.readMsg": "読む情報", diff --git a/packages/webapp/src/locales/zh-CN/basic.json b/packages/webapp/src/locales/zh-CN/basic.json index 507678e9..cff19ebc 100644 --- a/packages/webapp/src/locales/zh-CN/basic.json +++ b/packages/webapp/src/locales/zh-CN/basic.json @@ -121,10 +121,13 @@ "settings.cheat.enable": "启用", "settings.cheat.key": "键绑定", "settings.cheat.title": "金手指($1)", + "settings.combo.code": "连招代码", + "settings.combo.title": "连招($1)", "settings.keybinding.title": "手柄", "settings.shortcut.global": "全局", "settings.shortcut.inGame": "游戏内", "settings.shortcut.openCheat": "金手指", + "settings.shortcut.openCombo": "连招", "settings.shortcut.openHelp": "帮助", "settings.shortcut.openRam": "RAM 查看器", "settings.shortcut.readMsg": "阅读消息", diff --git a/packages/webapp/src/locales/zh-TW/basic.json b/packages/webapp/src/locales/zh-TW/basic.json index 45477fe8..ef162a30 100644 --- a/packages/webapp/src/locales/zh-TW/basic.json +++ b/packages/webapp/src/locales/zh-TW/basic.json @@ -118,10 +118,13 @@ "settings.cheat.enable": "啟用", "settings.cheat.key": "鍵綁定", "settings.cheat.title": "金手指($1)", + "settings.combo.code": "連招代碼", + "settings.combo.title": "連招($1)", "settings.keybinding.title": "控制器", "settings.shortcut.global": "全局", "settings.shortcut.inGame": "遊戲內", "settings.shortcut.openCheat": "金手指", + "settings.shortcut.openCombo": "連招", "settings.shortcut.openHelp": "幫助", "settings.shortcut.openRam": "RAM 查看器", "settings.shortcut.readMsg": "閱讀消息", diff --git a/packages/webapp/src/modules/combo-settings.ts b/packages/webapp/src/modules/combo-settings.ts new file mode 100644 index 00000000..0cc43bc2 --- /dev/null +++ b/packages/webapp/src/modules/combo-settings.ts @@ -0,0 +1,223 @@ +import { + GemElement, + html, + adoptedStyle, + customElement, + createCSSSheet, + css, + connectStore, + numattribute, +} from '@mantou/gem'; +import { Toast } from 'duoyun-ui/elements/toast'; + +import { Combo, configure } from 'src/configure'; +import { icons } from 'src/icons'; +import { updateAccount } from 'src/services/api'; +import { i18n } from 'src/i18n/basic'; +import { theme } from 'src/theme'; + +import type { Columns } from 'duoyun-ui/elements/table'; + +import 'duoyun-ui/elements/table'; +import 'duoyun-ui/elements/input'; +import 'duoyun-ui/elements/space'; +import 'duoyun-ui/elements/switch'; +import 'duoyun-ui/elements/shortcut-record'; +import 'duoyun-ui/elements/button'; + +const style = createCSSSheet(css` + :host { + display: block; + } + .list { + width: 40em; + min-height: 20em; + margin-block-end: 1em; + } + .list::part(side) { + height: 10em; + } + .list::part(icon) { + width: 1.5em; + opacity: 0.8; + } + .list::part(close) { + color: ${theme.negativeColor}; + } + .list::part(icon):hover { + opacity: 1; + } +`); + +type State = { + newCombo?: Combo; +}; + +/** + * @customElement m-combo-settings + */ +@customElement('m-combo-settings') +@adoptedStyle(style) +@connectStore(configure) +@connectStore(i18n.store) +export class MComboSettingsElement extends GemElement { + @numattribute gameId: number; + + state: State = {}; + + get #data() { + return configure.user?.settings.combo[this.gameId] || []; + } + + get #comboSettings() { + return configure.user?.settings.combo[this.gameId] || []; + } + + #onChangeNewCombo = (detail: Partial) => { + this.setState({ newCombo: Object.assign(this.state.newCombo!, detail) }); + }; + + #onChangeSettings = (data: Combo[]) => { + return updateAccount({ + settings: { + ...configure.user!.settings, + combo: { + ...configure.user?.settings.combo, + [this.gameId]: data, + }, + }, + }); + }; + + #addNewCheat = () => { + this.setState({ newCombo: { code: '', comment: '', enabled: true, binding: '' } }); + }; + + #addData = async (data: Combo) => { + if (this.#comboSettings.find((e) => e.code === data.code)) { + Toast.open('error', i18n.get('tip.cheat.exist')); + } else { + await this.#onChangeSettings([...this.#comboSettings, data]); + this.setState({ newCombo: undefined }); + } + }; + + #removeData = (data: Combo) => { + if (data === this.state.newCombo) { + this.setState({ newCombo: undefined }); + } else { + this.#onChangeSettings(this.#comboSettings.filter((e) => e !== data)); + } + }; + + #changeToggleKey = (data: Combo, detail: string[]) => { + const key = detail.length > 1 || detail[0].length > 1 ? undefined : detail[0]; + if (data === this.state.newCombo) { + this.#onChangeNewCombo({ binding: key }); + } else { + this.#onChangeSettings( + this.#comboSettings.map((e) => (e === data ? Object.assign(data, { toggleKey: key }) : e)), + ); + } + }; + + #toggle = (data: Combo) => { + if (data === this.state.newCombo) { + this.#onChangeNewCombo({ enabled: !data.enabled }); + } else { + this.#onChangeSettings( + this.#comboSettings.map((e) => (e === data ? Object.assign(data, { enabled: !data.enabled }) : e)), + ); + } + }; + + render = () => { + const data = this.#data; + const columns: Columns = [ + { + title: i18n.get('settings.combo.code'), + dataIndex: 'code', + render: (data) => + data === this.state.newCombo + ? html` + ) => this.#onChangeNewCombo({ code: detail.toUpperCase() })} + .value=${data.code} + > + ` + : data.code, + }, + { + title: i18n.get('settings.cheat.comment'), + dataIndex: 'comment', + render: (data) => + data === this.state.newCombo + ? html` + ) => this.#onChangeNewCombo({ comment: detail })} + .value=${data.comment} + > + ` + : data.comment, + }, + { + title: i18n.get('settings.cheat.key'), + dataIndex: 'binding', + width: '25%', + render: (data) => + html` + ) => this.#changeToggleKey(data, detail)} + .value=${data.binding ? [data.binding] : undefined} + > + `, + }, + { + title: i18n.get('settings.cheat.enable'), + dataIndex: 'enabled', + width: '100px', + render: (data) => + html` + this.#toggle(data)} neutral="informative" .checked=${data.enabled}> + `, + style: { + textAlign: 'center', + }, + }, + { + title: '', + width: '80px', + style: { + textAlign: 'right', + }, + render: (data) => + html` + + this.#addData(data)} + .element=${icons.check} + > + this.#removeData(data)} .element=${icons.close}> + + `, + }, + ]; + + return html` + + + ${i18n.get('settings.cheat.add')} + + `; + }; +} diff --git a/packages/webapp/src/modules/stage.ts b/packages/webapp/src/modules/stage.ts index 7f7d5165..ebfd7736 100644 --- a/packages/webapp/src/modules/stage.ts +++ b/packages/webapp/src/modules/stage.ts @@ -47,6 +47,7 @@ import { createGame, mapPointerButton, parseCheatCode, + parseComboCode, positionMapping, requestFrame, watchDevRom, @@ -97,6 +98,8 @@ type State = { roles: Partial>; cheats: Exclude, undefined>[]; cheatKeyHandles: Record void>; + combos: ReturnType[]; + comboKeyHandles: Record void>; canvasWidth: number; canvasHeight: number; }; @@ -119,6 +122,8 @@ export class MStageElement extends GemElement { roles: {}, cheats: [], cheatKeyHandles: {}, + combos: [], + comboKeyHandles: {}, canvasWidth: 0, canvasHeight: 0, }; @@ -235,12 +240,36 @@ export class MStageElement extends GemElement { }); }; + #currentComboMap = new Map, number>(); + #stepCombo = () => { + this.#currentComboMap.forEach((index, combo) => { + const prevFrame = combo.frames[index - 1]; + const frame = combo.frames[index]; + if (frame === prevFrame) { + this.#currentComboMap.set(combo, index + 1); + return; + } + prevFrame?.forEach((key) => { + this.#onKeyUp(new KeyboardEvent('keyup', { key })); + }); + if (frame) { + frame.forEach((key) => { + this.#onKeyDown(new KeyboardEvent('keydown', { key })); + }); + this.#currentComboMap.set(combo, index + 1); + } else { + this.#currentComboMap.delete(combo); + } + }); + }; + #sampleRate = 44100; #bufferSize = this.#sampleRate / 60; #nextStartTime = 0; #loop = () => { if (!this.#gameInstance || !this.#isVisible) return; this.#execCheat(); + this.#stepCombo(); const frameNum = this.#gameInstance.clock_frame(); const memory = this.#gameInstance.mem(); @@ -454,6 +483,7 @@ export class MStageElement extends GemElement { event.stopPropagation(); }, ...this.state.cheatKeyHandles, + ...this.state.comboKeyHandles, })(event); } }; @@ -533,24 +563,48 @@ export class MStageElement extends GemElement { () => { const gameId = this.#playing?.gameId; const cheatSettings = this.#settings?.cheat; - if (gameId && cheatSettings) { - const cheats = (cheatSettings[gameId] || []).map((cheat) => parseCheatCode(cheat)).filter(isNotNullish); - - this.setState({ - cheats, - cheatKeyHandles: Object.fromEntries( - cheats - .filter((cheat) => cheat.cheat.toggleKey) - .map((cheat) => [ - cheat.cheat.toggleKey, - (evt: KeyboardEvent) => { - cheat.enabled = !cheat.enabled; - evt.stopPropagation(); - }, - ]), - ), - }); - } + if (!gameId || !cheatSettings) return; + const cheats = (cheatSettings[gameId] || []).map((cheat) => parseCheatCode(cheat)).filter(isNotNullish); + + this.setState({ + cheats, + cheatKeyHandles: Object.fromEntries( + cheats + .filter((cheat) => cheat.cheat.toggleKey) + .map((cheat) => [ + cheat.cheat.toggleKey, + (evt: KeyboardEvent) => { + cheat.enabled = !cheat.enabled; + evt.stopPropagation(); + }, + ]), + ), + }); + }, + () => [this.#playing?.gameId, this.#settings?.cheat], + ); + + this.memo( + () => { + const gameId = this.#playing?.gameId; + const comboSettings = this.#settings?.combo; + if (!gameId || !comboSettings) return; + const combos = (comboSettings[gameId] || []).map((combo) => parseComboCode(combo)); + + this.setState({ + combos, + comboKeyHandles: Object.fromEntries( + combos.map((combo) => [ + combo.combo.binding, + (evt: KeyboardEvent) => { + // only once combo + if (this.#currentComboMap.size) return; + this.#currentComboMap.set(combo, 0); + evt.stopPropagation(); + }, + ]), + ), + }); }, () => [this.#playing?.gameId, this.#settings?.cheat], ); diff --git a/packages/webapp/src/pages/room.ts b/packages/webapp/src/pages/room.ts index dc83848d..10a9e684 100644 --- a/packages/webapp/src/pages/room.ts +++ b/packages/webapp/src/pages/room.ts @@ -48,6 +48,7 @@ import 'src/modules/room-recorder'; import 'src/modules/room-voice'; import 'src/modules/ads'; import 'src/modules/cheat-settings'; +import 'src/modules/combo-settings'; import 'src/elements/list'; import 'src/elements/fps'; import 'src/elements/ping'; @@ -187,6 +188,10 @@ export class PRoomElement extends GemElement { handle: this.#openCheatModal, tag: getShortcut('OPEN_CHEAT_SETTINGS', true), }, + this.#isHost && { + text: i18n.get('settings.shortcut.openCombo'), + handle: this.#openComboModal, + }, ].filter(isNotBoolean), { x: event.clientX, @@ -306,6 +311,26 @@ export class PRoomElement extends GemElement { ); }; + #openComboModal = () => { + if (!this.#playing) return; + Modal.open({ + header: i18n.get('settings.combo.title', store.games[this.#playing.gameId]?.name || ''), + body: html` + + + `, + disableDefaultCancelBtn: true, + disableDefaultOKBtn: true, + maskCloseable: true, + }).catch(() => { + // + }); + }; + #openCheatModal = () => { if (!this.#playing) return; Modal.open({ diff --git a/packages/webapp/src/utils/game.ts b/packages/webapp/src/utils/game.ts index 95fa757b..da3f5a6c 100644 --- a/packages/webapp/src/utils/game.ts +++ b/packages/webapp/src/utils/game.ts @@ -5,7 +5,7 @@ import { default as initNes, Nes, Button } from '@mantou/nes'; import { VideoRefreshRate } from 'src/constants'; import { logger } from 'src/logger'; -import type { Cheat } from 'src/configure'; +import type { Cheat, Combo } from 'src/configure'; import type { NesboxCanvasElement } from 'src/elements/canvas'; export function requestFrame(render: () => void, generator = VideoRefreshRate.AUTO) { @@ -209,3 +209,20 @@ export function parseCheatCode(cheat: Cheat) { val: new Uint32Array(new Uint8Array([...bytes, ...Array(4 - bytes.length)]).buffer)[0], }; } + +export function parseComboCode(combo: Combo) { + return { + combo, + enabled: combo.enabled, + // jk*4-*4-j*4 + frames: combo.code + .split('-') + .map((str) => { + const arr = str.split('*'); + const keys = [...arr[0]]; + const repeat = parseInt(arr[1]) || 1; + return new Array(repeat).fill(keys); + }) + .flat(), + }; +}