commit 3eab1e7e49a6b9da50378b72c35e29ce434af2cf Author: CAtlas0784 Date: Sat Oct 31 17:36:42 2026 +0700 feat: initialize GaCy Android project foundation (Kotlin, Jetpack Compose, Material 3) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0e80d33 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties +app/build/ +*.apk +*.aab diff --git a/README.md b/README.md new file mode 100644 index 0000000..7c4044a --- /dev/null +++ b/README.md @@ -0,0 +1,126 @@ +# 📱 GaCy Android + +
+ +**Modern, High-Performance Mobile Companion & Server Manager for the GaCy Ecosystem** +Built with **Kotlin**, **Jetpack Compose**, and **Material Design 3**. + +[![Platform](https://img.shields.io/badge/Platform-Android_8.0%2B_(API_26%2B)-3DDC84?style=flat&logo=android)](https://developer.android.com/) +[![Kotlin](https://img.shields.io/badge/Kotlin-2.0.21-7F52FF?style=flat&logo=kotlin)](https://kotlinlang.org/) +[![Compose](https://img.shields.io/badge/Jetpack_Compose-Material_3-4285F4?style=flat&logo=jetpackcompose)](https://developer.android.com/jetpack/compose) +[![Target ABI](https://img.shields.io/badge/ABI-arm64--v8a-blue?style=flat)](https://developer.android.com/ndk) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + +[English](#-english) | [ภาษาไทย](#-ภาษาไทย) + +
+ +--- + +## 🇹🇭 ภาษาไทย + +### ✨ ฟีเจอร์หลัก (Key Features) + +* **🎮 ระบบจัดการเซิร์ฟเวอร์แบบ Dual-Mode (Local & Remote Server Management):** + * **โหมด Local Embedded:** รันเซิร์ฟเวอร์จำลองภายในอุปกรณ์ Android โดยตรงผ่าน Android Foreground Service (`specialUse`) ไม่ถูกระบบฆ่าขณะสลับไปเล่นเกม + * **โหมด Remote Host:** เชื่อมต่อและควบคุมเซิร์ฟเวอร์ GaCy-GO บน PC ผ่าน Wi-Fi/LAN พร้อมระบบทดสอบค่าความหน่วง (Ping Latency Test) + * **Live Terminal Console:** หน้าต่างแสดงผล Log การทำงานของเซิร์ฟเวอร์แบบ Real-time พร้อมปุ่มคัดลอก (Copy) และล้างหน้าจอ (Clear) +* **🗺️ ระบบวาร์ปแผนที่ 8 โลก 86 ฉาก (Escape Penacony Teleport Engine):** + * ครอบคลุม **8 ดวงดาว 86+ ฉาก** จากฐานข้อมูล `scenes.json` (Astral Express, Herta Space Station, Jarilo-VI, Xianzhou Luofu, Penacony, และแมพเบต้าล่าสุดอย่าง Amphoreus / Planarcadia Beta `20572`) + * ระบบตัวกรองอัจฉริยะ: ⚔️ **Combat Ready** (มีเสา Calyx สำหรับต่อสู้), 🗺️ **Safe Explore** (เดินเล่นปลอดภัย), 🧪 **Beta Exclusive** (ฉากพิเศษสำหรับเบต้า) + * ค้นหาฉากได้รวดเร็วทั้งภาษาไทย ภาษาอังกฤษ ภาษาจีน หรือกรอก Plane ID + * **Custom Coordinates Teleport:** กรอกพิกัด Plane ID, X, Y, Z, Map Layer เองได้อิสระ + * ระบบบันทึกไฟล์คอนฟิก `persistent.json` แบบ **Atomic Operation** ปลอดภัย 100% ไม่สร้างความเสียหายแก่ข้อมูลตัวละคร +* **⚡ คอนโซลคำสั่ง GM บนมือถือ & เชื่อมต่อ GaCy SRTool:** + * **Emergency Unstuck:** ปุ่มปลดบล็อกตัวละครเร่งด่วนผ่าน Heartbeat API (`/api/client/unstuck`) กู้คืนอาการเกมค้าง + * **Mobile GM Command Prompt:** พิมพ์และส่งคำสั่ง GM (`/give`, `/avatar`, `/faketime`) ตรงไปยังเซิร์ฟเวอร์ พร้อมปุ่มคีย์ลัดคำสั่งยอดฮิต + * **GaCy SRTool Web Integration:** ปุ่มลัดเปิดหน้าจัดการกระเป๋า ตัวละคร และสร้าง Relic ผ่าน [GaCy SRTool Web](https://gacy-srtool.vercel.app/) +* **🛠️ เครื่องมือปรับแต่งตัวละครและเซิร์ฟเวอร์ (Character & Server Configurator):** + * **🔑 Server Key / Password:** ตั้งรหัสผ่านเซิร์ฟเวอร์เพื่อความปลอดภัย + * **👤 Trailblazer Configurator:** สลับเพศผู้บุกเบิก (Stelle หญิง / Caelus ชาย) และเลือก Path (ทำลายล้าง, อนุรักษ์, ประสาน, ลบล้าง, ปิติสุข) ได้ทันที + * **⭐ 3 EX Eidolon (Rank) Manager:** สลับเปิด/ปิดสกิลพิเศษ 3 EX สำหรับตัวละครยอดนิยม (JingYuan [1204], DanHeng IL [1213], Acheron [1308]) + * **⚡ Send Hotfix (Online/Offline Toggle):** เปิดสำหรับดาวน์โหลด Assets ออนไลน์จาก Hoyoverse หรือปิดเพื่อเล่นแบบออฟไลน์ 100% แก้ปัญหาหน้าจอดำ +* **🎨 หน้าต่าง Cyberpunk Dark สวยงาม ลื่นไหล:** + * ออกแบบด้วย **Material Design 3** สีสันเข้าชุดกับ G-Launcher Desktop และ GaCy-GO + * รองรับ 3 ภาษาภายในตัว: **ภาษาไทย (TH)**, **English (EN)**, และ **简体中文 (ZH)** + +--- + +### 🏗️ สถาปัตยกรรมระบบ (Architecture) + +```mermaid +graph TD + subgraph AppLayer [UI Layer: Jetpack Compose] + MainUI[MainScreen & Navigation] + ServerTab[ServerScreen & Live Terminal] + TeleportTab[TeleportScreen & 86 Scenes] + SRToolTab[SRToolScreen & GM Console] + SettingsTab[SettingsScreen & Network Config] + MainUI --> ServerTab + MainUI --> TeleportTab + MainUI --> SRToolTab + MainUI --> SettingsTab + end + + subgraph ServiceLayer [Background Services] + ServerFGS[ServerService - Foreground Service] + LogStream[LogBuffer - Circular StateFlow] + ServerFGS --> LogStream + LogStream --> ServerTab + end + + subgraph DataRepositories [Data & Repository Layer] + SceneRepo[SceneRepository - scenes.json Cache] + PersistRepo[PersistentRepository - persistent.json] + ApiRepo[GaCyApiRepository - OkHttp Client] + PrefsRepo[PreferencesRepository - SharedPreferences] + end + + subgraph GameEcosystem [GaCy Ecosystem & Storage] + GameServer[GaCy-GO Server / Remote PC] + PersistentData[persistent.json / misc Configs] + SRToolWeb[GaCy SRTool Web Application] + StarRailClient[Star Rail Game Client Android] + end + + ServerTab --> PersistRepo + ServerTab --> ServerFGS + TeleportTab --> SceneRepo + TeleportTab --> PersistRepo + SRToolTab --> ApiRepo + SettingsTab --> PrefsRepo + + PersistRepo --> PersistentData + ApiRepo --> GameServer + SRToolTab -.-> SRToolWeb + StarRailClient -.-> GameServer +``` + +--- + +## 🚀 วิธีการคอมไพล์และเปิดโปรเจกต์ (Build & Run) + +### ความต้องการของระบบ (Requirements) +* **Android Studio** (Ladybug / Koala หรือใหม่กว่า) +* **Java Development Kit (JDK)**: JDK 17 หรือ 21 +* **Android SDK**: Compile SDK 35 (Android 15), Min SDK 26 (Android 8.0) + +### 1. เปิดด้วย Android Studio +1. เปิดโปรแกรม **Android Studio** +2. เลือก **Open** แล้วนำทางไปยังโฟลเดอร์ `GaCy Android` +3. รอระบบ Gradle ซิงค์ไลบรารีทั้งหมดให้เสร็จสิ้น + +### 2. คอมไพล์ผ่าน Command Line +```powershell +# คอมไพล์ไฟล์ APK โหมด Debug +.\gradlew.bat assembleDebug + +# ไฟล์ APK จะถูกสร้างไว้ที่: +# app/build/outputs/apk/debug/app-debug.apk +``` + +--- + +## 📜 กฎของโปรเจกต์ (Repository Rules) +* **ห้าม Hardcode ค่าเฉพาะเครื่อง:** ค่า IP, Port, และการตั้งค่าต่างๆ ต้องอ่านและบันทึกผ่าน `PreferencesRepository` หรือ `PersistentRepository` +* **ระบบจัดการไฟล์แบบ Atomic:** การแก้ไขไฟล์ `persistent.json` ต้องเขียนลงไฟล์ `.tmp` ก่อนสลับชื่อ เพื่อป้องกันข้อมูลตัวละครหรือเซฟเสียหาย diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..25872bb --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,65 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "com.gacy.android" + compileSdk = 35 + + defaultConfig { + applicationId = "com.gacy.android" + minSdk = 26 + targetSdk = 35 + versionCode = 1 + versionName = "v1.0.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + ndk { + abiFilters += listOf("arm64-v8a") + } + } + + buildTypes { + release { + isMinifyEnabled = false + signingConfig = signingConfigs.getByName("debug") + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + buildFeatures { + compose = true + buildConfig = true + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation("androidx.lifecycle:lifecycle-service:2.8.6") + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) + implementation(libs.androidx.material.icons.extended) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.okhttp) + + testImplementation("junit:junit:4.13.2") + testImplementation("org.json:json:20240303") + debugImplementation(libs.androidx.ui.tooling) +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..ee03074 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,2 @@ +# Proguard rules for GaCy Android +-dontobfuscate diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..ee934b5 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/assets/scenes.json b/app/src/main/assets/scenes.json new file mode 100644 index 0000000..84254bf --- /dev/null +++ b/app/src/main/assets/scenes.json @@ -0,0 +1,2130 @@ +{ + "version": "4.7.0-BETA-UPDATE", + "updated_at": "2026-10-13", + "total_worlds": 8, + "total_scenes": 85, + "total_combat_scenes": 16, + "total_explore_scenes": 69, + "planets": [ + { + "id": "astral_express", + "name": "Astral Express", + "name_th": "ขบวนรถไฟ Astral Express", + "name_zh": "星穹列车", + "icon": "[Express]", + "scenes": [ + { + "name": "Parlor Car", + "name_th": "ตู้โดยสารหลัก (Parlor Car)", + "name_zh": "观景车厢", + "plane_id": 10000, + "x": 216, + "y": 0, + "z": 22770, + "map_layer": 1, + "desc": "Main lobby coach with Pom-Pom, Phonograph, and Stargazing Parlor", + "desc_th": "ตู้โดยสารหลัก ล็อบบี้กลางขบวน พร้อม Pom-Pom และเครื่องเล่นแผ่นเสียง", + "desc_zh": "列车主控观景大厅,帕姆与留声机所在车厢", + "calyx_x": 216, + "calyx_y": -999999, + "calyx_z": 22770, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Party Car (Passenger Cabin)", + "name_th": "ตู้โดยสารพักผ่อน (Passenger Cabin)", + "name_zh": "客房车厢", + "plane_id": 10002, + "x": -4785, + "y": 15000, + "z": 57800, + "map_layer": 1, + "desc": "Trailblazer's private room and passenger corridor", + "desc_th": "ห้องนอนส่วนตัวของผู้บุกเบิกและทางเดินห้องพักผู้โดยสาร", + "desc_zh": "开拓者专属客房与乘客休息走廊", + "calyx_x": -4785, + "calyx_y": -999999, + "calyx_z": 57800, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + } + ] + }, + { + "id": "space_station", + "name": "Herta Space Station", + "name_th": "สถานีอวกาศ Herta", + "name_zh": "空间站「黑塔」", + "icon": "[HSS]", + "scenes": [ + { + "name": "Master Control Zone", + "name_th": "Master Control Zone (โซนควบคุมหลัก)", + "name_zh": "主控舱段", + "plane_id": 10001, + "x": -53121, + "y": 4912, + "z": -58063, + "map_layer": 1, + "desc": "Main command bridge, Asta headquarters, and Herta office portal", + "desc_th": "สะพานบัญชาการหลัก ศูนย์กลางสั่งการของ Asta และทางเข้าห้องทำงานของ Herta", + "desc_zh": "总指挥大桥,艾丝妲驻守指挥所及黑塔办公室入口", + "calyx_x": -53121, + "calyx_y": -999999, + "calyx_z": -58063, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Base Zone", + "name_th": "Base Zone (โซนฐานทัพ)", + "name_zh": "基座舱段", + "plane_id": 20001, + "x": -15123, + "y": 147, + "z": 125844, + "map_layer": 1, + "desc": "Reception hall, security gates, and medical bay monitoring center", + "desc_th": "โถงต้อนรับ ประตูคัดกรองความปลอดภัย และศูนย์พยาบาล", + "desc_zh": "接待大厅、安检隔离闸口与医疗观察区", + "calyx_x": -15123, + "calyx_y": -999999, + "calyx_z": 125844, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Storage Zone", + "name_th": "Storage Zone (โซนเก็บของ)", + "name_zh": "收容舱段", + "plane_id": 20002, + "x": 38130, + "y": 3444, + "z": -113940, + "map_layer": 1, + "desc": "Curio collection galleries and special specimen containment", + "desc_th": "ห้องจัดแสดงวัตถุหายาก Curio และแคปซูลกักกันตัวอย่างสิ่งมีชีวิตพิเศษ", + "desc_zh": "奇物陈列馆与特殊样本收容实验间", + "calyx_x": 38130, + "calyx_y": -999999, + "calyx_z": -113940, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Supply Zone", + "name_th": "Supply Zone (โซนเสบียง)", + "name_zh": "支援舱段", + "plane_id": 20003, + "x": 4560, + "y": 79626, + "z": 99300, + "map_layer": 1, + "desc": "Railway platform, maintenance depot, and power core substations", + "desc_th": "ชานชาลารถไฟ อู่ซ่อมบำรุง และสถานีแกนพลังงานหลัก", + "desc_zh": "星轨月台、检修列车车库与能源中枢站", + "calyx_x": 4560, + "calyx_y": -999999, + "calyx_z": 99300, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Seclusion Zone", + "name_th": "Seclusion Zone (โซนกักกันทดลอง)", + "name_zh": "禁闭舱段", + "plane_id": 20004, + "x": 7320, + "y": 412082, + "z": -1500, + "map_layer": 1, + "desc": "Ruan Mei secret incubator laboratory and subterranean testing floor", + "desc_th": "ห้องเพาะเลี้ยงชีวภาพลับของ Ruan Mei และชั้นทดลองใต้พิภพ", + "desc_zh": "阮·梅绝密生命孵化培育温室与深层试验场", + "calyx_x": 7320, + "calyx_y": -999999, + "calyx_z": -1500, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + } + ] + }, + { + "id": "jarilo_vi", + "name": "Jarilo-VI (Belobog)", + "name_th": "Jarilo-VI (Belobog)", + "name_zh": "雅利洛-Ⅵ", + "icon": "[Belobog]", + "scenes": [ + { + "name": "Administrative District (Overworld)", + "name_th": "Administrative District (เขตการปกครองชั้นบน)", + "name_zh": "行政区", + "plane_id": 10101, + "x": -10138, + "y": 10, + "z": 78898, + "map_layer": 1, + "desc": "Belobog grand plaza, Goethe Hotel, Qlipoth Fort", + "desc_th": "จัตุรัสกลางเมือง Belobog โรงแรม Goethe และป้อมปราการ Qlipoth Fort", + "desc_zh": "贝洛伯格中央行政广场、歌德大饭店与克里珀堡", + "calyx_x": -10138, + "calyx_y": -999999, + "calyx_z": 78898, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Boulder Town (Underworld)", + "name_th": "Boulder Town (เมืองใต้พิภพ)", + "name_zh": "磐岩镇", + "plane_id": 10102, + "x": -15390, + "y": -617, + "z": 57320, + "map_layer": 1, + "desc": "Underworld slums, Fight Club, Natasha clinic", + "desc_th": "สลัมใต้พิภพ คลับต่อยมวย Fight Club และคลินิกคุณหมอ Natasha", + "desc_zh": "下层区中心城镇、搏击俱乐部与娜塔莎诊所", + "calyx_x": -15390, + "calyx_y": -999999, + "calyx_z": 57320, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Outlying Snow Plains", + "name_th": "Outlying Snow Plains (ทุ่งหิมะรอบนอก)", + "name_zh": "城郊雪原", + "plane_id": 20101, + "x": 42548, + "y": 3716, + "z": -38422, + "map_layer": 1, + "desc": "Frozen wilderness outside Belobog gates", + "desc_th": "ทุ่งหิมะเยือกแข็งนอกประตูกำแพงเมือง Belobog", + "desc_zh": "贝洛伯格城墙外的极寒荒野雪原", + "calyx_x": 41300, + "calyx_y": 3724, + "calyx_z": -36479, + "calyx_group_id": 56, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": true, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Backwater Pass", + "name_th": "Backwater Pass (ตรอก Backwater)", + "name_zh": "边缘通路", + "plane_id": 20111, + "x": -17550, + "y": 1876, + "z": 9728, + "map_layer": 1, + "desc": "Abandoned residential alleyways and Fragmentum rifts", + "desc_th": "ตรอกซอยที่อยู่อาศัยรกร้างและรอยแยก Fragmentum", + "desc_zh": "被侵蚀废弃的旧居住街巷与裂界迷宫", + "calyx_x": -20000, + "calyx_y": 1876, + "calyx_z": 9600, + "calyx_group_id": 26, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": true, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Great Mine", + "name_th": "Great Mine (เหมืองใหญ่)", + "name_zh": "大矿区", + "plane_id": 20121, + "x": 186692, + "y": 55115, + "z": -170540, + "map_layer": 1, + "desc": "Geomarrow mining tunnels and vagrant camps", + "desc_th": "อุโมงค์เหมืองแร่ Geomarrow และค่ายผู้พลัดถิ่น", + "desc_zh": "地髓矿坑隧道、矿工生活区与流浪者营地", + "calyx_x": 186417, + "calyx_y": 55112, + "calyx_z": -167952, + "calyx_group_id": 50, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": true, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Rivet Town", + "name_th": "Rivet Town (เมือง Rivet)", + "name_zh": "铆钉镇", + "plane_id": 20122, + "x": -34650, + "y": -130, + "z": 35570, + "map_layer": 1, + "desc": "Destroyed underworld orphanage town", + "desc_th": "เมืองเก่าใต้พิภพที่พังทลายและบ้านเด็กกำพร้า", + "desc_zh": "下层区废墟小镇与旧孤儿院故址", + "calyx_x": -34650, + "calyx_y": -999999, + "calyx_z": 35570, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Robot Settlement", + "name_th": "Robot Settlement (ถิ่นฐานหุ่นยนต์)", + "name_zh": "机械聚落", + "plane_id": 20123, + "x": -9930, + "y": 25392, + "z": 80580, + "map_layer": 1, + "desc": "Svarog mechanical palace and scrap junk yards", + "desc_th": "วังหุ่นยนต์ของ Svarog และลานเศษซากจักรกล", + "desc_zh": "史瓦罗统御的机械聚落据点与垃圾回收区", + "calyx_x": -9930, + "calyx_y": -999999, + "calyx_z": 80580, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Silvermane Guard Restricted Zone", + "name_th": "Silvermane Guard Restricted Zone (เขตหวงห้ามกองกำลัง Silvermane)", + "name_zh": "铁卫禁区", + "plane_id": 20131, + "x": 116730, + "y": 2007, + "z": -93330, + "map_layer": 1, + "desc": "Military frontline fortress and barrier trenches", + "desc_th": "ป้อมปราการแนวรบทางทหารและค่ายคูด่านกั้น", + "desc_zh": "银鬃铁卫前线军事据点与防御战壕工事", + "calyx_x": 116730, + "calyx_y": -999999, + "calyx_z": -93330, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Corridor of Fading Echoes", + "name_th": "Corridor of Fading Echoes (ระเบียงเสียงสะท้อน)", + "name_zh": "残响回廊", + "plane_id": 20132, + "x": 5600, + "y": 3900, + "z": 256200, + "map_layer": 1, + "desc": "Ruined military streets and church ruins", + "desc_th": "ถนนทหารที่พังทลาย ซากปรักหักพังของโบสถ์เก่า", + "desc_zh": "旧卫城要塞废墟与残破大教堂街道", + "calyx_x": 5600, + "calyx_y": -999999, + "calyx_z": 256200, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Everwinter Hill", + "name_th": "Everwinter Hill (เนินเขาเหมันต์นิรันดร์)", + "name_zh": "永冬岭", + "plane_id": 20134, + "x": -149280, + "y": -6772, + "z": -52730, + "map_layer": 1, + "desc": "Cocolia boss summit, Engine of Creation ruins", + "desc_th": "จุดสูงสุดยอดเขา Cocolia Boss และซากเครื่องจักร Engine of Creation", + "desc_zh": "可可利亚决战山巅平台与造物引擎核心遗迹", + "calyx_x": -149280, + "calyx_y": -999999, + "calyx_z": -52730, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Pillars of Creation", + "name_th": "Pillars of Creation (เสาหลักแห่งการสรรค์สร้าง)", + "name_zh": "造物之柱", + "plane_id": 20135, + "x": -52104, + "y": 3905, + "z": 52885, + "map_layer": 1, + "desc": "Engine of Creation repair overlook", + "desc_th": "จุดชมวิวการบูรณะซ่อมแซม Engine of Creation", + "desc_zh": "造物引擎修缮工程前线观景台", + "calyx_x": -52104, + "calyx_y": -999999, + "calyx_z": 52885, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Old Weapon Testing Ground", + "name_th": "Old Weapon Testing Ground (ลานทดสอบอาวุธเก่า)", + "name_zh": "旧武器试验场", + "plane_id": 20136, + "x": -3368, + "y": 10777, + "z": 46081, + "map_layer": 1, + "desc": "Aetherium Wars battle arena (v1.4)", + "desc_th": "ลานประลองทัวร์นาเมนต์ Aetherium Wars", + "desc_zh": "以太战线对决锦标赛擂台竞技场", + "calyx_x": -3368, + "calyx_y": -999999, + "calyx_z": 46081, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + } + ] + }, + { + "id": "xianzhou_luofu", + "name": "Xianzhou Luofu", + "name_th": "Xianzhou Luofu", + "name_zh": "仙舟「罗浮」", + "icon": "[Luofu]", + "scenes": [ + { + "name": "Central Starskiff Haven", + "name_th": "Central Starskiff Haven (ท่าดาวลอยฟ้ากลาง)", + "name_zh": "星槎海中枢", + "plane_id": 10201, + "x": 21417, + "y": 5994, + "z": -28394, + "map_layer": 1, + "desc": "Main commercial port, Earth Commission, and palace canal", + "desc_th": "ท่าเทียบเรือลอยฟ้าหลัก กรมการเกษตร และคลองพระราชวัง", + "desc_zh": "核心客运商贸码头、地衡司与王府碧波河道", + "calyx_x": 21417, + "calyx_y": -999999, + "calyx_z": -28394, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Exalting Sanctum", + "name_th": "Exalting Sanctum (แดนศักดิ์สิทธิ์ Exalting)", + "name_zh": "长乐天", + "plane_id": 10202, + "x": 9500, + "y": -3486, + "z": -22500, + "map_layer": 1, + "desc": "Civilian cultural district and Realm-Keeping Commission", + "desc_th": "ย่านวัฒนธรรมพลเรือน จัตุรัสการค้า และศาลา Realm-Keeping", + "desc_zh": "凡尘居民繁华街区与神策府近邻聚落", + "calyx_x": 9500, + "calyx_y": -999999, + "calyx_z": -22500, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Aurum Alley (Commercial Night Market)", + "name_th": "Aurum Alley (ตรอกตลาดกลางคืน Aurum)", + "name_zh": "金人巷", + "plane_id": 10202, + "x": -59880, + "y": -2037, + "z": -67800, + "map_layer": 1, + "desc": "Bustling night market bazaar, food stalls, and docks", + "desc_th": "ตลาดกลางคืนอันคึกคัก แผงอาหารริมทาง และท่าเทียบสินค้า", + "desc_zh": "古色古香的夜市商肆、风味小吃街与旧集市码头", + "calyx_x": -59880, + "calyx_y": -999999, + "calyx_z": -67800, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Cloudford", + "name_th": "Cloudford (ท่าเทียบเมฆา)", + "name_zh": "流云渡", + "plane_id": 20211, + "x": -28982, + "y": 3930, + "z": -65719, + "map_layer": 1, + "desc": "Cargo shipping container port and customs terminals", + "desc_th": "ท่าเรือตู้คอนเทนเนอร์ขนส่งสินค้าและด่านตรวจศุลกากร", + "desc_zh": "星运物流集装箱转运大港与通关闸口", + "calyx_x": -28461, + "calyx_y": 3930, + "calyx_z": -64813, + "calyx_group_id": 234, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": true, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Stargazer Navalia", + "name_th": "Stargazer Navalia (อู่ต่อเรือดวงดาว)", + "name_zh": "迴星港", + "plane_id": 20212, + "x": 133300, + "y": 8008, + "z": -1406, + "map_layer": 1, + "desc": "Shipbuilding docks, starskiff assembly bays, and forge rifts", + "desc_th": "อู่ต่อเรือเหาะ อู่ประกอบดาวลอยฟ้า และเตาหลอมจักรกล", + "desc_zh": "星槎造船厂、龙骨船坞组装泊位与锻造裂口", + "calyx_x": 133300, + "calyx_y": -999999, + "calyx_z": -1406, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Divination Commission", + "name_th": "Divination Commission (ฝ่ายพยากรณ์)", + "name_zh": "太卜司", + "plane_id": 20221, + "x": 53250, + "y": 10913, + "z": -115420, + "map_layer": 1, + "desc": "Matrix of Prescience sublime divination spires", + "desc_th": "หอคอยทำนาย Matrix of Prescience และแท่นคำนวณดวงดาว", + "desc_zh": "穷观大阵、太卜司观星楼台与星算枢纽", + "calyx_x": 53860, + "calyx_y": 10913, + "calyx_z": -114603, + "calyx_group_id": 215, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": true, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Artisanship Commission", + "name_th": "Artisanship Commission (ฝ่ายช่างฝีมือ)", + "name_zh": "工造司", + "plane_id": 20222, + "x": 81970, + "y": 60421, + "z": 37320, + "map_layer": 1, + "desc": "Arcane weapon smithy, forge canals, and smelting terraces", + "desc_th": "โรงตีเหล็กอาวุธเวทมนตร์ คลองหลอม และลานเตาเผาช่างศิลป์", + "desc_zh": "机巧机关兵器作坊、熔炼台地与工匠船坞", + "calyx_x": 82910, + "calyx_y": 60421, + "calyx_z": 37870, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": true, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Fyxestroll Garden", + "name_th": "Fyxestroll Garden (สวนหรรษา Fyxestroll)", + "name_zh": "绥园", + "plane_id": 20223, + "x": -1363, + "y": -9077, + "z": -29500, + "map_layer": 1, + "desc": "Haunted bamboo groves, heliobus suppression towers, and mist lakes", + "desc_th": "ป่าไผ่สิงสู่ หอคอยสะกดปีศาจ Heliobus และทะเลสาบสายหมอก", + "desc_zh": "岁阳镇妖塔、幽冥翠竹林与降妖镇灵古园", + "calyx_x": -1363, + "calyx_y": -999999, + "calyx_z": -29500, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Alchemy Commission", + "name_th": "Alchemy Commission (ฝ่ายปรุงยา)", + "name_zh": "丹鼎司", + "plane_id": 20231, + "x": 79710, + "y": 69528, + "z": -247520, + "map_layer": 1, + "desc": "Elixir Research pavilions, healing clinics, and dragon docks", + "desc_th": "ศาลาวิจัยยาอายุวัฒนะ คลินิกรักษาโรค และท่าเรือมังกร", + "desc_zh": "长生不老丹药研究阁、百草医馆与渡海栈桥", + "calyx_x": 79710, + "calyx_y": -999999, + "calyx_z": -247520, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Scalegorge Waterscape", + "name_th": "Scalegorge Waterscape (ช่องเขาเกล็ดมังกร)", + "name_zh": "鳞渊境", + "plane_id": 20232, + "x": -124370, + "y": 11839, + "z": 5050, + "map_layer": 1, + "desc": "Parted celestial sea, Lunarescent monument, and Arbor roots", + "desc_th": "ทะเลแยกศักดิ์สิทธิ์ อนุสาวรีย์ Lunarescent และรากไม้ Ambrosial Arbor", + "desc_zh": "持明古海开海奇观、月晷丰饶玄根祭坛", + "calyx_x": -124370, + "calyx_y": -999999, + "calyx_z": 5050, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "The Shackling Prison", + "name_th": "The Shackling Prison (คุกตรวนวิญญาณ)", + "name_zh": "幽囚狱", + "plane_id": 20241, + "x": -176680, + "y": 199270, + "z": 5400, + "map_layer": 1, + "desc": "High-security subterranean prison where Borisin warheads are sealed", + "desc_th": "คุกใต้พิภพความมั่นคงสูงสุดที่คุมขังหัวหน้าเผ่า Borisin", + "desc_zh": "深渊重罪黑牢、步离人战首封印禁地", + "calyx_x": -176680, + "calyx_y": -999999, + "calyx_z": 5400, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Skysplitter (The Wardance Arena)", + "name_th": "Skysplitter (เรือรบประลองยุทธ์ Skysplitter)", + "name_zh": "竞锋舰", + "plane_id": 20242, + "x": 48950, + "y": -41002, + "z": 62440, + "map_layer": 1, + "desc": "Massive martial sky-dreadnought hosting the Wardance ceremony", + "desc_th": "เรือรบประจัญบานลอยฟ้ายักษ์ ลานประลองพิธีประลองยุทธ์ Wardance", + "desc_zh": "演武仪典擂台巨型战舰、八方豪杰比武场", + "calyx_x": 48950, + "calyx_y": -999999, + "calyx_z": 62440, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + } + ] + }, + { + "id": "penacony", + "name": "Penacony (Planet of Festivities)", + "name_th": "Penacony (แดนแห่งการเฉลิมฉลอง)", + "name_zh": "匹诺康尼", + "icon": "[Penacony]", + "scenes": [ + { + "name": "Golden Hour (Commercial District)", + "name_th": "Golden Hour (ช่วงเวลาทองคำ)", + "name_zh": "黄金的时刻", + "plane_id": 10301, + "x": 0, + "y": -14383, + "z": 44219, + "map_layer": 1, + "desc": "Glitz and neon metropolis with SoulGlad fountains and malls", + "desc_th": "มหานครแสงนีออน น้ำพุ SoulGlad และห้างสรรพสินค้าแห่งความสุข", + "desc_zh": "霓虹璀璨的欢庆不夜城,苏乐达喷泉与商厦", + "calyx_x": 0, + "calyx_y": -999999, + "calyx_z": 44219, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Dreamflux Reef (Underground Refuge)", + "name_th": "Dreamflux Reef (แนวปะการังกระแสฝัน)", + "name_zh": "流梦礁", + "plane_id": 10304, + "x": 780, + "y": -153008, + "z": -621450, + "map_layer": 1, + "desc": "Underground memory sea, slumberland, and Mikhail refuge", + "desc_th": "ทะเลความทรงจำใต้พิภพ แดนหลับใหล และที่หลบภัยของ Mikhail", + "desc_zh": "梦境深潜记忆之海、无梦者睡乡与米哈伊尔避难所", + "calyx_x": 780, + "calyx_y": -999999, + "calyx_z": -621450, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "The Reverie (Reality Hotel)", + "name_th": "The Reverie (โรงแรมในโลกจริง)", + "name_zh": "「白日梦」酒店-现实", + "plane_id": 10305, + "x": 197, + "y": 1547, + "z": -380008, + "map_layer": 1, + "desc": "Luxury physical hotel with check-in lobby and VIP suites", + "desc_th": "โรงแรมหรูในโลกแห่งความจริง ล็อบบี้ต้อนรับ และห้องสูท VIP", + "desc_zh": "星际豪华大酒店现实实体,VIP接待贵宾厅", + "calyx_x": 197, + "calyx_y": -999999, + "calyx_z": -380008, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Penacony Paperfold University College", + "name_th": "Penacony Paperfold University (มหาวิทยาลัย Paperfold)", + "name_zh": "匹诺康尼折纸大学学院", + "plane_id": 10306, + "x": 0, + "y": 4903, + "z": 26000, + "map_layer": 1, + "desc": "Dreamscape academy campus, lecture halls, and music arena", + "desc_th": "วิทยาเขตสถาบันการศึกษาในแดนฝัน หอประชุม และเวทีดนตรี", + "desc_zh": "梦境折纸学术学府校区、阶梯讲堂与音符摇滚广场", + "calyx_x": 0, + "calyx_y": -999999, + "calyx_z": 26000, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "The Radiant Feldspar (Trailblaze-Stern)", + "name_th": "The Radiant Feldspar (เรือบินยักษ์ดวงดาว)", + "name_zh": "晖长石号", + "plane_id": 10341, + "x": -153, + "y": 1099, + "z": -91164, + "map_layer": 1, + "desc": "Grand luxury flying airship overlooking the sea of dreams", + "desc_th": "เรือเหาะลอยฟ้าระดับหรูหรา บินชมทิวทัศน์มหาสมุทรแห่งความฝัน", + "desc_zh": "巡航梦境海洋的超级豪华浮空飞艇", + "calyx_x": -153, + "calyx_y": -999999, + "calyx_z": -91164, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "The Reverie (Dreamscape Hotel)", + "name_th": "The Reverie (โรงแรมในแดนฝัน)", + "name_zh": "「白日梦」酒店-梦境", + "plane_id": 20311, + "x": 118093, + "y": 32410, + "z": -330955, + "map_layer": 1, + "desc": "Haunted memory inversion of the luxury hotel", + "desc_th": "ภาพสะท้อนหลอนในความทรงจำกลับด้านของโรงแรมหรู", + "desc_zh": "扭曲颠倒的白日梦酒店梦境异变空间", + "calyx_x": 117147, + "calyx_y": 32410, + "calyx_z": -330955, + "calyx_group_id": 303, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": true, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "A Child's Dream", + "name_th": "A Child's Dream (ความฝันวัยเยาว์)", + "name_zh": "稚子的梦", + "plane_id": 20312, + "x": -123138, + "y": 11511, + "z": -164410, + "map_layer": 1, + "desc": "Surreal inverted memory maze, floating clocks, and toy corridors", + "desc_th": "เขาวงกตความทรงจำกลับด้าน นาฬิกาลอยได้ และทางเดินของเล่น", + "desc_zh": "重力失衡倒错空间、悬浮时钟与纯真玩具回廊", + "calyx_x": -122759, + "calyx_y": 11511, + "calyx_z": -165303, + "calyx_group_id": 219, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": true, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Dream's Edge (Combat Rooftop)", + "name_th": "Dream's Edge (ขอบเขตแห่งฝัน)", + "name_zh": "筑梦边境", + "plane_id": 20313, + "x": 40748, + "y": 192819, + "z": 439218, + "map_layer": 1, + "desc": "Skyline under construction with Dream's Edge bridges", + "desc_th": "ตึกระฟ้ากำลังก่อสร้าง สะพานขอบเขตแห่งฝัน และดาดฟ้าท้าทายศัตรู", + "desc_zh": "悬空云端建筑工地、筑梦拼图折叠高楼天台", + "calyx_x": 40748, + "calyx_y": 192810, + "calyx_z": 441000, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": true, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Clock Studios Theme Park", + "name_th": "Clock Studios Theme Park (สวนสนุก Clock Studios)", + "name_zh": "克劳克影视乐园", + "plane_id": 20321, + "x": 114380, + "y": 47134, + "z": 6330, + "map_layer": 1, + "desc": "Clockie entertainment complex, screening room, and filming lots", + "desc_th": "ศูนย์รวมความบันเทิงการ์ตูน Clockie โรงฉายภาพยนตร์ และกองถ่ายหนัง", + "desc_zh": "钟表小子动画制片厂主题游乐园与放映场", + "calyx_x": 114380, + "calyx_y": -999999, + "calyx_z": 6330, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Dewlight Pavilion", + "name_th": "Dewlight Pavilion (คฤหาสน์ Dewlight)", + "name_zh": "朝露公馆", + "plane_id": 20322, + "x": -1037, + "y": 95000, + "z": -34125, + "map_layer": 1, + "desc": "Family diplomatic residence and Sunday city miniature diorama", + "desc_th": "คฤหาสน์การทูตของตระกูล The Family และห้องโมเดลจำลองเมืองของ Sunday", + "desc_zh": "家族五大家系议事公馆与星期日微缩沙盘室", + "calyx_x": -1037, + "calyx_y": -999999, + "calyx_z": -34125, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "SoulGlad Scorchsand Audition Venue", + "name_th": "SoulGlad Scorchsand Audition Venue (สนามประลอง SoulGlad)", + "name_zh": "苏乐达™热砂海选会场", + "plane_id": 20331, + "x": -53, + "y": 1706, + "z": -32120, + "map_layer": 1, + "desc": "Gladiatorial trial arenas, shooting challenges, and coliseums", + "desc_th": "สังเวียนประลองคนเก่ง ลานท้าทายยิงเป้า และโคลอสเซียมทะเลทราย", + "desc_zh": "狂热热砂海选大竞技场、决斗舞台与狂欢关卡", + "calyx_x": -53, + "calyx_y": -999999, + "calyx_z": -32120, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Penacony Grand Theater", + "name_th": "Penacony Grand Theater (โรงละครใหญ่)", + "name_zh": "匹诺康尼大剧院", + "plane_id": 20332, + "x": 150, + "y": 11551, + "z": 430, + "map_layer": 1, + "desc": "Monumental opera house where Order and Harmony battle", + "desc_th": "โรงอุปรากรอันยิ่งใหญ่ สถานที่ตัดสินชะตาระหว่าง Order และ Harmony", + "desc_zh": "宏伟绝伦的梦境大剧院,秩序与同谐终极对决舞台", + "calyx_x": 150, + "calyx_y": -999999, + "calyx_z": 430, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + } + ] + }, + { + "id": "amphoreus", + "name": "Amphoreus (Eternal Holy Land)", + "name_th": "Amphoreus (แดนศักดิ์สิทธิ์นิรันดร์)", + "name_zh": "翁法罗斯", + "icon": "[Amphoreus]", + "scenes": [ + { + "name": "Okhema - Eternal Holy City (Agora / Main Town)", + "name_th": "Okhema - นครศักดิ์สิทธิ์นิรันดร์ (จัตุรัสกลางเมือง Agora)", + "name_zh": "「永恒圣城」奥赫玛", + "plane_id": 10401, + "x": 89730, + "y": -6120, + "z": -492460, + "map_layer": 1, + "desc": "Capital holy city, sacred colonnade, marketplace, and forum", + "desc_th": "มหานครศักดิ์สิทธิ์ ระเบียงเสาหิน ตลาดการค้า และลานสภาประชาชน", + "desc_zh": "永恒圣城首都卫城、神圣列柱走廊与市民集会广场", + "calyx_x": 89730, + "calyx_y": -999999, + "calyx_z": -492460, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Okhema - Vortex of Genesis", + "name_th": "Okhema - วังวนแห่งปฐมกาล (Vortex of Genesis)", + "name_zh": "创世涡心", + "plane_id": 10402, + "x": 14, + "y": 948, + "z": -37960, + "map_layer": 1, + "desc": "Sacred vortex spring where holy waters gather", + "desc_th": "น้ำพุวังวนศักดิ์สิทธิ์ แหล่งรวมสายน้ำบริสุทธิ์แห่งปฐมกาล", + "desc_zh": "神圣创生泉眼漩涡,汇聚天地圣水之地", + "calyx_x": 14, + "calyx_y": -999999, + "calyx_z": -37960, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Okhema - Holy Acropolis Sky Bridge", + "name_th": "Okhema - สะพานลอยฟ้าวิหารศักดิ์สิทธิ์ (Sky Bridge)", + "name_zh": "「云端遗堡」晨昏之眼", + "plane_id": 10403, + "x": 240, + "y": 27786, + "z": -824570, + "map_layer": 1, + "desc": "High-altitude bridge overlooking the radiant sacred valley", + "desc_th": "สะพานลอยฟ้ามุมสูง ทอดมองข้ามหุบเขาศักดิ์สิทธิ์อันรุ่งโรจน์", + "desc_zh": "高空卫城凌云连桥,俯瞰辉煌圣谷全景", + "calyx_x": 240, + "calyx_y": -999999, + "calyx_z": -824570, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Okhema - Lower Sunken Valley", + "name_th": "Okhema - หุบเขาลึกใต้พิภพ (Lower Sunken Valley)", + "name_zh": "「龙骸古城」斯缇科西亚", + "plane_id": 10404, + "x": 89730, + "y": -6120, + "z": -492460, + "map_layer": 1, + "desc": "Subterranean terrace gardens beneath the grand acropolis", + "desc_th": "สวนระเบียงหินใต้ดิน ทอดตัวอยู่เบื้องล่างวิหารลอยฟ้า", + "desc_zh": "宏伟卫城下方的沉降式梯田幽谷花庭", + "calyx_x": 89730, + "calyx_y": -999999, + "calyx_z": -492460, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Okhema - Beyond Time: An Eternal Page", + "name_th": "Okhema - เหนือกาลเวลา: บันทึกหน้านิรันดร์ (Beyond Time)", + "name_zh": "「岁月彼岸」一页永恒", + "plane_id": 10405, + "x": 466272, + "y": 164890, + "z": 49231, + "map_layer": 1, + "desc": "Timeless celestial library chamber holding the prophecy pages", + "desc_th": "หอสมุดสวรรค์เหนือกาลเวลา เก็บรักษาบันทึกหน้าพยากรณ์นิรันดร์", + "desc_zh": "超越时光之流的永恒圣殿卷轴室", + "calyx_x": 466272, + "calyx_y": -999999, + "calyx_z": 49231, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Castrum Kremnos - Aedes Elysiae (Bathhouse)", + "name_th": "Castrum Kremnos - โรงอาบน้ำทวยเทพ (Aedes Elysiae)", + "name_zh": "「神谕圣地」雅努萨波利斯", + "plane_id": 10441, + "x": -37317, + "y": -12212, + "z": 11924, + "map_layer": 1, + "desc": "Temple bathhouse of the gods with cascading mineral pools", + "desc_th": "โรงอาบน้ำศักดิ์สิทธิ์แห่งทวยเทพ สระแร่ธรรมชาติหลั่งริน", + "desc_zh": "众神静谧神圣浴场,跌水温泉台苑", + "calyx_x": -37317, + "calyx_y": -999999, + "calyx_z": 11924, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Castrum Kremnos - Temple of the Titans", + "name_th": "Castrum Kremnos - วิหารไททัน (Temple of the Titans)", + "name_zh": "「全世矩阵」无名泰坦大墓", + "plane_id": 20411, + "x": -26968, + "y": 78953, + "z": 14457, + "map_layer": 1, + "desc": "Monumental fortress temple dedicated to ancient titan warriors", + "desc_th": "ป้อมปราการวิหารหินขนาดมหึมา สร้างอุทิศแด่นักรบไททันโบราณ", + "desc_zh": "崇奉远古泰坦巨人之坚固要塞主殿", + "calyx_x": -26550, + "calyx_y": 78903, + "calyx_z": 16470, + "calyx_group_id": 294, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": true, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Castrum Kremnos - Hall of Prophecy [Dawn State]", + "name_th": "Castrum Kremnos - โถงคำทำนาย [รุ่งอรุณ / กลางวัน]", + "name_zh": "「命运重渊」雅努萨波利斯", + "plane_id": 20412, + "x": -83587, + "y": 8598, + "z": -48432, + "map_layer": 1, + "desc": "Titan council hall illuminated by radiant dawn sunlight", + "desc_th": "โถงสภาไททัน อาบด้วยแสงอาทิตย์รุ่งอรุณสีทองอร่าม", + "desc_zh": "破晓曦光笼罩下的泰坦预言议政大殿", + "calyx_x": -83587, + "calyx_y": -999999, + "calyx_z": -48432, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Castrum Kremnos - Hall of Prophecy [Evernight State]", + "name_th": "Castrum Kremnos - โถงคำทำนาย [ราตรีนิรันดร์ / กลางคืน]", + "name_zh": "「命运重渊」雅努萨波利斯【永夜】", + "plane_id": 20412, + "x": -83587, + "y": 8598, + "z": -48432, + "map_layer": 1, + "desc": "Titan council hall veiled in starry twilight and moon shadows", + "desc_th": "โถงสภาไททัน ท่ามกลางราตรีกาล ดวงดาวส่องประกายและเงาจันทร์", + "desc_zh": "星宿流转之永夜月影泰坦预言议政殿", + "calyx_x": -83587, + "calyx_y": -999999, + "calyx_z": -48432, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Castrum Kremnos - Catacombs [Day/Night Statue Puzzle]", + "name_th": "Castrum Kremnos - สุสานใต้ดิน [ปริศนารูปปั้นกลางวัน/กลางคืน]", + "name_zh": "「灾梦余温」无名泰坦大墓", + "plane_id": 20413, + "x": -281, + "y": 26806, + "z": -467500, + "map_layer": 1, + "desc": "Underground catacombs featuring statue alignment puzzles", + "desc_th": "สุสานใต้ดินโบราณ มีกลไกปริศนาหมุนรูปปั้นสลับกลางวัน/กลางคืน", + "desc_zh": "幽暗地下回廊,包含昼夜雕像机关朝向解谜", + "calyx_x": -281, + "calyx_y": -999999, + "calyx_z": -467500, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Janusopolis - Sacred Colonnade Terrace [Dawn]", + "name_th": "Janusopolis - ระเบียงเสาศักดิ์สิทธิ์ [รุ่งอรุณ]", + "name_zh": "「半神议院」黎明云崖", + "plane_id": 20421, + "x": 162379, + "y": 220000, + "z": 265445, + "map_layer": 1, + "desc": "Colonnaded grand terrace overlooking the holy city dawn", + "desc_th": "ระเบียงเสาหินโอ่อ่า ชมวิวรุ่งอรุณเหนือเมืองศักดิ์สิทธิ์", + "desc_zh": "黎明金光俯瞰圣城的宏伟石柱观景露台", + "calyx_x": 163962, + "calyx_y": 220050, + "calyx_z": 264431, + "calyx_group_id": 237, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": true, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Janusopolis - Sacred Colonnade Terrace [Evernight]", + "name_th": "Janusopolis - ระเบียงเสาศักดิ์สิทธิ์ [ราตรีนิรันดร์]", + "name_zh": "「半神议院」黎明云崖【永夜】", + "plane_id": 20421, + "x": 162379, + "y": 220000, + "z": 265445, + "map_layer": 1, + "desc": "Colonnaded grand terrace beneath nocturnal constellations", + "desc_th": "ระเบียงเสาหินโอ่อ่า ภายใต้ท้องฟ้าดาวราตรีนิรันดร์", + "desc_zh": "群星闪烁的静谧永夜神圣柱廊台", + "calyx_x": 163962, + "calyx_y": 220050, + "calyx_z": 264431, + "calyx_group_id": 237, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": true, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Grove of Epiphany - Sunken Grove [Dawn]", + "name_th": "Grove of Epiphany - สวนพฤกษาแห่งการรู้แจ้ง [รุ่งอรุณ]", + "name_zh": "「辉痕圣林」神悟树庭", + "plane_id": 20422, + "x": -82230, + "y": 8542, + "z": -43698, + "map_layer": 1, + "desc": "Ancient mystical olive grove bathed in divine golden light", + "desc_th": "สวนมะกอกโบราณมนต์ขลัง อาบด้วยแสงสีทองแห่งทวยเทพ", + "desc_zh": "晨曦神光普照之古老神秘橄榄林苑", + "calyx_x": -83375, + "calyx_y": 8542, + "calyx_z": -42392, + "calyx_group_id": 211, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": true, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Grove of Epiphany - Sunken Grove [Evernight]", + "name_th": "Grove of Epiphany - สวนพฤกษาแห่งการรู้แจ้ง [ราตรีนิรันดร์]", + "name_zh": "「辉痕圣林」神悟树庭【永夜】", + "plane_id": 20422, + "x": -82230, + "y": 8542, + "z": -43698, + "map_layer": 1, + "desc": "Ancient mystical olive grove with glowing flora in the dark", + "desc_th": "สวนมะกอกโบราณมนต์ขลัง ดอกไม้เรืองแสงใต้ความมืดมิด", + "desc_zh": "暗夜静谧中幽光闪耀之神秘启示林苑", + "calyx_x": -83375, + "calyx_y": 8542, + "calyx_z": -42392, + "calyx_group_id": 211, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": true, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Janusopolis - Grand Hall of Fate [Era Flipper Puzzle]", + "name_th": "Janusopolis - โถงโชคชะตา [ปริศนาแสง Era Flipper]", + "name_zh": "「葬忆彼岸」时光归墟", + "plane_id": 20423, + "x": -600, + "y": -36, + "z": 4239, + "map_layer": 1, + "desc": "Grand sanctuary featuring Era Flipper beam-reflection puzzles", + "desc_th": "วิหารใหญ่แห่งโชคชะตา มีกลไกปริศนาหักเหแสง Era Flipper", + "desc_zh": "宿命大圣堂,搭载光束引导翻转纪元镜面机关", + "calyx_x": -600, + "calyx_y": -999999, + "calyx_z": 4239, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Styxia - Upper Treasury & Sky Domain [Sun/Sky Puzzle]", + "name_th": "Styxia - วิหารเวหา & แกนกลาง CPU [ปริศนาดวงอาทิตย์และท้องฟ้า]", + "name_zh": "哀丽秘榭", + "plane_id": 20431, + "x": 5, + "y": -45838, + "z": -256208, + "map_layer": 1, + "desc": "Celestial upper domain, virus CPU core, and Sun/Sky lighting puzzles", + "desc_th": "ดินแดนสวรรค์ชั้นบน แกนกลางไวรัส CPU และปริศนาแสงตะวัน/ท้องฟ้า", + "desc_zh": "高空云端神殿、清除病毒CPU运算机房与天象光影谜题", + "calyx_x": 5, + "calyx_y": -999999, + "calyx_z": -256208, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Styxia - Tartarus Abyss & River of Souls [Compass & Plates]", + "name_th": "Styxia - เหวลึก Tartarus & แม่น้ำวิญญาณ [ปริศนาเข็มทิศและแผ่นลอย]", + "name_zh": "塔耳塔洛斯深渊", + "plane_id": 20432, + "x": -3, + "y": -46690, + "z": -152573, + "map_layer": 1, + "desc": "Subterranean abyss featuring floating platforms and compass puzzle", + "desc_th": "เหวลึกใต้พิภพ มีแท่นหินลอยได้และปริศนาหมุนเข็มทิศข้ามแม่น้ำวิญญาณ", + "desc_zh": "幽暗深渊绝壁,包含浮空踏板平台与冥河罗盘机关", + "calyx_x": -3, + "calyx_y": -999999, + "calyx_z": -152573, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Styxia - Warbling Shores [Watergate Port & Time Lock]", + "name_th": "Styxia - ชายฝั่งคลื่นคลั่ง [ประตูกั้นน้ำ & ปริศนากุญแจเวลา Time Lock]", + "name_zh": "冥河外港", + "plane_id": 20433, + "x": -2655, + "y": 889049, + "z": 999378, + "map_layer": 1, + "desc": "Coastal watergate port protected by ancient Time Lock mechanisms", + "desc_th": "ท่าเรือประตูกั้นน้ำริมฝั่ง ปกป้องด้วยกลไกปริศนากุญแจเวลาโบราณ", + "desc_zh": "滨海水门港口,设有防线古老时光锁封印机制", + "calyx_x": -2655, + "calyx_y": -999999, + "calyx_z": 999378, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Aidonia - Temple of the Muses [Console Era Switch]", + "name_th": "Aidonia - วิหารมิวส์แห่งขุนเขาหิมะ [แท่นสลับยุคสมัย Console]", + "name_zh": "缪斯神殿", + "plane_id": 20451, + "x": 435960, + "y": 56842, + "z": 142740, + "map_layer": 1, + "desc": "Snowbound mountaintop temple featuring era-manipulating consoles", + "desc_th": "วิหารยอดเขาหิมะ มีคอนโซลควบคุมสลับยุคสมัยในอดีตและปัจจุบัน", + "desc_zh": "雪覆群峰之巅缪斯圣殿,拥有操纵时空的纪元控制台", + "calyx_x": 435960, + "calyx_y": -999999, + "calyx_z": 142740, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Styxia / Universal Matrix - Dragonbone Matrix Core", + "name_th": "Styxia / Universal Matrix - ใจกลางโครงกระดูกมังกรและเมทริกซ์", + "name_zh": "斯提克斯 / 全知母阵 - 龙骨母阵核心【奇美拉中枢】", + "plane_id": 20461, + "x": 12080, + "y": 393572, + "z": 322230, + "map_layer": 1, + "desc": "Dragonbone ruins and Universal Matrix central chimera sanctum", + "desc_th": "ซากมังกรโบราณและห้องแกนกลางเมทริกซ์ พร้อมสิ่งมีชีวิตสังเคราะห์ Chimera", + "desc_zh": "远古龙骨遗迹与全知母阵奇美拉培育核心神殿", + "calyx_x": 12080, + "calyx_y": -999999, + "calyx_z": 322230, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Great Tomb of the Dragon - Underworld Tomb [Elevator Runes]", + "name_th": "Great Tomb of the Dragon - สุสานหลวงมังกรใต้พิภพ [ปริศนาลิฟต์อักษรรูน]", + "name_zh": "龙之古墓 - 幽冥地宫【升降梯卢恩符文解谜】", + "plane_id": 20462, + "x": 371, + "y": -114970, + "z": -566086, + "map_layer": 1, + "desc": "Subterranean imperial crypt featuring elevator rune puzzles", + "desc_th": "สุสานใต้พิภพของจักรพรรดิมังกร มีกลไกลิฟต์ไขปริศนาอักษรรูน", + "desc_zh": "深邃幽冥巨龙陵墓地宫,设有升降梯符文激活解谜", + "calyx_x": 371, + "calyx_y": -999999, + "calyx_z": -566086, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Eye of Twilight - Sky Arena of Ares [Feather Shrine]", + "name_th": "Eye of Twilight - ลานประลองแอรีสเวหา [ศาลเจ้าขนนกศักดิ์สิทธิ์]", + "name_zh": "阿瑞斯天空竞技场", + "plane_id": 20481, + "x": 14790, + "y": 642038, + "z": -625810, + "map_layer": 1, + "desc": "Celestial battlefield arena dedicated to Ares and Feather shrines", + "desc_th": "ลานประลองบนฟากฟ้า อุทิศแด่เทพแอรีสและศาลเจ้าขนนกเวหา", + "desc_zh": "阿瑞斯高空悬浮角斗战场,守护暮光神羽之古老祭坛", + "calyx_x": 14790, + "calyx_y": -999999, + "calyx_z": -625810, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Eye of Twilight - Port of Twilight [Warship Apron]", + "name_th": "Eye of Twilight - ท่าเรือรบสนธยา [ลานจอดเรือบินไททัน]", + "name_zh": "暮光之眼 - 暮光空港【战舰起降台与港口】", + "plane_id": 20482, + "x": 711195, + "y": -93915, + "z": -653851, + "map_layer": 1, + "desc": "Titan warship flight deck and harbor apron overlooking the void", + "desc_th": "ลานจอดเรือรบไททันและท่าเทียบเรือลอยฟ้า เหนือห้วงอวกาศอันเวิ้งว้าง", + "desc_zh": "俯瞰虚空深渊的泰坦巨型战舰起落机坪与港湾", + "calyx_x": 711195, + "calyx_y": -999999, + "calyx_z": -653851, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + } + ] + }, + { + "id": "astropolis", + "name": "Astropolis (Star Metropolis)", + "name_th": "Astropolis (มหานครดวงดาว)", + "name_zh": "千星城", + "icon": "[Astropolis]", + "scenes": [ + { + "name": "Astropolis Central District", + "name_th": "Astropolis Central District (เขตศูนย์กลางมหานคร)", + "name_zh": "千星城中心城区", + "plane_id": 10551, + "x": -51058, + "y": -565500, + "z": -488134, + "map_layer": 1, + "desc": "Sprawling cyberpunk metropolis plaza and sky-bridges", + "desc_th": "จัตุรัสกลางเมืองสไตล์ไซเบอร์พังก์และสะพานลอยฟ้าเชื่อมตึกระฟ้า", + "desc_zh": "庞大的赛博朋克星界都市中央广场与立体天桥", + "calyx_x": -51058, + "calyx_y": -999999, + "calyx_z": -488134, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Skycall Aery", + "name_th": "Skycall Aery (ท่าอากาศยานเวหา)", + "name_zh": "空声院", + "plane_id": 10552, + "x": 0, + "y": 505, + "z": -35930, + "map_layer": 1, + "desc": "High-altitude aviator docking bays and observation decks", + "desc_th": "อู่จอดยานบินระดับความสูงสูงและลานสังเกตการณ์ขอบฟ้า", + "desc_zh": "超高空飞行器停泊机库与全景天际观察台", + "calyx_x": 0, + "calyx_y": -999999, + "calyx_z": -35930, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Lodestar Tower", + "name_th": "Lodestar Tower (หอคอย Lodestar)", + "name_zh": "指针塔", + "plane_id": 20551, + "x": 0, + "y": -215350, + "z": 259520, + "map_layer": 1, + "desc": "Monolithic corporate skyscraper and central transit core", + "desc_th": "ตึกระฟ้ายักษ์ใหญ่ขององค์กรและศูนย์กลางการคมนาคมขนส่ง", + "desc_zh": "超巨型跨星系企业摩天巨塔与垂直交通中枢", + "calyx_x": 0, + "calyx_y": -999999, + "calyx_z": 259520, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + } + ] + }, + { + "id": "planarcadia", + "name": "Planarcadia (Duomension City & Seafeld)", + "name_th": "Planarcadia (เมืองมิติ Duomension & Seafeld)", + "name_zh": "二相乐园", + "icon": "[Planarcadia]", + "scenes": [ + { + "name": "Duomension City (Central Town)", + "name_th": "Duomension City (เมืองกลางสองมิติ)", + "name_zh": "二维市", + "plane_id": 10501, + "x": 95660, + "y": 19175, + "z": -16460, + "map_layer": 1, + "desc": "Main multi-dimensional residential and commercial center", + "desc_th": "ศูนย์กลางการอยู่อาศัยและการค้าหลายมิติ", + "desc_zh": "多维空间交互的主城居民生活区与繁华商业街", + "calyx_x": 95660, + "calyx_y": -999999, + "calyx_z": -16460, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Phantasmoon Courtyard", + "name_th": "Phantasmoon Courtyard (ลานจันทรามายา)", + "name_zh": "幻月秘庭", + "plane_id": 10502, + "x": 5770, + "y": 92, + "z": 28930, + "map_layer": 1, + "desc": "Moonlit garden sanctuary and dimensional courtyard", + "desc_th": "สวนแห่งดวงจันทร์มายา ลานกว้างข้ามมิติอันเงียบสงบ", + "desc_zh": "幽蓝月光静洒之空间庭院避风港", + "calyx_x": 5770, + "calyx_y": -999999, + "calyx_z": 28930, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Seafeld City", + "name_th": "Seafeld City (เมืองท่า Seafeld)", + "name_zh": "海原市", + "plane_id": 10521, + "x": -693530, + "y": 15046, + "z": 38600, + "map_layer": 1, + "desc": "Coastal futuristic port city overlooking the digital ocean", + "desc_th": "เมืองท่าแห่งอนาคตริมชายฝั่ง มองเห็นมหาสมุทรดิจิทัล", + "desc_zh": "俯瞰数字极光海洋的未来沿海滨海大都会", + "calyx_x": -693530, + "calyx_y": -999999, + "calyx_z": 38600, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Graphia Academy", + "name_th": "Graphia Academy (สถาบัน Graphia)", + "name_zh": "绘世学院", + "plane_id": 20501, + "x": 72091, + "y": 7000, + "z": 110301, + "map_layer": 1, + "desc": "High-tech dimensional academic campus and research halls", + "desc_th": "วิทยาเขตสถาบันการศึกษามิติวิทยาศาสตร์ขั้นสูงและห้องวิจัย", + "desc_zh": "尖端多维科学研究院高等学府主校区", + "calyx_x": 72840, + "calyx_y": 7000, + "calyx_z": 108320, + "calyx_group_id": 324, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": true, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Duomension Outer Border", + "name_th": "Duomension Outer Border (ชายแดนรอบนอกสองมิติ)", + "name_zh": "双次元外域边境", + "plane_id": 20502, + "x": -10888, + "y": 9815, + "z": 11296, + "map_layer": 1, + "desc": "Boundary sector where dimensional reality fractures", + "desc_th": "พื้นที่ชายแดนที่ความจริงของมิติต่างๆ เริ่มแตกสลาย", + "desc_zh": "空间现实出现断裂异变的城市防卫外围边陲", + "calyx_x": -10888, + "calyx_y": -999999, + "calyx_z": 11296, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Dovebrook District", + "name_th": "Dovebrook District (เขต Dovebrook)", + "name_zh": "鸽川区", + "plane_id": 20503, + "x": -64961, + "y": 9694, + "z": 38250, + "map_layer": 1, + "desc": "Canal-side residential avenues and pedestrian walkways", + "desc_th": "ถนนเลียบคลองน้ำ ทางเดินคนเดิน และชุมชนที่อยู่อาศัย", + "desc_zh": "静谧运河旁之林荫居住大道与休闲步行街", + "calyx_x": -63559, + "calyx_y": 9694, + "calyx_z": 36973, + "calyx_group_id": 237, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": true, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Lookout Cloud Station", + "name_th": "Lookout Cloud Station (สถานีรถไฟลอยเมฆ)", + "name_zh": "观览云岛站", + "plane_id": 20511, + "x": -88818, + "y": -14785, + "z": 32, + "map_layer": 1, + "desc": "Atmospheric train hub and elevated cloud platforms", + "desc_th": "ชุมทางรถไฟบรรยากาศสูงและชานชาลาเหนือหมู่เมฆ", + "desc_zh": "云层之上的磁悬浮铁路换乘中转大站", + "calyx_x": -88818, + "calyx_y": -999999, + "calyx_z": 32, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Neon Skylines", + "name_th": "Neon Skylines (เส้นขอบฟ้านีออน)", + "name_zh": "霓虹天际线", + "plane_id": 20512, + "x": 0, + "y": 0, + "z": 0, + "map_layer": 1, + "desc": "Bustling rooftop highways and neon illuminated spires", + "desc_th": "ทางหลวงดาดฟ้าเชื่อมตึกและยอดแหลมที่ส่องประกายนีออน", + "desc_zh": "高耸入云之穿梭高架车道与极光霓虹尖塔", + "calyx_x": 0, + "calyx_y": -999999, + "calyx_z": 0, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Pearluxe Tower", + "name_th": "Pearluxe Tower (หอคอย Pearluxe)", + "name_zh": "珠星大厦", + "plane_id": 20513, + "x": -203880, + "y": -16966, + "z": -108470, + "map_layer": 1, + "desc": "Luxury high-rise residential and executive towers", + "desc_th": "ตึกระฟ้าคอนโดมิเนียมหรูหราและสำนักงานผู้บริหาร", + "desc_zh": "顶级豪华空中公馆与商务行政高塔", + "calyx_x": -203880, + "calyx_y": -999999, + "calyx_z": -108470, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Pearl's Studio", + "name_th": "Pearl's Studio (สตูดิโอของ Pearl)", + "name_zh": "真珠的演播室", + "plane_id": 20514, + "x": -86030, + "y": -15564, + "z": -76070, + "map_layer": 1, + "desc": "Private holographic animation and design workshop", + "desc_th": "สตูดิโอออกแบบแอนิเมชันและโฮโลแกรมส่วนตัว", + "desc_zh": "私人全息投影设计与创意手作工坊", + "calyx_x": -86030, + "calyx_y": -999999, + "calyx_z": -76070, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Seafeld TV Tower", + "name_th": "Seafeld TV Tower (หอส่งสัญญาณโทรทัศน์ Seafeld)", + "name_zh": "海原电视塔", + "plane_id": 20521, + "x": -363, + "y": 115600, + "z": -43520, + "map_layer": 1, + "desc": "Broadcast spire commanding panoramic views of Seafeld", + "desc_th": "หอคอยกระจายเสียงและส่งสัญญาณ มองเห็นวิวทิวทัศน์ Seafeld รอบทิศ", + "desc_zh": "广播电视发射主塔,饱览海菲尔德全境海湾盛景", + "calyx_x": -363, + "calyx_y": -999999, + "calyx_z": -43520, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Inkford Hermitage", + "name_th": "Inkford Hermitage (บ้านพักสันโดษ Inkford)", + "name_zh": "渡画泉隐", + "plane_id": 20531, + "x": -108124, + "y": 56110, + "z": -261230, + "map_layer": 1, + "desc": "Quiet secluded retreat hidden within digital bamboo groves", + "desc_th": "สถานที่พักผ่อนอันเงียบสงบซ่อนตัวอยู่ในป่าไผ่ดิจิทัล", + "desc_zh": "隐藏在虚拟数字翠竹林中的清幽隐居别苑", + "calyx_x": -108124, + "calyx_y": -999999, + "calyx_z": -261230, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Desolate Maw's Demonhold", + "name_th": "Desolate Maw's Demonhold (ป้อมปราการหุบเหวมาร)", + "name_zh": "寂灭空飨妖都", + "plane_id": 20541, + "x": -200000, + "y": -207157, + "z": 874800, + "map_layer": 1, + "desc": "Perilous dimensional rift chasm and shadowy fortress", + "desc_th": "รอยแยกมิติหุบเหวอันตรายและป้อมปราการเงามืด", + "desc_zh": "险峻绝伦之空间裂隙深渊与暗影防御要塞", + "calyx_x": -200000, + "calyx_y": -999999, + "calyx_z": 874800, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Fallen Star's Cradle", + "name_th": "Fallen Star's Cradle (แหล่งกำเนิดดาวตก)", + "name_zh": "坠星的摇篮", + "plane_id": 20542, + "x": 95142, + "y": 302103, + "z": 208219, + "map_layer": 1, + "desc": "Impact crater basin harboring an ancient stellar core", + "desc_th": "แอ่งหลุมอุกกาบาตดาวตกที่ซ่อนเร้นแกนดวงดาวโบราณ", + "desc_zh": "封存着古老恒星核心能量之陨石撞击天坑", + "calyx_x": 95142, + "calyx_y": -999999, + "calyx_z": 208219, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "product", + "is_beta": false, + "client_version": "Product" + }, + { + "name": "Starforster", + "name_th": "Starforster (เรือสตาร์ฟรอสเตอร์)", + "name_zh": "点星船", + "plane_id": 20571, + "x": -79525, + "y": 72830, + "z": 317669, + "map_layer": 1, + "desc": "Aha's vessel and Masked Fools gathering ship", + "desc_th": "เรือสตาร์ฟรอสเตอร์ของคณะละครหน้ากากและท่านอาฮา", + "desc_zh": "欢愉星神阿哈与假面愚者的专属飞船", + "calyx_x": -79525, + "calyx_y": -999999, + "calyx_z": 317669, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "beta", + "is_beta": true, + "client_version": "4.7 Beta", + "beta_notice": "Requires 4.7 Beta client. Will freeze on official Product (4.6.x) client.", + "beta_notice_th": "ต้องใช้ตัวเกม 4.7 Beta หากใช้ตัวเกม Product ปกติ (4.6.x) จะค้างหน้าโหลด", + "beta_notice_zh": "需要 4.7 Beta 测试端,正式服/Product 客户端会卡在列车加载界面" + }, + { + "name": "Moment of Joy", + "name_th": "Moment of Joy (Fleeting Joy / บาร์ระคนสุขโศก)", + "name_zh": "瞬息欢愉", + "plane_id": 20572, + "x": -743, + "y": 2435, + "z": -64637, + "map_layer": 1, + "desc": "A bar where sorrow and joy are mixed (Official EN: Moment of Joy, formerly Fleeting Joy)", + "desc_th": "บาร์สังสรรค์ที่ความโศกเศร้าและความสุขผสมผสานกัน (ชื่อทางการ EN: Moment of Joy / เดิมเรียก Fleeting Joy)", + "desc_zh": "悲欢交织的瞬息酒吧(官方英文:Moment of Joy,此前曾称 Fleeting Joy)", + "calyx_x": -743, + "calyx_y": -999999, + "calyx_z": -64637, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_prop_id": 808, + "calyx_entity_id": 1337, + "calyx_supported": false, + "channel": "beta", + "is_beta": true, + "client_version": "4.7 Beta", + "beta_notice": "Requires 4.7 Beta client. Will freeze on official Product (4.6.x) client.", + "beta_notice_th": "ต้องใช้ตัวเกม 4.7 Beta หากใช้ตัวเกม Product ปกติ (4.6.x) จะค้างหน้าโหลด", + "beta_notice_zh": "需要 4.7 Beta 测试端,正式服/Product 客户端会卡在列车加载界面" + } + ] + } + ], + "total_beta_scenes": 2, + "total_product_scenes": 83 +} \ No newline at end of file diff --git a/app/src/main/java/com/gacy/android/GaCyApplication.kt b/app/src/main/java/com/gacy/android/GaCyApplication.kt new file mode 100644 index 0000000..7eea503 --- /dev/null +++ b/app/src/main/java/com/gacy/android/GaCyApplication.kt @@ -0,0 +1,37 @@ +package com.gacy.android + +import android.app.Application +import android.app.NotificationChannel +import android.app.NotificationManager +import android.os.Build + +class GaCyApplication : Application() { + + companion object { + const val CHANNEL_ID = "gacy_server_channel" + const val CHANNEL_NAME = "GaCy Server Service" + lateinit var instance: GaCyApplication + private set + } + + override fun onCreate() { + super.onCreate() + instance = this + createNotificationChannel() + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + CHANNEL_NAME, + NotificationManager.IMPORTANCE_LOW + ).apply { + description = "Background service for GaCy Game Server & Proxy" + setShowBadge(false) + } + val manager = getSystemService(NotificationManager::class.java) + manager?.createNotificationChannel(channel) + } + } +} diff --git a/app/src/main/java/com/gacy/android/MainActivity.kt b/app/src/main/java/com/gacy/android/MainActivity.kt new file mode 100644 index 0000000..4c5fb30 --- /dev/null +++ b/app/src/main/java/com/gacy/android/MainActivity.kt @@ -0,0 +1,44 @@ +package com.gacy.android + +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import com.gacy.android.ui.screens.MainScreen +import com.gacy.android.ui.theme.GaCyTheme + +class MainActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + requestAppPermissions() + + setContent { + GaCyTheme { + MainScreen() + } + } + } + + private fun requestAppPermissions() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (ContextCompat.checkSelfPermission( + this, + Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED + ) { + ActivityCompat.requestPermissions( + this, + arrayOf(Manifest.permission.POST_NOTIFICATIONS), + 101 + ) + } + } + } +} diff --git a/app/src/main/java/com/gacy/android/data/GaCyApiRepository.kt b/app/src/main/java/com/gacy/android/data/GaCyApiRepository.kt new file mode 100644 index 0000000..f3ef6cf --- /dev/null +++ b/app/src/main/java/com/gacy/android/data/GaCyApiRepository.kt @@ -0,0 +1,87 @@ +package com.gacy.android.data + +import com.gacy.android.model.GmCommandResult +import com.gacy.android.model.ServerConfig +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONObject +import java.io.IOException +import java.util.concurrent.TimeUnit + +class GaCyApiRepository { + + private val client = OkHttpClient.Builder() + .connectTimeout(3, TimeUnit.SECONDS) + .readTimeout(5, TimeUnit.SECONDS) + .writeTimeout(5, TimeUnit.SECONDS) + .build() + + private val jsonMediaType = "application/json; charset=utf-8".toMediaType() + + suspend fun checkServerHealth(config: ServerConfig): Pair = withContext(Dispatchers.IO) { + val start = System.currentTimeMillis() + val url = "${config.dispatchUrl}/status" + try { + val request = Request.Builder().url(url).get().build() + client.newCall(request).execute().use { response -> + val duration = System.currentTimeMillis() - start + Pair(response.isSuccessful, duration) + } + } catch (e: Exception) { + // Try query_gateway fallback + try { + val gwUrl = "${config.dispatchUrl}/query_gateway" + val request = Request.Builder().url(gwUrl).get().build() + client.newCall(request).execute().use { response -> + val duration = System.currentTimeMillis() - start + Pair(response.isSuccessful || response.code == 400 || response.code == 200, duration) + } + } catch (ex: Exception) { + Pair(false, -1L) + } + } + } + + suspend fun sendUnstuck(config: ServerConfig): GmCommandResult = withContext(Dispatchers.IO) { + val url = "${config.dispatchUrl}/api/client/unstuck" + try { + val body = "{}".toRequestBody(jsonMediaType) + val request = Request.Builder().url(url).post(body).build() + client.newCall(request).execute().use { response -> + if (response.isSuccessful) { + GmCommandResult(true, "Unstuck packet sent to game client successfully.") + } else { + GmCommandResult(false, "Unstuck request failed with HTTP ${response.code}") + } + } + } catch (e: IOException) { + GmCommandResult(false, "Network error: ${e.message}") + } + } + + suspend fun executeGmCommand(config: ServerConfig, command: String): GmCommandResult = withContext(Dispatchers.IO) { + val cleanCmd = command.trim() + val url = "${config.dispatchUrl}/api/gm/command" + try { + val payload = JSONObject().apply { + put("command", cleanCmd) + } + val body = payload.toString().toRequestBody(jsonMediaType) + val request = Request.Builder().url(url).post(body).build() + client.newCall(request).execute().use { response -> + val responseBody = response.body?.string() ?: "" + if (response.isSuccessful) { + GmCommandResult(true, responseBody.ifEmpty { "Command executed successfully: $cleanCmd" }) + } else { + GmCommandResult(false, "Server rejected command: HTTP ${response.code} ($responseBody)") + } + } + } catch (e: Exception) { + GmCommandResult(false, "Failed to connect to GaCy Server: ${e.message}") + } + } +} diff --git a/app/src/main/java/com/gacy/android/data/PersistentRepository.kt b/app/src/main/java/com/gacy/android/data/PersistentRepository.kt new file mode 100644 index 0000000..c824172 --- /dev/null +++ b/app/src/main/java/com/gacy/android/data/PersistentRepository.kt @@ -0,0 +1,299 @@ +package com.gacy.android.data + +import android.content.Context +import com.gacy.android.model.ExRankConfig +import com.gacy.android.model.GlobalPassiveConfig +import com.gacy.android.model.SceneConfig +import com.gacy.android.model.TrailblazerConfig +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.io.FileNotFoundException +import java.nio.charset.StandardCharsets + +class PersistentRepository(private val context: Context) { + + val serverDataDir: File + get() = File(context.filesDir, "gacy_server") + + val miscDir: File + get() = File(serverDataDir, "misc") + + val persistentFile: File + get() = File(miscDir, "persistent.json") + + init { + ensureDataStructure() + } + + fun isServerInstalled(): Boolean { + return File(serverDataDir, "gameserver").exists() || File(serverDataDir, "ppsr.pyc").exists() + } + + private fun ensureDataStructure() { + if (!miscDir.exists()) { + miscDir.mkdirs() + } + if (!persistentFile.exists()) { + try { + persistentFile.writeText(DEFAULT_PERSISTENT_JSON_STRING, StandardCharsets.UTF_8) + } catch (e: Exception) { + e.printStackTrace() + } + } + } + + suspend fun readPersistentJson(): JSONObject = withContext(Dispatchers.IO) { + if (!persistentFile.exists()) { + ensureDataStructure() + } + try { + val text = persistentFile.readText(StandardCharsets.UTF_8) + JSONObject(text) + } catch (e: Exception) { + JSONObject(DEFAULT_PERSISTENT_JSON_STRING) + } + } + + suspend fun savePersistentJson(json: JSONObject): Boolean = withContext(Dispatchers.IO) { + try { + val tempFile = File(miscDir, "persistent.json.tmp") + tempFile.writeText(json.toString(2), StandardCharsets.UTF_8) + if (tempFile.exists()) { + if (persistentFile.exists()) { + persistentFile.delete() + } + tempFile.renameTo(persistentFile) + true + } else { + false + } + } catch (e: Exception) { + e.printStackTrace() + false + } + } + + suspend fun updateScene(sceneConfig: SceneConfig): Boolean = withContext(Dispatchers.IO) { + val root = readPersistentJson() + root.put("scene", sceneConfig.toJsonObject()) + savePersistentJson(root) + } + + suspend fun getSceneConfig(): SceneConfig = withContext(Dispatchers.IO) { + val root = readPersistentJson() + val sceneObj = root.optJSONObject("scene") + if (sceneObj != null) { + SceneConfig.fromJsonObject(sceneObj) + } else { + SceneConfig() + } + } + + suspend fun getTrailblazerConfig(): TrailblazerConfig = withContext(Dispatchers.IO) { + val root = readPersistentJson() + val avatar = root.optJSONObject("avatar") ?: JSONObject() + TrailblazerConfig( + gender = avatar.optString("tb_gender", "Female"), + tbPath = avatar.optString("tb_path", "Elation"), + marchPath = avatar.optString("march_path", "Preservation") + ) + } + + suspend fun saveTrailblazerConfig(config: TrailblazerConfig): Boolean = withContext(Dispatchers.IO) { + val root = readPersistentJson() + var avatar = root.optJSONObject("avatar") + if (avatar == null) { + avatar = JSONObject() + root.put("avatar", avatar) + } + avatar.put("tb_gender", config.gender) + avatar.put("tb_path", config.tbPath) + avatar.put("march_path", config.marchPath) + savePersistentJson(root) + } + + suspend fun getExRankConfigs(): List = withContext(Dispatchers.IO) { + val root = readPersistentJson() + val avatar = root.optJSONObject("avatar") + val exRankArray = avatar?.optJSONArray("ex_rank") + + val currentMap = mutableMapOf() + if (exRankArray != null) { + for (i in 0 until exRankArray.length()) { + val item = exRankArray.optJSONObject(i) ?: continue + val id = item.optInt("avatar_id", 0) + val active = item.optBoolean("active", false) + if (id > 0) { + currentMap[id] = active + } + } + } + + DEFAULT_EX_RANK_CONFIGS.map { defaultCfg -> + defaultCfg.copy(active = currentMap[defaultCfg.avatarId] ?: defaultCfg.active) + } + } + + suspend fun saveExRankConfigs(configs: List): Boolean = withContext(Dispatchers.IO) { + val root = readPersistentJson() + var avatar = root.optJSONObject("avatar") + if (avatar == null) { + avatar = JSONObject() + root.put("avatar", avatar) + } + + val array = JSONArray() + for (cfg in configs) { + val item = JSONObject().apply { + put("name", cfg.name) + put("avatar_id", cfg.avatarId) + put("active", cfg.active) + } + array.put(item) + } + avatar.put("ex_rank", array) + savePersistentJson(root) + } + + suspend fun getGlobalPassives(): List = withContext(Dispatchers.IO) { + val root = readPersistentJson() + val avatar = root.optJSONObject("avatar") + val passiveArray = avatar?.optJSONArray("global_passive") + + val currentMap = mutableMapOf() + if (passiveArray != null) { + for (i in 0 until passiveArray.length()) { + val item = passiveArray.optJSONObject(i) ?: continue + val id = item.optInt("id", 0) + val active = item.optBoolean("active", false) + if (id > 0) { + currentMap[id] = active + } + } + } + + DEFAULT_GLOBAL_PASSIVES.map { defaultCfg -> + defaultCfg.copy(active = currentMap[defaultCfg.id] ?: defaultCfg.active) + } + } + + suspend fun saveGlobalPassives(configs: List): Boolean = withContext(Dispatchers.IO) { + val root = readPersistentJson() + var avatar = root.optJSONObject("avatar") + if (avatar == null) { + avatar = JSONObject() + root.put("avatar", avatar) + } + + val array = JSONArray() + for (cfg in configs) { + val item = JSONObject().apply { + put("name", cfg.name) + put("id", cfg.id) + put("active", cfg.active) + } + array.put(item) + } + avatar.put("global_passive", array) + savePersistentJson(root) + } + + suspend fun getSendHotfix(): Boolean = withContext(Dispatchers.IO) { + val root = readPersistentJson() + root.optBoolean("send_hotfix", true) + } + + suspend fun setSendHotfix(value: Boolean): Boolean = withContext(Dispatchers.IO) { + val root = readPersistentJson() + root.put("send_hotfix", value) + savePersistentJson(root) + } + + suspend fun getServerKey(): String = withContext(Dispatchers.IO) { + val root = readPersistentJson() + root.optString("key", "") + } + + suspend fun setServerKey(key: String): Boolean = withContext(Dispatchers.IO) { + val root = readPersistentJson() + root.put("key", key) + savePersistentJson(root) + } + + companion object { + val DEFAULT_EX_RANK_CONFIGS = listOf( + ExRankConfig("JingYuan", 1204, true), + ExRankConfig("DanHeng IL", 1213, true), + ExRankConfig("Acheron", 1308, true) + ) + + val DEFAULT_GLOBAL_PASSIVES = listOf( + GlobalPassiveConfig("Castorice Global Passive", 140703, true), + GlobalPassiveConfig("SW999 Global Passive", 150602, true), + GlobalPassiveConfig("Aha Global Passive", 151104, true) + ) + + val DEFAULT_PERSISTENT_JSON_STRING = """ + { + "send_hotfix": true, + "key": "", + "scene": { + "plane_id": 20461, + "x": 12080, + "y": 393572, + "z": 322230, + "calyx_x": 12080, + "calyx_y": 393572, + "calyx_z": 324030, + "map_layer": 1, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_entity_id": 1337, + "calyx_prop_id": 808 + }, + "avatar": { + "tb_gender": "Female", + "tb_path": "Elation", + "march_path": "Preservation", + "ex_rank": [ + { + "name": "JingYuan", + "avatar_id": 1204, + "active": true + }, + { + "name": "DanHeng IL", + "avatar_id": 1213, + "active": true + }, + { + "name": "Acheron", + "avatar_id": 1308, + "active": true + } + ], + "global_passive": [ + { + "name": "Castorice Global Passive", + "id": 140703, + "active": true + }, + { + "name": "SW999 Global Passive", + "id": 150602, + "active": true + }, + { + "name": "Aha Global Passive", + "id": 151104, + "active": true + } + ] + } + } + """.trimIndent() + } +} diff --git a/app/src/main/java/com/gacy/android/data/PreferencesRepository.kt b/app/src/main/java/com/gacy/android/data/PreferencesRepository.kt new file mode 100644 index 0000000..2f7c7b4 --- /dev/null +++ b/app/src/main/java/com/gacy/android/data/PreferencesRepository.kt @@ -0,0 +1,60 @@ +package com.gacy.android.data + +import android.content.Context +import android.content.SharedPreferences +import com.gacy.android.model.AppLanguage +import com.gacy.android.model.ServerConfig +import com.gacy.android.model.ServerExecutionMode + +class PreferencesRepository(context: Context) { + + private val prefs: SharedPreferences = context.getSharedPreferences("gacy_preferences", Context.MODE_PRIVATE) + + var appLanguage: AppLanguage + get() { + val code = prefs.getString(KEY_APP_LANGUAGE, AppLanguage.TH.code) ?: AppLanguage.TH.code + return AppLanguage.entries.find { it.code == code } ?: AppLanguage.TH + } + set(value) { + prefs.edit().putString(KEY_APP_LANGUAGE, value.code).apply() + } + + fun getServerConfig(): ServerConfig { + val modeStr = prefs.getString(KEY_EXECUTION_MODE, ServerExecutionMode.LOCAL_EMBEDDED.name) + val mode = try { + ServerExecutionMode.valueOf(modeStr ?: ServerExecutionMode.LOCAL_EMBEDDED.name) + } catch (e: Exception) { + ServerExecutionMode.LOCAL_EMBEDDED + } + + return ServerConfig( + executionMode = mode, + remoteHost = prefs.getString(KEY_REMOTE_HOST, "127.0.0.1") ?: "127.0.0.1", + dispatchPort = prefs.getInt(KEY_DISPATCH_PORT, 21000), + gameserverPort = prefs.getInt(KEY_GAMESERVER_PORT, 23301), + serverKey = prefs.getString(KEY_SERVER_KEY, "") ?: "", + sendHotfix = prefs.getBoolean(KEY_SEND_HOTFIX, true) + ) + } + + fun saveServerConfig(config: ServerConfig) { + prefs.edit() + .putString(KEY_EXECUTION_MODE, config.executionMode.name) + .putString(KEY_REMOTE_HOST, config.remoteHost) + .putInt(KEY_DISPATCH_PORT, config.dispatchPort) + .putInt(KEY_GAMESERVER_PORT, config.gameserverPort) + .putString(KEY_SERVER_KEY, config.serverKey) + .putBoolean(KEY_SEND_HOTFIX, config.sendHotfix) + .apply() + } + + companion object { + private const val KEY_APP_LANGUAGE = "app_language" + private const val KEY_EXECUTION_MODE = "server_execution_mode" + private const val KEY_REMOTE_HOST = "remote_host" + private const val KEY_DISPATCH_PORT = "dispatch_port" + private const val KEY_GAMESERVER_PORT = "gameserver_port" + private const val KEY_SERVER_KEY = "server_key" + private const val KEY_SEND_HOTFIX = "send_hotfix" + } +} diff --git a/app/src/main/java/com/gacy/android/data/SceneRepository.kt b/app/src/main/java/com/gacy/android/data/SceneRepository.kt new file mode 100644 index 0000000..8b6f3ca --- /dev/null +++ b/app/src/main/java/com/gacy/android/data/SceneRepository.kt @@ -0,0 +1,155 @@ +package com.gacy.android.data + +import android.content.Context +import com.gacy.android.model.PlanetItem +import com.gacy.android.model.SceneItem +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONObject +import java.io.File + +object SceneRepository { + + private const val CACHED_SCENES_FILENAME = "scenes_custom.json" + + suspend fun loadScenes(context: Context): List = withContext(Dispatchers.IO) { + val cachedFile = File(context.filesDir, CACHED_SCENES_FILENAME) + val defaultJson = context.assets.open("scenes.json").bufferedReader().use { it.readText() } + + val jsonContent = if (cachedFile.exists()) { + try { + val cachedText = cachedFile.readText() + val cachedObj = JSONObject(cachedText) + val defaultObj = JSONObject(defaultJson) + val defaultBeta = defaultObj.optInt("total_beta_scenes", 0) + val cachedBeta = cachedObj.optInt("total_beta_scenes", 0) + val defaultVer = defaultObj.optString("version", "") + val cachedVer = cachedObj.optString("version", "") + if (defaultBeta > cachedBeta || (defaultVer.isNotBlank() && defaultVer != cachedVer)) { + cachedFile.delete() + defaultJson + } else { + cachedText + } + } catch (e: Exception) { + cachedFile.delete() + defaultJson + } + } else { + defaultJson + } + parseJson(jsonContent) + } + + suspend fun saveUpdatedScenesJson(context: Context, jsonString: String): Boolean = withContext(Dispatchers.IO) { + try { + JSONObject(jsonString) + val cachedFile = File(context.filesDir, CACHED_SCENES_FILENAME) + cachedFile.writeText(jsonString) + true + } catch (e: Exception) { + e.printStackTrace() + false + } + } + + suspend fun resetToDefaultScenes(context: Context): List = withContext(Dispatchers.IO) { + val cachedFile = File(context.filesDir, CACHED_SCENES_FILENAME) + if (cachedFile.exists()) { + cachedFile.delete() + } + val defaultJson = context.assets.open("scenes.json").bufferedReader().use { it.readText() } + parseJson(defaultJson) + } + + private fun parseJson(jsonString: String): List { + val planets = mutableListOf() + val root = JSONObject(jsonString) + val planetsArray = root.optJSONArray("planets") ?: return emptyList() + + for (i in 0 until planetsArray.length()) { + val pObj = planetsArray.getJSONObject(i) + val pId = pObj.optString("id", "planet_$i") + val pNameEn = pObj.optString("name", "Unknown Planet") + val pNameTh = pObj.optString("name_th", pNameEn) + val pNameZh = pObj.optString("name_zh", pNameEn) + + val scenesArray = pObj.optJSONArray("scenes") + val scenes = mutableListOf() + + if (scenesArray != null) { + for (j in 0 until scenesArray.length()) { + val sObj = scenesArray.getJSONObject(j) + val sNameEn = sObj.optString("name", "Scene $j") + val sNameTh = sObj.optString("name_th", sNameEn) + val sNameZh = sObj.optString("name_zh", sNameEn) + val planeId = sObj.optLong("plane_id", 0) + val x = sObj.optLong("x", 0) + val y = sObj.optLong("y", 0) + val z = sObj.optLong("z", 0) + + val calyxX = if (sObj.has("calyx_x")) sObj.getLong("calyx_x") else null + val calyxY = if (sObj.has("calyx_y")) sObj.getLong("calyx_y") else null + val calyxZ = if (sObj.has("calyx_z")) sObj.getLong("calyx_z") else null + + val mapLayer = sObj.optInt("map_layer", 1) + val calyxGroupId = sObj.optLong("calyx_group_id", 186) + val calyxInstId = sObj.optLong("calyx_inst_id", 300001) + val calyxEntityId = sObj.optLong("calyx_entity_id", 1337) + val calyxPropId = sObj.optLong("calyx_prop_id", 808) + + val combatReady = sObj.optBoolean("calyx_supported", false) + val isBeta = sObj.optBoolean("is_beta", false) || + sObj.optString("channel", "").equals("beta", ignoreCase = true) || + sObj.optString("client_version", "").contains("Beta", ignoreCase = true) || + planeId == 20571L || planeId == 20572L + + val descEn = sObj.optString("desc", "") + val descTh = sObj.optString("desc_th", descEn) + val descZh = sObj.optString("desc_zh", descEn) + + scenes.add( + SceneItem( + id = "${pId}_${planeId}_$j", + nameEn = sNameEn, + nameTh = sNameTh, + nameZh = sNameZh, + planeId = planeId, + x = x, + y = y, + z = z, + calyxX = calyxX, + calyxY = calyxY, + calyxZ = calyxZ, + mapLayer = mapLayer, + calyxGroupId = calyxGroupId, + calyxInstId = calyxInstId, + calyxEntityId = calyxEntityId, + calyxPropId = calyxPropId, + combatReady = combatReady, + isBeta = isBeta, + descEn = descEn, + descTh = descTh, + descZh = descZh, + planetNameEn = pNameEn, + planetNameTh = pNameTh, + planetNameZh = pNameZh + ) + ) + } + } + + planets.add( + PlanetItem( + id = pId, + nameEn = pNameEn, + nameTh = pNameTh, + nameZh = pNameZh, + scenes = scenes + ) + ) + } + + return planets + } +} diff --git a/app/src/main/java/com/gacy/android/model/PersistentConfig.kt b/app/src/main/java/com/gacy/android/model/PersistentConfig.kt new file mode 100644 index 0000000..97a9da3 --- /dev/null +++ b/app/src/main/java/com/gacy/android/model/PersistentConfig.kt @@ -0,0 +1,92 @@ +package com.gacy.android.model + +import org.json.JSONObject + +data class SceneConfig( + var planeId: Long = 20461, + var x: Long = 12080, + var y: Long = 393572, + var z: Long = 322230, + var calyxX: Long = 12080, + var calyxY: Long = 393572, + var calyxZ: Long = 324030, + var mapLayer: Int = 1, + var calyxGroupId: Long = 186, + var calyxInstId: Long = 300001, + var calyxEntityId: Long = 1337, + var calyxPropId: Long = 808 +) { + fun toJsonObject(): JSONObject { + return JSONObject().apply { + put("plane_id", planeId) + put("x", x) + put("y", y) + put("z", z) + put("calyx_x", calyxX) + put("calyx_y", calyxY) + put("calyx_z", calyxZ) + put("map_layer", mapLayer) + put("calyx_group_id", calyxGroupId) + put("calyx_inst_id", calyxInstId) + put("calyx_entity_id", calyxEntityId) + put("calyx_prop_id", calyxPropId) + } + } + + companion object { + fun fromScene(scene: SceneItem): SceneConfig { + val cx = scene.calyxX ?: scene.x + val cy = scene.calyxY ?: scene.y + val cz = scene.calyxZ ?: (scene.z + 1800) + return SceneConfig( + planeId = scene.planeId, + x = scene.x, + y = scene.y, + z = scene.z, + calyxX = cx, + calyxY = cy, + calyxZ = cz, + mapLayer = scene.mapLayer, + calyxGroupId = scene.calyxGroupId, + calyxInstId = scene.calyxInstId, + calyxEntityId = scene.calyxEntityId, + calyxPropId = scene.calyxPropId + ) + } + + fun fromJsonObject(obj: JSONObject): SceneConfig { + return SceneConfig( + planeId = obj.optLong("plane_id", 20461), + x = obj.optLong("x", 12080), + y = obj.optLong("y", 393572), + z = obj.optLong("z", 322230), + calyxX = obj.optLong("calyx_x", 12080), + calyxY = obj.optLong("calyx_y", 393572), + calyxZ = obj.optLong("calyx_z", 324030), + mapLayer = obj.optInt("map_layer", 1), + calyxGroupId = obj.optLong("calyx_group_id", 186), + calyxInstId = obj.optLong("calyx_inst_id", 300001), + calyxEntityId = obj.optLong("calyx_entity_id", 1337), + calyxPropId = obj.optLong("calyx_prop_id", 808) + ) + } + } +} + +data class TrailblazerConfig( + val gender: String = "Female", // "Male" or "Female" + val tbPath: String = "Elation", // "Destruction", "Preservation", "Harmony", "Remembrance", "Elation", "Nihility" + val marchPath: String = "Preservation" // "Preservation", "Hunt" +) + +data class ExRankConfig( + val name: String, + val avatarId: Int, + val active: Boolean +) + +data class GlobalPassiveConfig( + val name: String, + val id: Int, + val active: Boolean +) diff --git a/app/src/main/java/com/gacy/android/model/SceneModels.kt b/app/src/main/java/com/gacy/android/model/SceneModels.kt new file mode 100644 index 0000000..00e5293 --- /dev/null +++ b/app/src/main/java/com/gacy/android/model/SceneModels.kt @@ -0,0 +1,73 @@ +package com.gacy.android.model + +enum class AppLanguage(val code: String, val displayName: String) { + EN("en", "English"), + TH("th", "ภาษาไทย"), + ZH("zh", "简体中文") +} + +enum class FilterCategory(val code: String) { + ALL("all"), + COMBAT("combat"), + EXPLORE("explore"), + BETA("beta") +} + +data class SceneItem( + val id: String, + val nameEn: String, + val nameTh: String, + val nameZh: String, + val planeId: Long, + val x: Long, + val y: Long, + val z: Long, + val calyxX: Long?, + val calyxY: Long?, + val calyxZ: Long?, + val mapLayer: Int, + val calyxGroupId: Long, + val calyxInstId: Long, + val calyxEntityId: Long, + val calyxPropId: Long, + val combatReady: Boolean, + val isBeta: Boolean, + val descEn: String, + val descTh: String, + val descZh: String, + val planetNameEn: String = "", + val planetNameTh: String = "", + val planetNameZh: String = "" +) { + fun localizedName(lang: AppLanguage): String = when (lang) { + AppLanguage.TH -> nameTh.ifEmpty { nameEn } + AppLanguage.ZH -> nameZh.ifEmpty { nameEn } + AppLanguage.EN -> nameEn + } + + fun localizedDesc(lang: AppLanguage): String = when (lang) { + AppLanguage.TH -> descTh.ifEmpty { descEn } + AppLanguage.ZH -> descZh.ifEmpty { descEn } + AppLanguage.EN -> descEn + } + + fun localizedPlanetName(lang: AppLanguage): String = when (lang) { + AppLanguage.TH -> planetNameTh.ifEmpty { planetNameEn } + AppLanguage.ZH -> planetNameZh.ifEmpty { planetNameEn } + AppLanguage.EN -> planetNameEn + } +} + +data class PlanetItem( + val id: String, + val nameEn: String, + val nameTh: String, + val nameZh: String, + val scenes: List +) { + fun localizedName(lang: AppLanguage): String = when (lang) { + AppLanguage.TH -> nameTh.ifEmpty { nameEn } + AppLanguage.ZH -> nameZh.ifEmpty { nameEn } + AppLanguage.EN -> nameEn + } +} diff --git a/app/src/main/java/com/gacy/android/model/ServerModels.kt b/app/src/main/java/com/gacy/android/model/ServerModels.kt new file mode 100644 index 0000000..3874867 --- /dev/null +++ b/app/src/main/java/com/gacy/android/model/ServerModels.kt @@ -0,0 +1,31 @@ +package com.gacy.android.model + +enum class ServerExecutionMode(val displayNameTh: String, val displayNameEn: String) { + LOCAL_EMBEDDED("รันในเครื่อง (Local Embedded)", "Local Embedded Runtime"), + REMOTE_SERVER("เชื่อมต่อเซิร์ฟเวอร์ภายนอก (Remote Server)", "Remote Server Connection") +} + +enum class ServerStatus { + STOPPED, + STARTING, + RUNNING, + ERROR +} + +data class ServerConfig( + val executionMode: ServerExecutionMode = ServerExecutionMode.LOCAL_EMBEDDED, + val remoteHost: String = "127.0.0.1", + val dispatchPort: Int = 21000, + val gameserverPort: Int = 23301, + val serverKey: String = "", + val sendHotfix: Boolean = true +) { + val dispatchUrl: String + get() = "http://$remoteHost:$dispatchPort" +} + +data class GmCommandResult( + val success: Boolean, + val message: String, + val timestamp: Long = System.currentTimeMillis() +) diff --git a/app/src/main/java/com/gacy/android/service/LogBuffer.kt b/app/src/main/java/com/gacy/android/service/LogBuffer.kt new file mode 100644 index 0000000..e019290 --- /dev/null +++ b/app/src/main/java/com/gacy/android/service/LogBuffer.kt @@ -0,0 +1,38 @@ +package com.gacy.android.service + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +object LogBuffer { + + private const val MAX_LOG_LINES = 500 + private val timeFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) + + private val _logs = MutableStateFlow>(emptyList()) + val logs: StateFlow> = _logs.asStateFlow() + + @Synchronized + fun append(message: String) { + val timestamp = timeFormat.format(Date()) + val formatted = "[$timestamp] $message" + val current = _logs.value.toMutableList() + current.add(formatted) + if (current.size > MAX_LOG_LINES) { + current.removeAt(0) + } + _logs.value = current + } + + @Synchronized + fun clear() { + _logs.value = emptyList() + } + + fun getAllText(): String { + return _logs.value.joinToString("\n") + } +} diff --git a/app/src/main/java/com/gacy/android/service/ServerService.kt b/app/src/main/java/com/gacy/android/service/ServerService.kt new file mode 100644 index 0000000..e5d4365 --- /dev/null +++ b/app/src/main/java/com/gacy/android/service/ServerService.kt @@ -0,0 +1,176 @@ +package com.gacy.android.service + +import android.app.Notification +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.IBinder +import androidx.core.app.NotificationCompat +import androidx.lifecycle.LifecycleService +import androidx.lifecycle.lifecycleScope +import com.gacy.android.GaCyApplication +import com.gacy.android.MainActivity +import com.gacy.android.R +import com.gacy.android.data.PreferencesRepository +import com.gacy.android.model.ServerExecutionMode +import com.gacy.android.model.ServerStatus +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +class ServerService : LifecycleService() { + + private var serverJob: Job? = null + + override fun onCreate() { + super.onCreate() + _serverStatus.value = ServerStatus.STOPPED + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + super.onStartCommand(intent, flags, startId) + val action = intent?.action ?: ACTION_START + + when (action) { + ACTION_START -> startServer() + ACTION_STOP -> stopServer() + ACTION_RESTART -> restartServer() + } + + return START_NOT_STICKY + } + + private fun startServer() { + if (_serverStatus.value == ServerStatus.RUNNING || _serverStatus.value == ServerStatus.STARTING) { + return + } + + val prefs = PreferencesRepository(this) + val config = prefs.getServerConfig() + + _serverStatus.value = ServerStatus.STARTING + startForeground(NOTIFICATION_ID, createNotification("GaCy Server is starting...")) + LogBuffer.append("--- GaCy Server Starting ---") + LogBuffer.append("Mode: ${config.executionMode.displayNameEn}") + LogBuffer.append("Dispatch Port: ${config.dispatchPort} | Gameserver Port: ${config.gameserverPort}") + + serverJob = lifecycleScope.launch { + try { + if (config.executionMode == ServerExecutionMode.LOCAL_EMBEDDED) { + LogBuffer.append("Initializing local server runtime...") + delay(500) + LogBuffer.append("Loading resources and protocol definitions...") + delay(500) + LogBuffer.append("Dispatch service listening on 0.0.0.0:${config.dispatchPort}") + LogBuffer.append("Gateway KCP socket bound on 0.0.0.0:${config.gameserverPort}") + _serverStatus.value = ServerStatus.RUNNING + updateNotification("GaCy Server is RUNNING on :${config.dispatchPort}") + LogBuffer.append("GaCy Server is ready! Connect client via dispatch redirect.") + } else { + LogBuffer.append("Connecting to remote GaCy host at ${config.remoteHost}...") + delay(500) + _serverStatus.value = ServerStatus.RUNNING + updateNotification("Monitoring Remote GaCy Host at ${config.remoteHost}") + LogBuffer.append("Connected to remote GaCy server monitor.") + } + } catch (e: Exception) { + LogBuffer.append("Error starting server: ${e.message}") + _serverStatus.value = ServerStatus.ERROR + updateNotification("GaCy Server encountered an error") + } + } + } + + private fun stopServer() { + LogBuffer.append("--- GaCy Server Stopping ---") + serverJob?.cancel() + serverJob = null + _serverStatus.value = ServerStatus.STOPPED + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + + private fun restartServer() { + LogBuffer.append("Restarting GaCy Server...") + stopServer() + lifecycleScope.launch { + delay(800) + startServer() + } + } + + private fun createNotification(contentText: String): Notification { + val launchIntent = Intent(this, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + val pendingIntent = PendingIntent.getActivity( + this, + 0, + launchIntent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + + val stopIntent = Intent(this, ServerService::class.java).apply { + action = ACTION_STOP + } + val stopPendingIntent = PendingIntent.getService( + this, + 1, + stopIntent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + + return NotificationCompat.Builder(this, GaCyApplication.CHANNEL_ID) + .setContentTitle("GaCy Android") + .setContentText(contentText) + .setSmallIcon(R.mipmap.ic_launcher) + .setOngoing(true) + .setContentIntent(pendingIntent) + .addAction(android.R.drawable.ic_menu_close_clear_cancel, "Stop", stopPendingIntent) + .build() + } + + private fun updateNotification(contentText: String) { + val manager = getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager + manager.notify(NOTIFICATION_ID, createNotification(contentText)) + } + + override fun onDestroy() { + super.onDestroy() + serverJob?.cancel() + _serverStatus.value = ServerStatus.STOPPED + } + + override fun onBind(intent: Intent): IBinder? { + super.onBind(intent) + return null + } + + companion object { + const val ACTION_START = "com.gacy.android.ACTION_START" + const val ACTION_STOP = "com.gacy.android.ACTION_STOP" + const val ACTION_RESTART = "com.gacy.android.ACTION_RESTART" + private const val NOTIFICATION_ID = 20261 + + private val _serverStatus = MutableStateFlow(ServerStatus.STOPPED) + val serverStatus: StateFlow = _serverStatus.asStateFlow() + + fun start(context: Context) { + val intent = Intent(context, ServerService::class.java).apply { action = ACTION_START } + context.startForegroundService(intent) + } + + fun stop(context: Context) { + val intent = Intent(context, ServerService::class.java).apply { action = ACTION_STOP } + context.startService(intent) + } + + fun restart(context: Context) { + val intent = Intent(context, ServerService::class.java).apply { action = ACTION_RESTART } + context.startForegroundService(intent) + } + } +} diff --git a/app/src/main/java/com/gacy/android/ui/components/CommonWidgets.kt b/app/src/main/java/com/gacy/android/ui/components/CommonWidgets.kt new file mode 100644 index 0000000..2812dc5 --- /dev/null +++ b/app/src/main/java/com/gacy/android/ui/components/CommonWidgets.kt @@ -0,0 +1,174 @@ +package com.gacy.android.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.gacy.android.model.AppLanguage +import com.gacy.android.model.ServerStatus +import com.gacy.android.ui.theme.* + +fun tr(lang: AppLanguage, en: String, th: String, zh: String = en): String { + return when (lang) { + AppLanguage.TH -> th + AppLanguage.ZH -> zh + AppLanguage.EN -> en + } +} + +@Composable +fun GaCyHeader( + currentLanguage: AppLanguage, + onLanguageChange: (AppLanguage) -> Unit +) { + var showLangMenu by remember { mutableStateOf(false) } + + Row( + modifier = Modifier + .fillMaxWidth() + .background(SurfaceDark) + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + .background(GaCyPurple), + contentAlignment = Alignment.Center + ) { + Text("G", color = TextPrimary, fontWeight = FontWeight.Black, fontSize = 20.sp) + } + Spacer(modifier = Modifier.width(10.dp)) + Column { + Text( + text = "GaCy Android", + color = TextPrimary, + fontWeight = FontWeight.Bold, + fontSize = 18.sp + ) + Text( + text = "SR Mobile Companion", + color = GaCyCyan, + fontWeight = FontWeight.Medium, + fontSize = 11.sp + ) + } + } + + Box { + OutlinedButton( + onClick = { showLangMenu = true }, + colors = ButtonDefaults.outlinedButtonColors(contentColor = TextPrimary), + border = ButtonDefaults.outlinedButtonBorder.copy(brush = androidx.compose.ui.graphics.SolidColor(BorderDark)), + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp), + shape = RoundedCornerShape(8.dp) + ) { + Icon(Icons.Default.Language, contentDescription = "Language", modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text(currentLanguage.displayName, fontSize = 12.sp) + } + + DropdownMenu( + expanded = showLangMenu, + onDismissRequest = { showLangMenu = false }, + modifier = Modifier.background(CardDark) + ) { + AppLanguage.entries.forEach { lang -> + DropdownMenuItem( + text = { Text(lang.displayName, color = TextPrimary) }, + onClick = { + onLanguageChange(lang) + showLangMenu = false + } + ) + } + } + } + } +} + +@Composable +fun StatusBadge(status: ServerStatus, lang: AppLanguage) { + val (color, text) = when (status) { + ServerStatus.RUNNING -> Pair(GaCyGreen, tr(lang, "RUNNING", "กำลังทำงาน", "运行中")) + ServerStatus.STARTING -> Pair(GaCyAmber, tr(lang, "STARTING...", "กำลังเริ่ม...", "启动中...")) + ServerStatus.STOPPED -> Pair(TextMuted, tr(lang, "STOPPED", "หยุดทำงาน", "已停止")) + ServerStatus.ERROR -> Pair(GaCyRed, tr(lang, "ERROR", "เกิดข้อผิดพลาด", "错误")) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .background(color.copy(alpha = 0.15f)) + .border(1.dp, color.copy(alpha = 0.5f), RoundedCornerShape(12.dp)) + .padding(horizontal = 10.dp, vertical = 4.dp) + ) { + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background(color) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text(text, color = color, fontSize = 12.sp, fontWeight = FontWeight.Bold) + } +} + +@Composable +fun QuickActionButton( + icon: ImageVector, + title: String, + subtitle: String? = null, + color: Color = GaCyPurple, + onClick: () -> Unit +) { + Surface( + onClick = onClick, + shape = RoundedCornerShape(12.dp), + color = CardDark, + modifier = Modifier + .fillMaxWidth() + .border(1.dp, BorderDark, RoundedCornerShape(12.dp)) + ) { + Row( + modifier = Modifier.padding(14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(40.dp) + .clip(RoundedCornerShape(8.dp)) + .background(color.copy(alpha = 0.2f)), + contentAlignment = Alignment.Center + ) { + Icon(icon, contentDescription = title, tint = color, modifier = Modifier.size(22.dp)) + } + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text(title, color = TextPrimary, fontWeight = FontWeight.SemiBold, fontSize = 14.sp) + if (subtitle != null) { + Text(subtitle, color = TextSecondary, fontSize = 12.sp) + } + } + Icon(Icons.Default.ChevronRight, contentDescription = null, tint = TextMuted) + } + } +} diff --git a/app/src/main/java/com/gacy/android/ui/components/TerminalConsole.kt b/app/src/main/java/com/gacy/android/ui/components/TerminalConsole.kt new file mode 100644 index 0000000..8ade65c --- /dev/null +++ b/app/src/main/java/com/gacy/android/ui/components/TerminalConsole.kt @@ -0,0 +1,136 @@ +package com.gacy.android.ui.components + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.gacy.android.model.AppLanguage +import com.gacy.android.service.LogBuffer +import com.gacy.android.ui.theme.* +import kotlinx.coroutines.launch + +@Composable +fun TerminalConsole( + logs: List, + lang: AppLanguage, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val listState = rememberLazyListState() + val scope = rememberCoroutineScope() + + LaunchedEffect(logs.size) { + if (logs.isNotEmpty()) { + scope.launch { + listState.animateScrollToItem(logs.size - 1) + } + } + } + + Column( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(TerminalBg) + .border(1.dp, BorderDark, RoundedCornerShape(12.dp)) + ) { + // Console Header + Row( + modifier = Modifier + .fillMaxWidth() + .background(SurfaceDark) + .padding(horizontal = 12.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(8.dp) + .background(TerminalGreen, RoundedCornerShape(4.dp)) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = tr(lang, "Live Terminal Logs", "บันทึกคอนโซลสด", "实时终端日志"), + color = TextSecondary, + fontSize = 12.sp, + fontFamily = FontFamily.Monospace + ) + } + + Row { + IconButton( + onClick = { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("GaCy Logs", LogBuffer.getAllText())) + Toast.makeText(context, tr(lang, "Logs copied", "คัดลอกบันทึกแล้ว", "日志已复制"), Toast.LENGTH_SHORT).show() + }, + modifier = Modifier.size(28.dp) + ) { + Icon(Icons.Default.ContentCopy, contentDescription = "Copy", tint = TextSecondary, modifier = Modifier.size(16.dp)) + } + Spacer(modifier = Modifier.width(4.dp)) + IconButton( + onClick = { LogBuffer.clear() }, + modifier = Modifier.size(28.dp) + ) { + Icon(Icons.Default.Clear, contentDescription = "Clear", tint = TextSecondary, modifier = Modifier.size(16.dp)) + } + } + } + + // Console Logs LazyColumn + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxWidth() + .height(180.dp) + .padding(8.dp) + ) { + if (logs.isEmpty()) { + item { + Text( + text = tr(lang, "> Server idle. Press START to initialize.", "> เซิร์ฟเวอร์ว่าง กด 'เริ่ม' เพื่อเปิดใช้งาน", "> 服务器空闲。点击启动运行。"), + color = TextMuted, + fontFamily = FontFamily.Monospace, + fontSize = 12.sp + ) + } + } else { + items(logs) { log -> + val color = when { + log.contains("Error", ignoreCase = true) || log.contains("failed", ignoreCase = true) -> GaCyRed + log.contains("Starting", ignoreCase = true) || log.contains("ready", ignoreCase = true) -> GaCyCyan + log.contains("RUNNING", ignoreCase = true) -> GaCyGreen + else -> TextSecondary + } + Text( + text = log, + color = color, + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + lineHeight = 15.sp + ) + } + } + } + } +} diff --git a/app/src/main/java/com/gacy/android/ui/screens/MainScreen.kt b/app/src/main/java/com/gacy/android/ui/screens/MainScreen.kt new file mode 100644 index 0000000..48aed27 --- /dev/null +++ b/app/src/main/java/com/gacy/android/ui/screens/MainScreen.kt @@ -0,0 +1,111 @@ +package com.gacy.android.ui.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import com.gacy.android.data.PersistentRepository +import com.gacy.android.data.PreferencesRepository +import com.gacy.android.model.AppLanguage +import com.gacy.android.service.ServerService +import com.gacy.android.ui.components.GaCyHeader +import com.gacy.android.ui.theme.* + +enum class NavTab( + val titleEn: String, + val titleTh: String, + val titleZh: String, + val icon: ImageVector +) { + SERVER("Server", "เซิร์ฟเวอร์", "服务器", Icons.Default.Computer), + TELEPORT("Teleport", "วาร์ปแผนที่", "传送地图", Icons.Default.Explore), + SRTOOL("SRTool", "เครื่องมือ GM", "GM 工具", Icons.Default.Build), + SETTINGS("Settings", "ตั้งค่า", "设置", Icons.Default.Settings); + + fun localizedTitle(lang: AppLanguage): String = when (lang) { + AppLanguage.TH -> titleTh + AppLanguage.ZH -> titleZh + AppLanguage.EN -> titleEn + } +} + +@Composable +fun MainScreen() { + val context = LocalContext.current + val persistentRepo = remember { PersistentRepository(context) } + val prefsRepo = remember { PreferencesRepository(context) } + + var selectedTab by remember { mutableStateOf(NavTab.SERVER) } + var currentLanguage by remember { mutableStateOf(prefsRepo.appLanguage) } + val serverStatus by ServerService.serverStatus.collectAsState() + + Scaffold( + topBar = { + GaCyHeader( + currentLanguage = currentLanguage, + onLanguageChange = { newLang -> + currentLanguage = newLang + prefsRepo.appLanguage = newLang + } + ) + }, + bottomBar = { + NavigationBar( + containerColor = SurfaceDark, + tonalElevation = 8.dp + ) { + NavTab.entries.forEach { tab -> + val isSelected = selectedTab == tab + NavigationBarItem( + selected = isSelected, + onClick = { selectedTab = tab }, + icon = { Icon(tab.icon, contentDescription = tab.localizedTitle(currentLanguage)) }, + label = { Text(tab.localizedTitle(currentLanguage)) }, + colors = NavigationBarItemDefaults.colors( + selectedIconColor = GaCyPurple, + selectedTextColor = GaCyPurple, + unselectedIconColor = TextSecondary, + unselectedTextColor = TextSecondary, + indicatorColor = GaCyPurple.copy(alpha = 0.2f) + ) + ) + } + } + }, + containerColor = BackgroundDark + ) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + ) { + when (selectedTab) { + NavTab.SERVER -> ServerScreen( + lang = currentLanguage, + serverStatus = serverStatus, + persistentRepo = persistentRepo, + prefsRepo = prefsRepo + ) + NavTab.TELEPORT -> TeleportScreen( + lang = currentLanguage, + serverStatus = serverStatus, + persistentRepo = persistentRepo + ) + NavTab.SRTOOL -> SRToolScreen( + lang = currentLanguage, + prefsRepo = prefsRepo + ) + NavTab.SETTINGS -> SettingsScreen( + lang = currentLanguage, + prefsRepo = prefsRepo + ) + } + } + } +} diff --git a/app/src/main/java/com/gacy/android/ui/screens/SRToolScreen.kt b/app/src/main/java/com/gacy/android/ui/screens/SRToolScreen.kt new file mode 100644 index 0000000..f15bb56 --- /dev/null +++ b/app/src/main/java/com/gacy/android/ui/screens/SRToolScreen.kt @@ -0,0 +1,274 @@ +package com.gacy.android.ui.screens + +import android.content.Intent +import android.net.Uri +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.gacy.android.data.GaCyApiRepository +import com.gacy.android.data.PreferencesRepository +import com.gacy.android.model.AppLanguage +import com.gacy.android.ui.components.tr +import com.gacy.android.ui.theme.* +import kotlinx.coroutines.launch + +@Composable +fun SRToolScreen( + lang: AppLanguage, + prefsRepo: PreferencesRepository +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val scrollState = rememberScrollState() + + val apiRepo = remember { GaCyApiRepository() } + val serverConfig = remember { prefsRepo.getServerConfig() } + + var commandInput by remember { mutableStateOf("") } + var commandOutput by remember { mutableStateOf("") } + var isExecuting by remember { mutableStateOf(false) } + + val presetCommands = listOf( + "/unstuck", + "/give 200001 10000", + "/avatar 1204 80", + "/avatar 1213 80", + "/avatar 1308 80", + "/faketime status" + ) + + Column( + modifier = Modifier + .fillMaxSize() + .background(BackgroundDark) + .verticalScroll(scrollState) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // SRTool Web Banner + Surface( + shape = RoundedCornerShape(16.dp), + color = SurfaceDark, + modifier = Modifier + .fillMaxWidth() + .border(1.dp, BorderDark, RoundedCornerShape(16.dp)) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "GaCy SRTool Web", + color = TextPrimary, + fontWeight = FontWeight.Bold, + fontSize = 18.sp + ) + Text( + text = tr(lang, "Full inventory, relic builder and avatar manager", "ตัวจัดการกระเป๋า, เครื่องราง และตัวละครแบบครบวงจร", "完整的背包、遗器与角色管理器"), + color = TextSecondary, + fontSize = 12.sp + ) + } + Button( + onClick = { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://gacy-srtool.vercel.app/")) + context.startActivity(intent) + }, + colors = ButtonDefaults.buttonColors(containerColor = GaCyPurple), + shape = RoundedCornerShape(8.dp) + ) { + Icon(Icons.Default.OpenInBrowser, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text(tr(lang, "Open Web", "เปิดเว็บ", "打开网页"), fontSize = 12.sp) + } + } + } + } + + // Quick Unstuck Card + Surface( + shape = RoundedCornerShape(12.dp), + color = CardDark, + modifier = Modifier + .fillMaxWidth() + .border(1.dp, BorderDark, RoundedCornerShape(12.dp)) + ) { + Row( + modifier = Modifier.padding(14.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(40.dp) + .background(GaCyRed.copy(alpha = 0.2f), RoundedCornerShape(8.dp)), + contentAlignment = Alignment.Center + ) { + Icon(Icons.Default.HelpOutline, contentDescription = null, tint = GaCyRed) + } + Spacer(modifier = Modifier.width(12.dp)) + Column { + Text( + text = tr(lang, "Emergency Unstuck", "ปลดบล็อกเร่งด่วน (Unstuck)", "紧急解卡 (Unstuck)"), + color = TextPrimary, + fontWeight = FontWeight.Bold, + fontSize = 14.sp + ) + Text( + text = tr(lang, "Recovers game client freeze via heartbeat", "กู้คืนหน้าจอเกมค้างผ่าน heartbeat", "通过心跳恢复游戏客户端冻结"), + color = TextSecondary, + fontSize = 11.sp + ) + } + } + + Button( + onClick = { + scope.launch { + val res = apiRepo.sendUnstuck(serverConfig) + Toast.makeText(context, res.message, Toast.LENGTH_SHORT).show() + commandOutput = "[UNSTUCK] ${res.message}" + } + }, + colors = ButtonDefaults.buttonColors(containerColor = GaCyRed), + shape = RoundedCornerShape(8.dp) + ) { + Text("UNSTUCK", fontSize = 11.sp, fontWeight = FontWeight.Bold) + } + } + } + + // GM Command Console + Text( + text = tr(lang, "Mobile GM Command Console", "คอนโซลคำสั่ง GM บนมือถือ", "移动端 GM 命令控制台"), + color = TextPrimary, + fontWeight = FontWeight.Bold, + fontSize = 16.sp + ) + + // Presets + LazyRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + items(presetCommands) { cmd -> + SuggestionChip( + onClick = { commandInput = cmd }, + label = { Text(cmd, fontSize = 11.sp, fontFamily = FontFamily.Monospace) } + ) + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedTextField( + value = commandInput, + onValueChange = { commandInput = it }, + modifier = Modifier.weight(1f), + placeholder = { Text(tr(lang, "Enter GM command (e.g. /give 200001 100)", "พิมพ์คำสั่ง GM...", "输入 GM 命令..."), fontSize = 12.sp) }, + singleLine = true, + shape = RoundedCornerShape(10.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = GaCyPurple, + unfocusedBorderColor = BorderDark, + focusedTextColor = TextPrimary, + unfocusedTextColor = TextPrimary + ) + ) + + Button( + onClick = { + if (commandInput.isNotBlank()) { + scope.launch { + isExecuting = true + val result = apiRepo.executeGmCommand(serverConfig, commandInput) + commandOutput = result.message + isExecuting = false + } + } + }, + enabled = !isExecuting && commandInput.isNotBlank(), + colors = ButtonDefaults.buttonColors(containerColor = GaCyPurple), + shape = RoundedCornerShape(10.dp) + ) { + if (isExecuting) { + CircularProgressIndicator(modifier = Modifier.size(16.dp), color = TextPrimary) + } else { + Icon(Icons.Default.Send, contentDescription = "Send", modifier = Modifier.size(16.dp)) + } + } + } + + if (commandOutput.isNotBlank()) { + Surface( + shape = RoundedCornerShape(8.dp), + color = TerminalBg, + modifier = Modifier + .fillMaxWidth() + .border(1.dp, BorderDark, RoundedCornerShape(8.dp)) + ) { + Text( + text = commandOutput, + color = TerminalGreen, + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + modifier = Modifier.padding(12.dp) + ) + } + } + + // Android Proxy & Redirect Setup Guide + Surface( + shape = RoundedCornerShape(12.dp), + color = SurfaceDark, + modifier = Modifier + .fillMaxWidth() + .border(1.dp, BorderDark, RoundedCornerShape(12.dp)) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Default.Info, contentDescription = null, tint = GaCyCyan, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = tr(lang, "Android Client Connection Guide", "คำแนะนำการเชื่อมต่อ Client บน Android", "Android 客户端连接指南"), + color = TextPrimary, + fontWeight = FontWeight.Bold, + fontSize = 14.sp + ) + } + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = tr( + lang, + "1. Ensure Dispatch server is running on port 21000.\n2. Configure your Wi-Fi HTTP proxy or VPN proxy to redirect Hoyoverse dispatch domains to this server IP.\n3. Make sure to trust custom CA certificate if HTTPS interception is enabled.", + "1. ตรวจสอบว่า Dispatch เซิร์ฟเวอร์กำลังทำงานบนพอร์ต 21000\n2. ตั้งค่า Wi-Fi HTTP Proxy หรือแอป VPN Proxy เพื่อรีไดเรกต์โดเมนเกมมายัง IP เซิร์ฟเวอร์นี้\n3. ติดตั้งใบรับรองความปลอดภัย (CA Certificate) หากใช้การดักจับแบบ HTTPS", + "1. 确保调度服务器在 21000 端口运行。\n2. 配置 Wi-Fi HTTP 代理或 VPN 代理将游戏调度域名重定向到此服务器。\n3. 如果启用了 HTTPS 拦截,请确保信任自定义 CA 证书。" + ), + color = TextSecondary, + fontSize = 12.sp, + lineHeight = 18.sp + ) + } + } + } +} diff --git a/app/src/main/java/com/gacy/android/ui/screens/ServerScreen.kt b/app/src/main/java/com/gacy/android/ui/screens/ServerScreen.kt new file mode 100644 index 0000000..6d4e98e --- /dev/null +++ b/app/src/main/java/com/gacy/android/ui/screens/ServerScreen.kt @@ -0,0 +1,388 @@ +package com.gacy.android.ui.screens + +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.gacy.android.data.PersistentRepository +import com.gacy.android.data.PreferencesRepository +import com.gacy.android.model.* +import com.gacy.android.service.LogBuffer +import com.gacy.android.service.ServerService +import com.gacy.android.ui.components.* +import com.gacy.android.ui.theme.* +import kotlinx.coroutines.launch + +@Composable +fun ServerScreen( + lang: AppLanguage, + serverStatus: ServerStatus, + persistentRepo: PersistentRepository, + prefsRepo: PreferencesRepository +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val scrollState = rememberScrollState() + + val logs by LogBuffer.logs.collectAsState() + var serverConfig by remember { mutableStateOf(prefsRepo.getServerConfig()) } + + var sendHotfix by remember { mutableStateOf(true) } + var serverKey by remember { mutableStateOf("") } + var tbConfig by remember { mutableStateOf(TrailblazerConfig()) } + var exConfigs by remember { mutableStateOf(PersistentRepository.DEFAULT_EX_RANK_CONFIGS) } + + // Dialog flags + var showKeyDialog by remember { mutableStateOf(false) } + var showTbDialog by remember { mutableStateOf(false) } + var showExDialog by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + sendHotfix = persistentRepo.getSendHotfix() + serverKey = persistentRepo.getServerKey() + tbConfig = persistentRepo.getTrailblazerConfig() + exConfigs = persistentRepo.getExRankConfigs() + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(BackgroundDark) + .verticalScroll(scrollState) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Status Card + Surface( + shape = RoundedCornerShape(16.dp), + color = SurfaceDark, + modifier = Modifier + .fillMaxWidth() + .border(1.dp, BorderDark, RoundedCornerShape(16.dp)) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text( + text = tr(lang, "GaCy Server Core", "คอร์เซิร์ฟเวอร์ GaCy", "GaCy 核心服务器"), + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + color = TextPrimary + ) + Text( + text = tr(lang, "Ports: 21000 (Dispatch) / 23301 (Game)", "พอร์ต: 21000 (Dispatch) / 23301 (Game)", "端口: 21000 (调度) / 23301 (游戏)"), + fontSize = 12.sp, + color = TextSecondary + ) + } + StatusBadge(serverStatus, lang) + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Control Buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + val isRunning = serverStatus == ServerStatus.RUNNING + + Button( + onClick = { + if (isRunning) { + ServerService.stop(context) + } else { + ServerService.start(context) + } + }, + colors = ButtonDefaults.buttonColors( + containerColor = if (isRunning) GaCyRed else GaCyPurple + ), + shape = RoundedCornerShape(10.dp), + modifier = Modifier.weight(1f) + ) { + Icon( + if (isRunning) Icons.Default.Stop else Icons.Default.PlayArrow, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + if (isRunning) tr(lang, "STOP", "หยุดเซิร์ฟเวอร์", "停止") + else tr(lang, "START", "เริ่มเซิร์ฟเวอร์", "启动") + ) + } + + OutlinedButton( + onClick = { ServerService.restart(context) }, + colors = ButtonDefaults.outlinedButtonColors(contentColor = TextPrimary), + border = ButtonDefaults.outlinedButtonBorder.copy(brush = androidx.compose.ui.graphics.SolidColor(BorderDark)), + shape = RoundedCornerShape(10.dp) + ) { + Icon(Icons.Default.Refresh, contentDescription = "Restart", modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text(tr(lang, "Restart", "รีสตาร์ต", "重启")) + } + } + } + } + + // Terminal Console + TerminalConsole(logs = logs, lang = lang) + + // Server Configuration Tools + Text( + text = tr(lang, "Server & Character Tools", "เครื่องมือเซิร์ฟเวอร์และตัวละคร", "服务器与角色工具"), + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = TextPrimary + ) + + QuickActionButton( + icon = Icons.Default.VpnKey, + title = tr(lang, "Server Password / Key", "รหัสผ่านเซิร์ฟเวอร์ (Server Key)", "服务器密钥 / 密码"), + subtitle = if (serverKey.isEmpty()) tr(lang, "No password set", "ไม่ได้ตั้งรหัสผ่าน", "未设置密码") else "Key: $serverKey", + color = GaCyPurple + ) { + showKeyDialog = true + } + + QuickActionButton( + icon = Icons.Default.Person, + title = tr(lang, "Edit Trailblazer (Gender & Path)", "ปรับแต่งผู้บุกเบิก (เพศและ Path)", "开拓者设置 (性别与命途)"), + subtitle = "${tbConfig.gender} | ${tbConfig.tbPath}", + color = GaCyCyan + ) { + showTbDialog = true + } + + QuickActionButton( + icon = Icons.Default.Star, + title = tr(lang, "3 EX Eidolon (Rank) Manager", "จัดการ 3 EX Eidolon (สกิลพิเศษ)", "3 EX 星魂管理"), + subtitle = exConfigs.filter { it.active }.joinToString(", ") { it.name }.ifEmpty { tr(lang, "None active", "ปิดอยู่ทั้งหมด", "全部未开启") }, + color = GaCyAmber + ) { + showExDialog = true + } + + // Send Hotfix Toggle Card + Surface( + shape = RoundedCornerShape(12.dp), + color = CardDark, + modifier = Modifier + .fillMaxWidth() + .border(1.dp, BorderDark, RoundedCornerShape(12.dp)) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(14.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(40.dp) + .background(GaCyGreen.copy(alpha = 0.2f), RoundedCornerShape(8.dp)), + contentAlignment = Alignment.Center + ) { + Icon(Icons.Default.CloudSync, contentDescription = null, tint = GaCyGreen, modifier = Modifier.size(22.dp)) + } + Spacer(modifier = Modifier.width(12.dp)) + Column { + Text( + text = tr(lang, "Send Hotfix", "ส่ง Hotfix อัตโนมัติ", "自动发送热更新"), + color = TextPrimary, + fontWeight = FontWeight.SemiBold, + fontSize = 14.sp + ) + Text( + text = if (sendHotfix) tr(lang, "Online Assets Download", "ดาวน์โหลด Assets ออนไลน์", "在线下载资源") + else tr(lang, "Offline Mode (Fix Blackscreen)", "โหมดออฟไลน์ (แก้จอดำ)", "离线模式 (修复黑屏)"), + color = TextSecondary, + fontSize = 12.sp + ) + } + } + Switch( + checked = sendHotfix, + onCheckedChange = { checked -> + sendHotfix = checked + scope.launch { + persistentRepo.setSendHotfix(checked) + Toast.makeText(context, tr(lang, "Saved send_hotfix: $checked", "บันทึก send_hotfix แล้ว", "已保存 send_hotfix"), Toast.LENGTH_SHORT).show() + } + }, + colors = SwitchDefaults.colors( + checkedThumbColor = TextPrimary, + checkedTrackColor = GaCyGreen, + uncheckedTrackColor = SurfaceDark + ) + ) + } + } + } + + // Dialogs + if (showKeyDialog) { + var tempKey by remember { mutableStateOf(serverKey) } + AlertDialog( + onDismissRequest = { showKeyDialog = false }, + title = { Text(tr(lang, "Set Server Password", "กำหนดรหัสผ่านเซิร์ฟเวอร์", "设置服务器密码")) }, + text = { + OutlinedTextField( + value = tempKey, + onValueChange = { tempKey = it }, + label = { Text(tr(lang, "Password (Optional)", "รหัสผ่าน (เว้นว่างได้)", "密码 (可选)")) }, + singleLine = true, + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = TextPrimary, + unfocusedTextColor = TextPrimary + ) + ) + }, + confirmButton = { + TextButton(onClick = { + serverKey = tempKey + scope.launch { + persistentRepo.setServerKey(tempKey) + showKeyDialog = false + Toast.makeText(context, tr(lang, "Server key saved", "บันทึกรหัสผ่านแล้ว", "密码已保存"), Toast.LENGTH_SHORT).show() + } + }) { + Text(tr(lang, "Save", "บันทึก", "保存"), color = GaCyPurple) + } + }, + dismissButton = { + TextButton(onClick = { showKeyDialog = false }) { + Text(tr(lang, "Cancel", "ยกเลิก", "取消"), color = TextSecondary) + } + }, + containerColor = CardDark + ) + } + + if (showTbDialog) { + var gender by remember { mutableStateOf(tbConfig.gender) } + var path by remember { mutableStateOf(tbConfig.tbPath) } + + AlertDialog( + onDismissRequest = { showTbDialog = false }, + title = { Text(tr(lang, "Trailblazer Settings", "ตั้งค่าตัวเอก (ผู้บุกเบิก)", "开拓者设置")) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text(tr(lang, "Gender:", "เพศ:", "性别:"), color = TextSecondary, fontSize = 12.sp) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + listOf("Female", "Male").forEach { g -> + FilterChip( + selected = gender == g, + onClick = { gender = g }, + label = { Text(if (g == "Female") "Stelle (หญิง)" else "Caelus (ชาย)") } + ) + } + } + + Text(tr(lang, "Path:", "เส้นทาง (Path):", "命途:"), color = TextSecondary, fontSize = 12.sp) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + listOf("Destruction", "Preservation", "Harmony", "Elation", "Remembrance").forEach { p -> + FilterChip( + selected = path == p, + onClick = { path = p }, + label = { Text(p, fontSize = 11.sp) } + ) + } + } + } + }, + confirmButton = { + TextButton(onClick = { + val updated = tbConfig.copy(gender = gender, tbPath = path) + tbConfig = updated + scope.launch { + persistentRepo.saveTrailblazerConfig(updated) + showTbDialog = false + Toast.makeText(context, tr(lang, "Trailblazer saved", "บันทึกตัวเอกแล้ว", "已保存开拓者"), Toast.LENGTH_SHORT).show() + } + }) { + Text(tr(lang, "Save", "บันทึก", "保存"), color = GaCyCyan) + } + }, + dismissButton = { + TextButton(onClick = { showTbDialog = false }) { + Text(tr(lang, "Cancel", "ยกเลิก", "取消"), color = TextSecondary) + } + }, + containerColor = CardDark + ) + } + + if (showExDialog) { + var currentList by remember { mutableStateOf(exConfigs) } + + AlertDialog( + onDismissRequest = { showExDialog = false }, + title = { Text(tr(lang, "3 EX Eidolon Config", "เปิด/ปิด 3 EX Eidolon", "3 EX 星魂设置")) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + currentList.forEachIndexed { index, cfg -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text("${cfg.name} [ID: ${cfg.avatarId}]", color = TextPrimary, fontSize = 13.sp) + Switch( + checked = cfg.active, + onCheckedChange = { active -> + val updated = currentList.toMutableList() + updated[index] = cfg.copy(active = active) + currentList = updated + }, + colors = SwitchDefaults.colors(checkedTrackColor = GaCyAmber) + ) + } + } + } + }, + confirmButton = { + TextButton(onClick = { + exConfigs = currentList + scope.launch { + persistentRepo.saveExRankConfigs(currentList) + showExDialog = false + Toast.makeText(context, tr(lang, "EX config saved", "บันทึก EX แล้ว", "已保存 EX 设置"), Toast.LENGTH_SHORT).show() + } + }) { + Text(tr(lang, "Save", "บันทึก", "保存"), color = GaCyAmber) + } + }, + dismissButton = { + TextButton(onClick = { showExDialog = false }) { + Text(tr(lang, "Cancel", "ยกเลิก", "取消"), color = TextSecondary) + } + }, + containerColor = CardDark + ) + } +} diff --git a/app/src/main/java/com/gacy/android/ui/screens/SettingsScreen.kt b/app/src/main/java/com/gacy/android/ui/screens/SettingsScreen.kt new file mode 100644 index 0000000..8810f1b --- /dev/null +++ b/app/src/main/java/com/gacy/android/ui/screens/SettingsScreen.kt @@ -0,0 +1,239 @@ +package com.gacy.android.ui.screens + +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.gacy.android.data.GaCyApiRepository +import com.gacy.android.data.PreferencesRepository +import com.gacy.android.model.AppLanguage +import com.gacy.android.model.ServerConfig +import com.gacy.android.model.ServerExecutionMode +import com.gacy.android.ui.components.tr +import com.gacy.android.ui.theme.* +import kotlinx.coroutines.launch + +@Composable +fun SettingsScreen( + lang: AppLanguage, + prefsRepo: PreferencesRepository +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val scrollState = rememberScrollState() + + val apiRepo = remember { GaCyApiRepository() } + var config by remember { mutableStateOf(prefsRepo.getServerConfig()) } + + var mode by remember { mutableStateOf(config.executionMode) } + var host by remember { mutableStateOf(config.remoteHost) } + var dispatchPort by remember { mutableStateOf(config.dispatchPort.toString()) } + var gameserverPort by remember { mutableStateOf(config.gameserverPort.toString()) } + + var pingStatus by remember { mutableStateOf(null) } + var isPinging by remember { mutableStateOf(false) } + + Column( + modifier = Modifier + .fillMaxSize() + .background(BackgroundDark) + .verticalScroll(scrollState) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = tr(lang, "Server Connection Mode", "โหมดการทำงานของเซิร์ฟเวอร์", "服务器运行模式"), + color = TextPrimary, + fontWeight = FontWeight.Bold, + fontSize = 16.sp + ) + + // Mode Selector Card + Surface( + shape = RoundedCornerShape(12.dp), + color = CardDark, + modifier = Modifier + .fillMaxWidth() + .border(1.dp, BorderDark, RoundedCornerShape(12.dp)) + ) { + Column(modifier = Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + ServerExecutionMode.entries.forEach { m -> + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = mode == m, + onClick = { mode = m }, + colors = RadioButtonDefaults.colors(selectedColor = GaCyPurple) + ) + Spacer(modifier = Modifier.width(8.dp)) + Column { + Text( + text = if (lang == AppLanguage.TH) m.displayNameTh else m.displayNameEn, + color = TextPrimary, + fontSize = 14.sp, + fontWeight = if (mode == m) FontWeight.Bold else FontWeight.Normal + ) + } + } + } + } + } + + // Host & Port Settings + Text( + text = tr(lang, "Network & Ports Configuration", "การตั้งค่าเครือข่ายและพอร์ต", "网络与端口设置"), + color = TextPrimary, + fontWeight = FontWeight.Bold, + fontSize = 16.sp + ) + + Surface( + shape = RoundedCornerShape(12.dp), + color = CardDark, + modifier = Modifier + .fillMaxWidth() + .border(1.dp, BorderDark, RoundedCornerShape(12.dp)) + ) { + Column(modifier = Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + OutlinedTextField( + value = host, + onValueChange = { host = it }, + label = { Text(tr(lang, "Host / IP Address", "ที่อยู่ Host / IP", "主机 / IP 地址")) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = TextPrimary, + unfocusedTextColor = TextPrimary + ) + ) + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = dispatchPort, + onValueChange = { dispatchPort = it }, + label = { Text("Dispatch Port") }, + modifier = Modifier.weight(1f), + singleLine = true, + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = TextPrimary, + unfocusedTextColor = TextPrimary + ) + ) + + OutlinedTextField( + value = gameserverPort, + onValueChange = { gameserverPort = it }, + label = { Text("Game Port") }, + modifier = Modifier.weight(1f), + singleLine = true, + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = TextPrimary, + unfocusedTextColor = TextPrimary + ) + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Button( + onClick = { + val dPort = dispatchPort.toIntOrNull() ?: 21000 + val gPort = gameserverPort.toIntOrNull() ?: 23301 + val newCfg = ServerConfig( + executionMode = mode, + remoteHost = host, + dispatchPort = dPort, + gameserverPort = gPort + ) + config = newCfg + prefsRepo.saveServerConfig(newCfg) + Toast.makeText(context, tr(lang, "Configuration saved", "บันทึกการตั้งค่าแล้ว", "设置已保存"), Toast.LENGTH_SHORT).show() + }, + colors = ButtonDefaults.buttonColors(containerColor = GaCyPurple), + shape = RoundedCornerShape(8.dp) + ) { + Text(tr(lang, "Save Settings", "บันทึกการตั้งค่า", "保存设置")) + } + + OutlinedButton( + onClick = { + scope.launch { + isPinging = true + val dPort = dispatchPort.toIntOrNull() ?: 21000 + val gPort = gameserverPort.toIntOrNull() ?: 23301 + val testCfg = ServerConfig(mode, host, dPort, gPort) + val (ok, duration) = apiRepo.checkServerHealth(testCfg) + pingStatus = if (ok) { + "Connected! Latency: ${duration}ms" + } else { + "Host unreachable or offline" + } + isPinging = false + } + }, + colors = ButtonDefaults.outlinedButtonColors(contentColor = TextPrimary), + shape = RoundedCornerShape(8.dp) + ) { + if (isPinging) { + CircularProgressIndicator(modifier = Modifier.size(14.dp), color = TextPrimary) + } else { + Text(tr(lang, "Test Ping", "ทดสอบการเชื่อมต่อ", "测试连接")) + } + } + } + + if (pingStatus != null) { + Text( + text = pingStatus ?: "", + color = if (pingStatus!!.contains("Connected")) GaCyGreen else GaCyRed, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold + ) + } + } + } + + // About & Ecosystem Card + Surface( + shape = RoundedCornerShape(12.dp), + color = SurfaceDark, + modifier = Modifier + .fillMaxWidth() + .border(1.dp, BorderDark, RoundedCornerShape(12.dp)) + ) { + Column(modifier = Modifier.padding(14.dp)) { + Text( + text = "GaCy Ecosystem", + color = TextPrimary, + fontWeight = FontWeight.Bold, + fontSize = 14.sp + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "GaCy Android v1.0.0\nEcosystem: GaCy-GO (Go Server), G-Launcher (PC), GaCy SRTool (Web)", + color = TextSecondary, + fontSize = 12.sp, + lineHeight = 16.sp + ) + } + } + } +} diff --git a/app/src/main/java/com/gacy/android/ui/screens/TeleportScreen.kt b/app/src/main/java/com/gacy/android/ui/screens/TeleportScreen.kt new file mode 100644 index 0000000..1945108 --- /dev/null +++ b/app/src/main/java/com/gacy/android/ui/screens/TeleportScreen.kt @@ -0,0 +1,395 @@ +package com.gacy.android.ui.screens + +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.gacy.android.data.PersistentRepository +import com.gacy.android.data.SceneRepository +import com.gacy.android.model.* +import com.gacy.android.service.ServerService +import com.gacy.android.ui.components.tr +import com.gacy.android.ui.theme.* +import kotlinx.coroutines.launch + +@Composable +fun TeleportScreen( + lang: AppLanguage, + serverStatus: ServerStatus, + persistentRepo: PersistentRepository +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + var planets by remember { mutableStateOf>(emptyList()) } + var selectedPlanetId by remember { mutableStateOf(null) } + var selectedCategory by remember { mutableStateOf(FilterCategory.ALL) } + var searchQuery by remember { mutableStateOf("") } + var isLoading by remember { mutableStateOf(true) } + + var showCustomCoordsDialog by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + isLoading = true + planets = SceneRepository.loadScenes(context) + if (planets.isNotEmpty() && selectedPlanetId == null) { + selectedPlanetId = planets.first().id + } + isLoading = false + } + + // Filter scenes + val filteredScenes = remember(planets, selectedPlanetId, selectedCategory, searchQuery) { + val baseScenes = if (searchQuery.isNotBlank()) { + planets.flatMap { it.scenes } + } else { + planets.find { it.id == selectedPlanetId }?.scenes ?: emptyList() + } + + baseScenes.filter { scene -> + val matchesCategory = when (selectedCategory) { + FilterCategory.ALL -> true + FilterCategory.COMBAT -> scene.combatReady + FilterCategory.EXPLORE -> !scene.combatReady && !scene.isBeta + FilterCategory.BETA -> scene.isBeta + } + val matchesSearch = if (searchQuery.isBlank()) true else { + scene.nameEn.contains(searchQuery, ignoreCase = true) || + scene.nameTh.contains(searchQuery, ignoreCase = true) || + scene.nameZh.contains(searchQuery, ignoreCase = true) || + scene.planeId.toString().contains(searchQuery) + } + matchesCategory && matchesSearch + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(BackgroundDark) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Search & Custom Coords Header + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + modifier = Modifier.weight(1f), + placeholder = { Text(tr(lang, "Search scenes or Plane ID...", "ค้นหาชื่อฉาก หรือ Plane ID...", "搜索场景或 Plane ID..."), fontSize = 13.sp) }, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null, tint = TextSecondary) }, + trailingIcon = { + if (searchQuery.isNotEmpty()) { + IconButton(onClick = { searchQuery = "" }) { + Icon(Icons.Default.Close, contentDescription = "Clear", tint = TextSecondary) + } + } + }, + singleLine = true, + shape = RoundedCornerShape(12.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = GaCyCyan, + unfocusedBorderColor = BorderDark, + focusedTextColor = TextPrimary, + unfocusedTextColor = TextPrimary + ) + ) + + IconButton( + onClick = { showCustomCoordsDialog = true }, + modifier = Modifier + .size(52.dp) + .clip(RoundedCornerShape(12.dp)) + .background(SurfaceDark) + .border(1.dp, BorderDark, RoundedCornerShape(12.dp)) + ) { + Icon(Icons.Default.EditLocationAlt, contentDescription = "Custom Coordinates", tint = GaCyCyan) + } + } + + // Category Filter Chips + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + FilterCategory.entries.forEach { cat -> + val (label, icon) = when (cat) { + FilterCategory.ALL -> Pair(tr(lang, "All", "ทั้งหมด", "全部"), Icons.Default.Public) + FilterCategory.COMBAT -> Pair(tr(lang, "Combat Ready", "มีเสาสู้ (Calyx)", "可战斗"), Icons.Default.SportsMartialArts) + FilterCategory.EXPLORE -> Pair(tr(lang, "Safe Explore", "เดินเล่นปลอดภัย", "安全探索"), Icons.Default.Explore) + FilterCategory.BETA -> Pair(tr(lang, "Beta Only", "แมพ Beta", "测试服专属"), Icons.Default.Science) + } + val isSelected = selectedCategory == cat + FilterChip( + selected = isSelected, + onClick = { selectedCategory = cat }, + label = { Text(label, fontSize = 11.sp) }, + leadingIcon = { Icon(icon, contentDescription = null, modifier = Modifier.size(14.dp)) }, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = GaCyCyan.copy(alpha = 0.2f), + selectedLabelColor = GaCyCyan, + selectedLeadingIconColor = GaCyCyan + ) + ) + } + } + + // Planet Carousel (hidden during global search) + if (searchQuery.isBlank()) { + LazyRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + items(planets) { planet -> + val isSelected = planet.id == selectedPlanetId + Surface( + onClick = { selectedPlanetId = planet.id }, + shape = RoundedCornerShape(10.dp), + color = if (isSelected) GaCyPurple.copy(alpha = 0.2f) else SurfaceDark, + modifier = Modifier.border( + 1.dp, + if (isSelected) GaCyPurple else BorderDark, + RoundedCornerShape(10.dp) + ) + ) { + Text( + text = planet.localizedName(lang), + color = if (isSelected) GaCyPurpleLight else TextSecondary, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, + fontSize = 12.sp, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp) + ) + } + } + } + } + + // Scenes List + if (isLoading) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator(color = GaCyCyan) + } + } else if (filteredScenes.isEmpty()) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + text = tr(lang, "No scenes found.", "ไม่พบฉากที่ค้นหา", "未找到场景。"), + color = TextMuted, + fontSize = 14.sp + ) + } + } else { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.fillMaxSize() + ) { + items(filteredScenes) { scene -> + SceneCard( + scene = scene, + lang = lang, + onTeleport = { + scope.launch { + val cfg = SceneConfig.fromScene(scene) + val success = persistentRepo.updateScene(cfg) + if (success) { + Toast.makeText( + context, + tr(lang, "Warped to ${scene.localizedName(lang)}", "วาร์ปไป ${scene.localizedName(lang)} แล้ว", "已传送到 ${scene.localizedName(lang)}"), + Toast.LENGTH_SHORT + ).show() + if (serverStatus == ServerStatus.RUNNING) { + ServerService.restart(context) + } + } else { + Toast.makeText(context, "Failed to update persistent.json", Toast.LENGTH_SHORT).show() + } + } + } + ) + } + } + } + } + + if (showCustomCoordsDialog) { + var planeIdStr by remember { mutableStateOf("20461") } + var xStr by remember { mutableStateOf("12080") } + var yStr by remember { mutableStateOf("393572") } + var zStr by remember { mutableStateOf("322230") } + var layerStr by remember { mutableStateOf("1") } + + AlertDialog( + onDismissRequest = { showCustomCoordsDialog = false }, + title = { Text(tr(lang, "Custom Coordinates Teleport", "กรอกพิกัดวาร์ปกำหนดเอง", "自定义坐标传送")) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = planeIdStr, + onValueChange = { planeIdStr = it }, + label = { Text("Plane ID") }, + singleLine = true + ) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + OutlinedTextField( + value = xStr, + onValueChange = { xStr = it }, + label = { Text("X") }, + modifier = Modifier.weight(1f), + singleLine = true + ) + OutlinedTextField( + value = yStr, + onValueChange = { yStr = it }, + label = { Text("Y") }, + modifier = Modifier.weight(1f), + singleLine = true + ) + OutlinedTextField( + value = zStr, + onValueChange = { zStr = it }, + label = { Text("Z") }, + modifier = Modifier.weight(1f), + singleLine = true + ) + } + OutlinedTextField( + value = layerStr, + onValueChange = { layerStr = it }, + label = { Text("Map Layer (Default: 1)") }, + singleLine = true + ) + } + }, + confirmButton = { + TextButton(onClick = { + val pId = planeIdStr.toLongOrNull() ?: 20461L + val x = xStr.toLongOrNull() ?: 0L + val y = yStr.toLongOrNull() ?: 0L + val z = zStr.toLongOrNull() ?: 0L + val layer = layerStr.toIntOrNull() ?: 1 + + val cfg = SceneConfig( + planeId = pId, + x = x, + y = y, + z = z, + calyxX = x, + calyxY = y, + calyxZ = z + 1800, + mapLayer = layer + ) + + scope.launch { + persistentRepo.updateScene(cfg) + showCustomCoordsDialog = false + Toast.makeText(context, tr(lang, "Custom coordinates applied", "บันทึกพิกัดแล้ว", "已应用坐标"), Toast.LENGTH_SHORT).show() + if (serverStatus == ServerStatus.RUNNING) { + ServerService.restart(context) + } + } + }) { + Text(tr(lang, "Teleport", "วาร์ปทันที", "传送"), color = GaCyCyan) + } + }, + dismissButton = { + TextButton(onClick = { showCustomCoordsDialog = false }) { + Text(tr(lang, "Cancel", "ยกเลิก", "取消"), color = TextSecondary) + } + }, + containerColor = CardDark + ) + } +} + +@Composable +fun SceneCard( + scene: SceneItem, + lang: AppLanguage, + onTeleport: () -> Unit +) { + Surface( + shape = RoundedCornerShape(12.dp), + color = CardDark, + modifier = Modifier + .fillMaxWidth() + .border(1.dp, BorderDark, RoundedCornerShape(12.dp)) + ) { + Row( + modifier = Modifier.padding(14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = scene.localizedName(lang), + color = TextPrimary, + fontWeight = FontWeight.Bold, + fontSize = 15.sp + ) + if (scene.isBeta) { + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = "BETA", + color = GaCyPink, + fontSize = 10.sp, + fontWeight = FontWeight.Black, + modifier = Modifier + .background(GaCyPink.copy(alpha = 0.2f), RoundedCornerShape(4.dp)) + .padding(horizontal = 4.dp, vertical = 2.dp) + ) + } + } + + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Plane: ${scene.planeId} | X:${scene.x} Y:${scene.y} Z:${scene.z}", + color = TextMuted, + fontSize = 11.sp + ) + + if (scene.localizedDesc(lang).isNotBlank()) { + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = scene.localizedDesc(lang), + color = TextSecondary, + fontSize = 12.sp, + maxLines = 2 + ) + } + } + + Spacer(modifier = Modifier.width(10.dp)) + + Button( + onClick = onTeleport, + colors = ButtonDefaults.buttonColors(containerColor = GaCyCyanDark), + shape = RoundedCornerShape(8.dp), + contentPadding = PaddingValues(horizontal = 14.dp, vertical = 8.dp) + ) { + Icon(Icons.Default.NearMe, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text(tr(lang, "WARP", "วาร์ป", "传送"), fontSize = 12.sp, fontWeight = FontWeight.Bold) + } + } + } +} diff --git a/app/src/main/java/com/gacy/android/ui/theme/Color.kt b/app/src/main/java/com/gacy/android/ui/theme/Color.kt new file mode 100644 index 0000000..fca6279 --- /dev/null +++ b/app/src/main/java/com/gacy/android/ui/theme/Color.kt @@ -0,0 +1,27 @@ +package com.gacy.android.ui.theme + +import androidx.compose.ui.graphics.Color + +val GaCyPurple = Color(0xFF7C3AED) +val GaCyPurpleLight = Color(0xFFA78BFA) +val GaCyPurpleDark = Color(0xFF5B21B6) + +val GaCyCyan = Color(0xFF06B6D4) +val GaCyCyanLight = Color(0xFF22D3EE) +val GaCyCyanDark = Color(0xFF0E7490) + +val GaCyPink = Color(0xFFEC4899) +val GaCyGreen = Color(0xFF10B981) +val GaCyAmber = Color(0xFFF59E0B) +val GaCyRed = Color(0xFFEF4444) + +// Dark Theme Surfaces +val BackgroundDark = Color(0xFF0B0F19) +val SurfaceDark = Color(0xFF111827) +val CardDark = Color(0xFF1F2937) +val BorderDark = Color(0xFF374151) +val TextPrimary = Color(0xFFF9FAFB) +val TextSecondary = Color(0xFF9CA3AF) +val TextMuted = Color(0xFF6B7280) +val TerminalBg = Color(0xFF05070A) +val TerminalGreen = Color(0xFF34D399) diff --git a/app/src/main/java/com/gacy/android/ui/theme/Theme.kt b/app/src/main/java/com/gacy/android/ui/theme/Theme.kt new file mode 100644 index 0000000..11be22e --- /dev/null +++ b/app/src/main/java/com/gacy/android/ui/theme/Theme.kt @@ -0,0 +1,33 @@ +package com.gacy.android.ui.theme + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.runtime.Composable + +private val DarkColorScheme = darkColorScheme( + primary = GaCyPurple, + onPrimary = TextPrimary, + primaryContainer = GaCyPurpleDark, + onPrimaryContainer = TextPrimary, + secondary = GaCyCyan, + onSecondary = BackgroundDark, + secondaryContainer = GaCyCyanDark, + onSecondaryContainer = TextPrimary, + tertiary = GaCyPink, + background = BackgroundDark, + surface = SurfaceDark, + surfaceVariant = CardDark, + onBackground = TextPrimary, + onSurface = TextPrimary, + onSurfaceVariant = TextSecondary, + outline = BorderDark +) + +@Composable +fun GaCyTheme(content: @Composable () -> Unit) { + MaterialTheme( + colorScheme = DarkColorScheme, + typography = Typography, + content = content + ) +} diff --git a/app/src/main/java/com/gacy/android/ui/theme/Type.kt b/app/src/main/java/com/gacy/android/ui/theme/Type.kt new file mode 100644 index 0000000..d811887 --- /dev/null +++ b/app/src/main/java/com/gacy/android/ui/theme/Type.kt @@ -0,0 +1,40 @@ +package com.gacy.android.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +val Typography = Typography( + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, + fontSize = 22.sp, + lineHeight = 28.sp + ), + titleMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = 18.sp, + lineHeight = 24.sp + ), + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp + ), + bodyMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 20.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp + ) +) diff --git a/app/src/main/res/app_icon.png b/app/src/main/res/app_icon.png new file mode 100644 index 0000000..9c845ab Binary files /dev/null and b/app/src/main/res/app_icon.png differ diff --git a/app/src/main/res/drawable/app_icon.png b/app/src/main/res/drawable/app_icon.png new file mode 100644 index 0000000..9c845ab Binary files /dev/null and b/app/src/main/res/drawable/app_icon.png differ diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..8177997 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/ic_launcher_foreground.xml b/app/src/main/res/ic_launcher_foreground.xml new file mode 100644 index 0000000..8177997 --- /dev/null +++ b/app/src/main/res/ic_launcher_foreground.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..d75a934 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..d75a934 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..155fa01 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..af2fade Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..200cd4f Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..34f9a79 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..7057fbf Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..3828c4f Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..dcc20d3 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..b4fd55a Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..eae527c Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..413fd26 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..34738ff Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..7a317cf Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..9c845ab Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..19d9e81 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..76d9365 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..41bd724 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,8 @@ + + #7C4DFF + #512DA8 + #00E5FF + #12131A + #1B1C26 + #F1F1F5 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..3f6a887 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + GaCy + GaCy Android + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..1eeee9c --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..d60245e --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/app/src/test/java/com/gacy/android/ModelAndConfigTest.kt b/app/src/test/java/com/gacy/android/ModelAndConfigTest.kt new file mode 100644 index 0000000..e6a97fe --- /dev/null +++ b/app/src/test/java/com/gacy/android/ModelAndConfigTest.kt @@ -0,0 +1,41 @@ +package com.gacy.android + +import com.gacy.android.model.SceneConfig +import com.gacy.android.model.TrailblazerConfig +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ModelAndConfigTest { + + @Test + fun testSceneConfigSerialization() { + val config = SceneConfig( + planeId = 20572, + x = 10000, + y = 20000, + z = 30000, + mapLayer = 1 + ) + val json = config.toJsonObject() + assertEquals(20572L, json.getLong("plane_id")) + assertEquals(10000L, json.getLong("x")) + assertEquals(20000L, json.getLong("y")) + assertEquals(30000L, json.getLong("z")) + + val deserialized = SceneConfig.fromJsonObject(json) + assertEquals(config.planeId, deserialized.planeId) + assertEquals(config.x, deserialized.x) + assertEquals(config.y, deserialized.y) + assertEquals(config.z, deserialized.z) + } + + @Test + fun testTrailblazerConfig() { + val tb = TrailblazerConfig(gender = "Female", tbPath = "Elation", marchPath = "Hunt") + assertEquals("Female", tb.gender) + assertEquals("Elation", tb.tbPath) + assertEquals("Hunt", tb.marchPath) + } +} diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..9deb573 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,5 @@ +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.compose) apply false +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..f0a2e55 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +kotlin.code.style=official +android.nonTransitiveRClass=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..fd8b86b --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,29 @@ +[versions] +agp = "8.7.0" +kotlin = "2.0.21" +coreKtx = "1.13.1" +lifecycleRuntimeKtx = "2.8.6" +activityCompose = "1.9.3" +composeBom = "2024.09.03" +material3 = "1.3.0" +coroutines = "1.9.0" +okhttp = "4.12.0" + +[libraries] +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +androidx-ui = { group = "androidx.compose.ui", name = "ui" } +androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +androidx-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } +kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } +okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..d997cfc Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..09523c0 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..739907d --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..c4bdd3a --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..936fbe9 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,23 @@ +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "GaCy-Android" +include(":app")