User Options Web UI (#214)

This commit is contained in:
Raymond
2026-04-06 10:04:03 +08:00
committed by GitHub
parent 7a946708c3
commit 2e21926189
7 changed files with 267 additions and 5 deletions
@@ -3,10 +3,11 @@
<script lang="ts">
import {
type AquaNetUser,
type GameUserOption,
type UserBox,
type UserItem,
} from "../../libs/generalTypes";
import { DATA, USER, USERBOX, GAME } from "../../libs/sdk";
import { DATA, USER, USERBOX, GAME, SETTING } from "../../libs/sdk";
import { t, ts } from "../../libs/i18n";
import { FADE_IN, FADE_OUT, USERBOX_DEFAULT_URL } from "../../libs/config";
import { fade, slide } from "svelte/transition";
@@ -23,11 +24,15 @@
import { DDS } from "../../libs/userbox/dds";
import ChuniMatchingSettings from "./ChuniMatchingSettings.svelte";
import InputField from "../ui/InputField.svelte";
import UserOptionSlider from "./UserOptionSlider.svelte";
let user: AquaNetUser
let [loading, error, submitting, preview] = [true, "", "", ""]
let changed: string[] = [];
let userOptions: GameUserOption = {};
let userIsUsingPreset = false;
// Available (unlocked) options for each kind of item
// In allItems: 'namePlate', 'frame', 'trophy', 'mapIcon', 'systemVoice', 'avatarAccessory'
let allItems: Record<string, Record<string, { name: string }>> = {}
@@ -89,6 +94,13 @@
console.log("All items", allItems)
console.log("Userbox", userbox)
userOptions = await SETTING.optionGet('chu3').catch(_ => {
loading = false
error = t("userbox.error.nodata")
}) as GameUserOption
if (userOptions["optionSet"] != 3)
userIsUsingPreset = true;
loading = false
}
@@ -312,6 +324,39 @@
<InputField bind:field={userNameField}
callback={() => USERBOX.setUserBox({ field: "userName", value: userNameField.value })}/>
<!-- User Options -->
{#if userIsUsingPreset}
<blockquote class="info">
{t('settings.options.all.preset-warning')}
</blockquote>
{/if}
<div class="fields-ranges">
<UserOptionSlider
game="chu3" type="headphone"
minValue={0} maxValue={60}
defaultValue={userOptions["headphone"]}
getTextFunction={(value: number) => `${((value / 60) * 100).toFixed(0)}%`}
/>
<UserOptionSlider
game="chu3" type="trackSkip"
minValue={0} maxValue={7}
defaultValue={userOptions["trackSkip"]}
getTextFunction={(value: number) => [t('settings.options.all.none'), `S`, `S+`, `SS`, `SS+`, `SSS`, `SSS+`, t('settings.options.all.personal-best')][value]}
/>
<UserOptionSlider
game="chu3" type="speed"
minValue={0} maxValue={61}
defaultValue={userOptions["speed"]}
getTextFunction={(value: number) => value >= 61 ? "SONIC" : value < 55 ? `${(value / 4) + 1}` : `${value - 40}`}
/>
<UserOptionSlider
game="chu3" type="speed_120"
minValue={0} maxValue={61}
defaultValue={userOptions["speed_120"]}
getTextFunction={(value: number) => value >= 61 ? "SONIC" : value < 55 ? `${(value / 4) + 1}` : `${value - 40}`}
/>
</div>
</div>
<h2>{t("userbox.header.userbox")}</h2>
{#if !USERBOX_ENABLED.value || !USERBOX_INSTALLED}
@@ -394,6 +439,7 @@
{/each}
</div>
{/if}
{#if USERBOX_INSTALLED}
<!-- god this is a mess but idgaf atp -->
<div class="field boolean" style:margin-top="1em">
@@ -594,6 +640,18 @@ p.notice
.desc
opacity: 0.6
.fields-ranges
display: flex
flex-wrap: wrap
margin: 0.5rem 0
gap: 0 1rem
justify-content: center
:global(.field)
flex: calc(50% - 1rem)
width: 50%
/* AquaBox */
.chuni-userbox-row
@@ -4,9 +4,11 @@
import { t } from "../../libs/i18n.js";
import Icon from "@iconify/svelte";
import StatusOverlays from "../StatusOverlays.svelte";
import { GAME, USER } from "../../libs/sdk";
import { GAME, SETTING, USER } from "../../libs/sdk";
import GameSettingFields from "./GameSettingFields.svelte";
import { download } from "../../libs/ui";
import UserOptionSlider from "./UserOptionSlider.svelte";
import type { GameUserOption } from "../../libs/generalTypes";
const profileFields = [
['name', t('settings.mai2.name')],
@@ -14,12 +16,24 @@
let error: string
let submitting = ""
let loading = true
let values = Array(profileFields.length).fill('')
let changed: string[] = []
let userOptions: GameUserOption = {}
let userIsUsingPreset = false
USER.me().then(me =>
GAME.userSummary(me.username, 'mai2').then(({name}) => {
GAME.userSummary(me.username, 'mai2').then(async ({name}) => {
values = [name]
userOptions = await SETTING.optionGet('mai2').catch(_ => {
error = t("userbox.error.nodata")
loading = true
}) as GameUserOption
if (userOptions["optionKind"] != 3)
userIsUsingPreset = true
loading = false
}).catch(e => error = e.message));
function submit(field: string, value: string) {
@@ -208,6 +222,7 @@
}
</script>
{#if !loading}
<div class="fields">
{#each profileFields as [field, name], i (field)}
<div class="field">
@@ -228,6 +243,55 @@
</div>
</div>
{/each}
{#if userIsUsingPreset}
<blockquote class="info">
{t('settings.options.all.preset-warning')}
</blockquote>
{/if}
<div class="fields-ranges">
<!-- TODO: determine min & max -->
<UserOptionSlider
game="mai2" type="noteSpeed"
minValue={0} maxValue={37}
defaultValue={userOptions["noteSpeed"]}
getTextFunction={(value: number) => value >= 37 ? "SONIC" : `${(value / 4) + 1}`}
/>
<UserOptionSlider
game="mai2" type="touchSpeed"
minValue={0} maxValue={37}
defaultValue={userOptions["touchSpeed"]}
getTextFunction={(value: number) => value >= 37 ? "SONIC" : `${(value / 4) + 1}`}
/>
<UserOptionSlider
game="mai2" type="slideSpeed"
minValue={0} maxValue={20}
defaultValue={userOptions["slideSpeed"]}
getTextFunction={(value: number) => value == 10 ? "Normal" : `${value > 10 ? "Late" : "Fast"} ${(Math.abs(value - 10) / 10).toFixed(1)}`}
/>
<UserOptionSlider
game="mai2" type="headPhoneVolume"
minValue={0} maxValue={19}
defaultValue={userOptions["headPhoneVolume"]}
getTextFunction={(value: number) => `${((value / 19) * 100).toFixed(0)}%`}
/>
<UserOptionSlider
game="mai2" type="trackSkip"
minValue={0} maxValue={11}
defaultValue={userOptions["trackSkip"]}
getTextFunction={(value: number) => [
t('settings.options.all.none'),
t('settings.options.all.push'),
`S`, `SS`, `SSS`,
t('settings.options.all.personal-best'),
t('settings.options.all.rival-score'),
`${t('settings.options.all.life')} 300`,
`${t('settings.options.all.life')} 100`,
`${t('settings.options.all.life')} 50`,
`${t('settings.options.all.life')} 10`,
`${t('settings.options.all.life')} 1`
][value]}
/>
</div>
<GameSettingFields game="mai2"/>
<button class="exportButton" on:click={exportData}>
<Icon icon="bxs:file-export"/>
@@ -238,8 +302,9 @@
{t('settings.batchManualExport')}
</button>
</div>
{/if}
<StatusOverlays {error} loading={!values[0] || !!submitting}/>
<StatusOverlays {error} loading={!values[0] || !!submitting || loading}/>
<style lang="sass">
.fields
@@ -263,6 +328,17 @@
> input
flex: 1
.fields-ranges
display: flex
flex-wrap: wrap
margin: 0.5rem 0
gap: 0 1rem
justify-content: center
:global(.field)
flex: calc(50% - 1rem)
width: 50%
.exportButton
display: flex
justify-content: center
@@ -0,0 +1,86 @@
<script lang="ts">
import { t } from "../../libs/i18n";
import { EN_REF } from "../../libs/i18n/en_ref";
import type { GameName } from "../../libs/scoring";
import { SETTING } from "../../libs/sdk";
export let getTextFunction: (value: number) => string
export let defaultValue: number = 0
export let minValue: number = 0
export let maxValue: number = 0
export let type: string = "speed";
export let game: GameName;
export let disabled: boolean = false;
let value = defaultValue;
let rationalizedValue = defaultValue / maxValue;
async function submit() {
await SETTING.optionSet(game, type, value);
}
</script>
<div class="field range">
<div class="range-label">
{t(`settings.options.${game}.${type}` as keyof typeof EN_REF)} <!-- TODO: i18n -->
</div>
<div class="range-value">
<input
type="range" min={minValue} {disabled}
on:change={submit} max={maxValue} bind:value={value}
>
<div>
{getTextFunction(value)}
</div>
</div>
</div>
<style lang="sass">
@use "../../vars"
.field
width: 100%
.range-value
display: flex
align-items: center
div
min-width: 4rem
white-space: nowrap
display: flex
justify-content: center
input
flex: 100%
input
-webkit-appearance: none
width: 100%
height: 5px !important
outline: none
transition: opacity 200ms
padding: 4px
border-radius: 4px
opacity: 80%
background: linear-gradient(to right, transparent 0%, #caf1 10%, #caf1 90%, transparent 100%)
margin: 16px 0
&:hover
opacity: 100%
*:focus
outline: none
input::-webkit-slider-thumb
-webkit-appearance: none
appearance: none
width: 8px
height: 30px
background: vars.$c-main
border-radius: 8px
cursor: pointer
box-shadow: 0 0 2px 0 vars.$c-main
</style>
+2
View File
@@ -137,6 +137,8 @@ export interface GameOption {
changed?: boolean
}
export type GameUserOption = Record<string, number>
export interface UserItem { itemKind: number, itemId: number, stock: number }
export interface UserBox {
userName: string,
+18
View File
@@ -167,6 +167,24 @@ export const EN_REF_SETTINGS = {
'settings.tabs.mai2': 'Mai',
'settings.tabs.ongeki': 'Ongeki',
'settings.tabs.wacca': 'Wacca',
'settings.options.all.preset-warning': 'You are using a preset option set. Options may be reflected incorrectly.',
'settings.options.all.personal-best': 'Record',
'settings.options.all.rival-score': 'Rival',
'settings.options.all.life': 'Life',
'settings.options.all.push': 'Push',
'settings.options.all.none': 'None',
// Chuni User Options
'settings.options.chu3.speed': 'Lane Speed',
'settings.options.chu3.speed_120': 'Lane Speed (120fps)',
'settings.options.chu3.trackSkip': 'Track Skip Type',
'settings.options.chu3.headphone': 'Headphone Volume',
// Maimai User Options
'settings.options.mai2.noteSpeed': 'Note Speed',
'settings.options.mai2.touchSpeed': 'Touch Speed',
'settings.options.mai2.slideSpeed': 'Slide Speed',
'settings.options.mai2.headPhoneVolume': 'Headphone Volume',
'settings.options.mai2.trackSkip': 'Track Skip Type',
'settings.fields.mai2UnlockMusic.name': 'Unlock All Music',
'settings.fields.mai2UnlockMusic.desc': 'Unlock all music and master difficulty.',
'settings.fields.mai2UnlockChara.name': 'Unlock All Characters',
+17
View File
@@ -162,6 +162,23 @@ const zhSettings: typeof EN_REF_SETTINGS = {
'settings.tabs.mai2': '舞萌',
'settings.tabs.ongeki': '音击',
'settings.tabs.wacca': '华卡',
"settings.options.all.preset-warning": "您正在使用预设选项配置,部分选项可能显示不正确。",
"settings.options.all.personal-best": "记录",
"settings.options.all.rival-score": "对手",
"settings.options.all.life": "生命值",
"settings.options.all.push": "按压",
"settings.options.all.none": "无",
"settings.options.chu3.speed": "轨道速度",
"settings.options.chu3.speed_120": "轨道速度(120帧)",
"settings.options.chu3.trackSkip": "跳过曲目类型",
"settings.options.chu3.headphone": "耳机音量",
"settings.options.mai2.noteSpeed": "音符速度",
"settings.options.mai2.touchSpeed": "触控速度",
"settings.options.mai2.slideSpeed": "滑动速度",
"settings.options.mai2.headPhoneVolume": "耳机音量",
"settings.options.mai2.trackSkip": "跳过曲目类型",
'settings.fields.mai2UnlockMusic.name': '解锁谱面',
'settings.fields.mai2UnlockMusic.desc': '解锁所有曲目和大师难度谱面。',
'settings.fields.mai2UnlockChara.name': '解锁角色',
+6 -1
View File
@@ -9,7 +9,8 @@ import type {
AquaNetUser, GameOption,
UserBox,
UserItem,
Dict
Dict,
GameUserOption
} from './generalTypes'
import type { GameName } from './scoring'
@@ -255,6 +256,10 @@ export const SETTING = {
post('/api/v2/settings/set', { key, value: `${value}` }),
detailSet: (game: string, field: string, value: any) =>
post(`/api/v2/game/${game}/user-detail-set`, { field, value }),
optionGet: (game: string): Promise<GameUserOption> =>
post(`/api/v2/game/${game}/user-option`),
optionSet: (game: string, field: string, value: number): Promise<void> =>
post(`/api/v2/game/${game}/user-option-set`, { field, value }),
}
export const TRANSFER = {