13 Commits

Author SHA1 Message Date
67872bc4d1 fix: also add post scripts 2025-05-09 21:45:59 +00:00
469ba5f574 feat: add prelaunch scripts 2025-05-09 16:07:58 +00:00
8f05a04350 fix: file editor not updating state properly 2025-05-09 16:07:13 +00:00
4ddc54d528 feat: add japanese localization 2025-05-09 16:06:04 +00:00
c4d023ed43 fix: change the deep link domain 2025-05-01 16:48:24 +00:00
9b86af282e fix: update button enabling its package 2025-05-01 16:32:10 +00:00
2e17e0ae75 feat: diagnostic exports 2025-04-30 21:19:15 +00:00
edef5cc6dc fix: also replace download URLs 2025-04-30 07:35:54 +00:00
2dad0de4f1 fix: update rainycolor's domain 2025-04-30 06:59:38 +00:00
14a65eb5bb fix: keyboard unbinding and IR fixes 2025-04-29 19:59:21 +00:00
0add9200a6 feat: new grouping options 2025-04-28 22:00:33 +00:00
ee49da3665 fix: category sort 2025-04-28 16:47:45 +00:00
f478ad9216 feat: add package creator 2025-04-28 16:44:04 +00:00
33 changed files with 1155 additions and 136 deletions

View File

@ -1,3 +1,45 @@
## 0.20.1
- Added japanese localization
- Added user-customizable scripts
- The launch script runs directly before the game, and is equivalent to adding lines above `start "AM Daemon" ...` in start.bat
- The end script runs after the game has closed for any reason, and is equivalent to adding lines below `taskkill /f /im amdaemon.exe ...` in start.bat
- This functionality is needed for cursed things such as brokenithm
- Fixed the file editor not updating its state properly
- Fixed diagnostic exports not exporting paths
- Fixed "Install recommended packages" not enabling the segatools hook
## 0.19.1
- Fixed the update button enabling the package
- Fixed deep URLs with rainycolor.org
## 0.19.0
- Added diagnostic exports
## 0.18.3
- Updated Rainycolor's domain・真
## 0.18.2
- Updated Rainycolor's domain
## 0.18.1
- Keys can now be unbinded with Esc
- Fixed CHUNITHM IR behavior on actual keyboards
## 0.18.0
- Added new grouping options to the package list
## 0.17.0
- Added a package creation prompt
- Added a default package icon
## 0.16.0
- Fixed the clear cache button not working

View File

@ -9,7 +9,7 @@ This is a program that seeks to streamline game data configuration, currently su
STARTLINER is four things:
- a mod installer and updater, powered by [Rainycolor Watercolor](https://rainy.patafour.zip),
- a mod installer and updater, powered by [Rainycolor Watercolor](https://rainycolor.org),
- 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).

BIN
public/no-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

View File

@ -1,11 +1,12 @@
use ini::Ini;
use log;
use std::collections::{BTreeMap, HashMap};
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::path::PathBuf;
use tokio::sync::Mutex;
use tokio::fs;
use tauri::{AppHandle, Manager, State};
use crate::model::config::GlobalConfigField;
use crate::model::local::PackageManifest;
use crate::model::misc::Game;
use crate::model::patch::Patch;
use crate::modules::package::prepare_dlls;
@ -124,12 +125,13 @@ pub async fn kill() -> Result<(), String> {
pub async fn install_package(
state: State<'_, tokio::sync::Mutex<AppData>>,
key: PkgKey,
force: bool
force: bool,
enable: bool
) -> Result<InstallResult, String> {
log::debug!("invoke: install_package({})", key);
let mut appd = state.lock().await;
appd.pkgs.install_package(&key, force, true)
appd.pkgs.install_package(&key, force, true, enable)
.await
.map_err(|e| e.to_string())
}
@ -166,6 +168,65 @@ pub async fn toggle_package(state: State<'_, tokio::sync::Mutex<AppData>>, key:
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn create_package(
name: String,
description: String,
website: String,
r#type: String,
games: Vec<Game>
) -> Result<(), String> {
log::debug!("invoke: create_package");
let dir = util::pkg_dir_of("local", &name);
if dir.exists() {
return Err("Package already exists".to_owned());
}
let mut installers = Vec::new();
if r#type == "segatools" {
let mut map = BTreeMap::new();
map.insert(
"identifier".to_owned(),
serde_json::Value::String("segatools".to_owned())
);
installers.push(map);
} else if r#type == "native" {
let mut map = BTreeMap::new();
map.insert(
"identifier".to_owned(),
serde_json::Value::String("native_mod".to_owned())
);
map.insert(
"dll-game".to_owned(),
serde_json::Value::String("some.dll".to_owned())
);
map.insert(
"dll-amdaemon".to_owned(),
serde_json::Value::String("another.dll".to_owned())
);
installers.push(map);
}
let manifest = PackageManifest {
name,
version_number: "1.0.0".to_owned(),
description,
website_url: website,
dependencies: BTreeSet::new(),
installers,
games: Some(games)
};
std::fs::create_dir(&dir).map_err(|e| e.to_string())?;
let json = serde_json::to_string_pretty(&manifest).map_err(|e| e.to_string())?;
std::fs::write(dir.join("manifest.json"), json).map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn reload_all_packages(state: State<'_, tokio::sync::Mutex<AppData>>) -> Result<(), String> {
log::debug!("invoke: reload_all_packages");
@ -420,13 +481,18 @@ pub async fn create_shortcut(_app: AppHandle, profile_meta: ProfileMeta) -> Resu
}
#[tauri::command]
pub async fn export_profile(state: State<'_, Mutex<AppData>>, export_keychip: bool, files: Vec<String>) -> Result<(), String> {
pub async fn export_profile(
state: State<'_, Mutex<AppData>>,
is_diagnostic: bool,
export_keychip: bool,
files: Vec<String>
) -> Result<(), String> {
log::debug!("invoke: export_profile({:?}, {:?} files)", export_keychip, files.len());
let appd = state.lock().await;
match &appd.profile {
Some(p) => {
p.export(export_keychip, files)
p.export(export_keychip, files, is_diagnostic)
.map_err(|e| e.to_string())?;
}
None => {
@ -467,14 +533,14 @@ pub async fn clear_cache(state: State<'_, Mutex<AppData>>) -> Result<(), String>
}
#[tauri::command]
pub async fn list_platform_capabilities() -> Result<Vec<String>, ()> {
pub fn list_platform_capabilities() -> Result<Vec<String>, ()> {
log::debug!("invoke: list_platform_capabilities");
#[cfg(target_os = "windows")]
return Ok(vec!["display".to_owned(), "shortcut".to_owned(), "chunithm".to_owned()]);
return Ok(vec!["display".to_owned(), "shortcut".to_owned(), "chunithm".to_owned(), "preload-bat".to_owned()]);
#[cfg(target_os = "linux")]
return Ok(vec!["wine".to_owned()]);
return Ok(vec!["wine".to_owned(), "preload-sh".to_owned()]);
}
#[tauri::command]

View File

@ -17,6 +17,12 @@ pub struct DownloadTick {
ratio: f32,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct DownloadEndPayload {
pub key: PkgKey,
pub enable: bool
}
impl DownloadHandler {
pub fn new(app: AppHandle) -> DownloadHandler {
DownloadHandler {
@ -25,7 +31,7 @@ impl DownloadHandler {
}
}
pub fn download_zip(&mut self, zip_path: &PathBuf, pkg: &Package) -> Result<()> {
pub fn download_zip(&mut self, zip_path: &PathBuf, pkg: &Package, enable: bool) -> Result<()> {
let rmt = pkg.rmt.as_ref()
.ok_or_else(|| anyhow!("Attempted to download a package without remote data"))?
.clone();
@ -34,12 +40,18 @@ impl DownloadHandler {
} else {
// TODO clear cache button should clear this
self.paths.insert(zip_path.clone());
tauri::async_runtime::spawn(Self::download_zip_proc(self.app.clone(), zip_path.clone(), pkg.key(), rmt));
tauri::async_runtime::spawn(Self::download_zip_proc(self.app.clone(), zip_path.clone(), pkg.key(), rmt, enable));
Ok(())
}
}
async fn download_zip_proc(app: AppHandle, zip_path: PathBuf, pkg_key: PkgKey, rmt: Remote) -> Result<()> {
async fn download_zip_proc(
app: AppHandle,
zip_path: PathBuf,
pkg_key: PkgKey,
rmt: Remote,
enable: bool
) -> Result<()> {
use futures::StreamExt;
use tokio::io::AsyncWriteExt;
@ -66,7 +78,10 @@ impl DownloadHandler {
log::debug!("downloaded to {:?}", zip_path);
app.emit("download-end", pkg_key)?;
app.emit("download-end", DownloadEndPayload {
key: pkg_key,
enable
})?;
Ok(())
}

View File

@ -102,16 +102,23 @@ pub async fn run(_args: Vec<String>) {
});
app.listen("download-end", closure!(clone apph, |ev| {
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;
let res = appd.pkgs.install_package(&key, true, false).await;
log::debug!("download-end install {:?}", res);
});
let payload = serde_json::from_str::<download_handler::DownloadEndPayload>(ev.payload());
match payload {
Ok(payload) => {
log::debug!("download-end triggered: {:?}", payload);
let key = payload.key;
let apph = apph.clone();
tauri::async_runtime::spawn(async move {
let mutex = apph.state::<Mutex<AppData>>();
let mut appd = mutex.lock().await;
let res = appd.pkgs.install_package(&key, true, false, payload.enable).await;
log::debug!("download-end install {:?}", res);
});
},
Err(err) => {
log::error!("invalid download payload: {err}");
}
}
}));
app.listen("launch-end", closure!(clone apph, |_| {
@ -134,11 +141,13 @@ pub async fn run(_args: Vec<String>) {
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 {:?}",
res
);
if payload.enable == true {
let res = appd.toggle_package(payload.pkg.clone(), ToggleAction::EnableSelf);
log::debug!(
"install-end-prelude toggle {:?}",
res
);
}
use tauri::Emitter;
let res = apph.emit("install-end", payload);
log::debug!("install-end {:?}", res);
@ -193,6 +202,7 @@ pub async fn run(_args: Vec<String>) {
cmd::install_package,
cmd::delete_package,
cmd::toggle_package,
cmd::create_package,
cmd::list_profiles,
cmd::init_profile,
@ -260,7 +270,7 @@ fn deep_link(app: AppHandle, args: Vec<String>) {
log::info!("deep link: {}", url);
let regex = regex::Regex::new(
r"rainycolor://v1/install/rainy\.patafour\.zip/([^/]+)/([^/]+)/[0-9]+\.[0-9]+\.[0-9]+/"
r"rainycolor://v1/install/www\.rainycolor\.org/([^/]+)/([^/]+)/[0-9]+\.[0-9]+\.[0-9]+/"
).expect("Invalid regex");
if let Some(caps) = regex.captures(url) {
@ -272,11 +282,13 @@ fn deep_link(app: AppHandle, args: Vec<String>) {
let mut appd = mutex.lock().await;
if appd.pkgs.is_offline() {
log::warn!("Deep link installation failed: offline");
} else if let Err(e) = appd.pkgs.install_package(&key, true, true).await {
} else if let Err(e) = appd.pkgs.install_package(&key, true, true, true).await {
log::warn!("Deep link installation failed: {}", e.to_string());
}
});
}
} else {
log::error!("No caps");
}
}
}

View File

@ -6,10 +6,11 @@ use super::misc::Game;
// manifest.json
#[derive(Deserialize)]
#[derive(Serialize, Deserialize)]
pub struct PackageManifest {
pub name: String,
pub version_number: String,
pub website_url: String,
pub description: String,
pub dependencies: BTreeSet<PkgKeyVersion>,

View File

@ -117,12 +117,16 @@ impl Keyboard {
}
}
Keyboard::Chunithm(kb) => {
let mut enabled_ir = false;
if kb.enabled {
for (i, cell) in kb.cell.iter().enumerate() {
ini.with_section(Some("slider")).set(format!("cell{}", i + 1), cell.to_string());
}
for (i, ir) in kb.ir.iter().enumerate() {
ini.with_section(Some("ir")).set(format!("ir{}", i + 1), ir.to_string());
ini.with_section(Some("ir")).set(format!("ir{}", i + 1), (*ir).to_string());
if i > 0 && *ir != 0 {
enabled_ir = true;
}
}
ini.with_section(Some("io3"))
.set("test", kb.test.to_string())
@ -140,8 +144,13 @@ impl Keyboard {
.set("service", "0")
.set("coin", "0");
}
ini.with_section(Some("io3"))
.set("ir", "0");
if enabled_ir {
ini.with_section(Some("io3"))
.set("ir", "0");
} else {
ini.with_section(Some("io3"))
.set("ir", kb.ir[0].to_string());
}
}
}

View File

@ -29,6 +29,10 @@ impl PatchFileVec {
}
pub fn find_patches(&self, target: impl AsRef<Path>) -> Result<Vec<Patch>> {
if !target.as_ref().exists() {
log::warn!("invalid target path: {:?}", target.as_ref());
anyhow::bail!("Unable to open {:?}. Make sure the game path is correct.", target.as_ref());
}
let checksum = try_digest(target.as_ref())?;
let mut res_patches = Vec::new();

View File

@ -109,7 +109,7 @@ impl Package {
loc: None,
rmt: Some(Remote {
package_url: p.package_url,
download_url: v.download_url,
download_url: v.download_url.replace("https://rainy.patafour.zip/", "https://www.rainycolor.org/"),
icon: v.icon,
deprecated: p.is_deprecated,
nsfw: p.has_nsfw_content,

View File

@ -23,7 +23,8 @@ pub struct PackageStore {
#[derive(Clone, Serialize, Deserialize, Debug)]
pub struct Payload {
pub pkg: PkgKey
pub pkg: PkgKey,
pub enable: bool,
}
#[derive(Clone, Copy, Serialize, Deserialize, Debug)]
@ -132,7 +133,7 @@ impl PackageStore {
prelude::*,
};
let response = reqwest::get(format!("https://rainy.patafour.zip/c/{game}/api/v1/package/")).await?;
let response = reqwest::get(format!("https://www.rainycolor.org/c/{game}/api/v1/package/")).await?;
let reader = response
.bytes_stream()
@ -180,7 +181,13 @@ impl PackageStore {
self.offline = false;
}
pub async fn install_package(&mut self, key: &PkgKey, force: bool, install_deps: bool) -> Result<InstallResult> {
pub async fn install_package(
&mut self,
key: &PkgKey,
force: bool,
install_deps: bool,
enable: bool
) -> Result<InstallResult> {
log::info!("installation request: {}/{}/{}", key, force, install_deps);
let pkg = self.store.get(key)
@ -193,7 +200,8 @@ impl PackageStore {
}
self.app.emit("install-start", Payload {
pkg: key.to_owned()
pkg: key.to_owned(),
enable
})?;
let rmt = pkg.rmt.as_ref()
@ -203,7 +211,7 @@ impl PackageStore {
let mut set = HashSet::new();
self.resolve_deps(rmt.clone(), &mut set)?;
for dep in set {
Box::pin(self.install_package(&dep, false, false)).await?;
Box::pin(self.install_package(&dep, false, false, enable)).await?;
}
}
@ -214,7 +222,7 @@ impl PackageStore {
let part_path = zip_path.join(".part");
if !zip_path.exists() && !part_path.exists() {
self.dlh.download_zip(&zip_path, &pkg)?;
self.dlh.download_zip(&zip_path, &pkg, enable)?;
log::debug!("deferring {}", key);
return Ok(InstallResult::Deferred);
}
@ -230,7 +238,8 @@ impl PackageStore {
self.reload_package(key.to_owned()).await;
self.app.emit("install-end-prelude", Payload {
pkg: key.to_owned()
pkg: key.to_owned(),
enable
})?;
log::info!("installed {}", key);
@ -252,7 +261,8 @@ impl PackageStore {
if rv.is_ok() {
self.app.emit("install-end-prelude", Payload {
pkg: key.to_owned()
pkg: key.to_owned(),
enable: false
})?;
log::info!("deleted {}", key);
}

View File

@ -255,6 +255,7 @@ impl Profile {
let mut game_builder;
let mut amd_builder;
let mut prescript = None;
let target_path = PathBuf::from(&self.data.sgt.target);
let exe_dir = target_path.parent().ok_or_else(|| anyhow!("Invalid target path"))?;
@ -274,6 +275,12 @@ impl Profile {
amd_builder.arg("cmd.exe");
}
let script_ext = if cfg!(target_os = "windows") { "bat" } else { "sh" };
let prescript_path = self.config_dir().join(format!("pre.{script_ext}"));
if prescript_path.exists() {
prescript = util::spawn_script(prescript_path, &exe_dir, "\"STARTLINER launch script\"");
}
amd_builder.env(
"SEGATOOLS_CONFIG_PATH",
&ini_path,
@ -420,6 +427,23 @@ impl Profile {
util::pkill("amdaemon.exe").await;
}
if let Some(mut _child) = prescript {
#[cfg(target_os = "windows")]
{
// child.kill() doesn't work
util::pkill_title("STARTLINER launch script").await;
}
#[cfg(target_os = "linux")]
{
_child.start_kill()?;
}
}
let postscript_path = self.config_dir().join(format!("post.{script_ext}"));
if postscript_path.exists() {
_ = util::spawn_script(postscript_path, &exe_dir, "\"STARTLINER end script\"");
}
set.join_next().await.expect("No spawn").expect("No result");
log::debug!("Fin");

View File

@ -36,7 +36,7 @@ impl Profile {
Ok(())
}
pub fn export(&self, export_keychip: bool, extra_files: Vec<String>) -> anyhow::Result<()> {
pub fn export(&self, export_keychip: bool, extra_files: Vec<String>, is_diagnostic: bool) -> anyhow::Result<()> {
let mut prf = self.clone();
let dir = util::config_dir().join("exports");
@ -45,19 +45,21 @@ impl Profile {
std::fs::create_dir(&dir)?;
}
let path = dir.join(format!("{}-{}-template.zip", &self.meta.game, &self.meta.name));
let path = dir.join(format!("{}-{}-{}.zip", &self.meta.game, &self.meta.name, if is_diagnostic { "diagnostic" } else { "template" } ));
{
let sgt = &mut prf.data.sgt;
sgt.target = PathBuf::new();
if sgt.amfs.is_absolute() {
sgt.amfs = PathBuf::new();
}
if sgt.option.is_absolute() {
sgt.option = PathBuf::new();
}
if sgt.appdata.is_absolute() {
sgt.appdata = PathBuf::new();
if !is_diagnostic {
sgt.target = PathBuf::new();
if sgt.amfs.is_absolute() {
sgt.amfs = PathBuf::new();
}
if sgt.option.is_absolute() {
sgt.option = PathBuf::new();
}
if sgt.appdata.is_absolute() {
sgt.appdata = PathBuf::new();
}
}
}
@ -66,7 +68,7 @@ impl Profile {
if network.local_path.is_absolute() {
network.local_path = PathBuf::new();
}
if !export_keychip {
if !export_keychip || is_diagnostic {
network.keychip = String::new();
}
}
@ -83,6 +85,29 @@ impl Profile {
zip.write_all(&std::fs::read(self.config_dir().join(file))?)?;
}
if is_diagnostic {
let name = "mu3.exe.log";
let path = self.data_dir().join(name);
if path.exists() {
zip.start_file(name, options)?;
zip.write_all(&std::fs::read(path)?)?;
}
let name = "chusanApp.exe.log";
let path = self.data_dir().join(name);
if path.exists() {
zip.start_file(name, options)?;
zip.write_all(&std::fs::read(path)?)?;
}
let name = "amdaemon.exe.log";
let path = self.data_dir().join(name);
if path.exists() {
zip.start_file(name, options)?;
zip.write_all(&std::fs::read(path)?)?;
}
}
zip.finish()?;
Ok(())

View File

@ -105,6 +105,12 @@ pub async fn pkill(process_name: &str) {
.creation_flags(CREATE_NO_WINDOW).output().await;
}
#[cfg(target_os = "windows")]
pub async fn pkill_title(window_title: &str) {
_ = Command::new("taskkill.exe").arg("/fi").raw_arg(format!("\"WindowTitle eq {window_title}\""))
.creation_flags(CREATE_NO_WINDOW).output().await;
}
#[cfg(target_os = "linux")]
pub async fn pkill(process_name: &str) {
_ = Command::new("pkill").arg(process_name)
@ -214,4 +220,38 @@ pub fn create_shortcut(
file.Save(Some(lnk_path.to_str().ok_or_else(|| anyhow!("Illegal shortcut path"))?), true)?;
Ok(())
}
pub fn spawn_script(path: impl AsRef<Path>, cwd: impl AsRef<Path>, title: &str) -> Option<tokio::process::Child> {
// Seems? like this autism is needed to:
// 1. pop up a cmd window
// 2. launch the batch
// 3. die afterwards
let mut c;
#[cfg(target_os = "windows")]
{
c = Command::new("cmd");
c.args(["/C", "start"]);
c.raw_arg(title);
c.args(["cmd", "/C"]);
c.arg(path.as_ref());
c.current_dir(cwd);
}
#[cfg(target_os = "linux")]
{
c = Command::new("sh");
c.arg(path.as_ref());
c.current_dir(cwd);
}
log::debug!("Script launch: {:?}", c);
match c.spawn() {
Ok(child) => Some(child),
Err(e) => {
log::error!("unable to launch {:?}: {e}", path.as_ref());
None
}
}
}

View File

@ -63,9 +63,9 @@
},
{
// Ongeki
filename: "amdaemon.exe",
filename: "amdaemon.exe",
version: "46d47eab",
sha256: '962C76331306D0839AFD40808EA99D83E651D39C4708C448ADE0C77E8BC0A1B0',
sha256: '962C76331306D0839AFD40808EA99D83E651D39C4708C448ADE0C77E8BC0A1B0',
patches: [
{
id: 'standard-localhost',

View File

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

View File

@ -295,7 +295,7 @@ listen<DownloadingStatus>('download-progress', (event) => {
pkg.hasAvailableUpdates
"
icon="pi pi-download"
label="UPDATE ALL"
:label="t('updateAll')"
size="small"
class="mr-4 m-2.5"
@click="pkg.updateAll()"

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref } from 'vue';
import { onMounted, ref } from 'vue';
import Button from 'primevue/button';
import * as path from '@tauri-apps/api/path';
import { open } from '@tauri-apps/plugin-dialog';
@ -12,6 +12,7 @@ const props = defineProps({
filename: String,
promptname: String,
extension: String,
defaultValue: String,
});
const exists = ref(false);
@ -35,6 +36,12 @@ const save = async () => {
};
const filePick = async () => {
if (props.defaultValue !== undefined) {
contents.value = props.defaultValue;
exists.value = true;
await save();
return;
}
const p = await open({
multiple: false,
directory: false,
@ -54,13 +61,13 @@ const filePick = async () => {
}
};
(async () => {
onMounted(async () => {
if (props.filename === undefined) {
throw new Error('FileEditor without a filename');
}
target_path.value = await path.join(await prf.configDir, props.filename);
await load(target_path.value);
})();
});
</script>
<template>

View File

@ -12,8 +12,8 @@ invoke('get_changelog').then((s) => (changelog.value = s as string));
<template>
<h1>About</h1>
STARTLINER is a launcher, configuration tool and mod manager for
O.N.G.E.K.I. and CHUNITHM.
STARTLINER is a configuration tool, mod manager and start.bat automation
engine for O.N.G.E.K.I. and CHUNITHM.
<h1>Changelog</h1>
<ScrollPanel style="height: 200px">
<div class="markdown">

View File

@ -53,6 +53,6 @@ const remove = async () => {
class="self-center ml-4"
style="width: 2rem; height: 2rem"
:loading="pkg?.js.downloading"
v-on:click="async () => await pkgs.install(pkg)"
v-on:click="async () => await pkgs.install(pkg, true)"
/>
</template>

View File

@ -4,6 +4,9 @@ import InputText from 'primevue/inputtext';
import { fromKeycode, toKeycode } from '../keyboard';
import { usePrfStore } from '../stores';
import { OngekiButtons } from '../types';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const prf = usePrfStore();
@ -61,6 +64,10 @@ const handleKey = (
}
}
if (event.code === 'Escape') {
keycode = 0;
}
if (index !== undefined) {
data[button][index] = keycode;
} else {
@ -160,13 +167,24 @@ const fontSize = computed(() => {
<InputText
:style="{
width: small ? '2.8rem' : '5rem',
height: small ? '2.8rem' : tall ? '10rem' : '5rem',
height:
small && tall
? '5rem'
: small
? '2.8rem'
: tall
? '10rem'
: '5rem',
fontSize,
backgroundColor: color,
}"
unstyled
class="text-center buttoninputtext"
v-tooltip="tooltip ? `${tooltip}: ${modelValue}` : undefined"
v-tooltip="
tooltip
? `${tooltip}: ${modelValue} ${tooltip.startsWith('ir') ? `\n${t('cfg.keyboard.irTooltip')}` : ''}`
: undefined
"
@contextmenu.prevent="() => {}"
@keydown="(ev: KeyboardEvent) => handleKey(button, ev, index)"
@mousedown="

View File

@ -1,12 +1,18 @@
<script setup lang="ts">
import { Ref, computed, ref } from 'vue';
import Button from 'primevue/button';
import Dialog from 'primevue/dialog';
import Fieldset from 'primevue/fieldset';
import InputText from 'primevue/inputtext';
import MultiSelect from 'primevue/multiselect';
import SelectButton from 'primevue/selectbutton';
import ToggleSwitch from 'primevue/toggleswitch';
import { emit } from '@tauri-apps/api/event';
import ModListEntry from './ModListEntry.vue';
import ModTitlecard from './ModTitlecard.vue';
import { invoke } from '../invoke';
import { usePkgStore, usePrfStore } from '../stores';
import { Package } from '../types';
import { useClientStore, usePkgStore, usePrfStore } from '../stores';
import { Feature, Game, Package } from '../types';
import { pkgKey } from '../util';
import { useI18n } from 'vue-i18n';
@ -17,38 +23,120 @@ const props = defineProps({
});
const pkgs = usePkgStore();
const client = useClientStore();
const prf = usePrfStore();
const empty = ref(false);
const gameSublist: Ref<string[]> = ref([]);
invoke('get_game_packages', {
game: prf.current?.meta.game ?? null,
}).then((list) => {
gameSublist.value = list as string[];
const loadPackages = () => {
invoke('get_game_packages', {
game: prf.current?.meta.game ?? null,
}).then((list) => {
gameSublist.value = list as string[];
});
};
loadPackages();
const allCategories = computed(() => {
const res = new Set<string>();
for (const pkg of pkgs.allLocal) {
for (const cat of pkg.rmt?.categories ?? []) {
res.add(cat);
}
}
return [...res.values()].sort((a, b) => a.localeCompare(b));
});
const group = computed(() => {
const res = Object.assign(
{},
Object.groupBy(
pkgs.allLocal
.filter((p) => gameSublist.value.includes(pkgKey(p)))
.filter(
(p) =>
props.search === undefined ||
p.name
.toLowerCase()
.includes(props.search.toLowerCase()) ||
p.namespace
.toLowerCase()
.includes(props.search.toLowerCase())
)
.sort((p1, p2) => p1.namespace.localeCompare(p2.namespace))
.sort((p1, p2) => p1.name.localeCompare(p2.name)),
({ namespace }) => namespace
)
);
empty.value = Object.keys(res).length === 0;
const local = computed(() => {
return pkgs.allLocal
.filter((p) => gameSublist.value.includes(pkgKey(p)))
.filter((p) => p.namespace === 'local');
});
const groupedList = computed(() => {
const searchedPkgs = pkgs.allLocal
.filter((p) => gameSublist.value.includes(pkgKey(p)))
.filter((p) => p.namespace !== 'local')
.filter(
(p) =>
props.search === undefined ||
p.name.toLowerCase().includes(props.search.toLowerCase()) ||
p.namespace.toLowerCase().includes(props.search.toLowerCase())
);
let grouped;
if (client.pkgListMode === 'namespace') {
grouped = Object.groupBy(searchedPkgs, ({ namespace }) => namespace);
} else if (client.pkgListMode === 'type') {
grouped = {
standard: [] as Package[],
native: [] as Package[],
segatools: [] as Package[],
unsupported: [] as Package[] | undefined,
};
grouped.unsupported = [];
for (const pkg of searchedPkgs) {
const loc = pkg.loc;
if (!loc || !loc.status || typeof loc.status === 'string') {
grouped.unsupported.push(pkg);
} else {
if (
loc.status.OK[0] &
(Feature.GameDLL | Feature.Mempatcher | Feature.AmdDLL)
) {
grouped.native.push(pkg);
} else if (loc.status.OK[0] & Feature.Mod) {
grouped.standard.push(pkg);
}
if (
loc.status.OK[0] &
(Feature.AMNet |
Feature.Aime |
Feature.ChuniIO |
Feature.ChusanHook |
Feature.Mu3IO |
Feature.Mu3Hook)
) {
grouped.segatools.push(pkg);
}
}
}
if (grouped.unsupported.length === 0) {
delete grouped.unsupported;
}
} else {
grouped = {} as { [key: string]: Package[] };
for (const pkg of searchedPkgs) {
for (const cat of pkg.rmt?.categories ?? []) {
if (client.hiddenCategories.includes(cat)) {
continue;
}
if (!(cat in grouped)) {
grouped[cat] = [] as Package[];
}
grouped[cat].push(pkg);
}
}
}
let res: [string, Package[]][] = [];
for (const [k, v] of Object.entries(grouped)) {
if (v !== undefined) {
res.push([k, v]);
}
}
if (
client.pkgListMode === 'namespace' ||
client.pkgListMode === 'category'
) {
res.sort((a, b) => `${a[0]}`.localeCompare(`${b[0]}`));
} else if (client.pkgListMode === 'type') {
for (const entry of res) {
entry[0] = t(`pkglist.${entry[0]}`);
}
}
return res;
});
@ -56,12 +144,169 @@ const missing = computed(() => {
return prf.current?.data.mods.filter((m) => !pkgs.hasLocal(m)) ?? [];
});
const emptyVisible = ref(false);
setTimeout(() => (emptyVisible.value = true), 500);
const dialogVisible = ref(false);
const defaultModel = {
name: '',
description: '',
website: '',
type: 'rainy',
games: [] as string[],
};
const creatorModel = ref({ ...defaultModel });
const gameModel = (game: Game) =>
computed({
get() {
return (creatorModel.value.games as string[]).includes(game);
},
set(v: boolean) {
creatorModel.value.games = creatorModel.value.games.filter(
(g) => g !== game
);
if (v) {
creatorModel.value.games.push(game);
}
},
});
const gameModelOngeki = gameModel('ongeki');
const gameModelChunithm = gameModel('chunithm');
</script>
<template>
<Fieldset :legend="t('store.missing')" v-if="(missing?.length ?? 0) > 0">
<Dialog
modal
:visible="dialogVisible"
:closable="false"
:header="t('creator.header')"
:style="{ width: '500px', scale: client.scaleValue }"
class="creation-dialog"
>
<div style="position: absolute; left: 250px; top: 25px">
<a
href="https://gitea.tendokyu.moe/akanyan/STARTLINER/wiki/Package-format"
target="_blank"
style="text-decoration: underline"
class="self-center"
>{{ t('creator.packageFormat') }}</a
>
</div>
<h2>{{ t('creator.basic') }}</h2>
<div class="flex flex-col gap-3">
<InputText
size="small"
style="width: 100%"
:placeholder="t('creator.name')"
v-model="creatorModel.name"
/>
<InputText
size="small"
style="width: 100%"
:placeholder="t('creator.description')"
v-model="creatorModel.description"
/>
<InputText
size="small"
style="width: 100%"
:placeholder="t('creator.website')"
v-model="creatorModel.website"
/>
</div>
<h2>{{ t('creator.type') }}</h2>
<div class="flex flex-col items-center">
<SelectButton
:options="[
{ title: t('creator.rainy'), value: 'rainy' },
{ title: t('creator.native'), value: 'native' },
{ title: t('creator.segatools'), value: 'segatools' },
]"
v-model="creatorModel.type"
:allow-empty="false"
option-label="title"
option-value="value"
/>
</div>
<h2>{{ t('creator.games') }}</h2>
<div class="flex flex-col gap-4">
<div class="flex flex-row">
<div class="grow">{{ t('game.ongeki') }}</div>
<ToggleSwitch v-model="gameModelOngeki" />
</div>
<div class="flex flex-row">
<div class="grow">{{ t('game.chunithm') }}</div>
<ToggleSwitch v-model="gameModelChunithm" />
</div>
</div>
<div class="flex flex-row mt-5">
<Button
class="ml-auto mr-1"
style="width: 80px"
:label="t('ok')"
:disabled="creatorModel.games.length === 0"
@click="
async () => {
await invoke('create_package', creatorModel);
await pkgs.reloadAll();
loadPackages();
dialogVisible = false;
creatorModel = { ...defaultModel };
}
"
/>
<Button
class="mr-auto ml-1"
style="width: 80px"
:label="t('cancel')"
@click="
() => (
(dialogVisible = false),
(creatorModel = { ...defaultModel })
)
"
/>
</div>
</Dialog>
<div class="flex flex-row">
<SelectButton
:options="[
{ title: t('pkglist.namespace'), value: 'namespace' },
{ title: t('pkglist.type'), value: 'type' },
{ title: t('pkglist.category'), value: 'category' },
]"
v-model="client.pkgListMode"
v-on:update:model-value="
client.save();
emit('reload-icons');
"
:allow-empty="false"
option-label="title"
option-value="value"
/>
<div
class="grow text-right mr-2 self-center"
v-if="client.pkgListMode === 'category'"
>
{{ t('pkglist.exclusions') }}
</div>
<MultiSelect
v-if="client.pkgListMode === 'category'"
style="width: 30%"
:showToggleAll="false"
v-model="client.hiddenCategories"
v-on:value-change="
client.save();
emit('reload-icons');
"
:options="allCategories"
class="w-full grow"
/>
</div>
<Fieldset :legend="t('pkglist.missing')" v-if="(missing?.length ?? 0) > 0">
<div class="flex items-center" v-for="p in missing">
<ModTitlecard
show-namespace
@ -84,10 +329,28 @@ setTimeout(() => (emptyVisible.value = true), 500);
/>
</div>
</Fieldset>
<Fieldset v-for="(namespace, key) in group" :legend="key.toString()">
<ModListEntry v-for="p in namespace" :pkg="p" />
<Fieldset :legend="t('pkglist.local')">
<ModListEntry v-for="p in local" :pkg="p" />
<Button
rounded
icon="pi pi-plus"
severity="success"
aria-label="install"
size="small"
class="self-center"
style="width: 2rem; height: 2rem"
v-on:click="() => (dialogVisible = true)"
/>
</Fieldset>
<Fieldset v-for="[namespace, pkgs] in groupedList" :legend="namespace">
<ModListEntry v-for="p in pkgs" :pkg="p" />
</Fieldset>
<div v-if="empty === true && emptyVisible === true" class="text-3xl fadein">
</div>
</template>
<style lang="css" scoped>
.creation-dialog h2 {
margin-top: 0.6em;
margin-bottom: 0.4em;
font-size: 110%;
}
</style>

View File

@ -7,7 +7,7 @@ import LinkButton from './LinkButton.vue';
import ModTitlecard from './ModTitlecard.vue';
import UpdateButton from './UpdateButton.vue';
import { invoke } from '../invoke';
import { usePkgStore, usePrfStore } from '../stores';
import { useClientStore, usePkgStore, usePrfStore } from '../stores';
import { Feature, Package } from '../types';
import { hasFeature } from '../util';
import { useI18n } from 'vue-i18n';
@ -16,6 +16,7 @@ const { t } = useI18n();
const prf = usePrfStore();
const pkgs = usePkgStore();
const client = useClientStore();
const props = defineProps({
pkg: Object as () => Package,
@ -39,7 +40,13 @@ if (unsupported.value === true && model.value === true) {
<template>
<div class="flex items-center">
<ModTitlecard show-version show-icon show-description :pkg="pkg" />
<ModTitlecard
show-version
show-icon
show-description
:show-namespace="client.pkgListMode !== 'namespace'"
:pkg="pkg"
/>
<UpdateButton :pkg="pkg" />
<span v-tooltip="unsupported && t('store.incompatible')">
<ToggleSwitch

View File

@ -71,10 +71,12 @@ const recommendedTooltip = computed(() => {
const installRecommended = () => {
if (prf.current?.meta.game === 'ongeki') {
pkgs.installFromKey('segatools-mu3hook');
prf.current.data.sgt.hook = 'segatools-mu3hook';
}
if (prf.current?.meta.game === 'chunithm') {
pkgs.installFromKey('segatools-chusanhook');
pkgs.installFromKey('mempatcher-mempatcher');
prf.current.data.sgt.hook = 'segatools-chusanhook';
}
};
</script>

View File

@ -1,9 +1,14 @@
<script setup lang="ts">
import { computed } from 'vue';
import { ref } from 'vue';
import Chip from 'primevue/chip';
import { convertFileSrc } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { invoke } from '../invoke';
import { Feature, Package } from '../types';
import { hasFeature, needsUpdate } from '../util';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const props = defineProps({
pkg: Object as () => Package,
@ -14,23 +19,34 @@ const props = defineProps({
showIcon: Boolean,
});
const iconSrc = computed(() => {
const icon = props.pkg?.loc?.icon ?? props.pkg?.rmt?.icon;
const icon = ref('/no-icon.png');
if (icon === undefined) {
return '';
} else if (icon.startsWith('https://')) {
return icon;
const reloadIcons = async () => {
const src = props.pkg?.loc?.icon ?? props.pkg?.rmt?.icon;
if (src === undefined) {
icon.value = '/no-icon.png';
} else if (src.startsWith('https://')) {
icon.value = src;
} else {
return convertFileSrc(icon);
const convt = convertFileSrc(src);
if (await invoke('file_exists', { path: src })) {
icon.value = convt;
} else {
icon.value = '/no-icon.png';
}
}
});
};
reloadIcons();
listen('reload-icons', reloadIcons);
</script>
<template>
<img
v-if="showIcon"
:src="iconSrc"
:src="icon"
class="self-center rounded-sm"
width="32px"
height="32px"
@ -89,7 +105,12 @@ const iconSrc = computed(() => {
v-if="showNamespace && pkg?.namespace"
class="text-sm opacity-75"
>
by&nbsp;{{ pkg.namespace }}
&nbsp;{{
t('by', { namespace: pkg.namespace }).replaceAll(
' ',
'&nbsp;'
)
}}
</span>
<span class="m-2">
<span

View File

@ -29,30 +29,47 @@ const files = new Set<string>();
).includes('chunithm');
})();
const fileList = [
'aime.txt',
'inohara.cfg',
'saekawa.toml',
'mu3.ini',
'segatools-base.ini',
'pre.sh',
'pre.bat',
'post.sh',
'post.bat',
];
const diagnosticList = {
ongeki: ['mu3.ini', 'segatools-base.ini'],
chunithm: ['segatools-base.ini'],
};
const diagnostic = ref(false);
const exportTemplate = async () => {
const fl = [...files.values()];
exportVisible.value = false;
await invoke('export_profile', {
exportKeychip: exportKeychip.value,
files: fl,
isDiagnostic: diagnostic.value,
files:
diagnostic.value === true
? diagnosticList[prf.current!.meta.game]
: fl,
});
await invoke('open_file', {
path: await path.join(await general.configDir, 'exports'),
});
};
const fileList = {
ongeki: ['aime.txt', 'inohara.cfg', 'mu3.ini', 'segatools-base.ini'],
chunithm: ['aime.txt', 'saekawa.toml', 'segatools-base.ini'],
};
const fileListCurrent: Ref<string[]> = ref([]);
const recalcFileList = async () => {
const res: string[] = [];
files.clear();
for (const idx in fileList[prf.current!.meta.game]) {
const f = fileList[prf.current!.meta.game][idx];
for (const f of fileList) {
const p = await path.join(await prf.configDir, f);
if (await invoke('file_exists', { path: p })) {
res.push(f);
@ -91,16 +108,36 @@ const importPick = async () => {
:visible="exportVisible"
:closable="false /*this shit doesn't work */"
:header="`${t('profile.export')} ${prf.current?.meta.name}`"
:style="{ width: '300px', scale: client.scaleValue }"
:style="{ width: '330px', scale: client.scaleValue }"
>
<div class="flex flex-col gap-4">
<div class="flex flex-col items-center">
<SelectButton
v-model="diagnostic"
:options="[
{
title: t('profile.standardExport'),
value: false,
},
{
title: t('profile.diagnostic'),
value: true,
},
]"
:allow-empty="false"
option-label="title"
option-value="value"
>
</SelectButton>
</div>
<div class="flex flex-row">
<div class="grow">{{ t('profile.export') }} keychip</div>
<ToggleSwitch v-model="exportKeychip" />
<ToggleSwitch :disabled="diagnostic" v-model="exportKeychip" />
</div>
<div class="flex flex-row" v-for="f in fileListCurrent">
<div class="grow">{{ t('profile.export') }} {{ f }}</div>
<ToggleSwitch
:disabled="diagnostic"
:model-value="true"
@update:model-value="
(v) => {
@ -178,7 +215,7 @@ const importPick = async () => {
style="width: 200px"
:options="[
{ title: 'English', value: 'en' },
// { title: '日本語', value: 'ja' },
{ title: '日本語', value: 'ja' },
{ title: 'Polski', value: 'pl' },
]"
size="small"

View File

@ -17,6 +17,7 @@ const install = async () => {
await invoke('install_package', {
key: pkgKey(props.pkg),
force: true,
enable: false,
});
} catch (err) {
if (props.pkg !== undefined) {

View File

@ -95,7 +95,7 @@ const prf = usePrfStore();
</div>
</div>
<div v-if="prf.current?.meta.game === 'chunithm'">
<div class="absolute left-1/2 top-1/5">
<div class="absolute left-9/17 top-1/12">
<div
class="flex flex-row flex-nowrap gap-2 self-center w-full"
>
@ -108,6 +108,7 @@ const prf = usePrfStore();
button="ir"
:index="idx - 1"
:tooltip="`ir${idx}`"
tall
small
color="rgba(0, 255, 0, 0.2)"
/>

View File

@ -1,13 +1,27 @@
<script setup lang="ts">
import { ref } from 'vue';
import ToggleSwitch from 'primevue/toggleswitch';
import FileEditor from '../FileEditor.vue';
import OptionCategory from '../OptionCategory.vue';
import OptionRow from '../OptionRow.vue';
import { invoke } from '../../invoke';
import { usePrfStore } from '../../stores';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const extension = ref('');
invoke('list_platform_capabilities').then(async (v: unknown) => {
if (Array.isArray(v)) {
if (v.includes('preload-sh')) {
extension.value = 'sh';
} else if (v.includes('preload-bat')) {
extension.value = 'bat';
}
}
});
const prf = usePrfStore();
</script>
@ -26,5 +40,35 @@ const prf = usePrfStore();
<!-- <Button icon="pi pi-refresh" size="small" /> -->
<FileEditor filename="segatools-base.ini" />
</OptionRow>
<OptionRow
:title="t('cfg.misc.prescript')"
:tooltip="t('cfg.misc.prescriptTooltip')"
>
<FileEditor
v-if="extension === 'bat'"
filename="pre.bat"
:defaultValue="`@echo off\n\nREM This script will launch before (and alongside) the game\n`"
/>
<FileEditor
v-else-if="extension === 'sh'"
filename="pre.sh"
:defaultValue="`#!/bin/sh\n\n# This script will launch before (and alongside) the game\n`"
/>
</OptionRow>
<OptionRow
:title="t('cfg.misc.postscript')"
:tooltip="t('cfg.misc.postscriptTooltip')"
>
<FileEditor
v-if="extension === 'bat'"
filename="post.bat"
:defaultValue="`@echo off\n\nREM This script will launch after the game has died\n`"
/>
<FileEditor
v-else-if="extension === 'sh'"
filename="post.sh"
:defaultValue="`#!/bin/sh\n\n# This script will launch after the game has died\n`"
/>
</OptionRow>
</OptionCategory>
</template>

View File

@ -8,6 +8,8 @@ export default {
next: 'Next',
skip: 'Skip',
close: 'Close',
by: 'by {namespace}',
updateAll: 'UPDATE ALL',
start: {
failed: 'Start check failed',
accept: 'Run anyway',
@ -43,8 +45,23 @@ export default {
reallyDelete: 'Are you sure you want to delete {profile}?',
template: 'STARTLINER template',
importTemplate: 'Import template',
exportTemplate: 'Export template',
exportTemplate: 'Export profile',
export: 'Export',
standardExport: 'Template',
diagnostic: 'Diagnostic',
},
creator: {
header: 'Package creator',
basic: 'Basic information',
name: 'Name',
description: 'Description',
website: 'Website',
type: 'Package type',
rainy: 'Standard',
segatools: 'Segatools',
native: 'Native',
games: 'Games',
packageFormat: 'Package format spec',
},
store: {
installRecommended: 'Install recommended packages',
@ -52,10 +69,22 @@ export default {
deprecated: 'Show deprecated',
nsfw: 'Show NSFW',
incompatible: 'This package is currently incompatible with STARTLINER.',
missing: 'Missing',
includeCategories: 'Include categories',
excludeCategories: 'Exclude categories',
},
pkglist: {
missing: 'Missing',
local: 'Local packages',
namespace: 'By namespace',
type: 'By type',
category: 'By category',
standard: 'Standard mods',
native: 'Native mods',
segatools: 'segatools',
unsupported: 'Unsupported',
exclusions: 'Exclusions:',
},
patch: {
loading: 'Loading...',
noneFound:
@ -137,6 +166,11 @@ export default {
other: 'Other segatools options',
otherTooltip:
'Advanced or situational options not covered by STARTLINER',
prescript: 'Launch script',
prescriptTooltip: 'Optional script that runs before the game.',
postscript: 'End script',
postscriptTooltip:
'Optional script that runs after the game has ended.',
},
extensions: {
title: 'Extensions',
@ -163,6 +197,8 @@ export default {
'Only applicable if the IO module is set to segatools built-in (keyboard) or a compatible third-party module (like mu3io.NET)',
leverMode: 'Lever mode',
mouse: 'Mouse',
irTooltip:
'When playing on an actual keyboard, only bind ir1; leave the rest unbound',
},
wine: {
prefix: 'Wine prefix',

View File

@ -1,14 +1,295 @@
export default {
ok: 'OK',
cancel: 'キャンセル',
enable: '有効にする',
disable: '無効にする',
default: 'デフォルト',
search: '探索',
next: '次',
skip: 'スキップ',
close: '閉じる',
by: '{namespace}製',
updateAll: 'すべてを更新',
start: {
failed: '起動チェックに失敗',
accept: 'とにかく実行',
error: {
package: 'パッケージが見つからない',
dependency: '依存関係が見つからない',
tool: 'ツールが見つからない',
unknown: '不明エラー',
},
tooltip: {
game: 'ゲームパスを指定する必要があります',
amfs: 'amfsパスを指定する必要があります',
segatools: 'segatoolsフックパッケージが必要です',
},
button: {
start: '起動',
stop: '停止',
unchecked: 'チェックをスキップして起動',
shortcut: 'デスクトップショートカットを作成',
help: 'ヘルプ',
refresh: 'MODを再適用して起動',
cache: 'MODキャッシュのクリア',
},
},
game: {
ongeki: 'オンゲキ',
chunithm: 'チュウニズム',
},
profile: {
welcome: 'STARTLINERへようこそ! プロフィルの作成から始めよう。',
create: '{game}のプロフィル',
delete: 'プロフィル削除',
reallyDelete: '本当に{profile}を削除しますか?',
template: 'STARTLINERのテンプレート',
importTemplate: 'テンプレートのインポート',
exportTemplate: 'プロフィルのエクスポート',
export: 'エクスポート',
standardExport: 'テンプレート',
diagnostic: '診断',
},
creator: {
header: 'パッケージ製作者',
basic: '基本情報',
name: '名',
description: '説明',
website: 'ウェブサイト',
type: 'パッケージタイプ',
rainy: 'スタンダード',
segatools: 'segatools',
native: 'ネイティブ',
games: 'ゲーム',
packageFormat: 'パッケージフォーマット仕様',
},
store: {
installRecommended: 'おすすめパッケージのインストール',
installed: 'インストールされているの表示',
deprecated: '非推奨の表示',
nsfw: 'NSFWの表示',
incompatible: 'このパッケージは現在STARTLINERと互換性がありません。',
includeCategories: 'カテゴリーを含む',
excludeCategories: 'カテゴリーを除く',
},
pkglist: {
missing: '行方不明',
local: 'ローカルパッケージ',
namespace: '名前空間順',
type: 'タイプ順',
category: 'カテゴリ順',
standard: 'スタンダードMOD',
native: 'ネイティブMOD',
segatools: 'segatools',
unsupported: '未対応',
exclusions: '除外:',
},
patch: {
loading: 'ロード中...',
noneFound:
'互換性のあるパッチが見つかりません。パッチが適用されていないファイルを使用していることを確認してください。',
forceLoad: '強制ロード',
'standard-shared-audio':
'共有オーディオモードを強制、システムオーディオサンプルレートは48000Hzでなければなりません',
'standard-shared-audio-tooltip':
'互換性は向上するが、待ち時間が増える可能性があります',
'standard-2ch': '2チャンネルオーディオ出力を強制',
'standard-2ch-tooltip': '低音過負荷の可能性',
'standard-song-timer': '音楽選択タイマーを無効にする',
'standard-map-timer': 'マップ選択タイマー',
'standard-map-timer-tooltip':
'負に設定すると、タイマーは968値となる968-1967',
'standard-ticket-timer': 'チケット選択タイマー',
'standard-ticket-timer-tooltip':
'負に設定すると、タイマーは968値となる968-1967',
'standard-course-timer': 'コース選択タイマー',
'standard-course-timer-tooltip':
'負に設定すると、タイマーは968値となる968-1967',
'standard-unlimited-tracks': '最大トラック数無制限',
'standard-unlimited-tracks-tooltip':
'1クレジットにつき7曲以上再生する場合はチェックが必要',
'standard-maximum-tracks': '最大トラック数',
'standard-no-encryption': '暗号化無し',
'standard-no-encryption-tooltip': 'TLSも無効にする',
'standard-no-tls': 'TLS無し',
'standard-no-tls-tooltip': 'タイトルサーバーの回避策',
'standard-head-to-head': 'ヘッド・トゥ・ヘッドパッチ',
'standard-head-to-head-tooltip':
'ヘッド・トゥ・ヘッドプレイに接続しようとする際に、無限に同期しないことがあった問題を修正',
'standard-bypass-1080p': '1080pモニターチェックのバイパス',
'standard-bypass-120hz': '120hzモニターチェックのバイパス',
'standard-force-free-play-text': 'FREE PLAYクレジットテキストを強制',
'standard-force-free-play-text-tooltip':
'クレジット数をFREE PLAYに置き換える',
'standard-custom-free-play-length': 'カスタムFREE PLAYテキストの長さ',
'standard-custom-free-play-length-tooltip':
'強制FREE PLAYクレジットテキストが有効な場合に表示されるテキストの長さを変更します。',
'standard-custom-free-play-text': 'カスタムFREE PLAYテキスト',
'standard-custom-free-play-text-tooltip':
'無限クレジットを使用する場合、FREE PLAYのテキストを置き換える。',
'standard-localhost':
'ネットワークサーバーとして127.0.0.1/localhostを許可',
'standard-credit-freeze': 'クレジットフリーズ ',
'standard-credit-freeze-tooltip':
'クレジットの使用を防ぎます。ゲームを開始したり、プレミアムチケットを購入したりするには、少なくとも1つのクレジットが使用可能でなければならない。',
'standard-openssl-fix': 'OpenSSL SHAクラッシュのバグ修正',
'standard-openssl-fix-tooltip':
'第10世代以降のインテルCPUのクラッシュを修正',
},
cfg: {
afterRestart: '再起動後に適用',
hardware: 'ハードウェア',
segatools: {
general: '一般',
builtIn: 'segatools内蔵エミュレーション',
targetTooltip:
'STARTLINERはそれ以外のクリーンなデータにクラック実行可能ファイルを期待する。',
hooks: 'フック',
ioModules: 'IOモジュール',
ioModulesDesc: 'これは望ましい入力方法と一致するはずです。',
ioBuiltIn: 'segatools内蔵キーボードー',
io4: 'ネイティブIO4',
installTooltip:
'{thing}はパッケージストアからダウンロードできます。',
},
display: {
title: 'ディスプレイ',
resolution: 'ゲームの解像度',
primary: 'メイン',
target: 'ディスプレイ',
mode: 'モード',
rotation: '画面の向き',
refreshRate: 'リフレッシュレート',
borderlessFullscreen: 'ボーダレスフルスクリーン',
borderlessFullscreenTooltip:
'ディスプレイの解像度をゲームに合わせる。',
dontSwitchPrimary: '主ディスプレイの切り替えをスキップする',
dontSwitchPrimaryTooltip:
'プライマリディスプレイを切り替えると問題が発生する場合のみ、このオプションを有効にしてください。モニターのリフレッシュレートが一致している必要があります。',
index: 'ディスプレイインデックス',
portrait: '縦',
landscape: '横',
flipped: '反対向き',
window: 'ウィンドウ',
borderless: 'ボーダレス',
fullscreen: 'フルスクリーン',
},
network: {
title: 'ネットワーク',
type: 'ネットワークタイプ',
remote: 'リモート',
localArtemis: 'ローカルARTEMiS',
artemisPath: 'ARTEMiSパス',
address: 'サーバーアドレス',
keychip: 'キーチップ',
subnet: 'サブネット',
addrSuffix: 'アドレスサフィックス',
},
aime: {
type: 'Aimeタイプ',
modules: 'Aimeモジュール ',
code: 'アクセスコード',
codeTooltip:
'segatools内蔵エミュレーションまたは互換性のあるサードパーティ製パッケージでのみ使用可能。',
aimedb: '物理カードにはAiMeDBを使う',
aimedbTooltip:
'物理カードがアクセスコードを取得するためにAiMeDBを使用するかどうか。ゲームがホストされたネットワークを使用している場合、このオプションを有効にすると、物理筐体で取得するのと同じアカウントデータ/プロフィルがロードされます。',
serialPort: 'Aimeシリアルポート',
serialPortTooltip: `ポートはデバイスとプリンター、またはgooglechromelabs.github.io/serial-terminalで確認できます
AIC Picoの場合は、AIMEポートを選択する。`,
serverName: 'サーバー名',
},
misc: {
title: 'その他',
intel: '第10世代以降インテル向けOpenSSLバグの回避策',
intelTooltip: '代わりにamdaemonにパッチを当てることを推奨する。',
other: 'その他segatools設定',
otherTooltip: 'STARTLINERに含まれない上級者向けまたは状況別設定',
prescript: '起動前のスクリプト',
prescriptTooltip: 'ゲームの前に実行されるスクリプト',
postscript: '終了後のスクリプト',
postscriptTooltip: 'ゲーム終了後に実行されるスクリプト',
},
extensions: {
title: 'エクステンション',
bepInExConsole: 'BepInExコンソール',
audioMode: 'オーディオモード',
audioTooltip:
'排他2チャンネルモードには7EVENDAYSHOLIDAYS-ExclusiveAudioが必要です',
audioShared: '共有',
audio6Ch: '排他6チャンネル',
audio2Ch: '排他2チャンネル',
sampleRate: 'サンプルレート',
blacklist: '曲IDブラックリスト',
blacklistTooltip:
'このID範囲内の譜面のスコアは保存もアップロードもされない',
bonusTracks: 'ボーナストラックのアンロック',
bonusTracksTooltip:
'このオプションを無効にすると、曲リストが整理されます',
saekawa: 'Saekawa設定ファイル',
inohara: 'Inohara設定ファイル',
},
keyboard: {
title: 'キーボード',
tooltip:
'IOモジュールがsegatools内蔵キーボードまたは互換性のあるサードパーティモジュールmu3io.NETなどに設定されている場合のみ適用可能。',
leverMode: 'レバーモード',
mouse: 'マウス',
irTooltip:
'実際のキーボードで演奏する場合は、ir1だけをバインドし、残りはバインドしない。',
},
wine: {
prefix: 'Wineのプレフィックス',
runtime: 'Wineのランタイム',
},
startliner: {
offlineMode: 'オフラインモード',
offlineModeTooltip: 'パッケージストアを無効にする。',
autoUpdate: '自動更新',
verbose: '詳細ログ',
},
},
onboarding: {
or: 'または',
backButton: 'コントローラー背面のボタン',
standard: `
以下のような画面に引っかかるかもしれません:
{bigblack}Aグループの基準機から設定を取得{endbig}
その場合、テストメニューに移動し、{black}ゲーム設定{end}の{black}基準に従う{end}から{black}基準機{end}に切り替える必要があります。
テストメニューは%TESTMENU%でアクセスできます。
`,
'ongeki-system-processing': `
この画面が数分間引っかかるかもしれません。_これは普通のことです_。データのロードに時間がかかるだけです。
<code>7EVENDAYSHOLIDAYS/LoadBoost</code>をインストールすれば、それ以降の起動はずっと速くなります。
`,
'ongeki-lever': `
レバーのキャリブレーションを行わないと、3301エラーが発生する可能性があります。
{black}レバー設定{end}に移動し、レバーを両端に移動させ、{black}終了{end}を押してから{black}保存する{end}を押します。
`,
'chunithm-server': `
この画面に引っかかる場合は、ゲームを再起動してください。
問題が解決しない場合は、{link}ネットワーク設定を確認してください{endlink}
`,
finale: `
STARTボタンを右クリックすれば、いつでもこのページにアクセスできます。
その他のリソース:
- {segaguide}SEGAguide{endlink}
- {twotorial}two-torial{endlink}
## 楽しもう!
`,
},
};

View File

@ -8,6 +8,8 @@ export default {
next: 'Dalej',
skip: 'Pomiń',
close: 'Zamknij',
by: 'od {namespace}',
updateAll: 'ZAKTUALIZUJ WSZYSTKO',
start: {
failed: 'Uruchomienie nie powiodło się',
accept: 'Uruchom mimo to',
@ -43,8 +45,23 @@ export default {
reallyDelete: 'Czy na pewno chcesz usunąć {profile}?',
template: 'Szablon',
importTemplate: 'Importuj szablon',
exportTemplate: 'Eksportuj szablon',
exportTemplate: 'Eksportuj profil',
export: 'Eksportuj',
standardExport: 'Szablon',
diagnostic: 'Diagnostyka',
},
creator: {
header: 'Kreator pakietów',
basic: 'Podstawowe informacje',
name: 'Nazwa',
description: 'Opis',
website: 'Strona internetowa',
type: 'Typ',
rainy: 'Standardowy',
segatools: 'Segatools',
native: 'Natywny',
games: 'Gry',
packageFormat: 'Specyfikacja formatu',
},
store: {
installRecommended: 'Dodaj zalecane pakiety',
@ -53,10 +70,21 @@ export default {
nsfw: 'Pokaż mityczny O.N.G.E.K.I. Sex Mod dlaczego ta opcja w ogóle tu jest',
incompatible:
'Ten pakiet jest obecnie niekompatybilny ze STARTLINEREM.',
missing: 'Niedostępne',
includeCategories: 'Włącz kategorie',
excludeCategories: 'Wyłącz kategorie',
},
pkglist: {
missing: 'Niedostępne',
local: 'Lokalne pakiety',
namespace: 'Po przestrzeni nazw',
type: 'Po typie',
category: 'Po kategorii',
standard: 'Standardowe mody',
native: 'Natywne mody',
segatools: 'segatools',
unsupported: 'Niewspierane',
exclusions: 'Czarna lista:',
},
patch: {
loading: 'Wczytuję...',
noneFound:
@ -178,6 +206,11 @@ export default {
other: 'Inne opcje segatools',
otherTooltip:
'Zaawansowane lub sytuacyjne opcje, które nie są objęte przez STARTLINERA',
prescript: 'Skrypt startowy',
prescriptTooltip: 'Opcjonalny skrypt uruchamiany przed grą.',
postscript: 'Skrypt końcowy',
postscriptTooltip:
'Opcjonalny skrypt uruchamiany po zakończeniu gry.',
},
extensions: {
title: 'Rozszerzenia',
@ -204,6 +237,8 @@ export default {
'Dotyczy tylko wtedy, gdy moduł IO jest ustawiony na wbudowaną emulację lub zgodny moduł (np. mu3io.NET)',
leverMode: 'Tryb wajchy',
mouse: 'Mysz',
irTooltip:
'Jeśli grasz na klawiaturze, ustaw tylko ir1; pozostałe zostaw wyłączone',
},
wine: {
prefix: 'Wine prefix',

View File

@ -189,7 +189,7 @@ export const usePkgStore = defineStore('pkg', {
await this.reloadAll();
},
async install(pkg: Package | undefined) {
async install(pkg: Package | undefined, enable: boolean) {
if (pkg === undefined) {
return;
}
@ -198,6 +198,7 @@ export const usePkgStore = defineStore('pkg', {
await invoke('install_package', {
key: pkgKey(pkg),
force: true,
enable,
});
} catch (err) {
if (pkg !== undefined) {
@ -211,6 +212,7 @@ export const usePkgStore = defineStore('pkg', {
await invoke('install_package', {
key,
force: true,
enable: false,
});
} catch (err) {
console.error(err);
@ -221,7 +223,7 @@ export const usePkgStore = defineStore('pkg', {
const list = [];
for (const pkg of this.allLocal) {
if (pkg.rmt && pkg.rmt.version > pkg.loc!.version) {
list.push(this.install(pkg));
list.push(this.install(pkg, false));
}
}
await Promise.all(list);
@ -322,9 +324,10 @@ export const usePrfStore = defineStore('prf', () => {
const generalStore = useGeneralStore();
const configDir = computed(async () => {
const title = `profile-${current.value?.meta.game}-${current.value?.meta.name}`;
return path.join(
await generalStore.configDir,
`profile-${current.value?.meta.game}-${current.value?.meta.name}`
title
);
});
@ -375,6 +378,9 @@ export const useClientStore = defineStore('client', () => {
const onboarded: Ref<Game[]> = ref([]);
const locale: Ref<Locale> = ref('en');
const currentTab: Ref<string> = ref('users');
const pkgListMode: Ref<'namespace' | 'type' | 'category'> =
ref('namespace');
const hiddenCategories: Ref<string[]> = ref([]);
const _scaleValue = (value: ScaleType) =>
value === 's' ? 1 : value === 'm' ? 1.25 : value === 'l' ? 1.5 : 2;
@ -445,6 +451,14 @@ export const useClientStore = defineStore('client', () => {
if (input.currentTab) {
currentTab.value = input.currentTab;
}
if (input.pkgListMode) {
pkgListMode.value = input.pkgListMode;
}
if (input.hiddenCategories) {
hiddenCategories.value = input.hiddenCategories;
}
await setLocale(locale.value);
await setTheme(theme.value);
} catch (e) {
@ -484,6 +498,8 @@ export const useClientStore = defineStore('client', () => {
onboarded: onboarded.value,
locale: locale.value,
currentTab: currentTab.value,
pkgListMode: pkgListMode.value,
hiddenCategories: hiddenCategories.value,
})
);
};
@ -560,6 +576,8 @@ export const useClientStore = defineStore('client', () => {
timeout,
scaleModel,
currentTab,
pkgListMode,
hiddenCategories,
_scaleValue,
scaleValue,
load,