13 Commits

Author SHA1 Message Date
aaeed669df chore: bump ver 2025-04-19 11:46:07 +00:00
7084f40404 fix: improve help pages 2025-04-19 11:44:16 +00:00
f7e9d7d7db docs: rewrite README.md 2025-04-18 19:55:42 +00:00
e87b661f08 feat: onboarding 2025-04-18 15:00:52 +00:00
5d2d407659 chore: update CHANGELOG.md 2025-04-18 06:59:55 +00:00
795e889bd0 fix: better keyboard
* Scale the font as necessary
* Fix CHUNITHM order
* Fix num-unlocked numpad
2025-04-18 06:55:59 +00:00
7071f19877 fix: don't switch primary, for real 2025-04-17 18:43:27 +00:00
a72ec25088 chore: update CHANGELOG.md 2025-04-17 07:49:03 +00:00
5893536daa chore: bump ver 2025-04-17 07:44:58 +00:00
e9550e8eee feat: global progress bar
Also fix me having no foresight and executing things
inside log::debug! macros
2025-04-17 07:44:05 +00:00
658a69a1e2 feat: theme switcher 2025-04-16 22:50:15 +00:00
f3ee0d0068 fix: create a data dir for fern 2025-04-16 22:05:27 +00:00
43f885cffc fix: hotfix '@/types' 2025-04-16 21:54:51 +00:00
42 changed files with 739 additions and 201 deletions

View File

@ -1,3 +1,30 @@
## 0.11.1
- Improved help pages
## 0.11.0
- Added help pages
## 0.10.1
- Fixed the order of cells in the CHUNITHM keyboard
- Fixed numpad bindings with numlock disabled
- Disabled primary monitor cleanup when "don't switch primary monitor" is enabled
## 0.10.0
- Added a global progress bar
- Fixed issues with downloading under certain conditions
## 0.9.0
- Added a light/dark theme switcher
## 0.8.1
- Hotfixed the program failing to launch if the data dir hadn't already been created
## 0.8.0
- Added support for ChuniIO

View File

@ -1,17 +1,19 @@
# STARTLINER
A simple and easy to use launcher, configuration tool and mod manager
for O.N.G.E.K.I. and CHUNITHM, using [Rainycolor Watercolor](https://rainy.patafour.zip).
This is a program that seeks to streamline game data configuration, currently supporting O.N.G.E.K.I. and CHUNITHM.
STARTLINER is four things:
- a mod installer and updater, powered by [Rainycolor Watercolor](https://rainy.patafour.zip),
- a configuration GUI for segatools,
- a glorified `start.bat` clicker, with automatic monitor setup and rollback,
- [an abstraction allowing data configuration without touching the game directory](https://gitea.tendokyu.moe/akanyan/STARTLINER/wiki/Architecture-details).
STARTLINER's core design principle is to modify, configure and launch games without tampering with them.
This makes it possible to keep data cleaner than ever, and to have several configurations pointing at the same data.
Made with Rust (Tauri) and Vue. Technically multiplatform. Contributions welcome.
## Features
- [Clean](https://gitea.tendokyu.moe/akanyan/STARTLINER/wiki/Architecture-details) data modding
- Segatools configuration
- Monitor configuration with automatic rollback
- Support for multiple configurations pointing at the same data
## Usage
Download a prebuilt binary from [Releases](https://gitea.tendokyu.moe/akanyan/STARTLINER/releases) or build it yourself:

View File

@ -4,6 +4,5 @@
### Long-term
- Progress bars and other GUI sugar
- artemis as a special package
- Other arcade games (if there is demand)

View File

@ -0,0 +1,3 @@
If you're stuck on this screen, restart the game.
If the problem persists, <a href="https://gitea.tendokyu.moe/Dniel97/SEGAguide/wiki/FAQ#game-is-stuck-at-checking-distribution-server" target="_blank">check your network configuration</a>

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

8
public/help-finale.md Normal file
View File

@ -0,0 +1,8 @@
You can access this page any time by right-clicking the START button.
Additional resources:
- <a href="https://gitea.tendokyu.moe/Dniel97/SEGAguide/wiki/FAQ" target="_blank">SEGAguide</a>
- <a href="https://two-torial.xyz/" target="_blank">two-torial</a>
## Have fun

View File

@ -0,0 +1,3 @@
You also have to calibrate the lever, or you may get the error 3301.
Go to lever settings (<span class="bg-black text-white">レバー設定</span>), move the lever to both edges, then press "end" (<span class="bg-black text-white">終了</span>) and "save" (<span class="bg-black text-white">保存する</span>).

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

View File

@ -0,0 +1,3 @@
You might get stuck on this screen for several minutes. _This is normal_. The game just takes a long time to load data.
If you install <code>7EVENDAYSHOLIDAYS/LoadBoost</code>, subsequent launches will be much faster.

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

7
public/help-standard.md Normal file
View File

@ -0,0 +1,7 @@
You might get stuck on the following screen:
<div class="p-2 mt-1 mb-1 bg-black text-white">Aグループの基準機から設定を取得</div>
In which case, you should go to the test menu, and in game settings <span class="bg-black text-white">ゲーム設定</span> switch from "follow the standard machine" <span class="bg-black text-white">基準機に従う</span> to "standard machine" <span class="bg-black text-white">基準機</span>.
The test menu can be accessed with %TESTMENU%.

View File

@ -133,6 +133,8 @@ impl AppData {
}
fn init_logger(cfg: &GlobalConfig) {
_ = std::fs::create_dir_all(util::data_dir());
let mut fern_builder;
let colors = ColoredLevelConfig::new()
.debug(Color::Green)

View File

@ -1,5 +1,4 @@
use std::{collections::HashSet, path::PathBuf};
use futures::Stream;
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter};
use tokio::fs::File;
@ -15,7 +14,7 @@ pub struct DownloadHandler {
#[derive(Serialize, Deserialize, Clone)]
pub struct DownloadTick {
pkg_key: PkgKey,
ratio: f32
ratio: f32,
}
impl DownloadHandler {
@ -50,14 +49,15 @@ impl DownloadHandler {
let mut cache_file_w = File::create(&zip_path_part).await?;
let mut byte_stream = reqwest::get(&rmt.download_url).await?.bytes_stream();
let first_hint = byte_stream.size_hint().0 as f32;
let mut total_bytes = 0;
log::info!("downloading: {}", rmt.download_url);
while let Some(item) = byte_stream.next().await {
let i = item?;
app.emit("download-tick", DownloadTick {
total_bytes += i.len();
_ = app.emit("download-progress", DownloadTick {
pkg_key: pkg_key.clone(),
ratio: 1.0f32 - (byte_stream.size_hint().0 as f32) / first_hint
ratio: (total_bytes as f32) / (rmt.file_size as f32),
})?;
cache_file_w.write_all(&mut i.as_ref()).await?;
}

View File

@ -102,14 +102,15 @@ pub async fn run(_args: Vec<String>) {
});
app.listen("download-end", closure!(clone apph, |ev| {
log::debug!("download-end triggered: {}", ev.payload());
let raw = ev.payload();
log::debug!("download-end triggered: {}", raw);
let key = PkgKey(raw[1..raw.len()-1].to_owned());
let apph = apph.clone();
tauri::async_runtime::spawn(async move {
let mutex = apph.state::<Mutex<AppData>>();
let mut appd = mutex.lock().await;
log::debug!("download-end install {:?}", appd.pkgs.install_package(&key, true, false).await);
let res = appd.pkgs.install_package(&key, true, false).await;
log::debug!("download-end install {:?}", res);
});
}));
@ -126,19 +127,21 @@ pub async fn run(_args: Vec<String>) {
}));
app.listen("install-end-prelude", closure!(clone apph, |ev| {
log::debug!("install-end-prelude triggered: {}", ev.payload());
let payload = serde_json::from_str::<Payload>(ev.payload());
log::debug!("install-end-prelude triggered: {:?}", payload);
let apph = apph.clone();
if let Ok(payload) = payload {
tauri::async_runtime::spawn(async move {
let mutex = apph.state::<Mutex<AppData>>();
let mut appd = mutex.lock().await;
let res = appd.toggle_package(payload.pkg.clone(), ToggleAction::EnableSelf);
log::debug!(
"install-end-prelude toggle {:?}",
appd.toggle_package(payload.pkg.clone(), ToggleAction::EnableSelf)
res
);
use tauri::Emitter;
log::debug!("install-end {:?}", apph.emit("install-end", payload));
let res = apph.emit("install-end", payload);
log::debug!("install-end {:?}", res);
});
} else {
log::error!("install-end-prelude: invalid payload: {}", ev.payload());
@ -232,7 +235,8 @@ pub async fn run(_args: Vec<String>) {
let mutex = app.state::<Mutex<AppData>>();
let appd = mutex.lock().await;
if let Some(p) = &appd.profile {
log::debug!("save: {:?}", p.save());
let res = p.save();
log::debug!("save: {:?}", res);
app.exit(0);
}
});
@ -330,8 +334,8 @@ fn open_window(apph: AppHandle) -> anyhow::Result<()> {
let config = apph.config().clone();
tauri::WebviewWindowBuilder::new(&apph, "main", tauri::WebviewUrl::App("index.html".into()))
.title(format!("STARTLINER {}", config.version.unwrap_or_default()))
.inner_size(900f64, 480f64)
.min_inner_size(900f64, 480f64)
.inner_size(900f64, 600f64)
.min_inner_size(900f64, 600f64)
.build()?;
Ok(())

View File

@ -22,4 +22,5 @@ pub struct V1Version {
pub icon: String,
pub dependencies: BTreeSet<PkgKeyVersion>,
pub download_url: String,
pub file_size: i64,
}

View File

@ -6,14 +6,14 @@ use tauri::{AppHandle, Listener};
#[derive(Clone)]
pub struct DisplayInfo {
pub primary: String,
pub primary: Option<String>,
pub set: Option<DisplaySet>,
}
impl Default for DisplayInfo {
fn default() -> Self {
DisplayInfo {
primary: "default".to_owned(),
primary: None,
set: query_displays().ok(),
}
}
@ -60,7 +60,7 @@ impl Display {
.ok_or_else(|| anyhow!("Unable to query display settings"))?;
let res = DisplayInfo {
primary: primary.name().to_owned(),
primary: if self.dont_switch_primary { None } else { Some(primary.name().to_owned()) },
set: Some(display_set.clone()),
};
@ -132,12 +132,14 @@ impl Display {
let display_set = info.set.as_ref()
.ok_or_else(|| anyhow!("Unable to clean up displays: no display set"))?;
let primary = display_set
.displays()
.find(|display| display.name() == info.primary)
.ok_or_else(|| anyhow!("Display {} not found", info.primary))?;
if let Some(info_primary) = &info.primary {
let primary = display_set
.displays()
.find(|display| display.name() == info_primary)
.ok_or_else(|| anyhow!("Display {} not found", info_primary))?;
primary.set_primary()?;
primary.set_primary()?;
}
display_set.apply()?;
displayz::refresh()?;

View File

@ -81,6 +81,7 @@ pub struct Remote {
pub nsfw: bool,
pub categories: Vec<String>,
pub dependencies: BTreeSet<PkgKey>,
pub file_size: i64,
}
impl PkgKey {
@ -112,7 +113,8 @@ impl Package {
nsfw: p.has_nsfw_content,
version: v.version_number,
categories: p.categories,
dependencies: Self::sanitize_deps(v.dependencies)
dependencies: Self::sanitize_deps(v.dependencies),
file_size: v.file_size
}),
source: PackageSource::Rainy,
})

View File

@ -21,7 +21,7 @@ pub struct PackageStore {
offline: bool,
}
#[derive(Clone, Serialize, Deserialize)]
#[derive(Clone, Serialize, Deserialize, Debug)]
pub struct Payload {
pub pkg: PkgKey
}

View File

@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "STARTLINER",
"version": "0.8.0",
"version": "0.11.1",
"identifier": "zip.patafour.startliner",
"build": {
"beforeDevCommand": "bun run dev",

View File

@ -28,7 +28,9 @@ import {
usePrfStore,
} from '../stores';
import { Dirs } from '../types';
import { messageSplit } from '../util';
import { messageSplit, shouldPreferDark } from '../util';
document.documentElement.classList.toggle('use-dark-mode', shouldPreferDark());
const pkg = usePkgStore();
const prf = usePrfStore();
@ -86,6 +88,60 @@ listen<string>('launch-error', (event) => {
errorMessage.value = event.payload;
errorHeader.value = 'Launch error';
});
interface DownloadingStatus {
ratio: number;
pkg_key: string;
}
const downloading_status: Ref<DownloadingStatus[]> = ref([]);
const download_value = computed(() => {
return (
downloading_status.value.map((v) => v.ratio).reduce((a, v) => a * v) *
100
);
});
const downloadProgressText = computed(() => {
if (download_value.value < 7) {
return '';
}
let pkgs = `${downloading_status.value.length} package${downloading_status.value.length === 1 ? '' : 's'}`;
if (download_value.value < 14) {
return pkgs;
} else {
return `${pkgs} (${Math.floor(download_value.value)}%)`;
}
});
listen<DownloadingStatus>('download-progress', (event) => {
let status = downloading_status.value.find(
(v) => v.pkg_key === event.payload.pkg_key
);
if (status === undefined) {
status = {
ratio: 0,
pkg_key: event.payload.pkg_key,
};
downloading_status.value.push(status);
}
status.ratio = event.payload.ratio;
const remove = () => {
if (status !== undefined) {
downloading_status.value = downloading_status.value.filter(
(v) => v.pkg_key !== event.payload.pkg_key
);
}
};
if (status.ratio === 1.0) {
remove();
}
setTimeout(() => remove, 10_000);
});
</script>
<template>
@ -100,6 +156,16 @@ listen<string>('launch-error', (event) => {
: 'main-scale-xl'
"
>
<div
v-if="downloading_status.length > 0"
class="download-progress-bg"
></div>
<ProgressBar
v-if="downloading_status.length > 0"
:value="download_value"
class="download-progress"
>{{ downloadProgressText }}</ProgressBar
>
<ConfirmDialog>
<template #message="{ message }">
<ScrollPanel
@ -351,4 +417,23 @@ body {
.p-progressbar-label {
transition-duration: 0s !important;
}
.download-progress {
position: fixed !important;
bottom: 0;
left: 5vw;
width: 90vw;
z-index: 10000 !important;
margin: 20px auto;
}
.download-progress-bg {
position: fixed;
bottom: 0;
left: 0;
width: 100vw;
height: 60px;
background-color: var(--p-surface-900);
border-top: 1px solid var(--p-surface-600);
z-index: 998;
}
</style>

View File

@ -65,7 +65,7 @@ const filePick = async () => {
<template>
<Button v-if="!exists" icon="pi pi-plus" size="small" @click="filePick" />
<div v-else>
<div class="primitive-base" v-else>
<Button
v-if="exists"
icon="pi pi-pen-to-square"
@ -102,12 +102,20 @@ const filePick = async () => {
font-family: monospace;
white-space: nowrap;
position: fixed;
top: 10vh;
left: 10vw;
height: 80vh;
width: 80vw;
top: 50%;
left: 50%;
height: 500px;
width: 800px;
margin-left: -400px;
margin-top: -250px;
z-index: 1000;
padding: 20px;
border-radius: 20px;
background-color: #151515;
color: #ddd;
}
.primitive-base ::-webkit-scrollbar {
display: none;
}
</style>

View File

@ -16,7 +16,7 @@ invoke('get_changelog').then((s) => (changelog.value = s as string));
O.N.G.E.K.I. and CHUNITHM.
<h1>Changelog</h1>
<ScrollPanel style="height: 200px">
<div class="changelog">
<div class="markdown">
<vue-markdown-it
:source="changelog"
:options="{ typographer: true, breaks: true }"
@ -44,13 +44,20 @@ h1 {
font-size: 1.7rem;
}
.changelog h2 {
.markdown h3 {
font-size: 1.2rem;
}
.markdown h2 {
font-size: 1.4rem;
}
.changelog ul {
.markdown ul {
list-style-type: circle;
}
.changelog li {
.markdown li {
margin-left: 40px;
}
.markdown a {
text-decoration: underline;
}
</style>

View File

@ -1,4 +1,5 @@
<script setup lang="ts">
import { ref } from 'vue';
import Button from 'primevue/button';
import { invoke } from '../invoke';
import { usePkgStore } from '../stores';
@ -11,20 +12,26 @@ const props = defineProps({
pkg: Object as () => Package,
});
const deleting = ref(false);
const remove = async () => {
if (props.pkg === undefined) {
return;
}
deleting.value = true;
await invoke('delete_package', {
key: pkgKey(props.pkg),
});
deleting.value = false;
};
</script>
<template>
<Button
v-if="pkg?.loc && !pkg?.js.busy"
v-if="pkg?.loc && !pkg?.js.downloading"
rounded
icon="pi pi-trash"
severity="danger"
@ -32,7 +39,7 @@ const remove = async () => {
size="small"
class="self-center ml-4"
style="width: 2rem; height: 2rem"
:loading="pkg?.js.busy"
:loading="deleting"
v-on:click="remove()"
/>
@ -45,7 +52,7 @@ const remove = async () => {
size="small"
class="self-center ml-4"
style="width: 2rem; height: 2rem"
:loading="pkg?.js.busy"
:loading="pkg?.js.downloading"
v-on:click="async () => await pkgs.install(pkg)"
/>
</template>

View File

@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import InputText from 'primevue/inputtext';
import { fromKeycode, toKeycode } from '../keyboard';
import { usePrfStore } from '../stores';
import { OngekiButtons } from '../types';
@ -15,9 +16,51 @@ const handleKey = (
) => {
event.preventDefault();
const keycode = toKeycode(event.code);
let keycode = toKeycode(event.code);
if (keycode !== null && button !== undefined) {
const data = prf.current!.data.keyboard!.data as any;
if (event.getModifierState('NumLock') === false) {
switch (event.code) {
case 'NumpadDecimal':
keycode = toKeycode('Delete');
break;
case 'Numpad0':
keycode = toKeycode('Insert');
break;
case 'Numpad1':
keycode = toKeycode('End');
break;
case 'Numpad2':
keycode = toKeycode('ArrowDown');
break;
case 'Numpad3':
keycode = toKeycode('PageDown');
break;
case 'Numpad4':
keycode = toKeycode('ArrowLeft');
break;
case 'Numpad5':
keycode = toKeycode('Clear');
break;
case 'Numpad6':
keycode = toKeycode('ArrowRight');
break;
case 'Numpad7':
keycode = toKeycode('Home');
break;
case 'Numpad8':
keycode = toKeycode('ArrowUp');
break;
case 'Numpad9':
keycode = toKeycode('PageUp');
break;
default:
break;
}
}
if (index !== undefined) {
data[button][index] = keycode;
} else {
@ -75,7 +118,7 @@ const handleMouse = (
}
};
const getKey = (key: keyof OngekiButtons, index?: number) =>
const getKey = (key: keyof OngekiButtons, index?: number): any =>
computed(() => {
const data = prf.current!.data.keyboard?.data as any;
const keycode =
@ -85,147 +128,45 @@ const getKey = (key: keyof OngekiButtons, index?: number) =>
return keycode && fromKeycode(keycode) ? fromKeycode(keycode) : '';
});
const KEY_MAP: { [key: number]: string } = {
1: 'M1',
2: 'M2',
4: 'M3',
5: 'M4',
6: 'M5',
8: 'Backspace',
9: 'Tab',
13: 'Enter',
19: 'Pause',
20: 'CapsLock',
27: 'Escape',
32: 'Space',
33: 'PageUp',
34: 'PageDown',
35: 'End',
36: 'Home',
37: 'ArrowLeft',
38: 'ArrowUp',
39: 'ArrowRight',
40: 'ArrowDown',
45: 'Insert',
46: 'Delete',
48: 'Digit0',
49: 'Digit1',
50: 'Digit2',
51: 'Digit3',
52: 'Digit4',
53: 'Digit5',
54: 'Digit6',
55: 'Digit7',
56: 'Digit8',
57: 'Digit9',
65: 'KeyA',
66: 'KeyB',
67: 'KeyC',
68: 'KeyD',
69: 'KeyE',
70: 'KeyF',
71: 'KeyG',
72: 'KeyH',
73: 'KeyI',
74: 'KeyJ',
75: 'KeyK',
76: 'KeyL',
77: 'KeyM',
78: 'KeyN',
79: 'KeyO',
80: 'KeyP',
81: 'KeyQ',
82: 'KeyR',
83: 'KeyS',
84: 'KeyT',
85: 'KeyU',
86: 'KeyV',
87: 'KeyW',
88: 'KeyX',
89: 'KeyY',
90: 'KeyZ',
91: 'MetaLeft',
92: 'MetaRight',
93: 'ContextMenu',
96: 'Numpad0',
97: 'Numpad1',
98: 'Numpad2',
99: 'Numpad3',
100: 'Numpad4',
101: 'Numpad5',
102: 'Numpad6',
103: 'Numpad7',
104: 'Numpad8',
105: 'Numpad9',
106: 'NumpadMultiply',
107: 'NumpadAdd',
109: 'NumpadSubtract',
110: 'NumpadDecimal',
111: 'NumpadDivide',
112: 'F1',
113: 'F2',
114: 'F3',
115: 'F4',
116: 'F5',
117: 'F6',
118: 'F7',
119: 'F8',
120: 'F9',
121: 'F10',
122: 'F11',
123: 'F12',
144: 'NumLock',
145: 'ScrollLock',
160: 'ShiftLeft',
161: 'ShiftRight',
162: 'ControlLeft',
163: 'ControlRight',
164: 'AltLeft',
165: 'AltRight',
186: 'Semicolon',
187: 'Equal',
188: 'Comma',
189: 'Minus',
190: 'Period',
191: 'Slash',
192: 'Backquote',
219: 'BracketLeft',
220: 'Backslash',
221: 'BracketRight',
222: 'Quote',
};
const fromKeycode = (keyCode: number): string | null => {
return KEY_MAP[keyCode] ?? null;
};
const toKeycode = (key: string): number | null => {
const res = Object.entries(KEY_MAP).find(([_, v]) => v === key)?.[0];
return res ? parseInt(res) : null;
};
defineProps({
const props = defineProps({
small: Boolean,
verySmall: Boolean,
tall: Boolean,
tooltip: String,
button: String,
color: String,
index: Number,
});
const modelValue = computed(() => {
return getKey(props.button as keyof OngekiButtons, props.index).value;
});
const fontSize = computed(() => {
if (!props.small) {
return '1rem';
}
const len = modelValue.value.length;
if (len < 5) {
return '1rem';
}
if (len < 7) {
return '0.75rem';
}
return '0.5rem';
});
</script>
<template>
<InputText
:style="{
width: small ? '3em' : '5em',
height: small ? '3em' : tall ? '10em' : '5em',
fontSize: small ? '0.9em' : '1em',
width: small ? '2.8rem' : '5rem',
height: small ? '2.8rem' : tall ? '10rem' : '5rem',
fontSize,
backgroundColor: color,
}"
unstyled
class="text-center buttoninputtext"
v-tooltip="tooltip"
v-tooltip="tooltip ? `${tooltip}: ${modelValue}` : undefined"
@contextmenu.prevent="() => {}"
@keydown="(ev: KeyboardEvent) => handleKey(button, ev, index)"
@mousedown="
@ -233,7 +174,7 @@ defineProps({
handleMouse(button as keyof OngekiButtons, ev, index)
"
@focusout="() => (hasClickedM1Once = false)"
:model-value="getKey(button as keyof OngekiButtons, index) as any"
:model-value="modelValue"
/>
</template>
@ -241,5 +182,7 @@ defineProps({
.buttoninputtext {
border-radius: 6px;
border: 1px solid rgba(200, 200, 200, 0.3);
overflow: scroll !important;
text-align: center !important;
}
</style>

View File

@ -15,7 +15,6 @@ const props = defineProps({
const pkgs = usePkgStore();
const prf = usePrfStore();
const groupCallIndex = ref(0);
const empty = ref(false);
const gameSublist: Ref<string[]> = ref([]);
@ -46,10 +45,7 @@ const group = computed(() => {
({ namespace }) => namespace
)
);
if (groupCallIndex.value > 0) {
empty.value = Object.keys(res).length === 0;
}
groupCallIndex.value += 1;
empty.value = Object.keys(res).length === 0;
return res;
});

View File

@ -38,7 +38,7 @@ const iconSrc = computed(() => {
<label class="m-3 align-middle text grow z-5 h-50px">
<div>
<span class="text-lg">
{{ pkg?.name ?? 'Untitled' }}
{{ pkg?.name.replaceAll('_', ' ') ?? 'Untitled' }}
</span>
<span
v-if="pkg?.rmt?.deprecated"

View File

@ -0,0 +1,175 @@
<script setup lang="ts">
import { ComputedRef, computed, onMounted, ref } from 'vue';
import Button from 'primevue/button';
import Carousel from 'primevue/carousel';
import Dialog from 'primevue/dialog';
import { fromKeycode } from '../keyboard';
import { useClientStore, usePrfStore } from '../stores';
import { prettyPrint } from '../util';
import { VueMarkdownIt } from '@f3ve/vue-markdown-it';
const prf = usePrfStore();
const client = useClientStore();
const props = defineProps({
visible: Boolean,
firstTime: Boolean,
onFinish: Function,
});
interface Datum {
text: string;
image: string;
}
const game = computed(() => prf.current?.meta.game);
const processText = (s: string) => {
if (prf.current!.data.keyboard?.data.enabled) {
const testKey = prf.current!.data.keyboard?.data.test;
const readable = fromKeycode(testKey);
if (readable !== null) {
return s.replace(
'%TESTMENU%',
`${readable} or a button on the back of the controller`
);
}
}
return s.replace('%TESTMENU%', 'a button on the back of the controller');
};
const loadPage = async (title: string) => {
return {
text: await (await fetch(`/help-${title}.md`)).text(),
image: `help-${title}.png`,
};
};
let systemProcessing: Datum;
let standardOngeki: Datum;
let standardChunithm: Datum;
let lever: Datum;
let server: Datum;
let finaleOngeki: Datum;
let finaleChunithm: Datum;
const data: ComputedRef<Datum[]> = computed(() => {
const res = [];
switch (prf.current?.meta.game) {
case 'ongeki':
res.push(systemProcessing);
res.push(standardOngeki);
res.push(lever);
res.push(finaleOngeki);
break;
case 'chunithm':
res.push(standardChunithm);
res.push(server);
res.push(finaleChunithm);
break;
default:
break;
}
return res;
});
onMounted(async () => {
[standardOngeki, systemProcessing, lever, server, finaleOngeki] =
await Promise.all([
loadPage('standard'),
loadPage('ongeki-system-processing'),
loadPage('ongeki-lever'),
loadPage('chunithm-server'),
loadPage('finale'),
]);
standardOngeki = {
...standardOngeki,
image: '/help-standard-ongeki.png',
};
standardChunithm = {
...standardOngeki,
image: '/help-standard-chunithm.png',
};
finaleOngeki = {
...finaleOngeki,
image: '/help-finale-ongeki.png',
};
finaleChunithm = {
...finaleOngeki,
image: '/help-finale-chunithm.png',
};
});
const counter = ref(0);
const exitLabel = computed(() => {
return props.firstTime === true && counter.value < data.value.length - 1
? 'Skip'
: 'Close';
});
</script>
<template>
<Dialog
modal
:visible="visible"
:closable="false"
:header="
firstTime
? `It looks like you're running ${game ? prettyPrint(game) : '<game>'} for the first time`
: `${game ? prettyPrint(game) : '<game>'} help`
"
:style="{ width: '760px', scale: client.scaleValue }"
>
<Carousel
:value="data"
:num-visible="1"
:num-scroll="1"
:page="counter"
v-on:update:page="(p) => (counter = p)"
>
<template #item="slotProps">
<div class="md-container markdown">
<vue-markdown-it
:source="processText(slotProps.data?.text)"
:options="{
typographer: true,
breaks: true,
html: true,
}"
/>
</div>
<div
class="border border-surface-200 dark:border-surface-700 rounded m-2"
>
<img :src="slotProps.data.image" />
</div>
</template>
</Carousel>
<div style="width: 100%; text-align: center">
<Button
v-if="counter < data.length - 1"
class="m-auto mr-4"
label="Next"
@click="() => (counter += 1)"
/>
<Button
class="m-auto"
:label="exitLabel"
@click="() => onFinish && onFinish()"
/>
</div>
</Dialog>
</template>
<style lang="css">
.p-dialog ::-webkit-scrollbar {
display: none;
}
.md-container {
height: 9.5rem;
}
</style>

View File

@ -3,7 +3,7 @@ import InputNumber from 'primevue/inputnumber';
import ToggleSwitch from 'primevue/toggleswitch';
import OptionRow from './OptionRow.vue';
import { usePrfStore } from '../stores';
import { Patch } from '@/types';
import { Patch } from '../types';
const prf = usePrfStore();

View File

@ -5,10 +5,12 @@ import ContextMenu from 'primevue/contextmenu';
import { useConfirm } from 'primevue/useconfirm';
import { listen } from '@tauri-apps/api/event';
import { getCurrentWindow } from '@tauri-apps/api/window';
import Onboarding from './Onboarding.vue';
import { invoke } from '../invoke';
import { usePrfStore } from '../stores';
import { useClientStore, usePrfStore } from '../stores';
const prf = usePrfStore();
const client = useClientStore();
const confirmDialog = useConfirm();
type StartStatus = 'ready' | 'preparing' | 'running';
@ -98,6 +100,7 @@ const menuItems = [
{
label: 'Refresh and start',
icon: 'pi pi-sync',
tooltip: 'test',
command: async () => await startline(false, true),
},
{
@ -110,6 +113,14 @@ const menuItems = [
icon: 'pi pi-link',
command: createShortcut,
},
{
label: 'Help',
icon: 'pi pi-question-circle',
command: () => {
onboardingFirstTime.value = false;
onboardingVisible.value = true;
},
},
];
const menu = ref();
@ -117,9 +128,38 @@ const showContextMenu = (event: Event) => {
event.preventDefault();
menu.value.show(event);
};
const onboardingVisible = ref(false);
const onboardingFirstTime = ref(false);
const tryStart = () => {
const game = prf.current?.meta.game;
if (game !== undefined) {
if (client.onboarded.includes(game)) {
startline(false, false);
} else {
onboardingVisible.value = true;
onboardingFirstTime.value = true;
client.setOnboarded(game);
}
}
};
</script>
<template>
<Onboarding
:visible="onboardingVisible"
:first-time="onboardingFirstTime"
:on-finish="
() => {
onboardingVisible = false;
if (onboardingFirstTime === true) {
startline(false, false);
}
}
"
/>
<ContextMenu ref="menu" :model="menuItems" />
<Button
v-if="startStatus === 'ready'"
@ -130,7 +170,7 @@ const showContextMenu = (event: Event) => {
aria-label="start"
size="small"
class="m-2.5"
@click="startline(false, false)"
@click="tryStart"
@contextmenu="showContextMenu"
/>
<Button

View File

@ -20,17 +20,15 @@ const install = async () => {
});
} catch (err) {
if (props.pkg !== undefined) {
props.pkg.js.busy = false;
props.pkg.js.downloading = false;
}
}
//if (rv === 'Deferred') { /* download progress */ }
};
</script>
<template>
<Button
v-if="needsUpdate(pkg) && !pkg?.js.busy"
v-if="needsUpdate(pkg) && !pkg?.js.downloading"
rounded
icon="pi pi-download"
severity="success"

View File

@ -124,7 +124,7 @@ const prf = usePrfStore();
<div
v-for="idx in Array(16)
.fill(0)
.map((_, i) => 16 - i)"
.map((_, i) => 32 - 2 * i - 1)"
>
<KeyboardKey
button="cell"
@ -142,7 +142,7 @@ const prf = usePrfStore();
<div
v-for="idx in Array(16)
.fill(0)
.map((_, i) => 32 - i)"
.map((_, i) => 32 - 2 * i)"
>
<KeyboardKey
button="cell"

View File

@ -34,6 +34,15 @@ const verboseModel = computed({
await client.setVerbose(value);
},
});
const themeModel = computed({
get() {
return client.theme;
},
async set(value: 'light' | 'dark' | 'system') {
await client.setTheme(value);
},
});
</script>
<template>
@ -67,5 +76,18 @@ const verboseModel = computed({
>
<ToggleSwitch v-model="verboseModel" />
</OptionRow>
<OptionRow title="Theme">
<SelectButton
v-model="themeModel"
:options="[
{ title: 'System', value: 'system' },
{ title: 'Light', value: 'light' },
{ title: 'Dark', value: 'dark' },
]"
:allow-empty="false"
option-label="title"
option-value="value"
/>
</OptionRow>
</OptionCategory>
</template>

119
src/keyboard.ts Normal file
View File

@ -0,0 +1,119 @@
const KEY_MAP: { [key: number]: string } = {
1: 'M1',
2: 'M2',
4: 'M3',
5: 'M4',
6: 'M5',
8: 'Backspace',
9: 'Tab',
12: 'Clear',
13: 'Enter',
19: 'Pause',
20: 'CapsLock',
27: 'Escape',
32: 'Space',
33: 'PageUp',
34: 'PageDown',
35: 'End',
36: 'Home',
37: 'ArrowLeft',
38: 'ArrowUp',
39: 'ArrowRight',
40: 'ArrowDown',
45: 'Insert',
46: 'Delete',
48: 'Digit0',
49: 'Digit1',
50: 'Digit2',
51: 'Digit3',
52: 'Digit4',
53: 'Digit5',
54: 'Digit6',
55: 'Digit7',
56: 'Digit8',
57: 'Digit9',
65: 'KeyA',
66: 'KeyB',
67: 'KeyC',
68: 'KeyD',
69: 'KeyE',
70: 'KeyF',
71: 'KeyG',
72: 'KeyH',
73: 'KeyI',
74: 'KeyJ',
75: 'KeyK',
76: 'KeyL',
77: 'KeyM',
78: 'KeyN',
79: 'KeyO',
80: 'KeyP',
81: 'KeyQ',
82: 'KeyR',
83: 'KeyS',
84: 'KeyT',
85: 'KeyU',
86: 'KeyV',
87: 'KeyW',
88: 'KeyX',
89: 'KeyY',
90: 'KeyZ',
91: 'MetaLeft',
92: 'MetaRight',
93: 'ContextMenu',
96: 'Numpad0',
97: 'Numpad1',
98: 'Numpad2',
99: 'Numpad3',
100: 'Numpad4',
101: 'Numpad5',
102: 'Numpad6',
103: 'Numpad7',
104: 'Numpad8',
105: 'Numpad9',
106: 'NumpadMultiply',
107: 'NumpadAdd',
109: 'NumpadSubtract',
110: 'NumpadDecimal',
111: 'NumpadDivide',
112: 'F1',
113: 'F2',
114: 'F3',
115: 'F4',
116: 'F5',
117: 'F6',
118: 'F7',
119: 'F8',
120: 'F9',
121: 'F10',
122: 'F11',
123: 'F12',
144: 'NumLock',
145: 'ScrollLock',
160: 'ShiftLeft',
161: 'ShiftRight',
162: 'ControlLeft',
163: 'ControlRight',
164: 'AltLeft',
165: 'AltRight',
186: 'Semicolon',
187: 'Equal',
188: 'Comma',
189: 'Minus',
190: 'Period',
191: 'Slash',
192: 'Backquote',
219: 'BracketLeft',
220: 'Backslash',
221: 'BracketRight',
222: 'Quote',
};
export const fromKeycode = (keyCode: number): string | null => {
return KEY_MAP[keyCode] ?? null;
};
export const toKeycode = (key: string): number | null => {
const res = Object.entries(KEY_MAP).find(([_, v]) => v === key)?.[0];
return res ? parseInt(res) : null;
};

View File

@ -17,6 +17,9 @@ app.use(pinia);
app.use(PrimeVue, {
theme: {
preset: Preset,
options: {
darkModeSelector: '.use-dark-mode',
},
},
});
app.use(ConfirmationService);

View File

@ -6,7 +6,12 @@ import { PhysicalSize, getCurrentWindow } from '@tauri-apps/api/window';
import { readTextFile, writeTextFile } from '@tauri-apps/plugin-fs';
import { invoke, invoke_nopopup } from './invoke';
import { Dirs, Feature, Game, Package, Profile, ProfileMeta } from './types';
import { changePrimaryColor, hasFeature, pkgKey } from './util';
import {
changePrimaryColor,
hasFeature,
pkgKey,
shouldPreferDark,
} from './util';
type InstallStatus = {
pkg: string;
@ -114,13 +119,13 @@ export const usePkgStore = defineStore('pkg', {
listen<InstallStatus>('install-start', async (ev) => {
const key = ev.payload.pkg;
await this.reload(key);
this.pkg[key].js.busy = true;
this.pkg[key].js.downloading = true;
});
listen<InstallStatus>('install-end', async (ev) => {
const key = ev.payload.pkg;
await this.reload(key);
this.pkg[key].js.busy = false;
this.pkg[key].js.downloading = false;
});
},
@ -147,17 +152,22 @@ export const usePkgStore = defineStore('pkg', {
async reloadWith(key: string, pkg: Package) {
if (this.pkg[key] === undefined) {
this.pkg[key] = { js: { busy: false } } as Package;
this.pkg[key] = { js: { downloading: false } } as Package;
} else {
this.pkg[key].loc = null;
this.pkg[key].rmt = null;
}
Object.assign(this.pkg[key], pkg);
if (!pkg.js) {
pkg.js = { downloading: false };
}
if (pkg.rmt !== null) {
pkg.rmt.categories.forEach((c) =>
this.availableCategories.add(c)
);
pkg.js.downloading = false;
}
},
@ -188,9 +198,8 @@ export const usePkgStore = defineStore('pkg', {
force: true,
});
} catch (err) {
console.error(err);
if (pkg !== undefined) {
pkg.js.busy = false;
pkg.js.downloading = false;
}
}
},
@ -348,6 +357,10 @@ export const usePrfStore = defineStore('prf', () => {
};
});
export enum ClientData {
Onboarded,
}
export const useClientStore = defineStore('client', () => {
type ScaleType = 's' | 'm' | 'l' | 'xl';
const scaleFactor: Ref<ScaleType> = ref('s');
@ -356,16 +369,22 @@ export const useClientStore = defineStore('client', () => {
const offlineMode = ref(false);
const enableAutoupdates = ref(true);
const verbose = ref(false);
const theme: Ref<'light' | 'dark' | 'system'> = ref('system');
const onboarded: Ref<Game[]> = ref([]);
const scaleValue = (value: ScaleType) =>
const _scaleValue = (value: ScaleType) =>
value === 's' ? 1 : value === 'm' ? 1.25 : value === 'l' ? 1.5 : 2;
const scaleValue = computed(() => {
return _scaleValue(scaleFactor.value);
});
const setScaleFactor = async (value: ScaleType) => {
scaleFactor.value = value;
const window = getCurrentWindow();
const w = Math.floor(scaleValue(value) * 900);
const h = Math.floor(scaleValue(value) * 480);
const w = Math.floor(_scaleValue(value) * 900);
const h = Math.floor(_scaleValue(value) * 600);
let size = await window.innerSize();
@ -406,6 +425,15 @@ export const useClientStore = defineStore('client', () => {
if (input.scaleFactor) {
await setScaleFactor(input.scaleFactor);
}
if (input.theme) {
theme.value = input.theme;
}
if (input.onboarded) {
onboarded.value = input.onboarded;
}
await setTheme(theme.value);
} catch (e) {
console.error(`Error reading client options: ${e}`);
}
@ -436,6 +464,8 @@ export const useClientStore = defineStore('client', () => {
w: Math.floor(size.width),
h: Math.floor(size.height),
},
theme: theme.value,
onboarded: onboarded.value,
})
);
};
@ -468,6 +498,26 @@ export const useClientStore = defineStore('client', () => {
await invoke('set_global_config', { field: 'verbose', value });
};
const setTheme = async (value: 'light' | 'dark' | 'system') => {
if (value === 'dark') {
document.documentElement.classList.add('use-dark-mode');
} else if (value === 'light') {
document.documentElement.classList.remove('use-dark-mode');
} else {
document.documentElement.classList.toggle(
'use-dark-mode',
shouldPreferDark()
);
}
theme.value = value;
await save();
};
const setOnboarded = async (game: Game) => {
onboarded.value = [...onboarded.value, game];
await save();
};
getCurrentWindow().onResized(async ({ payload }) => {
// For whatever reason this is 0 when minimized
if (payload.width > 0) {
@ -480,13 +530,19 @@ export const useClientStore = defineStore('client', () => {
offlineMode,
enableAutoupdates,
verbose,
theme,
onboarded,
timeout,
scaleModel,
_scaleValue,
scaleValue,
load,
save,
queueSave,
setOfflineMode,
setAutoupdates,
setVerbose,
setTheme,
setOnboarded,
};
});

View File

@ -19,7 +19,7 @@ export interface Package {
icon: string;
} | null;
js: {
busy: boolean;
downloading: boolean;
};
}

View File

@ -59,3 +59,16 @@ export const hasFeature = (pkg: Package | undefined, feature: Feature) => {
export const messageSplit = (message: any) => {
return message.message?.split('\n');
};
export const shouldPreferDark = () => {
return window.matchMedia('(prefers-color-scheme: dark)').matches;
};
export const prettyPrint = (game: Game) => {
switch (game) {
case 'ongeki':
return 'O.N.G.E.K.I.';
case 'chunithm':
return 'CHUNITHM';
}
};

View File

@ -1,6 +1,6 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
// @ts-expect-error process is a nodejs global
const host = process.env.TAURI_DEV_HOST;
@ -30,4 +30,7 @@ export default defineConfig(async () => ({
ignored: ['**/rust/**'],
},
},
build: {
chunkSizeWarningLimit: 1024,
},
}));